- Terminals and file managers as tabs of a chat; chat terminals keep running on the server (no idle timeout) and re-attach from any device - "Just chat" in New session and a built-in chat mode with a lean prompt and only web tools; custom modes with kind (coding/conversation), read-only and tool picker - Mode selector as a dropdown - The chat shows running compactions (automatic: tokens and share of the limit) and labels summaries - Docker image builds and serves the terminal client binaries again - Fix: atomic session updates; background title/usage writes no longer undo each other (auto compaction triggered one turn late) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
639 lines
27 KiB
TypeScript
639 lines
27 KiB
TypeScript
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) => (
|
||
<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,
|
||
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={`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>{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) || "/" : "";
|
||
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>
|
||
);
|
||
}
|