返回 DeepSeek-Reasonix
Composer.tsx
根目录 / desktop / frontend / src / components / Composer.tsx
1 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
3 import { ArrowRight, ArrowUp, AtSign, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Equal, Eye, FilePlus2, FileText, Flag, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
4 import { asArray } from "../lib/array";
5 import { filterAtMatches } from "../lib/atMatches";
6 import { DedupIndex, sha256 } from "../lib/attachDedup";
7 import { app, onFilesDropped } from "../lib/bridge";
8 import { canUsePromptHistory, composerEnterAction, insertComposerNewline, isFnKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
9 import { cacheGeneration, loadOlder } from "../lib/composerHistory";
10 import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
11 import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts";
12 import { fallbackCopyText } from "../lib/clipboard";
13 import {
14 commandAvailableAtSlashPosition,
15 commandUsesStructuredInvocation,
16 invocationRequests,
17 replaceInvocationTextRange,
18 serializeInvocationSubmit,
19 typedStructuredInvocationDraft,
20 trimInvocationDraft,
21 type ComposerInvocation,
22 type StructuredInvocationSubmit,
23 } from "../lib/invocationDisplay";
24 import { formatTokens } from "../lib/format";
25 import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
26 import { createRafResizeUpdater } from "../lib/resizeDrag";
27 import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
28 import { useToast } from "../lib/toast";
29 import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type TokenMode, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
30 import {
31 formatWorkspaceReference,
32 parseWorkspaceReference,
33 readWorkspaceReferenceDrag,
34 WORKSPACE_REF_DRAG_TYPE,
35 } from "../lib/workspaceDrag";
36 import { SlashMenu, sortSlashCommandsForMenu } from "./SlashMenu";
37 import { ArgMenu } from "./ArgMenu";
38 import { ANCHORED_POPOVER_CLOSE_MS, AnchoredPopover } from "./AnchoredPopover";
39 import { EffortSwitcher } from "./EffortSwitcher";
40 import { ModelSwitcher } from "./ModelSwitcher";
41 import { Tooltip } from "./Tooltip";
42 import { ComposerContextCard } from "./ComposerContextCard";
43 import { Markdown } from "./Markdown";
44 import { CodeViewer } from "./CodeViewer";
45 import { ContextWindowRing } from "./ContextWindowRing";
46 import { ImageViewer } from "./ImageViewer";
47 import {
48 RichComposerInput,
49 slashQueryAt,
50 type RichComposerChangeOrigin,
51 type RichComposerInputHandle,
52 type RichComposerSelection,
53 type RichSlashQuery,
54 } from "./RichComposerInput";
55 import { VirtualMenu } from "./VirtualMenu";
56 import { activeFileReferenceToken, dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
57 import { activeRefTokenRe, escapeRefPath, unescapeRefPath } from "../lib/refToken";
58 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
59 import {
60 formatSelectedTextContext,
61 formatSelectionLabel,
62 languageFor,
63 normalizeSelectedText,
64 selectedTextSnippet,
65 type SelectedTextInsertRequest,
66 type SelectedTextReference,
67 } from "../lib/selectedTextContext";
68 interface Attachment {
69 path: string;
70 previewUrl?: string;
71 displayName?: string;
72 }
73
74 interface AttachmentDedupKey {
75 hash: string;
76 source: string;
77 }
78
79 export interface WorkspaceReference {
80 path: string;
81 isDir?: boolean;
82 displayPath?: string;
83 }
84
85 const LONG_PASTE_MIN_CHARS = 2000;
86 const LONG_PASTE_MIN_LINES = 20;
87 const COMPOSER_MIN_HEIGHT = 104;
88 const COMPOSER_MAX_HEIGHT = 360;
89 // Height reserved for the in-card run strip while a turn runs; applied via a
90 // CSS calc so --composer-height always stays in "logical height" space.
91 const COMPOSER_RUN_STRIP_RESERVED = 30;
92 const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
93 const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
94 const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
95 // Grace after compositionend to swallow a confirm-Enter that lands just after
96 // it; the real gap is a few ms, so keep it short or a deliberate quick second
97 // Enter (submit) gets eaten too.
98 const IME_CONFIRM_GRACE_MS = 100;
99 const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
100
101 type PastedBlock = {
102 label: string;
103 text: string;
104 };
105
106 type PendingGuidance = {
107 id: number;
108 text: string;
109 submitText: string;
110 structured?: StructuredInvocationSubmit;
111 };
112
113 type FileRefSearchCacheEntry = {
114 entries: DirEntry[];
115 cachedAt: number;
116 };
117
118 type ComposerDraft = {
119 text: string;
120 invocations: ComposerInvocation[];
121 attachments: Attachment[];
122 workspaceRefs: WorkspaceReference[];
123 pastedBlocks: PastedBlock[];
124 openPastedLabels: string[];
125 sessionRefs: SessionReference[];
126 selectedTextRefs: SelectedTextReference[];
127 attachmentDedupKeys: Record<string, AttachmentDedupKey>;
128 nextPasteId: number;
129 historyIndex: number;
130 savedText: string;
131 pendingGuidance: PendingGuidance[];
132 guidanceExpanded: boolean;
133 guidanceSendingId: number | null;
134 pendingPaste: number;
135 submitting: boolean;
136 };
137
138 type ComposerEditSnapshot = {
139 text: string;
140 invocations: ComposerInvocation[];
141 pastedBlocks: PastedBlock[];
142 openPastedLabels: string[];
143 nextPasteId: number;
144 selection: RichComposerSelection;
145 };
146
147 type ComposerEditTransaction = {
148 before: ComposerEditSnapshot;
149 after: ComposerEditSnapshot;
150 nativeBarrierBefore: boolean;
151 nativeBarrierAfter: boolean;
152 };
153
154 type ComposerEditHistory = {
155 undo: ComposerEditTransaction[];
156 redo: ComposerEditTransaction[];
157 undoNativeBarrier: boolean;
158 redoNativeBarrier: boolean;
159 };
160
161 type WebkitFileEntry = {
162 isDirectory?: boolean;
163 };
164
165 const DEFAULT_COMPOSER_DRAFT_KEY = "__default_composer_draft__";
166 const MAX_COMPOSER_EDIT_HISTORY = 50;
167
168 function lineCount(s: string): number {
169 if (s === "") return 0;
170 return s.split(/\r\n|\r|\n/).length;
171 }
172
173 function shouldFoldPaste(s: string): boolean {
174 return s.length >= LONG_PASTE_MIN_CHARS || lineCount(s) >= LONG_PASTE_MIN_LINES;
175 }
176
177 function renderPastedBlock(block: PastedBlock): string {
178 return `${block.label}\n\n--- Begin ${block.label} ---\n${block.text}\n--- End ${block.label} ---`;
179 }
180
181 function baseName(path: string): string {
182 const clean = path.replace(/[\\/]+$/, "");
183 return clean.split(/[\\/]/).filter(Boolean).pop() ?? path;
184 }
185
186 function attachmentName(attachment: Attachment): string {
187 return (attachment.displayName || baseName(attachment.path) || "attachment").trim();
188 }
189
190 function attachmentExt(name: string): string {
191 const dot = name.lastIndexOf(".");
192 return dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
193 }
194
195 function hasImageAttachments(items: Attachment[]): boolean {
196 return items.some((attachment) => Boolean(attachment.previewUrl));
197 }
198
199 function displayRefName(name: string): string {
200 return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment";
201 }
202
203 function formatAttachmentDisplayReference(attachment: Attachment): string {
204 return `@[${displayRefName(attachmentName(attachment))}](${attachment.path})`;
205 }
206
207 function sortComposerAttachments(items: Attachment[]): Attachment[] {
208 return [...items].sort((a, b) => {
209 const ai = a.previewUrl ? 0 : 1;
210 const bi = b.previewUrl ? 0 : 1;
211 return ai - bi;
212 });
213 }
214
215 function workspaceReferenceKey(ref: WorkspaceReference): string {
216 return `${ref.isDir ? "dir" : "file"}:${ref.path}`;
217 }
218
219 type PastChatToken = {
220 from: number;
221 query: string;
222 };
223
224 function activePastChatToken(text: string): PastChatToken | null {
225 const queryText = text.replace(/[\r\n]+$/u, "");
226 const match = /(?:^|\s)#([^\s#]*)$/u.exec(queryText);
227 if (!match) return null;
228 return { from: match.index, query: match[1] };
229 }
230
231 export function composerPickFileEntry(
232 text: string,
233 atRaw: string | null,
234 atDir: string,
235 entry: DirEntry,
236 ): { text: string; workspaceRef?: WorkspaceReference } {
237 const queryText = text.replace(/[\r\n]+$/u, "");
238 const atPos = queryText.length - (atRaw?.length ?? 0) - 1; // index of '@'
239 const prefix = queryText.slice(0, Math.max(0, atPos));
240 const refPath = dirEntrySubmitPath(entry, atDir);
241 if (entry.path || entry.displayPath) {
242 return { text: prefix, workspaceRef: { path: refPath, isDir: entry.isDir, displayPath: entry.displayPath } };
243 }
244 // Inline fallback: escape whitespace so the ref survives @-token parsing.
245 return { text: prefix + "@" + escapeRefPath(refPath) + (entry.isDir ? "/" : " ") };
246 }
247
248 function emptyComposerDraft(): ComposerDraft {
249 return {
250 text: "",
251 invocations: [],
252 attachments: [],
253 workspaceRefs: [],
254 pastedBlocks: [],
255 openPastedLabels: [],
256 sessionRefs: [],
257 selectedTextRefs: [],
258 attachmentDedupKeys: {},
259 nextPasteId: 1,
260 historyIndex: -1,
261 savedText: "",
262 pendingGuidance: [],
263 guidanceExpanded: false,
264 guidanceSendingId: null,
265 pendingPaste: 0,
266 submitting: false,
267 };
268 }
269
270 // Exact (trimmed) equality only: the consumed-steer notice carries the steer
271 // text verbatim, and substring matching removed the wrong queue item when one
272 // queued text contained another (#6238).
273 function guidanceTextMatches(queued: string, consumed: string): boolean {
274 const left = queued.trim();
275 const right = consumed.trim();
276 if (!left || !right) return false;
277 return left === right;
278 }
279
280 function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
281 return {
282 text: draft.text,
283 invocations: draft.invocations.map((invocation) => ({ ...invocation, command: { ...invocation.command } })),
284 attachments: [...draft.attachments],
285 workspaceRefs: [...draft.workspaceRefs],
286 pastedBlocks: [...draft.pastedBlocks],
287 openPastedLabels: [...draft.openPastedLabels],
288 sessionRefs: [...draft.sessionRefs],
289 selectedTextRefs: draft.selectedTextRefs.map((reference) => ({ ...reference })),
290 attachmentDedupKeys: { ...draft.attachmentDedupKeys },
291 nextPasteId: draft.nextPasteId,
292 historyIndex: draft.historyIndex,
293 savedText: draft.savedText,
294 pendingGuidance: draft.pendingGuidance.map((item) => ({ ...item })),
295 guidanceExpanded: draft.guidanceExpanded,
296 guidanceSendingId: draft.guidanceSendingId,
297 pendingPaste: draft.pendingPaste,
298 submitting: draft.submitting,
299 };
300 }
301
302 function attachmentDedupFromKeys(keys: Record<string, AttachmentDedupKey>): DedupIndex {
303 const index = new DedupIndex();
304 for (const key of Object.values(keys)) {
305 index.add(key.hash, key.source);
306 }
307 return index;
308 }
309
310 function draftHasAttachmentDedupKey(draft: ComposerDraft, key: AttachmentDedupKey): boolean {
311 return Object.values(draft.attachmentDedupKeys).some((existing) => existing.hash === key.hash && existing.source === key.source);
312 }
313
314 function fileKey(file: File): string {
315 return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
316 }
317
318 function clipboardFiles(data: DataTransfer): File[] {
319 const files = Array.from(data.files);
320 const seen = new Set(files.map(fileKey));
321 for (const item of Array.from(data.items)) {
322 if (item.kind !== "file") continue;
323 const file = item.getAsFile();
324 if (!file) continue;
325 const key = fileKey(file);
326 if (seen.has(key)) continue;
327 seen.add(key);
328 files.push(file);
329 }
330 return files;
331 }
332
333 function clipboardHasImageHint(data: DataTransfer): boolean {
334 const imageType = (value: string) => {
335 const type = value.toLowerCase();
336 return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff");
337 };
338 return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType);
339 }
340
341 function isPasteShortcut(e: KeyboardEvent<HTMLElement>): boolean {
342 return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey;
343 }
344
345 async function dataURLHash(dataUrl: string): Promise<string> {
346 try {
347 const res = await fetch(dataUrl);
348 return sha256(await res.blob());
349 } catch {
350 return "";
351 }
352 }
353
354 function composerMaxHeight(): number {
355 if (typeof window === "undefined") return COMPOSER_MAX_HEIGHT;
356 return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
357 }
358
359 // The rendered card includes the run strip while a turn runs; subtract it to
360 // recover the user's logical height when measuring from the DOM.
361 function composerLogicalHeight(card: HTMLElement): number {
362 const strip = card.querySelector(".composer-run-strip");
363 const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
364 return card.getBoundingClientRect().height - stripHeight;
365 }
366
367 function clampComposerHeight(height: number): number {
368 return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
369 }
370
371 function composerAutoInputMaxHeight(extraReservedHeight = 0): number {
372 return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT - extraReservedHeight);
373 }
374
375 function loadComposerHeight(): number | null {
376 return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
377 }
378
379 function fmtElapsed(ms: number): string {
380 const s = Math.floor(ms / 1000);
381 if (s < 60) return `${s}s`;
382 return `${Math.floor(s / 60)}m ${s % 60}s`;
383 }
384
385 // --- past:chats hover preview helpers (PR-C2) ---
386 // Pure formatting helpers used by the past:chats list tooltip. They never read
387 // from disk, never call PreviewSession — they only shape the data that already
388 // lives in the SessionMeta snapshot we fetched on entry.
389 const PAST_CHAT_PREVIEW_MAX = 200;
390
391 function truncatePreview(value?: string, max = PAST_CHAT_PREVIEW_MAX): string {
392 const text = (value || "").trim();
393 if (text.length <= max) return text;
394 return `${text.slice(0, max)}...`;
395 }
396
397 function fmtSessionTime(value?: number): string {
398 if (!value) return "";
399 const d = new Date(value);
400 if (Number.isNaN(d.getTime())) return "";
401 const yyyy = d.getFullYear();
402 const mm = String(d.getMonth() + 1).padStart(2, "0");
403 const dd = String(d.getDate()).padStart(2, "0");
404 const hh = String(d.getHours()).padStart(2, "0");
405 const mi = String(d.getMinutes()).padStart(2, "0");
406 return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
407 }
408
409 function pastChatTitle(session: SessionMeta): string {
410 return session.title || session.topicTitle || session.preview || "Untitled";
411 }
412
413 function useTick(on: boolean): number {
414 const [, setN] = useState(0);
415 useEffect(() => {
416 if (!on) return;
417 const id = window.setInterval(() => setN((n) => n + 1), 1000);
418 return () => window.clearInterval(id);
419 }, [on]);
420 return Date.now();
421 }
422
423 function isImeKeyEvent(
424 e: KeyboardEvent<HTMLElement>,
425 composing: boolean,
426 lastCompositionEndAt: number,
427 ): boolean {
428 const native = e.nativeEvent as globalThis.KeyboardEvent & {
429 isComposing?: boolean;
430 keyCode?: number;
431 };
432 return (
433 composing ||
434 native.isComposing === true ||
435 native.keyCode === 229 ||
436 Date.now() - lastCompositionEndAt < IME_CONFIRM_GRACE_MS
437 );
438 }
439
440 // --- past:chats session reference → prompt context (PR-B) ---
441 // Send-side helpers for "@past:chats" session references. PR-A wired the menu and
442 // the composer-context card; this layer reads each referenced session through the
443 // existing PreviewSession API and prepends a compact user/assistant transcript to
444 // submitText so the model sees the referenced chat as background context.
445 const SESSION_REF_MAX_MESSAGES = 30;
446 const SESSION_REF_MAX_CHARS = 20_000;
447 const PAST_CHATS_MENU_ITEM = "past:chats";
448
449 // limitSessionMessages keeps the most recent useful messages within a char budget.
450 // Walks from the end so the truncation is always "drop the oldest", which matches
451 // the intuition that the latest turns are the relevant ones for follow-up.
452 function limitSessionMessages(
453 messages: HistoryMessage[],
454 maxMessages = SESSION_REF_MAX_MESSAGES,
455 maxChars = SESSION_REF_MAX_CHARS,
456 ): { messages: HistoryMessage[]; truncated: boolean } {
457 const useful = messages
458 .filter(
459 (m) =>
460 (m.role === "user" || m.role === "assistant") &&
461 typeof m.content === "string" &&
462 m.content.trim().length > 0,
463 )
464 .slice(-maxMessages);
465 const result: HistoryMessage[] = [];
466 let total = 0;
467 let truncated = useful.length >= maxMessages;
468 for (let i = useful.length - 1; i >= 0; i--) {
469 const msg = useful[i];
470 const content = msg.content.trim();
471 if (total + content.length > maxChars) {
472 truncated = true;
473 break;
474 }
475 result.unshift({ ...msg, content });
476 total += content.length;
477 }
478 if (result.length < useful.length) truncated = true;
479 return { messages: result, truncated };
480 }
481
482 // formatSessionContext renders one referenced session as a labelled transcript.
483 // Falls back to a "no usable messages" note when filtering empties the list so
484 // the model still sees that something was referenced.
485 function formatSessionContext(
486 ref: SessionReference,
487 messages: HistoryMessage[],
488 truncated: boolean,
489 t: Translator,
490 ): string {
491 const body = messages
492 .map((m) => `${m.role === "user" ? t("composer.sessionContextUser") : t("composer.sessionContextAssistant")}: ${m.content.trim()}`)
493 .join("\n\n");
494 return [
495 `[${t("composer.sessionContextSession", { title: ref.title })}]`,
496 truncated ? t("composer.sessionContextTruncated") : "",
497 body || t("composer.sessionContextEmpty"),
498 ]
499 .filter(Boolean)
500 .join("\n");
501 }
502
503 // buildSessionContext reads each referenced session, formats the most recent
504 // slice, and joins them with a separator. A single failed read must not block
505 // the others; a localized read-failure note marks the bad one and the
506 // remaining refs still flow through.
507 async function buildSessionContext(refs: SessionReference[], t: Translator): Promise<string> {
508 if (refs.length === 0) return "";
509 let context = `${t("composer.sessionContextHeader")}\n\n`;
510 for (const ref of refs) {
511 try {
512 const raw = await app.PreviewSession(ref.path);
513 const limited = limitSessionMessages(asArray(raw));
514 context += `${formatSessionContext(ref, limited.messages, limited.truncated, t)}\n\n---\n\n`;
515 } catch (error) {
516 console.error("[past:chats] failed to preview session", ref.path, error);
517 context += `[${t("composer.sessionContextSession", { title: ref.title })}]\n${t("composer.sessionContextReadFailed")}\n\n---\n\n`;
518 }
519 }
520 context += `${t("composer.sessionContextFooter")}\n`;
521 return context;
522 }
523
524 export function Composer({
525 running,
526 collaborationMode,
527 toolApprovalMode,
528 tokenMode,
529 goal,
530 goalStatus,
531 goalRuntime,
532 cwd,
533 modelLabel,
534 imageInputEnabled = true,
535 tabId,
536 effort,
537 onSend,
538 onSteer,
539 onCancel,
540 onCycleMode,
541 onSetMode,
542 onSetCollaborationMode,
543 onSetToolApprovalMode,
544 onToggleYoloApprovalMode,
545 onClearGoal,
546 onPauseGoal,
547 onResumeGoal,
548 onSwitchModel,
549 onSetEffort,
550 onSetTokenMode,
551 insertRequest,
552 selectedTextRequest,
553 disabled,
554 submitDisabled = false,
555 readOnly = false,
556 decisionPending = false,
557 ready,
558 turnStartAt,
559 turnWaitAccumMs = 0,
560 promptWaitStartedAt,
561 turnTokens,
562 turnArgChars = 0,
563 retry,
564 suspendedByDecision = false,
565 pendingApprovalLabel,
566 pendingAsk = false,
567 transientDismissSignal,
568 sessionKey,
569 workspaceScopeKey,
570 fileRefRefreshKey,
571 guidanceConsumedKey,
572 guidanceConsumedText,
573 guidanceQueuePreviewItems,
574 showContextWindowRing = false,
575 heroMode = false,
576 context,
577 turnCost,
578 currency,
579 cacheHitTokens,
580 cacheMissTokens,
581 balance,
582 onInvocationMetadataChange,
583 }: {
584 running: boolean;
585 collaborationMode: CollaborationMode;
586 toolApprovalMode: ToolApprovalMode;
587 tokenMode: TokenMode;
588 goal?: string;
589 goalStatus?: string;
590 goalRuntime?: GoalRuntime;
591 cwd?: string;
592 modelLabel: string;
593 imageInputEnabled?: boolean;
594 tabId?: string;
595 effort?: EffortInfo;
596 onSend: (displayText: string, submitText?: string, tabId?: string, structured?: StructuredInvocationSubmit) => void | Promise<void>;
597 onInvocationMetadataChange?: (metadata: Record<string, { kind: "skill" | "subagent"; color?: string }>) => void;
598 onSteer?: (submitText: string, tabId?: string) => void | Promise<void>;
599 // Returns the un-sent text when cancelling before the server replied (so it can
600 // be restored to the input); undefined for a normal cancel.
601 onCancel: () => string | undefined;
602 onCycleMode: () => void;
603 onSetMode: (mode: Mode) => void;
604 onSetCollaborationMode: (mode: CollaborationMode) => void;
605 onSetToolApprovalMode: (mode: ToolApprovalMode) => void;
606 onToggleYoloApprovalMode: () => void;
607 onClearGoal: () => void;
608 onPauseGoal: () => void;
609 onResumeGoal: () => void;
610 onSwitchModel: (name: string) => boolean | Promise<boolean>;
611 onSetEffort: (level: string) => void;
612 onSetTokenMode: (mode: TokenMode) => void;
613 insertRequest?: ComposerInsertRequest | null;
614 selectedTextRequest?: SelectedTextInsertRequest | null;
615 disabled?: boolean;
616 submitDisabled?: boolean;
617 readOnly?: boolean;
618 decisionPending?: boolean;
619 // ready/cwd/running/workspaceScopeKey re-trigger the command fetch: Commands() returns only
620 // built-ins until boot.Build finishes (the controller, hence skills/custom/MCP,
621 // is nil before then), the available set changes when the workspace switches,
622 // and a completed turn may have installed skills or MCP prompts.
623 ready?: boolean;
624 turnStartAt?: number;
625 // Tab-scoped user-wait from the controller (approval/ask). Counts while the
626 // tab is in the background so Composer does not invent a wait start on focus.
627 turnWaitAccumMs?: number;
628 promptWaitStartedAt?: number;
629 turnTokens?: number;
630 // Streaming tool-call argument chars (no usage event yet) — folded into the
631 // pill as an estimated-token tail so a long write_file body reads as
632 // progress, not a stall.
633 turnArgChars?: number;
634 retry?: { attempt: number; max: number };
635 // True while a footer decision surface (approval / ask / clear context) owns
636 // the UI. Pauses the model-work ticker without rendering a "waiting approval"
637 // run strip (the decision card already conveys that state).
638 suspendedByDecision?: boolean;
639 // Legacy strip labels kept for isolated unit tests; App prefers
640 // suspendedByDecision so the decision card is not duplicated in the strip.
641 pendingApprovalLabel?: string | null;
642 pendingAsk?: boolean;
643 transientDismissSignal?: number;
644 sessionKey?: string;
645 workspaceScopeKey?: string;
646 fileRefRefreshKey?: number | string;
647 guidanceConsumedKey?: string;
648 guidanceConsumedText?: string;
649 guidanceQueuePreviewItems?: readonly string[];
650 showContextWindowRing?: boolean;
651 // Creation empty-session hero: slim centered composer under the welcome
652 // headline (hides task/profile/approval chrome; keeps model + effort).
653 heroMode?: boolean;
654 context?: ContextInfo;
655 turnCost?: number;
656 currency?: string;
657 cacheHitTokens?: number;
658 cacheMissTokens?: number;
659 balance?: BalanceInfo;
660 }) {
661 const { t, locale } = useI18n();
662 const { showToast } = useToast();
663 const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []);
664 const sendComboLabel = useShortcutComboLabel("composer.send");
665 const undoComboLabel = useShortcutComboLabel("composer.undo");
666 const redoComboLabel = useShortcutComboLabel("composer.redo");
667 const yoloComboLabel = useShortcutComboLabel("toolApproval.yolo");
668 const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
669 const now = useTick(running);
670 const [text, setText] = useState("");
671 const [attachments, setAttachments] = useState<Attachment[]>([]);
672 const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
673 const openComposerImageViewer = useCallback((url: string, name: string) => {
674 setImageViewer({ open: true, url, name });
675 }, []);
676
677 const closeComposerImageViewer = useCallback(() => {
678 setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
679 }, []);
680
681 const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
682 const [invocations, setInvocations] = useState<ComposerInvocation[]>([]);
683 const [plainSelection, setPlainSelection] = useState<RichComposerSelection>({ start: 0, end: 0 });
684 const [richSelection, setRichSelection] = useState<RichComposerSelection>({ start: 0, end: 0 });
685 const [richSlashQuery, setRichSlashQuery] = useState<RichSlashQuery | null>(null);
686 const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
687 const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
688 const [pendingPaste, setPendingPaste] = useState(0);
689 const pendingPasteRef = useRef(0);
690 const pastedBlocksRef = useRef<PastedBlock[]>([]);
691 const nextPasteId = useRef(1);
692 const nextInvocationId = useRef(1);
693 const [active, setActive] = useState(0);
694 const [dismissed, setDismissed] = useState(false);
695 const [dragOver, setDragOver] = useState(false);
696 const [composerHeight, setComposerHeight] = useState<number | null>(loadComposerHeight);
697 const [composerResizing, setComposerResizing] = useState(false);
698 const [textareaAutoHeight, setTextareaAutoHeight] = useState<number | null>(null);
699 const [textareaAutoOverflow, setTextareaAutoOverflow] = useState(false);
700 const [intentMenuOpen, setIntentMenuOpen] = useState(false);
701 const [intentMenuClosing, setIntentMenuClosing] = useState(false);
702 const [profileMenuOpen, setProfileMenuOpen] = useState(false);
703 const [profileMenuClosing, setProfileMenuClosing] = useState(false);
704 const [moreMenuOpen, setMoreMenuOpen] = useState(false);
705 const [moreMenuClosing, setMoreMenuClosing] = useState(false);
706 const [contentMenuOpen, setContentMenuOpen] = useState(false);
707 const [showPastChats, setShowPastChats] = useState(false);
708 const [directPastChats, setDirectPastChats] = useState(false);
709 const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
710 const [pastChatQuery, setPastChatQuery] = useState("");
711 const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
712 const [selectedTextRefs, setSelectedTextRefs] = useState<SelectedTextReference[]>([]);
713 const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
714 const [guidanceExpanded, setGuidanceExpanded] = useState(false);
715 const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
716 const [guidanceRetryNonce, setGuidanceRetryNonce] = useState(0);
717 const [guidanceDraftKey, setGuidanceDraftKey] = useState(draftKey);
718 const pendingGuidanceRef = useRef<PendingGuidance[]>([]);
719 const guidanceExpandedRef = useRef(false);
720 const guidanceSendingIdRef = useRef<number | null>(null);
721 const nextGuidanceId = useRef(1);
722 const [loadingPastChats, setLoadingPastChats] = useState(false);
723 const [submitting, setSubmitting] = useState(false);
724 const [inputMenuPoint, setInputMenuPoint] = useState<ContextMenuPoint | null>(null);
725 const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
726 // Prompt history navigation (plain ↑/↓)
727 // Use refs for values read inside async closures to avoid stale captures
728 // on rapid key presses (the React closure trap).
729 const historyIndexRef = useRef(-1);
730 const historyEntriesRef = useRef<PromptHistoryEntry[]>([]);
731 const historyLoadRef = useRef<Promise<void> | null>(null);
732 const historyGenerationRef = useRef(cacheGeneration());
733 // historyIndex state is written (via setHistoryIndex) for potential future
734 // UI feedback (e.g. "3/200" indicator); currently unused in render.
735 const [, setHistoryIndex] = useState(-1);
736 const savedTextRef = useRef("");
737 const taRef = useRef<HTMLTextAreaElement>(null);
738 const richInputRef = useRef<RichComposerInputHandle>(null);
739 const fileInputRef = useRef<HTMLInputElement>(null);
740 const editHistoryByDraftRef = useRef<Record<string, ComposerEditHistory>>({});
741 const pendingNativeInputTypeRef = useRef<string | undefined>(undefined);
742 const composerCardRef = useRef<HTMLDivElement>(null);
743 const composerWrapRef = useRef<HTMLDivElement>(null);
744 const contentMenuAnchorRef = useRef<HTMLButtonElement>(null);
745 const intentMenuAnchorRef = useRef<HTMLButtonElement>(null);
746 const profileMenuAnchorRef = useRef<HTMLButtonElement>(null);
747 const moreMenuAnchorRef = useRef<HTMLButtonElement>(null);
748 const intentCloseTimerRef = useRef<number | null>(null);
749 const profileCloseTimerRef = useRef<number | null>(null);
750 const moreCloseTimerRef = useRef<number | null>(null);
751 // Creation chrome: hover-open task/profile menus (same pattern as ContextWindowRing).
752 const intentHoverTimerRef = useRef<number | null>(null);
753 const profileHoverTimerRef = useRef<number | null>(null);
754 const creationChrome = showContextWindowRing;
755 const wasRunningByDraftRef = useRef<Record<string, boolean>>({ [draftKey]: running });
756 const composingRef = useRef(false);
757 const lastCompositionEndAt = useRef(0);
758 const lastSelectionRef = useRef({ start: 0, end: 0 });
759 const consumedInsertIdByDraftRef = useRef<Record<string, number>>({});
760 const consumedSelectedTextIdByDraftRef = useRef<Record<string, number>>({});
761 const lastTransientDismissSignal = useRef(transientDismissSignal);
762 const lastGuidanceConsumedKeyByDraftRef = useRef<Record<string, string | undefined>>(
763 guidanceConsumedKey ? { [draftKey]: guidanceConsumedKey } : {},
764 );
765 const selfDispatchedGuidanceByDraftRef = useRef<Record<string, string[]>>({});
766 const submittingRef = useRef(false);
767 const nativeClipboardPasteTimerRef = useRef<number | null>(null);
768 // Snapshot of the current cwd so async callbacks (openPastChats) can detect
769 // workspace switches and discard stale responses (issue #3601).
770 const cwdRef = useRef(cwd);
771 cwdRef.current = cwd;
772 const attachmentDedupRef = useRef(new DedupIndex());
773 const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
774 const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
775 const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
776 const activeDraftKeyRef = useRef(draftKey);
777 const draftActivationEpochRef = useRef(0);
778 const textRef = useRef(text);
779 const invocationsRef = useRef(invocations);
780 const attachmentsRef = useRef(attachments);
781 const workspaceRefsRef = useRef(workspaceRefs);
782 const openPastedLabelsRef = useRef(openPastedLabels);
783 const sessionRefsRef = useRef(sessionRefs);
784 const selectedTextRefsRef = useRef(selectedTextRefs);
785 textRef.current = text;
786 invocationsRef.current = invocations;
787 attachmentsRef.current = attachments;
788 workspaceRefsRef.current = workspaceRefs;
789 pastedBlocksRef.current = pastedBlocks;
790 openPastedLabelsRef.current = openPastedLabels;
791 sessionRefsRef.current = sessionRefs;
792 selectedTextRefsRef.current = selectedTextRefs;
793 pendingGuidanceRef.current = pendingGuidance;
794 guidanceExpandedRef.current = guidanceExpanded;
795 guidanceSendingIdRef.current = guidanceSendingId;
796 pendingPasteRef.current = pendingPaste;
797 submittingRef.current = submitting;
798
799 const snapshotComposerDraft = (): ComposerDraft => ({
800 text: textRef.current,
801 invocations: invocationsRef.current.map((invocation) => ({ ...invocation, command: { ...invocation.command } })),
802 attachments: [...attachmentsRef.current],
803 workspaceRefs: [...workspaceRefsRef.current],
804 pastedBlocks: [...pastedBlocksRef.current],
805 openPastedLabels: [...openPastedLabelsRef.current],
806 sessionRefs: [...sessionRefsRef.current],
807 selectedTextRefs: selectedTextRefsRef.current.map((reference) => ({ ...reference })),
808 attachmentDedupKeys: { ...attachmentDedupKeysRef.current },
809 nextPasteId: nextPasteId.current,
810 historyIndex: historyIndexRef.current,
811 savedText: savedTextRef.current,
812 pendingGuidance: pendingGuidanceRef.current.map((item) => ({ ...item })),
813 guidanceExpanded: guidanceExpandedRef.current,
814 guidanceSendingId: guidanceSendingIdRef.current,
815 pendingPaste: pendingPasteRef.current,
816 submitting: submittingRef.current,
817 });
818
819 const restoreComposerDraft = (draft: ComposerDraft) => {
820 const next = cloneComposerDraft(draft);
821 textRef.current = next.text;
822 invocationsRef.current = next.invocations;
823 attachmentsRef.current = next.attachments;
824 workspaceRefsRef.current = next.workspaceRefs;
825 openPastedLabelsRef.current = next.openPastedLabels;
826 sessionRefsRef.current = next.sessionRefs;
827 selectedTextRefsRef.current = next.selectedTextRefs;
828 setText(next.text);
829 setInvocations(next.invocations);
830 setAttachments(next.attachments);
831 setWorkspaceRefs(next.workspaceRefs);
832 pastedBlocksRef.current = next.pastedBlocks;
833 setPastedBlocks(next.pastedBlocks);
834 setOpenPastedLabels(next.openPastedLabels);
835 setSessionRefs(next.sessionRefs);
836 setSelectedTextRefs(next.selectedTextRefs);
837 attachmentDedupKeysRef.current = next.attachmentDedupKeys;
838 attachmentDedupRef.current = attachmentDedupFromKeys(next.attachmentDedupKeys);
839 nextPasteId.current = next.nextPasteId;
840 historyIndexRef.current = next.historyIndex;
841 savedTextRef.current = next.savedText;
842 pendingGuidanceRef.current = next.pendingGuidance;
843 guidanceExpandedRef.current = next.guidanceExpanded;
844 guidanceSendingIdRef.current = next.guidanceSendingId;
845 pendingPasteRef.current = next.pendingPaste;
846 submittingRef.current = next.submitting;
847 setPendingGuidance(next.pendingGuidance);
848 setGuidanceExpanded(next.guidanceExpanded);
849 setGuidanceSendingId(next.guidanceSendingId);
850 setPendingPaste(next.pendingPaste);
851 setSubmitting(next.submitting);
852 setHistoryIndex(next.historyIndex);
853 const restoredSelection = { start: next.text.length, end: next.text.length };
854 lastSelectionRef.current = restoredSelection;
855 setPlainSelection(restoredSelection);
856 setRichSelection(restoredSelection);
857 setRichSlashQuery(
858 next.invocations.length > 0
859 ? slashQueryAt(next.text, restoredSelection)
860 : null,
861 );
862 setComposerPrompt(null);
863 setShowPastChats(false);
864 setDirectPastChats(false);
865 setContentMenuOpen(false);
866 setPastChatQuery("");
867 setLoadingPastChats(false);
868 setActive(0);
869 setInputMenuPoint(null);
870 setDragOver(false);
871 setImageViewer((current) => current.open ? { ...current, open: false } : current);
872 setIntentMenuOpen(false);
873 setIntentMenuClosing(false);
874 setMoreMenuOpen(false);
875 setMoreMenuClosing(false);
876 };
877
878 const composerEditSnapshot = (
879 targetDraftKey: string,
880 selection?: RichComposerSelection,
881 ): ComposerEditSnapshot => {
882 if (targetDraftKey === activeDraftKeyRef.current) {
883 return {
884 text: textRef.current,
885 invocations: invocationsRef.current.map((invocation) => ({ ...invocation, command: { ...invocation.command } })),
886 pastedBlocks: [...pastedBlocksRef.current],
887 openPastedLabels: [...openPastedLabelsRef.current],
888 nextPasteId: nextPasteId.current,
889 selection: selection ?? getComposerSelection(),
890 };
891 }
892 const draft = draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft();
893 const start = Math.min(selection?.start ?? draft.text.length, draft.text.length);
894 return {
895 text: draft.text,
896 invocations: draft.invocations.map((invocation) => ({ ...invocation, command: { ...invocation.command } })),
897 pastedBlocks: [...draft.pastedBlocks],
898 openPastedLabels: [...draft.openPastedLabels],
899 nextPasteId: draft.nextPasteId,
900 selection: {
901 start,
902 end: Math.min(selection?.end ?? start, draft.text.length),
903 afterInvocationId: selection?.afterInvocationId,
904 },
905 };
906 };
907
908 const composerEditStateMatches = (left: ComposerEditSnapshot, right: ComposerEditSnapshot): boolean =>
909 left.text === right.text
910 && left.nextPasteId === right.nextPasteId
911 && JSON.stringify(left.invocations) === JSON.stringify(right.invocations)
912 && JSON.stringify(left.pastedBlocks) === JSON.stringify(right.pastedBlocks);
913
914 const editHistoryForDraft = (targetDraftKey: string): ComposerEditHistory => {
915 const existing = editHistoryByDraftRef.current[targetDraftKey];
916 if (existing) return existing;
917 const created: ComposerEditHistory = {
918 undo: [],
919 redo: [],
920 undoNativeBarrier: false,
921 redoNativeBarrier: false,
922 };
923 editHistoryByDraftRef.current[targetDraftKey] = created;
924 return created;
925 };
926
927 const clearComposerEditHistory = (targetDraftKey: string) => {
928 delete editHistoryByDraftRef.current[targetDraftKey];
929 };
930
931 const syncComposerNativeHistory = (targetDraftKey: string, inputType?: string) => {
932 const history = editHistoryByDraftRef.current[targetDraftKey];
933 if (!history) return;
934 const current = composerEditSnapshot(targetDraftKey);
935 const undoTransaction = history.undo[history.undo.length - 1];
936 const redoTransaction = history.redo[history.redo.length - 1];
937
938 if (inputType === "historyUndo") {
939 history.undoNativeBarrier = Boolean(
940 undoTransaction && !composerEditStateMatches(current, undoTransaction.after),
941 );
942 // The browser has just created at least one native redo unit. Keep it
943 // ahead of any older custom redo transaction until historyRedo reaches
944 // that transaction's boundary again.
945 history.redoNativeBarrier = history.undo.length > 0 || history.redo.length > 0;
946 return;
947 }
948
949 if (inputType === "historyRedo") {
950 history.undoNativeBarrier = Boolean(
951 undoTransaction && !composerEditStateMatches(current, undoTransaction.after),
952 );
953 if (redoTransaction) {
954 history.redoNativeBarrier = !composerEditStateMatches(current, redoTransaction.before);
955 } else if (undoTransaction) {
956 history.redoNativeBarrier = !composerEditStateMatches(current, undoTransaction.after);
957 } else {
958 history.redoNativeBarrier = false;
959 }
960 return;
961 }
962
963 // A new browser edit sits above the latest custom transaction even when
964 // its net text later returns to the same value (type then Backspace).
965 history.undoNativeBarrier = history.undo.length > 0;
966 history.redo = [];
967 history.redoNativeBarrier = false;
968 };
969
970 const recordComposerEdit = (
971 targetDraftKey: string,
972 before: ComposerEditSnapshot,
973 after: ComposerEditSnapshot,
974 ) => {
975 if (composerEditStateMatches(before, after)) return;
976 const history = editHistoryForDraft(targetDraftKey);
977 const previous = history.undo[history.undo.length - 1];
978 const nativeBarrierBefore = Boolean(
979 previous
980 && (
981 history.undoNativeBarrier
982 || !composerEditStateMatches(before, previous.after)
983 ),
984 );
985 history.undo.push({
986 before,
987 after,
988 nativeBarrierBefore,
989 nativeBarrierAfter: false,
990 });
991 if (history.undo.length > MAX_COMPOSER_EDIT_HISTORY) history.undo.shift();
992 history.redo = [];
993 history.undoNativeBarrier = false;
994 history.redoNativeBarrier = false;
995 };
996
997 const restoreComposerEdit = (targetDraftKey: string, snapshot: ComposerEditSnapshot) => {
998 const invocations = snapshot.invocations.map((invocation) => ({ ...invocation, command: { ...invocation.command } }));
999 const pastedBlocks = [...snapshot.pastedBlocks];
1000 const openPastedLabels = [...snapshot.openPastedLabels];
1001 if (targetDraftKey !== activeDraftKeyRef.current) {
1002 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1003 draft.text = snapshot.text;
1004 draft.invocations = invocations;
1005 draft.pastedBlocks = pastedBlocks;
1006 draft.openPastedLabels = openPastedLabels;
1007 draft.nextPasteId = snapshot.nextPasteId;
1008 draftsBySessionRef.current[targetDraftKey] = draft;
1009 return;
1010 }
1011 textRef.current = snapshot.text;
1012 invocationsRef.current = invocations;
1013 pastedBlocksRef.current = pastedBlocks;
1014 openPastedLabelsRef.current = openPastedLabels;
1015 nextPasteId.current = snapshot.nextPasteId;
1016 setText(snapshot.text);
1017 setInvocations(invocations);
1018 setPastedBlocks(pastedBlocks);
1019 setOpenPastedLabels(openPastedLabels);
1020 setComposerPrompt(null);
1021 resetPromptHistoryNavigation();
1022 setComposerSelection(
1023 snapshot.selection.start,
1024 snapshot.selection.end,
1025 snapshot.selection.afterInvocationId,
1026 );
1027 };
1028
1029 const canUndoComposerEdit = (targetDraftKey: string): boolean => {
1030 const history = editHistoryByDraftRef.current[targetDraftKey];
1031 if (!history || history.undoNativeBarrier) return false;
1032 const transaction = history.undo[history.undo.length - 1];
1033 return Boolean(
1034 transaction
1035 && composerEditStateMatches(composerEditSnapshot(targetDraftKey), transaction.after),
1036 );
1037 };
1038
1039 const undoComposerEdit = (targetDraftKey: string): boolean => {
1040 const history = editHistoryByDraftRef.current[targetDraftKey];
1041 if (!history || !canUndoComposerEdit(targetDraftKey)) return false;
1042 const transaction = history.undo.pop();
1043 if (!transaction) return false;
1044 transaction.nativeBarrierAfter = history.redoNativeBarrier;
1045 history.redo.push(transaction);
1046 restoreComposerEdit(targetDraftKey, transaction.before);
1047 const previous = history.undo[history.undo.length - 1];
1048 history.undoNativeBarrier = Boolean(previous && transaction.nativeBarrierBefore);
1049 history.redoNativeBarrier = false;
1050 return true;
1051 };
1052
1053 const canRedoComposerEdit = (targetDraftKey: string): boolean => {
1054 const history = editHistoryByDraftRef.current[targetDraftKey];
1055 if (!history || history.redoNativeBarrier) return false;
1056 const transaction = history.redo[history.redo.length - 1];
1057 return Boolean(
1058 transaction
1059 && composerEditStateMatches(composerEditSnapshot(targetDraftKey), transaction.before),
1060 );
1061 };
1062
1063 const redoComposerEdit = (targetDraftKey: string): boolean => {
1064 const history = editHistoryByDraftRef.current[targetDraftKey];
1065 if (!history || !canRedoComposerEdit(targetDraftKey)) return false;
1066 const transaction = history.redo.pop();
1067 if (!transaction) return false;
1068 history.undo.push(transaction);
1069 restoreComposerEdit(targetDraftKey, transaction.after);
1070 history.undoNativeBarrier = false;
1071 const next = history.redo[history.redo.length - 1];
1072 history.redoNativeBarrier = transaction.nativeBarrierAfter
1073 || Boolean(next && next.nativeBarrierBefore);
1074 return true;
1075 };
1076
1077 const updatePendingGuidanceForDraft = (
1078 targetDraftKey: string,
1079 update: (items: PendingGuidance[]) => PendingGuidance[],
1080 ) => {
1081 if (targetDraftKey === activeDraftKeyRef.current) {
1082 const next = update(pendingGuidanceRef.current);
1083 pendingGuidanceRef.current = next;
1084 setPendingGuidance(next);
1085 return;
1086 }
1087 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1088 draft.pendingGuidance = update(draft.pendingGuidance);
1089 draftsBySessionRef.current[targetDraftKey] = draft;
1090 };
1091
1092 const updateGuidanceSendingIdForDraft = (targetDraftKey: string, next: number | null) => {
1093 if (targetDraftKey === activeDraftKeyRef.current) {
1094 guidanceSendingIdRef.current = next;
1095 setGuidanceSendingId(next);
1096 return;
1097 }
1098 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1099 draft.guidanceSendingId = next;
1100 draftsBySessionRef.current[targetDraftKey] = draft;
1101 };
1102
1103 const updatePendingPasteForDraft = (targetDraftKey: string, delta: number) => {
1104 if (targetDraftKey === activeDraftKeyRef.current) {
1105 const next = Math.max(0, pendingPasteRef.current + delta);
1106 pendingPasteRef.current = next;
1107 setPendingPaste(next);
1108 return;
1109 }
1110 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1111 draft.pendingPaste = Math.max(0, draft.pendingPaste + delta);
1112 draftsBySessionRef.current[targetDraftKey] = draft;
1113 };
1114
1115 const updateSubmittingForDraft = (targetDraftKey: string, next: boolean) => {
1116 if (targetDraftKey === activeDraftKeyRef.current) {
1117 submittingRef.current = next;
1118 setSubmitting(next);
1119 return;
1120 }
1121 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1122 draft.submitting = next;
1123 draftsBySessionRef.current[targetDraftKey] = draft;
1124 };
1125
1126 const draftIsSubmitting = (targetDraftKey: string): boolean =>
1127 targetDraftKey === activeDraftKeyRef.current
1128 ? submittingRef.current
1129 : Boolean(draftsBySessionRef.current[targetDraftKey]?.submitting);
1130
1131 const draftHasPendingPaste = (targetDraftKey: string): boolean =>
1132 targetDraftKey === activeDraftKeyRef.current
1133 ? pendingPasteRef.current > 0
1134 : (draftsBySessionRef.current[targetDraftKey]?.pendingPaste ?? 0) > 0;
1135
1136 useLayoutEffect(() => {
1137 const previousKey = activeDraftKeyRef.current;
1138 if (previousKey === draftKey) return;
1139 draftsBySessionRef.current[previousKey] = snapshotComposerDraft();
1140 draftActivationEpochRef.current += 1;
1141 activeDraftKeyRef.current = draftKey;
1142 setGuidanceDraftKey(draftKey);
1143 restoreComposerDraft(draftsBySessionRef.current[draftKey] ?? emptyComposerDraft());
1144 }, [draftKey]);
1145
1146 useEffect(() => {
1147 return () => {
1148 draftsBySessionRef.current[activeDraftKeyRef.current] = snapshotComposerDraft();
1149 };
1150 }, []);
1151
1152 const clearNativeClipboardPasteTimer = () => {
1153 if (nativeClipboardPasteTimerRef.current === null) return;
1154 window.clearTimeout(nativeClipboardPasteTimerRef.current);
1155 nativeClipboardPasteTimerRef.current = null;
1156 };
1157
1158 useEffect(() => () => clearNativeClipboardPasteTimer(), []);
1159
1160 useEffect(() => {
1161 const wasRunning = wasRunningByDraftRef.current[draftKey] ?? running;
1162 if (wasRunning && !running) {
1163 setGuidanceExpanded(false);
1164 if (text.trim() === "") {
1165 pastedBlocksRef.current = [];
1166 setPastedBlocks([]);
1167 setOpenPastedLabels([]);
1168 }
1169 }
1170 wasRunningByDraftRef.current[draftKey] = running;
1171 }, [draftKey, running, text]);
1172
1173 // A message queued while a turn was running (without the explicit "guide"
1174 // steer click) is the user's next turn, not scratch text to discard — send
1175 // it once the turn is done. Gated on submitDisabled, not just running:
1176 // if the turn ends while the controller is still activating/hydrating,
1177 // App's onSend silently no-ops on !controllerReady, but sendQueuedGuidance
1178 // still removes the item as if it had sent — so wait for submitDisabled to
1179 // clear instead of firing into that no-op window (#6210 follow-up). Once
1180 // both conditions hold, a successful send removes the head and starts a
1181 // new turn, which flips `running` true then false again, re-running this
1182 // effect to drain the shelf one item at a time; a failed send is left in
1183 // place (dismissible via the trash button) rather than silently dropped.
1184 // guidanceDraftKey identifies which session the rendered queue belongs to:
1185 // during a tab switch React still renders once with the previous queue, and
1186 // that stale render must never submit through the new session's onSend.
1187 useEffect(() => {
1188 // Never auto-send guidance while a decision surface owns the footer —
1189 // the draft must stay intact until the user finishes the decision.
1190 if (guidanceDraftKey !== draftKey || running || submitDisabled || suspendedByDecision) return;
1191 const next = pendingGuidance[0];
1192 if (next) void sendQueuedGuidance(next, draftKey);
1193 }, [draftKey, guidanceDraftKey, guidanceRetryNonce, running, submitDisabled, pendingGuidance, suspendedByDecision]);
1194
1195 useEffect(() => {
1196 if (guidanceDraftKey !== draftKey || !running || !guidanceQueuePreviewKey) return;
1197 setGuidanceExpanded(false);
1198 updatePendingGuidanceForDraft(
1199 draftKey,
1200 () =>
1201 guidanceQueuePreviewKey
1202 .split("\n")
1203 .map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
1204 );
1205 }, [draftKey, guidanceDraftKey, guidanceQueuePreviewKey, running]);
1206
1207 useEffect(() => {
1208 if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
1209 }, [guidanceExpanded, pendingGuidance.length]);
1210
1211 // --- slash commands ---
1212 const [commands, setCommands] = useState<CommandInfo[]>([]);
1213 useEffect(() => {
1214 let live = true;
1215 app.Commands()
1216 .then((next) => {
1217 if (live) setCommands(asArray(next));
1218 })
1219 .catch(() => {});
1220 return () => {
1221 live = false;
1222 };
1223 }, [ready, cwd, running, workspaceScopeKey]);
1224 useEffect(() => {
1225 onInvocationMetadataChange?.(Object.fromEntries(
1226 commands
1227 .filter(commandUsesStructuredInvocation)
1228 .map((command) => [command.name, {
1229 kind: command.kind === "subagent" ? "subagent" : "skill",
1230 color: command.color,
1231 }]),
1232 ));
1233 }, [commands, onInvocationMetadataChange]);
1234
1235 const slashText = useMemo(() => text.replace(/[\r\n]+$/u, ""), [text]);
1236 const plainSlashQuery = useMemo(() => slashQueryAt(slashText, {
1237 start: Math.min(plainSelection.start, slashText.length),
1238 end: Math.min(plainSelection.end, slashText.length),
1239 }), [plainSelection, slashText]);
1240 const activeSlashQuery = invocations.length > 0 ? richSlashQuery : plainSlashQuery;
1241 const slashQuery = activeSlashQuery?.query ?? null;
1242 const slashMatches = useMemo(
1243 () => slashQuery === null
1244 ? []
1245 : sortSlashCommandsForMenu(commands.filter((c) => c.name.toLowerCase().includes(slashQuery))),
1246 [slashQuery, commands],
1247 );
1248 const slashCommandAtStart = Boolean(
1249 activeSlashQuery
1250 && invocations.length === 0
1251 && slashText.slice(0, activeSlashQuery.from).trim() === "",
1252 );
1253 const slashCommandDisabled = useCallback(
1254 (command: CommandInfo) => !commandAvailableAtSlashPosition(command, slashCommandAtStart),
1255 [slashCommandAtStart],
1256 );
1257 const slashSelectableIndices = useMemo(
1258 () => slashMatches.flatMap((command, index) => slashCommandDisabled(command) ? [] : [index]),
1259 [slashCommandDisabled, slashMatches],
1260 );
1261 const slashQueryKey = activeSlashQuery
1262 ? `${activeSlashQuery.from}:${activeSlashQuery.to}:${activeSlashQuery.query}`
1263 : "";
1264
1265 // --- slash argument completion ("/cmd <args>") --- mirrors the CLI: once past
1266 // the command word, the backend suggests sub-commands (/skill → list/show/…,
1267 // /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. Debounced
1268 // by 120ms so rapid typing doesn't flood the backend with IPC calls — the
1269 // menu only updates after the user pauses.
1270 const [argRes, setArgRes] = useState<SlashArgsResult | null>(null);
1271 const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
1272 useEffect(() => {
1273 if (invocations.length > 0 || !slashText.startsWith("/") || !/\s/.test(slashText)) {
1274 setArgRes(null);
1275 return;
1276 }
1277 let live = true;
1278 clearTimeout(debounceRef.current);
1279 debounceRef.current = setTimeout(() => {
1280 app
1281 .SlashArgs(slashText)
1282 .then((r) => {
1283 if (!live) return;
1284 // Drop suggestions that wouldn't change the input — the token is already
1285 // fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
1286 // lingers on a complete command and Enter keeps "accepting" a no-op
1287 // instead of sending. (Defense-in-depth: the backend filters these too.)
1288 // r.items can arrive as null (an empty Go slice serializes to JSON null),
1289 // so guard before filtering — otherwise the throw is swallowed and the
1290 // stale menu from the previous keystroke lingers (the /skill list bug).
1291 const items = asArray(r?.items);
1292 const from = r?.from ?? 0;
1293 const useful = items.filter((it) => slashText.slice(0, from) + it.insert !== slashText);
1294 setArgRes(useful.length > 0 ? { items: useful, from } : null);
1295 setActive(0);
1296 })
1297 .catch(() => {});
1298 }, 120);
1299 return () => {
1300 live = false;
1301 clearTimeout(debounceRef.current);
1302 };
1303 }, [invocations.length, slashText]);
1304
1305 // --- @ file references (token at the end of the text) ---
1306 // atRaw is everything after a trailing "@token"; atDir is its path up to the
1307 // last "/", atFrag the part after. The menu lists one directory level (atDir)
1308 // and filters by atFrag — descending one level per pick.
1309 const activeAtToken = useMemo(() => activeFileReferenceToken(text), [text]);
1310 const atRaw = activeAtToken?.raw ?? null;
1311 const atDir = activeAtToken?.dir ?? "";
1312 const atFrag = activeAtToken?.frag ?? "";
1313 const pastChatToken = useMemo(() => activePastChatToken(text), [text]);
1314 const pastChatTokenQuery = pastChatToken?.query ?? null;
1315
1316 const [entries, setEntries] = useState<DirEntry[]>([]);
1317 const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
1318 const dirCache = useRef<Record<string, DirEntry[]>>({});
1319 const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
1320 const fileRefTabId = tabId ?? "";
1321 const fileRefScopeKey = workspaceScopeKey ?? `${fileRefTabId}\u0000${cwd ?? ""}`;
1322
1323 const clearFileRefState = useCallback(() => {
1324 dirCache.current = {};
1325 searchCache.current = {};
1326 setEntries([]);
1327 setSearchEntries([]);
1328 setShowPastChats(false);
1329 setPastChats([]);
1330 setPastChatQuery("");
1331 setLoadingPastChats(false);
1332 setActive(0);
1333 setDismissed(false);
1334 }, []);
1335
1336 // Controller/session changes invalidate @ mention state even when tab and
1337 // workspace identities stay the same (saved-session rebinds and rebuilds).
1338 const prevFileRefScopeRef = useRef(fileRefScopeKey);
1339 useEffect(() => {
1340 if (prevFileRefScopeRef.current === fileRefScopeKey) return;
1341 prevFileRefScopeRef.current = fileRefScopeKey;
1342 clearFileRefState();
1343 }, [clearFileRefState, fileRefScopeKey]);
1344
1345 const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
1346 useEffect(() => {
1347 if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
1348 prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
1349 clearFileRefState();
1350 }, [clearFileRefState, fileRefRefreshKey]);
1351
1352 useEffect(() => {
1353 if (atRaw === null) return;
1354 const cached = dirCache.current[atDir];
1355 if (cached) {
1356 setEntries(cached);
1357 } else {
1358 setEntries([]);
1359 }
1360 let live = true;
1361 app
1362 .ListDirForTab(fileRefTabId, unescapeRefPath(atDir))
1363 .then((es) => {
1364 const list = asArray(es);
1365 if (!live) return;
1366 dirCache.current[atDir] = list;
1367 setEntries(list);
1368 })
1369 .catch(() => {});
1370 return () => {
1371 live = false;
1372 };
1373 // Re-fetch when the menu opens, the directory level changes, or the
1374 // workspace tree refreshes; cached data is only a fast first paint.
1375 }, [atRaw === null, atDir, fileRefRefreshKey, fileRefScopeKey, fileRefTabId]);
1376 useEffect(() => {
1377 if (atRaw === null || atDir !== "" || atFrag === "") {
1378 setSearchEntries([]);
1379 return;
1380 }
1381 const cached = searchCache.current[atFrag];
1382 if (cached) {
1383 setSearchEntries(cached.entries);
1384 if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
1385 } else {
1386 setSearchEntries([]);
1387 }
1388 let live = true;
1389 app
1390 .SearchFileRefsForTab(fileRefTabId, atFrag)
1391 .then((es) => {
1392 const list = asArray(es);
1393 if (!live) return;
1394 searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
1395 setSearchEntries(list);
1396 })
1397 .catch(() => {});
1398 return () => {
1399 live = false;
1400 };
1401 }, [atRaw === null, atDir, atFrag, fileRefRefreshKey, fileRefScopeKey, fileRefTabId]);
1402 const atMatches = useMemo(
1403 () => {
1404 if (atRaw === null) return [];
1405 return filterAtMatches(entries, searchEntries, atFrag);
1406 },
1407 [atRaw, atFrag, entries, searchEntries],
1408 );
1409
1410 // Unified menu item model for the @ menu. "past:chats" is a real selectable
1411 // item (kind "pastChats"), not an active===0 special case.
1412 type AtMenuItem =
1413 | { kind: "pastChats" }
1414 | { kind: "file"; entry: DirEntry };
1415
1416 const includePastChatsItem = atRaw !== null && atDir === "" && (atFrag === "" || PAST_CHATS_MENU_ITEM.startsWith(atFrag));
1417
1418 const atMenuItems = useMemo<AtMenuItem[]>(
1419 () => [
1420 ...(includePastChatsItem ? [{ kind: "pastChats" as const }] : []),
1421 ...atMatches.map((entry) => ({ kind: "file" as const, entry })),
1422 ],
1423 [includePastChatsItem, atMatches],
1424 );
1425 const atMenuItemKey = useCallback(
1426 (item: AtMenuItem) => item.kind === "pastChats" ? "past:chats" : (item.entry.isDir ? "d:" : "f:") + (item.entry.path || item.entry.name),
1427 [],
1428 );
1429
1430 // --- which menu (if any) is open --- (slash command names win; then slash
1431 // arguments; then @-refs — they're rarely valid at once)
1432 const menuMode: "slash" | "slasharg" | "at" | "pastChats" | null =
1433 directPastChats
1434 ? "pastChats"
1435 : slashMatches.length > 0 && !dismissed
1436 ? "slash"
1437 : argRes && argRes.items.length > 0 && !dismissed
1438 ? "slasharg"
1439 : atRaw !== null && !dismissed
1440 ? "at"
1441 : null;
1442 const menuOpen = menuMode !== null;
1443 useLayoutEffect(() => {
1444 if (!menuOpen) return;
1445 const anchor = composerWrapRef.current;
1446 if (!anchor) return;
1447 return observeComposerMenuViewport(anchor);
1448 }, [menuOpen]);
1449 const countBase =
1450 menuMode === "slash"
1451 ? slashMatches.length
1452 : menuMode === "slasharg"
1453 ? argRes!.items.length
1454 : menuMode === "at"
1455 ? atMenuItems.length
1456 : menuMode === "pastChats"
1457 ? pastChats.length
1458 : 0;
1459
1460 // Reset highlight + un-dismiss whenever the active query changes.
1461 useEffect(() => {
1462 setActive(0);
1463 setDismissed(false);
1464 }, [slashQueryKey, atRaw, pastChatTokenQuery]);
1465
1466 useEffect(() => {
1467 if (transientDismissSignal === undefined || transientDismissSignal === lastTransientDismissSignal.current) return;
1468 lastTransientDismissSignal.current = transientDismissSignal;
1469 setDismissed(true);
1470 }, [transientDismissSignal]);
1471
1472 const takeSelfDispatchedGuidance = useCallback((text: string, targetDraftKey: string): boolean => {
1473 const selfDispatched = selfDispatchedGuidanceByDraftRef.current[targetDraftKey] ?? [];
1474 const idx = selfDispatched.findIndex((queued) => guidanceTextMatches(queued, text));
1475 if (idx < 0) return false;
1476 selfDispatched.splice(idx, 1);
1477 if (selfDispatched.length === 0) delete selfDispatchedGuidanceByDraftRef.current[targetDraftKey];
1478 return true;
1479 }, []);
1480
1481 useEffect(() => {
1482 if (guidanceDraftKey !== draftKey || !guidanceConsumedKey) return;
1483 if (guidanceConsumedKey === lastGuidanceConsumedKeyByDraftRef.current[draftKey]) return;
1484 lastGuidanceConsumedKeyByDraftRef.current[draftKey] = guidanceConsumedKey;
1485 const consumed = (guidanceConsumedText ?? "").trim();
1486 if (consumed && takeSelfDispatchedGuidance(consumed, draftKey)) return;
1487 updatePendingGuidanceForDraft(draftKey, (items) => {
1488 if (items.length === 0) return items;
1489 const idx = consumed
1490 ? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
1491 : -1;
1492 // Only remove on a real match. Steer notices also fire for guidance this
1493 // client never queued (another window, bot bridge, turn-end flush) —
1494 // falling back to dropping items[0] silently deleted unrelated queued
1495 // guidance (#6238).
1496 if (idx < 0) return items;
1497 return items.filter((_, index) => index !== idx);
1498 });
1499 }, [draftKey, guidanceDraftKey, guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
1500
1501 // When the @ trigger disappears (user deleted the @), close the past:chats
1502 // sub-menu and reset related state. Without this, showPastChats can outlive
1503 // the @ token and leave the session list visible with no way to dismiss it.
1504 useEffect(() => {
1505 if (menuMode !== "at" && menuMode !== "pastChats" && showPastChats) {
1506 setShowPastChats(false);
1507 setPastChatQuery("");
1508 setActive(0);
1509 }
1510 }, [menuMode]);
1511
1512 useEffect(() => {
1513 if (menuMode && menuMode !== "pastChats") setContentMenuOpen(false);
1514 }, [menuMode]);
1515
1516 // A starting run closes the transient content surfaces. Without this the
1517 // popover state survives the run (its open prop gates on !running) and the
1518 // menu would pop back unprompted the moment the turn finishes.
1519 useEffect(() => {
1520 if (!running) return;
1521 setContentMenuOpen(false);
1522 setDirectPastChats(false);
1523 setShowPastChats(false);
1524 setPastChatQuery("");
1525 if (pastChatToken) setDismissed(true);
1526 }, [pastChatToken, running]);
1527
1528 const resetPromptHistoryNavigation = () => {
1529 if (historyIndexRef.current === -1) return;
1530 historyIndexRef.current = -1;
1531 setHistoryIndex(-1);
1532 };
1533
1534 const syncPromptHistoryGeneration = () => {
1535 const nextGeneration = cacheGeneration();
1536 if (historyGenerationRef.current === nextGeneration) return;
1537 historyGenerationRef.current = nextGeneration;
1538 historyEntriesRef.current = [];
1539 historyLoadRef.current = null;
1540 historyIndexRef.current = -1;
1541 setHistoryIndex(-1);
1542 };
1543
1544 const ensurePromptHistoryIndex = async (index: number): Promise<boolean> => {
1545 if (index < historyEntriesRef.current.length) return true;
1546 if (historyLoadRef.current) await historyLoadRef.current;
1547 while (index >= historyEntriesRef.current.length) {
1548 let loaded = 0;
1549 const task = loadOlder().then((entries) => {
1550 loaded = entries.length;
1551 if (loaded > 0) {
1552 historyEntriesRef.current = historyEntriesRef.current.concat(entries);
1553 }
1554 });
1555 historyLoadRef.current = task;
1556 await task;
1557 historyLoadRef.current = null;
1558 if (loaded === 0) return index < historyEntriesRef.current.length;
1559 }
1560 return true;
1561 };
1562
1563 const prefetchPromptHistoryTail = () => {
1564 if (historyLoadRef.current) return;
1565 void ensurePromptHistoryIndex(historyEntriesRef.current.length);
1566 };
1567
1568 const focusComposerInput = () => {
1569 if (invocationsRef.current.length > 0) richInputRef.current?.focus();
1570 else taRef.current?.focus();
1571 };
1572
1573 const requestActiveDraftFrame = (callback: () => void) => {
1574 const activationEpoch = draftActivationEpochRef.current;
1575 requestAnimationFrame(() => {
1576 if (draftActivationEpochRef.current !== activationEpoch) return;
1577 callback();
1578 });
1579 };
1580
1581 const getComposerSelection = () => {
1582 if (invocationsRef.current.length > 0) return richInputRef.current?.getSelection() ?? richSelection;
1583 const ta = taRef.current;
1584 const start = ta?.selectionStart ?? textRef.current.length;
1585 const end = ta?.selectionEnd ?? start;
1586 return { start: Math.min(start, end), end: Math.max(start, end) };
1587 };
1588
1589 const setComposerSelection = (start: number, end = start, afterInvocationId?: string) => {
1590 const nextSelection = { start, end, afterInvocationId };
1591 lastSelectionRef.current = { start, end };
1592 if (invocationsRef.current.length === 0) setPlainSelection(nextSelection);
1593 requestActiveDraftFrame(() => {
1594 if (invocationsRef.current.length > 0) {
1595 richInputRef.current?.setSelectionRange(start, end, afterInvocationId);
1596 return;
1597 }
1598 const ta = taRef.current;
1599 if (!ta) return;
1600 ta.focus();
1601 ta.setSelectionRange(start, end);
1602 });
1603 };
1604
1605 const focusComposerFromContentBlank = (event: ReactMouseEvent<HTMLDivElement>) => {
1606 if (event.target !== event.currentTarget || disabled || readOnly) return;
1607 event.preventDefault();
1608 setComposerSelection(textRef.current.length);
1609 };
1610
1611 const setTextCaretEnd = (next: string, trackEdit = true) => {
1612 const targetDraftKey = activeDraftKeyRef.current;
1613 const beforeEdit = trackEdit ? composerEditSnapshot(targetDraftKey) : null;
1614 textRef.current = next;
1615 setText(next);
1616 setComposerSelection(next.length);
1617 if (beforeEdit) {
1618 recordComposerEdit(
1619 targetDraftKey,
1620 beforeEdit,
1621 composerEditSnapshot(targetDraftKey, { start: next.length, end: next.length }),
1622 );
1623 }
1624 };
1625
1626 const rememberCaret = () => {
1627 if (invocationsRef.current.length > 0) {
1628 const selection = richInputRef.current?.getSelection();
1629 if (selection) lastSelectionRef.current = { start: selection.start, end: selection.end };
1630 return;
1631 }
1632 const ta = taRef.current;
1633 if (!ta) return;
1634 const nextSelection = { start: ta.selectionStart ?? text.length, end: ta.selectionEnd ?? text.length };
1635 lastSelectionRef.current = nextSelection;
1636 setPlainSelection(nextSelection);
1637 };
1638
1639 const insertNewlineAtCaret = () => {
1640 const selection = getComposerSelection();
1641 const targetDraftKey = activeDraftKeyRef.current;
1642 const beforeEdit = composerEditSnapshot(targetDraftKey, selection);
1643 const updated = insertComposerNewline(textRef.current, invocationsRef.current, selection);
1644 textRef.current = updated.text;
1645 invocationsRef.current = updated.invocations;
1646 setText(updated.text);
1647 setInvocations(updated.invocations);
1648 const caret = selection.start + 1;
1649 setComposerSelection(caret);
1650 recordComposerEdit(
1651 targetDraftKey,
1652 beforeEdit,
1653 composerEditSnapshot(targetDraftKey, { start: caret, end: caret }),
1654 );
1655 };
1656
1657 const insertTextAtCaret = (snippet: string) => {
1658 const selection = getComposerSelection();
1659 const targetDraftKey = activeDraftKeyRef.current;
1660 const beforeEdit = composerEditSnapshot(targetDraftKey, selection);
1661 const start = selection.start;
1662 const end = selection.end;
1663 const current = textRef.current;
1664 const before = current.slice(0, start);
1665 const after = current.slice(end);
1666 const leading = before.length === 0 || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
1667 const body = snippet.trimEnd();
1668 const trailing = after.length === 0 ? "\n" : after.startsWith("\n") ? "" : "\n\n";
1669 const inserted = leading + body + trailing;
1670 const pos = before.length + inserted.length;
1671 const updated = replaceInvocationTextRange(current, invocationsRef.current, start, end, inserted);
1672 textRef.current = updated.text;
1673 invocationsRef.current = updated.invocations;
1674 setText(updated.text);
1675 setInvocations(updated.invocations);
1676 setComposerSelection(pos);
1677 recordComposerEdit(
1678 targetDraftKey,
1679 beforeEdit,
1680 composerEditSnapshot(targetDraftKey, { start: pos, end: pos }),
1681 );
1682 };
1683
1684 const replaceComposerText = (next: string) => {
1685 clearComposerEditHistory(activeDraftKeyRef.current);
1686 clearAttachments();
1687 setWorkspaceRefs([]);
1688 setSessionRefs([]);
1689 selectedTextRefsRef.current = [];
1690 setSelectedTextRefs([]);
1691 pastedBlocksRef.current = [];
1692 setPastedBlocks([]);
1693 setOpenPastedLabels([]);
1694 setTextCaretEnd(next, false);
1695 };
1696
1697 const addWorkspaceReference = (ref: WorkspaceReference) => {
1698 setWorkspaceRefs((prev) => {
1699 const key = workspaceReferenceKey(ref);
1700 if (prev.some((item) => workspaceReferenceKey(item) === key)) return prev;
1701 const next = [...prev, ref];
1702 workspaceRefsRef.current = next;
1703 return next;
1704 });
1705 requestActiveDraftFrame(focusComposerInput);
1706 };
1707
1708 useEffect(() => {
1709 if (!insertRequest || insertRequest.id === consumedInsertIdByDraftRef.current[draftKey]) return;
1710 consumedInsertIdByDraftRef.current[draftKey] = insertRequest.id;
1711 if (insertRequest.mode === "replace") {
1712 replaceComposerText(insertRequest.text);
1713 return;
1714 }
1715 if (insertRequest.mode === "prefix") {
1716 const prefix = `${insertRequest.text.trimEnd()} `;
1717 const current = textRef.current;
1718 setTextCaretEnd(current ? prefix + current : prefix);
1719 return;
1720 }
1721 const ref = parseWorkspaceReference(insertRequest.text);
1722 if (ref) {
1723 addWorkspaceReference(ref);
1724 return;
1725 }
1726 insertTextAtCaret(insertRequest.text);
1727 }, [draftKey, insertRequest]);
1728
1729 useEffect(() => {
1730 if (!selectedTextRequest || selectedTextRequest.id === consumedSelectedTextIdByDraftRef.current[draftKey]) return;
1731 consumedSelectedTextIdByDraftRef.current[draftKey] = selectedTextRequest.id;
1732 const normalized = normalizeSelectedText(selectedTextRequest.text);
1733 if (!normalized.text) return;
1734 if (normalized.truncated) showToast(t("composer.selectedTextTruncated"), "warn");
1735 const path = selectedTextRequest.path;
1736 const duplicate = selectedTextRefsRef.current.some(
1737 (reference) => reference.text === normalized.text && (reference.path ?? "") === (path ?? ""),
1738 );
1739 if (!duplicate) {
1740 const next = [
1741 ...selectedTextRefsRef.current,
1742 {
1743 id: `${path ? "code" : "chat"}-selection-${selectedTextRequest.id}`,
1744 text: normalized.text,
1745 ...(path ? { path } : {}),
1746 },
1747 ];
1748 selectedTextRefsRef.current = next;
1749 setSelectedTextRefs(next);
1750 }
1751 requestActiveDraftFrame(focusComposerInput);
1752 }, [draftKey, selectedTextRequest, showToast, t]);
1753
1754 const expandPastedBlocks = (displayText: string, blocks = pastedBlocksRef.current): string => {
1755 let expanded = displayText;
1756 for (const block of blocks) {
1757 if (expanded.includes(block.label)) {
1758 expanded = expanded.split(block.label).join(renderPastedBlock(block));
1759 }
1760 }
1761 return expanded;
1762 };
1763
1764 const rememberAttachment = (path: string, key: AttachmentDedupKey) => {
1765 attachmentDedupRef.current.add(key.hash, key.source);
1766 attachmentDedupKeysRef.current[path] = key;
1767 };
1768
1769 const forgetAttachment = (path: string) => {
1770 const key = attachmentDedupKeysRef.current[path];
1771 if (key) {
1772 attachmentDedupRef.current.forget(key.hash, key.source);
1773 delete attachmentDedupKeysRef.current[path];
1774 }
1775 };
1776
1777 const clearAttachments = () => {
1778 attachmentsRef.current = [];
1779 setAttachments([]);
1780 attachmentDedupRef.current.clear();
1781 attachmentDedupKeysRef.current = {};
1782 };
1783
1784 const removeAttachment = (path: string) => {
1785 forgetAttachment(path);
1786 setAttachments(attachmentsRef.current.filter((x) => x.path !== path));
1787 requestActiveDraftFrame(focusComposerInput);
1788 };
1789
1790 const attachmentSeenInDraft = (targetDraftKey: string, key: AttachmentDedupKey): boolean => {
1791 if (targetDraftKey === activeDraftKeyRef.current) return attachmentDedupRef.current.seen(key.hash, key.source);
1792 const draft = draftsBySessionRef.current[targetDraftKey];
1793 return draft ? draftHasAttachmentDedupKey(draft, key) : false;
1794 };
1795
1796 const addAttachmentToDraft = (targetDraftKey: string, attachment: Attachment, key: AttachmentDedupKey): boolean => {
1797 if (targetDraftKey === activeDraftKeyRef.current) {
1798 if (attachmentDedupRef.current.seen(key.hash, key.source)) return false;
1799 rememberAttachment(attachment.path, key);
1800 const next = [...attachmentsRef.current, attachment];
1801 attachmentsRef.current = next;
1802 setAttachments(next);
1803 return true;
1804 }
1805 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1806 if (draftHasAttachmentDedupKey(draft, key)) return false;
1807 draft.attachmentDedupKeys[attachment.path] = key;
1808 draft.attachments = [...draft.attachments, attachment];
1809 draftsBySessionRef.current[targetDraftKey] = draft;
1810 return true;
1811 };
1812
1813 const addWorkspaceReferenceToDraft = (targetDraftKey: string, ref: WorkspaceReference) => {
1814 if (targetDraftKey === activeDraftKeyRef.current) {
1815 addWorkspaceReference(ref);
1816 return;
1817 }
1818 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1819 const key = workspaceReferenceKey(ref);
1820 if (draft.workspaceRefs.some((item) => workspaceReferenceKey(item) === key)) return;
1821 draft.workspaceRefs = [...draft.workspaceRefs, ref];
1822 draftsBySessionRef.current[targetDraftKey] = draft;
1823 };
1824
1825 const clearSubmittedDraft = (targetDraftKey: string) => {
1826 clearComposerEditHistory(targetDraftKey);
1827 if (targetDraftKey === activeDraftKeyRef.current) {
1828 textRef.current = "";
1829 setText("");
1830 invocationsRef.current = [];
1831 setInvocations([]);
1832 setRichSlashQuery(null);
1833 historyIndexRef.current = -1;
1834 setHistoryIndex(-1);
1835 clearAttachments();
1836 workspaceRefsRef.current = [];
1837 setWorkspaceRefs([]);
1838 sessionRefsRef.current = [];
1839 setSessionRefs([]);
1840 selectedTextRefsRef.current = [];
1841 setSelectedTextRefs([]);
1842 pastedBlocksRef.current = [];
1843 setPastedBlocks([]);
1844 openPastedLabelsRef.current = [];
1845 setOpenPastedLabels([]);
1846 savedTextRef.current = "";
1847 return;
1848 }
1849 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
1850 draft.text = "";
1851 draft.invocations = [];
1852 draft.attachments = [];
1853 draft.workspaceRefs = [];
1854 draft.pastedBlocks = [];
1855 draft.openPastedLabels = [];
1856 draft.sessionRefs = [];
1857 draft.selectedTextRefs = [];
1858 draft.attachmentDedupKeys = {};
1859 draft.historyIndex = -1;
1860 draft.savedText = "";
1861 draftsBySessionRef.current[targetDraftKey] = draft;
1862 };
1863
1864 const clearIntentCloseTimer = useCallback(() => {
1865 if (intentCloseTimerRef.current === null) return;
1866 window.clearTimeout(intentCloseTimerRef.current);
1867 intentCloseTimerRef.current = null;
1868 }, []);
1869
1870 const clearProfileCloseTimer = useCallback(() => {
1871 if (profileCloseTimerRef.current === null) return;
1872 window.clearTimeout(profileCloseTimerRef.current);
1873 profileCloseTimerRef.current = null;
1874 }, []);
1875
1876 // Hover timers only touch refs — no useCallback cross-deps (avoids TDZ on HMR).
1877 const clearHoverTimer = (timerRef: { current: number | null }) => {
1878 if (timerRef.current == null) return;
1879 window.clearTimeout(timerRef.current);
1880 timerRef.current = null;
1881 };
1882
1883 const openIntentMenu = useCallback(() => {
1884 clearIntentCloseTimer();
1885 clearHoverTimer(intentHoverTimerRef);
1886 clearHoverTimer(profileHoverTimerRef);
1887 if (profileCloseTimerRef.current != null) {
1888 window.clearTimeout(profileCloseTimerRef.current);
1889 profileCloseTimerRef.current = null;
1890 }
1891 setProfileMenuOpen(false);
1892 setProfileMenuClosing(false);
1893 setContentMenuOpen(false);
1894 setDirectPastChats(false);
1895 setDismissed(true);
1896 setIntentMenuClosing(false);
1897 setIntentMenuOpen(true);
1898 }, [clearIntentCloseTimer]);
1899
1900 const closeIntentMenu = useCallback((afterClose?: () => void) => {
1901 clearIntentCloseTimer();
1902 clearHoverTimer(intentHoverTimerRef);
1903 setIntentMenuClosing(true);
1904 window.requestAnimationFrame(() => setIntentMenuOpen(false));
1905 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1906 intentCloseTimerRef.current = window.setTimeout(() => {
1907 intentCloseTimerRef.current = null;
1908 setIntentMenuClosing(false);
1909 afterClose?.();
1910 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
1911 }, [clearIntentCloseTimer]);
1912
1913 const openProfileMenu = useCallback(() => {
1914 clearProfileCloseTimer();
1915 clearHoverTimer(profileHoverTimerRef);
1916 clearHoverTimer(intentHoverTimerRef);
1917 if (intentCloseTimerRef.current != null) {
1918 window.clearTimeout(intentCloseTimerRef.current);
1919 intentCloseTimerRef.current = null;
1920 }
1921 setIntentMenuOpen(false);
1922 setIntentMenuClosing(false);
1923 setContentMenuOpen(false);
1924 setDirectPastChats(false);
1925 setDismissed(true);
1926 setProfileMenuClosing(false);
1927 setProfileMenuOpen(true);
1928 }, [clearProfileCloseTimer]);
1929
1930 const closeProfileMenu = useCallback((afterClose?: () => void) => {
1931 clearProfileCloseTimer();
1932 clearHoverTimer(profileHoverTimerRef);
1933 setProfileMenuClosing(true);
1934 window.requestAnimationFrame(() => setProfileMenuOpen(false));
1935 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1936 profileCloseTimerRef.current = window.setTimeout(() => {
1937 profileCloseTimerRef.current = null;
1938 setProfileMenuClosing(false);
1939 afterClose?.();
1940 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
1941 }, [clearProfileCloseTimer]);
1942
1943 useEffect(() => () => {
1944 clearIntentCloseTimer();
1945 clearHoverTimer(intentHoverTimerRef);
1946 clearProfileCloseTimer();
1947 clearHoverTimer(profileHoverTimerRef);
1948 }, [clearIntentCloseTimer, clearProfileCloseTimer]);
1949
1950 const onIntentHoverEnter = useCallback(() => {
1951 if (!creationChrome || disabled || running) return;
1952 clearHoverTimer(intentHoverTimerRef);
1953 intentHoverTimerRef.current = window.setTimeout(() => {
1954 intentHoverTimerRef.current = null;
1955 openIntentMenu();
1956 }, 120);
1957 }, [creationChrome, disabled, openIntentMenu, running]);
1958
1959 const onIntentHoverLeave = useCallback(() => {
1960 if (!creationChrome) return;
1961 clearHoverTimer(intentHoverTimerRef);
1962 if (!intentMenuOpen && !intentMenuClosing) return;
1963 intentHoverTimerRef.current = window.setTimeout(() => {
1964 intentHoverTimerRef.current = null;
1965 closeIntentMenu();
1966 }, 140);
1967 }, [closeIntentMenu, creationChrome, intentMenuClosing, intentMenuOpen]);
1968
1969 const onIntentPopoverEnter = useCallback(() => {
1970 if (!creationChrome) return;
1971 clearHoverTimer(intentHoverTimerRef);
1972 }, [creationChrome]);
1973
1974 const onProfileHoverEnter = useCallback(() => {
1975 if (!creationChrome || disabled || running) return;
1976 clearHoverTimer(profileHoverTimerRef);
1977 profileHoverTimerRef.current = window.setTimeout(() => {
1978 profileHoverTimerRef.current = null;
1979 openProfileMenu();
1980 }, 120);
1981 }, [creationChrome, disabled, openProfileMenu, running]);
1982
1983 const onProfileHoverLeave = useCallback(() => {
1984 if (!creationChrome) return;
1985 clearHoverTimer(profileHoverTimerRef);
1986 if (!profileMenuOpen && !profileMenuClosing) return;
1987 profileHoverTimerRef.current = window.setTimeout(() => {
1988 profileHoverTimerRef.current = null;
1989 closeProfileMenu();
1990 }, 140);
1991 }, [closeProfileMenu, creationChrome, profileMenuClosing, profileMenuOpen]);
1992
1993 const onProfilePopoverEnter = useCallback(() => {
1994 if (!creationChrome) return;
1995 clearHoverTimer(profileHoverTimerRef);
1996 }, [creationChrome]);
1997
1998 const clearMoreCloseTimer = useCallback(() => {
1999 if (moreCloseTimerRef.current === null) return;
2000 window.clearTimeout(moreCloseTimerRef.current);
2001 moreCloseTimerRef.current = null;
2002 }, []);
2003
2004 const openMoreMenu = useCallback(() => {
2005 clearMoreCloseTimer();
2006 setContentMenuOpen(false);
2007 setDirectPastChats(false);
2008 setDismissed(true);
2009 setMoreMenuClosing(false);
2010 setMoreMenuOpen(true);
2011 }, [clearMoreCloseTimer]);
2012
2013 const closeMoreMenu = useCallback((afterClose?: () => void) => {
2014 clearMoreCloseTimer();
2015 setMoreMenuClosing(true);
2016 window.requestAnimationFrame(() => setMoreMenuOpen(false));
2017 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2018 moreCloseTimerRef.current = window.setTimeout(() => {
2019 moreCloseTimerRef.current = null;
2020 setMoreMenuClosing(false);
2021 afterClose?.();
2022 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
2023 }, [clearMoreCloseTimer]);
2024
2025 useEffect(() => () => clearMoreCloseTimer(), [clearMoreCloseTimer]);
2026
2027 const fileDedupKey = async (file: File): Promise<AttachmentDedupKey> => ({
2028 hash: await sha256(file),
2029 source: `file:${file.name}:${file.size}:${file.lastModified}`,
2030 });
2031
2032 const planModeOn = collaborationMode === "plan";
2033 const activeGoal = (goal ?? "").trim();
2034 const goalModeOn = collaborationMode === "goal";
2035 const warnImageInputFallback = useCallback((message = t("composer.imageInputUnsupported")) => {
2036 showToast(message, "warn");
2037 }, [showToast, t]);
2038
2039 const submit = async () => {
2040 if (disabled || (!running && submitDisabled) || readOnly) return;
2041 const submitDraftKey = activeDraftKeyRef.current;
2042 const submitTabId = tabId;
2043 if (draftIsSubmitting(submitDraftKey)) return;
2044 const currentText = textRef.current;
2045 const rawDraft = trimInvocationDraft(currentText, invocationsRef.current);
2046 const typedGoalDraft = goalModeOn && !activeGoal && rawDraft.invocations.length === 0
2047 ? typedStructuredInvocationDraft(rawDraft.text, commands)
2048 : null;
2049 const trimmedDraft = typedGoalDraft ?? rawDraft;
2050 const trimmedText = trimmedDraft.text;
2051 if (draftHasPendingPaste(submitDraftKey)) return;
2052 if (!imageInputEnabled && hasImageAttachments(attachmentsRef.current)) {
2053 warnImageInputFallback();
2054 }
2055 const currentAttachments = attachmentsRef.current;
2056 const currentWorkspaceRefs = workspaceRefsRef.current;
2057 const inlineInvocationCount = trimmedDraft.invocations.filter((invocation) => invocation.command.kind === "skill").length;
2058 const subagentInvocationCount = trimmedDraft.invocations.filter((invocation) => invocation.command.kind === "subagent").length;
2059 if (goalModeOn && !activeGoal && trimmedDraft.invocations.length > 0 && !trimmedText) {
2060 // Goal setup still needs task text when a structured invocation is
2061 // present. Attachments and workspace refs remain valid task-only input.
2062 setComposerPrompt(t("composer.goalInputRequired"));
2063 requestActiveDraftFrame(focusComposerInput);
2064 return;
2065 }
2066 if (!trimmedText && currentAttachments.length === 0 && currentWorkspaceRefs.length === 0 && inlineInvocationCount === 0) {
2067 if (goalModeOn && !activeGoal) {
2068 setComposerPrompt(t("composer.goalInputRequired"));
2069 requestActiveDraftFrame(focusComposerInput);
2070 } else if (subagentInvocationCount > 0) {
2071 setComposerPrompt(t("composer.subagentTaskRequired"));
2072 requestActiveDraftFrame(focusComposerInput);
2073 }
2074 return;
2075 }
2076 setComposerPrompt(null);
2077 updateSubmittingForDraft(submitDraftKey, true);
2078 try {
2079 const orderedAttachments = sortComposerAttachments(currentAttachments);
2080 const refs = [
2081 ...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
2082 ...orderedAttachments.map((a) => `@${a.path}`),
2083 ].join(" ");
2084 const displayRefs = [
2085 ...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.displayPath || ref.path, ref.isDir)),
2086 ...orderedAttachments.map(formatAttachmentDisplayReference),
2087 ...selectedTextRefsRef.current.map(formatSelectionLabel),
2088 ].join(" ");
2089 const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
2090 // PR-B: when past:chats refs are attached, prepend their formatted transcript
2091 // to submitText only (displayText stays unchanged so the user still sees their
2092 // original prompt in the input preview). With no refs we keep the original
2093 // submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
2094 const currentSessionRefs = sessionRefsRef.current;
2095 const currentSelectedTextRefs = selectedTextRefsRef.current;
2096 const currentPastedBlocks = [...pastedBlocksRef.current];
2097 const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs, t);
2098 const selectedTextContext = formatSelectedTextContext(currentSelectedTextRefs);
2099 const invocationText = serializeInvocationSubmit(trimmedText, trimmedDraft.invocations);
2100 const baseSubmitText = [expandPastedBlocks(invocationText, currentPastedBlocks), refs].filter(Boolean).join(" ");
2101 const submitBase = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
2102 const submitText = [submitBase, selectedTextContext].filter(Boolean).join("\n\n");
2103 const structuredInput = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
2104 const structured = trimmedDraft.invocations.length > 0 ? {
2105 display: [invocationText, displayRefs].filter(Boolean).join(invocationText && displayRefs ? " " : ""),
2106 input: [sessionContext ? `${sessionContext}${structuredInput}` : structuredInput, selectedTextContext].filter(Boolean).join("\n\n"),
2107 invocations: invocationRequests(trimmedDraft.invocations),
2108 } satisfies StructuredInvocationSubmit : undefined;
2109 if (running) {
2110 // An entity-only submit has an empty displayText (entities live
2111 // outside the text model); fall back to the serialized slash form so
2112 // the queue shows the invocation instead of silently dropping it
2113 // while clearSubmittedDraft wipes the composer.
2114 const guidanceText = displayText.trim() || (structured?.display.trim() ?? "");
2115 const guidanceSubmitText = submitText.trim();
2116 if (guidanceText) {
2117 const id = nextGuidanceId.current++;
2118 updatePendingGuidanceForDraft(submitDraftKey, (items) => [
2119 ...items,
2120 { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText, structured },
2121 ]);
2122 }
2123 clearSubmittedDraft(submitDraftKey);
2124 return;
2125 }
2126 await onSend(displayText, submitText, submitTabId, structured);
2127 clearSubmittedDraft(submitDraftKey);
2128 } catch (error) {
2129 showToast(error instanceof Error ? error.message : String(error), "warn");
2130 } finally {
2131 updateSubmittingForDraft(submitDraftKey, false);
2132 }
2133 };
2134
2135 const sendQueuedGuidance = async (
2136 item: PendingGuidance,
2137 targetDraftKey = activeDraftKeyRef.current,
2138 targetTabId = tabId,
2139 ) => {
2140 if (targetDraftKey !== activeDraftKeyRef.current || disabled || readOnly || guidanceSendingIdRef.current !== null) return;
2141 if (running && item.structured) return;
2142 const displayText = item.text.trim();
2143 const submitText = item.submitText.trim() || displayText;
2144 if (!displayText || !submitText) return;
2145 const attemptedSteer = running && onSteer !== undefined;
2146 let retryRejectedSteer = false;
2147 const selfDispatched = selfDispatchedGuidanceByDraftRef.current[targetDraftKey] ?? [];
2148 selfDispatched.push(submitText);
2149 selfDispatchedGuidanceByDraftRef.current[targetDraftKey] = selfDispatched;
2150 updateGuidanceSendingIdForDraft(targetDraftKey, item.id);
2151 try {
2152 if (attemptedSteer) await onSteer(submitText, targetTabId);
2153 else await onSend(displayText, submitText, targetTabId, item.structured);
2154 updatePendingGuidanceForDraft(targetDraftKey, (items) => items.filter((queued) => queued.id !== item.id));
2155 window.setTimeout(() => {
2156 takeSelfDispatchedGuidance(submitText, targetDraftKey);
2157 }, 5000);
2158 } catch (error) {
2159 retryRejectedSteer = attemptedSteer;
2160 takeSelfDispatchedGuidance(submitText, targetDraftKey);
2161 showToast(error instanceof Error ? error.message : String(error), "warn");
2162 } finally {
2163 const current = targetDraftKey === activeDraftKeyRef.current
2164 ? guidanceSendingIdRef.current
2165 : draftsBySessionRef.current[targetDraftKey]?.guidanceSendingId;
2166 if (current === item.id) updateGuidanceSendingIdForDraft(targetDraftKey, null);
2167 // TurnDone may render while TrySteer is still pending. That render cannot
2168 // auto-send because the guidance item is marked in flight, so re-run the
2169 // idle-queue effect after a rejected steer settles. Ordinary onSend
2170 // failures intentionally do not re-arm, avoiding an automatic retry loop.
2171 if (retryRejectedSteer && targetDraftKey === activeDraftKeyRef.current) {
2172 setGuidanceRetryNonce((value) => value + 1);
2173 }
2174 }
2175 };
2176
2177 const readFileAsDataURL = (file: File) =>
2178 new Promise<string>((resolve, reject) => {
2179 const reader = new FileReader();
2180 reader.onload = () => resolve(String(reader.result));
2181 reader.onerror = () => reject(reader.error);
2182 reader.readAsDataURL(file);
2183 });
2184
2185 const attachImageFiles = async (files: File[], sourceDraftKey: string) => {
2186 const images = files.filter((f) => f.type.startsWith("image/"));
2187 if (images.length === 0) return;
2188 for (const file of images) {
2189 updatePendingPasteForDraft(sourceDraftKey, 1);
2190 try {
2191 const key = await fileDedupKey(file);
2192 if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
2193 const dataUrl = await readFileAsDataURL(file);
2194 const path = await app.SavePastedImage(dataUrl);
2195 const previewUrl = await app.AttachmentDataURL(path);
2196 addAttachmentToDraft(sourceDraftKey, { path, previewUrl, displayName: file.name }, key);
2197 } catch (error) {
2198 console.warn("[composer] failed to attach pasted image", error);
2199 showToast(t("composer.attachImageFailed"), "warn");
2200 // non-fatal: a failed image attach must not block normal text input
2201 } finally {
2202 updatePendingPasteForDraft(sourceDraftKey, -1);
2203 }
2204 }
2205 };
2206
2207 // Non-image pastes (PDFs, docs): the clipboard hands us bytes, not a path, so
2208 // the kernel stores them and we reference the saved path — attached, not ignored.
2209 const attachOtherFiles = async (files: File[], sourceDraftKey: string) => {
2210 const others = files.filter((f) => !f.type.startsWith("image/"));
2211 if (others.length === 0) return;
2212 for (const file of others) {
2213 updatePendingPasteForDraft(sourceDraftKey, 1);
2214 try {
2215 const key = await fileDedupKey(file);
2216 if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
2217 const dataUrl = await readFileAsDataURL(file);
2218 const path = await app.SavePastedFile(file.name, dataUrl);
2219 addAttachmentToDraft(sourceDraftKey, { path, displayName: file.name }, key);
2220 } catch {
2221 console.warn("[composer] failed to attach pasted file");
2222 showToast(t("composer.attachFileFailed"), "warn");
2223 // non-fatal: a failed attach must not block normal text input
2224 } finally {
2225 updatePendingPasteForDraft(sourceDraftKey, -1);
2226 }
2227 }
2228 };
2229
2230 const attachFiles = (files: File[]) => {
2231 const sourceDraftKey = activeDraftKeyRef.current;
2232 void attachImageFiles(files, sourceDraftKey);
2233 void attachOtherFiles(files, sourceDraftKey);
2234 };
2235
2236 const attachNativeClipboardImage = async (notifyOnError: boolean, sourceDraftKey: string) => {
2237 updatePendingPasteForDraft(sourceDraftKey, 1);
2238 try {
2239 const path = await app.SaveClipboardImage();
2240 const previewUrl = await app.AttachmentDataURL(path);
2241 const key = { hash: await dataURLHash(previewUrl), source: `native-clipboard:${path}` };
2242 if (attachmentSeenInDraft(sourceDraftKey, key)) return;
2243 addAttachmentToDraft(sourceDraftKey, { path, previewUrl }, key);
2244 } catch (error) {
2245 console.warn("[composer] failed to read native clipboard image", error);
2246 if (notifyOnError) showToast(t("composer.pasteImageFailed"), "warn");
2247 } finally {
2248 updatePendingPasteForDraft(sourceDraftKey, -1);
2249 }
2250 };
2251
2252 // OS file drops arrive as absolute paths through the native bridge (the webview
2253 // withholds them from the HTML drop event); the kernel resolves each into a
2254 // workspace @reference or a stored attachment.
2255 const attachDroppedPaths = async (paths: string[], sourceDraftKey = activeDraftKeyRef.current) => {
2256 setDragOver(false);
2257 for (const path of paths) {
2258 updatePendingPasteForDraft(sourceDraftKey, 1);
2259 try {
2260 const key = { hash: "", source: `path:${path}` };
2261 if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
2262 const item = await app.AttachDropped(path);
2263 if (item.kind === "workspace") {
2264 addWorkspaceReferenceToDraft(sourceDraftKey, { path: item.path, isDir: item.isDir, displayPath: item.displayPath });
2265 } else {
2266 addAttachmentToDraft(sourceDraftKey, { path: item.path, previewUrl: item.previewUrl, displayName: baseName(path) }, key);
2267 }
2268 } catch {
2269 console.warn("[composer] failed to attach dropped file");
2270 showToast(t("composer.attachDropFailed"), "warn");
2271 // non-fatal: a failed drop attach must not block normal text input
2272 } finally {
2273 updatePendingPasteForDraft(sourceDraftKey, -1);
2274 }
2275 }
2276 };
2277
2278 useEffect(() => {
2279 return onFilesDropped((paths) => void attachDroppedPaths(paths, activeDraftKeyRef.current));
2280 }, []);
2281
2282 const onPaste = (e: ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
2283 clearNativeClipboardPasteTimer();
2284 const files = clipboardFiles(e.clipboardData);
2285 if (files.length > 0) {
2286 e.preventDefault();
2287 attachFiles(files);
2288 return;
2289 }
2290
2291 const pasted = e.clipboardData.getData("text");
2292 const hasImageHint = clipboardHasImageHint(e.clipboardData);
2293 if (hasImageHint || pasted === "") {
2294 e.preventDefault();
2295 void attachNativeClipboardImage(hasImageHint, activeDraftKeyRef.current);
2296 return;
2297 }
2298
2299 // Always prevent the browser default paste so React's controlled-input
2300 // reconciliation cannot race with the native DOM update and lose the
2301 // pasted content (WebView2 / Windows). We insert the text manually below.
2302 e.preventDefault();
2303 const selection = getComposerSelection();
2304 const start = selection.start;
2305 const end = selection.end;
2306 const sourceDraftKey = activeDraftKeyRef.current;
2307 const beforeEdit = composerEditSnapshot(sourceDraftKey, selection);
2308
2309 // Normalize CRLF from Windows clipboard so caret offsets match the
2310 // textarea's normalized value. The raw text (with CRLF) is preserved
2311 // in the PastedBlock for long pastes so block content is lossless.
2312 const normalizedPasted = pasted.replace(/\r\n/g, "\n");
2313 let caret: number;
2314
2315 if (shouldFoldPaste(pasted)) {
2316 // Long paste: fold into a collapsible block so the composer stays compact.
2317 const id = nextPasteId.current++;
2318 const lines = lineCount(pasted);
2319 const label = t("composer.pastedLabel", { id, lines });
2320 const block: PastedBlock = { label, text: pasted }; // keep raw text (CRLF preserved)
2321 const next = replaceInvocationTextRange(
2322 textRef.current,
2323 invocationsRef.current,
2324 start,
2325 end,
2326 label,
2327 selection.afterInvocationId,
2328 );
2329 pastedBlocksRef.current = [...pastedBlocksRef.current, block];
2330 setPastedBlocks((prev) => [...prev, block]);
2331 textRef.current = next.text;
2332 invocationsRef.current = next.invocations;
2333 setText(next.text);
2334 setInvocations(next.invocations);
2335 caret = start + label.length;
2336 setComposerSelection(caret);
2337 } else {
2338 // The paste event is intentionally prevented above, so the browser
2339 // cannot add this edit to its native undo history. Record the complete
2340 // programmatic edit below while leaving ordinary typing in the native
2341 // history.
2342 resetPromptHistoryNavigation();
2343 const next = replaceInvocationTextRange(
2344 textRef.current,
2345 invocationsRef.current,
2346 start,
2347 end,
2348 normalizedPasted,
2349 selection.afterInvocationId,
2350 );
2351 textRef.current = next.text;
2352 invocationsRef.current = next.invocations;
2353 setText(next.text);
2354 setInvocations(next.invocations);
2355 caret = start + normalizedPasted.length;
2356 setComposerSelection(caret);
2357 }
2358 recordComposerEdit(
2359 sourceDraftKey,
2360 beforeEdit,
2361 composerEditSnapshot(sourceDraftKey, { start: caret, end: caret }),
2362 );
2363 };
2364
2365 const getInputSelection = () => {
2366 const selection = getComposerSelection();
2367 const start = selection.start;
2368 const end = selection.end;
2369 const from = Math.min(start, end);
2370 const to = Math.max(start, end);
2371 return {
2372 from,
2373 to,
2374 selected: textRef.current.slice(from, to),
2375 afterInvocationId: start === end ? selection.afterInvocationId : undefined,
2376 };
2377 };
2378
2379 const focusInputRange = (start: number, end = start, afterInvocationId?: string) => {
2380 setComposerSelection(start, end, afterInvocationId);
2381 };
2382
2383 const replaceInputRange = (
2384 value: string,
2385 start: number,
2386 end: number,
2387 targetDraftKey = activeDraftKeyRef.current,
2388 afterInvocationId?: string,
2389 ) => {
2390 if (targetDraftKey === activeDraftKeyRef.current) {
2391 const current = textRef.current;
2392 const next = replaceInvocationTextRange(
2393 current,
2394 invocationsRef.current,
2395 start,
2396 end,
2397 value,
2398 afterInvocationId,
2399 );
2400 textRef.current = next.text;
2401 invocationsRef.current = next.invocations;
2402 setText(next.text);
2403 setInvocations(next.invocations);
2404 focusInputRange(start + value.length);
2405 return;
2406 }
2407 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
2408 const next = replaceInvocationTextRange(
2409 draft.text,
2410 draft.invocations,
2411 start,
2412 end,
2413 value,
2414 afterInvocationId,
2415 );
2416 draft.text = next.text;
2417 draft.invocations = next.invocations;
2418 draftsBySessionRef.current[targetDraftKey] = draft;
2419 };
2420
2421 const insertPastedText = (
2422 pasted: string,
2423 start: number,
2424 end: number,
2425 targetDraftKey = activeDraftKeyRef.current,
2426 afterInvocationId?: string,
2427 ) => {
2428 const normalizedPasted = pasted.replace(/\r\n/g, "\n");
2429 const beforeEdit = composerEditSnapshot(targetDraftKey, { start, end, afterInvocationId });
2430 let caret: number;
2431 if (targetDraftKey !== activeDraftKeyRef.current) {
2432 const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
2433 let inserted: string;
2434 if (shouldFoldPaste(pasted)) {
2435 const id = draft.nextPasteId++;
2436 const lines = lineCount(pasted);
2437 const label = t("composer.pastedLabel", { id, lines });
2438 draft.pastedBlocks = [...draft.pastedBlocks, { label, text: pasted }];
2439 inserted = label;
2440 caret = start + label.length;
2441 } else {
2442 draft.historyIndex = -1;
2443 inserted = normalizedPasted;
2444 caret = start + normalizedPasted.length;
2445 }
2446 const next = replaceInvocationTextRange(
2447 draft.text,
2448 draft.invocations,
2449 start,
2450 end,
2451 inserted,
2452 afterInvocationId,
2453 );
2454 draft.text = next.text;
2455 draft.invocations = next.invocations;
2456 draftsBySessionRef.current[targetDraftKey] = draft;
2457 recordComposerEdit(targetDraftKey, beforeEdit, composerEditSnapshot(targetDraftKey, { start: caret, end: caret }));
2458 return;
2459 }
2460
2461 if (shouldFoldPaste(pasted)) {
2462 const id = nextPasteId.current++;
2463 const lines = lineCount(pasted);
2464 const label = t("composer.pastedLabel", { id, lines });
2465 const block: PastedBlock = { label, text: pasted };
2466 const next = replaceInvocationTextRange(
2467 textRef.current,
2468 invocationsRef.current,
2469 start,
2470 end,
2471 label,
2472 afterInvocationId,
2473 );
2474 pastedBlocksRef.current = [...pastedBlocksRef.current, block];
2475 setPastedBlocks((prev) => [...prev, block]);
2476 textRef.current = next.text;
2477 invocationsRef.current = next.invocations;
2478 setText(next.text);
2479 setInvocations(next.invocations);
2480 caret = start + label.length;
2481 focusInputRange(caret);
2482 } else {
2483 resetPromptHistoryNavigation();
2484 const next = replaceInvocationTextRange(
2485 textRef.current,
2486 invocationsRef.current,
2487 start,
2488 end,
2489 normalizedPasted,
2490 afterInvocationId,
2491 );
2492 textRef.current = next.text;
2493 invocationsRef.current = next.invocations;
2494 setText(next.text);
2495 setInvocations(next.invocations);
2496 caret = start + normalizedPasted.length;
2497 focusInputRange(caret);
2498 }
2499 recordComposerEdit(targetDraftKey, beforeEdit, composerEditSnapshot(targetDraftKey, { start: caret, end: caret }));
2500 };
2501
2502 const copyComposerSelection = async (cut = false) => {
2503 const selection = getInputSelection();
2504 const sourceDraftKey = activeDraftKeyRef.current;
2505 setInputMenuPoint(null);
2506 if (!selection.selected) {
2507 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2508 return;
2509 }
2510 try {
2511 await navigator.clipboard.writeText(selection.selected);
2512 } catch {
2513 // Fall back to Wails desktop runtime, then execCommand
2514 try {
2515 if (typeof window !== "undefined" && (await window.runtime?.ClipboardSetText?.(selection.selected))) {
2516 /* ok */
2517 } else if (!fallbackCopyText(selection.selected)) {
2518 // Every clipboard path failed. Cutting now would delete text that
2519 // never reached the clipboard, so keep the draft intact.
2520 if (sourceDraftKey === activeDraftKeyRef.current) {
2521 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2522 }
2523 return;
2524 }
2525 } catch {
2526 if (sourceDraftKey === activeDraftKeyRef.current) {
2527 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2528 }
2529 return;
2530 }
2531 }
2532 if (cut) {
2533 const beforeEdit = composerEditSnapshot(sourceDraftKey, { start: selection.from, end: selection.to });
2534 if (sourceDraftKey === activeDraftKeyRef.current) resetPromptHistoryNavigation();
2535 replaceInputRange("", selection.from, selection.to, sourceDraftKey);
2536 recordComposerEdit(
2537 sourceDraftKey,
2538 beforeEdit,
2539 composerEditSnapshot(sourceDraftKey, { start: selection.from, end: selection.from }),
2540 );
2541 } else if (sourceDraftKey === activeDraftKeyRef.current) {
2542 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2543 }
2544 };
2545
2546 const pasteIntoComposer = async () => {
2547 const selection = getInputSelection();
2548 const sourceDraftKey = activeDraftKeyRef.current;
2549 setInputMenuPoint(null);
2550
2551 // Try reading clipboard items for image detection (no event in menu path)
2552 try {
2553 const items = await navigator.clipboard.read();
2554 if (items.some((item) => item.types.some((t) => t.startsWith("image/")))) {
2555 void attachNativeClipboardImage(true, sourceDraftKey);
2556 return;
2557 }
2558 } catch {
2559 /* clipboard.read() not supported or permission denied; fall through */
2560 }
2561
2562 if (!navigator.clipboard?.readText) {
2563 if (sourceDraftKey === activeDraftKeyRef.current) {
2564 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2565 }
2566 return;
2567 }
2568 try {
2569 const pasted = await navigator.clipboard.readText();
2570 if (pasted === "") {
2571 // Match the keyboard paste handler: an empty text read means "nothing
2572 // to insert" (empty clipboard, files, or unsupported types) — never
2573 // replace the current selection with nothing. An image may still be
2574 // attachable through the native clipboard path.
2575 if (sourceDraftKey === activeDraftKeyRef.current) {
2576 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2577 }
2578 void attachNativeClipboardImage(false, sourceDraftKey);
2579 return;
2580 }
2581 insertPastedText(
2582 pasted,
2583 selection.from,
2584 selection.to,
2585 sourceDraftKey,
2586 selection.afterInvocationId,
2587 );
2588 } catch {
2589 if (sourceDraftKey === activeDraftKeyRef.current) {
2590 focusInputRange(selection.from, selection.to, selection.afterInvocationId);
2591 }
2592 }
2593 };
2594
2595 const selectAllComposerText = () => {
2596 setInputMenuPoint(null);
2597 focusInputRange(0, text.length);
2598 };
2599
2600 const openInputMenu = (event: ReactMouseEvent<HTMLElement>) => {
2601 event.preventDefault();
2602 event.stopPropagation();
2603 rememberCaret();
2604 setInputMenuPoint(contextMenuPointFromEvent(event));
2605 };
2606
2607 const hasWorkspaceReferenceDrag = (dataTransfer: DataTransfer): boolean =>
2608 Array.from(dataTransfer.types).includes(WORKSPACE_REF_DRAG_TYPE);
2609
2610 const hasFileDrag = (dataTransfer: DataTransfer): boolean =>
2611 Array.from(dataTransfer.items).some((it) => it.kind === "file") || dataTransfer.files.length > 0;
2612
2613 const fileDragItems = (dataTransfer: DataTransfer): DataTransferItem[] =>
2614 Array.from(dataTransfer.items).filter((item) => item.kind === "file");
2615
2616 const getWebkitFileEntry = (item: DataTransferItem): WebkitFileEntry | null => {
2617 const getAsEntry = (item as DataTransferItem & { webkitGetAsEntry?: () => WebkitFileEntry | null }).webkitGetAsEntry;
2618 return typeof getAsEntry === "function" ? getAsEntry.call(item) : null;
2619 };
2620
2621 const hasPathlessFileDrop = (dataTransfer: DataTransfer): boolean => {
2622 const items = fileDragItems(dataTransfer);
2623 if (items.length === 0) return dataTransfer.files.length > 0;
2624 return items.some((item) => getWebkitFileEntry(item) === null);
2625 };
2626
2627 const clearWailsDropTarget = () => {
2628 document.querySelectorAll(".wails-drop-target-active").forEach((el) => el.classList.remove("wails-drop-target-active"));
2629 };
2630
2631 const stopNativeFileDrop = (e: DragEvent<HTMLDivElement>) => {
2632 e.preventDefault();
2633 e.stopPropagation();
2634 e.nativeEvent.stopImmediatePropagation();
2635 clearWailsDropTarget();
2636 };
2637
2638 const onFileDropCapture = (e: DragEvent<HTMLDivElement>) => {
2639 if (hasWorkspaceReferenceDrag(e.dataTransfer) || !hasFileDrag(e.dataTransfer)) return;
2640 e.preventDefault();
2641 if (!hasPathlessFileDrop(e.dataTransfer)) return;
2642 const files = Array.from(e.dataTransfer.files);
2643 if (files.length === 0) return;
2644 stopNativeFileDrop(e);
2645 setDragOver(false);
2646 attachFiles(files);
2647 };
2648
2649 const onDrop = (e: DragEvent<HTMLDivElement>) => {
2650 const droppedWorkspaceRef = readWorkspaceReferenceDrag(e.dataTransfer);
2651 if (droppedWorkspaceRef) {
2652 e.preventDefault();
2653 setDragOver(false);
2654 addWorkspaceReference(droppedWorkspaceRef);
2655 return;
2656 }
2657
2658 // OS file drops deliver no usable bytes/paths here; the native bridge
2659 // (onFilesDropped -> AttachDropped) handles them. Prevent webview navigation.
2660 if (hasFileDrag(e.dataTransfer)) {
2661 e.preventDefault();
2662 setDragOver(false);
2663 }
2664 };
2665
2666 const onDragOver = (e: DragEvent<HTMLDivElement>) => {
2667 if (!hasWorkspaceReferenceDrag(e.dataTransfer) && !hasFileDrag(e.dataTransfer)) return;
2668 e.preventDefault(); // required for the drop event to fire
2669 e.dataTransfer.dropEffect = "copy";
2670 setDragOver(true);
2671 };
2672
2673 const onDragLeave = () => setDragOver(false);
2674
2675 // handleCancel stops the in-flight turn; if it was cancelled before the server
2676 // replied, the just-sent text is handed back so we drop it back into the input.
2677 const handleCancel = () => {
2678 const restored = onCancel();
2679 if (goalModeOn && activeGoal) onClearGoal();
2680 // A user-requested cancel must not let the natural-completion effect submit
2681 // the queued follow-up. Fold it back into the draft: cancelling means "stop
2682 // acting", not "discard what I typed" — the same contract onCancel already
2683 // honors for un-sent text. Structured items fold back as their slash form
2684 // (structured.display is valid /name syntax) so the invocation survives the
2685 // round trip instead of degrading to its bare task text.
2686 const queued = pendingGuidance
2687 .map((item) => item.structured?.display ?? item.text)
2688 .filter((part) => part.trim() !== "");
2689 if (queued.length === 0) {
2690 if (typeof restored === "string") setTextCaretEnd(restored);
2691 return;
2692 }
2693 updatePendingGuidanceForDraft(activeDraftKeyRef.current, () => []);
2694 setGuidanceExpanded(false);
2695 const base = typeof restored === "string" ? restored : text;
2696 setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
2697 };
2698
2699 const pickCommand = (c: CommandInfo) => {
2700 const query = activeSlashQuery;
2701 if (!query || slashCommandDisabled(c)) return;
2702 if (!commandUsesStructuredInvocation(c)) {
2703 if (invocationsRef.current.length > 0 && richSlashQuery) {
2704 richInputRef.current?.replaceRange(`/${c.name} `, richSlashQuery.from, richSlashQuery.to);
2705 } else {
2706 const targetDraftKey = activeDraftKeyRef.current;
2707 const beforeEdit = composerEditSnapshot(targetDraftKey, { start: query.from, end: query.to });
2708 const next = replaceInvocationTextRange(
2709 textRef.current,
2710 invocationsRef.current,
2711 query.from,
2712 query.to,
2713 `/${c.name} `,
2714 );
2715 const caret = query.from + c.name.length + 2;
2716 textRef.current = next.text;
2717 setText(next.text);
2718 setComposerSelection(caret);
2719 recordComposerEdit(
2720 targetDraftKey,
2721 beforeEdit,
2722 composerEditSnapshot(targetDraftKey, { start: caret, end: caret }),
2723 );
2724 }
2725 return;
2726 }
2727 if (invocationsRef.current.length > 0 && richSlashQuery) {
2728 richInputRef.current?.insertInvocation(c, richSlashQuery);
2729 setRichSlashQuery(null);
2730 return;
2731 }
2732 const targetDraftKey = activeDraftKeyRef.current;
2733 const beforeEdit = composerEditSnapshot(targetDraftKey, { start: query.from, end: query.to });
2734 const invocation: ComposerInvocation = {
2735 id: `composer-invocation-${nextInvocationId.current++}`,
2736 offset: query.from,
2737 command: c,
2738 };
2739 const next = replaceInvocationTextRange(
2740 textRef.current,
2741 invocationsRef.current,
2742 query.from,
2743 query.to,
2744 "",
2745 );
2746 textRef.current = next.text;
2747 invocationsRef.current = [invocation];
2748 setText(next.text);
2749 setInvocations([invocation]);
2750 setRichSlashQuery(null);
2751 recordComposerEdit(
2752 targetDraftKey,
2753 beforeEdit,
2754 composerEditSnapshot(targetDraftKey, {
2755 start: query.from,
2756 end: query.from,
2757 afterInvocationId: invocation.id,
2758 }),
2759 );
2760 requestActiveDraftFrame(() => richInputRef.current?.setSelectionRange(
2761 query.from,
2762 query.from,
2763 invocation.id,
2764 ));
2765 };
2766
2767 const activePastedBlocks = pastedBlocks.filter((block) => text.includes(block.label));
2768 const shellModeActive = text.trimStart().startsWith("!");
2769
2770 const removeWorkspaceReference = (target: WorkspaceReference) => {
2771 const key = workspaceReferenceKey(target);
2772 setWorkspaceRefs((prev) => prev.filter((ref) => workspaceReferenceKey(ref) !== key));
2773 requestActiveDraftFrame(focusComposerInput);
2774 };
2775
2776 const togglePastedPreview = (label: string) => {
2777 setOpenPastedLabels((prev) => {
2778 const next = prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label];
2779 openPastedLabelsRef.current = next;
2780 return next;
2781 });
2782 };
2783
2784 const replacePastedBlockLabel = (block: PastedBlock, replacement: string): number | null => {
2785 const current = textRef.current;
2786 const start = current.indexOf(block.label);
2787 if (start < 0) return null;
2788 const next = replaceInvocationTextRange(
2789 current,
2790 invocationsRef.current,
2791 start,
2792 start + block.label.length,
2793 replacement,
2794 );
2795 textRef.current = next.text;
2796 invocationsRef.current = next.invocations;
2797 setText(next.text);
2798 setInvocations(next.invocations);
2799 setComposerSelection(next.text.length);
2800 return next.text.length;
2801 };
2802
2803 const removePastedBlock = (block: PastedBlock) => {
2804 const targetDraftKey = activeDraftKeyRef.current;
2805 const beforeEdit = composerEditSnapshot(targetDraftKey);
2806 const nextBlocks = pastedBlocksRef.current.filter((x) => x.label !== block.label);
2807 const nextOpenLabels = openPastedLabelsRef.current.filter((x) => x !== block.label);
2808 pastedBlocksRef.current = nextBlocks;
2809 openPastedLabelsRef.current = nextOpenLabels;
2810 setPastedBlocks(nextBlocks);
2811 setOpenPastedLabels(nextOpenLabels);
2812 const caret = replacePastedBlockLabel(block, "");
2813 if (caret !== null) {
2814 recordComposerEdit(
2815 targetDraftKey,
2816 beforeEdit,
2817 composerEditSnapshot(targetDraftKey, { start: caret, end: caret }),
2818 );
2819 }
2820 };
2821
2822 const expandPastedBlock = (block: PastedBlock) => {
2823 const targetDraftKey = activeDraftKeyRef.current;
2824 const beforeEdit = composerEditSnapshot(targetDraftKey);
2825 const nextBlocks = pastedBlocksRef.current.filter((x) => x.label !== block.label);
2826 const nextOpenLabels = openPastedLabelsRef.current.filter((x) => x !== block.label);
2827 pastedBlocksRef.current = nextBlocks;
2828 openPastedLabelsRef.current = nextOpenLabels;
2829 setPastedBlocks(nextBlocks);
2830 setOpenPastedLabels(nextOpenLabels);
2831 const caret = replacePastedBlockLabel(block, block.text);
2832 if (caret !== null) {
2833 recordComposerEdit(
2834 targetDraftKey,
2835 beforeEdit,
2836 composerEditSnapshot(targetDraftKey, { start: caret, end: caret }),
2837 );
2838 }
2839 };
2840
2841 useEffect(() => {
2842 const onResize = () => setComposerHeight((height) => (height === null ? null : clampComposerHeight(height)));
2843 window.addEventListener("resize", onResize);
2844 return () => window.removeEventListener("resize", onResize);
2845 }, []);
2846
2847 const measureTextareaAutoHeight = useCallback(() => {
2848 if (composerHeight !== null) {
2849 setTextareaAutoHeight(null);
2850 setTextareaAutoOverflow(false);
2851 return;
2852 }
2853 // Creation empty hero starts single-line but must grow so multi-line drafts
2854 // stay readable before send (review: fixed 20px + overflow:hidden clipped).
2855 if (heroMode) {
2856 const node = taRef.current;
2857 if (!node) {
2858 setTextareaAutoHeight(20);
2859 setTextareaAutoOverflow(false);
2860 return;
2861 }
2862 const previousHeight = node.style.height;
2863 node.style.height = "auto";
2864 const scrollHeight = node.scrollHeight || 20;
2865 const maxHeight = 96;
2866 const nextHeight = Math.min(Math.max(scrollHeight, 20), maxHeight);
2867 const nextOverflow = scrollHeight > maxHeight + 1;
2868 node.style.height = previousHeight;
2869 setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
2870 setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
2871 return;
2872 }
2873 const richHeight = invocationsRef.current.length > 0 ? richInputRef.current?.scrollHeight() : 0;
2874 const node = taRef.current;
2875 if (!richHeight && !node) return;
2876 const previousHeight = node?.style.height;
2877 if (node) node.style.height = "auto";
2878 const scrollHeight = richHeight || node?.scrollHeight || 0;
2879 const maxHeight = composerAutoInputMaxHeight();
2880 const nextHeight = Math.min(scrollHeight, maxHeight);
2881 const nextOverflow = scrollHeight > maxHeight + 1;
2882 if (node && previousHeight !== undefined) node.style.height = previousHeight;
2883 setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
2884 setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
2885 }, [composerHeight, heroMode, invocations.length]);
2886
2887 useLayoutEffect(() => {
2888 measureTextareaAutoHeight();
2889 }, [text, measureTextareaAutoHeight]);
2890
2891 useEffect(() => {
2892 if (composerHeight !== null) return;
2893 let frame = 0;
2894 const update = () => {
2895 if (frame) window.cancelAnimationFrame(frame);
2896 frame = window.requestAnimationFrame(() => {
2897 frame = 0;
2898 measureTextareaAutoHeight();
2899 });
2900 };
2901 window.addEventListener("resize", update);
2902 const observer = new MutationObserver(update);
2903 observer.observe(document.documentElement, {
2904 attributes: true,
2905 attributeFilter: ["data-text-size", "data-font-family", "data-mono-font-family", "style"],
2906 });
2907 return () => {
2908 if (frame) window.cancelAnimationFrame(frame);
2909 window.removeEventListener("resize", update);
2910 observer.disconnect();
2911 };
2912 }, [composerHeight, measureTextareaAutoHeight]);
2913
2914 const saveComposerHeight = (height: number) => {
2915 saveLayoutSize("composerHeight", height, clampComposerHeight);
2916 };
2917
2918 const resetComposerHeight = () => {
2919 setComposerHeight(null);
2920 clearLayoutSize("composerHeight");
2921 };
2922
2923 const onComposerResizeStart = (e: ReactPointerEvent<HTMLButtonElement>) => {
2924 if (e.button !== 0) return;
2925 const card = composerCardRef.current;
2926 if (!card) return;
2927
2928 e.preventDefault();
2929 const startY = e.clientY;
2930 const startHeight = composerHeight ?? composerLogicalHeight(card);
2931 let nextHeight = clampComposerHeight(startHeight);
2932 let moved = false;
2933 card.style.setProperty("--composer-height", `${nextHeight}px`);
2934 e.currentTarget.setAttribute("aria-valuenow", String(nextHeight));
2935 const liveResize = createRafResizeUpdater({
2936 target: card,
2937 separator: e.currentTarget,
2938 cssVar: "--composer-height",
2939 });
2940 setComposerResizing(true);
2941 document.body.classList.add("composer-resizing");
2942
2943 const onMove = (event: PointerEvent) => {
2944 moved = true;
2945 nextHeight = clampComposerHeight(startHeight + startY - event.clientY);
2946 liveResize.schedule(nextHeight);
2947 };
2948 const onUp = () => {
2949 liveResize.flush();
2950 setComposerResizing(false);
2951 document.body.classList.remove("composer-resizing");
2952 if (moved) {
2953 setComposerHeight(nextHeight);
2954 saveComposerHeight(nextHeight);
2955 }
2956 document.removeEventListener("pointermove", onMove);
2957 document.removeEventListener("pointerup", onUp);
2958 document.removeEventListener("pointercancel", onUp);
2959 };
2960
2961 document.addEventListener("pointermove", onMove);
2962 document.addEventListener("pointerup", onUp);
2963 document.addEventListener("pointercancel", onUp);
2964 };
2965
2966 const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
2967 const card = composerCardRef.current;
2968 const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
2969 const step = e.shiftKey ? 32 : 16;
2970 let next: number | null = null;
2971 if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
2972 else if (e.key === "ArrowDown" || e.key === "PageDown") next = current - step;
2973 else if (e.key === "Home") next = COMPOSER_MIN_HEIGHT;
2974 else if (e.key === "End") next = composerMaxHeight();
2975 if (next === null) return;
2976 e.preventDefault();
2977 const height = clampComposerHeight(next);
2978 setComposerHeight(height);
2979 saveComposerHeight(height);
2980 };
2981
2982 const pickEntry = (e: DirEntry) => {
2983 const picked = composerPickFileEntry(text, atRaw, atDir, e);
2984 if (picked.workspaceRef) {
2985 setTextCaretEnd(picked.text);
2986 addWorkspaceReference(picked.workspaceRef);
2987 return;
2988 }
2989 // A directory keeps the menu open (trailing "/"); a file completes it (space).
2990 setTextCaretEnd(picked.text);
2991 };
2992
2993 // --- past:chats session reference ---
2994 const openPastChats = useCallback(async (initialQuery = "") => {
2995 const snapshotCwd = cwdRef.current;
2996 const sourceDraftKey = activeDraftKeyRef.current;
2997 setShowPastChats(true);
2998 setActive(0);
2999 setPastChatQuery(initialQuery);
3000 setLoadingPastChats(true);
3001 try {
3002 const sessions = await app.ListSessions();
3003 // Discard stale response if workspace changed while the request was in-flight.
3004 if (cwdRef.current !== snapshotCwd || activeDraftKeyRef.current !== sourceDraftKey) return;
3005 const sorted = asArray(sessions)
3006 .filter((s) => !s.current)
3007 .sort((a, b) => {
3008 const at = a.lastActivityAt || a.modTime || a.createdAt || 0;
3009 const bt = b.lastActivityAt || b.modTime || b.createdAt || 0;
3010 return bt - at;
3011 })
3012 .slice(0, 50);
3013 setPastChats(sorted);
3014 } catch {
3015 if (cwdRef.current !== snapshotCwd || activeDraftKeyRef.current !== sourceDraftKey) return;
3016 setPastChats([]);
3017 } finally {
3018 if (cwdRef.current === snapshotCwd && activeDraftKeyRef.current === sourceDraftKey) setLoadingPastChats(false);
3019 }
3020 }, []);
3021
3022 useEffect(() => {
3023 if (!pastChatToken || directPastChats || dismissed || running || disabled || readOnly) return;
3024 setDirectPastChats(true);
3025 void openPastChats(pastChatToken.query);
3026 }, [directPastChats, disabled, dismissed, openPastChats, pastChatToken, readOnly, running]);
3027
3028 const clearDirectPastChatToken = () => {
3029 const current = textRef.current;
3030 const token = activePastChatToken(current);
3031 if (!token) return current.length;
3032 const next = replaceInvocationTextRange(current, invocationsRef.current, token.from, current.length, "");
3033 textRef.current = next.text;
3034 invocationsRef.current = next.invocations;
3035 setText(next.text);
3036 setInvocations(next.invocations);
3037 return token.from;
3038 };
3039
3040 const dismissDirectPastChats = () => {
3041 // Keep the literal token text — "#6310" may be an issue number or a
3042 // heading, not a session query. Dismissing only closes the panel;
3043 // `dismissed` suppresses reopening until the query changes, the same
3044 // contract as the slash and @ menus. Selecting a session (pickSession)
3045 // is the only path that consumes the token.
3046 setDismissed(true);
3047 setDirectPastChats(false);
3048 setShowPastChats(false);
3049 setPastChatQuery("");
3050 setActive(0);
3051 requestActiveDraftFrame(focusComposerInput);
3052 };
3053
3054 // The typed panel follows the live token: typing in the composer extends
3055 // the query, and deleting the token (or ending it with whitespace) closes
3056 // the panel instead of leaving it open on a stale query.
3057 useEffect(() => {
3058 if (!directPastChats) return;
3059 if (pastChatTokenQuery === null) {
3060 setDirectPastChats(false);
3061 setShowPastChats(false);
3062 setPastChatQuery("");
3063 setActive(0);
3064 return;
3065 }
3066 setPastChatQuery(pastChatTokenQuery);
3067 }, [directPastChats, pastChatTokenQuery]);
3068
3069 const insertContentTrigger = (trigger: "@" | "#" | "/") => {
3070 const selection = getInputSelection();
3071 const targetDraftKey = activeDraftKeyRef.current;
3072 const beforeEdit = composerEditSnapshot(targetDraftKey, {
3073 start: selection.from,
3074 end: selection.to,
3075 afterInvocationId: selection.afterInvocationId,
3076 });
3077 const current = textRef.current;
3078 const needsSpace = selection.from > 0 && !/\s/.test(current.charAt(selection.from - 1));
3079 const value = `${needsSpace ? " " : ""}${trigger}`;
3080 setContentMenuOpen(false);
3081 setDirectPastChats(false);
3082 setShowPastChats(false);
3083 setDismissed(false);
3084 replaceInputRange(
3085 value,
3086 selection.from,
3087 selection.to,
3088 targetDraftKey,
3089 selection.afterInvocationId,
3090 );
3091 const caret = selection.from + value.length;
3092 recordComposerEdit(
3093 targetDraftKey,
3094 beforeEdit,
3095 composerEditSnapshot(targetDraftKey, { start: caret, end: caret }),
3096 );
3097 if (trigger === "#") {
3098 setDirectPastChats(true);
3099 void openPastChats();
3100 }
3101 };
3102
3103 const openContentMenu = () => {
3104 if (intentMenuOpen || intentMenuClosing) closeIntentMenu();
3105 if (profileMenuOpen || profileMenuClosing) closeProfileMenu();
3106 if (moreMenuOpen || moreMenuClosing) closeMoreMenu();
3107 setDirectPastChats(false);
3108 setShowPastChats(false);
3109 setDismissed(true);
3110 setContentMenuOpen(true);
3111 };
3112
3113 const chooseAttachmentFiles = () => {
3114 setContentMenuOpen(false);
3115 fileInputRef.current?.click();
3116 };
3117
3118 // PR-C1: client-side filter for the past:chats list. Matches against the
3119 // human-visible fields (title, topic, preview, path, workspace) so users
3120 // can narrow long session lists without a backend round-trip. Lowercased
3121 // substring match keeps the behaviour predictable across locales.
3122 const filteredPastChats = useMemo(() => {
3123 const q = pastChatQuery.trim().toLowerCase();
3124 if (!q) return pastChats;
3125 return pastChats.filter((session) =>
3126 [
3127 session.title,
3128 session.topicTitle,
3129 session.preview,
3130 session.path,
3131 session.workspaceRoot,
3132 ]
3133 .map((value) => String(value ?? "").toLowerCase())
3134 .some((value) => value.includes(q)),
3135 );
3136 }, [pastChats, pastChatQuery]);
3137
3138 // Final menu item count: when the past:chats list is open, count the
3139 // filtered sessions instead of file entries + the "past:chats" row.
3140 const count = (menuMode === "at" && showPastChats) || menuMode === "pastChats"
3141 ? filteredPastChats.length
3142 : countBase;
3143
3144 // Clamp active index when the menu item count changes (e.g. switching
3145 // between file list and past:chats list, or filtering sessions).
3146 useEffect(() => {
3147 if (menuMode === "slash") {
3148 if (!slashSelectableIndices.includes(active)) {
3149 setActive(slashSelectableIndices[0] ?? 0);
3150 }
3151 return;
3152 }
3153 const maxIdx = Math.max(0, count - 1);
3154 setActive((prev) => (prev > maxIdx ? 0 : prev));
3155 }, [active, count, menuMode, slashSelectableIndices]);
3156
3157
3158 const removeAtToken = (value: string) => {
3159 return value.replace(/[\r\n]+$/u, "").replace(activeRefTokenRe, "").trimEnd();
3160 };
3161
3162 const pickSession = (session: SessionMeta) => {
3163 setSessionRefs((prev) => {
3164 if (prev.some((x) => x.path === session.path)) {
3165 return prev;
3166 }
3167 return [
3168 ...prev,
3169 {
3170 path: session.path,
3171 title: session.title || session.topicTitle || session.preview || "Untitled",
3172 preview: session.preview,
3173 turns: session.turns,
3174 createdAt: session.createdAt,
3175 lastActivityAt: session.lastActivityAt,
3176 },
3177 ];
3178 });
3179 const caret = directPastChats ? clearDirectPastChatToken() : null;
3180 if (!directPastChats) setText((prev) => removeAtToken(prev));
3181 setDirectPastChats(false);
3182 setPastChatQuery("");
3183 setShowPastChats(false);
3184 setActive(0);
3185 setComposerSelection(caret ?? textRef.current.length);
3186 };
3187
3188 const removeSessionRef = (path: string) => {
3189 setSessionRefs((prev) => prev.filter((ref) => ref.path !== path));
3190 };
3191
3192 // pickArg replaces just the current token with the suggestion. A "descend" item
3193 // (e.g. "/skill show ") ends with a space, so the effect re-fetches the next
3194 // level; a terminal item leaves the menu (next fetch returns nothing).
3195 const pickArg = (it: SlashArgItem) => {
3196 if (!argRes) return;
3197 setTextCaretEnd(slashText.slice(0, argRes.from) + it.insert);
3198 };
3199
3200 const pickActive = () => {
3201 if (menuMode === "slash") {
3202 const item = slashMatches[active];
3203 if (item && !slashCommandDisabled(item)) pickCommand(item);
3204 return;
3205 }
3206 if (menuMode === "slasharg" && argRes) {
3207 const item = argRes.items[active];
3208 if (item) pickArg(item);
3209 return;
3210 }
3211 if (menuMode === "at" || menuMode === "pastChats") {
3212 if (showPastChats) {
3213 const session = filteredPastChats[active];
3214 if (session) pickSession(session);
3215 return;
3216 }
3217 if (menuMode === "pastChats") return;
3218 const item = atMenuItems[active];
3219 if (!item) return;
3220 if (item.kind === "pastChats") {
3221 void openPastChats();
3222 return;
3223 }
3224 pickEntry(item.entry);
3225 }
3226 };
3227
3228 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
3229 const composing = isImeKeyEvent(e, composingRef.current, lastCompositionEndAt.current);
3230 const native = e.nativeEvent as globalThis.KeyboardEvent & {
3231 keyCode?: number;
3232 which?: number;
3233 code?: string;
3234 };
3235 const fnKey = isFnKeyEvent(native);
3236 const historyDirection = promptHistoryDirectionFromEvent({
3237 key: e.key,
3238 code: native.code,
3239 keyCode: native.keyCode,
3240 which: native.which,
3241 });
3242
3243 if (e.key === "Enter" && composing) return;
3244 if (fnKey) return;
3245
3246 if (isPasteShortcut(e) && !composing) {
3247 clearNativeClipboardPasteTimer();
3248 const sourceDraftKey = activeDraftKeyRef.current;
3249 nativeClipboardPasteTimerRef.current = window.setTimeout(() => {
3250 nativeClipboardPasteTimerRef.current = null;
3251 void attachNativeClipboardImage(false, sourceDraftKey);
3252 }, 160);
3253 }
3254
3255 // Shift+Tab toggles plan mode only. Tool access is deliberately changed via
3256 // the access menu so keyboard cycling never crosses a permission boundary.
3257 if (e.key === "Tab" && e.shiftKey && !composing) {
3258 e.preventDefault();
3259 onCycleMode();
3260 return;
3261 }
3262
3263 if (
3264 !composing
3265 && !isReservedComposerHistoryShortcut(e.nativeEvent, shortcutPlatform)
3266 && matchesShortcut(e.nativeEvent, "toolApproval.yolo", shortcutPlatform)
3267 ) {
3268 e.preventDefault();
3269 onToggleYoloApprovalMode();
3270 return;
3271 }
3272
3273 syncPromptHistoryGeneration();
3274
3275 const inputSelection = getComposerSelection();
3276 const inputValue = textRef.current;
3277
3278 const canUseCurrentPromptHistory = () => canUsePromptHistory({
3279 direction: historyDirection,
3280 menuOpen: Boolean(menuMode),
3281 composing,
3282 altKey: e.altKey,
3283 ctrlKey: e.ctrlKey,
3284 metaKey: e.metaKey,
3285 shiftKey: e.shiftKey,
3286 fnKey,
3287 value: inputValue,
3288 selectionStart: inputSelection.start,
3289 selectionEnd: inputSelection.end,
3290 historyIndex: historyIndexRef.current,
3291 }) && invocationsRef.current.length === 0;
3292
3293 // Prompt history navigation: plain ↑/↓ only. Fn/Page/Home/End are left to
3294 // the native textarea/OS so macOS dictation and text navigation keep working.
3295
3296 // When navigating history, any other key (letter, Backspace, etc.) resets
3297 // back to the saved draft when another key is used.
3298 if (historyIndexRef.current !== -1 && !canUseCurrentPromptHistory()) {
3299 historyIndexRef.current = -1;
3300 setHistoryIndex(-1);
3301 }
3302
3303 if (canUseCurrentPromptHistory()) {
3304 e.preventDefault();
3305 const sourceDraftKey = activeDraftKeyRef.current;
3306 void (async () => {
3307 // Keep the navigation result with the draft where the key was pressed;
3308 // loading older history may outlive a tab switch.
3309 if (historyIndexRef.current === -1) {
3310 savedTextRef.current = text; // save current draft
3311 }
3312 const sourceIndex = historyIndexRef.current;
3313 const target =
3314 historyDirection === "up"
3315 ? sourceIndex + 1
3316 : historyDirection === "down"
3317 ? sourceIndex - 1
3318 : sourceIndex;
3319 if (target >= historyEntriesRef.current.length && !(await ensurePromptHistoryIndex(target))) {
3320 return;
3321 }
3322 const next =
3323 historyDirection === "up"
3324 ? Math.min(target, historyEntriesRef.current.length - 1)
3325 : historyDirection === "down"
3326 ? Math.max(target, -1)
3327 : sourceIndex;
3328 const historyText = next === -1 ? null : historyEntriesRef.current[next]?.text ?? "";
3329 if (sourceDraftKey === activeDraftKeyRef.current) {
3330 historyIndexRef.current = next;
3331 setHistoryIndex(next);
3332 setTextCaretEnd(historyText ?? savedTextRef.current);
3333 } else {
3334 const beforeEdit = composerEditSnapshot(sourceDraftKey);
3335 const draft = cloneComposerDraft(draftsBySessionRef.current[sourceDraftKey] ?? emptyComposerDraft());
3336 draft.historyIndex = next;
3337 draft.text = historyText ?? draft.savedText;
3338 draftsBySessionRef.current[sourceDraftKey] = draft;
3339 recordComposerEdit(
3340 sourceDraftKey,
3341 beforeEdit,
3342 composerEditSnapshot(sourceDraftKey, { start: draft.text.length, end: draft.text.length }),
3343 );
3344 }
3345 if (historyDirection === "up" && historyEntriesRef.current.length - 1 - next <= PROMPT_HISTORY_PREFETCH_REMAINING) {
3346 prefetchPromptHistoryTail();
3347 }
3348 })();
3349 return;
3350 }
3351
3352 if (menuMode && !composing) {
3353 if (e.key === "ArrowDown" && count > 0) {
3354 e.preventDefault();
3355 if (menuMode === "slash") {
3356 if (slashSelectableIndices.length > 0) {
3357 setActive((current) => {
3358 const currentPosition = slashSelectableIndices.indexOf(current);
3359 return slashSelectableIndices[(currentPosition + 1) % slashSelectableIndices.length];
3360 });
3361 }
3362 } else {
3363 setActive((i) => (i + 1) % count);
3364 }
3365 return;
3366 }
3367 if (e.key === "ArrowUp" && count > 0) {
3368 e.preventDefault();
3369 if (menuMode === "slash") {
3370 if (slashSelectableIndices.length > 0) {
3371 setActive((current) => {
3372 const currentPosition = slashSelectableIndices.indexOf(current);
3373 const previousPosition = currentPosition < 0 ? 0 : currentPosition - 1;
3374 return slashSelectableIndices[
3375 (previousPosition + slashSelectableIndices.length) % slashSelectableIndices.length
3376 ];
3377 });
3378 }
3379 } else {
3380 setActive((i) => (i - 1 + count) % count);
3381 }
3382 return;
3383 }
3384 if (e.key === "Enter" || e.key === "Tab") {
3385 e.preventDefault();
3386 pickActive();
3387 return;
3388 }
3389 if (e.key === "Escape") {
3390 e.preventDefault();
3391 if (menuMode === "pastChats") {
3392 dismissDirectPastChats();
3393 } else if (showPastChats) {
3394 setPastChatQuery("");
3395 setShowPastChats(false);
3396 setActive(0);
3397 } else {
3398 setDismissed(true);
3399 }
3400 return;
3401 }
3402 }
3403
3404 // The send chord (default Enter) sends and the newline chord (default
3405 // Shift+Enter) breaks the line — both configurable in Settings →
3406 // Shortcuts. The default send layout retains legacy modified-Enter send
3407 // aliases; explicit custom bindings are exact. `composing` guards IME confirms.
3408 if (e.key === "Enter" && !composing) {
3409 const enterAction = composerEnterAction(e.nativeEvent, shortcutPlatform);
3410 if (enterAction === "newline-insert") {
3411 e.preventDefault();
3412 insertNewlineAtCaret();
3413 return;
3414 }
3415 if (enterAction === "send") {
3416 e.preventDefault();
3417 submit();
3418 return;
3419 }
3420 if (enterAction !== "newline-native") {
3421 e.preventDefault();
3422 return;
3423 }
3424 // "newline-native" falls through so the input inserts the break itself.
3425 }
3426 // Esc interrupts the in-flight turn (matches the Stop button's hint), and
3427 // restores the text if the server hadn't replied yet.
3428 if (e.key === "Escape" && running) {
3429 e.preventDefault();
3430 handleCancel();
3431 }
3432
3433 // Browser undo owns ordinary DOM edits, while programmatic composer edits
3434 // live in the per-draft transaction stacks. Native barriers preserve the
3435 // real ordering even when later browser edits happen to return to the same
3436 // text (for example type then Backspace).
3437 const undoShortcut = matchesShortcut(e.nativeEvent, "composer.undo", shortcutPlatform);
3438 const redoShortcut = matchesShortcut(e.nativeEvent, "composer.redo", shortcutPlatform)
3439 || (
3440 shortcutPlatform !== "darwin"
3441 && e.ctrlKey
3442 && !e.metaKey
3443 && !e.altKey
3444 && !e.shiftKey
3445 && e.key.toLowerCase() === "y"
3446 );
3447 if (!composing && (undoShortcut || redoShortcut)) {
3448 const targetDraftKey = activeDraftKeyRef.current;
3449 const history = editHistoryForDraft(targetDraftKey);
3450 const current = composerEditSnapshot(targetDraftKey);
3451
3452 if (undoShortcut) {
3453 const transaction = history.undo[history.undo.length - 1];
3454 if (history.undoNativeBarrier) return;
3455 if (!transaction) return;
3456 if (!composerEditStateMatches(current, transaction.after)) {
3457 // A programmatic owner changed state without joining either history.
3458 // Do not let a stale browser entry mutate that unknown boundary.
3459 e.preventDefault();
3460 return;
3461 }
3462 e.preventDefault();
3463 undoComposerEdit(targetDraftKey);
3464 return;
3465 }
3466
3467 const transaction = history.redo[history.redo.length - 1];
3468 if (history.redoNativeBarrier) return;
3469 if (!transaction) {
3470 // A custom edit is a new branch and invalidates native redo entries
3471 // that the browser cannot see. With no custom history, native redo
3472 // remains fully browser-owned.
3473 if (history.undo.length > 0) e.preventDefault();
3474 return;
3475 }
3476 if (!composerEditStateMatches(current, transaction.before)) {
3477 e.preventDefault();
3478 return;
3479 }
3480 e.preventDefault();
3481 redoComposerEdit(targetDraftKey);
3482 }
3483 };
3484
3485 // Keydown handler for the past:chats search <input>. The search input is a
3486 // sibling of the <textarea>, so keyboard events never reach the textarea's
3487 // onKeyDown. We intercept navigation keys here and delegate to the same
3488 // menu logic. Regular typing keys (letters, Backspace, etc.) pass through
3489 // so the user can type a search query.
3490 const onPastChatSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
3491 if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === "Tab" || e.key === "Escape") {
3492 e.preventDefault();
3493 e.stopPropagation();
3494 if (e.key === "ArrowDown" && count > 0) {
3495 setActive((i) => (i + 1) % count);
3496 } else if (e.key === "ArrowUp" && count > 0) {
3497 setActive((i) => (i - 1 + count) % count);
3498 } else if (e.key === "Enter" || e.key === "Tab") {
3499 pickActive();
3500 } else if (e.key === "Escape") {
3501 if (menuMode === "pastChats") dismissDirectPastChats();
3502 else {
3503 setPastChatQuery("");
3504 setShowPastChats(false);
3505 setActive(0);
3506 }
3507 }
3508 }
3509 };
3510
3511 // When the run strip is visible inside a user-resized card, the card grows
3512 // by the strip's reserved height so the meta row stays fully visible.
3513 // --composer-height carries only the user's logical height; the reservation
3514 // is a separate variable consumed by the CSS calc, so the live resize drag
3515 // (which writes raw logical heights) stays consistent with this render path.
3516 const showRunStrip = Boolean(retry || running);
3517 const composerCardStyle = composerHeight === null
3518 ? undefined
3519 : ({
3520 "--composer-height": `${composerHeight}px`,
3521 "--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
3522 } as CSSProperties);
3523 const textareaStyle = composerHeight === null && textareaAutoHeight !== null
3524 ? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
3525 : undefined;
3526 const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
3527 const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
3528 void onSetMode;
3529 const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
3530 onSetToolApprovalMode(nextMode);
3531 requestActiveDraftFrame(focusComposerInput);
3532 };
3533 const chooseTaskMode = (nextMode: CollaborationMode) => {
3534 closeIntentMenu(() => {
3535 if (nextMode !== collaborationMode) onSetCollaborationMode(nextMode);
3536 requestActiveDraftFrame(focusComposerInput);
3537 });
3538 };
3539 const stopGoalMode = () => {
3540 closeIntentMenu(() => {
3541 onClearGoal();
3542 requestActiveDraftFrame(focusComposerInput);
3543 });
3544 };
3545 const chooseTokenMode = (mode: TokenMode) => {
3546 closeProfileMenu(() => {
3547 if (mode !== tokenMode) onSetTokenMode(mode);
3548 requestActiveDraftFrame(focusComposerInput);
3549 });
3550 };
3551 const runtimeProfileShortKey = tokenMode === "economy"
3552 ? "composer.runtimeProfileEconomyShort"
3553 : tokenMode === "delivery"
3554 ? "composer.runtimeProfileDeliveryShort"
3555 : "composer.runtimeProfileBalancedShort";
3556 const runtimeProfileTooltipSummaryKey = tokenMode === "economy"
3557 ? "composer.runtimeProfileEconomyTooltipSummary"
3558 : tokenMode === "delivery"
3559 ? "composer.runtimeProfileDeliveryTooltipSummary"
3560 : "composer.runtimeProfileBalancedTooltipSummary";
3561 const RuntimeProfileIcon = tokenMode === "economy" ? Gauge : tokenMode === "delivery" ? Flag : Equal;
3562 const runtimeProfileTriggerLabel = t("composer.runtimeProfileTrigger", { mode: t(runtimeProfileShortKey) });
3563 const runtimeProfileTooltipLabel = t("composer.controlTooltip", {
3564 category: t("composer.runtimeProfileTitle"),
3565 mode: t(runtimeProfileShortKey),
3566 summary: t(runtimeProfileTooltipSummaryKey),
3567 });
3568 const taskModeShortKey = collaborationMode === "plan"
3569 ? "composer.taskModePlanShort"
3570 : collaborationMode === "goal"
3571 ? "composer.taskModeGoalShort"
3572 : "composer.taskModeDirectShort";
3573 const taskModeTooltipSummaryKey = collaborationMode === "plan"
3574 ? "composer.taskModePlanTooltipSummary"
3575 : collaborationMode === "goal"
3576 ? "composer.taskModeGoalTooltipSummary"
3577 : "composer.taskModeDirectTooltipSummary";
3578 const TaskModeIcon = collaborationMode === "plan" ? List : collaborationMode === "goal" ? Target : ArrowRight;
3579 const taskModeTriggerLabel = t("composer.taskModeTrigger", { mode: t(taskModeShortKey) });
3580 const taskModeTooltipLabel = t("composer.controlTooltip", {
3581 category: t("composer.intentMenuTitle"),
3582 mode: t(taskModeShortKey),
3583 summary: t(taskModeTooltipSummaryKey),
3584 });
3585 const effortLevels = asArray(effort?.levels);
3586 const currentEffort = effort?.current || "auto";
3587 const compactEffortTitle = currentEffort === "auto"
3588 ? t("status.effortAutoTitle", { def: effort?.default || "auto" })
3589 : `${t("status.effortTitle")}: ${currentEffort}`;
3590 const hasEffort = Boolean(effort?.supported && effortLevels.length > 0);
3591 const chooseEffortLevel = (level: string) => {
3592 closeMoreMenu(() => {
3593 if (level !== currentEffort) onSetEffort(level);
3594 requestActiveDraftFrame(focusComposerInput);
3595 });
3596 };
3597 // Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
3598 // Decision surfaces own the "waiting on user" UI; while suspendedByDecision
3599 // is true we still pause the work clock but do not render a waiting strip.
3600 const waitingPrompt = suspendedByDecision
3601 ? null
3602 : pendingApprovalLabel
3603 ? "approval"
3604 : pendingAsk
3605 ? "ask"
3606 : null;
3607 const pauseWorkClock = suspendedByDecision || Boolean(waitingPrompt);
3608 // Decision surfaces hide the whole composer, so mode controls stay disabled.
3609 // Legacy tests that pass pendingApprovalLabel without suspendedByDecision
3610 // still keep the approval bar usable mid-prompt.
3611 const approvalBarDisabled = Boolean(disabled) && !(pendingApprovalLabel && !suspendedByDecision);
3612 // Waiting on the user is not model work. Approval/ask wait is owned by the
3613 // per-tab controller (turnWaitAccumMs + promptWaitStartedAt) so background
3614 // tabs keep accumulating. Composer only tracks local pauses for surfaces the
3615 // controller does not know about (clear-context, legacy strip tests).
3616 const controllerTracksWait = typeof promptWaitStartedAt === "number" && promptWaitStartedAt > 0;
3617 const controllerWaitMs = Math.max(0, turnWaitAccumMs || 0)
3618 + (controllerTracksWait ? Math.max(0, now - promptWaitStartedAt) : 0);
3619 const trackLocalPause = pauseWorkClock && !controllerTracksWait;
3620 const [localWaitAccumMs, setLocalWaitAccumMs] = useState(0);
3621 const localPauseSinceRef = useRef<number | null>(null);
3622 useEffect(() => {
3623 localPauseSinceRef.current = null;
3624 setLocalWaitAccumMs(0);
3625 if (trackLocalPause) localPauseSinceRef.current = Date.now();
3626 // trackLocalPause is read from the render that changed draft/turn.
3627 // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional scope-only reset
3628 }, [draftKey, turnStartAt]);
3629 useEffect(() => {
3630 if (trackLocalPause) {
3631 if (localPauseSinceRef.current == null) localPauseSinceRef.current = Date.now();
3632 return;
3633 }
3634 if (localPauseSinceRef.current == null) return;
3635 const delta = Date.now() - localPauseSinceRef.current;
3636 localPauseSinceRef.current = null;
3637 if (delta > 0) setLocalWaitAccumMs((total) => total + delta);
3638 }, [trackLocalPause]);
3639 const localOpenWaitMs = localPauseSinceRef.current != null
3640 ? Math.max(0, now - localPauseSinceRef.current)
3641 : 0;
3642 const waitAccumMs = controllerWaitMs + localWaitAccumMs + localOpenWaitMs;
3643 // Close menus/popovers while a decision surface owns the footer.
3644 useEffect(() => {
3645 if (!suspendedByDecision) return;
3646 setDismissed(true);
3647 setContentMenuOpen(false);
3648 setDirectPastChats(false);
3649 setShowPastChats(false);
3650 closeIntentMenu();
3651 closeProfileMenu();
3652 closeMoreMenu();
3653 }, [suspendedByDecision, closeIntentMenu, closeProfileMenu, closeMoreMenu]);
3654 const runStateText = retry
3655 ? t("status.retrying", { attempt: retry.attempt, max: retry.max })
3656 : waitingPrompt === "approval"
3657 ? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
3658 : waitingPrompt === "ask"
3659 ? t("composer.runWaitingAsk")
3660 : running && !suspendedByDecision
3661 ? t("composer.runAnnounceRunning")
3662 : null;
3663 const runTicker = !retry && !pauseWorkClock && running && turnStartAt
3664 ? (() => {
3665 const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
3666 const words = SPINNER_WORDS[locale];
3667 const word = words[Math.floor(elapsedMs / 3000) % words.length];
3668 const liveTokens = (turnTokens ?? 0) + Math.round((turnArgChars ?? 0) / 4);
3669 const tok = liveTokens > 0 ? ` · ↓ ${formatTokens(liveTokens)} ${t("status.tokens")}` : "";
3670 return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
3671 })()
3672 : null;
3673 const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0 &&
3674 !invocations.some((invocation) => invocation.command.kind === "skill");
3675 const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
3676 const submitTooltip = running
3677 ? t("composer.queueGuidance", { combo: sendComboLabel })
3678 : t("composer.send", { combo: sendComboLabel });
3679 const composerPlaceholder = readOnly
3680 ? t("composer.readOnlyChannel")
3681 : disabled
3682 ? t("common.loading")
3683 : running
3684 ? t("composer.steerPlaceholder", { combo: sendComboLabel })
3685 : goalModeOn && !activeGoal
3686 ? t("composer.goalInputPlaceholder")
3687 : t("composer.placeholder");
3688 const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
3689 const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
3690 const showGuidanceExpander = pendingGuidance.length > 2;
3691 const composerMetaClass = [
3692 "composer-meta",
3693 hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
3694 ].join(" ");
3695
3696 const inputSelection = getInputSelection();
3697 const hasInputSelection = inputSelection.from !== inputSelection.to;
3698 // Platform-correct hint: ⌘ on macOS, Ctrl elsewhere — same formatter the
3699 // shortcut settings UI uses.
3700 const editMenuShortcut = (key: string) =>
3701 formatShortcutCombo(
3702 shortcutPlatform === "darwin" ? { key, meta: true } : { key, ctrl: true },
3703 shortcutPlatform,
3704 );
3705 const inputMenuItems: ContextMenuItem[] = [
3706 {
3707 key: "undo",
3708 label: t("shortcuts.action.composerUndo"),
3709 shortcut: undoComboLabel,
3710 disabled: disabled || !canUndoComposerEdit(activeDraftKeyRef.current),
3711 onSelect: () => {
3712 setInputMenuPoint(null);
3713 undoComposerEdit(activeDraftKeyRef.current);
3714 },
3715 },
3716 {
3717 key: "redo",
3718 label: t("shortcuts.action.composerRedo"),
3719 shortcut: redoComboLabel,
3720 disabled: disabled || !canRedoComposerEdit(activeDraftKeyRef.current),
3721 onSelect: () => {
3722 setInputMenuPoint(null);
3723 redoComposerEdit(activeDraftKeyRef.current);
3724 },
3725 },
3726 {
3727 type: "separator",
3728 key: "edit-history-separator",
3729 },
3730 {
3731 key: "cut",
3732 label: t("common.cut"),
3733 shortcut: editMenuShortcut("x"),
3734 disabled: disabled || !hasInputSelection,
3735 onSelect: () => void copyComposerSelection(true),
3736 },
3737 {
3738 key: "copy",
3739 label: t("common.copy"),
3740 shortcut: editMenuShortcut("c"),
3741 disabled: !hasInputSelection,
3742 onSelect: () => void copyComposerSelection(),
3743 },
3744 {
3745 key: "paste",
3746 label: t("common.paste"),
3747 shortcut: editMenuShortcut("v"),
3748 disabled,
3749 onSelect: () => void pasteIntoComposer(),
3750 },
3751 {
3752 key: "select-all",
3753 label: t("common.selectAll"),
3754 shortcut: editMenuShortcut("a"),
3755 disabled: text.length === 0,
3756 onSelect: selectAllComposerText,
3757 },
3758 ];
3759
3760 return (
3761 <div
3762 ref={composerWrapRef}
3763 className={[
3764 "composer-wrap",
3765 decisionPending ? "composer-wrap--decision-pending" : "",
3766 heroMode ? "composer-wrap--hero" : "",
3767 ].filter(Boolean).join(" ")}
3768 style={{ "--wails-drop-target": "drop" } as CSSProperties}
3769 onDropCapture={onFileDropCapture}
3770 >
3771 <input
3772 ref={fileInputRef}
3773 className="composer-content-file-input"
3774 type="file"
3775 multiple
3776 tabIndex={-1}
3777 aria-hidden="true"
3778 onChange={(event) => {
3779 const files = Array.from(event.currentTarget.files ?? []);
3780 event.currentTarget.value = "";
3781 if (files.length > 0) attachFiles(files);
3782 requestActiveDraftFrame(() => taRef.current?.focus());
3783 }}
3784 />
3785 <AnchoredPopover
3786 open={contentMenuOpen && !disabled && !readOnly && !running}
3787 anchorRef={contentMenuAnchorRef}
3788 onClose={() => setContentMenuOpen(false)}
3789 className="composer-access-menu composer-content-menu"
3790 align="start"
3791 >
3792 <div className="composer-access-menu__section" role="menu" aria-label={t("composer.contentMenuTitle")}>
3793 <button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item" onClick={chooseAttachmentFiles}>
3794 <FilePlus2 size={16} aria-hidden="true" />
3795 <span className="composer-access-menu__copy">
3796 <span className="composer-access-menu__title">{t("composer.contentAddAttachment")}</span>
3797 <span className="composer-access-menu__desc">{t("composer.contentAddAttachmentDesc")}</span>
3798 </span>
3799 </button>
3800 <button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item" onClick={() => insertContentTrigger("@")}>
3801 <AtSign size={16} aria-hidden="true" />
3802 <span className="composer-access-menu__copy">
3803 <span className="composer-access-menu__title">{t("composer.contentReferenceFiles")}</span>
3804 <span className="composer-access-menu__desc">{t("composer.contentReferenceFilesDesc")}</span>
3805 </span>
3806 </button>
3807 <button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item" onClick={() => insertContentTrigger("#")}>
3808 <Hash size={16} aria-hidden="true" />
3809 <span className="composer-access-menu__copy">
3810 <span className="composer-access-menu__title">{t("composer.contentReferenceSessions")}</span>
3811 <span className="composer-access-menu__desc">{t("composer.contentReferenceSessionsDesc")}</span>
3812 </span>
3813 </button>
3814 <button
3815 type="button"
3816 role="menuitem"
3817 className="composer-access-menu__item composer-content-menu__item"
3818 onClick={() => insertContentTrigger("/")}
3819 disabled={text.trim().length > 0}
3820 title={text.trim().length > 0 ? t("composer.contentUseCommandsEmptyOnly") : undefined}
3821 >
3822 <span className="composer-content-menu__trigger-icon" aria-hidden="true">/</span>
3823 <span className="composer-access-menu__copy">
3824 <span className="composer-access-menu__title">{t("composer.contentUseCommands")}</span>
3825 <span className="composer-access-menu__desc">{text.trim().length > 0 ? t("composer.contentUseCommandsEmptyOnly") : t("composer.contentUseCommandsDesc")}</span>
3826 </span>
3827 </button>
3828 </div>
3829 </AnchoredPopover>
3830 {!heroMode && <AnchoredPopover
3831 open={intentMenuOpen}
3832 closing={intentMenuClosing}
3833 anchorRef={intentMenuAnchorRef}
3834 onClose={() => closeIntentMenu()}
3835 className="composer-access-menu composer-intent-menu"
3836 align="start"
3837 >
3838 <div
3839 className="composer-access-menu__section"
3840 role="menu"
3841 aria-label={t("composer.intentMenuTitle")}
3842 onMouseEnter={creationChrome ? onIntentPopoverEnter : undefined}
3843 onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
3844 >
3845 <div className="composer-access-menu__label">{t("composer.intentMenuTitle")}</div>
3846 <button
3847 type="button"
3848 role="menuitemradio"
3849 aria-checked={collaborationMode === "normal"}
3850 className={`composer-access-menu__item composer-intent-menu__item${collaborationMode === "normal" ? " composer-access-menu__item--active" : ""}`}
3851 onClick={() => chooseTaskMode("normal")}
3852 disabled={disabled || running}
3853 >
3854 <ArrowRight size={16} />
3855 <span className="composer-access-menu__copy">
3856 <span className="composer-access-menu__title">{t("composer.taskModeDirect")}</span>
3857 <span className="composer-access-menu__desc">{t("composer.taskModeDirectDesc")}</span>
3858 </span>
3859 {collaborationMode === "normal" && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
3860 </button>
3861 <button
3862 type="button"
3863 role="menuitemradio"
3864 aria-checked={planModeOn}
3865 className={`composer-access-menu__item composer-intent-menu__item${planModeOn ? " composer-access-menu__item--active" : ""}`}
3866 onClick={() => chooseTaskMode("plan")}
3867 disabled={disabled || running}
3868 >
3869 <List size={16} />
3870 <span className="composer-access-menu__copy">
3871 <span className="composer-access-menu__title">{t("composer.taskModePlan")}</span>
3872 <span className="composer-access-menu__desc">{t("composer.taskModePlanDesc")}</span>
3873 </span>
3874 {planModeOn && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
3875 </button>
3876 <button
3877 type="button"
3878 role="menuitemradio"
3879 aria-checked={goalModeOn}
3880 className={`composer-access-menu__item composer-intent-menu__item${goalModeOn ? " composer-access-menu__item--active" : ""}`}
3881 onClick={() => chooseTaskMode("goal")}
3882 disabled={disabled || running}
3883 title={activeGoal || undefined}
3884 >
3885 <Target size={16} />
3886 <span className="composer-access-menu__copy">
3887 <span className="composer-access-menu__title">{t("composer.taskModeGoal")}</span>
3888 <span className="composer-access-menu__desc">{activeGoal || t("composer.taskModeGoalDesc")}</span>
3889 </span>
3890 {goalModeOn && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
3891 </button>
3892 {goalModeOn && activeGoal && (
3893 <div className="composer-intent-menu__goal-actions">
3894 <div className="composer-intent-menu__goal-runtime">
3895 {goalRuntime && (
3896 <span className="composer-intent-menu__goal-runtime-line">
3897 {t("composer.goalRuntimeLine", {
3898 turnsUsed: goalRuntime.turnsUsed,
3899 turnsLimit: goalRuntime.turnsLimit,
3900 tokensUsed: formatTokens(goalRuntime.tokensUsed),
3901 noProgressTurns: goalRuntime.noProgressTurns,
3902 noProgressLimit: goalRuntime.noProgressLimit,
3903 extensions: goalRuntime.budgetExtensions,
3904 })}
3905 </span>
3906 )}
3907 {goalStatus === "blocked" && !goalRuntime?.stopCause && (
3908 <span className="composer-intent-menu__goal-runtime-line composer-intent-menu__goal-runtime-line--blocked">
3909 {t("composer.goalBlocked")}
3910 </span>
3911 )}
3912 {goalStatus === "blocked" && goalRuntime?.stopCause && (
3913 <span className="composer-intent-menu__goal-runtime-line composer-intent-menu__goal-runtime-line--paused">
3914 {t("composer.goalPaused")}
3915 {goalRuntime.lastReason ? ` — ${goalRuntime.lastReason}` : ""}
3916 </span>
3917 )}
3918 </div>
3919 {goalStatus === "blocked" ? (
3920 <button
3921 type="button"
3922 className="composer-intent-menu__stop"
3923 onClick={onResumeGoal}
3924 disabled={disabled}
3925 >
3926 {t("composer.taskModeResumeGoal")}
3927 </button>
3928 ) : (
3929 <button
3930 type="button"
3931 className="composer-intent-menu__stop"
3932 onClick={onPauseGoal}
3933 disabled={disabled || running}
3934 >
3935 {t("composer.taskModePauseGoal")}
3936 </button>
3937 )}
3938 <button
3939 type="button"
3940 className="composer-intent-menu__stop"
3941 onClick={stopGoalMode}
3942 disabled={disabled || running}
3943 >
3944 {t("composer.taskModeStopGoal")}
3945 </button>
3946 </div>
3947 )}
3948 </div>
3949 </AnchoredPopover>}
3950 {!heroMode && <AnchoredPopover
3951 open={profileMenuOpen}
3952 closing={profileMenuClosing}
3953 anchorRef={profileMenuAnchorRef}
3954 onClose={() => closeProfileMenu()}
3955 className="composer-access-menu composer-profile-menu"
3956 align="start"
3957 >
3958 <div
3959 className="composer-access-menu__section"
3960 role="menu"
3961 aria-label={t("composer.runtimeProfileTitle")}
3962 onMouseEnter={creationChrome ? onProfilePopoverEnter : undefined}
3963 onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
3964 >
3965 <div className="composer-access-menu__label">{t("composer.runtimeProfileTitle")}</div>
3966 {([
3967 ["economy", Gauge, "composer.runtimeProfileEconomy", "composer.runtimeProfileEconomyDesc"],
3968 ["full", Equal, "composer.runtimeProfileBalanced", "composer.runtimeProfileBalancedDesc"],
3969 ["delivery", Flag, "composer.runtimeProfileDelivery", "composer.runtimeProfileDeliveryDesc"],
3970 ] as const).map(([profile, Icon, titleKey, descKey]) => (
3971 <button
3972 key={profile}
3973 type="button"
3974 role="menuitemradio"
3975 className={`composer-access-menu__item composer-profile-menu__item${tokenMode === profile ? " composer-access-menu__item--active" : ""}`}
3976 onClick={() => chooseTokenMode(profile)}
3977 disabled={disabled || running}
3978 title={t(descKey)}
3979 aria-checked={tokenMode === profile}
3980 >
3981 <Icon size={16} strokeWidth={1.75} />
3982 <span className="composer-access-menu__copy">
3983 <span className="composer-access-menu__title">{t(titleKey)}</span>
3984 <span className="composer-access-menu__desc">{t(descKey)}</span>
3985 </span>
3986 {tokenMode === profile && <Check size={15} aria-hidden="true" />}
3987 </button>
3988 ))}
3989 </div>
3990 </AnchoredPopover>}
3991 <AnchoredPopover
3992 open={moreMenuOpen && !disabled && !running}
3993 closing={moreMenuClosing}
3994 anchorRef={moreMenuAnchorRef}
3995 onClose={() => closeMoreMenu()}
3996 className="composer-access-menu composer-more-menu"
3997 align="end"
3998 >
3999 {hasEffort && (
4000 <div className="composer-access-menu__section">
4001 <div className="composer-access-menu__label">{t("status.effortTitle")}</div>
4002 <div className="composer-more-menu__items" role="listbox" aria-label={t("status.effortTitle")}>
4003 {effortLevels.map((level) => (
4004 <button
4005 key={level}
4006 type="button"
4007 role="option"
4008 aria-selected={level === currentEffort}
4009 className={`composer-more-menu__item${level === currentEffort ? " composer-more-menu__item--active" : ""}`}
4010 onClick={() => chooseEffortLevel(level)}
4011 disabled={running}
4012 >
4013 <Gauge size={14} />
4014 <span>{level}</span>
4015 {level === currentEffort && <Check size={13} />}
4016 </button>
4017 ))}
4018 </div>
4019 </div>
4020 )}
4021 </AnchoredPopover>
4022 {menuMode === "slash" && (
4023 <SlashMenu
4024 items={slashMatches}
4025 activeIndex={active}
4026 onPick={pickCommand}
4027 onHover={setActive}
4028 isDisabled={slashCommandDisabled}
4029 disabledReason={t("slash.startOnly")}
4030 />
4031 )}
4032 {menuMode === "slasharg" && argRes && (
4033 <ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
4034 )}
4035 {(menuMode === "at" || menuMode === "pastChats") && (
4036 showPastChats ? (
4037 <div className="slashmenu" role="listbox">
4038 {loadingPastChats ? (
4039 <div className="slashmenu__item slashmenu__item--empty">
4040 <span className="slashmenu__name">{t("composer.pastChatsLoading")}</span>
4041 </div>
4042 ) : pastChats.length === 0 ? (
4043 <div className="slashmenu__item slashmenu__item--empty">
4044 <span className="slashmenu__name">{t("composer.pastChatsEmpty")}</span>
4045 </div>
4046 ) : (
4047 <>
4048 <div className="slashmenu__item slashmenu__item--search" onMouseDown={(ev) => ev.preventDefault()}>
4049 <Search size={13} className="filemenu__icon" />
4050 <input
4051 className="slashmenu__search"
4052 type="text"
4053 placeholder={t("composer.pastChatsSearch")}
4054 value={pastChatQuery}
4055 // In the token-driven flows (typed "#" or the content-menu
4056 // action) focus must stay in the composer: typing there
4057 // extends the token and filters the list, and stealing
4058 // focus mid-word hijacks ordinary "#123" text. Only the
4059 // @-flow subpanel, which has no composer token to type
4060 // into, moves focus here.
4061 autoFocus={!directPastChats}
4062 onChange={(ev) => {
4063 setPastChatQuery(ev.target.value);
4064 setActive(0);
4065 }}
4066 onKeyDown={onPastChatSearchKeyDown}
4067 />
4068 </div>
4069 {filteredPastChats.length === 0 ? (
4070 <div className="slashmenu__item slashmenu__item--empty">
4071 <span className="slashmenu__name">{t("composer.pastChatsNoMatches")}</span>
4072 </div>
4073 ) : (
4074 filteredPastChats.map((session, i) => {
4075 // PR-C2: hover preview uses only the SessionMeta fields we
4076 // already have on hand — no extra PreviewSession call, no
4077 // backend round-trip, no read of the full transcript.
4078 const turns = typeof session.turns === "number";
4079 const ts = session.lastActivityAt || session.modTime || session.createdAt;
4080 const preview = truncatePreview(session.preview);
4081 const pathText = session.workspaceRoot || session.path;
4082 const tooltipLabel =
4083 turns || ts || preview || pathText ? (
4084 <div className="past-chat-hover">
4085 <div className="past-chat-hover__title">{pastChatTitle(session)}</div>
4086 {preview && <div className="past-chat-hover__preview">{preview}</div>}
4087 {(turns || ts) && (
4088 <div className="past-chat-hover__meta">
4089 {turns && <span>{t("composer.sessionTurns", { n: session.turns })}</span>}
4090 {ts && <span>· {fmtSessionTime(ts)}</span>}
4091 </div>
4092 )}
4093 {pathText && <div className="past-chat-hover__path">{pathText}</div>}
4094 </div>
4095 ) : null;
4096 return (
4097 <Tooltip key={session.path} block label={tooltipLabel}>
4098 <button
4099 className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
4100 onMouseDown={(ev) => {
4101 ev.preventDefault();
4102 pickSession(session);
4103 }}
4104 onMouseMove={() => setActive(i)}
4105 >
4106 <MessageSquare size={13} className="filemenu__icon" />
4107 <span className="slashmenu__name slashmenu__name--file">
4108 {pastChatTitle(session)}
4109 {turns ? ` (${t("composer.sessionTurns", { n: session.turns })})` : ""}
4110 </span>
4111 </button>
4112 </Tooltip>
4113 );
4114 })
4115 )}
4116 </>
4117 )}
4118 <button
4119 className="slashmenu__item slashmenu__item--back"
4120 onMouseDown={(ev) => {
4121 ev.preventDefault();
4122 if (menuMode === "pastChats") dismissDirectPastChats();
4123 else {
4124 setPastChatQuery("");
4125 setShowPastChats(false);
4126 setActive(0);
4127 }
4128 }}
4129 >
4130 <span className="slashmenu__name">
4131 {menuMode === "pastChats" ? t("composer.contentCloseSessions") : t("composer.backToFiles")}
4132 </span>
4133 </button>
4134 </div>
4135 ) : menuMode === "at" ? (
4136 <VirtualMenu
4137 items={atMenuItems}
4138 activeIndex={active}
4139 itemKey={atMenuItemKey}
4140 renderItem={(it, i) =>
4141 it.kind === "pastChats" ? (
4142 <button
4143 className={`slashmenu__item${i === active ? " slashmenu__item--active" : ""}`}
4144 onMouseDown={(ev) => {
4145 ev.preventDefault();
4146 void openPastChats();
4147 }}
4148 onMouseMove={() => setActive(i)}
4149 >
4150 <MessageSquare size={13} className="filemenu__icon" />
4151 <span className="slashmenu__name">{PAST_CHATS_MENU_ITEM}</span>
4152 </button>
4153 ) : (
4154 <button
4155 role="option"
4156 aria-selected={i === active}
4157 className={`slashmenu__item ${i === active ? "slashmenu__item--active" : ""}`}
4158 onMouseDown={(ev) => {
4159 ev.preventDefault();
4160 pickEntry(it.entry);
4161 }}
4162 onMouseMove={() => setActive(i)}
4163 >
4164 {it.entry.isDir ? (
4165 <Folder size={13} className="filemenu__icon filemenu__icon--dir" />
4166 ) : (
4167 <FileText size={13} className="filemenu__icon" />
4168 )}
4169 <span className="slashmenu__name slashmenu__name--file">
4170 {dirEntryMenuLabel(it.entry)}
4171 {it.entry.isDir ? "/" : ""}
4172 </span>
4173 </button>
4174 )
4175 }
4176 />
4177 ) : null
4178 )}
4179 {pendingGuidance.length > 0 && (
4180 <div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
4181 <div className="composer-guidance-head">
4182 <span className="composer-guidance-head__label">
4183 <CornerDownRight size={14} />
4184 <span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
4185 </span>
4186 </div>
4187 <div className="composer-guidance-list">
4188 {visibleGuidance.map((item) => (
4189 <div className="composer-guidance-item" key={item.id}>
4190 <CornerDownRight size={14} className="composer-guidance-item__icon" />
4191 <span className="composer-guidance-item__text">{item.text}</span>
4192 <Tooltip label={t("composer.guidanceSend")}>
4193 <button
4194 className="composer-guidance-item__guide"
4195 type="button"
4196 aria-label={t("composer.guidanceSend")}
4197 disabled={!running || disabled || readOnly || guidanceSendingId !== null || Boolean(item.structured)}
4198 onClick={() => void sendQueuedGuidance(item)}
4199 >
4200 <CornerDownRight size={13} />
4201 <span>{t("composer.guidanceMode")}</span>
4202 </button>
4203 </Tooltip>
4204 <Tooltip label={t("composer.guidanceDismiss")}>
4205 <button
4206 className="composer-guidance-item__action"
4207 type="button"
4208 aria-label={t("composer.guidanceDismiss")}
4209 disabled={guidanceSendingId === item.id}
4210 onClick={() => updatePendingGuidanceForDraft(
4211 activeDraftKeyRef.current,
4212 (items) => items.filter((queued) => queued.id !== item.id),
4213 )}
4214 >
4215 <Trash2 size={14} />
4216 </button>
4217 </Tooltip>
4218 </div>
4219 ))}
4220 {showGuidanceExpander && (
4221 <button
4222 className="composer-guidance-more"
4223 type="button"
4224 aria-expanded={guidanceExpanded}
4225 onClick={() => setGuidanceExpanded((value) => !value)}
4226 >
4227 {guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
4228 <span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
4229 </button>
4230 )}
4231 </div>
4232 </div>
4233 )}
4234 {(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0 || selectedTextRefs.length > 0) && (
4235 <div className="composer-context" aria-label={t("composer.contextItems")}>
4236 {sortComposerAttachments(attachments).map((a) => {
4237 const imageOnly = Boolean(a.previewUrl) && attachments.every((item) => item.previewUrl) && workspaceRefs.length === 0 && sessionRefs.length === 0;
4238 return (
4239 <ComposerContextCard
4240 key={a.path}
4241 variant="attachment"
4242 tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
4243 removeLabel={t("composer.removeImage")}
4244 onRemove={() => removeAttachment(a.path)}
4245 previewUrl={a.previewUrl}
4246 onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
4247 imageOnly={imageOnly}
4248 name={attachmentName(a)}
4249 meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
4250 />
4251 );
4252 })}
4253 {workspaceRefs.map((ref) => (
4254 <ComposerContextCard
4255 key={workspaceReferenceKey(ref)}
4256 variant="workspace"
4257 tooltipLabel={ref.displayPath ? formatWorkspaceReference(ref.displayPath, ref.isDir) : formatWorkspaceReference(ref.path, ref.isDir)}
4258 removeLabel={t("composer.removeReference")}
4259 onRemove={() => removeWorkspaceReference(ref)}
4260 folder={Boolean(ref.isDir)}
4261 label={ref.isDir ? `${baseName(ref.displayPath || ref.path)}/` : baseName(ref.displayPath || ref.path)}
4262 />
4263 ))}
4264 {sessionRefs.map((ref) => (
4265 <div
4266 className="composer-context__item composer-context__item--session"
4267 key={ref.path}
4268 >
4269 <Tooltip label={ref.preview || ref.title}>
4270 <span className="composer-context__label">
4271 <MessageSquare size={15} />
4272 <span>
4273 {ref.title}
4274 {typeof ref.turns === "number" ? ` (${t("composer.sessionTurns", { n: ref.turns })})` : ""}
4275 </span>
4276 </span>
4277 </Tooltip>
4278 <Tooltip label={t("composer.removeSessionReference")}>
4279 <button
4280 type="button"
4281 onClick={() => removeSessionRef(ref.path)}
4282 >
4283 <X size={13} />
4284 </button>
4285 </Tooltip>
4286 </div>
4287 ))}
4288 {selectedTextRefs.map((reference) => (
4289 <ComposerContextCard
4290 key={reference.id}
4291 variant="selection"
4292 tooltipLabel={reference.path
4293 ? <CodeViewer value={reference.text} language={languageFor(reference.path)} maxHeight={240} />
4294 : <Markdown text={reference.text} />}
4295 removeLabel={t("composer.removeSelectedText")}
4296 onRemove={() => {
4297 const next = selectedTextRefsRef.current.filter((item) => item.id !== reference.id);
4298 selectedTextRefsRef.current = next;
4299 setSelectedTextRefs(next);
4300 requestActiveDraftFrame(focusComposerInput);
4301 }}
4302 name={reference.path ? reference.path.split("/").filter(Boolean).pop() ?? reference.path : selectedTextSnippet(reference.text)}
4303 meta={reference.path ? t("composer.selectedCode") : t("composer.selectedText")}
4304 icon={reference.path ? <FileText size={20} /> : <MessageSquare size={20} />}
4305 />
4306 ))}
4307 </div>
4308 )}
4309 <ImageViewer
4310 open={imageViewer.open}
4311 imageUrl={imageViewer.url}
4312 imageName={imageViewer.name}
4313 onClose={closeComposerImageViewer}
4314 />
4315 {activePastedBlocks.length > 0 && (
4316 <div className="composer__pasted">
4317 {activePastedBlocks.map((block) => {
4318 const open = openPastedLabels.includes(block.label);
4319 return (
4320 <div className="composer__pasted-block" key={block.label}>
4321 <div className="composer__pasted-head">
4322 <FileText size={15} />
4323 <span className="composer__pasted-label">{block.label}</span>
4324 <div className="composer__pasted-actions">
4325 <Tooltip label={t(open ? "composer.pastedHidePreview" : "composer.pastedShowPreview")}>
4326 <button type="button" onClick={() => togglePastedPreview(block.label)}>
4327 <Eye size={14} />
4328 </button>
4329 </Tooltip>
4330 <Tooltip label={t("composer.pastedExpand")}>
4331 <button type="button" onClick={() => expandPastedBlock(block)}>
4332 {t("composer.pastedExpand")}
4333 </button>
4334 </Tooltip>
4335 <Tooltip label={t("composer.pastedRemove")}>
4336 <button type="button" onClick={() => removePastedBlock(block)}>
4337 <Trash2 size={14} />
4338 </button>
4339 </Tooltip>
4340 </div>
4341 </div>
4342 {open && <pre className="composer__pasted-preview">{block.text}</pre>}
4343 </div>
4344 );
4345 })}
4346 </div>
4347 )}
4348 <div
4349 className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
4350 ref={composerCardRef}
4351 style={composerCardStyle}
4352 >
4353 <button
4354 className="composer-resize-handle"
4355 type="button"
4356 role="separator"
4357 aria-orientation="horizontal"
4358 aria-label={t("composer.resize")}
4359 aria-valuemin={COMPOSER_MIN_HEIGHT}
4360 aria-valuemax={composerMaxHeight()}
4361 aria-valuenow={composerResizeValue}
4362 title={t("composer.resize")}
4363 onPointerDown={onComposerResizeStart}
4364 onKeyDown={onComposerResizeKeyDown}
4365 onDoubleClick={resetComposerHeight}
4366 />
4367 {runStateText && (
4368 <div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
4369 <span className="composer-run-strip__dot" aria-hidden="true" />
4370 {/* The ticker re-renders every second; keep it out of the accessibility
4371 tree and announce only the stable state text via the live region. */}
4372 <span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
4373 {runTicker ?? runStateText}
4374 </span>
4375 <span className="sr-only" role="status">{runStateText}</span>
4376 </div>
4377 )}
4378 <div
4379 className={`composer${invocations.length > 0 ? " composer--has-invocation" : ""}${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
4380 onDrop={onDrop}
4381 onDragOver={onDragOver}
4382 onDragLeave={onDragLeave}
4383 >
4384 <div className="composer__input-row">
4385 <span className="composer__caret">{shellModeActive ? "$" : "›"}</span>
4386 <div className="composer__content" onMouseDown={focusComposerFromContentBlank}>
4387 {invocations.length > 0 ? (
4388 <RichComposerInput
4389 ref={richInputRef}
4390 text={text}
4391 invocations={invocations}
4392 placeholder={composerPlaceholder}
4393 disabled={disabled || readOnly}
4394 style={textareaStyle}
4395 onChange={(
4396 nextText,
4397 nextInvocations,
4398 origin: RichComposerChangeOrigin,
4399 ) => {
4400 const targetDraftKey = activeDraftKeyRef.current;
4401 const beforeEdit = origin.source === "programmatic"
4402 ? composerEditSnapshot(targetDraftKey, origin.beforeSelection)
4403 : null;
4404 resetPromptHistoryNavigation();
4405 const hadInvocations = invocationsRef.current.length > 0;
4406 textRef.current = nextText;
4407 invocationsRef.current = nextInvocations;
4408 setText(nextText);
4409 setInvocations(nextInvocations);
4410 if (beforeEdit) {
4411 recordComposerEdit(
4412 targetDraftKey,
4413 beforeEdit,
4414 composerEditSnapshot(targetDraftKey, origin.afterSelection),
4415 );
4416 } else {
4417 syncComposerNativeHistory(targetDraftKey, origin.inputType);
4418 }
4419 if (composerPrompt) setComposerPrompt(null);
4420 if (hadInvocations && nextInvocations.length === 0) {
4421 // Removing the last entity unmounts the rich input and
4422 // swaps the plain textarea back in; without an explicit
4423 // handoff the focused editable disappears and the next
4424 // keystrokes land on <body>. RichComposerInput reports
4425 // the removal caret through onSelectionChange before
4426 // this onChange fires.
4427 setComposerSelection(Math.min(lastSelectionRef.current.start, nextText.length));
4428 }
4429 }}
4430 onSelectionChange={(selection, query) => {
4431 setRichSelection(selection);
4432 setRichSlashQuery(query);
4433 lastSelectionRef.current = { start: selection.start, end: selection.end };
4434 }}
4435 onKeyDown={onKeyDown}
4436 onContextMenu={openInputMenu}
4437 onPaste={onPaste}
4438 onCompositionStart={() => {
4439 composingRef.current = true;
4440 }}
4441 onCompositionEnd={() => {
4442 composingRef.current = false;
4443 lastCompositionEndAt.current = Date.now();
4444 }}
4445 />
4446 ) : (
4447 <textarea
4448 id="composer-input"
4449 ref={taRef}
4450 className="composer__input"
4451 aria-label={t("composer.placeholder")}
4452 value={text}
4453 onInputCapture={(e) => {
4454 pendingNativeInputTypeRef.current = (e.nativeEvent as InputEvent).inputType;
4455 }}
4456 onChange={(e) => {
4457 const targetDraftKey = activeDraftKeyRef.current;
4458 const inputType = (e.nativeEvent as InputEvent).inputType
4459 || pendingNativeInputTypeRef.current;
4460 pendingNativeInputTypeRef.current = undefined;
4461 resetPromptHistoryNavigation();
4462 textRef.current = e.target.value;
4463 setText(e.target.value);
4464 const nextSelection = {
4465 start: e.target.selectionStart ?? e.target.value.length,
4466 end: e.target.selectionEnd ?? e.target.value.length,
4467 };
4468 lastSelectionRef.current = nextSelection;
4469 setPlainSelection(nextSelection);
4470 syncComposerNativeHistory(targetDraftKey, inputType);
4471 if (composerPrompt) setComposerPrompt(null);
4472 }}
4473 onSelect={rememberCaret}
4474 onClick={rememberCaret}
4475 onKeyUp={rememberCaret}
4476 onFocus={rememberCaret}
4477 onContextMenu={openInputMenu}
4478 onPaste={onPaste}
4479 onKeyDown={onKeyDown}
4480 onCompositionStart={() => {
4481 composingRef.current = true;
4482 }}
4483 onCompositionEnd={() => {
4484 composingRef.current = false;
4485 lastCompositionEndAt.current = Date.now();
4486 }}
4487 style={textareaStyle}
4488 placeholder={composerPlaceholder}
4489 rows={1}
4490 disabled={disabled || readOnly}
4491 />
4492 )}
4493 </div>
4494 {composerPrompt && (
4495 <span className="composer__prompt" role="status">
4496 {composerPrompt}
4497 </span>
4498 )}
4499 {running && (
4500 <Tooltip label={t("composer.stop")}>
4501 <button
4502 className="composer__btn composer__btn--stop"
4503 type="button"
4504 onClick={handleCancel}
4505 aria-label={t("composer.stop")}
4506 >
4507 <Square size={12} fill="currentColor" />
4508 </button>
4509 </Tooltip>
4510 )}
4511 <Tooltip label={submitTooltip}>
4512 <button
4513 className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
4514 onClick={submit}
4515 disabled={submitBlocked}
4516 aria-label={submitTooltip}
4517 >
4518 {running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
4519 </button>
4520 </Tooltip>
4521 </div>
4522 </div>
4523 <ContextMenu
4524 open={inputMenuPoint !== null}
4525 point={inputMenuPoint}
4526 items={inputMenuItems}
4527 className="context-menu--composer-input"
4528 minWidth={64}
4529 ariaLabel={t("composer.inputActions")}
4530 onClose={() => setInputMenuPoint(null)}
4531 />
4532 <div className={composerMetaClass}>
4533 <div className="composer-meta__params">
4534 {!heroMode && (
4535 <div className="composer-meta__control composer-meta__control--content">
4536 <Tooltip label={t("composer.contentMenuTitle")} disabled={contentMenuOpen}>
4537 <button
4538 ref={contentMenuAnchorRef}
4539 type="button"
4540 className={`composer-content-trigger${contentMenuOpen ? " composer-content-trigger--open" : ""}`}
4541 onClick={() => (contentMenuOpen ? setContentMenuOpen(false) : openContentMenu())}
4542 disabled={disabled || readOnly || running}
4543 aria-haspopup="menu"
4544 aria-expanded={contentMenuOpen}
4545 aria-label={t("composer.contentMenuTitle")}
4546 >
4547 <Plus size={17} strokeWidth={1.8} aria-hidden="true" />
4548 </button>
4549 </Tooltip>
4550 </div>
4551 )}
4552 {!heroMode && (
4553 <div className="composer-meta__control composer-meta__control--intent">
4554 <Tooltip label={taskModeTooltipLabel} disabled={intentMenuOpen || intentMenuClosing || creationChrome}>
4555 <button
4556 ref={intentMenuAnchorRef}
4557 type="button"
4558 className={`composer-task-mode-trigger${intentMenuOpen || intentMenuClosing ? " composer-task-mode-trigger--open" : ""}`}
4559 onClick={() => (intentMenuOpen || intentMenuClosing ? closeIntentMenu() : openIntentMenu())}
4560 onMouseEnter={creationChrome ? onIntentHoverEnter : undefined}
4561 onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
4562 disabled={disabled || running}
4563 aria-haspopup="menu"
4564 aria-expanded={intentMenuOpen && !intentMenuClosing}
4565 aria-label={taskModeTriggerLabel}
4566 title={intentMenuOpen || intentMenuClosing || creationChrome ? undefined : taskModeTriggerLabel}
4567 >
4568 <TaskModeIcon size={14} aria-hidden="true" />
4569 <span className="composer-task-mode-trigger__value">{t(taskModeShortKey)}</span>
4570 <ChevronsUpDown size={11} aria-hidden="true" />
4571 </button>
4572 </Tooltip>
4573 </div>
4574 )}
4575 {!heroMode && (
4576 <div className="composer-meta__control composer-meta__control--profile">
4577 <Tooltip label={runtimeProfileTooltipLabel} disabled={profileMenuOpen || profileMenuClosing || creationChrome}>
4578 <button
4579 ref={profileMenuAnchorRef}
4580 type="button"
4581 data-profile={tokenMode}
4582 className={`composer-profile-trigger${profileMenuOpen || profileMenuClosing ? " composer-profile-trigger--open" : ""}`}
4583 onClick={() => (profileMenuOpen || profileMenuClosing ? closeProfileMenu() : openProfileMenu())}
4584 onMouseEnter={creationChrome ? onProfileHoverEnter : undefined}
4585 onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
4586 disabled={disabled || running}
4587 aria-haspopup="menu"
4588 aria-expanded={profileMenuOpen && !profileMenuClosing}
4589 aria-label={runtimeProfileTriggerLabel}
4590 title={profileMenuOpen || profileMenuClosing || creationChrome ? undefined : runtimeProfileTriggerLabel}
4591 >
4592 <RuntimeProfileIcon size={14} strokeWidth={1.75} aria-hidden="true" />
4593 <span className="composer-profile-trigger__label">
4594 <span className="composer-profile-trigger__value">{t(runtimeProfileShortKey)}</span>
4595 </span>
4596 <ChevronsUpDown size={11} aria-hidden="true" />
4597 </button>
4598 </Tooltip>
4599 </div>
4600 )}
4601 {!heroMode && (
4602 <div className="composer-meta__control composer-meta__control--approval">
4603 {/* A pending tool approval disables the composer, but the approval
4604 bar stays usable so mode changes remain possible mid-prompt;
4605 the approval card explains that the pending request still needs
4606 an explicit decision. */}
4607 <div
4608 className="composer-modebar composer-modebar--approval"
4609 data-mode={toolApprovalMode}
4610 title={t("composer.accessMenuTitle", { shortcut: yoloComboLabel })}
4611 >
4612 <span className="composer-modebar__thumb" aria-hidden="true" />
4613 <button
4614 type="button"
4615 className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
4616 onClick={() => chooseApprovalMode("ask")}
4617 disabled={approvalBarDisabled}
4618 aria-pressed={toolApprovalMode === "ask"}
4619 title={t("composer.accessAskTitle")}
4620 >
4621 <Shield size={14} />
4622 <span>{t("composer.modeAsk")}</span>
4623 </button>
4624 <button
4625 type="button"
4626 className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
4627 onClick={() => chooseApprovalMode("auto")}
4628 disabled={approvalBarDisabled}
4629 aria-pressed={toolApprovalMode === "auto"}
4630 title={t("composer.accessAutoTitle")}
4631 >
4632 <ShieldCheck size={14} />
4633 <span>{t("composer.modeNormal")}</span>
4634 </button>
4635 <button
4636 type="button"
4637 className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
4638 onClick={() => chooseApprovalMode("yolo")}
4639 disabled={approvalBarDisabled}
4640 aria-pressed={toolApprovalMode === "yolo"}
4641 title={t("composer.accessYoloTitle", { shortcut: yoloComboLabel })}
4642 >
4643 <ShieldAlert size={14} />
4644 <span>{t("composer.modeYolo")}</span>
4645 </button>
4646 </div>
4647 </div>
4648 )}
4649 {!heroMode && <span className="composer-meta__divider" aria-hidden="true" />}
4650 <div className="composer-meta__control composer-meta__control--model">
4651 {/*
4652 Creation-only: showContextWindowRing is wired to sidebarCreation
4653 (desktopLayoutStyle === "creation") in App.tsx. The ring popover
4654 is portaled to <body> without an .app--creation prefix, so its
4655 styles look global but only ever apply in creation layout. If you
4656 ever surface this ring in another layout, its font sizes already
4657 scale via --font-scale (see .context-ring-popover in styles.css).
4658 */}
4659 {!heroMode && showContextWindowRing && (
4660 <ContextWindowRing
4661 enabled={showContextWindowRing}
4662 context={context}
4663 tabId={tabId}
4664 turnCost={turnCost}
4665 currency={currency}
4666 cacheHitTokens={cacheHitTokens}
4667 cacheMissTokens={cacheMissTokens}
4668 balance={balance}
4669 />
4670 )}
4671 <ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} />
4672 </div>
4673 {!heroMode && hasEffort && (
4674 <div className="composer-meta__control composer-meta__control--effort">
4675 <EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
4676 </div>
4677 )}
4678 {!heroMode && hasEffort && (
4679 <div className="composer-meta__control composer-meta__control--more">
4680 <Tooltip label={compactEffortTitle} disabled={moreMenuOpen || moreMenuClosing}>
4681 <button
4682 ref={moreMenuAnchorRef}
4683 type="button"
4684 className={`composer-more-trigger composer-more-trigger--effort${currentEffort !== "auto" ? " composer-more-trigger--explicit" : ""}${moreMenuOpen || moreMenuClosing ? " composer-more-trigger--open" : ""}`}
4685 onClick={() => (moreMenuOpen || moreMenuClosing ? closeMoreMenu() : openMoreMenu())}
4686 disabled={disabled || running}
4687 aria-haspopup="menu"
4688 aria-expanded={moreMenuOpen && !moreMenuClosing}
4689 aria-label={compactEffortTitle}
4690 title={moreMenuOpen || moreMenuClosing ? undefined : compactEffortTitle}
4691 >
4692 <Gauge size={14} />
4693 <span>{currentEffort}</span>
4694 <ChevronsUpDown size={11} />
4695 </button>
4696 </Tooltip>
4697 </div>
4698 )}
4699 </div>
4700 </div>
4701 </div>
4702 </div>
4703 );
4704 }
4705
4705 lines Plain Text