Files
openworker/surfaces/gui/src/components/Composer.tsx
T
Rohit C Prasad f1eb652d61 Allow mid-session model switching with a persisted transcript marker
Picker stays live for the session; switches persist a model_switch notice (§17 revised).
Rebinds refused mid-turn; images become placeholders for non-vision targets at send time.
2026-07-22 15:43:54 -07:00

664 lines
26 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Attachment } from "../types";
import { isPdfFile, readFile } from "../attach";
import { getSettings, inspectPdf } from "../api";
import { Dropdown, type Option } from "./Dropdown";
import { Icon } from "./Icon";
import { Toggle } from "./Toggle";
import {
cancelDictation,
getDictationLevel,
getDictationStatus,
isTauri,
startDictation,
stopDictation,
type DictationStatus,
} from "../tauri";
const PERMISSION_OPTIONS: Option[] = [
{ value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" },
{ value: "plan", label: "Plan", description: "Explore read-only, propose a plan for approval, then build" },
{ value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" },
{ value: "auto", label: "Full access", description: "Run everything without asking" },
{ value: "custom", label: "Custom", description: "Use auto-allow rules from config.toml" },
];
// No hardcoded model fallback: until the server supplies the list (a few seconds after a
// cold app boot), the picker renders a disabled "Loading models…" chip. A baked-in list
// goes stale and silently offers ids the backend never confirmed (caught 2026-07-21).
// Drop the provider prefix for display (anthropic:claude-opus-4-8 → claude-opus-4-8); full id on hover.
const shortModel = (m: string) => (m.includes(":") ? m.split(":").slice(1).join(":") : m);
// Identify an attachment by name + payload size so duplicates (e.g. the same file picked twice,
// or a prefill applied twice) collapse to one chip.
const attKey = (a: Attachment) =>
a.kind === "text"
? `t:${a.name}:${a.text?.length ?? 0}`
: `${a.kind[0]}:${a.name}:${a.data_url?.length ?? 0}`;
const mergeAttachments = (cur: Attachment[], add: Attachment[]): Attachment[] => {
const seen = new Set(cur.map(attKey));
return [...cur, ...add.filter((a) => !seen.has(attKey(a)))].slice(0, 8);
};
interface Props {
mode: string;
model: string;
models?: string[];
modelLabels?: Record<string, string>; // curated display names (raw id when absent)
// The model is FIXED once the session has history (§17): the picker renders ONLY on a fresh
// session; after the first turn the fact lives in the topbar subtitle (§22) — no
// interactive-then-disabled control.
running: boolean;
connected: boolean;
// False when the default model's provider has no key — the composer shows a "connect a model"
// banner and routes sends to setup (preserving the draft) instead of dropping them.
modelReady?: boolean;
onConnectModel?: () => void;
onConfigureVoiceInput?: () => void;
onSend: (text: string, attachments?: Attachment[]) => void;
onInterrupt: () => void;
onModeChange: (mode: string) => void;
onModelChange: (model: string) => void;
// When set (Code/Cowork), the Mode menu is shown. The folder/roots + branch controls left the
// composer for the Session settings drawer (§22) — folder access is standing session config.
workspace?: string;
// Unattended / send-approvals-to-Inbox — folded into the Mode menu (§22): "who approves, and
// when" is one mental model. Absent handler = no toggle (e.g. Chat).
unattended?: boolean;
onUnattendedChange?: (on: boolean) => void;
approvalSlot?: ReactNode;
// Push text + attachments into the composer (e.g. a start-panel task card). The `nonce` makes
// repeated identical prefills re-apply; the user can still edit before sending.
prefill?: { text: string; attachments?: Attachment[]; nonce: number };
// Changes when the active conversation changes; clears any unsent draft.
resetKey?: string;
// Surface-specific hint shown in the empty textarea.
placeholder?: string;
}
export function Composer(props: Props) {
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [dragging, setDragging] = useState(false);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [dictation, setDictation] = useState<DictationStatus | null>(null);
const [dictationBusy, setDictationBusy] = useState<string | null>(null);
const [dictationError, setDictationError] = useState<string | null>(null);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [attachNotice, setAttachNotice] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const noticeTimer = useRef<number | null>(null);
// Rejected-attachment notice: visible ~8s, then clears (or on ✕).
const showAttachNotice = (message: string) => {
setAttachNotice(message);
if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
noticeTimer.current = window.setTimeout(() => setAttachNotice(null), 8000);
};
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const max = parseFloat(getComputedStyle(el).lineHeight || "22") * 4;
const next = Math.min(el.scrollHeight, max);
el.style.height = `${Math.max(next, 24)}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
// Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at
// most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments
// are de-duplicated so the same file never lands twice.
const appliedNonce = useRef<number>(-1);
useEffect(() => {
const p = props.prefill;
if (!p || p.nonce === appliedNonce.current) return;
appliedNonce.current = p.nonce;
setText(p.text);
if (p.attachments?.length) setAttachments((cur) => mergeAttachments(cur, p.attachments!));
textareaRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prefill?.nonce]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another.
useEffect(() => {
setText("");
setAttachments([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Dictation is intentionally native-only: the browser/dev build remains a local server client
// and never turns on the browser microphone or ships audio anywhere.
useEffect(() => {
if (!isTauri()) return;
const refresh = (event?: Event) => {
const supplied = (event as CustomEvent<DictationStatus> | undefined)?.detail;
if (supplied) {
setDictation(supplied);
return;
}
void getDictationStatus().then((status) => status && setDictation(status));
};
refresh();
window.addEventListener("coworker:voice-input-changed", refresh);
return () => window.removeEventListener("coworker:voice-input-changed", refresh);
}, []);
useEffect(() => {
if (!dictation?.recording) {
setRecordingSeconds(0);
return;
}
const started = Date.now();
const timer = window.setInterval(() => {
setRecordingSeconds(Math.floor((Date.now() - started) / 1000));
}, 250);
return () => window.clearInterval(timer);
}, [dictation?.recording]);
// Live waveform: poll mic loudness at ~10Hz while recording; the bars scroll left so the
// trace reads as a real input meter (owner catch on DMG #28 — the first cut's bars were
// decorative constants and read as fake).
const [levels, setLevels] = useState<number[]>([]);
useEffect(() => {
if (!dictation?.recording) {
setLevels([]);
return;
}
const timer = window.setInterval(() => {
getDictationLevel().then((level) => {
if (typeof level === "number") setLevels((cur) => [...cur.slice(-13), level]);
});
}, 100);
return () => window.clearInterval(timer);
}, [dictation?.recording]);
useEffect(() => {
if (!dictation?.recording) return;
const cancelOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
void cancelDictation()
.catch(() => undefined)
.finally(() => {
void getDictationStatus().then((status) => status && setDictation(status));
});
};
window.addEventListener("keydown", cancelOnEscape);
return () => window.removeEventListener("keydown", cancelOnEscape);
}, [dictation?.recording]);
const voiceReady = !!dictation?.supported && !!dictation?.model_verified && !!dictation?.test_passed;
const recordingTime = `${Math.floor(recordingSeconds / 60)}:${String(recordingSeconds % 60).padStart(2, "0")}`;
// Attach-time PDF thresholds (Settings → Token savings): a PDF over the user's page or
// size limit is REJECTED with a visible notice — never attached, never silently dropped.
// The rationale is token cost: a big PDF re-rides every turn of the conversation.
const addFiles = async (files: FileList | File[]) => {
const list = Array.from(files);
let maxPages = 20;
let maxMb = 10;
if (list.some(isPdfFile)) {
try {
const s = await getSettings();
if (s.pdf_max_pages) maxPages = s.pdf_max_pages;
if (s.pdf_max_mb) maxMb = s.pdf_max_mb;
} catch {
/* offline settings fetch — fall back to defaults */
}
}
const accepted: File[] = [];
for (const file of list) {
if (isPdfFile(file) && file.size > maxMb * 1024 * 1024) {
showAttachNotice(
`${file.name} skipped — ${(file.size / 1024 / 1024).toFixed(1)} MB is over your ${maxMb} MB limit (Settings → Token savings)`,
);
continue;
}
accepted.push(file);
}
const read = (await Promise.all(accepted.map(readFile))).filter(Boolean) as Attachment[];
const next: Attachment[] = [];
for (const a of read) {
if (a.kind === "pdf" && a.data_url) {
const info = await inspectPdf(a.data_url).catch(() => null);
if (info?.ok && (info.pages ?? 0) > maxPages) {
showAttachNotice(
`${a.name} skipped — ${info.pages} pages is over your ${maxPages}-page limit (Settings → Token savings)`,
);
continue;
}
if (info && !info.ok) {
showAttachNotice(`${a.name} skipped — ${info.error || "could not read PDF"}`);
continue;
}
}
next.push(a);
}
if (next.length) setAttachments((a) => mergeAttachments(a, next));
};
// The "+" menu offers typed shortcuts; each just narrows the OS picker's filter.
const pickFiles = (accept: string) => {
setAttachMenuOpen(false);
if (fileInput.current) {
fileInput.current.accept = accept;
fileInput.current.click();
}
};
const needsModel = props.modelReady === false;
const submit = () => {
const t = text.trim();
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
if (needsModel) {
props.onConnectModel?.();
return;
}
props.onSend(t, attachments);
setText("");
setAttachments([]);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const onPaste = (e: React.ClipboardEvent) => {
const imgs = Array.from(e.clipboardData.items)
.filter((it) => it.kind === "file" && it.type.startsWith("image/"))
.map((it) => it.getAsFile())
.filter(Boolean) as File[];
if (imgs.length) {
e.preventDefault();
addFiles(imgs);
}
};
const toggleDictation = async () => {
if (!isTauri() || dictationBusy) return;
setDictationError(null);
try {
if (dictation?.recording) {
setDictationBusy("Transcribing…");
const transcript = await stopDictation();
if (transcript === null) throw new Error("Could not transcribe your recording.");
if (transcript.trim()) {
setText((draft) => (draft.trim() ? `${draft.trimEnd()} ${transcript.trim()}` : transcript.trim()));
}
setDictation(await getDictationStatus());
textareaRef.current?.focus();
return;
}
const status = dictation || (await getDictationStatus());
if (!status) throw new Error("Voice dictation is unavailable.");
if (!status.supported || !status.model_verified || !status.test_passed) {
props.onConfigureVoiceInput?.();
return;
}
setDictationBusy("Starting microphone…");
const recording = await startDictation();
if (!recording?.recording) throw new Error("Could not start the microphone.");
setDictation(recording);
} catch (error) {
setDictationError(error instanceof Error ? error.message : "Voice dictation is unavailable.");
const status = await getDictationStatus();
if (status) setDictation(status);
} finally {
setDictationBusy(null);
}
};
const modelsLoaded = !!(props.models && props.models.length);
const modelOptions: Option[] = Array.from(
new Set([props.model, ...(props.models || [])]),
).map((m) => ({
value: m,
label: props.modelLabels?.[m] || shortModel(m),
}));
const iconBtn =
"w-7 h-7 grid place-items-center rounded-md text-muted hover:text-ink hover:bg-paper shrink-0";
// The send button is accent only when there's something to send — subtle grey otherwise, so the
// composer isn't carrying a constant blue dot.
const hasContent = text.trim().length > 0 || attachments.length > 0;
return (
<div className="composer-wrap px-6 pb-5 pt-4">
{props.approvalSlot}
{dictationError && (
<div className="max-w-3xl mx-auto mb-2 px-1 text-[12px] text-red-600" role="alert">
{dictationError}
</div>
)}
{/* Rejected-attachment notice (PDF over the user's Token-savings thresholds). */}
{attachNotice && (
<div
data-testid="attach-notice"
className="max-w-3xl mx-auto mb-1.5 flex items-center gap-2 rounded-lg border border-warnInk/30 bg-warnSoft px-3 py-1.5 text-[12.5px] text-warnInk"
>
<span className="flex-1">{attachNotice}</span>
<button
className="shrink-0 opacity-60 hover:opacity-100"
onClick={() => setAttachNotice(null)}
title="Dismiss"
>
</button>
</div>
)}
{/* Attachments preview — a strip ABOVE the input box (mock/Claude-style). */}
{attachments.length > 0 && (
<div className="max-w-3xl mx-auto mb-1.5 flex flex-wrap gap-2">
{attachments.map((a, i) => (
<AttachChip key={i} a={a} onRemove={() => setAttachments((all) => all.filter((_, j) => j !== i))} />
))}
</div>
)}
<div
className={
"composer max-w-3xl mx-auto rounded-2xl border border-line bg-panel shadow-sm" +
(dragging ? " dragging" : "")
}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}}
>
<textarea
ref={textareaRef}
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
placeholder={props.placeholder || "Ask the coworker… (drop or paste files)"}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKey}
onPaste={onPaste}
rows={1}
/>
{/* Three-control row (§22): + attach · Mode ⌄ …(right)… model (fresh only) · send */}
<div className="px-2.5 pb-2.5 pt-1 flex items-center gap-1.5">
{/* + attach menu */}
<div className="relative">
<button
className={iconBtn + (attachMenuOpen ? " bg-paper text-ink" : "")}
title="Attach"
aria-label="Attach"
onClick={() => setAttachMenuOpen((v) => !v)}
>
<Icon name="plus" size={17} />
</button>
{attachMenuOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setAttachMenuOpen(false)} />
<div className="absolute z-40 bottom-full mb-1 left-0 min-w-[180px] rounded-xl border border-line bg-panel shadow-2xl py-1.5">
{attachItem("image", "Photo or image", () => pickFiles("image/*"))}
{attachItem("file", "PDF", () => pickFiles("application/pdf,.pdf"))}
{attachItem(
"fileCode",
"Other files",
() => pickFiles("text/*,.md,.csv,.json,.yaml,.yml,.log,.py,.ts,.tsx,.js,.rs,.go,.toml"),
)}
</div>
</>
)}
</div>
<input
ref={fileInput}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
}}
/>
{/* Listening replaces the quiet middle controls with a LIVE waveform (mic RMS,
polled ~10Hz, scrolling left) + elapsed time (§37). */}
{dictation?.recording ? (
<div className="voice-wave-row flex-1 flex items-center gap-2 ml-1" aria-hidden="true">
<span className="voice-wave-line" />
<span className="voice-wave-bars">
{Array.from({ length: 14 }, (_, index) => {
const level = levels[levels.length - 14 + index] ?? 0;
return <i key={index} style={{ height: Math.round(4 + level * 24) }} />;
})}
</span>
<span className="text-[12px] text-muted tabular-nums">{recordingTime}</span>
</div>
) : props.workspace !== undefined ? (
<ModeMenu
mode={props.mode}
onModeChange={props.onModeChange}
unattended={props.unattended}
onUnattendedChange={props.onUnattendedChange}
/>
) : null}
{dictationBusy === "Transcribing…" && <span className="text-[11.5px] text-accent">Transcribing</span>}
<span className="ml-auto" />
{/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
mid-session switching shipped, so the picker stays actionable; the topbar
subtitle still states the current model). */}
{!dictation?.recording && (needsModel ? (
<button
className="pill model-warn chip"
onClick={() => props.onConnectModel?.()}
title="Connect a model"
aria-label="No model connected — connect a model"
>
<span className="pill-label">No model</span>
<span className="model-warn-ico" aria-hidden></span>
</button>
) : modelsLoaded ? (
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
) : (
<button
className="pill chip text-faint cursor-default"
disabled
data-testid="models-loading"
title="Fetching the model list from the server"
>
<span className="pill-label">Loading models</span>
</button>
))}
{/* mic — immediately before send (owner call, DMG #28 walkthrough) */}
{isTauri() && (
<button
className={
iconBtn +
(dictation?.recording ? " bg-red-50 text-red-600 hover:bg-red-100" : "") +
(dictationBusy ? " opacity-60" : "") +
(!voiceReady && !dictation?.recording ? " opacity-40" : "")
}
onClick={() => void toggleDictation()}
disabled={!!dictationBusy}
title={
dictationBusy ||
(dictation?.recording
? "Stop recording and transcribe"
: voiceReady
? "Start local voice dictation"
: "Configure Voice Input in Settings")
}
aria-label={dictation?.recording ? "Stop dictation" : voiceReady ? "Start dictation" : "Configure Voice Input in Settings"}
aria-disabled={!voiceReady && !dictation?.recording}
>
<Icon name={dictation?.recording ? "stop" : "mic"} size={16} />
</button>
)}
{/* send / stop */}
{props.running ? (
<button className="btn danger" onClick={props.onInterrupt}>
Stop
</button>
) : (
<button
className={
"w-7 h-7 rounded-full grid place-items-center shrink-0 transition-colors " +
(hasContent && props.connected && !dictation?.recording && !dictationBusy
? "bg-accent text-white hover:brightness-105"
: "bg-paper border border-line text-faint")
}
onClick={submit}
disabled={!props.connected || !!dictation?.recording || !!dictationBusy}
title={needsModel ? "Connect a model to send" : undefined}
aria-label="Send"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
)}
</div>
</div>
<span className="sr-only" role="status" aria-live="polite">
{dictation?.recording ? `Listening, ${recordingTime}` : dictationBusy || ""}
</span>
</div>
);
}
// The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with
// the current one marked, plus — when the session supports it — the "Send approvals to Inbox"
// toggle at the bottom (the old standalone InboxControl, folded in).
function ModeMenu({
mode,
onModeChange,
unattended,
onUnattendedChange,
}: {
mode: string;
onModeChange: (mode: string) => void;
unattended?: boolean;
onUnattendedChange?: (on: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const current = PERMISSION_OPTIONS.find((o) => o.value === mode);
return (
<div className="relative">
{/* Borderless, and it names the CHOSEN mode (owner ask 2026-07-11, competitor composer
comparison): "Ask for approval ⌄" not a generic "Mode ⌄" pill. aria-label stays
"Mode" so the accessible name is stable across mode changes. */}
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] text-muted hover:text-ink hover:bg-paper shrink-0"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Mode"
title={
`Mode: ${current?.label || mode}` +
(unattended ? " · approvals go to the Inbox" : "")
}
>
{current?.label || mode}
<Icon name="chevronDown" size={11} className="text-faint" />
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div
className="absolute z-40 bottom-full mb-1 left-0 w-[260px] rounded-xl border border-line bg-panel shadow-2xl p-1.5"
role="menu"
data-testid="mode-menu"
>
{PERMISSION_OPTIONS.map((o) => (
<button
key={o.value}
className="w-full flex flex-col items-start px-2.5 py-1.5 rounded-lg text-left hover:bg-paper"
onClick={() => {
onModeChange(o.value);
setOpen(false);
}}
>
<span
className={
"text-[13px] " + (o.value === mode ? "font-medium text-accent" : "text-ink")
}
>
{o.label}
{o.value === mode && <span className="ml-1.5"></span>}
</span>
<span className="text-[11px] text-faint leading-snug">{o.description}</span>
</button>
))}
{onUnattendedChange && (
<>
<div className="my-1 border-t border-line" />
<div className="flex items-center gap-2 px-2.5 py-1.5">
<span className="flex-1 min-w-0">
<span className="block text-[13px] text-ink">Send approvals to Inbox</span>
<span className="block text-[11px] text-faint leading-snug">
Approvals &amp; questions go to the Inbox; the agent keeps working.
</span>
</span>
<Toggle
checked={!!unattended}
onChange={onUnattendedChange}
title="Send approvals to the Inbox"
/>
</div>
</>
)}
</div>
</>
)}
</div>
);
}
// A row in the "+" attach menu.
function attachItem(icon: "image" | "file" | "fileCode", label: string, onClick: () => void) {
return (
<button
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-left hover:bg-paper"
onClick={onClick}
>
<Icon name={icon} size={15} className="shrink-0 text-muted" /> {label}
</button>
);
}
function AttachChip({ a, onRemove }: { a: Attachment; onRemove: () => void }) {
return (
<div className={"attach-chip" + (a.kind === "image" ? " img" : "")}>
{a.kind === "image" ? (
<img src={a.data_url} alt={a.name} />
) : (
<>
<Icon name="file" size={13} />
<span className="attach-name">{a.name}</span>
</>
)}
<button className="attach-x" onClick={onRemove} title="Remove">
</button>
</div>
);
}