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