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, type Key } from "../i18n"; import type { ImageData } from "../types"; import { readImage } from "./Composer"; import { formatSize } 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) => ( ); const ICONS: Record = { build: svg(), plan: svg( <> , ), craft: svg( <> , ), agent: svg( <> , ), website: svg( <> , ), app: svg( <> , ), game: svg( <> , ), software: svg( <> , ), fresh: svg(), clean: svg( <> , ), deep: svg( <> , ), cad: svg( <> , ), folder: svg(), git: svg( <> , ), upload: svg(), clip: svg(), chat: svg(), start: svg(), }; function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) { return ( ); } function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { return (

{n} {title}

{children}
); } 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(prefill ? "build" : "craft"); const [target, setTarget] = useState("fresh"); const [deep, setDeep] = useState(false); const [url, setUrl] = useState(""); const [text, setText] = useState(prefill ?? ""); const [images, setImages] = useState([]); const [files, setFiles] = useState([]); 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([]); const [progress, setProgress] = useState>({}); const originalInput = useRef(null); const [folderName, setFolderName] = useState(""); const [nameTouched, setNameTouched] = useState(false); const [rootPath, setRootPath] = useState(""); const [picked, setPicked] = useState(localStorage.getItem(LAST_DIR) ?? ""); const [error, setError] = useState(); const [creating, setCreating] = useState(); const fileInput = useRef(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]; 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, 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 ( <>

{t("new.title")}

{t("wizard.title")}

{t("wizard.subtitle")}

setTask("chat")} /> setTask("craft")} /> setTask("cad")} /> setTask("build")} /> setTask("plan")} /> {custom.map((a) => ( setTask(a.name)} /> ))}
{task === "craft" && (
{(["website", "app", "game", "software", "fresh"] as Target[]).map((x) => ( setTarget(x)} /> ))}
{target !== "fresh" && (
setDeep(false)} /> setDeep(true)} />
)} {target === "software" && !deep &&
{t("wizard.softwareNeedsDeep")}
} {target === "software" && (
e.preventDefault()} onDrop={(e) => { e.preventDefault(); setOriginals((c) => [...c, ...e.dataTransfer.files]); }} onClick={() => originalInput.current?.click()} role="button" tabIndex={0} > {ICONS.upload}
{t("wizard.originalTitle")}
{t("wizard.originalHint")}
{ if (e.target.files) setOriginals((c) => [...c, ...e.target.files!]); e.target.value = ""; }} />
)} {target === "software" && originals.length > 0 && (
{originals.map((f, i) => (
{f.name} {formatSize(f.size)} {progress[f.name] !== undefined ? ( ) : ( )}
))}
)}
)} {needsUrl && ( setUrl(e.target.value)} /> )}
e.preventDefault()} onDrop={(e) => { e.preventDefault(); void addFiles(e.dataTransfer.files); }} >