Files
codecraft/web/src/components/Composer.tsx
T
Richard ZinkandClaude Opus 5.5 ec1b9b4925 Add file badges, menu icons, LSP diagnostics, robust compaction
- Files in chat: preview with type/size badges and file name; attachment chips
- Menu and settings tabs with icons
- Heatmap: distinct colour for empty hours, hourly labels
- Context chip shows used/total tokens (e.g. 221k/240k) plus percent
- LSP client: diagnostics appended to edit/write results and a diagnostics tool
  (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed)
- Compaction summarizes a plain transcript and retries once on empty output
- files/stat endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:50:12 +01:00

279 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatSize } from "./FileFigure";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
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. */
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[]) => 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);
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 suggestions = useMemo(() => {
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);
}, [text, cmdData, t]);
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);
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();
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
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" });
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" : ""}`);
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.name}
role="option"
aria-selected={i === active}
className={i === active ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
setText(`/${s.name} `);
ref.current?.focus();
}}
>
<span className="name">/{s.name}</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>📄 {f.name}</span>
<small>{formatSize(f.size)}</small>
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
×
</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")}>
×
</button>
</div>
))}
</div>
)}
<textarea
ref={ref}
rows={1}
value={text}
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
onChange={(e) => {
setText(e.target.value);
setActive(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">
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
{agents.map((a) => (
<button
key={a.name}
role="tab"
aria-selected={session.agent === a.name}
className={session.agent === a.name ? "active" : ""}
title={a.description}
onClick={() => onUpdate({ agent: a.name })}
>
{a.name}
</button>
))}
</div>
{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")} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
📎
<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")}
</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>
);
}