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