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; // 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([]); const [dragging, setDragging] = useState(false); const [attachMenuOpen, setAttachMenuOpen] = useState(false); const [dictation, setDictation] = useState(null); const [dictationBusy, setDictationBusy] = useState(null); const [dictationError, setDictationError] = useState(null); const [recordingSeconds, setRecordingSeconds] = useState(0); const [attachNotice, setAttachNotice] = useState(null); const fileInput = useRef(null); const textareaRef = useRef(null); const noticeTimer = useRef(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(-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 | 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([]); 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 (
{props.approvalSlot} {dictationError && (
{dictationError}
)} {/* Rejected-attachment notice (PDF over the user's Token-savings thresholds). */} {attachNotice && (
{attachNotice}
)} {/* Attachments preview — a strip ABOVE the input box (mock/Claude-style). */} {attachments.length > 0 && (
{attachments.map((a, i) => ( setAttachments((all) => all.filter((_, j) => j !== i))} /> ))}
)}
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }} >