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

+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react";
import { fmtCompact, useT } from "../i18n";
import { useStore, type Draft } from "../store";
@@ -143,8 +144,8 @@ export function ActivityLine({
<span className="activity-text">{label}</span>
{step && (
<span className="activity-meta">
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
({fmtElapsed(elapsed)} · <Icon name="arrow-up" className="tiny" /> {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · <Icon name="arrow-down" className="tiny" /> {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
</span>
)}
</div>
@@ -163,8 +164,8 @@ export function useTitleActivity() {
const waiting = useStore((s) => s.permissions.length + s.questions.length);
useEffect(() => {
const base = "CodeCraft";
if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`;
else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
if (waiting > 0) document.title = `(!) ${t("activity.titleInput")} – ${base}`;
else if (busyCount > 0) document.title = `(${busyCount}) ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
else document.title = base;
}, [busyCount, waiting, t]);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useFetch } from "../hooks";
import { fmtCost, fmtPercent, useT } from "../i18n";
@@ -30,7 +31,7 @@ function Bar({ share, warning, exceeded }: { share: number; warning: boolean; ex
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
if (!r) return <span className="muted">–</span>;
if (r.error) return <span className="muted" title={r.error}>⚠ {t("budget.remoteError")}</span>;
if (r.error) return <span className="muted" title={r.error}><Icon name="warning" /> {t("budget.remoteError")}</span>;
const cur = r.currency ?? "USD";
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
+59 -31
View File
@@ -1,8 +1,9 @@
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
@@ -10,6 +11,7 @@ import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
@@ -47,7 +49,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
{live ? <Dots /> : <ThinkIcon />}
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
<Icon name={open ? "chevron-down" : "chevron-right"} />
</button>
{open && (
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
@@ -240,27 +242,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
{editingTitle ? (
<input
autoFocus
defaultValue={session.title}
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
style={{ width: "100%" }}
/>
) : (
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
↩
</a>
)}
{session.title || t("session.untitled")}
</h1>
)}
<h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
<Icon name="undo" />
</a>
)}
{session.title || t("session.untitled")}
{!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
<Icon name="edit" />
</button>
)}
</h1>
<div className="path">{session.cwd}</div>
</div>
{ctxShare !== undefined && (
@@ -271,8 +268,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
<span className="hide-mobile">
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
{fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
</span>
{fmtCost(stats.total.cost, stats.currency)}
</button>
@@ -285,17 +282,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
⇄
<Icon name="share" />
</button>
)}
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
🔀
<Icon name="fork" />
</button>
)}
<details className="menu-pop">
<summary className="small ghost" title={t("chat.export")}>
💾
<Icon name="download" />
</summary>
<div className="menu-pop-body">
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
@@ -307,14 +304,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊
<Icon name="compact" />
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑
<Icon name="trash" />
</button>
</>
)}
</div>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
className="chat-scroll"
@@ -375,7 +373,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!busy && !readOnly && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
✎
<Icon name="edit" />
</button>
</div>
)}
@@ -417,7 +415,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<div key={e.id} className="error-box" role="alert">
<span>{e.message}</span>
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
×
<Icon name="close" />
</button>
</div>
))}
@@ -438,11 +436,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!following && (
<div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
↓
<Icon name="arrow-down" />
</button>
</div>
)}
</div>
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
@@ -500,3 +499,32 @@ function fmtK(n: number) {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
/** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */
function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
const t = useT();
const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"];
const cur = phase ? phases.indexOf(phase) : -1;
return (
<div className="phase-bar" aria-label="Craft progress">
{phases.map((p, i) => {
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
return (
<span key={p} style={{ display: "contents" }}>
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
<span className={`phase ${state}`}>
<span className="dot">
{state === "done" && (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</span>
{t(`phase.${p}` as Key)}
</span>
</span>
);
})}
</div>
);
}
+11 -8
View File
@@ -1,4 +1,6 @@
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
@@ -20,7 +22,7 @@ const MAX_SIDE = 2000;
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
async function readImage(file: File): Promise<ImageData> {
export async function readImage(file: File): Promise<ImageData> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
@@ -179,8 +181,9 @@ export function Composer({
await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) });
// Keep uploads out of git status without touching the project's .gitignore.
await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" });
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
await uploadFile(path, f).catch((e) => {
throw new Error(`${f.name}: ${e.message}`);
});
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
} catch (e: any) {
toast(e.message ?? String(e));
@@ -229,10 +232,10 @@ export function Composer({
<div className="attachments">
{files.map((f) => (
<div className="att file" key={f.path} title={f.path}>
<span>📄 {f.name}</span>
<span><Icon name="file" /> {f.name}</span>
<small>{formatSize(f.size)}</small>
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</div>
))}
@@ -245,7 +248,7 @@ export function Composer({
<div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</div>
))}
@@ -310,14 +313,14 @@ export function Composer({
</select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
📎
<Icon name="clip" />
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
</label>
</div>
<div className="composer-actions">
{busy && (
<button className="danger" onClick={onAbort} title="Esc">
■ {t("composer.stop")}
<Icon name="stop" /> {t("composer.stop")}
</button>
)}
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react";
/** Human-readable file size. */
@@ -137,7 +138,7 @@ export function FileChip({ url, name, size, sizeText }: { url?: string; name: st
const type = fileType(name, meta.mime);
return (
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
<span className="file-chip-icon">📎</span>
<Icon name="clip" className="file-chip-icon" />
<span className="file-chip-name">{basename(name)}</span>
{type && <span className="file-badge">{type}</span>}
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
+20 -19
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { del, get, post, put } from "../api";
import { useFetch } from "../hooks";
@@ -45,13 +46,13 @@ const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return "📁";
if (IMAGE.test(e.name)) return "🖼";
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
if (/\.(md|txt)$/i.test(e.name)) return "📝";
return "📄";
if (e.is_dir) return <Icon name="folder" className="ficon dir" />;
if (IMAGE.test(e.name)) return <Icon name="image" className="ficon" />;
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return <Icon name="music" className="ficon" />;
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return <Icon name="cube" className="ficon" />;
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return <Icon name="file-doc" className="ficon" />;
if (/\.(md|txt)$/i.test(e.name)) return <Icon name="file-text" className="ficon" />;
return <Icon name="file" className="ficon" />;
}
export function Files({ onMenu }: { onMenu: () => void }) {
@@ -243,13 +244,13 @@ export function Files({ onMenu }: { onMenu: () => void }) {
)}
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
+ 📄
<Icon name="plus" className="tiny" /> <Icon name="file" />
</button>
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
+ 📁
<Icon name="plus" className="tiny" /> <Icon name="folder" />
</button>
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
⤒
<Icon name="upload" />
</button>
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
@@ -277,7 +278,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
) : (
hits.map((h) => (
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
<span>{h.is_dir ? "📁" : "📄"}</span>
<Icon name={h.is_dir ? "folder" : "file"} className="ficon" />
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
</button>
))
@@ -286,7 +287,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<>
{listing?.parent && (
<button className="entry" onClick={() => load(listing.parent!)}>
<span>↰</span>
<Icon name="folder-up" className="ficon" />
<span className="name">..</span>
</button>
)}
@@ -298,11 +299,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
<span>{icon(e)}</span>
<span className="name">{e.name}</span>
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>}
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark === "•" ? <Icon name="dot" className="tiny" /> : mark}</span>}
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
</button>
<details className="entry-menu">
<summary aria-label={t("files.actions")}>⋯</summary>
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
@@ -333,7 +334,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
←
<Icon name="arrow-left" />
</button>
<b className="grow">{pane.title}</b>
</div>
@@ -349,11 +350,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
←
<Icon name="arrow-left" />
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> ●</span>}
{dirty && <span className="dirty"> <Icon name="dot" className="tiny" /></span>}
</b>
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
@@ -361,7 +362,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
)}
<a className="button small" href={rawUrl(f.path, true)}>
⤓
<Icon name="download" />
</a>
{!f.binary && (
<button className="small primary" disabled={!dirty} onClick={() => save()}>
@@ -400,7 +401,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.files")}</h1>
+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react";
import { post } from "../api";
import { fmtRelative, useT } from "../i18n";
@@ -104,17 +105,17 @@ export function GitPanel({
{f.path}
</button>
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
↗
<Icon name="external" />
</button>
{isStaged ? (
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
−
<Icon name="minus" />
</button>
) : (
<>
{f.index !== "?" && (
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
↺
<Icon name="refresh" />
</button>
)}
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
@@ -153,7 +154,7 @@ export function GitPanel({
</button>
{status.upstream && (
<span className="chip" title={status.upstream}>
↑{status.ahead} ↓{status.behind}
<Icon name="arrow-up" className="tiny" />{status.ahead} <Icon name="arrow-down" className="tiny" />{status.behind}
</span>
)}
<span className="spacer" />
+229
View File
@@ -0,0 +1,229 @@
import type { ReactNode } from "react";
/**
* Line icons drawn with `currentColor`: they take the text colour of their
* context, so every theme colours them (no per-theme icon sets needed).
*/
const P: Record<string, ReactNode> = {
menu: <path d="M4 6h16M4 12h16M4 18h16" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
check: <path d="m5 12.5 4.5 4.5L19 7" />,
"x-mark": <path d="M7 7l10 10M17 7 7 17" />,
"arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />,
"arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />,
"arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />,
"chevron-right": <path d="m9 6 6 6-6 6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />,
share: (
<>
<circle cx="18" cy="5.5" r="2.5" />
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="18.5" r="2.5" />
<path d="m8.2 10.8 7.6-4M8.2 13.2l7.6 4" />
</>
),
fork: (
<>
<circle cx="6" cy="5" r="2" />
<circle cx="18" cy="5" r="2" />
<circle cx="12" cy="19" r="2" />
<path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5" />
</>
),
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
clip: <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" />,
file: (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4" />
</>
),
"file-text": (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4M9 12h6M9 16h6" />
</>
),
"file-doc": (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4M9 11h6M9 14h6M9 17h4" />
</>
),
folder: <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" />,
"folder-up": (
<>
<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" />
<path d="M12 16v-5M9.5 13.5 12 11l2.5 2.5" />
</>
),
folders: (
<>
<path d="M7 8a1 1 0 0 1 1-1h4l1.5 1.5H20a1 1 0 0 1 1 1V18a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1Z" />
<path d="M4 15V6a1 1 0 0 1 1-1h4" />
</>
),
image: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="10" r="1.8" />
<path d="m4 17 5-4 4 3 3-2 4 3" />
</>
),
music: (
<>
<path d="M9 18V6l11-2v12" />
<circle cx="6.5" cy="18" r="2.5" />
<circle cx="17.5" cy="16" r="2.5" />
</>
),
cube: (
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>
),
external: <path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" />,
refresh: <path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" />,
lock: (
<>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3M12 15v2" />
</>
),
question: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5V14M12 17h.01" />
</>
),
"radio-on": (
<>
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3.5" className="fill" />
</>
),
"radio-off": <circle cx="12" cy="12" r="8" />,
"box-on": (
<>
<rect x="4" y="4" width="16" height="16" rx="3" />
<path d="m8 12 3 3 5-6" />
</>
),
"box-off": <rect x="4" y="4" width="16" height="16" rx="3" />,
play: <path d="M8 5v14l11-7Z" className="fill" />,
stop: <rect x="6" y="6" width="12" height="12" rx="2" className="fill" />,
warning: <path d="M12 4 2.5 20h19ZM12 10v4.5M12 17.5h.01" />,
brain: (
<>
<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1V5a2 2 0 0 0-3-1Z" />
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
</>
),
eye: (
<>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</>
),
gear: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1M18.7 18.7l-2.1-2.1M7.4 7.4 5.3 5.3" />
</>
),
robot: (
<>
<rect x="5" y="8" width="14" height="11" rx="3" />
<path d="M12 4v4M9 13h.01M15 13h.01M9.5 16h5M3 12v3M21 12v3" />
</>
),
plug: <path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0ZM12 17v4" />,
shield: (
<>
<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" />
</>
),
users: (
<>
<circle cx="9" cy="8" r="3.2" />
<path d="M3 20a6 6 0 0 1 12 0M16 4.5a3.2 3.2 0 0 1 0 6.3M18 14.5a6 6 0 0 1 3 5.5" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="3.8" />
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
</>
),
toolbox: (
<>
<rect x="3" y="8" width="18" height="11" rx="2" />
<path d="M9 8V5h6v3M3 13h18M10 12v2M14 12v2" />
</>
),
scroll: <path d="M7 4h11a2 2 0 0 1 0 4h-1v10a2 2 0 0 1-2 2H6a2 2 0 0 1 0-4h1ZM10 9h4M10 12h4" />,
terminal: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="m7 9 3 3-3 3M12 15h5" />
</>
),
logout: <path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5M15 8l4 4-4 4M19 12H9" />,
chart: <path d="M4 20V4M4 20h16M8 16v-4M12 16V8M16 16v-6M20 16V6" />,
wrench: <path d="M14.5 5.5a4 4 0 0 0 5 5L11 19a2.1 2.1 0 0 1-3-3l8.5-8.5a4 4 0 0 1-2-2Z" />,
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M21.5 12h-2M4.5 12h-2M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4M18.7 18.7l-1.4-1.4M6.7 6.7 5.3 5.3" />
</>
),
moon: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />,
"theme-auto": (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 3.5v17a8.5 8.5 0 0 0 0-17Z" className="fill" />
</>
),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />,
minus: <path d="M5 12h14" />,
upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />,
more: (
<>
<circle cx="5" cy="12" r="1.6" className="fill" />
<circle cx="12" cy="12" r="1.6" className="fill" />
<circle cx="19" cy="12" r="1.6" className="fill" />
</>
),
copy: (
<>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" />
</>
),
sparkle: <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />,
};
export type IconName = keyof typeof P;
export function Icon({ name, size, className, title }: { name: string; size?: number; className?: string; title?: string }) {
return (
<svg
className={`icon ${className ?? ""}`}
viewBox="0 0 24 24"
width={size}
height={size}
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
>
{title && <title>{title}</title>}
{P[name] ?? P.dot}
</svg>
);
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react";
import { del, post } from "../api";
import { useFetch } from "../hooks";
@@ -63,7 +64,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.images")}</h1>
@@ -141,16 +142,16 @@ export function Images({ onMenu }: { onMenu: () => void }) {
</div>
<div className="row" style={{ gap: 4 }}>
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
⤓
<Icon name="download" />
</a>
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
↺
<Icon name="refresh" />
</button>
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
⧉
<Icon name="copy" />
</button>
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
🗑
<Icon name="trash" />
</button>
</div>
</figcaption>
+4 -3
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useState } from "react";
import { post } from "../api";
import { useT } from "../i18n";
@@ -43,7 +44,7 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
return (
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
<h3>
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span>
<Icon name="lock" /> {t("permission.title")} <span className="chip mono">{req.tool}</span>
</h3>
<SubagentHint sessionId={req.session_id} currentId={currentId} />
<div style={{ margin: "6px 0" }}>{req.title}</div>
@@ -122,7 +123,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current
return (
<div className="card" role="dialog" aria-label={t("question.title")}>
<h3>❓ {t("question.title")}</h3>
<h3><Icon name="question" /> {t("question.title")}</h3>
<div className="from">{source}</div>
<SubagentHint sessionId={req.session_id} currentId={currentId} />
{req.questions.map((q, qi) => (
@@ -143,7 +144,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current
onClick={() => toggle(qi, o.label, !!q.multi_select)}
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
>
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span>
<Icon name={q.multi_select ? (isSel ? "box-on" : "box-off") : isSel ? "radio-on" : "radio-off"} />
<div style={{ flex: 1 }}>
{o.label === OTHER ? (
<input
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { memo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -36,7 +37,7 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
<img src={url} alt="SVG" />
</FileFigure>
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
{showCode ? "▾ SVG" : "▸ SVG"}
<Icon name={showCode ? "chevron-down" : "chevron-right"} /> SVG
</button>
{showCode && children}
</div>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
// Loaded on demand only when such a file is opened.
import { useEffect, useRef, useState } from "react";
@@ -147,7 +148,7 @@ export function ModelViewer({ url, ext }: { url: string; ext: string }) {
return (
<div className="model-viewer">
<div ref={host} className="model-canvas" />
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div>
<div className="model-info">{state.loading ? "…" : state.error ? <><Icon name="warning" /> {state.error}</> : state.info}</div>
</div>
);
}
+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>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { del, post } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
@@ -38,7 +39,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
title={t("queue.remove")}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
>
×
<Icon name="close" />
</button>
)}
</div>
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react";
import { patch, post } from "../api";
import { useFetch } from "../hooks";
import { useT } from "../i18n";
import { Icon } from "./Icon";
import { ModelSelect } from "./ModelSelect";
import { Spinner } from "./Activity";
/** Rename a session: type a name, or let a chosen model suggest one. */
export function RenameDialog({ sessionId, title, onClose }: { sessionId: string; title: string; onClose: () => void }) {
const t = useT();
const config = useFetch<{ small_model?: string; default_model?: string }>("/config").data;
const [value, setValue] = useState(title);
const [model, setModel] = useState("");
const [busy, setBusy] = useState<"gen" | "save">();
const [error, setError] = useState<string>();
const chosen = model || config?.small_model || config?.default_model || "";
const generate = async () => {
setBusy("gen");
setError(undefined);
try {
const r = await post<{ title: string }>(`/sessions/${sessionId}/title`, { model: chosen || undefined });
setValue(r.title);
} catch (e: any) {
setError(e.message);
} finally {
setBusy(undefined);
}
};
const save = async () => {
setBusy("save");
try {
await patch(`/sessions/${sessionId}`, { title: value.trim() });
onClose();
} catch (e: any) {
setError(e.message);
setBusy(undefined);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("rename.title")} onClick={(e) => e.stopPropagation()}>
<h3>{t("rename.title")}</h3>
<input
autoFocus
style={{ width: "100%" }}
value={value}
placeholder={t("session.untitled")}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !busy && void save()}
/>
<div className="rename-gen">
<span className="hint">{t("rename.generateWith")}</span>
<ModelSelect value={chosen} onChange={setModel} />
<button className="small" onClick={generate} disabled={!!busy}>
{busy === "gen" ? <Spinner small /> : <Icon name="sparkle" />} {t("rename.generate")}
</button>
</div>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" onClick={save} disabled={!!busy}>
{t("common.save")}
</button>
</div>
</div>
</div>
);
}
+73 -29
View File
@@ -1,9 +1,11 @@
import { Icon } from "./Icon";
import { useEffect, useState, useSyncExternalStore } from "react";
import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
@@ -54,7 +56,6 @@ function InterfaceSettings() {
const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!;
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
return (
<div className="panel">
<h3>{t("settings.interface")}</h3>
@@ -107,21 +108,66 @@ function InterfaceSettings() {
</select>
<div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label>
<div>
{notif === "granted" ? (
<span className="chip">{t("settings.notificationsOn")}</span>
) : (
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
<PushControl />
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
const [state, setState] = useState<"loading" | "on" | "off" | "unsupported" | "denied">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!pushSupported()) return setState("unsupported");
if (Notification.permission === "denied") return setState("denied");
currentSubscription()
.then((s) => setState(s ? "on" : "off"))
.catch(() => setState("off"));
}, []);
const run = (f: () => Promise<unknown>) => async () => {
setBusy(true);
try {
await f();
} catch (e: any) {
toast(e.message ?? String(e));
} finally {
setBusy(false);
}
};
if (state === "loading") return <div />;
if (state === "unsupported") return <div className="hint">{t("settings.pushUnsupported")}</div>;
if (state === "denied") return <div className="hint">{t("settings.pushDenied")}</div>;
return (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{state === "on" ? (
<>
<span className="chip">{t("settings.notificationsOn")}</span>
<button
className="small"
disabled={busy}
onClick={run(async () => {
const n = await testPush();
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
})}
>
{t("settings.pushTest")}
</button>
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
{t("settings.pushDisable")}
</button>
</>
) : (
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
);
}
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const s = cfg.config.server;
@@ -425,8 +471,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<th>ID</th>
<th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th>
<th title={t("settings.reasoning")}>🧠</th>
<th title={t("settings.vision")}>👁</th>
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
<th title={t("settings.vision")}><Icon name="eye" /></th>
<th>{t("settings.effort")}</th>
<th>{t("settings.priceIn")}</th>
<th>{t("settings.priceOut")}</th>
@@ -472,7 +518,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
</td>
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</td>
</tr>
@@ -733,7 +779,7 @@ function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, str
</td>
<td>
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</td>
</tr>
@@ -819,17 +865,17 @@ function Raw({ reload }: { reload: () => void }) {
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const;
const TAB_ICONS: Record<string, string> = {
general: "⚙️",
providers: "🧠",
agents: "🤖",
mcp: "🔌",
permissions: "🛡️",
users: "👥",
folders: "🗂️",
tools: "🧰",
audit: "📜",
account: "👤",
raw: "📝",
general: "gear",
providers: "brain",
agents: "robot",
mcp: "plug",
permissions: "shield",
users: "users",
folders: "folders",
tools: "toolbox",
audit: "scroll",
account: "user",
raw: "file-text",
};
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
@@ -843,7 +889,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.settings")}</h1>
@@ -854,9 +900,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<div className="tabs" role="tablist">
{TABS.map((x) => (
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
<span className="nav-icon" aria-hidden>
{TAB_ICONS[x]}
</span>
<Icon name={TAB_ICONS[x]} className="nav-icon" />
{t(`settings.tab.${x}` as Key)}
</button>
))}
+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { toast } from "../dialogs";
@@ -40,7 +41,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
return (
<>
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })}
{running ? <Spinner small /> : <Icon name="terminal" />} {t("shells.chip", { n: running, total: shells.length })}
</button>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
@@ -49,13 +50,13 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
✕
<Icon name="close" />
</button>
</div>
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
{shells.map((s) => (
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
{s.running ? "● " : "○ "}
<Icon name={s.running ? "dot" : "radio-off"} className={`tiny ${s.running ? "ok" : ""}`} />{" "}
{s.command.slice(0, 40)}
</button>
))}
@@ -67,7 +68,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
<span className="spacer" />
{current.running && (
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
■ {t("shells.stop")}
<Icon name="stop" /> {t("shells.stop")}
</button>
)}
</div>
+8 -7
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, post } from "../api";
@@ -123,7 +124,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<span>{s.title || t("session.untitled")}</span>
</div>
<div className="sub">
{s.shared && <span title={t("sidebar.shared")}>⇄ </span>}
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
</div>
</button>
@@ -138,25 +139,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
<span className="nav-icon" aria-hidden>🚪</span>
<Icon name="logout" className="nav-icon" />
{t("nav.logout")}
</button>
</div>
<div className="sidebar-foot">
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
<span className="nav-icon" aria-hidden>📁</span>
<Icon name="folder" className="nav-icon" />
{t("nav.files")}
</button>
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
<span className="nav-icon" aria-hidden>🖼️</span>
<Icon name="image" className="nav-icon" />
{t("nav.images")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<span className="nav-icon" aria-hidden>📊</span>
<Icon name="chart" className="nav-icon" />
{t("nav.stats")}
</button>
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
<span className="nav-icon" aria-hidden>🔧</span>
<Icon name="wrench" className="nav-icon" />
{t("nav.settings")}
</button>
</div>
@@ -168,7 +169,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
function ThemeModeButton() {
const t = useT();
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
const icon = <Icon name={mode === "auto" ? "theme-auto" : mode === "light" ? "sun" : "moon"} />;
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
return (
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useMemo, useState } from "react";
import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
@@ -163,7 +164,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.stats")}</h1>
+14 -7
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure";
import { useT } from "../i18n";
import { navigate } from "../router";
@@ -26,6 +27,12 @@ export function toolTitle(name: string, input: any): string {
return input?.path ?? ".";
case "bash":
return input?.description || input?.command || "";
case "craft_phase":
return `${input?.phase ?? ""}${input?.note ? ` – ${input.note}` : ""}`;
case "web_search":
return input?.query ?? "";
case "diagnostics":
return input?.path ?? "";
case "glob":
case "grep":
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
@@ -35,7 +42,7 @@ export function toolTitle(name: string, input: any): string {
case "task":
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
case "image_compare":
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`;
return `${input?.original ?? ""} vs. ${input?.candidate ?? ""}`;
case "skill":
return input?.name ?? "";
case "ask_user":
@@ -46,12 +53,12 @@ export function toolTitle(name: string, input: any): string {
}
export function TodoList({ todos }: { todos: Todo[] }) {
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const;
const icon = { completed: "box-on", in_progress: "play", pending: "box-off" } as const;
return (
<ul>
{todos.map((td, i) => (
<li key={i} className={td.status}>
<span>{icon[td.status] ?? "☐"}</span>
<Icon name={icon[td.status as keyof typeof icon] ?? "box-off"} />
<span>{td.content}</span>
</li>
))}
@@ -63,7 +70,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const t = useT();
const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
const meta = result?.meta;
// The live task list has its own panel; updates are a single line here.
@@ -73,8 +80,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const current = todos.find((x) => x.status === "in_progress");
return (
<div className="todo-line">
☑ {t("tool.todosUpdated", { done, total: todos.length })}
{current && <span> · ▶ {current.content}</span>}
<Icon name="box-on" /> {t("tool.todosUpdated", { done, total: todos.length })}
{current && <span> · <Icon name="play" className="tiny" /> {current.content}</span>}
</div>
);
}
@@ -90,7 +97,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
<div className="tool-head">
<span className="tool-name">{call.name}</span>
<span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span>
<span className="status ok"><Icon name="check" /></span>
</div>
<FileFigure url={writtenImage} name={call.input.path}>
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
+6 -5
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect } from "react";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
@@ -64,7 +65,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
✕
<Icon name="close" />
</button>
</div>
{!data ? (
@@ -85,7 +86,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<div className="kpi">
<div className="k-label">{t("stats.tokens")}</div>
<div className="k-value">
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" /> {fmtCompact(data.total.output_tokens)}<Icon name="arrow-down" className="tiny" />
</div>
<div className="k-sub">
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
@@ -96,7 +97,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<div className="k-value" style={{ fontSize: 15 }}>
{data.first ? fmtDate(data.first) : "–"}
</div>
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div>
<div className="k-sub">{data.last ? <><Icon name="chevron-right" className="tiny" /> {fmtDate(data.last)}</> : ""}</div>
</div>
</div>
@@ -118,8 +119,8 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<Row label="ID" value={<code>{m.model}</code>} />
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} />
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} />
<Row label={t("settings.reasoning")} value={mi?.reasoning ? <><Icon name="check" />{mi.effort ? ` (${mi.effort})` : ""}</> : "–"} />
<Row label={t("settings.vision")} value={mi?.vision ? <Icon name="check" /> : "–"} />
{isCurrent && mi?.context ? (
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
) : null}