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