- 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>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|