Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2724
-228
No files matched your search
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { post, uploadFile } from "../api";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT } from "../i18n";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
import { readImage } from "./Composer";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
interface DirListing {
|
||||
@@ -14,124 +18,585 @@ interface DirListing {
|
||||
|
||||
const LAST_DIR = "codecraft.lastDir";
|
||||
|
||||
type Task = "build" | "plan" | "craft" | string;
|
||||
type Target = "website" | "app" | "game" | "software" | "fresh";
|
||||
|
||||
// --- icons (SVG: identical on every system) -------------------------------------
|
||||
|
||||
const svg = (children: ReactNode) => (
|
||||
<svg viewBox="0 0 24 24" className="wiz-icon" aria-hidden="true">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
const ICONS: Record<string, ReactNode> = {
|
||||
build: svg(<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16" />),
|
||||
plan: svg(
|
||||
<>
|
||||
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
|
||||
<path d="M9 4v14M15 6v14" />
|
||||
</>,
|
||||
),
|
||||
craft: svg(
|
||||
<>
|
||||
<path d="m4 20 10-10M14 10l2-2" />
|
||||
<path d="M17 3v3M15.5 4.5h3M20 8v2M19 9h2M11 3v2M10 4h2" />
|
||||
</>,
|
||||
),
|
||||
agent: svg(
|
||||
<>
|
||||
<rect x="5" y="7" width="14" height="11" rx="3" />
|
||||
<path d="M12 3v4M9 12h.01M15 12h.01M9.5 15h5" />
|
||||
</>,
|
||||
),
|
||||
website: svg(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3c2.5 2.8 3.5 5.8 3.5 9s-1 6.2-3.5 9c-2.5-2.8-3.5-5.8-3.5-9s1-6.2 3.5-9Z" />
|
||||
</>,
|
||||
),
|
||||
app: svg(
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="M3 9h18M6 7h.01M8.5 7h.01" />
|
||||
</>,
|
||||
),
|
||||
game: svg(
|
||||
<>
|
||||
<path d="M7 9h10a4 4 0 0 1 4 4v1a3 3 0 0 1-5.4 1.8L14.5 14h-5l-1.1 1.8A3 3 0 0 1 3 14v-1a4 4 0 0 1 4-4Z" />
|
||||
<path d="M8 11.5v3M6.5 13h3M15.5 12h.01M17.5 13.5h.01" />
|
||||
</>,
|
||||
),
|
||||
software: svg(
|
||||
<>
|
||||
<path d="M5 3h11l3 3v15H5Z" />
|
||||
<path d="M8 3v5h7V3M8 21v-7h8v7" />
|
||||
</>,
|
||||
),
|
||||
fresh: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z" />),
|
||||
clean: svg(
|
||||
<>
|
||||
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</>,
|
||||
),
|
||||
deep: svg(
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
|
||||
</>,
|
||||
),
|
||||
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
|
||||
git: svg(
|
||||
<>
|
||||
<circle cx="6" cy="6" r="2.2" />
|
||||
<circle cx="6" cy="18" r="2.2" />
|
||||
<circle cx="18" cy="9" r="2.2" />
|
||||
<path d="M6 8.2v7.6M18 11.2c0 3.5-4 3-9.6 5.6" />
|
||||
</>,
|
||||
),
|
||||
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||
};
|
||||
|
||||
function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={`wiz-card ${active ? "active" : ""}`} onClick={onClick} aria-pressed={active}>
|
||||
{ICONS[icon] ?? ICONS.agent}
|
||||
<span className="wiz-card-text">
|
||||
<span className="wiz-card-title">{title}</span>
|
||||
{desc && <span className="wiz-card-desc">{desc}</span>}
|
||||
</span>
|
||||
{active && (
|
||||
<svg viewBox="0 0 24 24" className="wiz-check" aria-hidden="true">
|
||||
<path d="m5 12 4.5 4.5L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="wiz-step">
|
||||
<h3>
|
||||
<span className="wiz-num">{n}</span>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const slug = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/https?:\/\/(www\.)?/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.split("-")
|
||||
.slice(0, 4)
|
||||
.join("-")
|
||||
.slice(0, 40);
|
||||
|
||||
async function api(path: string, init: RequestInit) {
|
||||
const r = await fetch(`/api${path}`, { credentials: "same-origin", ...init });
|
||||
if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Guided start: what to do → (craft: what to recreate, clean room/deep) → describe → model & folder. */
|
||||
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
|
||||
const t = useT();
|
||||
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
const [task, setTask] = useState<Task>("craft");
|
||||
const [target, setTarget] = useState<Target>("fresh");
|
||||
const [deep, setDeep] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImages] = useState<ImageData[]>([]);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [model, setModel] = useState("");
|
||||
const [agent, setAgent] = useState("build");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// Folder: a new one (name) inside a root, or an existing one (picked).
|
||||
const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new");
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
// Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks.
|
||||
const [originals, setOriginals] = useState<File[]>([]);
|
||||
const [progress, setProgress] = useState<Record<string, number>>({});
|
||||
const originalInput = useRef<HTMLInputElement>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [rootPath, setRootPath] = useState("");
|
||||
const [picked, setPicked] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary");
|
||||
const [creating, setCreating] = useState<string>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
}, [config, model]);
|
||||
|
||||
// A remembered directory that vanished: fall back to the workspace root.
|
||||
useEffect(() => {
|
||||
if (dirError && path) setPath("");
|
||||
}, [dirError, path]);
|
||||
if (!rootPath && roots.length) setRootPath(roots[roots.length - 1].path);
|
||||
}, [roots, rootPath]);
|
||||
// Old software is analysed in deep mode; websites default to clean room.
|
||||
useEffect(() => {
|
||||
if (target === "software") setDeep(true);
|
||||
else if (target === "website") setDeep(false);
|
||||
}, [target]);
|
||||
// Working on existing code usually happens in an existing folder.
|
||||
useEffect(() => {
|
||||
setFolderMode((m) => (m === "git" ? m : task === "craft" ? "new" : "existing"));
|
||||
}, [task]);
|
||||
|
||||
const create = async () => {
|
||||
if (!listing) return;
|
||||
setCreating(true);
|
||||
setError(undefined);
|
||||
const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
||||
const autoName = useMemo(
|
||||
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"),
|
||||
[text, url, target, task, folderMode, repoName],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!nameTouched) setFolderName(autoName);
|
||||
}, [autoName, nameTouched]);
|
||||
|
||||
const ideas: Key[] =
|
||||
task !== "craft"
|
||||
? task === "plan"
|
||||
? ["wizard.idea.planFeature", "wizard.idea.planRefactor"]
|
||||
: ["wizard.idea.buildTests", "wizard.idea.buildBug"]
|
||||
: ({
|
||||
website: ["wizard.idea.siteLanding", "wizard.idea.siteShop"],
|
||||
app: ["wizard.idea.appTodo", "wizard.idea.appNotes"],
|
||||
game: ["wizard.idea.gameSnake", "wizard.idea.gameBreakout"],
|
||||
software: ["wizard.idea.swDos", "wizard.idea.swTool"],
|
||||
fresh: ["wizard.idea.freshBakery", "wizard.idea.freshSnake"],
|
||||
} as Record<Target, Key[]>)[target];
|
||||
|
||||
const addFiles = async (list: FileList | File[]) => {
|
||||
const all = [...list];
|
||||
const imgs = all.filter((f) => f.type.startsWith("image/") && target !== "software");
|
||||
try {
|
||||
localStorage.setItem(LAST_DIR, listing.path);
|
||||
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent });
|
||||
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
|
||||
onCreated(s.id);
|
||||
const read = await Promise.all(imgs.map(readImage));
|
||||
setImages((cur) => [...cur, ...read]);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setCreating(false);
|
||||
}
|
||||
setFiles((cur) => [...cur, ...all.filter((f) => !imgs.includes(f))]);
|
||||
};
|
||||
|
||||
const needsUrl = task === "craft" && target === "website";
|
||||
const isRecreate = task === "craft" && target === "software";
|
||||
const canStart =
|
||||
!creating &&
|
||||
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
||||
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
|
||||
/** The first message(s) for the agent. */
|
||||
const prompts = (uploaded: string[]): string[] => {
|
||||
const desc = text.trim();
|
||||
const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : "";
|
||||
if (task !== "craft") return [desc + fileList];
|
||||
switch (target) {
|
||||
case "website":
|
||||
return [`Recreate the website ${url.trim()} as a new project in this folder.${desc ? `\n\n${desc}` : ""}${fileList}`];
|
||||
case "app":
|
||||
case "game":
|
||||
return [
|
||||
`Recreate this ${target === "app" ? "app" : "game"} as a new project in this folder${images.length ? " – screenshots are attached" : ""}.${desc ? `\n\n${desc}` : ""}${fileList}`,
|
||||
];
|
||||
case "software":
|
||||
return [
|
||||
`/recreate-init The program files are already in original/ (${uploaded.length ? uploaded.join(", ") : "added by the user"}). Set up the layout around them without moving them.`,
|
||||
`/recreate-start ${desc}`,
|
||||
];
|
||||
default:
|
||||
return [`Build this from scratch (there is no original to study – start with a short plan, then build, check and refine):\n\n${desc}${fileList}`];
|
||||
}
|
||||
};
|
||||
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
const currentRoot = listing?.root;
|
||||
const create = async () => {
|
||||
setError(undefined);
|
||||
try {
|
||||
// 1. Folder
|
||||
let cwd = picked;
|
||||
if (folderMode === "git") {
|
||||
setCreating(t("wizard.cloning"));
|
||||
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
||||
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
||||
cwd = target;
|
||||
} else if (folderMode === "new") {
|
||||
setCreating(t("wizard.creatingFolder"));
|
||||
const base = folderName.trim().replace(/[\\/]/g, "-");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const candidate = `${rootPath}/${i ? `${base}-${i + 1}` : base}`;
|
||||
try {
|
||||
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: candidate, dir: true }) });
|
||||
cwd = candidate;
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (!/exists/i.test(e.message)) throw e;
|
||||
}
|
||||
}
|
||||
if (!cwd) throw new Error("could not create the folder");
|
||||
}
|
||||
localStorage.setItem(LAST_DIR, cwd);
|
||||
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
||||
const uploaded: string[] = [];
|
||||
const mkdirs = async (dir: string) => {
|
||||
let parent = cwd;
|
||||
for (const part of dir.split("/")) {
|
||||
parent = `${parent}/${part}`;
|
||||
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: parent, dir: true }) }).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
const send = async (list: File[], dir: string) => {
|
||||
if (!list.length) return;
|
||||
await mkdirs(dir);
|
||||
for (const f of list) {
|
||||
const name = f.name.replace(/[\\/]/g, "_");
|
||||
setCreating(`${t("wizard.uploading")} ${name}`);
|
||||
await uploadFile(`${cwd}/${dir}/${name}`, f, (x) => setProgress((p) => ({ ...p, [f.name]: x })));
|
||||
uploaded.push(`${dir}/${name}`);
|
||||
}
|
||||
};
|
||||
// Originals of old software go to original/, everything else to .codecraft/uploads/.
|
||||
await send(originals, "original");
|
||||
await send(files, isRecreate ? "original" : ".codecraft/uploads");
|
||||
// 3. Session and first prompt(s)
|
||||
setCreating(t("wizard.starting"));
|
||||
const isCraft = task === "craft";
|
||||
const s = await post<{ id: string }>("/sessions", {
|
||||
cwd,
|
||||
model: model || undefined,
|
||||
agent: task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
const msgs = prompts(uploaded).filter((m) => m.trim());
|
||||
for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] });
|
||||
onCreated(s.id);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setCreating(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
let n = 1;
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("new.title")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||||
<div className="panel">
|
||||
<h3>{t("new.directory")}</h3>
|
||||
{roots.length > 1 && (
|
||||
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||
{roots.map((r) => (
|
||||
<button key={r.path} className={currentRoot === r.path ? "active" : ""} onClick={() => setPath(r.path)}>
|
||||
{r.name}
|
||||
<div className="page-inner wizard">
|
||||
<h2 className="wiz-title">{t("wizard.title")}</h2>
|
||||
<p className="wiz-sub">{t("wizard.subtitle")}</p>
|
||||
|
||||
<div className="panel wiz-panel">
|
||||
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||
<div className="wiz-cards">
|
||||
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
|
||||
{custom.map((a) => (
|
||||
<Card key={a.name} icon="agent" title={a.name} desc={a.description} active={task === a.name} onClick={() => setTask(a.name)} />
|
||||
))}
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
{task === "craft" && (
|
||||
<Step n={n++} title={t("wizard.whatToRecreate")}>
|
||||
<div className="wiz-cards five">
|
||||
{(["website", "app", "game", "software", "fresh"] as Target[]).map((x) => (
|
||||
<Card key={x} icon={x} title={t(`wizard.target.${x}` as Key)} active={target === x} onClick={() => setTarget(x)} />
|
||||
))}
|
||||
</div>
|
||||
{target !== "fresh" && (
|
||||
<div className="wiz-cards two" style={{ marginTop: 12 }}>
|
||||
<Card icon="clean" title={t("craft.cleanRoom")} desc={t("wizard.cleanDesc")} active={!deep} onClick={() => setDeep(false)} />
|
||||
<Card icon="deep" title={t("craft.deep")} desc={t("wizard.deepDesc")} active={deep} onClick={() => setDeep(true)} />
|
||||
</div>
|
||||
)}
|
||||
{target === "software" && !deep && <div className="notice">{t("wizard.softwareNeedsDeep")}</div>}
|
||||
{target === "software" && (
|
||||
<div
|
||||
className="wiz-drop"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setOriginals((c) => [...c, ...e.dataTransfer.files]);
|
||||
}}
|
||||
onClick={() => originalInput.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{ICONS.upload}
|
||||
<div>
|
||||
<b>{t("wizard.originalTitle")}</b>
|
||||
<div className="hint">{t("wizard.originalHint")}</div>
|
||||
</div>
|
||||
<input
|
||||
ref={originalInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files) setOriginals((c) => [...c, ...e.target.files!]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{target === "software" && originals.length > 0 && (
|
||||
<div className="wiz-originals">
|
||||
{originals.map((f, i) => (
|
||||
<div key={`${f.name}${i}`} className="wiz-original">
|
||||
<span className="name">{f.name}</span>
|
||||
<span className="size">{formatSize(f.size)}</span>
|
||||
{progress[f.name] !== undefined ? (
|
||||
<span className="bar">
|
||||
<i style={{ width: `${Math.round(progress[f.name] * 100)}%` }} />
|
||||
</span>
|
||||
) : (
|
||||
<button className="small ghost" onClick={() => setOriginals((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Step>
|
||||
)}
|
||||
|
||||
<Step n={n++} title={t("wizard.sayWhat")}>
|
||||
{needsUrl && (
|
||||
<input
|
||||
className="wiz-url"
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://example.com"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="wiz-input"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
void addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={text}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : "wizard.placeholder.task")}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
e.preventDefault();
|
||||
void addFiles(e.clipboardData.files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !matchMedia("(pointer: coarse)").matches && canStart) {
|
||||
e.preventDefault();
|
||||
void create();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(images.length > 0 || files.length > 0) && (
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={`i${i}`}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{files.map((f, i) => (
|
||||
<div className="att file" key={`f${i}`}>
|
||||
<span><Icon name="file" /> {f.name}</span>
|
||||
<small>{formatSize(f.size)}</small>
|
||||
<button onClick={() => setFiles((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="wiz-input-foot">
|
||||
<button type="button" className="ghost small" onClick={() => fileInput.current?.click()} title={t("wizard.attach")}>
|
||||
{ICONS.clip}
|
||||
</button>
|
||||
<span>{target === "software" && task === "craft" ? t("wizard.softwareHint") : t("wizard.enterHint")}</span>
|
||||
<input ref={fileInput} type="file" multiple hidden onChange={(e) => e.target.files && void addFiles(e.target.files)} />
|
||||
</div>
|
||||
</div>
|
||||
{ideas && (
|
||||
<div className="wiz-ideas">
|
||||
<span>{t("wizard.ideas")}</span>
|
||||
{ideas.map((k) => (
|
||||
<button key={k} type="button" className="chip-btn" onClick={() => setText(t(k))}>
|
||||
{t(k)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-row">
|
||||
<label>{t("wizard.builtBy")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</div>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<code className="grow" style={{ flex: 1 }}>
|
||||
{rel}
|
||||
</code>
|
||||
{listing?.parent && (
|
||||
<button className="small" onClick={() => setPath(listing.parent!)}>
|
||||
↑ {t("new.up")}
|
||||
</button>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
{listing?.dirs.map((d) => (
|
||||
<button key={d.path} onClick={() => setPath(d.path)}>
|
||||
<span>📁</span>
|
||||
<span style={{ flex: 1 }}>{d.name}</span>
|
||||
{d.git && <span className="chip">git</span>}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="wiz-start-row">
|
||||
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
|
||||
{ICONS.start}
|
||||
{creating ?? t("wizard.start")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="form">
|
||||
<label>{t("new.model")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
<label>{t("new.mode")}</label>
|
||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||
{agents.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name} – {a.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>{t("new.firstPrompt")}</h3>
|
||||
<textarea
|
||||
style={{ width: "100%", minHeight: 110 }}
|
||||
value={prompt}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<button className="primary" disabled={!listing || creating} onClick={create}>
|
||||
{t("new.start", { dir: rel })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Browse the user's folders and pick one. */
|
||||
function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
|
||||
const t = useT();
|
||||
const [browse, setBrowse] = useState(path);
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const { data: listing, error } = useFetch<DirListing>(`/fs/dirs${browse ? `?path=${encodeURIComponent(browse)}` : ""}`);
|
||||
useEffect(() => {
|
||||
if (error && browse) setBrowse("");
|
||||
}, [error, browse]);
|
||||
useEffect(() => {
|
||||
if (listing && listing.path !== path) onPick(listing.path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listing?.path]);
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
return (
|
||||
<div className="wiz-picker">
|
||||
{roots.length > 1 && (
|
||||
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||
{roots.map((r) => (
|
||||
<button key={r.path} className={listing?.root === r.path ? "active" : ""} onClick={() => setBrowse(r.path)}>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<code className="grow" style={{ flex: 1 }}>
|
||||
{rel}
|
||||
</code>
|
||||
{listing?.parent && (
|
||||
<button className="small" onClick={() => setBrowse(listing.parent!)}>
|
||||
<Icon name="arrow-up" /> {t("new.up")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
{listing?.dirs.map((d) => (
|
||||
<button key={d.path} onClick={() => setBrowse(d.path)}>
|
||||
{ICONS.folder}
|
||||
<span style={{ flex: 1 }}>{d.name}</span>
|
||||
{d.git && <span className="chip">git</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user