Files
codecraft/web/src/components/Composer.tsx
T
Richard ZinkandClaude Opus 5.5 c850586a81 Chat tabs, chat mode, own modes, compaction notice, CLI binaries again
- 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>
2026-11-09 11:24:58 +01:00

361 lines
15 KiB
TypeScript

import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch, useModels } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
name: string;
description: string;
}
const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" },
{ name: "new", key: "command.new" },
];
const MAX_SIDE = 2000;
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
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");
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
// PNG keeps screenshots crisp; photos become JPEG.
const type = file.type === "image/png" && file.size < 1_500_000 ? "image/png" : "image/jpeg";
const url = canvas.toDataURL(type, 0.85);
return { media_type: type, data: url.slice(url.indexOf(",") + 1) };
}
export function Composer({
session,
busy,
text,
setText,
onSend,
onAbort,
onUpdate,
}: {
session: Session;
busy: boolean;
text: string;
setText: (s: string) => void;
onSend: (text: string, images: ImageData[], effort?: string) => void;
onAbort: () => void;
onUpdate: (patch: Partial<Session>) => void;
}) {
const t = useT();
const ref = useRef<HTMLTextAreaElement>(null);
const [images, setImages] = useState<ImageData[]>([]);
// Non-image attachments, already uploaded into the project.
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
const [uploading, setUploading] = useState(0);
const [active, setActive] = useState(0);
// Reasoning effort for the next message only ("" = model default).
const [effort, setEffort] = useState("");
const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false);
const models = useModels();
const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
// @file mentions: token before the caret and matching files.
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
}, [text]);
const updateMention = (value: string, caret: number) => {
const m = value.slice(0, caret).match(/(?:^|\s)@([\w./~+\-]*)$/);
setMention(m ? { start: caret - m[1].length - 1, end: caret, q: m[1] } : undefined);
};
useEffect(() => {
if (!mention || mention.q.length < 1) {
setMentionHits([]);
return;
}
let alive = true;
const timer = setTimeout(() => {
fetch(`/api/files/search?path=${encodeURIComponent(session.cwd)}&q=${encodeURIComponent(mention.q)}`, { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : []))
.then((hits: { rel: string; is_dir: boolean }[]) => alive && setMentionHits(hits.slice(0, 12)))
.catch(() => {});
}, 120);
return () => {
alive = false;
clearTimeout(timer);
};
}, [mention?.q, session.cwd]);
const suggestions = useMemo((): { key: string; label: string; description: string; apply: () => void }[] => {
if (mention && mentionHits.length) {
return mentionHits.map((h) => ({
key: h.rel,
label: `@${h.rel}${h.is_dir ? "/" : ""}`,
description: h.is_dir ? t("composer.folder") : "",
apply: () => {
const insert = `@${h.rel}${h.is_dir ? "/" : ""} `;
const next = text.slice(0, mention.start) + insert + text.slice(mention.end);
setText(next);
setMention(undefined);
setMentionHits([]);
const caret = mention.start + insert.length;
requestAnimationFrame(() => ref.current?.setSelectionRange(caret, caret));
},
}));
}
const m = text.match(/^\/(\S*)$/);
if (!m) return [];
const all: Suggestion[] = [
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []),
];
return all
.filter((c) => c.name.startsWith(m[1]))
.slice(0, 12)
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
}, [text, cmdData, t, mention, mentionHits]);
const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
// Files are referenced by path; the agent reads them with its tools.
const refs = files.length ? `\n\n${t("composer.attachedFiles")}\n${files.map((f) => `- ${f.path} (${formatSize(f.size)})`).join("\n")}` : "";
onSend(`${text}${refs}`, images, effort);
setEffort("");
setImages([]);
setFiles([]);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a + (e.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
suggestions[Math.min(active, suggestions.length - 1)].apply();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMention(undefined);
setMentionHits([]);
return;
}
}
// Enter sends on desktop; on touch devices Enter inserts a newline.
const touch = matchMedia("(pointer: coarse)").matches;
if (e.key === "Enter" && !e.shiftKey && !touch && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
if (e.key === "Escape" && busy) onAbort();
};
const addFiles = async (list: FileList | File[]) => {
const all = [...list];
try {
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
} catch (e: any) {
toast(e.message ?? String(e));
}
// Everything else is uploaded into the project folder.
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
const path = `${session.cwd}/.codecraft/uploads/${stamp}-${f.name.replace(/[\\/]/g, "_")}`;
setUploading((n) => n + 1);
try {
await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft`, dir: true }) });
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" });
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));
} finally {
setUploading((n) => n - 1);
}
}
};
const removeFile = (path: string) => {
setFiles((cur) => cur.filter((f) => f.path !== path));
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
};
return (
<div className="composer-wrap">
<div
className="composer"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
void addFiles(e.dataTransfer.files);
}}
>
{suggestions.length > 0 && (
<div className="suggest" role="listbox">
{suggestions.map((s, i) => (
<div
key={s.key}
role="option"
aria-selected={i === active}
className={i === active ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
s.apply();
ref.current?.focus();
}}
>
<span className="name">{s.label}</span>
<span className="desc">{s.description}</span>
</div>
))}
</div>
)}
{(images.length > 0 || files.length > 0 || uploading > 0) && (
<div className="attachments">
{files.map((f) => (
<div className="att file" key={f.path} title={f.path}>
<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>
))}
{uploading > 0 && (
<div className="att file">
<span>{t("composer.uploading", { n: uploading })}</span>
</div>
)}
{images.map((img, i) => (
<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>
))}
</div>
)}
<textarea
ref={ref}
rows={1}
value={text}
placeholder={interim ? `${interim} …` : busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
onChange={(e) => {
setText(e.target.value);
setActive(0);
updateMention(e.target.value, e.target.selectionStart ?? e.target.value.length);
}}
onClick={(e) => updateMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
onKeyDown={onKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (pasted.length > 0) {
e.preventDefault();
void addFiles(pasted);
}
}}
aria-label={t("composer.placeholder")}
/>
<div className="composer-bar">
<div className="composer-options">
<select
className="mode-select"
value={session.agent}
onChange={(e) => onUpdate({ agent: e.target.value })}
aria-label={t("composer.mode")}
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
>
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
{agents.map((a) => (
<option key={a.name} value={a.name} title={a.description}>
{a.name}
</option>
))}
</select>
{session.agent === "craft" && (
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
{t("craft.cleanRoom")}
</button>
<button className={session.clean_room === false ? "active deep" : ""} onClick={() => onUpdate({ clean_room: false })}>
{t("craft.deep")}
</button>
</div>
)}
<select
value={session.permission_mode}
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
title={t("composer.permissionMode")}
>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="default">{t("permissionMode.default")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
{reasoning && <EffortSelect value={effort} onChange={setEffort} compact />}
<button className="ghost" style={{ padding: "3px 6px" }} onClick={() => setPicker(true)} title={t("templates.title")}>
<Icon name="template" />
</button>
{picker && (
<TemplatePicker
onClose={() => setPicker(false)}
onPick={(x) => {
setPicker(false);
setText(text.trim() ? `${text.replace(/\s+$/, "")}\n\n${x}` : x);
setTimeout(() => ref.current?.focus(), 0);
}}
/>
)}
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
<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">
<Icon name="stop" /> {t("composer.stop")}
</button>
)}
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
{busy ? t("composer.queue") : t("composer.send")}
</button>
</div>
</div>
</div>
</div>
);
}