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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 21:03:26 +01:00
1 parent 013695722a
commit a0d0e707f2
48 files changed
+2724 -228

No files matched your search

+548 -83
View File
@@ -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>
);
}