User messages are rendered with the same Markdown component as the model's answers; single line breaks are kept (hard breaks outside code blocks), so plain multi-line messages look as before. The eye button next to the text field shows the formatted message before sending. The text sent to the model is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
import { TemplatePicker } from "./TemplatePicker";
|
|
import { Icon } from "./Icon";
|
|
import { formatSize, imageSrc } 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 { Markdown } from "./Markdown";
|
|
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);
|
|
// Rendered Markdown instead of the text field (to check formatting before sending).
|
|
const [preview, setPreview] = 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={imageSrc(img)} alt="" />
|
|
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{preview && (
|
|
<div
|
|
className="composer-preview"
|
|
role="button"
|
|
tabIndex={0}
|
|
title={t("composer.previewEdit")}
|
|
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
|
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
|
>
|
|
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
style={preview ? { display: "none" } : undefined}
|
|
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);
|
|
}}
|
|
/>
|
|
)}
|
|
<button
|
|
className={`ghost ${preview ? "active" : ""}`}
|
|
style={{ padding: "3px 6px" }}
|
|
onClick={() => setPreview(!preview)}
|
|
aria-pressed={preview}
|
|
title={t(preview ? "composer.previewEdit" : "composer.preview")}
|
|
>
|
|
<Icon name={preview ? "edit" : "eye"} />
|
|
</button>
|
|
<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>
|
|
);
|
|
}
|