Closes #246. The transcript, composer and approval controls move from structured.ts/structuredRender.ts into components under web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row kinds, both projections, the single-active approval nonce, and diff rendering with its own line counts. The pure layers stay shared — structuredDiff.ts computes the diff, structuredRender.ts still sanitizes markdown — and model.ts holds the client-side decisions (activity grouping, notice classification, offered decisions, `/` completion) so they are testable without a DOM. Like TerminalPane, the pane draws the body only; pane chrome reads the connection through onStatusChange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
// The composer: draft, attachments, `/` command autocomplete off the live
|
||
// `available_commands_update` notices, the session-settings menu, and the
|
||
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
|
||
// the files that go with it.
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type ClipboardEvent,
|
||
type DragEvent,
|
||
type FormEvent,
|
||
type KeyboardEvent,
|
||
} from "react";
|
||
|
||
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
|
||
import {
|
||
applyCommand,
|
||
commandTokenAt,
|
||
filterCommands,
|
||
orderConfigOptions,
|
||
type AvailableCommand,
|
||
} from "./model.js";
|
||
|
||
/** Per-message ceilings the daemon enforces on uploads
|
||
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
|
||
export const MAX_ATTACHMENTS = 10;
|
||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
|
||
* going inline. */
|
||
const PASTE_TO_FILE_CHARS = 4_000;
|
||
const PASTE_TO_FILE_LINES = 40;
|
||
|
||
export interface ComposerProps {
|
||
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
|
||
* them back. */
|
||
attachments: File[];
|
||
onAttachmentsChange(files: File[]): void;
|
||
commands: AvailableCommand[];
|
||
configOptions: AcpSessionConfigOption[];
|
||
/** A turn is in flight — the cancel control replaces nothing, it appears
|
||
* beside send (a queued message is still allowed). */
|
||
turnActive: boolean;
|
||
/** An upload/POST is in flight: the attachment controls are frozen. */
|
||
uploading: boolean;
|
||
configBusy: boolean;
|
||
/** Restored into the box when a send fails. */
|
||
draft: string;
|
||
onDraftChange(draft: string): void;
|
||
onSubmit(text: string, attachments: File[]): void;
|
||
onCancel(): void;
|
||
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
|
||
onError(message: string): void;
|
||
menuId: string;
|
||
}
|
||
|
||
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
|
||
}
|
||
|
||
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||
?? String(option.currentValue);
|
||
}
|
||
|
||
export function Composer({
|
||
attachments,
|
||
onAttachmentsChange,
|
||
commands,
|
||
configOptions,
|
||
turnActive,
|
||
uploading,
|
||
configBusy,
|
||
draft,
|
||
onDraftChange,
|
||
onSubmit,
|
||
onCancel,
|
||
onConfigChange,
|
||
onError,
|
||
menuId,
|
||
}: ComposerProps) {
|
||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||
const configRef = useRef<HTMLDivElement | null>(null);
|
||
const [caret, setCaret] = useState(0);
|
||
const [activeCommand, setActiveCommand] = useState(0);
|
||
const [configOpen, setConfigOpen] = useState(false);
|
||
/** Escape closes the menu until the draft or caret moves again. */
|
||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||
const pasteCount = useRef(0);
|
||
|
||
// Grow with the draft, up to the CSS max-height; past that it scrolls.
|
||
useLayoutEffect(() => {
|
||
const input = inputRef.current;
|
||
if (!input) return;
|
||
input.style.height = "auto";
|
||
input.style.overflowY = "hidden";
|
||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||
if (input.scrollHeight > 160) input.style.overflowY = "auto";
|
||
}, [draft]);
|
||
|
||
useEffect(() => {
|
||
if (!configOpen) return;
|
||
const onPointerDown = (event: PointerEvent) => {
|
||
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
|
||
};
|
||
document.addEventListener("pointerdown", onPointerDown);
|
||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||
}, [configOpen]);
|
||
|
||
const token = commandTokenAt(draft, caret, caret);
|
||
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
|
||
const menuOpen = matches.length > 0 && !menuDismissed;
|
||
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
|
||
|
||
const addFiles = useCallback(
|
||
(files: File[]) => {
|
||
if (uploading) return;
|
||
const next = [...attachments];
|
||
for (const file of files) {
|
||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||
} else if (next.length >= MAX_ATTACHMENTS) {
|
||
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
|
||
} else {
|
||
next.push(file);
|
||
}
|
||
}
|
||
onAttachmentsChange(next);
|
||
},
|
||
[attachments, onAttachmentsChange, onError, uploading],
|
||
);
|
||
|
||
const syncCaret = () => {
|
||
setMenuDismissed(false);
|
||
setCaret(inputRef.current?.selectionStart ?? 0);
|
||
};
|
||
|
||
const chooseCommand = (command: AvailableCommand) => {
|
||
const input = inputRef.current;
|
||
if (!input) return;
|
||
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
|
||
if (!current) return;
|
||
const applied = applyCommand(draft, current, command.name);
|
||
onDraftChange(applied.value);
|
||
setActiveCommand(0);
|
||
// The caret has to move after React has written the new value.
|
||
requestAnimationFrame(() => {
|
||
input.focus();
|
||
input.setSelectionRange(applied.cursor, applied.cursor);
|
||
setCaret(applied.cursor);
|
||
});
|
||
};
|
||
|
||
const submit = () => {
|
||
if ((!draft.trim() && attachments.length === 0) || uploading) return;
|
||
onSubmit(draft, attachments);
|
||
};
|
||
|
||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (menuOpen) {
|
||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
|
||
return;
|
||
}
|
||
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
|
||
event.preventDefault();
|
||
chooseCommand(matches[activeIndex]);
|
||
return;
|
||
}
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setMenuDismissed(true);
|
||
return;
|
||
}
|
||
}
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
submit();
|
||
}
|
||
};
|
||
|
||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const clipboard = event.clipboardData;
|
||
if (!clipboard || uploading) return;
|
||
if (clipboard.files.length) {
|
||
event.preventDefault();
|
||
addFiles(Array.from(clipboard.files));
|
||
return;
|
||
}
|
||
const text = clipboard.getData("text/plain");
|
||
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
|
||
// No slot for it, or too big to upload: leave it inline rather than
|
||
// dropping it on the floor.
|
||
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
|
||
event.preventDefault();
|
||
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
|
||
};
|
||
|
||
const ordered = orderConfigOptions(configOptions);
|
||
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||
|
||
return (
|
||
<form
|
||
className="structured-composer"
|
||
onSubmit={(event: FormEvent) => {
|
||
event.preventDefault();
|
||
submit();
|
||
}}
|
||
onDragOver={(event: DragEvent) => {
|
||
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||
}}
|
||
onDrop={(event: DragEvent) => {
|
||
if (!event.dataTransfer?.files.length) return;
|
||
event.preventDefault();
|
||
addFiles(Array.from(event.dataTransfer.files));
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="structured-attach"
|
||
aria-label="Attach files"
|
||
title="Attach files (up to 20 MiB each)"
|
||
disabled={uploading}
|
||
onClick={() => fileRef.current?.click()}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
|
||
</svg>
|
||
</button>
|
||
<input
|
||
ref={fileRef}
|
||
className="structured-file-input"
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
disabled={uploading}
|
||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||
addFiles(Array.from(event.target.files ?? []));
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
<div className="structured-input-wrap">
|
||
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
|
||
{attachments.map((file, index) => (
|
||
<span key={`${file.name}-${index}`} className="structured-attachment">
|
||
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`Remove ${file.name}`}
|
||
disabled={uploading}
|
||
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
|
||
{matches.map((command, index) => (
|
||
<div
|
||
key={command.name}
|
||
id={`${menuId}-option-${index}`}
|
||
className="structured-command-option"
|
||
role="option"
|
||
aria-selected={index === activeIndex}
|
||
onPointerDown={(event) => event.preventDefault()}
|
||
onClick={() => chooseCommand(command)}
|
||
>
|
||
<div className="structured-command-title">
|
||
<span className="structured-command-name">{`/${command.name}`}</span>
|
||
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
|
||
</div>
|
||
<div className="structured-command-description">
|
||
{command.description.replace(/\s+/g, " ").trim()}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<textarea
|
||
ref={inputRef}
|
||
className="structured-input"
|
||
rows={1}
|
||
placeholder="Reply, or / for commands…"
|
||
autoComplete="off"
|
||
aria-autocomplete="list"
|
||
aria-expanded={menuOpen}
|
||
aria-controls={menuId}
|
||
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
|
||
value={draft}
|
||
onChange={(event) => {
|
||
onDraftChange(event.target.value);
|
||
setActiveCommand(0);
|
||
setMenuDismissed(false);
|
||
setCaret(event.target.selectionStart ?? event.target.value.length);
|
||
}}
|
||
onClick={syncCaret}
|
||
onSelect={syncCaret}
|
||
onKeyUp={(event) => {
|
||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
|
||
}}
|
||
onKeyDown={onKeyDown}
|
||
onPaste={onPaste}
|
||
/>
|
||
</div>
|
||
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
|
||
<button
|
||
type="button"
|
||
className="structured-config-trigger"
|
||
aria-haspopup="true"
|
||
aria-expanded={configOpen}
|
||
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
|
||
title={summary}
|
||
disabled={configBusy}
|
||
onClick={() => setConfigOpen((open) => !open)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
|
||
</svg>
|
||
</button>
|
||
<div
|
||
className="structured-config-menu"
|
||
role="group"
|
||
aria-label="Session settings"
|
||
hidden={!configOpen}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== "Escape") return;
|
||
setConfigOpen(false);
|
||
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
|
||
}}
|
||
>
|
||
{ordered.map((option) => (
|
||
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
|
||
<span className="structured-config-name">{option.name}</span>
|
||
{option.type === "select" ? (
|
||
<select
|
||
className="structured-config-select"
|
||
disabled={configBusy}
|
||
value={String(option.currentValue)}
|
||
onChange={(event) => onConfigChange(option, event.target.value)}
|
||
>
|
||
{configChoices(option).map((choice) => (
|
||
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
|
||
{choice.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : option.type === "boolean" ? (
|
||
<input
|
||
type="checkbox"
|
||
className="structured-config-toggle"
|
||
disabled={configBusy}
|
||
checked={option.currentValue === true}
|
||
onChange={(event) => onConfigChange(option, event.target.checked)}
|
||
/>
|
||
) : null}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button type="submit" className="structured-send">
|
||
<span>Send</span>
|
||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||
<path d="m7 5 5 5-5 5M12 10H3" />
|
||
</svg>
|
||
</button>
|
||
{turnActive && (
|
||
<button
|
||
type="button"
|
||
className="structured-cancel"
|
||
aria-label="Cancel turn"
|
||
title="Cancel turn"
|
||
onClick={onCancel}
|
||
>
|
||
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
|
||
</svg>
|
||
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|