Files
codecraft/web/src/components/NewSession.tsx
T
Richard ZinkandClaude Sonnet 5.5 62fe5bf467 Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 22:30:12 +01:00

660 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { post, uploadFile } from "../api";
import { promptDialog, toast } from "../dialogs";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
interface DirListing {
path: string;
parent: string | null;
root: string;
workspace: string;
dirs: { name: string; path: string; git: boolean }[];
}
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" />
</>,
),
cad: svg(
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>,
),
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" />),
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
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 roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const config = useFetch<{ default_model?: string }>("/config").data;
// Text shared from another app (PWA share target) starts a normal task.
const [prefill] = useState(() => takePrefill("new"));
const [task, setTask] = useState<Task>(prefill ? "build" : "craft");
const [target, setTarget] = useState<Target>("fresh");
const [deep, setDeep] = useState(false);
const [url, setUrl] = useState("");
const [text, setText] = useState(prefill ?? "");
const [images, setImages] = useState<ImageData[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [model, setModel] = 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("");
const [isolated, setIsolated] = useState(false);
// 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<string>();
const fileInput = useRef<HTMLInputElement>(null);
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
}, [config, model]);
useEffect(() => {
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" || task === "cad" ? "new" : "existing"));
}, [task]);
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` : task === "cad" ? "3d-model" : "project"),
[text, url, target, task, folderMode, repoName],
);
useEffect(() => {
if (!nameTouched) setFolderName(autoName);
}, [autoName, nameTouched]);
const ideas: Key[] =
task === "cad"
? ["wizard.idea.cadBox", "wizard.idea.cadBracket"]
: 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 {
const read = await Promise.all(imgs.map(readImage));
setImages((cur) => [...cur, ...read]);
} catch (e: any) {
setError(e.message);
}
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)) &&
(task === "chat" ? !!rootPath : 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 === "cad") return [`/cad ${desc}${images.length ? " (sketch attached)" : ""}${fileList}`];
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 create = async () => {
setError(undefined);
try {
// 1. Folder
let cwd = picked;
if (task === "chat") {
// A plain conversation needs no project: it runs in the user's own folder.
cwd = rootPath;
} else 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");
}
if (task !== "chat") 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,
// The server default is not sent, so the project's own default model can apply.
model: model && model !== config?.default_model ? model : undefined,
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
if (isolated && folderMode !== "new" && task !== "chat") {
setCreating(t("worktree.creating"));
await post(`/sessions/${s.id}/worktree`, { action: "create" });
}
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 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="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<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) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "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={imageSrc(img)} 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>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
<ModelSelect value={model} onChange={setModel} />
</div>
{task !== "chat" && (
<>
<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>
{folderMode === "git" && (
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
)}
{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} />}
{folderMode !== "new" && (
<label className="wiz-check-row">
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
<span>
<b>{t("worktree.isolate")}</b>
<span className="hint"> – {t("worktree.isolateHint")}</span>
</span>
</label>
)}
</>
)}
{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>
</div>
</>
);
}
/** Browse the user's folders and pick one. */
export 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) || "/" : "";
/** Create a subfolder of the shown folder and select it. */
const newFolder = async () => {
if (!listing) return;
const name = (await promptDialog({ title: t("files.newFolder"), message: t("files.newFolderName") }))?.trim();
if (!name) return;
if (name.includes("/") || name === "." || name === "..") return toast(t("new.invalidFolderName"));
const target = `${listing.path.replace(/\/$/, "")}/${name}`;
try {
await post("/files/create", { path: target, dir: true });
setBrowse(target);
} catch (e: any) {
toast(e.message);
}
};
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>
)}
{listing && (
<button className="small" onClick={() => void newFolder()}>
<Icon name="plus" /> {t("files.newFolder")}
</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>
);
}