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