1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import { useComposerTypingGuard } from "./useComposerTypingGuard";
3import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
4import { PREPARE_UI_REFRESH_EVENT } from "../../uiRefresh";
5import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
6import { isLocalEnvironmentDisabled } from "../../localEnvironment";
7import { usePrimaryEnvironmentId } from "../../state/environments";
8import { useRightPanelStore } from "~/rightPanelStore";
9import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
10import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
11import {
12 composerPullRequestsFromLinks,
13 filterComposerPullRequestMatches,
14} from "@t3tools/shared/composerPullRequestMatches";
15import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
16import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
17import { RefreshIcon } from "~/components/ui/refresh-icon";
18import {
19 questionAttachmentDraftId,
20 countQuestionAttachments,
21 useQuestionAttachmentPreparation,
22 changeQuestionAttachmentPreparation,
23} from "../../questionAttachments";
24import type {
25 ApprovalRequestId,
26 KeybindingCommand,
27 AssistantCitation,
28 ChatAttachment as ContractChatAttachment,
29 ChatFileAttachment,
30 EnvironmentId,
31 ModelSelection,
32 ProjectId,
33 PullRequestListInput,
34 PreviewAnnotationPayload,
35 ProviderApprovalDecision,
36 ThreadContextRecord,
37 ProviderInteractionMode,
38 ResolvedKeybindingsConfig,
39 RuntimeMode,
40 RuntimeRequestId,
41 ScopedThreadRef,
42 ServerProvider,
43 ThreadId,
44 SnapShotSource,
45} from "@t3tools/contracts";
46import {
47 EnvironmentRequestInvalidError,
48 AuthOrchestrationOperateScope,
49 ProviderDriverKind,
50 ProviderInstanceId,
51 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
52 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
53 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
54 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
55} from "@t3tools/contracts";
56import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
57import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
58import * as Schema from "effect/Schema";
59import {
60 isPasteAsTextShortcut,
61 nextPastedTextFileName,
62 pastedTextDisposition,
63 wouldTextPasteExceedLimit,
64} from "@t3tools/client-runtime/text-paste";
65import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
66import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
67import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
68import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
69import {
70 memo,
71 type ComponentProps,
72 type ReactNode,
73 useCallback,
74 useEffect,
75 useEffectEvent,
76 useId,
77 useImperativeHandle,
78 useLayoutEffect,
79 useMemo,
80 useRef,
81 useState,
82 useSyncExternalStore,
83} from "react";
84import { createPortal, flushSync } from "react-dom";
85import {
86 clampCollapsedComposerCursor,
87 type ComposerTrigger,
88 type ComposerSubmissionIntent,
89 collapseExpandedComposerCursor,
90 composerSubmissionIntentForKey,
91 composerStateAtPromptEnd,
92 detectComposerTrigger,
93 expandCollapsedComposerCursor,
94 formatAssistantCitationForComposer,
95 replaceTextRange,
96} from "../../composer-logic";
97import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
98import {
99 deriveComposerSendState,
100 getAntigravitySendBlockReason,
101 readFileAsDataUrl,
102 resolveComposerInteractionMode,
103 resolveComposerProviderSelection,
104 threadShellHasStarted,
105} from "../ChatView.logic";
106import {
107 dataTransferHasComposerMention,
108 makeComposerMentionDragHandlers,
109} from "./composerMentionDrag";
110import {
111 composerFloatingLayerProps,
112 useComposerMenuProps,
113 isInsideCollapsedComposerControls,
114 isInsideRestingComposerControlScope,
115} from "./composerEventScope";
116import {
117 type ComposerFileAttachment,
118 type ComposerImageAttachment,
119 type DraftId,
120 type PersistedComposerFileAttachment,
121 type PersistedComposerImageAttachment,
122 composerFileDedupKey,
123 composerFileMatchesReattachMarker,
124 composerFileNeedsReattach,
125 composerDraftHasUserContent,
126 composerTargetKey,
127 hydrateImagesFromPersisted,
128 useComposerDraftStore,
129 useComposerThreadDraft,
130 useEffectiveComposerModelState,
131} from "../../composerDraftStore";
132import {
133 MAX_STASH_ENTRIES,
134 partitionStashAttachments,
135 usePromptStashStore,
136 type PromptStashEntry,
137} from "../../promptStashStore";
138import { ComposerStashBadge } from "./ComposerStashBadge";
139import { ComposerStashMenu } from "./ComposerStashMenu";
140import { useComposerMenuState } from "./useComposerMenuState";
141import { useComposerTriggerState } from "./useComposerTriggerState";
142import { useComposerFocusState } from "./useComposerFocusState";
143import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
144import {
145 ComposerTasksBadge,
146 ComposerTasksContent,
147 ComposerTasksDrawer,
148 type ComposerTaskStep,
149 type ComposerTasksProgress,
150} from "./ComposerTasksBadge";
151import { ComposerActivityRow } from "./ComposerActivityStatus";
152import {
153 reconcileAttachmentContextReferences,
154 type RetainedAttachmentContextPayloads,
155} from "./composerContextUndo";
156import type { ThreadSyncPhase } from "../../threadSync";
157import { ComposerBanner } from "./ComposerBanner";
158import { ComposerSurface } from "./ComposerSurface";
159import {
160 ComposerBannerStack,
161 type ComposerBannerStackContent,
162 type ComposerBannerStackItem,
163} from "./ComposerBannerStack";
164import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
165import {
166 fileAttachmentTooLargeMessage,
167 formatAttachmentSize,
168} from "@t3tools/client-runtime/state/attachments";
169import {
170 attachmentsToReleaseOnUploadCapabilityLoss,
171 composerOtherFilesForPresentation,
172 classifyComposerAttachmentFile,
173 fileAttachmentCapabilityBlockReason,
174 fileAttachmentStagingLimit,
175 isPreviewableComposerVideo,
176 normalizeComposerImageFileMimeType,
177 shouldHandleComposerAttachmentPaste,
178} from "./composerAttachmentFiles";
179import {
180 readAttachmentUpload,
181 releaseAttachmentUpload,
182 releaseDraftAttachment,
183 releasePersistedAttachmentUpload,
184 retryAttachmentUpload,
185 startAttachmentUpload,
186 useAttachmentUploadStore,
187 verifyStashedAttachmentUpload,
188} from "../../lib/attachmentUploadQueue";
189import {
190 attachmentUploadBlockReason,
191 formatAttachmentUploadProgress,
192} from "../../lib/attachmentUploadState";
193import { isCommandPaletteOpen } from "../../commandPaletteBus";
194import { getTerminalFocusOwner } from "../../lib/terminalFocus";
195import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
196import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";
197import {
198 type TerminalContextDraft,
199 type TerminalContextSelection,
200} from "../../lib/terminalContext";
201import { useComposerPathSearch } from "../../lib/composerPathSearchState";
202import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
203import {
204 getRestingComposerImagePreviewCounts,
205 resolveRestingComposerControlsLayout,
206 shouldAnimateComposerRestingTransition,
207 shouldUseCompactComposerPrimaryActions,
208 shouldUseCompactComposerFooter,
209 shouldUseRestingComposerLayout,
210} from "../composerFooterLayout";
211import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
212import {
213 type ComposerCitationCommentRequest,
214 type ComposerPromptEditorHandle,
215 ComposerPromptEditor,
216} from "../ComposerPromptEditor";
217import {
218 ComposerContextActionsContext,
219 composerContextRecordsFromDraft,
220 uploadedContextRecordFromDraft,
221} from "../composerContextPresentation";
222import { useOpenPrLink } from "~/lib/openPullRequestLink";
223import {
224 collectInlineContextIds,
225 stripInlineContextReferences,
226 type ComposerContextReference,
227 ensureInlineContextReferences,
228 formatInlineContextReference,
229 insertInlineContextReference,
230 inlineContextReferenceReplacement,
231 toKindScopedComposerContextId,
232} from "~/lib/composerContextReferences";
233import {
234 asKnownContextRecord,
235 composerContextImportLookupIds,
236 isSameComposerContextPayload,
237 uploadedAttachmentContextRecord,
238 fileContextReference,
239 imageContextReference,
240 previewAnnotationContextId,
241 previewAnnotationContextRecord,
242 previewAnnotationFromRecord,
243 reviewCommentContextId,
244 reviewCommentContextReference,
245 reviewCommentContextRecord,
246 reviewCommentFromRecord,
247 terminalContextDraftFromRecord,
248 terminalContextReference,
249 terminalContextRecord,
250 threadContextRecord,
251 threadContextReference,
252} from "~/lib/composerContextRecords";
253import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
254import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag";
255import { readThreadShell, useThreadShells } from "~/state/entities";
256import { requestConfirmDialog } from "~/confirmDialog";
257import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
258import type {
259 ComposerContextClipboardFragment,
260 ComposerContextRecord,
261 ThreadPullRequestLink,
262} from "@t3tools/contracts";
263import { resolveAssetUrl } from "~/assets/assetUrls";
264import { assetEnvironment } from "~/state/assets";
265import { readPreparedConnection } from "~/state/session";
266import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
267import { orchestrationEnvironment } from "~/state/orchestration";
268import { threadContextAttachment } from "~/lib/threadContextAttachment";
269import {
270 pullRequestEnvironment,
271 usePullRequestList,
272 type EnvironmentQueryTarget,
273} from "~/state/pullRequests";
274import { useEnvironmentQuery } from "~/state/query";
275import { useDebouncedValue } from "~/state/queries";
276import { ProviderModelPicker } from "./ProviderModelPicker";
277import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
278import {
279 type ComposerCommandItem,
280 ComposerCommandMenu,
281 composerSuggestionOptionId,
282} from "./ComposerCommandMenu";
283import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
284import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
285import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
286import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
287import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
288import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
289import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
290import {
291 ComposerControl,
292 ComposerControlIcon,
293 ComposerControlSeparator,
294 ComposerSelectControl,
295} from "./ComposerControl";
296import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
297import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic";
298import {
299 matchesPullRequestQuery,
300 rankPullRequestMatches,
301} from "../pullRequest/pullRequestList.logic";
302import {
303 searchSlashCommandItems,
304 slashCommandItemsForPromptPosition,
305} from "./composerSlashCommandSearch";
306import {
307 getComposerPromptInjectionState,
308 getComposerProviderState,
309 renderProviderTraitsMenuContent,
310 renderProviderTraitsPicker,
311} from "./composerProviderState";
312import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter";
313import {
314 providerSupportsManualCompaction,
315 resolveContextWindowModelDisplayName,
316 shouldReserveContextWindowMeter,
317} from "./ContextWindowMeter.logic";
318import {
319 attachVideoThumbnail,
320 buildAttachmentVideoPreview,
321 buildExpandedImagePreview,
322 type ExpandedImagePreview,
323} from "./ExpandedImagePreview";
324import {
325 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
326 SnapShotAttachmentDetails,
327} from "./SnapShotAttachmentDetails";
328import {
329 getPendingSnapShotAnimations,
330 pendingSnapShotAnimationIdsForTarget,
331 scheduleSnapShotAnimationDestination,
332 setSnapShotAnimationDestination,
333 shouldAnimateSnapShotArrival,
334 subscribeToPendingSnapShotAnimations,
335} from "../../lib/snapShotAnimation";
336import { resizeSnapShotSource } from "../../lib/snapShotSource";
337import { basenameOfPath } from "../../pierre-icons";
338import { cn, isMacPlatform, randomUUID } from "~/lib/utils";
339import {
340 getComposerPromptLengthValidationMessage,
341 getComposerSubmissionValidationMessage,
342 submitComposerDraft,
343} from "./composerSubmission";
344import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
345import { PierreEntryIcon } from "./PierreEntryIcon";
346import { pendingDraftWork } from "./pendingDraftWork";
347import {
348 importComposerThreadAttachment,
349 remainingComposerAttachmentSlots,
350} from "./composerThreadImport";
351import { isTimelineScrollTarget } from "./timelineScrollTarget";
352import {
353 createComposerScrollGestureState,
354 recordComposerScrollGestureEvent,
355 shouldCollapseComposerForScrollKey,
356 resetComposerScrollGesture,
357 suppressActiveComposerScrollGesture,
358} from "./composerScrollGesture";
359import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
360import { observeResize } from "~/lib/observeResize";
361
362function ComposerVideoThumbnail({ file }: { file: File }) {
363 const setVideo = useCallback(
364 (video: HTMLVideoElement | null) => {
365 if (!video) return;
366 return attachVideoThumbnail(video, file);
367 },
368 [file],
369 );
370
371 return (
372 <video
373 ref={setVideo}
374 muted
375 playsInline
376 preload="metadata"
377 aria-hidden="true"
378 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
379 className="pointer-events-none absolute inset-0 size-full object-cover"
380 />
381 );
382}
383
384type ComposerCommandMenuPosition = {
385 bottom: number;
386 left: number;
387 maxHeight: number;
388 width: number;
389};
390
391function SnapShotAttachmentFrame({
392 animationId,
393 animationSource,
394 arrival,
395 animateArrival,
396 className,
397 ...props
398}: ComponentProps<"div"> & {
399 readonly animationId?: string | undefined;
400 readonly animationSource?: SnapShotSource | undefined;
401 readonly arrival?: boolean | undefined;
402 readonly animateArrival?: boolean | undefined;
403}) {
404 const frameRef = useRef<HTMLDivElement>(null);
405
406 useLayoutEffect(() => {
407 const frame = frameRef.current;
408 if (!frame || (!animationId && !arrival)) return;
409 frame.scrollIntoView({ block: "nearest", inline: "nearest" });
410 if (!animationId) return;
411
412 return scheduleSnapShotAnimationDestination(animationId, () =>
413 setSnapShotAnimationDestination(animationId, frame, animationSource),
414 );
415 }, [animationId, animationSource, arrival]);
416
417 return (
418 <div
419 ref={frameRef}
420 className={cn(
421 animateArrival &&
422 !animationId &&
423 "origin-center transition-[opacity,scale] duration-300 ease-drawer starting:scale-95 starting:opacity-0 motion-reduce:transition-none motion-reduce:starting:scale-100 motion-reduce:starting:opacity-100",
424 className,
425 )}
426 {...props}
427 />
428 );
429}
430
431const COMPOSER_PULL_REQUEST_LIST_LIMIT = 99;
432const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
433const COMPOSER_PULL_REQUEST_RESULT_LIMIT = 12;
434const EMPTY_PULL_REQUEST_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> =
435 [];
436
437const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24;
438const COMPOSER_SCROLL_GESTURE_RESET_MS = 120;
439const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50;
440const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
441const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4;
442
443function useComposerRestingTransition(
444 isCollapsed: boolean,
445 isResting: boolean,
446 restingControlsRef: React.RefObject<HTMLDivElement | null>,
447 onOverlayHeightChange: (height: number) => void,
448 animationsActive: boolean,
449 animationDurationMs: number,
450) {
451 const elementRef = useRef<HTMLDivElement>(null);
452 const isCollapsedRef = useRef(isCollapsed);
453 const previousCollapsedRef = useRef(isCollapsed);
454 const previousRestingRef = useRef(isResting);
455 const previousHeightRef = useRef<number | null>(null);
456 const previousModelStripHeightRef = useRef<number | null>(null);
457 const previousContentOffsetsRef = useRef<{
458 promptFromTop: number | null;
459 promptHeight: number | null;
460 actionFromBottom: number | null;
461 controlsFromBottom: number | null;
462 controlsFromLeft: number | null;
463 surfaceBottomInset: number | null;
464 }>({
465 promptFromTop: null,
466 promptHeight: null,
467 actionFromBottom: null,
468 controlsFromBottom: null,
469 controlsFromLeft: null,
470 surfaceBottomInset: null,
471 });
472 const animationRef = useRef<Animation | null>(null);
473 const animationTargetHeightRef = useRef<number | null>(null);
474 const contentAnimationsRef = useRef<Animation[]>([]);
475 const stateChangeAnimationsRef = useRef<Animation[]>([]);
476 const pinnedOverlayRef = useRef<HTMLElement | null>(null);
477 const transitionCleanupTimeoutRef = useRef<number | null>(null);
478 const transitionLayoutRequestRef = useRef(0);
479 const hasCompletedInitialLayoutRef = useRef(false);
480
481 const clearOverlayPin = useCallback(() => {
482 // The overlay belongs to the chat view and outlives this composer, so it
483 // is remembered from pin time rather than re-resolved through a ref that
484 // React may already have detached during unmount.
485 const overlay = pinnedOverlayRef.current;
486 pinnedOverlayRef.current = null;
487 overlay?.style.removeProperty("height");
488 overlay?.style.removeProperty("display");
489 overlay?.style.removeProperty("flex-direction");
490 overlay?.style.removeProperty("justify-content");
491 }, []);
492
493 const clearTransitionStyles = useCallback(() => {
494 const element = elementRef.current;
495 const footer = element?.querySelector<HTMLElement>('[data-chat-composer-footer="true"]');
496 element?.style.removeProperty("overflow");
497 element?.style.removeProperty("clip-path");
498 element?.style.removeProperty("overflow-clip-margin");
499 element
500 ?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]')
501 ?.style.removeProperty("height");
502 footer?.style.removeProperty("position");
503 footer?.style.removeProperty("top");
504 footer?.style.removeProperty("bottom");
505 footer?.style.removeProperty("left");
506 footer?.style.removeProperty("right");
507 footer?.style.removeProperty("height");
508 const shell = element?.closest<HTMLElement>('[data-slot="composer-shell"]');
509 shell?.removeAttribute("data-model-strip-transition");
510 const modelStrip = shell?.querySelector<HTMLElement>('[data-composer-model-strip="true"]');
511 for (const property of [
512 "position",
513 "top",
514 "visibility",
515 "height",
516 "min-height",
517 "padding-top",
518 "align-items",
519 "z-index",
520 "pointer-events",
521 ]) {
522 modelStrip?.style.removeProperty(property);
523 }
524 clearOverlayPin();
525 }, [clearOverlayPin]);
526
527 isCollapsedRef.current = isCollapsed;
528
529 const transitionToCurrentGeometry = useCallback(
530 (stateChanged: boolean) => {
531 const element = elementRef.current;
532 const surface = element?.querySelector<HTMLElement>('[data-chat-composer-surface="true"]');
533 if (!element || !surface) return;
534
535 const nextIsCollapsed = isCollapsedRef.current;
536
537 const visibleTransitionElement = (selector: string) =>
538 Array.from(element.querySelectorAll<HTMLElement>(selector)).find(
539 (candidate) => candidate.getClientRects().length > 0,
540 ) ?? null;
541 const prompt = visibleTransitionElement(
542 '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]',
543 );
544 const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]');
545 const footer = element.querySelector<HTMLElement>('[data-chat-composer-footer="true"]');
546 const continuousControls =
547 element.dataset.inlineRestingControls === "true" ||
548 element.dataset.modelOnlyStrip === "true";
549 const controls = nextIsCollapsed
550 ? restingControlsRef.current
551 : element.querySelector<HTMLElement>('[data-chat-composer-controls="left"]');
552
553 const shell = element.closest<HTMLElement>('[data-slot="composer-shell"]');
554 const modelStrip = shell?.querySelector<HTMLElement>('[data-composer-model-strip="true"]');
555 const interruptedStripHeight =
556 animationRef.current && modelStrip ? modelStrip.getBoundingClientRect().height : null;
557 const interruptedAnimation = animationRef.current;
558 const interruptedPromptTop = interruptedAnimation
559 ? (prompt?.getBoundingClientRect().top ?? null)
560 : null;
561 const interruptedActionTop = interruptedAnimation
562 ? (action?.getBoundingClientRect().top ?? null)
563 : null;
564 const interruptedControlsLeft = interruptedAnimation
565 ? (controls?.getBoundingClientRect().left ?? null)
566 : null;
567 const interruptedControlsTop = interruptedAnimation
568 ? (controls?.getBoundingClientRect().top ?? null)
569 : null;
570 const interruptedHeight = interruptedAnimation
571 ? element.getBoundingClientRect().height
572 : null;
573 const interruptedTargetHeight = animationTargetHeightRef.current;
574 const interruptedCurrentTime =
575 typeof interruptedAnimation?.currentTime === "number"
576 ? interruptedAnimation.currentTime
577 : null;
578 const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration;
579 if (transitionCleanupTimeoutRef.current !== null) {
580 window.clearTimeout(transitionCleanupTimeoutRef.current);
581 transitionCleanupTimeoutRef.current = null;
582 }
583 interruptedAnimation?.cancel();
584 animationRef.current = null;
585 for (const animation of contentAnimationsRef.current) animation.cancel();
586 contentAnimationsRef.current = [];
587 // The reveal and fade animations keep their own schedule across the
588 // body-resize re-entries that retarget the geometry mid-flight (every
589 // transition with a draft triggers one); cancelling them there would
590 // pop their subjects to full visibility at the start of the tween.
591 if (stateChanged) {
592 for (const animation of stateChangeAnimationsRef.current) animation.cancel();
593 stateChangeAnimationsRef.current = [];
594 }
595 clearTransitionStyles();
596
597 const nextRect = element.getBoundingClientRect();
598 const nextHeight = nextRect.height;
599 const nextModelStripHeight = modelStrip?.getBoundingClientRect().height ?? null;
600 // The chat view resize-observes the overlay to place the timeline
601 // inset, the scroll-to-end pill, and the mini player. Publishing the
602 // destination height here turns that feedback into one update instead
603 // of a ChatView re-render on every animation frame.
604 const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]');
605 const overlayRect = overlay?.getBoundingClientRect();
606 const overlayHeight = overlayRect?.height ?? null;
607 if (overlayHeight !== null) {
608 onOverlayHeightChange(overlayHeight);
609 }
610 const nextPromptRect = prompt?.getBoundingClientRect() ?? null;
611 const nextPromptTop = nextPromptRect?.top ?? null;
612 const nextActionTop = action?.getBoundingClientRect().top ?? null;
613 const nextControlsRect = controls?.getBoundingClientRect() ?? null;
614 const nextControlsTop = nextControlsRect?.top ?? null;
615 const footerBottom = footer ? nextRect.bottom - footer.getBoundingClientRect().bottom : 1;
616 const previousHeight = interruptedHeight ?? previousHeightRef.current;
617 const targetChanged =
618 interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5;
619 const shouldAnimate = shouldAnimateComposerRestingTransition({
620 hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current,
621 stateChanged,
622 hasInterruptedAnimation: interruptedHeight !== null,
623 });
624
625 if (
626 shouldAnimate &&
627 animationsActive &&
628 previousHeight !== null &&
629 Math.abs(previousHeight - nextHeight) >= 0.5
630 ) {
631 const remainingDuration =
632 typeof interruptedDuration === "number" && interruptedCurrentTime !== null
633 ? Math.max(1, interruptedDuration - interruptedCurrentTime)
634 : animationDurationMs;
635 const duration =
636 interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs;
637 element.style.overflow = "clip";
638 if (modelStrip) {
639 // The model controls cross the input's lower edge on their way to the strip.
640 const controlsClearance = Math.max(
641 nextModelStripHeight ?? 0,
642 previousModelStripHeightRef.current ?? 0,
643 );
644 element.style.overflowClipMargin = `${controlsClearance}px`;
645 element.style.clipPath = `inset(0 0 -${controlsClearance}px 0)`;
646 }
647 surface.style.height = "100%";
648
649 // Pinning the overlay at the destination height keeps the resize
650 // observer quiet for the tween; bottom alignment keeps the animating
651 // surface glued to the overlay's stable bottom edge. The pin lasts
652 // only for the tween so later attachment, thread, font, and viewport
653 // changes remain natural.
654 if (overlay && overlayHeight !== null) {
655 overlay.style.height = `${String(overlayHeight)}px`;
656 overlay.style.display = "flex";
657 overlay.style.flexDirection = "column";
658 overlay.style.justifyContent = "flex-end";
659 pinnedOverlayRef.current = overlay;
660 }
661
662 // Keep the footer attached to the stable bottom edge while the outer
663 // height changes. Its resting absolute layout otherwise spans the old
664 // height on collapse, while its expanded flow layout falls below the
665 // clipped surface on expansion.
666 if (footer) {
667 footer.style.position = "absolute";
668 footer.style.top = "auto";
669 footer.style.bottom = `${String(footerBottom)}px`;
670 footer.style.height = "3rem";
671 if (nextIsCollapsed) {
672 footer.style.left = "auto";
673 footer.style.right = "1px";
674 } else {
675 footer.style.left = "1px";
676 footer.style.right = "1px";
677 }
678 }
679
680 let stripAnimation: Animation | null = null;
681 if (modelStrip) {
682 const stripHeight = modelStrip.getBoundingClientRect().height;
683 const stripOverlap = -Number.parseFloat(getComputedStyle(modelStrip).marginTop);
684 const fromHeight =
685 interruptedStripHeight ??
686 (previousCollapsedRef.current
687 ? (previousModelStripHeightRef.current ?? stripHeight)
688 : stripOverlap);
689 const toHeight = nextIsCollapsed ? stripHeight : stripOverlap;
690 modelStrip.style.position = "relative";
691 modelStrip.style.top = "auto";
692 modelStrip.style.visibility = "visible";
693 modelStrip.style.minHeight = "0";
694 // Padding must not impose a 24px minimum on the 16px overlap endpoint.
695 // Keep the controls bottom-aligned while the strip retracts behind the input.
696 modelStrip.style.paddingTop = "0";
697 modelStrip.style.alignItems = "flex-end";
698 modelStrip.style.pointerEvents = "none";
699 modelStrip.style.zIndex = "20";
700 shell?.setAttribute("data-model-strip-transition", "true");
701 stripAnimation = modelStrip.animate(
702 [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }],
703 { duration, easing: COMPOSER_RESTING_TRANSITION_EASING, fill: "both" },
704 );
705 }
706
707 const animation = element.animate(
708 [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }],
709 {
710 duration,
711 easing: COMPOSER_RESTING_TRANSITION_EASING,
712 fill: "both",
713 },
714 );
715 animationRef.current = animation;
716 animationTargetHeightRef.current = nextHeight;
717
718 const animatedRect = element.getBoundingClientRect();
719 const previousBottom =
720 overlayRect && previousContentOffsetsRef.current.surfaceBottomInset !== null
721 ? overlayRect.bottom - previousContentOffsetsRef.current.surfaceBottomInset
722 : animatedRect.bottom;
723 const bottomShift = previousBottom - animatedRect.bottom;
724 const previousPromptTop =
725 interruptedPromptTop ??
726 (previousContentOffsetsRef.current.promptFromTop === null
727 ? null
728 : animatedRect.top + bottomShift + previousContentOffsetsRef.current.promptFromTop);
729 const previousActionTop =
730 interruptedActionTop ??
731 (previousContentOffsetsRef.current.actionFromBottom === null
732 ? null
733 : previousBottom - previousContentOffsetsRef.current.actionFromBottom);
734 const contentAnimations: Animation[] = stripAnimation ? [stripAnimation] : [];
735 const animateContentPosition = (
736 content: HTMLElement | null,
737 previousTop: number | null,
738 previousLeft: number | null = null,
739 ) => {
740 if (!content || previousTop === null) return;
741 const rect = content.getBoundingClientRect();
742 const offset = previousTop - rect.top;
743 const offsetX = previousLeft === null ? 0 : previousLeft - rect.left;
744 if (Math.abs(offset) < 0.5 && Math.abs(offsetX) < 0.5) return;
745 contentAnimations.push(
746 content.animate(
747 [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "none" }],
748 {
749 duration,
750 easing: COMPOSER_RESTING_TRANSITION_EASING,
751 },
752 ),
753 );
754 };
755 animateContentPosition(prompt, previousPromptTop);
756 animateContentPosition(action, previousActionTop);
757 if (continuousControls) {
758 const previousControlsTop =
759 interruptedControlsTop ??
760 (previousContentOffsetsRef.current.controlsFromBottom === null
761 ? null
762 : previousBottom - previousContentOffsetsRef.current.controlsFromBottom);
763 const previousControlsLeft =
764 interruptedControlsLeft ??
765 (previousContentOffsetsRef.current.controlsFromLeft === null
766 ? null
767 : animatedRect.left + previousContentOffsetsRef.current.controlsFromLeft);
768 animateContentPosition(controls, previousControlsTop, previousControlsLeft);
769 }
770 contentAnimationsRef.current = contentAnimations;
771
772 if (stateChanged) {
773 const stateChangeAnimations: Animation[] = [];
774
775 // A prompt that gains lines on expansion would otherwise slide up
776 // from under the footer band as one block. Opening a bottom clip in
777 // step with the tween instead unfurls the extra lines beneath the
778 // rising first line, so no text crosses the returning controls.
779 const previousPromptHeight = previousContentOffsetsRef.current.promptHeight;
780 if (
781 !nextIsCollapsed &&
782 prompt &&
783 nextPromptRect &&
784 previousPromptHeight !== null &&
785 nextPromptRect.height - previousPromptHeight >= 0.5
786 ) {
787 const hiddenHeight = nextPromptRect.height - previousPromptHeight;
788 stateChangeAnimations.push(
789 prompt.animate(
790 [
791 { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` },
792 { clipPath: "inset(0 0 0 0)" },
793 ],
794 {
795 duration,
796 easing: COMPOSER_RESTING_TRANSITION_EASING,
797 },
798 ),
799 );
800 }
801
802 // The footer controls teleport between the composer footer and the
803 // context strip below it in a single commit. Fading the arriving
804 // cluster in along its direction of travel reads as one continuous
805 // move instead of a pop. Collapsing controls land in empty strip
806 // space and can appear immediately, but expanding controls return
807 // to the bottom row the prompt still occupies while the surface is
808 // short, so they stay hidden through the first half of the tween
809 // and fade in once the geometry has mostly settled.
810 const arrivingControls = nextIsCollapsed
811 ? restingControlsRef.current
812 : element.querySelector<HTMLElement>('[data-chat-composer-controls="left"]');
813 if (arrivingControls && !continuousControls) {
814 const drift = nextIsCollapsed
815 ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX
816 : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX;
817 stateChangeAnimations.push(
818 arrivingControls.animate(
819 [
820 { opacity: 0, transform: `translateY(${String(drift)}px)` },
821 { opacity: 1, transform: "none" },
822 ],
823 {
824 duration: nextIsCollapsed ? duration : duration / 2,
825 delay: nextIsCollapsed ? 0 : duration / 2,
826 fill: "backwards",
827 easing: COMPOSER_RESTING_TRANSITION_EASING,
828 },
829 ),
830 );
831 }
832
833 const arrivingImagePreviews = nextIsCollapsed
834 ? Array.from(
835 element.querySelectorAll<HTMLElement>('[data-chat-composer-resting-images="true"]'),
836 )
837 : Array.from(
838 element.querySelectorAll<HTMLElement>('[data-chat-composer-expanded-image="true"]'),
839 );
840 for (const imagePreview of arrivingImagePreviews) {
841 stateChangeAnimations.push(
842 imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], {
843 duration: nextIsCollapsed ? duration : duration / 2,
844 delay: nextIsCollapsed ? 0 : duration / 2,
845 fill: "backwards",
846 easing: COMPOSER_RESTING_TRANSITION_EASING,
847 }),
848 );
849 }
850 stateChangeAnimationsRef.current = stateChangeAnimations;
851 }
852
853 const finishTransition = (cancelAnimations: boolean) => {
854 if (animationRef.current !== animation) return;
855 if (transitionCleanupTimeoutRef.current !== null) {
856 window.clearTimeout(transitionCleanupTimeoutRef.current);
857 transitionCleanupTimeoutRef.current = null;
858 }
859 if (cancelAnimations) {
860 animation.cancel();
861 for (const contentAnimation of contentAnimationsRef.current) {
862 contentAnimation.cancel();
863 }
864 for (const stateChangeAnimation of stateChangeAnimationsRef.current) {
865 stateChangeAnimation.cancel();
866 }
867 }
868 animationRef.current = null;
869 animationTargetHeightRef.current = null;
870 contentAnimationsRef.current = [];
871 stateChangeAnimationsRef.current = [];
872 animation.cancel();
873 stripAnimation?.cancel();
874 clearTransitionStyles();
875 };
876 void animation.finished.catch(() => undefined).then(() => finishTransition(false));
877 // A suspended document timeline can leave `finished` pending while
878 // these measurement styles remain active. Wall-clock cleanup makes
879 // the natural layout the eventual source of truth in that case.
880 transitionCleanupTimeoutRef.current = window.setTimeout(
881 () => finishTransition(true),
882 duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS,
883 );
884 } else {
885 animationTargetHeightRef.current = null;
886 }
887
888 previousCollapsedRef.current = nextIsCollapsed;
889 previousHeightRef.current = nextHeight;
890 previousModelStripHeightRef.current = nextModelStripHeight;
891 previousContentOffsetsRef.current = {
892 promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top,
893 promptHeight: nextPromptRect?.height ?? null,
894 actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop,
895 controlsFromBottom: nextControlsTop === null ? null : nextRect.bottom - nextControlsTop,
896 controlsFromLeft: nextControlsRect === null ? null : nextControlsRect.left - nextRect.left,
897 surfaceBottomInset: overlayRect ? overlayRect.bottom - nextRect.bottom : null,
898 };
899 },
900 [
901 animationDurationMs,
902 animationsActive,
903 clearTransitionStyles,
904 onOverlayHeightChange,
905 restingControlsRef,
906 ],
907 );
908
909 useLayoutEffect(() => {
910 const requestId = transitionLayoutRequestRef.current + 1;
911 transitionLayoutRequestRef.current = requestId;
912 const stateChanged = previousCollapsedRef.current !== isCollapsed;
913 // A non-Git context strip enters or leaves flow through ChatView state in
914 // an earlier layout effect. Let React flush that parent update before the
915 // FLIP reads its destination geometry, while still running before paint.
916 queueMicrotask(() => {
917 if (transitionLayoutRequestRef.current !== requestId) return;
918 transitionToCurrentGeometry(stateChanged);
919 });
920 return () => {
921 if (transitionLayoutRequestRef.current === requestId) {
922 transitionLayoutRequestRef.current += 1;
923 }
924 };
925 }, [isCollapsed, transitionToCurrentGeometry]);
926
927 // The resting flag can change while the collapsed layout stays the same,
928 // for example when an unfocused thread crosses the phone breakpoint. The
929 // chat view pairs overlay heights with that flag, so republish the natural
930 // height for the new flag. A transition in flight publishes its own.
931 useLayoutEffect(() => {
932 if (previousRestingRef.current === isResting) return;
933 previousRestingRef.current = isResting;
934 if (animationRef.current) return;
935 const overlay = elementRef.current?.closest<HTMLElement>('[data-chat-composer-overlay="true"]');
936 if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height);
937 }, [isResting, onOverlayHeightChange]);
938
939 useLayoutEffect(() => {
940 const element = elementRef.current;
941 if (!element) return;
942
943 const body = element.querySelector<HTMLElement>('[data-chat-composer-body="true"]');
944 return observeResize(body ? [element, body] : element, (entries) => {
945 if (animationRef.current) {
946 if (body && entries.some((entry) => entry.target === body)) {
947 transitionToCurrentGeometry(false);
948 }
949 return;
950 }
951 const elementRect = element.getBoundingClientRect();
952 const visibleTransitionElement = (selector: string) =>
953 Array.from(element.querySelectorAll<HTMLElement>(selector)).find(
954 (candidate) => candidate.getClientRects().length > 0,
955 ) ?? null;
956 const promptRect = visibleTransitionElement(
957 '[data-composer-prompt-surface="true"], [data-chat-composer-transition-prompt="true"]',
958 )?.getBoundingClientRect();
959 const actionTop = visibleTransitionElement(
960 '[data-chat-composer-transition-actions="true"]',
961 )?.getBoundingClientRect().top;
962 const controlsRect = (
963 isCollapsedRef.current
964 ? restingControlsRef.current
965 : visibleTransitionElement('[data-chat-composer-controls="left"]')
966 )?.getBoundingClientRect();
967 previousHeightRef.current = elementRect.height;
968 previousContentOffsetsRef.current = {
969 promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top,
970 promptHeight: promptRect?.height ?? null,
971 actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop,
972 controlsFromBottom:
973 controlsRect === undefined ? null : elementRect.bottom - controlsRect.top,
974 controlsFromLeft: controlsRect === undefined ? null : controlsRect.left - elementRect.left,
975 surfaceBottomInset:
976 (element
977 .closest<HTMLElement>('[data-chat-composer-overlay="true"]')
978 ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom,
979 };
980 });
981 }, [restingControlsRef, transitionToCurrentGeometry]);
982
983 useEffect(() => {
984 // Host discovery and width measurement settle through layout updates on
985 // mount. Treat that bootstrap as initial geometry so an existing thread
986 // paints at rest instead of visibly collapsing from the expanded height.
987 hasCompletedInitialLayoutRef.current = true;
988 return () => {
989 if (transitionCleanupTimeoutRef.current !== null) {
990 window.clearTimeout(transitionCleanupTimeoutRef.current);
991 transitionCleanupTimeoutRef.current = null;
992 }
993 animationRef.current?.cancel();
994 animationRef.current = null;
995 animationTargetHeightRef.current = null;
996 for (const animation of contentAnimationsRef.current) animation.cancel();
997 contentAnimationsRef.current = [];
998 for (const animation of stateChangeAnimationsRef.current) animation.cancel();
999 stateChangeAnimationsRef.current = [];
1000 clearTransitionStyles();
1001 };
1002 }, [clearTransitionStyles]);
1003
1004 return elementRef;
1005}
1006
1007function composerCommandMenuPositionsEqual(
1008 a: ComposerCommandMenuPosition,
1009 b: ComposerCommandMenuPosition,
1010): boolean {
1011 return (
1012 a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width
1013 );
1014}
1015
1016function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) {
1017 const [position, setPosition] = useState<ComposerCommandMenuPosition | null>(null);
1018
1019 useLayoutEffect(() => {
1020 const anchor = props.anchor;
1021 if (!anchor) {
1022 setPosition(null);
1023 return;
1024 }
1025
1026 const updatePosition = () => {
1027 const form = anchor.closest<HTMLElement>('[data-chat-composer-form="true"]');
1028 const mainSurface = form?.querySelector<HTMLElement>(
1029 '[data-chat-composer-main-surface="true"]',
1030 );
1031 const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect();
1032 const rootFontSizePx =
1033 Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
1034 const drawerInsetRem = Number.parseFloat(
1035 window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"),
1036 );
1037 const drawerInset = drawerInsetRem * rootFontSizePx;
1038 const width = rect.width - drawerInset * 2;
1039 if (width <= 0) {
1040 setPosition(null);
1041 return;
1042 }
1043 // One extra pixel prevents fractional layout coordinates from exposing
1044 // the canvas between the drawer mask and the composer's foreground edge.
1045 // Mirrors --chat-composer-attachment-overlap: calc(1rem + 1px).
1046 const composerOverlap = rootFontSizePx + 1;
1047 const next = {
1048 bottom: window.innerHeight - rect.top - composerOverlap,
1049 left: rect.left + drawerInset,
1050 maxHeight: Math.max(96, rect.top - 24 + composerOverlap),
1051 width,
1052 };
1053 setPosition((current) =>
1054 current && composerCommandMenuPositionsEqual(current, next) ? current : next,
1055 );
1056 };
1057
1058 updatePosition();
1059 window.addEventListener("resize", updatePosition);
1060 window.addEventListener("scroll", updatePosition, true);
1061
1062 // The composer is centered and capped at a max width, so opening a side
1063 // panel slides it sideways without ever resizing it. Watching the anchor
1064 // alone would leave the menu behind; the ancestors are what shrink, and
1065 // they resize on every frame of the panel animation.
1066 const observed: Element[] = [anchor];
1067 for (let element = anchor.parentElement; element; element = element.parentElement) {
1068 observed.push(element);
1069 }
1070 const stopObserving = observeResize(observed, updatePosition);
1071
1072 return () => {
1073 stopObserving();
1074 window.removeEventListener("resize", updatePosition);
1075 window.removeEventListener("scroll", updatePosition, true);
1076 };
1077 }, [props.anchor]);
1078
1079 if (!position) return null;
1080
1081 return createPortal(
1082 <div
1083 className="pointer-events-auto fixed z-40 flex flex-col"
1084 data-composer-drawer-layer="true"
1085 style={{
1086 bottom: position.bottom,
1087 left: position.left,
1088 maxHeight: position.maxHeight,
1089 width: position.width,
1090 }}
1091 >
1092 {props.children}
1093 </div>,
1094 document.body,
1095 );
1096}
1097import { Button } from "../ui/button";
1098import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select";
1099import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1100import { toastManager } from "../ui/toast";
1101import {
1102 FileIcon,
1103 BotIcon,
1104 CircleAlertIcon,
1105 PaperclipIcon,
1106 PencilRulerIcon,
1107 PlayIcon,
1108 ShieldIcon,
1109 XIcon,
1110} from "lucide-react";
1111import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
1112import { hasProviderSetup } from "./ProviderStatusBanner";
1113import {
1114 applyProviderInstanceSettings,
1115 deriveProviderInstanceEntries,
1116 NO_PROVIDER_MODEL_SELECTION,
1117 sortProviderInstanceEntries,
1118 type ProviderInstanceEntry,
1119} from "../../providerInstances";
1120import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
1121import type { UnifiedSettings } from "@t3tools/contracts/settings";
1122import {
1123 isVideoAttachment,
1124 type ChatMessage,
1125 type SessionPhase,
1126 type Thread,
1127 type ThreadShell,
1128 videoMimeType,
1129} from "../../types";
1130import {
1131 buildComposerPromptHistoryEntries,
1132 stepComposerPromptHistory,
1133 type ComposerPromptHistoryPosition,
1134} from "./composerPromptHistory";
1135import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
1136import type {
1137 LatestProposedPlanState,
1138 PendingApproval,
1139 PendingUserInput,
1140} from "../../session-logic";
1141import {
1142 resolveComposerDispatchMode,
1143 type ComposerDispatchMode,
1144} from "@t3tools/client-runtime/state/composer-dispatch";
1145import type { ContextWindowSnapshot } from "../../lib/contextWindow";
1146import {
1147 getProviderSlashCommandsForSlashMenu,
1148 getProviderSkillsForSlashMenu,
1149 hasCompleteProviderWorkspaceSnapshot,
1150 hasCurrentProviderWorkspaceSnapshot,
1151 resolveProviderSkillsForCwd,
1152 resolveProviderSlashCommandsForCwd,
1153} from "@t3tools/client-runtime/providerSkills";
1154import { searchProviderSkills } from "../../providerSkillSearch";
1155import { useDelayedStatus } from "../../hooks/useDelayedStatus";
1156import { useMediaQuery } from "../../hooks/useMediaQuery";
1157import { usePanelAnimationSettings } from "../../panelAnimations";
1158import { useAtomCommand } from "../../state/use-atom-command";
1159import { readEnvironmentScope } from "../../state/session";
1160import { ComposerDevServerStatus } from "./ComposerDevServerStatus";
1161import { serverEnvironment } from "../../state/server";
1162import type { ReviewCommentContext } from "../../reviewCommentContext";
1163
1164const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
1165
1166const extendReplacementRangeForTrailingSpace = (
1167 text: string,
1168 rangeEnd: number,
1169 replacement: string,
1170): number => {
1171 if (!replacement.endsWith(" ")) {
1172 return rangeEnd;
1173 }
1174 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
1175};
1176
1177function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) {
1178 const [controls, setControls] = useState<HTMLDivElement | null>(null);
1179 const controlsRef = useRef<HTMLDivElement>(null);
1180 const attachControls = useCallback((element: HTMLDivElement | null) => {
1181 controlsRef.current = element;
1182 setControls(element);
1183 }, []);
1184 const hostRef = useRef(host);
1185 hostRef.current = host;
1186 const [layout, setLayout] = useState<{
1187 hiddenCount: number;
1188 iconOnlyCount?: number;
1189 visible: boolean;
1190 }>({ hiddenCount: 0, visible: true });
1191
1192 const measure = useCallback(() => {
1193 const currentHost = useControlsAsHost ? controls : hostRef.current;
1194 if (currentHost === null || !controls) return;
1195
1196 const measurement = measureRestingComposerControls(controls);
1197 if (!measurement) return;
1198 const style = getComputedStyle(currentHost);
1199 const hostWidth =
1200 currentHost.clientWidth -
1201 (Number.parseFloat(style.paddingInlineStart) || 0) -
1202 (Number.parseFloat(style.paddingInlineEnd) || 0);
1203
1204 setLayout((current) => {
1205 const next = resolveRestingComposerControlsLayout({
1206 ...measurement,
1207 hostWidth,
1208 previous: current,
1209 });
1210 return next.hiddenCount === current.hiddenCount &&
1211 next.iconOnlyCount === current.iconOnlyCount &&
1212 next.visible === current.visible
1213 ? current
1214 : next;
1215 });
1216 }, [controls, useControlsAsHost]);
1217
1218 useLayoutEffect(measure, [measure, host]);
1219 useEffect(() => {
1220 const currentHost = useControlsAsHost ? controls : host;
1221 if (!currentHost || !controls) return;
1222 let stopObserving = () => {};
1223 const observeControls = () => {
1224 stopObserving();
1225 stopObserving = observeResize(
1226 [
1227 currentHost,
1228 controls,
1229 ...controls.querySelectorAll<HTMLElement>(
1230 "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]",
1231 ),
1232 ],
1233 measure,
1234 );
1235 measure();
1236 };
1237 observeControls();
1238 const mutations = new MutationObserver(observeControls);
1239 mutations.observe(controls, { childList: true, subtree: true, characterData: true });
1240 document.fonts.addEventListener("loadingdone", measure);
1241 return () => {
1242 stopObserving();
1243 mutations.disconnect();
1244 document.fonts.removeEventListener("loadingdone", measure);
1245 };
1246 }, [host, controls, useControlsAsHost, measure]);
1247
1248 return {
1249 controlsRef,
1250 attachControls,
1251 hiddenBlockCount: layout.hiddenCount,
1252 iconOnlyBlockCount: layout.iconOnlyCount ?? 0,
1253 controlsVisible: layout.visible,
1254 };
1255}
1256
1257type RuntimeModeOption = { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode];
1258const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeConfig[mode] }));
1259const supervisedRuntimeModeOption = {
1260 mode: "approval-required" as const,
1261 ...runtimeModeConfig["approval-required"],
1262};
1263const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
1264 showInteractionModeToggle: boolean;
1265 interactionMode: ProviderInteractionMode;
1266 runtimeMode: RuntimeMode;
1267 runtimeModeOptions: ReadonlyArray<RuntimeModeOption>;
1268 size?: "sm" | "xs";
1269 hidden?: boolean;
1270 onToggleInteractionMode: () => void;
1271 onRuntimeModeChange: (mode: RuntimeMode) => void;
1272}) {
1273 const size = props.size ?? "sm";
1274 const composerFloatingLayerProps = useComposerMenuProps();
1275 const [open, setOpen] = useComposerMenuState(props.hidden);
1276 const runtimeModeOption =
1277 props.runtimeModeOptions.find((option) => option.mode === props.runtimeMode) ??
1278 supervisedRuntimeModeOption;
1279 const RuntimeModeIcon = runtimeModeOption.icon;
1280 const interactionModeTooltip =
1281 props.interactionMode === "plan"
1282 ? "Plan mode — click to return to normal build mode"
1283 : "Default mode — click to enter plan mode";
1284
1285 const interactionModeToggle = props.showInteractionModeToggle ? (
1286 <>
1287 <ComposerControlSeparator size={size} />
1288 <Tooltip>
1289 <TooltipTrigger
1290 render={
1291 <ComposerControl
1292 size={size}
1293 className="shrink-0 whitespace-nowrap"
1294 aria-pressed={props.interactionMode === "plan"}
1295 type="button"
1296 onClick={props.onToggleInteractionMode}
1297 aria-label={interactionModeTooltip}
1298 />
1299 }
1300 >
1301 {props.interactionMode === "plan" ? (
1302 <ComposerControlIcon
1303 icon={PencilRulerIcon}
1304 size={size}
1305 className="text-current opacity-100"
1306 />
1307 ) : (
1308 <ComposerControlIcon
1309 icon={BotIcon}
1310 size={size}
1311 opticalSize={size === "xs" ? "default" : "large"}
1312 />
1313 )}
1314 <span data-composer-control-label className="sr-only sm:not-sr-only">
1315 {props.interactionMode === "plan" ? "Plan" : "Build"}
1316 </span>
1317 </TooltipTrigger>
1318 <TooltipPopup side="top">{interactionModeTooltip}</TooltipPopup>
1319 </Tooltip>
1320 </>
1321 ) : null;
1322
1323 return (
1324 <>
1325 <ComposerControlSeparator size={size} />
1326
1327 <Tooltip>
1328 <Select
1329 open={open}
1330 onOpenChange={setOpen}
1331 value={props.runtimeMode}
1332 onValueChange={(value) => props.onRuntimeModeChange(value!)}
1333 >
1334 <TooltipTrigger
1335 render={
1336 <ComposerSelectControl
1337 data-composer-shortcut="composer.mode"
1338 size={size}
1339 aria-label="Runtime mode"
1340 />
1341 }
1342 >
1343 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />
1344 <SelectValue data-composer-control-label>{runtimeModeOption.label}</SelectValue>
1345 </TooltipTrigger>
1346 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
1347 {props.runtimeModeOptions.map((option) => {
1348 const OptionIcon = option.icon;
1349 return (
1350 <SelectItem
1351 key={option.mode}
1352 value={option.mode}
1353 hideIndicator
1354 className="min-w-64"
1355 >
1356 <div className="flex min-w-0 items-center gap-3">
1357 <div className="grid min-w-0 flex-1 gap-0.5">
1358 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
1359 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
1360 {option.label}
1361 </span>
1362 <span className="text-muted-foreground text-xs leading-4">
1363 {option.description}
1364 </span>
1365 </div>
1366 </div>
1367 </SelectItem>
1368 );
1369 })}
1370 </SelectPopup>
1371 </Select>
1372 <TooltipPopup side="top">{runtimeModeOption.description}</TooltipPopup>
1373 </Tooltip>
1374
1375 {interactionModeToggle}
1376 </>
1377 );
1378});
1379
1380const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
1381 compact: boolean;
1382 canOperateThread: boolean;
1383 activeContextWindow: ContextWindowSnapshot | null;
1384 reserveContextWindowMeter: boolean;
1385 activeThreadModelDisplayName: string | null;
1386 isPreparingWorktree: boolean;
1387 pendingAction: {
1388 questionIndex: number;
1389 isLastQuestion: boolean;
1390 canAdvance: boolean;
1391 isResponding: boolean;
1392 isComplete: boolean;
1393 } | null;
1394 isRunning: boolean;
1395 canInterrupt: boolean;
1396 followUpBehavior: "queue" | "steer";
1397 alternateShortcutLabel: string | null;
1398 showPlanFollowUpPrompt: boolean;
1399 promptHasText: boolean;
1400 isSendBusy: boolean;
1401 sendDisabledReason: string | null;
1402 isConnecting: boolean;
1403 isEnvironmentUnavailable: boolean;
1404 hasSendableContent: boolean;
1405 canResume: boolean;
1406 preserveComposerFocusOnPointerDown?: boolean;
1407 isEditingQueuedMessage: boolean;
1408 onSubmitMessage: React.MouseEventHandler<HTMLButtonElement>;
1409 onResume: () => void;
1410 onPreviousPendingQuestion: () => void;
1411 onInterrupt: () => void;
1412 onImplementPlanInNewThread: () => void;
1413 onCompactContext?: (() => void) | undefined;
1414 compactDisabled: boolean;
1415 compactDisabledReason: string | null;
1416 compactBeforeSendTokens: number | null;
1417 keepFullHistory: boolean;
1418 onToggleKeepFullHistory: () => void;
1419}) {
1420 return (
1421 <>
1422 {props.activeContextWindow ? (
1423 <ContextWindowMeter
1424 usage={props.activeContextWindow}
1425 modelDisplayName={props.activeThreadModelDisplayName}
1426 onCompact={props.onCompactContext}
1427 compactDisabled={props.compactDisabled}
1428 compactDisabledReason={props.compactDisabledReason}
1429 />
1430 ) : props.reserveContextWindowMeter ? (
1431 <ContextWindowMeterPlaceholder />
1432 ) : null}
1433 <ComposerPrimaryActions
1434 compact={props.compact}
1435 canOperateThread={props.canOperateThread}
1436 pendingAction={props.pendingAction}
1437 isRunning={props.isRunning}
1438 canInterrupt={props.canInterrupt}
1439 followUpBehavior={props.followUpBehavior}
1440 alternateShortcutLabel={props.alternateShortcutLabel}
1441 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
1442 promptHasText={props.promptHasText}
1443 isSendBusy={props.isSendBusy}
1444 sendDisabledReason={props.sendDisabledReason}
1445 isConnecting={props.isConnecting}
1446 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
1447 isPreparingWorktree={props.isPreparingWorktree}
1448 hasSendableContent={props.hasSendableContent}
1449 canResume={props.canResume}
1450 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
1451 isEditingQueuedMessage={props.isEditingQueuedMessage}
1452 onSubmitMessage={props.onSubmitMessage}
1453 onResume={props.onResume}
1454 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
1455 onInterrupt={props.onInterrupt}
1456 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
1457 compactBeforeSendTokens={props.compactBeforeSendTokens}
1458 keepFullHistory={props.keepFullHistory}
1459 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
1460 />
1461 </>
1462 );
1463});
1464
1465// --------------------------------------------------------------------------
1466// Handle exposed to ChatView
1467// --------------------------------------------------------------------------
1468
1469export interface ChatComposerHandle {
1470 focusAtEnd: () => void;
1471 focusAt: (cursor: number) => void;
1472 /** Expand the desktop composer at the timeline end without taking focus. */
1473 restoreAfterTimelineReachedEnd: () => void;
1474 collapseForTimelineScrollKey: (key: string) => void;
1475 addDroppedFiles: (files: File[]) => void;
1476 addDroppedFolders: (folders: File[]) => void;
1477 hasPendingAttachments: () => boolean;
1478 insertTextAtEnd: (
1479 text: string,
1480 options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer },
1481 ) => boolean;
1482 /** Apply large-paste folding for text redirected from a blurred composer. */
1483 pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean;
1484 citeAssistantText: (
1485 citation: AssistantCitation,
1486 sourceAnchor: AssistantCitationSourceAnchor,
1487 ) => boolean;
1488 openModelPicker: () => void;
1489 toggleModelPicker: () => void;
1490 openControl: (command: KeybindingCommand) => void;
1491 isModelPickerOpen: () => boolean;
1492 /** True when a collapsed caret sits before everything in the draft, including when it is empty. */
1493 isCaretAtStart: () => boolean;
1494 compactContext: () => void;
1495 readSnapshot: () => {
1496 value: string;
1497 cursor: number;
1498 expandedCursor: number;
1499 contextIds: string[];
1500 };
1501 /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */
1502 resetCursorState: (options?: {
1503 cursor?: number;
1504 prompt?: string;
1505 detectTrigger?: boolean;
1506 }) => void;
1507 /** Insert a terminal context from the terminal drawer. */
1508 addTerminalContext: (selection: TerminalContextSelection) => void;
1509 /** Get the current prompt/effort/model state for use in send. */
1510 getSendContext: () => {
1511 answeringPendingUserInput: boolean;
1512 prompt: string;
1513 images: ComposerImageAttachment[];
1514 files: ComposerFileAttachment[];
1515 terminalContexts: TerminalContextDraft[];
1516 previewAnnotations: PreviewAnnotationPayload[];
1517 reviewComments: ReviewCommentContext[];
1518 threadContexts: ThreadContextRecord[];
1519 selectedPromptEffort: string | null;
1520 selectedModelOptionsForDispatch: unknown;
1521 selectedModelSelection: ModelSelection;
1522 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
1523 providerAvailable: boolean;
1524 selectedProvider: ProviderDriverKind;
1525 selectedModel: string;
1526 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
1527 interactionMode: ProviderInteractionMode;
1528 interactionModeEnabled: boolean;
1529 };
1530 /** Validate the fully composed text immediately before a provider turn starts. */
1531 validateProviderInput: (providerInput: string) => boolean;
1532 setMultipleModelSelections: (selections: ReadonlyArray<ModelSelection>) => void;
1533}
1534
1535// --------------------------------------------------------------------------
1536// Props
1537// --------------------------------------------------------------------------
1538
1539export interface ChatComposerProps {
1540 composerDraftTarget: ScopedThreadRef | DraftId;
1541 environmentId: EnvironmentId;
1542 canOperateThread: boolean;
1543 attachmentUploadsCapabilityKnown: boolean;
1544 supportsAttachmentUploads: boolean;
1545 supportsQuestionAttachments: boolean;
1546 maxFileAttachmentBytes: number | null;
1547 routeKind: "server" | "draft";
1548 routeThreadRef: ScopedThreadRef;
1549 draftId: DraftId | null;
1550 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
1551 supportsMultipleModels: boolean;
1552 onMultipleModelSelectionsChange: React.Dispatch<
1553 React.SetStateAction<ReadonlyArray<ModelSelection> | null>
1554 >;
1555
1556 // Thread context
1557 activeThreadId: ThreadId | null;
1558 activeThreadEnvironmentId: EnvironmentId | undefined;
1559 activeThread: Thread | undefined;
1560 /** The routed server thread's shell, present before its detail loads. */
1561 activeThreadShell: ThreadShell | null;
1562 /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */
1563 promptHistoryMessages: ReadonlyArray<ChatMessage>;
1564 isServerThread: boolean;
1565 isLocalDraftThread: boolean;
1566 forceExpandedOnMobile: boolean;
1567 projectSelectionRequired: boolean;
1568
1569 // Session phase
1570 phase: SessionPhase;
1571 /** Stop is offered: a run is preparing, starting, or running. */
1572 canInterrupt: boolean;
1573 isConnecting: boolean;
1574 isSendBusy: boolean;
1575 canResume: boolean;
1576 isRevertingCheckpoint?: boolean;
1577 sendDisabledReason: string | null;
1578 isPreparingWorktree: boolean;
1579 bannerItems: readonly ComposerBannerStackItem[];
1580 /** Tokens a stale session would re-read; null when the thread is not offered compaction. */
1581 resumeCompactionTokens: number | null;
1582 /** The Compact chip is off, so the next send keeps full history. */
1583 keepFullHistory: boolean;
1584 /** Flips the Compact chip for the active thread. */
1585 onToggleKeepFullHistory: () => void;
1586 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
1587 onUsageLimitsCommand?: (() => void) | undefined;
1588 environmentUnavailable: {
1589 readonly label: string;
1590 readonly connection: EnvironmentConnectionPresentation;
1591 } | null;
1592
1593 // Pending approvals / inputs
1594 activePendingApproval: PendingApproval | null;
1595 pendingApprovals: PendingApproval[];
1596 pendingUserInputs: PendingUserInput[];
1597 activePendingProgress: {
1598 questionIndex: number;
1599 isLastQuestion: boolean;
1600 canAdvance: boolean;
1601 customAnswer: string;
1602 activeQuestion: {
1603 id: string;
1604 initialAnswer?: string | undefined;
1605 multiSelect?: boolean | undefined;
1606 allowCustomAnswer?: boolean | undefined;
1607 } | null;
1608 } | null;
1609 activePendingResolvedAnswers: Record<string, unknown> | null;
1610 activePendingIsResponding: boolean;
1611 activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>;
1612 activePendingQuestionIndex: number;
1613 respondingRequestIds: RuntimeRequestId[];
1614
1615 // Plan
1616 showPlanFollowUpPrompt: boolean;
1617 activeProposedPlan: LatestProposedPlanState | null;
1618 activeTasksProgress: ComposerTasksProgress | null;
1619 activeTaskSteps: readonly ComposerTaskStep[] | null;
1620 threadSyncPhase: ThreadSyncPhase | null;
1621
1622 // Mode
1623 runtimeMode: RuntimeMode;
1624 interactionMode: ProviderInteractionMode;
1625
1626 // Provider / model
1627 lockedProvider: ProviderDriverKind | null;
1628 providerStatuses: ServerProvider[];
1629 /** False until the environment's server config has arrived at least once. */
1630 providerCatalogKnown: boolean;
1631 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
1632 activeThreadModelSelection: ModelSelection | null | undefined;
1633 reportedModelSelection?: ModelSelection | null;
1634 /** The active project, so the picker can fold in its provider instance overrides. */
1635 activeProjectId: ProjectId | null;
1636
1637 // Context window
1638 activeContextWindow: ContextWindowSnapshot | null;
1639 compactThreadUnavailable: boolean;
1640 compactDisabled: boolean;
1641 compactDisabledReason: string | null;
1642
1643 // Misc
1644 resolvedTheme: "light" | "dark";
1645 settings: UnifiedSettings;
1646 keybindings: ResolvedKeybindingsConfig;
1647 terminalOpen: boolean;
1648 gitCwd: string | null;
1649 pullRequestProjectId: ProjectId | null;
1650 pullRequestRepository: string | null;
1651 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
1652 threadPullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined;
1653 restingControlsHost: HTMLDivElement | null;
1654 restingControlsHaveLeadingContext: boolean;
1655 onRestingControlsVisibilityChange: (visible: boolean) => void;
1656 getTimelineScrollableNode: () => HTMLElement | null;
1657 isTimelineAtLogicalEnd: () => boolean;
1658 /** Whether the timeline has more content than fits above the composer. */
1659 timelineOverflows: boolean;
1660 onComposerOverlayHeightChange: (height: number) => void;
1661 /**
1662 * Whether the desktop resting layout is active. Reported from a layout
1663 * effect, so it is current before the chat view measures the overlay.
1664 */
1665 onRestingChange: (resting: boolean) => void;
1666
1667 // Refs the parent needs kept in sync
1668 promptRef: React.RefObject<string>;
1669 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
1670 composerFilesRef: React.RefObject<ComposerFileAttachment[]>;
1671 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
1672 composerRef: React.RefObject<ChatComposerHandle | null>;
1673 onPageScrollKeyDown: (key: "PageUp" | "PageDown") => void;
1674 onPageScrollKeyUp: (key: string) => void;
1675 onPageScrollRelease: () => void;
1676
1677 // Queued runs strip rendered above the composer (v2 queue/steer).
1678 queuedRunsControl?: ReactNode;
1679 // Queued-message edit mode: attachments already stored on the message being
1680 // edited. Rendered in the attachment strip with a remove control; removal is
1681 // client state in ChatView until the edit is saved.
1682 editingQueuedAttachments: ReadonlyArray<{
1683 readonly attachment: ContractChatAttachment;
1684 readonly url: string | null;
1685 }> | null;
1686 onRemoveEditingQueuedAttachment: (attachmentId: string) => void;
1687
1688 // Callbacks
1689 onCompactContext: () => void;
1690 onSend: (
1691 e?: { preventDefault: () => void },
1692 dispatchMode?: ComposerDispatchMode,
1693 submissionIntent?: ComposerSubmissionIntent,
1694 ) => void;
1695 onResume: () => void;
1696 onInterrupt: () => void;
1697 onImplementPlanInNewThread: () => void;
1698 onRespondToApproval: (
1699 requestId: RuntimeRequestId,
1700 decision: ProviderApprovalDecision,
1701 ) => Promise<unknown>;
1702 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
1703 onAdvanceActivePendingUserInput: () => void;
1704 onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void;
1705 onPreviousActivePendingUserInputQuestion: () => void;
1706 onChangeActivePendingUserInputCustomAnswer: (
1707 questionId: string,
1708 value: string,
1709 nextCursor: number,
1710 expandedCursor: number,
1711 suppressSuggestions: boolean,
1712 ) => void;
1713
1714 onProviderModelSelect: (
1715 instanceId: ProviderInstanceId,
1716 model: string,
1717 options?: { focusComposer?: boolean },
1718 ) => void;
1719 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
1720 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
1721 toggleInteractionMode: () => void;
1722 handleRuntimeModeChange: (mode: RuntimeMode) => void;
1723 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
1724
1725 focusComposer: () => void;
1726 scheduleComposerFocus: () => void;
1727 setThreadError: (threadId: ThreadId | null, error: string | null) => void;
1728 onExpandImage: (preview: ExpandedImagePreview) => void;
1729 onFileOpen: (attachment: ChatFileAttachment) => void;
1730}
1731
1732// --------------------------------------------------------------------------
1733// Component
1734// --------------------------------------------------------------------------
1735
1736export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {
1737 const {
1738 composerDraftTarget,
1739 environmentId,
1740 canOperateThread,
1741 attachmentUploadsCapabilityKnown,
1742 supportsAttachmentUploads,
1743 supportsQuestionAttachments,
1744 maxFileAttachmentBytes,
1745 routeKind,
1746 routeThreadRef,
1747 draftId,
1748 multipleModelSelections,
1749 supportsMultipleModels,
1750 onMultipleModelSelectionsChange: setMultipleModelSelections,
1751 activeThreadId,
1752 activeThreadEnvironmentId: _activeThreadEnvironmentId,
1753 activeThread,
1754 promptHistoryMessages,
1755 isServerThread: _isServerThread,
1756 isLocalDraftThread: _isLocalDraftThread,
1757 forceExpandedOnMobile,
1758 projectSelectionRequired,
1759 phase,
1760 canInterrupt,
1761 isConnecting,
1762 isSendBusy,
1763 canResume,
1764 isRevertingCheckpoint = false,
1765 sendDisabledReason: externalSendDisabledReason,
1766 isPreparingWorktree,
1767 environmentUnavailable,
1768 activePendingApproval: incomingPendingApproval,
1769 pendingApprovals,
1770 pendingUserInputs: incomingPendingUserInputs,
1771 activePendingProgress: incomingPendingProgress,
1772 activePendingResolvedAnswers,
1773 activePendingIsResponding: incomingPendingIsResponding,
1774 activePendingDraftAnswers,
1775 activePendingQuestionIndex,
1776 respondingRequestIds,
1777 showPlanFollowUpPrompt,
1778 activeProposedPlan,
1779 runtimeMode,
1780 interactionMode: requestedInteractionMode,
1781 lockedProvider,
1782 providerStatuses,
1783 providerCatalogKnown,
1784 activeProjectDefaultModelSelection,
1785 activeThreadModelSelection,
1786 reportedModelSelection,
1787 activeProjectId,
1788 activeContextWindow,
1789 compactThreadUnavailable,
1790 compactDisabled,
1791 compactDisabledReason,
1792 resolvedTheme,
1793 settings,
1794 keybindings,
1795 terminalOpen,
1796 gitCwd,
1797 pullRequestProjectId,
1798 pullRequestRepository,
1799 threadPullRequests,
1800 restingControlsHost,
1801 restingControlsHaveLeadingContext,
1802 onRestingControlsVisibilityChange,
1803 getTimelineScrollableNode,
1804 isTimelineAtLogicalEnd,
1805 timelineOverflows,
1806 onComposerOverlayHeightChange,
1807 onRestingChange,
1808 promptRef,
1809 composerRef,
1810 composerImagesRef,
1811 composerFilesRef,
1812 composerTerminalContextsRef,
1813 onPageScrollKeyDown,
1814 onPageScrollKeyUp,
1815 onPageScrollRelease,
1816 onCompactContext,
1817 onSend,
1818 onResume,
1819 onInterrupt,
1820 onImplementPlanInNewThread,
1821 onRespondToApproval,
1822 onSelectActivePendingUserInputOption,
1823 onAdvanceActivePendingUserInput,
1824 onDismissActivePendingUserInput,
1825 onPreviousActivePendingUserInputQuestion,
1826 onChangeActivePendingUserInputCustomAnswer,
1827 onProviderModelSelect,
1828 onOpenProviderSetup,
1829 getModelDisabledReason,
1830 toggleInteractionMode,
1831 handleRuntimeModeChange,
1832 handleInteractionModeChange,
1833 focusComposer,
1834 scheduleComposerFocus,
1835 setThreadError,
1836 onExpandImage,
1837 onFileOpen,
1838 editingQueuedAttachments,
1839 onRemoveEditingQueuedAttachment,
1840 } = props;
1841 const primaryEnvironmentId = usePrimaryEnvironmentId();
1842 const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
1843 const {
1844 heldRequestIds,
1845 onDraftChange: onTypingGuardDraftChange,
1846 onFocus: onTypingGuardFocus,
1847 onBlur: onTypingGuardBlur,
1848 onSend: onTypingGuardSend,
1849 } = useComposerTypingGuard(composerDraftTargetKey, [
1850 ...pendingApprovals.map((request) => request.requestId),
1851 ...incomingPendingUserInputs.map((request) => request.requestId),
1852 ]);
1853 // Hold composer takeover while the user finishes their thread draft.
1854 const activePendingApproval =
1855 incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId)
1856 ? incomingPendingApproval
1857 : null;
1858 const holdingUserInput = incomingPendingUserInputs[0]
1859 ? heldRequestIds.has(incomingPendingUserInputs[0].requestId)
1860 : false;
1861 const pendingUserInputs = holdingUserInput ? [] : incomingPendingUserInputs;
1862 const activePendingProgress = holdingUserInput ? null : incomingPendingProgress;
1863 const activePendingIsResponding = !holdingUserInput && incomingPendingIsResponding;
1864 const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined;
1865 // Opening a running thread resyncs for a few frames. Show the sync row, and
1866 // hide the tasks row for it, only when the sync lasts. Logic that depends on
1867 // the real phase keeps reading `props.threadSyncPhase`.
1868 const shownSyncPhase = useDelayedStatus(composerDraftTargetKey, props.threadSyncPhase);
1869 const activeTasksProgress = shownSyncPhase === null ? props.activeTasksProgress : null;
1870 const activeTaskSteps = shownSyncPhase === null ? props.activeTaskSteps : null;
1871 const isEditingQueuedMessage = editingQueuedAttachments !== null;
1872 // ------------------------------------------------------------------
1873 // Store subscriptions (prompt / images / terminal contexts)
1874 // ------------------------------------------------------------------
1875 const composerDraft = useComposerThreadDraft(composerDraftTarget);
1876 // Live target key, for async flows that must notice a thread switch that
1877 // happened while they awaited.
1878 const composerDraftTargetKeyRef = useRef("");
1879 composerDraftTargetKeyRef.current = composerDraftTargetKey;
1880 const questionAttachmentTarget =
1881 pendingUserInputs[0] && activePendingProgress?.activeQuestion
1882 ? questionAttachmentDraftId(
1883 environmentId,
1884 activeThreadId!,
1885 pendingUserInputs[0].requestId,
1886 activePendingProgress.activeQuestion.id,
1887 )
1888 : null;
1889 const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget;
1890 const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget);
1891 const attachmentTargetKey = composerTargetKey(attachmentDraftTarget);
1892 // An import that finishes after a draft change must compare against the draft open *now*, not
1893 // the one captured in the closure that started it.
1894 const attachmentTargetKeyRef = useRef(attachmentTargetKey);
1895 attachmentTargetKeyRef.current = attachmentTargetKey;
1896 const questionPreparations = useQuestionAttachmentPreparation((state) => state.counts);
1897 const prompt = composerDraft.prompt;
1898 const composerImages = attachmentDraft.images;
1899 const composerFiles = attachmentDraft.files;
1900 // A question answer has no chips: its files live in the question draft and show in the
1901 // strip. Only the thread prompt's references decide which files leave the strip.
1902 const inlineFileIdSet = useMemo(() => {
1903 if (questionAttachmentTarget) return new Set<string>();
1904 const contextIds = new Set(collectInlineContextIds(prompt));
1905 return new Set(
1906 composerFiles
1907 .filter((file) => contextIds.has(toKindScopedComposerContextId("file", file.id)))
1908 .map((file) => file.id),
1909 );
1910 }, [composerFiles, prompt, questionAttachmentTarget]);
1911 const composerVideos = composerFiles.filter((file) =>
1912 isPreviewableComposerVideo(file, environmentId),
1913 );
1914 const composerOtherFiles = composerOtherFilesForPresentation(
1915 composerFiles,
1916 environmentId,
1917 inlineFileIdSet,
1918 );
1919 const composerTerminalContexts = composerDraft.terminalContexts;
1920 const composerPreviewAnnotations = composerDraft.previewAnnotations;
1921 const composerReviewComments = composerDraft.reviewComments;
1922 const composerThreadContexts = attachmentDraft.threadContexts;
1923 const pendingSnapShotAnimations = useSyncExternalStore(
1924 subscribeToPendingSnapShotAnimations,
1925 getPendingSnapShotAnimations,
1926 getPendingSnapShotAnimations,
1927 );
1928 const pendingSnapShotIds = useMemo(
1929 () => pendingSnapShotAnimationIdsForTarget(pendingSnapShotAnimations, composerDraftTarget),
1930 [composerDraftTarget, pendingSnapShotAnimations],
1931 );
1932 const pendingSnapShotIdSet = useMemo(() => new Set(pendingSnapShotIds), [pendingSnapShotIds]);
1933 const uncommittedSnapShotIds = pendingSnapShotIds.filter(
1934 (id) => !composerImages.some((image) => image.id === id),
1935 );
1936 const standaloneComposerImages = useMemo(() => {
1937 const previewAnnotationIds = new Set(
1938 composerPreviewAnnotations.map((annotation) => annotation.id),
1939 );
1940 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));
1941 }, [composerImages, composerPreviewAnnotations]);
1942 const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
1943 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
1944 const openPrLink = useOpenPrLink(routeThreadRef);
1945 const [previewFileId, setPreviewFileId] = useState<string | null>(null);
1946 const previewFile = composerFiles.find((file) => file.id === previewFileId);
1947 const composerContextActions = useMemo(
1948 () => ({
1949 environmentId,
1950 cwd: gitCwd ?? undefined,
1951 expandImage: (imageId: string) => {
1952 const preview = buildExpandedImagePreview(composerImages, imageId);
1953 if (preview) onExpandImage(preview);
1954 },
1955 openFile: setPreviewFileId,
1956 openMention: (path: string) => useRightPanelStore.getState().openFile(routeThreadRef, path),
1957 expandVideo: (fileId: string) => {
1958 const file = composerFiles.find((candidate) => candidate.id === fileId);
1959 if (!file || !isVideoAttachment(file)) return;
1960 const localPreview = buildExpandedImagePreview([file], file.id);
1961 if (localPreview) {
1962 onExpandImage(localPreview);
1963 return;
1964 }
1965 if (file.uploadedAttachmentId === undefined || file.uploadEnvironmentId !== environmentId) {
1966 return;
1967 }
1968 const persistedPreview = buildAttachmentVideoPreview(environmentId, {
1969 type: "file",
1970 id: file.uploadedAttachmentId,
1971 name: file.name,
1972 mimeType: file.mimeType,
1973 sizeBytes: file.sizeBytes,
1974 });
1975 if (persistedPreview) onExpandImage(persistedPreview);
1976 },
1977 openPullRequest: (event: React.MouseEvent<HTMLElement>, url: string) => {
1978 openPrLink(event, url);
1979 },
1980 }),
1981 [
1982 composerFiles,
1983 composerImages,
1984 gitCwd,
1985 environmentId,
1986 onExpandImage,
1987 openPrLink,
1988 routeThreadRef,
1989 ],
1990 );
1991 const composerContextRecords = useMemo(
1992 () =>
1993 composerContextRecordsFromDraft({
1994 terminalContexts: composerTerminalContexts,
1995 reviewComments: composerReviewComments,
1996 previewAnnotations: composerPreviewAnnotations,
1997 threadContexts: composerThreadContexts,
1998 images: composerImages,
1999 files: composerFiles,
2000 uploadsByImageId,
2001 }),
2002 [
2003 composerFiles,
2004 composerImages,
2005 composerPreviewAnnotations,
2006 composerReviewComments,
2007 composerTerminalContexts,
2008 composerThreadContexts,
2009 uploadsByImageId,
2010 ],
2011 );
2012 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
2013 const fileStagingLimit = fileAttachmentStagingLimit({
2014 attachmentUploadsCapabilityKnown,
2015 supportsAttachmentUploads,
2016 maxFileAttachmentBytes,
2017 });
2018 const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({
2019 files: composerFiles,
2020 attachmentUploadsCapabilityKnown,
2021 supportsAttachmentUploads,
2022 maxFileAttachmentBytes,
2023 });
2024 const attachmentBlockReason =
2025 (questionAttachmentTarget &&
2026 !supportsQuestionAttachments &&
2027 (composerImages.length > 0 || composerFiles.length > 0)
2028 ? "Update this server to send files with question answers"
2029 : null) ??
2030 fileCapabilityBlockReason ??
2031 (supportsAttachmentUploads
2032 ? needsReattachFileCount > 0
2033 ? needsReattachFileCount === 1
2034 ? "Attach the interrupted file again or remove it"
2035 : "Attach the interrupted files again or remove them"
2036 : attachmentUploadBlockReason({
2037 imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id),
2038 uploadsByImageId,
2039 environmentId,
2040 })
2041 : null);
2042 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
2043 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
2044 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
2045 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
2046 const removeComposerDraftFile = useComposerDraftStore((store) => store.removeFile);
2047 const setComposerDraftFileUpload = useComposerDraftStore((store) => store.setFileUpload);
2048 const insertComposerDraftTerminalContext = useComposerDraftStore(
2049 (store) => store.insertTerminalContext,
2050 );
2051 const setComposerDraftTerminalContexts = useComposerDraftStore(
2052 (store) => store.setTerminalContexts,
2053 );
2054 const removeComposerDraftPreviewAnnotation = useComposerDraftStore(
2055 (store) => store.removePreviewAnnotation,
2056 );
2057 const removeComposerDraftReviewComment = useComposerDraftStore(
2058 (store) => store.removeReviewComment,
2059 );
2060 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
2061 (store) => store.clearPersistedAttachments,
2062 );
2063 const clearComposerDraftTerminalContexts = useComposerDraftStore(
2064 (store) => store.clearTerminalContexts,
2065 );
2066 const clearComposerDraftPromptAndImages = useComposerDraftStore(
2067 (store) => store.clearComposerPromptAndImages,
2068 );
2069 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
2070 (store) => store.syncPersistedAttachments,
2071 );
2072 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
2073
2074 useEffect(() => {
2075 if (!attachmentUploadsCapabilityKnown) {
2076 return;
2077 }
2078 if (!supportsAttachmentUploads) {
2079 // The capability can flap on reconnect or version skew. Deleting a
2080 // persisted hydrated upload here would make the next send fail
2081 // verification while the file still sits in the draft.
2082 for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss([
2083 ...composerImages,
2084 ...composerFiles,
2085 ])) {
2086 releaseAttachmentUpload(attachment.id);
2087 }
2088 return;
2089 }
2090 if (!canOperateThread) return;
2091 const invalidFiles =
2092 maxFileAttachmentBytes === null
2093 ? composerFiles
2094 : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes);
2095 for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) {
2096 releaseAttachmentUpload(attachment.id);
2097 }
2098 const uploadableFiles =
2099 maxFileAttachmentBytes === null
2100 ? []
2101 : composerFiles.filter((file) => file.sizeBytes <= maxFileAttachmentBytes);
2102 const uploadableAttachments = [...composerImages, ...uploadableFiles];
2103 for (const attachment of uploadableAttachments) {
2104 // A needs-reattach file has no bytes to upload and no upload to verify.
2105 if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
2106 continue;
2107 }
2108 startAttachmentUpload({
2109 environmentId,
2110 image: attachment,
2111 draftTarget: attachmentDraftTarget,
2112 });
2113 }
2114 }, [
2115 attachmentUploadsCapabilityKnown,
2116 attachmentDraftTarget,
2117 canOperateThread,
2118 composerFiles,
2119 composerImages,
2120 environmentId,
2121 maxFileAttachmentBytes,
2122 supportsAttachmentUploads,
2123 ]);
2124
2125 useEffect(() => {
2126 for (const file of composerFiles) {
2127 if (
2128 !attachmentUploadsCapabilityKnown ||
2129 !supportsAttachmentUploads ||
2130 maxFileAttachmentBytes === null ||
2131 file.sizeBytes > maxFileAttachmentBytes
2132 ) {
2133 continue;
2134 }
2135 const upload = uploadsByImageId[file.id];
2136 if (upload?.status === "ready" && upload.environmentId === environmentId) {
2137 setComposerDraftFileUpload(
2138 attachmentDraftTarget,
2139 file.id,
2140 environmentId,
2141 upload.attachmentId,
2142 );
2143 }
2144 }
2145 }, [
2146 attachmentUploadsCapabilityKnown,
2147 attachmentDraftTarget,
2148 composerFiles,
2149 environmentId,
2150 maxFileAttachmentBytes,
2151 setComposerDraftFileUpload,
2152 supportsAttachmentUploads,
2153 uploadsByImageId,
2154 ]);
2155
2156 // ------------------------------------------------------------------
2157 // Model state
2158 // ------------------------------------------------------------------
2159 // Instance-aware projection of the wire provider list. One entry per
2160 // configured instance (default built-in + any custom `providerInstances.*`),
2161 // sorted default-first per driver kind for a stable picker order.
2162 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
2163 () =>
2164 sortProviderInstanceEntries(
2165 applyProviderInstanceSettings(
2166 deriveProviderInstanceEntries(providerStatuses),
2167 settings,
2168 activeProjectId,
2169 ),
2170 ),
2171 [providerStatuses, settings, activeProjectId],
2172 );
2173 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
2174 const {
2175 selectedProviderEntry,
2176 requestedDriverKind,
2177 lockedContinuationGroupKey,
2178 unavailableProviderInstanceId,
2179 } = useMemo(
2180 () =>
2181 resolveComposerProviderSelection({
2182 entries: providerInstanceEntries,
2183 candidateInstanceIds: [
2184 selectedProviderByThreadId,
2185 activeThread?.runtime?.providerInstanceId,
2186 activeThreadModelSelection?.instanceId,
2187 activeProjectDefaultModelSelection?.instanceId,
2188 ],
2189 lockedProvider,
2190 lockedInstanceId:
2191 activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId,
2192 }),
2193 [
2194 activeProjectDefaultModelSelection?.instanceId,
2195 activeThread?.runtime?.providerInstanceId,
2196 activeThreadModelSelection?.instanceId,
2197 selectedProviderByThreadId,
2198 lockedProvider,
2199 providerInstanceEntries,
2200 ],
2201 );
2202 const selectedInstanceId =
2203 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
2204 const noProviderAvailable =
2205 selectedProviderEntry === undefined && multipleModelSelections === null;
2206 // Before the catalog arrives, every thread resolves to "no provider". Send
2207 // stays blocked either way; only the chrome waits, keeping the picker with
2208 // the thread's own selection instead of swapping in the setup button and
2209 // back once the catalog lands.
2210 const providerCatalogPending = noProviderAvailable && !providerCatalogKnown;
2211 const showProviderUnavailable = noProviderAvailable && !providerCatalogPending;
2212 const providerSetupInstanceId = noProviderAvailable
2213 ? (unavailableProviderInstanceId ??
2214 (lockedProvider === null
2215 ? providerInstanceEntries.find((entry) => hasProviderSetup(entry.snapshot))?.instanceId
2216 : undefined))
2217 : undefined;
2218 const resolvedCompactDisabledReason =
2219 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
2220 // The driver kind follows the instance that will actually run the turn,
2221 // which can differ from the persisted selection when that selection is
2222 // disabled.
2223 const selectedProvider: ProviderDriverKind =
2224 selectedProviderEntry?.driverKind ?? requestedDriverKind;
2225 const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes;
2226 const compatibleRuntimeModeOptions =
2227 supportedRuntimeModes && supportedRuntimeModes.length > 0
2228 ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode))
2229 : runtimeModeOptions;
2230 // Older threads can contain a mode their current provider no longer offers.
2231 // Display the provider's first supported mode, which is also its safe legacy
2232 // fallback, without mutating persisted state until the user makes a choice.
2233 const compatibleRuntimeMode = compatibleRuntimeModeOptions.some(
2234 (option) => option.mode === runtimeMode,
2235 )
2236 ? runtimeMode
2237 : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode);
2238
2239 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
2240 threadRef: composerDraftTarget,
2241 providers: providerStatuses,
2242 selectedProvider,
2243 selectedInstanceId,
2244 threadModelSelection: activeThreadModelSelection,
2245 projectModelSelection: activeProjectDefaultModelSelection,
2246 settings,
2247 });
2248 const providerSendBlockReason = getAntigravitySendBlockReason(
2249 selectedProviderEntry?.snapshot,
2250 selectedModel,
2251 );
2252 const sendDisabledReason =
2253 externalSendDisabledReason ??
2254 (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ??
2255 (activePendingProgress
2256 ? attachmentBlockReason
2257 : (attachmentBlockReason ??
2258 (multipleModelSelections === null ? providerSendBlockReason : null)));
2259 const isSendDisabled = sendDisabledReason !== null;
2260 const selectedProviderStatus = useMemo(
2261 () => selectedProviderEntry?.snapshot ?? null,
2262 [selectedProviderEntry],
2263 );
2264 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
2265 // Memoized so the composer menu memo below can cache between renders.
2266 const selectedProviderSkills = useMemo(
2267 () =>
2268 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) : [],
2269 [gitCwd, selectedProviderStatus],
2270 );
2271 const selectedProviderSlashCommands = useMemo(
2272 () =>
2273 selectedProviderStatus
2274 ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd)
2275 : [],
2276 [gitCwd, selectedProviderStatus],
2277 );
2278 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
2279 reportFailure: false,
2280 });
2281 // The last scan this composer asked for. A request inside the TTL is not
2282 // repeated, so a client clock ahead of the server's cannot loop rescans.
2283 const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null);
2284 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
2285 key: string;
2286 notBefore: number;
2287 } | null>(null);
2288 const workspaceRefreshScopeKey =
2289 gitCwd && selectedProviderEntry
2290 ? `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`
2291 : null;
2292 const workspaceSlashCommandsPending =
2293 selectedProviderStatus?.workspaceSnapshots?.some(
2294 (snapshot) => snapshot.cwd === gitCwd && snapshot.slashCommandsPending === true,
2295 ) ?? false;
2296 useEffect(() => {
2297 if (
2298 !workspaceSlashCommandsPending ||
2299 !workspaceRefreshRetry ||
2300 workspaceRefreshRetry.key !== workspaceRefreshScopeKey
2301 )
2302 return;
2303 const timeout = setTimeout(
2304 () => {
2305 setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current));
2306 },
2307 Math.max(0, workspaceRefreshRetry.notBefore - Date.now()),
2308 );
2309 return () => clearTimeout(timeout);
2310 }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]);
2311 const hadWorkspaceSnapshotRef = useRef(false);
2312 useEffect(() => {
2313 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
2314 selectedProviderStatus,
2315 gitCwd,
2316 );
2317 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
2318 workspaceRefreshKeyRef.current = null;
2319 setWorkspaceRefreshRetry(null);
2320 }
2321 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
2322 }, [gitCwd, selectedProviderStatus]);
2323 useEffect(() => {
2324 if (!gitCwd || !selectedProviderEntry) return;
2325 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
2326 const now = Date.now();
2327 const lastRequest = workspaceRefreshKeyRef.current;
2328 if (
2329 lastRequest?.key === key &&
2330 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
2331 )
2332 return;
2333 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) {
2334 setWorkspaceRefreshRetry(null);
2335 return;
2336 }
2337 const retry = workspaceRefreshRetry;
2338 if (retry?.key === key && now < retry.notBefore) return;
2339 const request = { key, requestedAt: now };
2340 workspaceRefreshKeyRef.current = request;
2341 const retryLater = () => {
2342 if (workspaceRefreshKeyRef.current !== request) return;
2343 workspaceRefreshKeyRef.current = null;
2344 setWorkspaceRefreshRetry({
2345 key,
2346 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
2347 });
2348 };
2349 void refreshProviders({
2350 environmentId,
2351 input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd },
2352 }).then((result) => {
2353 const hasWorkspaceSnapshot =
2354 result._tag === "Success" &&
2355 hasCompleteProviderWorkspaceSnapshot(
2356 result.value.providers.find(
2357 (provider) => provider.instanceId === selectedProviderEntry.instanceId,
2358 ),
2359 gitCwd,
2360 );
2361 if (!hasWorkspaceSnapshot) retryLater();
2362 }, retryLater);
2363 }, [
2364 environmentId,
2365 gitCwd,
2366 prompt,
2367 refreshProviders,
2368 selectedProviderEntry,
2369 workspaceRefreshRetry,
2370 ]);
2371 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
2372 () => selectedProviderEntry?.models ?? [],
2373 [selectedProviderEntry],
2374 );
2375
2376 const composerPromptInjectionState = useMemo(
2377 () => getComposerPromptInjectionState(prompt),
2378 [prompt],
2379 );
2380 const composerProviderState = useMemo(
2381 () =>
2382 getComposerProviderState({
2383 provider: selectedProvider,
2384 model: selectedModel,
2385 models: selectedProviderModels,
2386 promptInjectionState: composerPromptInjectionState,
2387 modelOptions: composerModelOptions?.[selectedInstanceId],
2388 planModeEnabled: settings.planModeEnabled,
2389 }),
2390 [
2391 composerModelOptions,
2392 composerPromptInjectionState,
2393 selectedInstanceId,
2394 selectedModel,
2395 selectedProvider,
2396 selectedProviderModels,
2397 settings.planModeEnabled,
2398 ],
2399 );
2400
2401 const selectedPromptEffort = composerProviderState.promptEffort;
2402 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
2403 const { enabled: planModeUiEnabled, interactionMode } = resolveComposerInteractionMode({
2404 planModeEnabled: settings.planModeEnabled,
2405 provider: selectedProviderStatus,
2406 interactionMode: requestedInteractionMode,
2407 });
2408 const selectedModelSelection = useMemo<ModelSelection>(
2409 () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
2410 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
2411 );
2412 const selectedModelForPicker = selectedModel;
2413 // Instance-keyed option list so the picker can show each configured
2414 // instance (built-in + custom) as a first-class sidebar entry. The
2415 // options are server-reported models plus that exact instance's
2416 // configured custom models. A missing OpenCode selection is included as
2417 // an unavailable row until the catalog reports it again.
2418 const modelOptionsByInstance = useMemo<
2419 ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
2420 >(() => {
2421 const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
2422 for (const entry of providerInstanceEntries) {
2423 out.set(
2424 entry.instanceId,
2425 getAppModelOptionsForInstance(
2426 settings,
2427 entry,
2428 entry.instanceId === selectedInstanceId ? selectedModelForPicker : null,
2429 ),
2430 );
2431 }
2432 return out;
2433 }, [providerInstanceEntries, selectedInstanceId, selectedModelForPicker, settings]);
2434 const selectedModelForPickerWithCustomFallback = useMemo(() => {
2435 const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
2436 return currentOptions.some((option) => option.slug === selectedModelForPicker)
2437 ? selectedModelForPicker
2438 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
2439 }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
2440
2441 // ------------------------------------------------------------------
2442 // Context window
2443 // ------------------------------------------------------------------
2444
2445 const activeThreadModelDisplayName = useMemo(
2446 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
2447 [activeThreadModelSelection, modelOptionsByInstance],
2448 );
2449 const reserveContextWindowMeter = shouldReserveContextWindowMeter({
2450 meterEnabled: settings.contextWindowMeterEnabled,
2451 detailLoading: props.threadSyncPhase === "loading",
2452 threadStarted: threadShellHasStarted(props.activeThreadShell),
2453 providerReportsContextWindow: selectedProviderStatus
2454 ? selectedProviderStatus.reportsContextWindow === true
2455 : null,
2456 });
2457
2458 // ------------------------------------------------------------------
2459 // Composer-local state
2460 // ------------------------------------------------------------------
2461 const [composerCursor, setComposerCursor] = useState(() => {
2462 const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt;
2463 return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer);
2464 });
2465 const {
2466 trigger: composerTrigger,
2467 setTrigger: setComposerTrigger,
2468 resolveTrigger: resolveComposerTrigger,
2469 dismissTrigger: dismissComposerTrigger,
2470 resetTrigger: resetComposerTrigger,
2471 } = useComposerTriggerState(() =>
2472 isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length),
2473 );
2474 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
2475 const composerSuggestionId = useId();
2476 const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
2477 // Active ArrowUp recall. Cleared on edit and on thread switch.
2478 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
2479 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
2480 null,
2481 );
2482 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
2483 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
2484 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
2485 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
2486 const isMobileViewport = useMediaQuery("max-sm");
2487 const {
2488 isComposerFocused,
2489 setIsComposerFocused,
2490 isComposerScrollCollapsed,
2491 setIsComposerScrollCollapsed,
2492 restoreAfterTimelineReachedEnd,
2493 } = useComposerFocusState();
2494 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
2495 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
2496 null,
2497 );
2498 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
2499 const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor);
2500 const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt;
2501 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
2502 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
2503 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
2504 key: 0,
2505 active: false,
2506 });
2507 const isComposerCollapsedMobile =
2508 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;
2509
2510 // ------------------------------------------------------------------
2511 // Refs
2512 // ------------------------------------------------------------------
2513 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
2514 const pasteAsTextShortcutUntilRef = useRef(0);
2515 const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set<string> }>({
2516 targetKey: "",
2517 names: new Set(),
2518 });
2519 const attachmentInputRef = useRef<HTMLInputElement>(null);
2520 const composerFormRef = useRef<HTMLFormElement>(null);
2521 const composerSurfaceRef = useRef<HTMLDivElement>(null);
2522 const providerInputRejectedRef = useRef(false);
2523 const composerSelectLockRef = useRef(false);
2524 const composerMenuOpenRef = useRef(false);
2525 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
2526 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
2527 const composerBlurFrameRef = useRef<number | null>(null);
2528 const mobileComposerExpandFrameRef = useRef<number | null>(null);
2529 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
2530 const mobileComposerExpandInFlightRef = useRef(false);
2531 const composerScrollCollapseTimeoutRef = useRef<number | null>(null);
2532 const composerScrollCollapseEligibleRef = useRef(false);
2533 const windowRefocusInFlightRef = useRef(false);
2534 const composerScrollGestureRef = useRef(createComposerScrollGestureState());
2535 const stashPulseKeyRef = useRef(0);
2536 const stashPulseTimeoutRef = useRef<number | null>(null);
2537 /**
2538 * Snapshots currently being encoded, keyed by target+prompt+image ids.
2539 * Keyed rather than boolean so a genuinely different prompt (or a different
2540 * thread) can still be stashed while an earlier encode is running.
2541 */
2542 const stashInFlightRef = useRef<Set<string>>(new Set());
2543 /**
2544 * Count of pasted images still being compressed, per thread. Reserved
2545 * against the attachment limit so concurrent pastes can't overshoot it,
2546 * and checked before sending so an image cannot move into
2547 * the next draft.
2548 */
2549 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
2550 const pendingThreadImportsRef = useRef<Map<string, number>>(new Map());
2551 const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
2552 isRevertingCheckpointRef.current = isRevertingCheckpoint;
2553
2554 useEffect(() => {
2555 const armPasteAsTextShortcut = () => {
2556 // Electron can deliver its native menu action just before the paste
2557 // event, while browsers normally deliver keydown first. A short deadline
2558 // bridges both event paths without leaving later pastes in bypass mode.
2559 pasteAsTextShortcutUntilRef.current = Date.now() + 1_000;
2560 };
2561 const onKeyDown = (event: KeyboardEvent) => {
2562 if (
2563 event.target instanceof Node &&
2564 composerFormRef.current?.contains(event.target) &&
2565 isPasteAsTextShortcut(event, isMacPlatform(navigator.platform))
2566 ) {
2567 armPasteAsTextShortcut();
2568 }
2569 };
2570 const onBlur = () => {
2571 pasteAsTextShortcutUntilRef.current = 0;
2572 };
2573 const onPrepareUiRefresh = (event: Event) => {
2574 if (
2575 pendingImageCompressionsRef.current.size > 0 ||
2576 pendingThreadImportsRef.current.size > 0 ||
2577 stashInFlightRef.current.size > 0
2578 )
2579 event.preventDefault();
2580 };
2581 const onDesktopPasteAsText = () => {
2582 const activeElement = document.activeElement;
2583 const blocksPasteToFocus =
2584 activeElement instanceof Element &&
2585 activeElement.closest(
2586 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]',
2587 ) !== null;
2588 if (
2589 (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) ||
2590 !blocksPasteToFocus
2591 ) {
2592 armPasteAsTextShortcut();
2593 }
2594 };
2595 window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
2596 window.addEventListener("keydown", onKeyDown, true);
2597 window.addEventListener("blur", onBlur);
2598 window.addEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
2599 return () => {
2600 window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText);
2601 window.removeEventListener("keydown", onKeyDown, true);
2602 window.removeEventListener("blur", onBlur);
2603 window.removeEventListener(PREPARE_UI_REFRESH_EVENT, onPrepareUiRefresh);
2604 };
2605 }, []);
2606
2607 // ------------------------------------------------------------------
2608 // Derived: composer send state
2609 // ------------------------------------------------------------------
2610 const composerSendState = useMemo(
2611 () =>
2612 deriveComposerSendState({
2613 prompt,
2614 imageCount: composerImages.length + composerFiles.length,
2615 terminalContexts: composerTerminalContexts,
2616 elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length,
2617 }),
2618 [
2619 composerFiles.length,
2620 composerImages.length,
2621 composerPreviewAnnotations.length,
2622 composerReviewComments.length,
2623 composerTerminalContexts,
2624 prompt,
2625 ],
2626 );
2627 // ------------------------------------------------------------------
2628 // Derived: composer trigger / menu
2629 // ------------------------------------------------------------------
2630 const composerTriggerKind = composerTrigger?.kind ?? null;
2631 const [emojiSearch, setEmojiSearch] = useState<
2632 typeof import("./composerEmojiSearch") | "error" | null
2633 >(null);
2634 const isEmojiTrigger = composerTriggerKind === "emoji";
2635
2636 useEffect(() => {
2637 if (!isEmojiTrigger || emojiSearch !== null) return;
2638 let active = true;
2639 void import("./composerEmojiSearch").then(
2640 (search) => {
2641 if (active) setEmojiSearch(search);
2642 },
2643 () => {
2644 if (active) setEmojiSearch("error");
2645 },
2646 );
2647 return () => {
2648 active = false;
2649 };
2650 }, [emojiSearch, isEmojiTrigger]);
2651
2652 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
2653 const pullRequestTriggerQuery =
2654 composerTrigger?.kind === "pull-request" ? composerTrigger.query : "";
2655 const pullRequestTextQuery =
2656 composerTriggerKind === "pull-request" &&
2657 pullRequestTriggerQuery.length > 0 &&
2658 !/^\d+$/u.test(pullRequestTriggerQuery)
2659 ? pullRequestTriggerQuery
2660 : null;
2661 const debouncedPullRequestTextQuery = useDebouncedValue(pullRequestTextQuery, 180);
2662 const settledPullRequestTextQuery =
2663 pullRequestTextQuery === debouncedPullRequestTextQuery ? pullRequestTextQuery : null;
2664 const isPathTrigger = composerTriggerKind === "path";
2665 // Thread shells only feed `@` thread matches, so skip shell updates otherwise.
2666 const environmentThreadShells = useThreadShells(isPathTrigger);
2667 const workspaceEntries = useComposerPathSearch({
2668 environmentId,
2669 cwd: isPathTrigger ? gitCwd : null,
2670 query: isPathTrigger ? pathTriggerQuery : null,
2671 });
2672 const compactSlashCommandAvailable =
2673 composerTrigger?.kind === "slash-command" &&
2674 prompt.slice(0, composerTrigger.rangeStart).trim() === "" &&
2675 !compactThreadUnavailable &&
2676 prompt.slice(composerTrigger.rangeEnd).trim() === "" &&
2677 composerImages.length + composerFiles.length === 0 &&
2678 composerDraft.persistedAttachments.length === 0 &&
2679 composerTerminalContexts.length === 0 &&
2680 composerPreviewAnnotations.length === 0 &&
2681 composerReviewComments.length === 0;
2682
2683 const pullRequestListTargets = useMemo(
2684 () =>
2685 composerTriggerKind !== "pull-request" ||
2686 pullRequestProjectId === null ||
2687 (pullRequestTextQuery !== null && settledPullRequestTextQuery === null)
2688 ? EMPTY_PULL_REQUEST_LIST_TARGETS
2689 : [
2690 {
2691 environmentId,
2692 input: {
2693 state: "all" as const,
2694 projectId: pullRequestProjectId,
2695 limit: COMPOSER_PULL_REQUEST_LIST_LIMIT,
2696 ...(settledPullRequestTextQuery === null
2697 ? {}
2698 : { query: settledPullRequestTextQuery }),
2699 },
2700 },
2701 ],
2702 [
2703 composerTriggerKind,
2704 environmentId,
2705 pullRequestProjectId,
2706 pullRequestTextQuery,
2707 settledPullRequestTextQuery,
2708 ],
2709 );
2710 const pullRequestLookup = usePullRequestList(pullRequestListTargets);
2711 const pullRequestTriggerNumber = useMemo(() => {
2712 if (composerTrigger?.kind !== "pull-request" || composerTrigger.query.length === 0) {
2713 return null;
2714 }
2715 const number = Number(composerTrigger.query);
2716 return Number.isSafeInteger(number) && number > 0 ? number : null;
2717 }, [composerTrigger]);
2718 const debouncedPullRequestNumber = useDebouncedValue(pullRequestTriggerNumber, 180);
2719 const settledPullRequestNumber =
2720 pullRequestTriggerNumber === debouncedPullRequestNumber ? pullRequestTriggerNumber : null;
2721 const recentHasExactPullRequest =
2722 settledPullRequestNumber !== null &&
2723 pullRequestLookup.data?.entries.some(
2724 (entry) =>
2725 entry.projectId === pullRequestProjectId &&
2726 entry.repository.trim().toLowerCase() === pullRequestRepository?.trim().toLowerCase() &&
2727 entry.number === settledPullRequestNumber,
2728 ) === true;
2729 const exactPullRequestLookup = useEnvironmentQuery(
2730 settledPullRequestNumber === null ||
2731 pullRequestProjectId === null ||
2732 pullRequestRepository === null ||
2733 recentHasExactPullRequest
2734 ? null
2735 : pullRequestEnvironment.detail({
2736 environmentId,
2737 input: {
2738 projectId: pullRequestProjectId,
2739 repository: pullRequestRepository,
2740 number: settledPullRequestNumber,
2741 },
2742 }),
2743 );
2744
2745 const linkedPullRequests = useMemo(
2746 () =>
2747 pullRequestProjectId === null
2748 ? []
2749 : composerPullRequestsFromLinks(threadPullRequests ?? [], pullRequestProjectId),
2750 [pullRequestProjectId, threadPullRequests],
2751 );
2752
2753 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
2754 if (!composerTrigger) return [];
2755 if (composerTrigger.kind === "emoji") {
2756 return emojiSearch && emojiSearch !== "error"
2757 ? emojiSearch.searchComposerEmoji(composerTrigger.query)
2758 : [];
2759 }
2760 if (composerTrigger.kind === "path") {
2761 // Threads only surface for a typed query so `@` alone stays a file picker. A title match
2762 // is far more specific than a fuzzy path hit, so the few threads lead the list.
2763 return [
2764 ...matchComposerThreadItems({
2765 shells: environmentThreadShells,
2766 environmentId,
2767 excludeThreadId: activeThreadId,
2768 query: composerTrigger.query,
2769 }),
2770 ...workspaceEntries.entries.map((entry) => ({
2771 id: `path:${entry.kind}:${entry.path}`,
2772 type: "path" as const,
2773 path: entry.path,
2774 pathKind: entry.kind,
2775 label: basenameOfPath(entry.path),
2776 description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))),
2777 })),
2778 ];
2779 }
2780 if (composerTrigger.kind === "slash-command") {
2781 const builtInSlashCommandItems = [
2782 {
2783 id: "slash:model",
2784 type: "slash-command",
2785 command: "model",
2786 label: "/model",
2787 description: "Switch response model for this thread",
2788 },
2789 ...(planModeUiEnabled
2790 ? ([
2791 {
2792 id: "slash:plan",
2793 type: "slash-command",
2794 command: "plan",
2795 label: "/plan",
2796 description: "Switch this thread into plan mode",
2797 },
2798 {
2799 id: "slash:default",
2800 type: "slash-command",
2801 command: "default",
2802 label: "/default",
2803 description: "Switch this thread back to normal build mode",
2804 },
2805 ] as const)
2806 : []),
2807 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
2808 const slashMenuSkills = getProviderSkillsForSlashMenu(
2809 selectedProviderSkills,
2810 settings.showSkillsInSlashMenu,
2811 );
2812 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
2813 selectedProviderSlashCommands,
2814 slashMenuSkills,
2815 ).map((command) => ({
2816 id: `provider-slash-command:${selectedProvider}:${command.name}`,
2817 type: "provider-slash-command" as const,
2818 provider: selectedProvider,
2819 command,
2820 label: `/${command.name}`,
2821 description: command.description ?? command.input?.hint ?? "Run provider command",
2822 }));
2823 const query = composerTrigger.query.trim().toLowerCase();
2824 const skillItems = slashMenuSkills.map((skill) => ({
2825 id: `skill:${selectedProvider}:${skill.name}`,
2826 type: "skill" as const,
2827 provider: selectedProvider,
2828 skill,
2829 label: `/skill:${skill.name}`,
2830 description:
2831 skill.shortDescription ??
2832 skill.description ??
2833 (skill.scope ? `${skill.scope} skill` : ""),
2834 }));
2835 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
2836 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
2837 );
2838 const slashCommandItems = slashCommandItemsForPromptPosition(
2839 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
2840 composerTrigger.rangeStart === 0,
2841 );
2842 return searchSlashCommandItems(slashCommandItems, query);
2843 }
2844 if (composerTrigger.kind === "skill") {
2845 return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
2846 id: `skill:${selectedProvider}:${skill.name}`,
2847 type: "skill" as const,
2848 provider: selectedProvider,
2849 skill,
2850 label: formatProviderSkillDisplayName(skill),
2851 description:
2852 skill.shortDescription ??
2853 skill.description ??
2854 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
2855 }));
2856 }
2857 if (
2858 composerTrigger.kind === "pull-request" &&
2859 pullRequestProjectId !== null &&
2860 pullRequestRepository !== null
2861 ) {
2862 const exactPullRequest =
2863 exactPullRequestLookup.data?.number === pullRequestTriggerNumber
2864 ? [exactPullRequestLookup.data]
2865 : [];
2866 const matches = /^\d*$/u.test(composerTrigger.query)
2867 ? filterComposerPullRequestMatches({
2868 entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])],
2869 linked: linkedPullRequests,
2870 projectId: pullRequestProjectId,
2871 repository: pullRequestRepository,
2872 query: composerTrigger.query,
2873 limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT,
2874 })
2875 : rankPullRequestMatches(
2876 (pullRequestLookup.data?.entries ?? []).filter((entry) => {
2877 if (
2878 entry.projectId !== pullRequestProjectId ||
2879 entry.repository.trim().toLowerCase() !== pullRequestRepository.trim().toLowerCase()
2880 ) {
2881 return false;
2882 }
2883 const provider = pullRequestLookup.data?.providers.find(
2884 (candidate) => candidate.host === entry.host,
2885 );
2886 return (
2887 provider?.searchesOnHost === true ||
2888 matchesPullRequestQuery(entry, composerTrigger.query)
2889 );
2890 }),
2891 composerTrigger.query,
2892 ).slice(0, COMPOSER_PULL_REQUEST_RESULT_LIMIT);
2893 return matches.map((pullRequest) => ({
2894 id: `pull-request:${pullRequest.projectId}:${pullRequest.repository}:${pullRequest.number}`,
2895 type: "pull-request",
2896 pullRequest: {
2897 number: pullRequest.number,
2898 title: pullRequest.title,
2899 url: pullRequest.url,
2900 headBranch: pullRequest.headBranch,
2901 baseBranch: pullRequest.baseBranch,
2902 state: pullRequest.state,
2903 isDraft: pullRequest.isDraft,
2904 },
2905 label: `#${pullRequest.number}`,
2906 description:
2907 pullRequest.repository.trim().toLowerCase() === pullRequestRepository.trim().toLowerCase()
2908 ? pullRequest.title
2909 : `${pullRequest.repository} · ${pullRequest.title}`,
2910 }));
2911 }
2912 return [];
2913 }, [
2914 activeThreadId,
2915 compactSlashCommandAvailable,
2916 composerTrigger,
2917 emojiSearch,
2918 environmentId,
2919 environmentThreadShells,
2920 exactPullRequestLookup.data,
2921 linkedPullRequests,
2922 planModeUiEnabled,
2923 pullRequestLookup.data,
2924 pullRequestProjectId,
2925 pullRequestRepository,
2926 pullRequestTriggerNumber,
2927 selectedProvider,
2928 selectedProviderSkills,
2929 selectedProviderSlashCommands,
2930 selectedProviderStatus,
2931 settings.showSkillsInSlashMenu,
2932 workspaceEntries.entries,
2933 ]);
2934
2935 const composerMenuOpen = Boolean(composerTrigger);
2936 const composerMenuSearchKey = composerTrigger
2937 ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
2938 : null;
2939 const activeComposerMenuItem = useMemo(() => {
2940 const activeItemId = resolveComposerMenuActiveItemId({
2941 items: composerMenuItems,
2942 highlightedItemId: composerHighlightedItemId,
2943 currentSearchKey: composerMenuSearchKey,
2944 highlightedSearchKey: composerHighlightedSearchKey,
2945 });
2946 return composerMenuItems.find((item) => item.id === activeItemId) ?? null;
2947 }, [
2948 composerHighlightedItemId,
2949 composerHighlightedSearchKey,
2950 composerMenuItems,
2951 composerMenuSearchKey,
2952 ]);
2953
2954 composerMenuOpenRef.current = composerMenuOpen;
2955 composerMenuItemsRef.current = composerMenuItems;
2956 activeComposerMenuItemRef.current = activeComposerMenuItem;
2957
2958 const nonPersistedComposerImageIdSet = useMemo(
2959 () => new Set(nonPersistedComposerImageIds),
2960 [nonPersistedComposerImageIds],
2961 );
2962
2963 const isComposerApprovalState = activePendingApproval !== null;
2964 const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState;
2965 const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
2966 const activePendingUserInput = pendingUserInputs[0] ?? null;
2967 const isChoiceOnlyPendingQuestion =
2968 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
2969 const showComposerTopDrawer =
2970 isComposerApprovalState ||
2971 pendingUserInputs.length > 0 ||
2972 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
2973 const showCollapsedMobilePromptRow =
2974 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
2975 const showComposerAttachAction =
2976 fileStagingLimit !== null &&
2977 (!activePendingProgress ||
2978 (supportsQuestionAttachments &&
2979 activePendingProgress.activeQuestion?.allowCustomAnswer !== false));
2980 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
2981 const composerFooterActionLayoutKey = useMemo(() => {
2982 if (activePendingProgress) {
2983 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
2984 }
2985 if (phase === "running") {
2986 return "running";
2987 }
2988 if (showPlanFollowUpPrompt) {
2989 return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
2990 }
2991 return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
2992 }, [
2993 activePendingIsResponding,
2994 activePendingProgress,
2995 composerSendState.hasSendableContent,
2996 isConnecting,
2997 isPreparingWorktree,
2998 isSendBusy,
2999 phase,
3000 prompt,
3001 showPlanFollowUpPrompt,
3002 ]);
3003
3004 const isComposerMenuLoading =
3005 (isEmojiTrigger && emojiSearch === null) ||
3006 (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) ||
3007 (composerTriggerKind === "pull-request" &&
3008 pullRequestProjectId !== null &&
3009 pullRequestRepository !== null &&
3010 (pullRequestLookup.isPending ||
3011 pullRequestTextQuery !== debouncedPullRequestTextQuery ||
3012 pullRequestTriggerNumber !== debouncedPullRequestNumber ||
3013 exactPullRequestLookup.isPending));
3014 const composerMenuEmptyState = useMemo(() => {
3015 if (composerTriggerKind === "emoji") {
3016 return emojiSearch === "error"
3017 ? "Emoji could not load. Reload and try again."
3018 : "No matching emoji.";
3019 }
3020 if (composerTriggerKind === "skill") {
3021 return "No skills found. Try / to browse provider commands.";
3022 }
3023 if (composerTriggerKind === "pull-request") {
3024 if (pullRequestProjectId === null || pullRequestRepository === null) {
3025 return "Pull requests are not available for this project.";
3026 }
3027 if (
3028 pullRequestLookup.error !== null ||
3029 pullRequestLookup.data?.errors.some((error) => error.projectId === pullRequestProjectId)
3030 ) {
3031 return "Pull requests could not be read for this project.";
3032 }
3033 return composerTrigger?.query
3034 ? `No pull request matches ${composerTrigger.query}.`
3035 : "No pull requests found in this repository.";
3036 }
3037 return composerTriggerKind === "path"
3038 ? "No matching files or folders."
3039 : "No matching command.";
3040 }, [
3041 composerTrigger,
3042 composerTriggerKind,
3043 emojiSearch,
3044 pullRequestLookup.data?.errors,
3045 pullRequestLookup.error,
3046 pullRequestProjectId,
3047 pullRequestRepository,
3048 ]);
3049
3050 // ------------------------------------------------------------------
3051 // Provider traits UI
3052 // ------------------------------------------------------------------
3053 const setPromptFromTraits = useCallback(
3054 (nextPrompt: string) => {
3055 if (nextPrompt === promptRef.current) {
3056 scheduleComposerFocus();
3057 return;
3058 }
3059 promptRef.current = nextPrompt;
3060 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
3061 const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
3062 setComposerCursor(nextCursor);
3063 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
3064 scheduleComposerFocus();
3065 },
3066 [
3067 composerDraftTarget,
3068 promptRef,
3069 scheduleComposerFocus,
3070 setComposerDraftPrompt,
3071 setComposerTrigger,
3072 ],
3073 );
3074
3075 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
3076 provider: selectedProvider,
3077 instanceId: selectedInstanceId,
3078 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3079 ...(routeKind === "draft" && draftId ? { draftId } : {}),
3080 model: selectedModel,
3081 models: selectedProviderModels,
3082 modelOptions: composerModelOptions?.[selectedInstanceId],
3083 reportedModelSelection,
3084 prompt,
3085 onPromptChange: setPromptFromTraits,
3086 planModeEnabled: settings.planModeEnabled,
3087 });
3088 const providerTraitsPickerInput = {
3089 provider: selectedProvider,
3090 instanceId: selectedInstanceId,
3091 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3092 ...(routeKind === "draft" && draftId ? { draftId } : {}),
3093 model: selectedModel,
3094 models: selectedProviderModels,
3095 modelOptions: composerModelOptions?.[selectedInstanceId],
3096 reportedModelSelection,
3097 prompt,
3098 onPromptChange: setPromptFromTraits,
3099 planModeEnabled: settings.planModeEnabled,
3100 isComposerOwned: true,
3101 } satisfies Parameters<typeof renderProviderTraitsPicker>[0];
3102 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
3103 const [inlineRestingControlsHost, setInlineRestingControlsHost] = useState<HTMLDivElement | null>(
3104 null,
3105 );
3106 const {
3107 controlsRef: restingComposerControlsRef,
3108 attachControls: attachRestingComposerControls,
3109 hiddenBlockCount: restingControlsHiddenBlockCount,
3110 iconOnlyBlockCount: restingControlsIconOnlyBlockCount,
3111 controlsVisible: restingControlsVisible,
3112 } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost);
3113 const expandedControlsLayout = useRestingComposerControlsLayout(null, true);
3114 const pendingPrimaryAction = useMemo(
3115 () =>
3116 activePendingProgress
3117 ? {
3118 questionIndex: activePendingProgress.questionIndex,
3119 isLastQuestion: activePendingProgress.isLastQuestion,
3120 canAdvance:
3121 activePendingProgress.canAdvance &&
3122 !attachmentBlockReason &&
3123 !(questionPreparations[attachmentTargetKey] ?? 0),
3124 isResponding: activePendingIsResponding,
3125 isComplete: Boolean(activePendingResolvedAnswers),
3126 }
3127 : null,
3128 [
3129 activePendingIsResponding,
3130 activePendingProgress,
3131 activePendingResolvedAnswers,
3132 attachmentBlockReason,
3133 questionPreparations,
3134 attachmentTargetKey,
3135 ],
3136 );
3137 const showResumeAction =
3138 canResume && !composerDraftHasUserContent(composerDraft) && !isEditingQueuedMessage;
3139 const collapsedComposerPrimaryActionDisabled =
3140 phase === "running" ||
3141 isSendBusy ||
3142 isSendDisabled ||
3143 isConnecting ||
3144 noProviderAvailable ||
3145 projectSelectionRequired ||
3146 environmentUnavailable !== null ||
3147 (!composerSendState.hasSendableContent && !showResumeAction);
3148 const collapsedComposerPrimaryActionLabel = showResumeAction
3149 ? "Resume thread"
3150 : props.resumeCompactionTokens !== null && !props.keepFullHistory
3151 ? "Open composer to compact and send"
3152 : "Send message";
3153 const showMobilePendingAnswerActions =
3154 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
3155
3156 // ------------------------------------------------------------------
3157 // Prompt helpers
3158 // ------------------------------------------------------------------
3159 const setPrompt = useCallback(
3160 (nextPrompt: string) => {
3161 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
3162 },
3163 [composerDraftTarget, setComposerDraftPrompt],
3164 );
3165
3166 const addComposerImage = useCallback(
3167 (image: ComposerImageAttachment) => addComposerDraftImages(attachmentDraftTarget, [image]),
3168 [attachmentDraftTarget, addComposerDraftImages],
3169 );
3170
3171 const addComposerImagesToDraft = useCallback(
3172 (images: ComposerImageAttachment[]) => addComposerDraftImages(attachmentDraftTarget, images),
3173 [attachmentDraftTarget, addComposerDraftImages],
3174 );
3175
3176 const addComposerFilesToDraft = useCallback(
3177 (files: ComposerFileAttachment[]) =>
3178 addComposerDraftFiles(attachmentDraftTarget, files, {
3179 appendReference: questionAttachmentTarget === null,
3180 }),
3181 [addComposerDraftFiles, attachmentDraftTarget, questionAttachmentTarget],
3182 );
3183
3184 const removeComposerImageFromDraft = useCallback(
3185 (imageId: string) => {
3186 if (questionAttachmentTarget && activePendingIsResponding) return;
3187 releaseAttachmentUpload(imageId);
3188 removeComposerDraftImage(attachmentDraftTarget, imageId);
3189 },
3190 [
3191 attachmentDraftTarget,
3192 questionAttachmentTarget,
3193 activePendingIsResponding,
3194 removeComposerDraftImage,
3195 ],
3196 );
3197
3198 const removeComposerFileFromDraft = useCallback(
3199 (fileId: string) => {
3200 if (questionAttachmentTarget && activePendingIsResponding) return;
3201 // Release by the draft attachment, not the bare queue key: a hydrated
3202 // file's upload lives server-side under its persisted attachment id.
3203 const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);
3204 if (file) {
3205 releaseDraftAttachment(file);
3206 } else {
3207 releaseAttachmentUpload(fileId);
3208 }
3209 removeComposerDraftFile(attachmentDraftTarget, fileId);
3210 },
3211 [
3212 attachmentDraftTarget,
3213 questionAttachmentTarget,
3214 activePendingIsResponding,
3215 composerFilesRef,
3216 removeComposerDraftFile,
3217 ],
3218 );
3219
3220 const addComposerDraftTerminalContexts = useComposerDraftStore(
3221 (store) => store.addTerminalContexts,
3222 );
3223 const addComposerDraftReviewComment = useComposerDraftStore((store) => store.addReviewComment);
3224 const addComposerDraftPreviewAnnotation = useComposerDraftStore(
3225 (store) => store.addPreviewAnnotation,
3226 );
3227 const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts);
3228 const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts);
3229 const buildContextClipboardFragment = useCallback(
3230 (contextIds: ReadonlyArray<string>): string | null => {
3231 const wanted = new Set(contextIds);
3232 // An annotation's screenshot is referenced by the annotation record, not by the copied
3233 // text. Pull it in so the round-trip keeps the image the annotation points at.
3234 for (const annotation of composerPreviewAnnotations) {
3235 if (
3236 wanted.has(previewAnnotationContextId(annotation.id)) &&
3237 composerImages.some((image) => image.id === annotation.id)
3238 ) {
3239 wanted.add(toKindScopedComposerContextId("image", annotation.id));
3240 }
3241 }
3242 const records: ComposerContextRecord[] = [
3243 ...composerTerminalContexts
3244 .filter((c) => wanted.has(terminalContextReference(c).contextId))
3245 .map(terminalContextRecord),
3246 ...composerThreadContexts.filter((record) => wanted.has(record.contextId)),
3247 ...composerReviewComments
3248 .filter((c) => wanted.has(reviewCommentContextId(c.id)))
3249 .map(reviewCommentContextRecord),
3250 ...composerPreviewAnnotations
3251 .filter((a) => wanted.has(previewAnnotationContextId(a.id)))
3252 .map((annotation) =>
3253 previewAnnotationContextRecord(annotation, {
3254 screenshotContextId: composerImages.some((image) => image.id === annotation.id)
3255 ? annotation.id
3256 : undefined,
3257 }),
3258 ),
3259 ...[...composerImages, ...composerFiles]
3260 .filter((attachment) =>
3261 wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)),
3262 )
3263 .flatMap((attachment) => {
3264 const record = uploadedAttachmentContextRecord(
3265 attachment,
3266 uploadsByImageId[attachment.id],
3267 );
3268 return record ? [record] : [];
3269 }),
3270 ];
3271 if (records.length === 0) return null;
3272 return encodeComposerContextFragment({
3273 version: 1,
3274 source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) },
3275 records,
3276 });
3277 },
3278 [
3279 activeThread,
3280 composerFiles,
3281 composerImages,
3282 composerPreviewAnnotations,
3283 composerReviewComments,
3284 composerTerminalContexts,
3285 composerThreadContexts,
3286 environmentId,
3287 uploadsByImageId,
3288 ],
3289 );
3290 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false });
3291 const loadThreadTranscript = useAtomCommand(orchestrationEnvironment.threadTranscript, {
3292 reportFailure: false,
3293 });
3294 const countReservedAttachments = useCallback(() => {
3295 const questionRequest = pendingUserInputs[0];
3296 const otherQuestionKeys =
3297 questionAttachmentTarget && questionRequest && activeThreadId
3298 ? questionRequest.questions
3299 .map((question) =>
3300 questionAttachmentDraftId(
3301 environmentId,
3302 activeThreadId,
3303 questionRequest.requestId,
3304 question.id,
3305 ),
3306 )
3307 .filter((key) => key !== questionAttachmentTarget)
3308 : [];
3309 const draft = getComposerDraft(attachmentDraftTarget);
3310 return (
3311 (draft?.images.length ?? 0) +
3312 (draft?.files.length ?? 0) +
3313 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) +
3314 (pendingThreadImportsRef.current.get(attachmentTargetKey) ?? 0) +
3315 countQuestionAttachments(otherQuestionKeys)
3316 );
3317 }, [
3318 activeThreadId,
3319 attachmentDraftTarget,
3320 attachmentTargetKey,
3321 environmentId,
3322 getComposerDraft,
3323 pendingUserInputs,
3324 questionAttachmentTarget,
3325 ]);
3326 /**
3327 * Bytes for a pasted image or file come back through the source environment's asset URL
3328 * (the client is the only party that can reach both) and re-enter this draft as a normal
3329 * attachment under a fresh id. The pasted chip is rewritten to that id and reads as
3330 * unresolved until the bytes land; a failed transfer says so and leaves the chip to remove.
3331 */
3332 const runAttachmentImport = useCallback(
3333 async (
3334 record: Extract<ComposerContextRecord, { kind: "image" | "file" }>,
3335 localId: string,
3336 sourceEnvironmentId: EnvironmentId,
3337 importTargetKey: string,
3338 ) => {
3339 const fail = (reason: string) => {
3340 toastManager.add({
3341 type: "error",
3342 title: `Couldn't bring ${record.name} into this message`,
3343 description: `${reason} Remove the chip or attach the file again.`,
3344 });
3345 };
3346 const sourceConnection = readPreparedConnection(sourceEnvironmentId);
3347 if (!sourceConnection) {
3348 fail("The environment it came from is not connected.");
3349 return;
3350 }
3351 const result = await createAssetUrl({
3352 environmentId: sourceEnvironmentId,
3353 input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } },
3354 });
3355 const url =
3356 result._tag === "Success"
3357 ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl)
3358 : null;
3359 if (!url) {
3360 fail("The original attachment is no longer available.");
3361 return;
3362 }
3363 let blob: Blob;
3364 try {
3365 const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
3366 if (!response.ok) throw new Error(`HTTP ${response.status}`);
3367 blob = await response.blob();
3368 } catch {
3369 fail("Downloading it from the source failed.");
3370 return;
3371 }
3372 const file = new File([blob], record.name, { type: record.mimeType || blob.type });
3373 // The draft these bytes belong to may have been sent or switched away from while they
3374 // downloaded. Dropping them here keeps them out of whatever draft is open now.
3375 if (attachmentTargetKeyRef.current !== importTargetKey) return;
3376 const replacesFileMarker =
3377 record.kind === "file" &&
3378 composerFilesRef.current.some(
3379 (candidate) =>
3380 composerFileNeedsReattach(candidate) &&
3381 composerFileMatchesReattachMarker(candidate, {
3382 name: record.name,
3383 mimeType: file.type,
3384 sizeBytes: file.size,
3385 }),
3386 );
3387 if (
3388 !replacesFileMarker &&
3389 (pendingThreadImportsRef.current.get(importTargetKey) ?? 0) > 0 &&
3390 countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS
3391 ) {
3392 fail("The attachment limit has been reached.");
3393 return;
3394 }
3395 if (record.kind === "image") {
3396 const accepted = addComposerImage({
3397 type: "image",
3398 id: localId,
3399 name: record.name,
3400 mimeType: file.type,
3401 sizeBytes: file.size,
3402 previewUrl: URL.createObjectURL(file),
3403 file,
3404 });
3405 if (!accepted.includes(localId))
3406 fail("The draft rejected this attachment (duplicate or attachment limit reached).");
3407 } else {
3408 const accepted = addComposerFilesToDraft([
3409 {
3410 type: "file",
3411 id: localId,
3412 name: record.name,
3413 mimeType: file.type,
3414 sizeBytes: file.size,
3415 file,
3416 },
3417 ]);
3418 if (!accepted.includes(localId))
3419 fail("The draft rejected this attachment (duplicate or attachment limit reached).");
3420 }
3421 },
3422 [
3423 addComposerFilesToDraft,
3424 addComposerImage,
3425 composerFilesRef,
3426 countReservedAttachments,
3427 createAssetUrl,
3428 ],
3429 );
3430 const importAttachmentRecord = useCallback(
3431 async (
3432 record: Extract<ComposerContextRecord, { kind: "image" | "file" }>,
3433 localId: string,
3434 sourceEnvironmentId: EnvironmentId,
3435 ) => {
3436 // The chip lands in the draft immediately while these bytes are still downloading. Count
3437 // the transfer against its own draft so a send cannot snapshot a message whose chip has no
3438 // attachment behind it, and so bytes for an abandoned draft never enter the next one.
3439 const importTargetKey = attachmentTargetKey;
3440 pendingDraftWork.begin(importTargetKey);
3441 try {
3442 await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey);
3443 } finally {
3444 pendingDraftWork.end(importTargetKey);
3445 }
3446 },
3447 [attachmentTargetKey, runAttachmentImport],
3448 );
3449 /**
3450 * Brings records into this draft (paste, stash restore). Binaries are transferred only
3451 * when `sourceEnvironmentId` is given; the stash restores its own images and files.
3452 */
3453 const importContextRecords = useCallback(
3454 (
3455 records: ReadonlyArray<ComposerContextRecord>,
3456 sourceEnvironmentId: EnvironmentId | null,
3457 ): ReadonlyMap<string, string> => {
3458 const rewritten = new Map<string, string>();
3459 const dependentAttachmentLocalIds = new Map<string, string>();
3460 const skippedDependentAttachmentIds = new Set<string>();
3461 // Resolve annotations before their dependent screenshot records even if a foreign
3462 // clipboard producer emitted the records in a different order.
3463 const orderedRecords = records.toSorted((left, right) =>
3464 left.kind === "preview-annotation" && right.kind !== "preview-annotation"
3465 ? -1
3466 : right.kind === "preview-annotation" && left.kind !== "preview-annotation"
3467 ? 1
3468 : 0,
3469 );
3470 for (const candidate of orderedRecords) {
3471 // Producer ids fold into context ids, so two different excerpts can collide. Only skip
3472 // when the draft already holds the same payload; a colliding but different record is
3473 // re-minted under a fresh id so both survive the paste.
3474 const record = asKnownContextRecord(candidate);
3475 if (!record) continue;
3476 const existing = composerContextImportLookupIds(record).flatMap((contextId) => {
3477 const found = composerContextRecords.get(contextId);
3478 return found ? [found] : [];
3479 })[0];
3480 const existingRecord =
3481 existing?.kind === "terminal"
3482 ? terminalContextRecord(existing.record)
3483 : existing?.kind === "review-comment"
3484 ? reviewCommentContextRecord(existing.record)
3485 : existing?.kind === "preview-annotation"
3486 ? previewAnnotationContextRecord(existing.record)
3487 : existing?.kind === "thread"
3488 ? existing.record
3489 : existing
3490 ? (uploadedContextRecordFromDraft(existing) ?? undefined)
3491 : undefined;
3492 if (existingRecord && isSameComposerContextPayload(existingRecord, record)) {
3493 if (record.kind === "preview-annotation" && record.screenshotContextId) {
3494 skippedDependentAttachmentIds.add(record.screenshotContextId);
3495 }
3496 continue;
3497 }
3498 const conflicts = existing !== undefined;
3499 switch (record.kind) {
3500 case "terminal": {
3501 const threadId = activeThread?.id ?? activeThreadId;
3502 if (!threadId) break;
3503 const imported = terminalContextDraftFromRecord(record, threadId);
3504 const draft = conflicts ? { ...imported, id: randomUUID() } : imported;
3505 addComposerDraftTerminalContexts(composerDraftTarget, [draft], {
3506 appendReference: false,
3507 });
3508 rewritten.set(record.contextId, terminalContextReference(draft).contextId);
3509 break;
3510 }
3511 case "review-comment": {
3512 const imported = reviewCommentFromRecord(record);
3513 const comment = conflicts ? { ...imported, id: randomUUID() } : imported;
3514 addComposerDraftReviewComment(composerDraftTarget, comment, {
3515 appendReference: false,
3516 });
3517 rewritten.set(record.contextId, reviewCommentContextId(comment.id));
3518 break;
3519 }
3520 case "element":
3521 case "preview-annotation": {
3522 const imported =
3523 record.kind === "element"
3524 ? elementContextToPreviewAnnotation(record, randomUUID(), new Date().toISOString())
3525 : previewAnnotationFromRecord(record);
3526 const annotation = conflicts ? { ...imported, id: randomUUID() } : imported;
3527 if (record.kind === "preview-annotation" && record.screenshotContextId) {
3528 dependentAttachmentLocalIds.set(record.screenshotContextId, annotation.id);
3529 }
3530 addComposerDraftPreviewAnnotation(composerDraftTarget, annotation, {
3531 appendReference: false,
3532 });
3533 rewritten.set(record.contextId, previewAnnotationContextId(annotation.id));
3534 break;
3535 }
3536 case "thread": {
3537 // The agent can only read threads on its own server; a pasted foreign one is dropped.
3538 if (record.environmentId !== environmentId) break;
3539 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
3540 appendReference: false,
3541 });
3542 rewritten.set(record.contextId, record.contextId);
3543 break;
3544 }
3545 case "image":
3546 case "file": {
3547 if (skippedDependentAttachmentIds.has(record.contextId)) break;
3548 if (sourceEnvironmentId === null && !conflicts) {
3549 rewritten.set(record.contextId, record.contextId);
3550 break;
3551 }
3552 if (sourceEnvironmentId === null) break;
3553 const localId = dependentAttachmentLocalIds.get(record.contextId) ?? randomUUID();
3554 rewritten.set(record.contextId, toKindScopedComposerContextId(record.kind, localId));
3555 void importAttachmentRecord(record, localId, sourceEnvironmentId);
3556 break;
3557 }
3558 default:
3559 break;
3560 }
3561 }
3562 return rewritten;
3563 },
3564 [
3565 activeThread,
3566 activeThreadId,
3567 addComposerDraftPreviewAnnotation,
3568 addComposerDraftReviewComment,
3569 addComposerDraftTerminalContexts,
3570 addComposerDraftThreadContexts,
3571 composerContextRecords,
3572 attachmentDraftTarget,
3573 composerDraftTarget,
3574 environmentId,
3575 importAttachmentRecord,
3576 ],
3577 );
3578 const importContextFragment = useCallback(
3579 (fragment: ComposerContextClipboardFragment): ReadonlyMap<string, string> =>
3580 importContextRecords(fragment.records, fragment.source.environmentId),
3581 [importContextRecords],
3582 );
3583
3584 // ------------------------------------------------------------------
3585 // Sync refs back to parent
3586 // ------------------------------------------------------------------
3587 useEffect(() => {
3588 if (isLiteralPendingAnswer) return;
3589 promptRef.current = prompt;
3590 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
3591 }, [isLiteralPendingAnswer, prompt, promptRef]);
3592
3593 useEffect(() => {
3594 if (composerSubmissionError === null) return;
3595 const nextError = getComposerPromptLengthValidationMessage(prompt);
3596 if (nextError !== composerSubmissionError) {
3597 setComposerSubmissionError(nextError);
3598 }
3599 }, [composerSubmissionError, prompt]);
3600
3601 useEffect(() => {
3602 setProviderInputSubmissionError(null);
3603 }, [
3604 composerPreviewAnnotations,
3605 composerReviewComments,
3606 composerTerminalContexts,
3607 prompt,
3608 selectedModel,
3609 selectedPromptEffort,
3610 selectedProvider,
3611 ]);
3612
3613 useEffect(() => {
3614 composerImagesRef.current = composerImages;
3615 }, [composerImages, composerImagesRef]);
3616
3617 useEffect(() => {
3618 composerFilesRef.current = composerFiles;
3619 }, [composerFiles, composerFilesRef]);
3620
3621 useEffect(() => {
3622 composerTerminalContextsRef.current = composerTerminalContexts;
3623 }, [composerTerminalContexts, composerTerminalContextsRef]);
3624
3625 // ------------------------------------------------------------------
3626 // Composer menu highlight sync
3627 // ------------------------------------------------------------------
3628 useEffect(() => {
3629 if (!composerMenuOpen) {
3630 setComposerHighlightedItemId(null);
3631 setComposerHighlightedSearchKey(null);
3632 return;
3633 }
3634 const nextActiveItemId = resolveComposerMenuActiveItemId({
3635 items: composerMenuItems,
3636 highlightedItemId: composerHighlightedItemId,
3637 currentSearchKey: composerMenuSearchKey,
3638 highlightedSearchKey: composerHighlightedSearchKey,
3639 });
3640 setComposerHighlightedItemId((existing) =>
3641 existing === nextActiveItemId ? existing : nextActiveItemId,
3642 );
3643 setComposerHighlightedSearchKey((existing) =>
3644 existing === composerMenuSearchKey ? existing : composerMenuSearchKey,
3645 );
3646 }, [
3647 composerHighlightedItemId,
3648 composerHighlightedSearchKey,
3649 composerMenuItems,
3650 composerMenuOpen,
3651 composerMenuSearchKey,
3652 ]);
3653
3654 const lastSyncedPendingInputRef = useRef<{
3655 requestId: string | null;
3656 questionId: string | null;
3657 } | null>(null);
3658
3659 useEffect(() => {
3660 const nextCustomAnswer = activePendingProgress?.customAnswer;
3661 if (typeof nextCustomAnswer !== "string") {
3662 // The question is gone and the editor shows the thread draft again. The
3663 // ref still holds the last answer text, and Send reads the ref. Place
3664 // the caret at the end so the next keystroke appends.
3665 if (lastSyncedPendingInputRef.current !== null) {
3666 promptRef.current = prompt;
3667 const { cursor, trigger } = composerStateAtPromptEnd(prompt);
3668 setComposerCursor(cursor);
3669 resetComposerTrigger(trigger);
3670 }
3671 lastSyncedPendingInputRef.current = null;
3672 return;
3673 }
3674
3675 const nextRequestId = activePendingUserInput?.requestId ?? null;
3676 const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
3677 const questionChanged =
3678 lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
3679 lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
3680 const textChangedExternally = promptRef.current !== nextCustomAnswer;
3681
3682 lastSyncedPendingInputRef.current = {
3683 requestId: nextRequestId,
3684 questionId: nextQuestionId,
3685 };
3686
3687 if (!questionChanged && !textChangedExternally) {
3688 return;
3689 }
3690
3691 promptRef.current = nextCustomAnswer;
3692 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer);
3693 setComposerCursor(cursor);
3694 resetComposerTrigger(trigger);
3695 setComposerHighlightedItemId(null);
3696 }, [
3697 activePendingProgress?.customAnswer,
3698 activePendingProgress?.activeQuestion?.id,
3699 isLiteralPendingAnswer,
3700 activePendingUserInput?.requestId,
3701 prompt,
3702 promptRef,
3703 resetComposerTrigger,
3704 ]);
3705
3706 // ------------------------------------------------------------------
3707 // Reset compositor state on thread/draft change
3708 // ------------------------------------------------------------------
3709 useEffect(() => {
3710 setComposerHighlightedItemId(null);
3711 setComposerHighlightedSearchKey(null);
3712 setComposerSubmissionError(null);
3713 setProviderInputSubmissionError(null);
3714 const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer);
3715 setComposerCursor(cursor);
3716 resetComposerTrigger(trigger);
3717 setIsDragOverComposer(false);
3718 setIsComposerScrollCollapsed(false);
3719 }, [
3720 draftId,
3721 activeThreadId,
3722 isLiteralPendingAnswer,
3723 promptRef,
3724 resetComposerTrigger,
3725 setIsComposerScrollCollapsed,
3726 ]);
3727
3728 // ------------------------------------------------------------------
3729 // Footer compact layout observation
3730 // ------------------------------------------------------------------
3731 useLayoutEffect(() => {
3732 const composerForm = composerFormRef.current;
3733 if (!composerForm) return;
3734 const measureComposerFormWidth = () => composerForm.clientWidth;
3735 const measureFooterCompactness = () => {
3736 const composerFormWidth = measureComposerFormWidth();
3737 const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {
3738 hasWideActions: composerFooterHasWideActions,
3739 });
3740 const primaryActionsCompact =
3741 footerCompact &&
3742 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
3743 hasWideActions: composerFooterHasWideActions,
3744 });
3745 return {
3746 primaryActionsCompact,
3747 footerCompact,
3748 };
3749 };
3750
3751 const initialCompactness = measureFooterCompactness();
3752 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
3753 setIsComposerFooterCompact(initialCompactness.footerCompact);
3754 return observeResize(composerForm, () => {
3755 const nextCompactness = measureFooterCompactness();
3756 setIsComposerPrimaryActionsCompact((previous) =>
3757 previous === nextCompactness.primaryActionsCompact
3758 ? previous
3759 : nextCompactness.primaryActionsCompact,
3760 );
3761 setIsComposerFooterCompact((previous) =>
3762 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
3763 );
3764 });
3765 }, [
3766 activeThreadId,
3767 composerFooterActionLayoutKey,
3768 composerFooterHasWideActions,
3769 isComposerApprovalState,
3770 isComposerCollapsedMobile,
3771 ]);
3772
3773 // ------------------------------------------------------------------
3774 // Image persist effect
3775 // ------------------------------------------------------------------
3776 useEffect(() => {
3777 let cancelled = false;
3778 void (async () => {
3779 if (composerImages.length === 0) {
3780 clearComposerDraftPersistedAttachments(attachmentDraftTarget);
3781 return;
3782 }
3783 const getPersistedAttachmentsForThread = () =>
3784 getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? [];
3785 try {
3786 const currentPersistedAttachments = getPersistedAttachmentsForThread();
3787 const existingPersistedById = new Map(
3788 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
3789 );
3790 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
3791 await Promise.all(
3792 composerImages.map(async (image) => {
3793 try {
3794 const dataUrl = await readFileAsDataUrl(image.file);
3795 stagedAttachmentById.set(image.id, {
3796 id: image.id,
3797 name: image.name,
3798 mimeType: image.mimeType,
3799 sizeBytes: image.sizeBytes,
3800 dataUrl,
3801 ...(image.source ? { source: image.source } : {}),
3802 });
3803 } catch {
3804 const existingPersisted = existingPersistedById.get(image.id);
3805 if (existingPersisted) {
3806 stagedAttachmentById.set(image.id, existingPersisted);
3807 }
3808 }
3809 }),
3810 );
3811 const serialized = Array.from(stagedAttachmentById.values());
3812 if (cancelled) return;
3813 syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized);
3814 } catch {
3815 const currentImageIds = new Set(composerImages.map((image) => image.id));
3816 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
3817 const fallbackPersistedIds: Array<string> = [];
3818 for (const attachment of fallbackPersistedAttachments) {
3819 if (currentImageIds.has(attachment.id)) {
3820 fallbackPersistedIds.push(attachment.id);
3821 }
3822 }
3823 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
3824 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
3825 fallbackPersistedIdSet.has(attachment.id),
3826 );
3827 if (cancelled) return;
3828 syncComposerDraftPersistedAttachments(attachmentDraftTarget, fallbackAttachments);
3829 }
3830 })();
3831 return () => {
3832 cancelled = true;
3833 };
3834 }, [
3835 attachmentDraftTarget,
3836 clearComposerDraftPersistedAttachments,
3837 composerImages,
3838 getComposerDraft,
3839 syncComposerDraftPersistedAttachments,
3840 ]);
3841
3842 // ------------------------------------------------------------------
3843 // Callbacks: prompt change
3844 // ------------------------------------------------------------------
3845 const expandComposerForEditorChange = useCallback(() => {
3846 // Editor changes win over the momentum tail of the active scroll gesture.
3847 suppressActiveComposerScrollGesture(
3848 composerScrollGestureRef.current,
3849 window.performance.now(),
3850 COMPOSER_SCROLL_GESTURE_RESET_MS,
3851 );
3852 setIsComposerScrollCollapsed(false);
3853 }, [setIsComposerScrollCollapsed]);
3854
3855 /**
3856 * Payloads for chips the prompt no longer references. History undo restores the
3857 * reference text but knows nothing about the draft records behind it, so a delete keeps its
3858 * payload here and an undo puts it back rather than leaving a dangling chip.
3859 */
3860 const removedContextPayloadsRef = useRef<{
3861 terminals: Map<string, TerminalContextDraft>;
3862 reviewComments: Map<string, ReviewCommentContext>;
3863 threads: Map<string, ThreadContextRecord>;
3864 }>({ terminals: new Map(), reviewComments: new Map(), threads: new Map() });
3865 const removedAttachmentContextPayloadsRef = useRef<RetainedAttachmentContextPayloads>({
3866 files: new Map(),
3867 previewAnnotations: new Map(),
3868 });
3869
3870 const onPromptChange = useCallback(
3871 (
3872 nextPrompt: string,
3873 nextCursor: number,
3874 expandedCursor: number,
3875 suppressSuggestions: boolean,
3876 contextIds: string[],
3877 ) => {
3878 expandComposerForEditorChange();
3879 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
3880 if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
3881 setComposerCursor(nextCursor);
3882 setComposerTrigger(
3883 suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor),
3884 );
3885 onChangeActivePendingUserInputCustomAnswer(
3886 activePendingProgress.activeQuestion.id,
3887 nextPrompt,
3888 nextCursor,
3889 expandedCursor,
3890 suppressSuggestions,
3891 );
3892 return;
3893 }
3894 onTypingGuardDraftChange();
3895 promptRef.current = nextPrompt;
3896 setPrompt(nextPrompt);
3897 // Any edit ends browsing, even one later undone by hand: typing a
3898 // character and deleting it leaves the text equal to the recall, and
3899 // ArrowDown must move the caret then, not clear the composer.
3900 if (promptHistoryPositionRef.current?.recalled !== nextPrompt) {
3901 promptHistoryPositionRef.current = null;
3902 }
3903 const referenced = new Set(contextIds);
3904 const retained = removedContextPayloadsRef.current;
3905
3906 // An undone delete brings the reference back; restore the payload it points at.
3907 const liveTerminalIds = new Set<string>(
3908 composerTerminalContexts.map((context) => terminalContextReference(context).contextId),
3909 );
3910 const restoredTerminals = [...referenced].flatMap((contextId) => {
3911 if (liveTerminalIds.has(contextId)) return [];
3912 const context = retained.terminals.get(contextId);
3913 return context ? [context] : [];
3914 });
3915 const nextTerminals = [
3916 ...composerTerminalContexts.filter((context) =>
3917 referenced.has(terminalContextReference(context).contextId),
3918 ),
3919 ...restoredTerminals,
3920 ];
3921 for (const context of composerTerminalContexts) {
3922 const contextId = terminalContextReference(context).contextId;
3923 if (!referenced.has(contextId)) retained.terminals.set(contextId, context);
3924 }
3925 if (
3926 nextTerminals.length !== composerTerminalContexts.length ||
3927 restoredTerminals.length > 0
3928 ) {
3929 setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals);
3930 }
3931
3932 const liveThreadIds = new Set<string>(
3933 composerThreadContexts.map((record) => record.contextId),
3934 );
3935 const restoredThreads = [...referenced].flatMap((contextId) => {
3936 if (liveThreadIds.has(contextId)) return [];
3937 const record = retained.threads.get(contextId);
3938 return record ? [record] : [];
3939 });
3940 const nextThreads = [
3941 ...composerThreadContexts.filter((record) => referenced.has(record.contextId)),
3942 ...restoredThreads,
3943 ];
3944 for (const record of composerThreadContexts) {
3945 if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record);
3946 }
3947 if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) {
3948 setComposerDraftThreadContexts(composerDraftTarget, nextThreads);
3949 }
3950
3951 for (const comment of composerReviewComments) {
3952 const contextId = reviewCommentContextId(comment.id);
3953 if (!referenced.has(contextId)) {
3954 retained.reviewComments.set(contextId, comment);
3955 removeComposerDraftReviewComment(composerDraftTarget, comment.id);
3956 }
3957 }
3958 const liveReviewIds = new Set<string>(
3959 composerReviewComments.map((comment) => reviewCommentContextId(comment.id)),
3960 );
3961 for (const contextId of referenced) {
3962 if (liveReviewIds.has(contextId)) continue;
3963 const comment = retained.reviewComments.get(contextId);
3964 if (comment) {
3965 addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false });
3966 }
3967 }
3968
3969 const attachmentChanges = reconcileAttachmentContextReferences({
3970 referencedContextIds: referenced,
3971 files: composerFiles,
3972 images: composerImages,
3973 previewAnnotations: composerPreviewAnnotations,
3974 retained: removedAttachmentContextPayloadsRef.current,
3975 });
3976 for (const annotationId of attachmentChanges.annotationIdsToRemove) {
3977 // Keep the upload queue entry alive: undo restores the image that owns it.
3978 removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId);
3979 }
3980 for (const restored of attachmentChanges.annotationsToRestore) {
3981 if (restored.image) addComposerDraftImages(attachmentDraftTarget, [restored.image]);
3982 addComposerDraftPreviewAnnotation(composerDraftTarget, restored.annotation, {
3983 appendReference: false,
3984 });
3985 }
3986 for (const fileId of attachmentChanges.filesToRemove) {
3987 // The retained File and upload are still sendable if the editor restores the chip.
3988 removeComposerDraftFile(attachmentDraftTarget, fileId);
3989 }
3990 if (attachmentChanges.filesToRestore.length > 0) {
3991 addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore);
3992 }
3993 setComposerCursor(nextCursor);
3994 setComposerTrigger(
3995 suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor),
3996 );
3997 },
3998 [
3999 activePendingProgress?.activeQuestion,
4000 expandComposerForEditorChange,
4001 onTypingGuardDraftChange,
4002 pendingUserInputs.length,
4003 onChangeActivePendingUserInputCustomAnswer,
4004 promptRef,
4005 setPrompt,
4006 setComposerTrigger,
4007 composerDraftTarget,
4008 composerTerminalContexts,
4009 setComposerDraftTerminalContexts,
4010 composerThreadContexts,
4011 setComposerDraftThreadContexts,
4012 composerReviewComments,
4013 composerPreviewAnnotations,
4014 composerImages,
4015 composerFiles,
4016 removeComposerDraftReviewComment,
4017 removeComposerDraftPreviewAnnotation,
4018 removeComposerDraftFile,
4019 addComposerDraftReviewComment,
4020 addComposerDraftPreviewAnnotation,
4021 addComposerDraftImages,
4022 addComposerDraftFiles,
4023 attachmentDraftTarget,
4024 ],
4025 );
4026
4027 // ------------------------------------------------------------------
4028 // Callbacks: prompt replacement / menu
4029 // ------------------------------------------------------------------
4030 const applyPromptReplacement = useCallback(
4031 (
4032 rangeStart: number,
4033 rangeEnd: number,
4034 replacement: string,
4035 options?: {
4036 expectedText?: string;
4037 expandedCursorAfterReplace?: number;
4038 focusEditorAfterReplace?: boolean;
4039 citationComment?: {
4040 start: number;
4041 sourceAnchor: AssistantCitationSourceAnchor;
4042 insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"];
4043 };
4044 },
4045 ): boolean => {
4046 if (
4047 activePendingUserInput &&
4048 activePendingProgress?.activeQuestion?.allowCustomAnswer === false
4049 ) {
4050 return false;
4051 }
4052 const currentText = promptRef.current;
4053 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
4054 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
4055 if (
4056 options?.expectedText !== undefined &&
4057 currentText.slice(safeStart, safeEnd) !== options.expectedText
4058 ) {
4059 return false;
4060 }
4061 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
4062 const nextCursor = collapseExpandedComposerCursor(
4063 next.text,
4064 options?.expandedCursorAfterReplace ?? next.cursor,
4065 isLiteralPendingAnswer,
4066 );
4067 const nextExpandedCursor = expandCollapsedComposerCursor(
4068 next.text,
4069 nextCursor,
4070 isLiteralPendingAnswer,
4071 );
4072 if (options?.citationComment) {
4073 composerEditorRef.current?.requestCitationComment({
4074 value: next.text,
4075 citationStart: options.citationComment.start,
4076 sourceAnchor: options.citationComment.sourceAnchor,
4077 insertedSpaces: options.citationComment.insertedSpaces,
4078 });
4079 }
4080 promptRef.current = next.text;
4081 const activePendingQuestion = activePendingProgress?.activeQuestion;
4082 if (activePendingQuestion && activePendingUserInput) {
4083 onChangeActivePendingUserInputCustomAnswer(
4084 activePendingQuestion.id,
4085 next.text,
4086 nextCursor,
4087 nextExpandedCursor,
4088 isLiteralPendingAnswer,
4089 );
4090 } else {
4091 setPrompt(next.text);
4092 onTypingGuardDraftChange();
4093 }
4094 setComposerCursor(nextCursor);
4095 setComposerTrigger(
4096 isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor),
4097 );
4098 if (options?.focusEditorAfterReplace !== false) {
4099 window.requestAnimationFrame(() => {
4100 // Type-to-focus routes only the first key through here; once the
4101 // controlled update focuses the editor, later keys land natively.
4102 // Skip the deferred caret placement when the draft has moved on,
4103 // or it drags the caret back behind what was typed since.
4104 if (promptRef.current !== next.text) return;
4105 composerEditorRef.current?.focusAt(nextCursor);
4106 });
4107 }
4108 return true;
4109 },
4110 [
4111 activePendingProgress?.activeQuestion,
4112 activePendingUserInput,
4113 isLiteralPendingAnswer,
4114 onChangeActivePendingUserInputCustomAnswer,
4115 onTypingGuardDraftChange,
4116 promptRef,
4117 setPrompt,
4118 setComposerTrigger,
4119 ],
4120 );
4121
4122 const readComposerSnapshot = useCallback((): {
4123 value: string;
4124 cursor: number;
4125 expandedCursor: number;
4126 contextIds: string[];
4127 } => {
4128 const editorSnapshot = composerEditorRef.current?.readSnapshot();
4129 if (editorSnapshot) {
4130 return editorSnapshot;
4131 }
4132 return {
4133 value: promptRef.current,
4134 cursor: composerCursor,
4135 expandedCursor: expandCollapsedComposerCursor(
4136 promptRef.current,
4137 composerCursor,
4138 isLiteralPendingAnswer,
4139 ),
4140 contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current),
4141 };
4142 }, [composerCursor, isLiteralPendingAnswer, promptRef]);
4143
4144 const resolveActiveComposerTrigger = useCallback((): {
4145 snapshot: { value: string; cursor: number; expandedCursor: number };
4146 trigger: ComposerTrigger | null;
4147 } => {
4148 const snapshot = readComposerSnapshot();
4149 return {
4150 snapshot,
4151 trigger:
4152 isLiteralPendingAnswer || composerEditorRef.current?.suggestionsSuppressed()
4153 ? null
4154 : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)),
4155 };
4156 }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]);
4157
4158 const { onUsageLimitsCommand } = props;
4159 const onSelectComposerItem = useCallback(
4160 (item: ComposerCommandItem) => {
4161 if (composerSelectLockRef.current) return;
4162 composerSelectLockRef.current = true;
4163 window.requestAnimationFrame(() => {
4164 composerSelectLockRef.current = false;
4165 });
4166 const { snapshot, trigger } = resolveActiveComposerTrigger();
4167 if (!trigger) return;
4168 if (item.type === "emoji") {
4169 if (
4170 trigger.kind !== "emoji" ||
4171 trigger.query.toLowerCase() !== item.query ||
4172 !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id)
4173 ) {
4174 return;
4175 }
4176 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, item.emoji, {
4177 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4178 });
4179 if (applied) setComposerHighlightedItemId(null);
4180 return;
4181 }
4182 if (item.type === "path") {
4183 const replacement = `${serializeComposerFileLink(item.path)} `;
4184 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4185 snapshot.value,
4186 trigger.rangeEnd,
4187 replacement,
4188 );
4189 const applied = applyPromptReplacement(
4190 trigger.rangeStart,
4191 replacementRangeEnd,
4192 replacement,
4193 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4194 );
4195 if (applied) {
4196 setComposerHighlightedItemId(null);
4197 }
4198 return;
4199 }
4200 if (item.type === "slash-command") {
4201 if (item.command === "model") {
4202 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
4203 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4204 focusEditorAfterReplace: false,
4205 });
4206 if (applied) {
4207 setComposerHighlightedItemId(null);
4208 setIsComposerModelPickerOpen(true);
4209 }
4210 return;
4211 }
4212 if (!planModeUiEnabled) return;
4213 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
4214 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
4215 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4216 });
4217 if (applied) {
4218 setComposerHighlightedItemId(null);
4219 }
4220 return;
4221 }
4222 if (item.type === "provider-slash-command") {
4223 if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) {
4224 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
4225 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4226 focusEditorAfterReplace: false,
4227 });
4228 if (applied) {
4229 setComposerHighlightedItemId(null);
4230 onUsageLimitsCommand();
4231 }
4232 return;
4233 }
4234 const replacement = `/${item.command.name} `;
4235 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4236 snapshot.value,
4237 trigger.rangeEnd,
4238 replacement,
4239 );
4240 const applied = applyPromptReplacement(
4241 trigger.rangeStart,
4242 replacementRangeEnd,
4243 replacement,
4244 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4245 );
4246 if (applied) {
4247 setComposerHighlightedItemId(null);
4248 }
4249 return;
4250 }
4251 if (item.type === "skill") {
4252 const replacement = `$${item.skill.name} `;
4253 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4254 snapshot.value,
4255 trigger.rangeEnd,
4256 replacement,
4257 );
4258 const applied = applyPromptReplacement(
4259 trigger.rangeStart,
4260 replacementRangeEnd,
4261 replacement,
4262 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4263 );
4264 if (applied) {
4265 setComposerHighlightedItemId(null);
4266 }
4267 return;
4268 }
4269 if (item.type === "pull-request") {
4270 if (
4271 trigger.kind !== "pull-request" ||
4272 !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id)
4273 ) {
4274 return;
4275 }
4276 const comment = buildPullRequestReferenceContext(item.pullRequest);
4277 const replacement = `${formatInlineContextReference(
4278 reviewCommentContextReference(comment),
4279 )} `;
4280 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4281 snapshot.value,
4282 trigger.rangeEnd,
4283 replacement,
4284 );
4285 const applied = applyPromptReplacement(
4286 trigger.rangeStart,
4287 replacementRangeEnd,
4288 replacement,
4289 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4290 );
4291 if (applied) {
4292 addComposerDraftReviewComment(composerDraftTarget, comment, {
4293 appendReference: false,
4294 });
4295 setComposerHighlightedItemId(null);
4296 }
4297 return;
4298 }
4299 if (item.type === "thread") {
4300 if (trigger.kind !== "path") return;
4301 const shell = readThreadShell(item.thread);
4302 if (!shell) return;
4303 const record = threadContextRecord(item.thread, shell.title);
4304 const replacement = `${formatInlineContextReference(threadContextReference(record))} `;
4305 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4306 snapshot.value,
4307 trigger.rangeEnd,
4308 replacement,
4309 );
4310 const applied = applyPromptReplacement(
4311 trigger.rangeStart,
4312 replacementRangeEnd,
4313 replacement,
4314 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4315 );
4316 if (applied) {
4317 addComposerDraftThreadContexts(attachmentDraftTarget, [record], {
4318 appendReference: false,
4319 });
4320 setComposerHighlightedItemId(null);
4321 }
4322 return;
4323 }
4324 },
4325 [
4326 addComposerDraftReviewComment,
4327 addComposerDraftThreadContexts,
4328 applyPromptReplacement,
4329 attachmentDraftTarget,
4330 composerDraftTarget,
4331 handleInteractionModeChange,
4332 planModeUiEnabled,
4333 onUsageLimitsCommand,
4334 resolveActiveComposerTrigger,
4335 ],
4336 );
4337
4338 const onComposerMenuItemHighlighted = useCallback(
4339 (itemId: string | null) => {
4340 setComposerHighlightedItemId(itemId);
4341 setComposerHighlightedSearchKey(composerMenuSearchKey);
4342 },
4343 [composerMenuSearchKey],
4344 );
4345
4346 const nudgeComposerMenuHighlight = useCallback(
4347 (key: "ArrowDown" | "ArrowUp") => {
4348 if (composerMenuItems.length === 0) return;
4349 const highlightedIndex = composerMenuItems.findIndex(
4350 (item) => item.id === composerHighlightedItemId,
4351 );
4352 const normalizedIndex =
4353 highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
4354 const offset = key === "ArrowDown" ? 1 : -1;
4355 const nextIndex =
4356 (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
4357 const nextItem = composerMenuItems[nextIndex];
4358 setComposerHighlightedItemId(nextItem?.id ?? null);
4359 },
4360 [composerHighlightedItemId, composerMenuItems],
4361 );
4362
4363 const blurMobileComposerAfterSend = useCallback(() => {
4364 if (!isMobileViewport) return;
4365 if (composerBlurFrameRef.current !== null) {
4366 window.cancelAnimationFrame(composerBlurFrameRef.current);
4367 composerBlurFrameRef.current = null;
4368 }
4369 const activeElement = document.activeElement;
4370 if (activeElement instanceof HTMLElement) {
4371 activeElement.blur();
4372 }
4373 setIsComposerFocused(false);
4374 }, [isMobileViewport, setIsComposerFocused]);
4375
4376 const shouldBlurMobileComposerOnSubmit = useCallback(() => {
4377 if (!isMobileViewport) return false;
4378 if (
4379 isSendBusy ||
4380 isSendDisabled ||
4381 isConnecting ||
4382 noProviderAvailable ||
4383 environmentUnavailable !== null ||
4384 phase === "running"
4385 ) {
4386 return false;
4387 }
4388 if (activePendingProgress) {
4389 return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
4390 }
4391 return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
4392 }, [
4393 activePendingProgress,
4394 activePendingResolvedAnswers,
4395 composerSendState.hasSendableContent,
4396 environmentUnavailable,
4397 isConnecting,
4398 isMobileViewport,
4399 isSendBusy,
4400 isSendDisabled,
4401 noProviderAvailable,
4402 phase,
4403 showPlanFollowUpPrompt,
4404 ]);
4405
4406 const submitComposer = useCallback(
4407 (
4408 event?: { preventDefault: () => void },
4409 dispatchMode?: ComposerDispatchMode,
4410 submissionIntent?: ComposerSubmissionIntent,
4411 ) => {
4412 if (
4413 noProviderAvailable ||
4414 isSendDisabled ||
4415 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
4416 ) {
4417 event?.preventDefault();
4418 return;
4419 }
4420 // A send while a pasted image is still compressing would strand that
4421 // image: the turn snapshot wouldn't include it, and it would surface
4422 // in the *next* draft instead. Only oversized images hit this — small
4423 // files clear the pending counter within a microtask.
4424 if (
4425 activeThreadId &&
4426 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0
4427 ) {
4428 event?.preventDefault();
4429 toastManager.add({
4430 type: "info",
4431 title: "Still compressing a pasted image.",
4432 description: "Send again once its thumbnail appears.",
4433 });
4434 return;
4435 }
4436 // A pasted chip's bytes arrive over the network, so the same hazard applies for longer:
4437 // sending now would snapshot a chip with no attachment behind it.
4438 if (pendingDraftWork.has(attachmentTargetKey)) {
4439 event?.preventDefault();
4440 toastManager.add({
4441 type: "info",
4442 title: "Still bringing attached context into this message.",
4443 description: "Send again once its chip resolves.",
4444 });
4445 return;
4446 }
4447 const submission = submitComposerDraft({
4448 prompt: promptRef.current,
4449 submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn",
4450 event,
4451 onSend: (sendEvent) => {
4452 // ChatView reports its final composed-input preflight through the
4453 // composer handle before its first asynchronous send step.
4454 providerInputRejectedRef.current = false;
4455 onSend(
4456 sendEvent,
4457 dispatchMode ??
4458 resolveComposerDispatchMode({
4459 running: phase === "running",
4460 alternateModifier: false,
4461 activeTurnDefault: settings.followUpBehavior,
4462 }),
4463 submissionIntent,
4464 );
4465 return !providerInputRejectedRef.current;
4466 },
4467 });
4468 setComposerSubmissionError(submission.validationMessage);
4469 if (!submission.didDispatch) return;
4470 onTypingGuardSend();
4471 if (shouldBlurMobileComposerOnSubmit()) {
4472 blurMobileComposerAfterSend();
4473 }
4474 },
4475 [
4476 activeThreadId,
4477 activePendingProgress,
4478 attachmentTargetKey,
4479 onTypingGuardSend,
4480 blurMobileComposerAfterSend,
4481 environmentId,
4482 isSendDisabled,
4483 noProviderAvailable,
4484 onSend,
4485 settings.followUpBehavior,
4486 phase,
4487 promptRef,
4488 shouldBlurMobileComposerOnSubmit,
4489 ],
4490 );
4491 const handleSubmitMessage = useCallback(
4492 (event: React.MouseEvent<HTMLButtonElement>) => {
4493 event.preventDefault();
4494 submitComposer(
4495 event,
4496 resolveComposerDispatchMode({
4497 running: phase === "running",
4498 alternateModifier: event.metaKey || event.ctrlKey,
4499 activeTurnDefault: settings.followUpBehavior,
4500 }),
4501 );
4502 },
4503 [phase, settings.followUpBehavior, submitComposer],
4504 );
4505 const submitCitationAndSend = useCallback(() => {
4506 submitComposer(
4507 undefined,
4508 resolveComposerDispatchMode({
4509 running: phase === "running",
4510 alternateModifier: false,
4511 activeTurnDefault: settings.followUpBehavior,
4512 }),
4513 );
4514 }, [phase, settings.followUpBehavior, submitComposer]);
4515 // A question or approval took the editor while a citation comment was
4516 // unsaved. Keep the comment in the prompt that comes back afterward.
4517 const saveCitationDraftToPrompt = useCallback(
4518 (replacedValue: string, savedValue: string) => {
4519 if (replacedValue !== prompt) return;
4520 if (promptRef.current === replacedValue) promptRef.current = savedValue;
4521 setPrompt(savedValue);
4522 },
4523 [prompt, promptRef, setPrompt],
4524 );
4525 const compactThreadContext = useCallback(() => {
4526 if (
4527 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
4528 compactDisabled ||
4529 noProviderAvailable ||
4530 activePendingApproval !== null ||
4531 pendingUserInputs.length > 0 ||
4532 phase === "running" ||
4533 isSendBusy ||
4534 isConnecting ||
4535 !activeThreadId
4536 ) {
4537 return;
4538 }
4539 onCompactContext();
4540 }, [
4541 activePendingApproval,
4542 activeThreadId,
4543 compactDisabled,
4544 composerDraftTarget,
4545 environmentId,
4546 isConnecting,
4547 isSendBusy,
4548 noProviderAvailable,
4549 onCompactContext,
4550 pendingUserInputs.length,
4551 phase,
4552 ]);
4553 const expandMobileComposer = useCallback(() => {
4554 if (composerBlurFrameRef.current !== null) {
4555 window.cancelAnimationFrame(composerBlurFrameRef.current);
4556 composerBlurFrameRef.current = null;
4557 }
4558 if (mobileComposerExpandFrameRef.current !== null) {
4559 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
4560 }
4561 if (mobileComposerExpandReleaseFrameRef.current !== null) {
4562 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
4563 }
4564 mobileComposerExpandInFlightRef.current = true;
4565 setIsComposerFocused(true);
4566 mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
4567 mobileComposerExpandFrameRef.current = null;
4568 composerEditorRef.current?.focusAtEnd();
4569 mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
4570 mobileComposerExpandReleaseFrameRef.current = null;
4571 mobileComposerExpandInFlightRef.current = false;
4572 });
4573 });
4574 }, [setIsComposerFocused]);
4575
4576 // ------------------------------------------------------------------
4577 // Prompt history (ArrowUp / ArrowDown)
4578 // ------------------------------------------------------------------
4579 // Entries are built on the keypress, not per render: the timeline changes
4580 // on every streamed delta and ArrowUp is rare.
4581 const promptHistoryMessagesRef = useRef(promptHistoryMessages);
4582 promptHistoryMessagesRef.current = promptHistoryMessages;
4583
4584 // The composer persists across threads. A recall from thread A must not
4585 // be treated as active in thread B, where the text-match fallback could
4586 // otherwise turn B's own draft into a browsing position.
4587 const promptHistoryTargetKey = composerTargetKey(composerDraftTarget);
4588 useEffect(() => {
4589 promptHistoryPositionRef.current = null;
4590 }, [promptHistoryTargetKey]);
4591
4592 const replacePromptFromHistory = useCallback(
4593 (nextPrompt: string) => {
4594 promptRef.current = nextPrompt;
4595 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
4596 setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length));
4597 setComposerTrigger(null);
4598 setComposerHighlightedItemId(null);
4599 },
4600 [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger],
4601 );
4602
4603 const navigatePromptHistory = useCallback(
4604 (direction: "backward" | "forward", event: KeyboardEvent): boolean => {
4605 if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) {
4606 return false;
4607 }
4608 if (isComposerApprovalState || pendingUserInputs.length > 0) return false;
4609 // A composer holding an image, file, picked element, preview
4610 // annotation, or review comment is not empty. Recalling text into it
4611 // would send the old prompt with the new context, which is never what
4612 // ArrowUp meant.
4613 if (
4614 composerImagesRef.current.length > 0 ||
4615 composerFilesRef.current.length > 0 ||
4616 composerTerminalContextsRef.current.length > 0 ||
4617 composerPreviewAnnotations.length > 0 ||
4618 composerReviewComments.length > 0
4619 ) {
4620 return false;
4621 }
4622 // A typed draft with no active recall can never step, so skip the
4623 // layout read and the entry build for that common case.
4624 if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) {
4625 return false;
4626 }
4627 const editor = composerEditorRef.current;
4628 if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) {
4629 return false;
4630 }
4631 const step = stepComposerPromptHistory({
4632 direction,
4633 entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current),
4634 position: promptHistoryPositionRef.current,
4635 currentPrompt: promptRef.current,
4636 });
4637 if (!step) return false;
4638 promptHistoryPositionRef.current = step.position;
4639 replacePromptFromHistory(step.prompt);
4640 return true;
4641 },
4642 [
4643 composerTerminalContextsRef,
4644 composerFilesRef,
4645 composerImagesRef,
4646 composerPreviewAnnotations.length,
4647 composerReviewComments.length,
4648 isComposerApprovalState,
4649 pendingUserInputs.length,
4650 promptRef,
4651 replacePromptFromHistory,
4652 ],
4653 );
4654
4655 // ------------------------------------------------------------------
4656 // Callbacks: command key
4657 // ------------------------------------------------------------------
4658 const onComposerCommandKey = (key: string, event: KeyboardEvent) => {
4659 if (event.isComposing || event.keyCode === 229) return false;
4660 const { trigger } = resolveActiveComposerTrigger();
4661 if (trigger?.kind === "emoji") {
4662 const action = composerEmojiKeyAction(event);
4663 if (action === "select") {
4664 const selectedItem = activeComposerMenuItemRef.current;
4665 if (selectedItem?.type === "emoji") onSelectComposerItem(selectedItem);
4666 } else if (action === "next" || action === "previous") {
4667 nudgeComposerMenuHighlight(action === "next" ? "ArrowDown" : "ArrowUp");
4668 } else if (action === "dismiss") {
4669 dismissComposerTrigger(trigger);
4670 composerMenuOpenRef.current = false;
4671 }
4672 if (action !== null) return true;
4673 }
4674 const submissionIntent = composerSubmissionIntentForKey({
4675 event,
4676 keybindings,
4677 isMobileViewport,
4678 isDraftThread: routeKind === "draft",
4679 isRunning: phase === "running",
4680 sendShortcut: settings.sendShortcut,
4681 prompt: promptRef.current,
4682 });
4683 if (key === "Tab" && event.shiftKey && submissionIntent === null) {
4684 if (!planModeUiEnabled) return false;
4685 toggleInteractionMode();
4686 return true;
4687 }
4688 const menuIsActive =
4689 !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null);
4690 if (key === "Escape") {
4691 if (!menuIsActive) return false;
4692 dismissComposerTrigger(trigger);
4693 composerMenuOpenRef.current = false;
4694 return true;
4695 }
4696 if (
4697 menuIsActive &&
4698 trigger?.kind !== "emoji" &&
4699 (submissionIntent === null || submissionIntent === "foreground")
4700 ) {
4701 const currentItems = composerMenuItemsRef.current;
4702 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
4703 if (key === "ArrowDown" && currentItems.length > 0) {
4704 nudgeComposerMenuHighlight("ArrowDown");
4705 return true;
4706 }
4707 if (key === "ArrowUp" && currentItems.length > 0) {
4708 nudgeComposerMenuHighlight("ArrowUp");
4709 return true;
4710 }
4711 if ((key === "Enter" || key === "Tab") && selectedItem) {
4712 onSelectComposerItem(selectedItem);
4713 return true;
4714 }
4715 }
4716 if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) {
4717 return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event);
4718 }
4719 if (submissionIntent) {
4720 submitComposer(
4721 undefined,
4722 resolveComposerDispatchMode({
4723 running: phase === "running",
4724 alternateModifier: submissionIntent === "alternate",
4725 activeTurnDefault: settings.followUpBehavior,
4726 }),
4727 submissionIntent,
4728 );
4729 return true;
4730 }
4731 return false;
4732 };
4733
4734 // ------------------------------------------------------------------
4735 // Prompt stash (⌘S)
4736 // ------------------------------------------------------------------
4737 // Files remain tied to the environment that owns their uploaded bytes.
4738 const stashQueue = usePromptStashStore((state) => state.entries);
4739 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
4740 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
4741 const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
4742
4743 useEffect(() => {
4744 return () => {
4745 if (stashPulseTimeoutRef.current !== null) {
4746 window.clearTimeout(stashPulseTimeoutRef.current);
4747 }
4748 };
4749 }, []);
4750
4751 /** Briefly highlight the badge so the save registers without a flourish. */
4752 const pulseStashBadge = useCallback(() => {
4753 stashPulseKeyRef.current += 1;
4754 setStashPulse({ key: stashPulseKeyRef.current, active: true });
4755 if (stashPulseTimeoutRef.current !== null) {
4756 window.clearTimeout(stashPulseTimeoutRef.current);
4757 }
4758 stashPulseTimeoutRef.current = window.setTimeout(() => {
4759 stashPulseTimeoutRef.current = null;
4760 setStashPulse((current) => ({ ...current, active: false }));
4761 }, 1200);
4762 }, []);
4763
4764 const restoreStashEntry = useCallback(
4765 async (menuEntry: PromptStashEntry) => {
4766 const filesToVerify = menuEntry.files ?? [];
4767 if (filesToVerify.some((file) => file.environmentId !== environmentId)) {
4768 toastManager.add({
4769 type: "error",
4770 title: "Stashed files belong to another environment",
4771 description: "Restore this prompt in the environment that received its files.",
4772 });
4773 return;
4774 }
4775 setIsStashMenuOpen(false);
4776
4777 // The server sweeps pending uploads after 24 hours, so ask before
4778 // reattaching. An expired upload restores as a needs-reattach row
4779 // instead of a reference the next send would fail to verify. Verify
4780 // BEFORE taking: the take removes the entry from durable storage, and a
4781 // tab closed during this await must still find it there after reload.
4782 const verifications = await Promise.all(
4783 filesToVerify.map((file) =>
4784 verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }),
4785 ),
4786 );
4787 const expiredAttachmentIds = new Set(
4788 filesToVerify
4789 .filter((_, index) => verifications[index]?.status === "missing")
4790 .map((file) => file.attachmentId),
4791 );
4792
4793 // A thread switch during the verify await would mix the new thread's
4794 // prompt with this invocation's captured target. Nothing was taken yet,
4795 // so abort and leave the entry restorable where the user now is.
4796 if (
4797 isRevertingCheckpointRef.current ||
4798 composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current
4799 ) {
4800 return;
4801 }
4802
4803 // The take is also the double-activation guard (click + Enter): the
4804 // second caller finds the entry gone and stops here.
4805 const { entry, durable } = takeStashEntry(menuEntry.id);
4806 if (!entry) return;
4807 if (!durable) {
4808 toastManager.add({
4809 type: "warning",
4810 title: "Restored prompt may reappear in the stash",
4811 description:
4812 "Browser storage rejected the update, so this entry could still be there after a reload.",
4813 data: { hideCopyButton: true },
4814 });
4815 }
4816
4817 const rewrittenContextIds = entry.records
4818 ? importContextRecords(entry.records, null)
4819 : new Map<string, string>();
4820 const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => {
4821 const contextId = rewrittenContextIds.get(reference.contextId);
4822 return contextId
4823 ? formatInlineContextReference({
4824 ...reference,
4825 contextId,
4826 kind: reference.kind === "element" ? "preview-annotation" : reference.kind,
4827 })
4828 : reference.source;
4829 });
4830 const currentPrompt = promptRef.current;
4831 // An image-only stash must not append blank lines to whatever is
4832 // already in the composer.
4833 const nextPrompt =
4834 restoredPrompt.length === 0
4835 ? currentPrompt
4836 : currentPrompt.trim().length
4837 ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}`
4838 : restoredPrompt;
4839 let promptChanged = nextPrompt !== currentPrompt;
4840 if (promptChanged) {
4841 promptRef.current = nextPrompt;
4842 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
4843 setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length));
4844 setComposerTrigger(null);
4845 }
4846
4847 let unrestoredFileNames: string[] = [];
4848 const expiredFileNames: string[] = [];
4849 let restoredFileCount = 0;
4850 const stashedFiles = entry.files ?? [];
4851 if (stashedFiles.length > 0) {
4852 const composerFilesNow = composerFilesRef.current;
4853 const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
4854 const retainedUploadIds = new Set(
4855 composerFilesNow.flatMap((file) =>
4856 file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
4857 ),
4858 );
4859 const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey));
4860 const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach);
4861 const restoredMarkerIds = new Set<string>();
4862 const duplicateFiles: PersistedComposerFileAttachment[] = [];
4863 const markerReplacements: ComposerFileAttachment[] = [];
4864 const appendedFiles: ComposerFileAttachment[] = [];
4865 for (const file of stashedFiles) {
4866 const expired = expiredAttachmentIds.has(file.attachmentId);
4867 const key = composerFileDedupKey(file);
4868 const restored: ComposerFileAttachment = {
4869 type: "file",
4870 id: file.id,
4871 name: file.name,
4872 mimeType: file.mimeType,
4873 sizeBytes: file.sizeBytes,
4874 file: null,
4875 ...(file.source ? { source: file.source } : {}),
4876 // An expired upload carries no ids, so it hydrates as a
4877 // needs-reattach row and the "Attach again" flow takes over.
4878 ...(expired
4879 ? {}
4880 : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }),
4881 };
4882 if (existingFileIds.has(file.id)) {
4883 if (!expired && !retainedUploadIds.has(file.attachmentId)) {
4884 duplicateFiles.push(file);
4885 }
4886 continue;
4887 }
4888 const reattachMarker = reattachMarkers.find(
4889 (marker) =>
4890 !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file),
4891 );
4892 if (reattachMarker) {
4893 restoredMarkerIds.add(reattachMarker.id);
4894 existingFileIds.add(file.id);
4895 existingFileKeys.add(key);
4896 if (expired) {
4897 expiredFileNames.push(file.name);
4898 } else {
4899 retainedUploadIds.add(file.attachmentId);
4900 markerReplacements.push(restored);
4901 }
4902 continue;
4903 }
4904 if (existingFileKeys.has(key)) {
4905 if (!expired && !retainedUploadIds.has(file.attachmentId)) {
4906 duplicateFiles.push(file);
4907 }
4908 continue;
4909 }
4910 existingFileIds.add(file.id);
4911 existingFileKeys.add(key);
4912 if (expired) {
4913 expiredFileNames.push(file.name);
4914 } else {
4915 retainedUploadIds.add(file.attachmentId);
4916 }
4917 appendedFiles.push(restored);
4918 }
4919 const capacity = remainingComposerAttachmentSlots(
4920 composerImagesRef.current.length + composerFilesNow.length,
4921 pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
4922 );
4923 // Marker replacements reuse their marker's slot; only appended files
4924 // consume capacity.
4925 const filesToAppend = appendedFiles.slice(0, capacity);
4926 const skippedFiles = appendedFiles.slice(capacity);
4927 unrestoredFileNames = skippedFiles.map((file) => file.name);
4928 // A non-durable take can resurrect the stash entry after a reload;
4929 // deleting these uploads would leave it pointing at nothing.
4930 if (durable) {
4931 for (const file of duplicateFiles) {
4932 releasePersistedAttachmentUpload({
4933 id: file.id,
4934 environmentId,
4935 attachmentId: file.attachmentId,
4936 });
4937 }
4938 for (const file of skippedFiles) {
4939 if (file.uploadedAttachmentId) {
4940 releasePersistedAttachmentUpload({
4941 id: file.id,
4942 environmentId,
4943 attachmentId: file.uploadedAttachmentId,
4944 });
4945 }
4946 }
4947 }
4948 const restoredFiles = [...markerReplacements, ...filesToAppend];
4949 if (restoredFiles.length > 0) {
4950 addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true });
4951 const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt;
4952 if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) {
4953 promptRef.current = restoredFilePrompt;
4954 setComposerCursor(
4955 collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length),
4956 );
4957 setComposerTrigger(null);
4958 promptChanged = true;
4959 }
4960 restoredFileCount = filesToAppend.length;
4961 }
4962 }
4963
4964 let unrestoredImageNames: string[] = [];
4965 if (entry.attachments.length > 0) {
4966 const existingIds = new Set(composerImagesRef.current.map((image) => image.id));
4967 // The draft store also dedupes by mimeType+sizeBytes+name, so filter
4968 // on the same key here. Counting a duplicate against capacity would
4969 // burn a slot the store then refuses to fill, pushing a genuinely
4970 // unique image into the overflow list for nothing.
4971 const existingDedupKeys = new Set(
4972 composerImagesRef.current.map(
4973 (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`,
4974 ),
4975 );
4976 const capacity = remainingComposerAttachmentSlots(
4977 composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount,
4978 pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
4979 );
4980 const pending = entry.attachments.filter(
4981 (attachment) =>
4982 !existingIds.has(attachment.id) &&
4983 !existingDedupKeys.has(
4984 `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`,
4985 ),
4986 );
4987 // Anything past the attachment limit cannot be restored. The entry is
4988 // already out of the queue, so report the overflow by name instead of
4989 // discarding it silently.
4990 unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name);
4991 const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity));
4992 if (restoredImages.length > 0) {
4993 addComposerDraftImages(composerDraftTarget, restoredImages);
4994 }
4995 }
4996
4997 // Deliberately no model/provider restore: the stash exists to carry a
4998 // prompt across threads and providers, so whatever the composer has
4999 // selected right now stays selected.
5000
5001 // Each cause gets its own sentence so "too large" is never blamed for a
5002 // file that actually failed to decode, or for one the composer simply
5003 // had no room to take back.
5004 const missingImageReasons: string[] = [];
5005 if (entry.droppedImageNames.length > 0) {
5006 missingImageReasons.push(
5007 `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`,
5008 );
5009 }
5010 if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) {
5011 missingImageReasons.push(
5012 `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`,
5013 );
5014 }
5015 if (unrestoredImageNames.length > 0) {
5016 missingImageReasons.push(
5017 `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
5018 );
5019 }
5020 if (unrestoredFileNames.length > 0) {
5021 missingImageReasons.push(
5022 `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
5023 );
5024 }
5025 if (expiredFileNames.length > 0) {
5026 missingImageReasons.push(
5027 `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`,
5028 );
5029 }
5030 if (missingImageReasons.length > 0) {
5031 toastManager.add({
5032 type: "warning",
5033 title: "Some attachments were not restored",
5034 description: missingImageReasons.join(" "),
5035 });
5036 }
5037
5038 // Only yank the caret to the end when text was actually inserted;
5039 // restoring images alone should leave the user where they were typing.
5040 if (promptChanged) {
5041 window.requestAnimationFrame(() => {
5042 composerEditorRef.current?.focusAtEnd();
5043 });
5044 }
5045 },
5046 [
5047 addComposerDraftFiles,
5048 addComposerDraftImages,
5049 composerDraftTarget,
5050 composerFilesRef,
5051 composerImagesRef,
5052 environmentId,
5053 promptRef,
5054 setComposerDraftPrompt,
5055 setComposerTrigger,
5056 takeStashEntry,
5057 importContextRecords,
5058 ],
5059 );
5060
5061 const deleteStashEntry = useCallback(
5062 (entry: PromptStashEntry) => {
5063 const { entry: removed, durable } = takeStashEntry(entry.id);
5064 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
5065 setIsStashMenuOpen(false);
5066 }
5067 if (durable && removed) {
5068 for (const file of removed.files ?? []) {
5069 releasePersistedAttachmentUpload({
5070 id: file.id,
5071 environmentId: file.environmentId,
5072 attachmentId: file.attachmentId,
5073 });
5074 }
5075 }
5076 if (!durable) {
5077 toastManager.add({
5078 type: "warning",
5079 title: "Stash entry may come back",
5080 description:
5081 "Browser storage rejected the delete, so this prompt could reappear after a reload.",
5082 data: { hideCopyButton: true },
5083 });
5084 }
5085 },
5086 [stashQueue, takeStashEntry],
5087 );
5088
5089 const stashCurrentPrompt = useCallback(async () => {
5090 // Stashing clears the draft. A pasted attachment still downloading would then land in the
5091 // emptied composer instead of travelling with the entry it belongs to.
5092 if (pendingDraftWork.has(attachmentTargetKeyRef.current)) {
5093 toastManager.add({
5094 type: "info",
5095 title: "Still bringing attached context into this message.",
5096 description: "Stash again once its chip resolves.",
5097 });
5098 return;
5099 }
5100 const prompt = promptRef.current.trim();
5101 const images = [...composerImagesRef.current];
5102 const files = [...composerFilesRef.current];
5103 // Context chips keep their links in the prompt; the payloads behind them travel as
5104 // records so the restore can resolve every chip.
5105 const stashedRecords: ComposerContextRecord[] = [
5106 ...composerTerminalContextsRef.current.map(terminalContextRecord),
5107 ...composerReviewComments.map(reviewCommentContextRecord),
5108 ...composerPreviewAnnotations.map((annotation) =>
5109 previewAnnotationContextRecord(annotation, {
5110 screenshotContextId: images.some((image) => image.id === annotation.id)
5111 ? annotation.id
5112 : undefined,
5113 }),
5114 ),
5115 ];
5116 if (prompt.length === 0 && images.length === 0 && files.length === 0) {
5117 const entries = usePromptStashStore.getState().entries;
5118 const entry = entries.length === 1 ? entries[0] : undefined;
5119 if (entry && !entry.pendingImageCount) {
5120 await restoreStashEntry(entry);
5121 } else {
5122 setIsStashMenuOpen((open) => !open);
5123 }
5124 return;
5125 }
5126 const stashedFiles: PersistedComposerFileAttachment[] = [];
5127 for (const file of files) {
5128 if (composerFileNeedsReattach(file)) {
5129 toastManager.add({
5130 type: "error",
5131 title: "Attach dropped files again or remove them before stashing",
5132 });
5133 return;
5134 }
5135 const upload = readAttachmentUpload(file.id);
5136 if (upload?.status !== "ready" || upload.environmentId !== environmentId) {
5137 toastManager.add({
5138 type: "error",
5139 title: "Wait for file uploads before stashing this prompt",
5140 });
5141 return;
5142 }
5143 stashedFiles.push({
5144 id: file.id,
5145 name: file.name,
5146 mimeType: file.mimeType,
5147 sizeBytes: file.sizeBytes,
5148 attachmentId: upload.attachmentId,
5149 environmentId,
5150 ...(file.source ? { source: file.source } : {}),
5151 });
5152 }
5153 // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
5154 // twice. Guard on the snapshot itself rather than a bare boolean: once
5155 // the composer has been cleared the user can type something genuinely
5156 // new (or switch threads) while encoding continues, and that deserves its
5157 // own entry.
5158 const attachmentKey = images
5159 .map((image) => `image:${image.id}`)
5160 .concat(files.map((file) => `file:${file.id}`))
5161 .join(",");
5162 const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n");
5163 if (stashInFlightRef.current.has(snapshotKey)) return;
5164 stashInFlightRef.current.add(snapshotKey);
5165
5166 const stashTarget = composerDraftTarget;
5167 const entryId = randomUUID();
5168 try {
5169 // Persist the text-only entry *first*, then clear. Ordering matters in
5170 // both directions: writing before clearing means a crash or closed tab
5171 // mid-encode still leaves the prompt recoverable, while clearing before
5172 // the async image work means edits typed during encoding are not wiped.
5173 // Images are appended to the stored entry as they finish encoding.
5174 const { evicted, written, durable } = stashEntryToQueue({
5175 id: entryId,
5176 createdAt: new Date().toISOString(),
5177 prompt,
5178 attachments: [],
5179 ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}),
5180 droppedImageNames: [],
5181 unreadableImageNames: [],
5182 pendingImageCount: images.length,
5183 ...(stashedRecords.length > 0 ? { records: stashedRecords } : {}),
5184 });
5185
5186 // Clearing the composer is only safe once the write actually landed.
5187 // If it was rejected (quota) the store has already rolled itself back,
5188 // so leave the composer untouched rather than making it the second
5189 // casualty of a reload.
5190 if (!written) {
5191 toastManager.add({
5192 type: "error",
5193 title: "Could not stash this prompt",
5194 description:
5195 "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.",
5196 data: { hideCopyButton: true },
5197 });
5198 return;
5199 }
5200 // Written but only into the in-memory fallback (localStorage blocked):
5201 // the entry is visible and restorable this session, so proceed with the
5202 // clear, but say it won't survive a reload.
5203 if (!durable) {
5204 toastManager.add({
5205 type: "warning",
5206 title: "Stashed prompt will not survive a reload",
5207 description:
5208 "Browser storage is unavailable, so this stash is kept in memory only for this session.",
5209 data: { hideCopyButton: true },
5210 });
5211 }
5212
5213 // Everything the entry carries leaves the draft with it.
5214 promptRef.current = "";
5215 clearComposerDraftPromptAndImages(stashTarget);
5216 clearComposerDraftTerminalContexts(stashTarget);
5217 for (const comment of composerReviewComments) {
5218 removeComposerDraftReviewComment(stashTarget, comment.id);
5219 }
5220 for (const annotation of composerPreviewAnnotations) {
5221 releaseAttachmentUpload(annotation.id);
5222 removeComposerDraftPreviewAnnotation(stashTarget, annotation.id);
5223 }
5224 setComposerDraftPrompt(stashTarget, "");
5225 for (const image of images) {
5226 releaseAttachmentUpload(image.id);
5227 }
5228 setComposerCursor(0);
5229 setComposerTrigger(null);
5230 pulseStashBadge();
5231
5232 if (evicted) {
5233 for (const file of evicted.files ?? []) {
5234 releasePersistedAttachmentUpload({
5235 id: file.id,
5236 environmentId: file.environmentId,
5237 attachmentId: file.attachmentId,
5238 });
5239 }
5240 toastManager.add({
5241 type: "warning",
5242 title: "Oldest stashed prompt discarded",
5243 description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`,
5244 data: { hideCopyButton: true },
5245 });
5246 }
5247
5248 // Images are re-encoded for the stash rather than stored verbatim: the
5249 // composer allows up to 10MB per image, but localStorage gives the whole
5250 // origin ~5MB. Only the stashed copy shrinks; the live attachment (and
5251 // anything sent without stashing) keeps the original file.
5252 const candidateAttachments: PersistedComposerImageAttachment[] = [];
5253 const oversizedImageNames: string[] = [];
5254 const unreadableImageNames: string[] = [];
5255 for (const image of images) {
5256 const result = await compressImageForStash(image.file);
5257 if (!result.ok) {
5258 // "too large" and "could not be read" are distinct outcomes; the
5259 // menu and restore toast report them separately.
5260 (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push(
5261 image.name,
5262 );
5263 continue;
5264 }
5265 candidateAttachments.push({
5266 id: image.id,
5267 name: image.name,
5268 mimeType: result.image.mimeType,
5269 sizeBytes: result.image.sizeBytes,
5270 dataUrl: result.image.dataUrl,
5271 ...(image.source
5272 ? { source: resizeSnapShotSource(image.source, result.image.imageSize) }
5273 : {}),
5274 });
5275 }
5276 const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);
5277
5278 const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
5279 attachments: kept,
5280 droppedImageNames: [...oversizedImageNames, ...droppedNames],
5281 unreadableImageNames,
5282 });
5283 if (attached) {
5284 // The second phase can be rejected on its own: the text-only entry
5285 // fit, but adding image payloads pushed past the quota. Disk would
5286 // then still hold the phase-one entry with pendingImageCount set,
5287 // which reads as an orphan after reload — so say so now. Gated on the
5288 // entry write having been durable: on the in-memory fallback nothing
5289 // is ever durable, and the session-only warning already covered it.
5290 if (!imagesDurable && durable && images.length > 0) {
5291 toastManager.add({
5292 type: "warning",
5293 title: "Stashed images were not saved",
5294 description:
5295 "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.",
5296 data: { hideCopyButton: true },
5297 });
5298 }
5299 } else if (kept.length > 0) {
5300 // The entry was restored or deleted before its images finished
5301 // encoding, so they have nowhere to land. Say so rather than letting
5302 // them evaporate.
5303 toastManager.add({
5304 type: "warning",
5305 title: "Stashed images did not attach",
5306 description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`,
5307 data: { hideCopyButton: true },
5308 });
5309 }
5310 } finally {
5311 // Must clear on every path: a throw that left this set would wedge this
5312 // snapshot's ⌘S until the composer remounts.
5313 stashInFlightRef.current.delete(snapshotKey);
5314 }
5315 }, [
5316 clearComposerDraftPromptAndImages,
5317 clearComposerDraftTerminalContexts,
5318 setComposerDraftPrompt,
5319 setComposerTrigger,
5320 composerDraftTarget,
5321 composerFilesRef,
5322 composerImagesRef,
5323 composerTerminalContextsRef,
5324 composerReviewComments,
5325 composerPreviewAnnotations,
5326 removeComposerDraftReviewComment,
5327 removeComposerDraftPreviewAnnotation,
5328 environmentId,
5329 finalizeStashEntryImages,
5330 promptRef,
5331 pulseStashBadge,
5332 restoreStashEntry,
5333 stashEntryToQueue,
5334 ]);
5335
5336 const toggleStashMenu = useCallback(() => {
5337 if (isComposerCollapsedMobile) {
5338 expandMobileComposer();
5339 setIsStashMenuOpen(true);
5340 return;
5341 }
5342 setIsStashMenuOpen((open) => !open);
5343 }, [expandMobileComposer, isComposerCollapsedMobile]);
5344 const toggleTasksDrawer = useCallback(() => {
5345 setIsTasksDrawerOpen((open) => !open);
5346 }, []);
5347 const hasBannerItems = props.bannerItems.length > 0;
5348 const hasBlockingComposerTopDrawer =
5349 activePendingApproval !== null || pendingUserInputs.length > 0;
5350 const showInlineTasksBadge =
5351 activeTasksProgress !== null &&
5352 activeTaskSteps !== null &&
5353 !isTasksDrawerOpen &&
5354 !hasBlockingComposerTopDrawer &&
5355 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);
5356 const inlineTasksBadge = showInlineTasksBadge ? (
5357 <ComposerTasksBadge
5358 expanded={false}
5359 onToggle={toggleTasksDrawer}
5360 placement="inline"
5361 progress={activeTasksProgress}
5362 steps={activeTaskSteps}
5363 />
5364 ) : null;
5365 const hasImageAttachmentAttention = standaloneComposerImages.some((image) => {
5366 const upload = uploadsByImageId[image.id];
5367 const failedInCurrentEnvironment =
5368 supportsAttachmentUploads &&
5369 upload?.status === "failed" &&
5370 upload.environmentId === environmentId;
5371 // A failed upload remains actionable in the expanded attachment tray, but
5372 // its collapsed thumbnail is enough to signal that the draft has images.
5373 return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment;
5374 });
5375 // Banners and the tasks badge dock above the surface rather than inside
5376 // it, so they do not hold the composer open; only surface-internal chrome
5377 // does.
5378 const composerHasExpandedChrome =
5379 showComposerTopDrawer ||
5380 isTasksDrawerOpen ||
5381 composerMenuOpen ||
5382 isStashMenuOpen ||
5383 isDragOverComposer ||
5384 isPreparingWorktree ||
5385 showProviderUnavailable ||
5386 projectSelectionRequired ||
5387 environmentUnavailable !== null ||
5388 composerSubmissionError !== null ||
5389 providerInputSubmissionError !== null ||
5390 hasImageAttachmentAttention;
5391 const isComposerResting = shouldUseRestingComposerLayout({
5392 isExistingThread: routeKind === "server" && activeThreadId !== null,
5393 isMobileViewport,
5394 isScrollCollapsed: isComposerScrollCollapsed,
5395 hasExpandedChrome: composerHasExpandedChrome,
5396 hasMultilinePrompt,
5397 timelineOverflows,
5398 });
5399 const expandedComposerImages = isComposerResting
5400 ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id))
5401 : standaloneComposerImages;
5402 // Keep collapsed controls inside the input when workspace context is hidden.
5403 const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile;
5404 const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null;
5405 const composerControlsVisibleInStrip =
5406 composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible;
5407 const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible;
5408 if (composerControlsHidden && isComposerModelPickerOpen) {
5409 setIsComposerModelPickerOpen(false);
5410 }
5411 useLayoutEffect(() => {
5412 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);
5413 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);
5414 useLayoutEffect(() => {
5415 onRestingChange(isComposerResting);
5416 }, [isComposerResting, onRestingChange]);
5417 // The resting footer floats over the prompt row, so the row reserves its
5418 // measured width. Wide actions like the compact-before-send chip vary too much for a fixed inset.
5419 const [restingActionsElement, setRestingActionsElement] = useState<HTMLDivElement | null>(null);
5420 const [restingActionsWidth, setRestingActionsWidth] = useState<number | null>(null);
5421 useLayoutEffect(() => {
5422 if (!isComposerResting || !restingActionsElement) return;
5423 const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth);
5424 measure();
5425 return observeResize(restingActionsElement, measure);
5426 }, [isComposerResting, restingActionsElement]);
5427 const restingImagePreviewCounts = getRestingComposerImagePreviewCounts(
5428 standaloneComposerImages.length,
5429 );
5430 const restingComposerImages = standaloneComposerImages.slice(
5431 0,
5432 restingImagePreviewCounts.visibleCount,
5433 );
5434 const collapsedComposerImagePreviews =
5435 restingComposerImages.length > 0 ? (
5436 <div
5437 data-chat-composer-resting-images="true"
5438 className="flex shrink-0 items-center gap-1 ps-1"
5439 >
5440 {restingComposerImages.map((image) => (
5441 <button
5442 key={image.id}
5443 type="button"
5444 className="relative size-7 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-border/70 bg-muted/60"
5445 aria-label={`Preview ${image.name}`}
5446 onPointerDown={(event) => event.preventDefault()}
5447 onClick={() => {
5448 const preview = buildExpandedImagePreview(composerImages, image.id);
5449 if (preview) onExpandImage(preview);
5450 }}
5451 >
5452 {image.previewUrl ? (
5453 <ComposerImageThumbnail
5454 file={image.file}
5455 alt=""
5456 className="size-full object-cover"
5457 fallback={
5458 <PierreEntryIcon
5459 pathValue={image.name}
5460 kind="file"
5461 theme={resolvedTheme}
5462 className="m-auto size-3.5"
5463 />
5464 }
5465 />
5466 ) : (
5467 <PierreEntryIcon
5468 pathValue={image.name}
5469 kind="file"
5470 theme={resolvedTheme}
5471 className="m-auto size-3.5"
5472 />
5473 )}
5474 </button>
5475 ))}
5476 {restingImagePreviewCounts.overflowCount > 0 ? (
5477 <button
5478 type="button"
5479 className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border/70 bg-muted/60 font-medium text-secondary-label text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
5480 aria-label={`Show ${String(restingImagePreviewCounts.overflowCount)} more image attachments`}
5481 onPointerDown={(event) => event.preventDefault()}
5482 onClick={() => {
5483 if (isComposerCollapsedMobile) {
5484 expandMobileComposer();
5485 return;
5486 }
5487 setIsComposerScrollCollapsed(false);
5488 setIsComposerFocused(true);
5489 scheduleComposerFocus();
5490 }}
5491 >
5492 +{restingImagePreviewCounts.overflowCount}
5493 </button>
5494 ) : null}
5495 </div>
5496 ) : null;
5497 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
5498 usePanelAnimationSettings();
5499 const composerMainSurfaceRef = useComposerRestingTransition(
5500 composerControlsCollapsed,
5501 isComposerResting,
5502 restingComposerControlsRef,
5503 onComposerOverlayHeightChange,
5504 panelAnimationsActive,
5505 panelAnimationDurationMs,
5506 );
5507 const canTrackComposerScrollGesture =
5508 routeKind === "server" && activeThreadId !== null && !isMobileViewport;
5509 const canScrollCollapseComposer =
5510 canTrackComposerScrollGesture &&
5511 settings.composerCollapseOnScroll &&
5512 !hasMultilinePrompt &&
5513 !composerHasExpandedChrome &&
5514 !showInlineTasksBadge;
5515 // Scrolling only has something to collapse while the composer is expanded,
5516 // focused or not, so the wheel handler keys off the resting state rather
5517 // than editor focus.
5518 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting;
5519
5520 useEffect(() => {
5521 if (!canScrollCollapseComposer) {
5522 setIsComposerScrollCollapsed(false);
5523 }
5524 }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]);
5525
5526 // Returning to the window re-fires focus on the element that already held
5527 // it. That focus arrives after the window's own event, so a window focus
5528 // marks the frame in which it should be ignored by the form's capture.
5529 useEffect(() => {
5530 if (!isComposerScrollCollapsed) return;
5531 let frame: number | null = null;
5532 const onWindowFocus = () => {
5533 windowRefocusInFlightRef.current = true;
5534 if (frame !== null) window.cancelAnimationFrame(frame);
5535 frame = window.requestAnimationFrame(() => {
5536 frame = null;
5537 windowRefocusInFlightRef.current = false;
5538 });
5539 };
5540 window.addEventListener("focus", onWindowFocus);
5541 return () => {
5542 window.removeEventListener("focus", onWindowFocus);
5543 if (frame !== null) window.cancelAnimationFrame(frame);
5544 windowRefocusInFlightRef.current = false;
5545 };
5546 }, [isComposerScrollCollapsed]);
5547
5548 useEffect(() => {
5549 if (!canTrackComposerScrollGesture) return;
5550
5551 const finishScrollGesture = () => {
5552 if (composerScrollCollapseTimeoutRef.current !== null) {
5553 window.clearTimeout(composerScrollCollapseTimeoutRef.current);
5554 }
5555 composerScrollCollapseTimeoutRef.current = null;
5556 resetComposerScrollGesture(composerScrollGestureRef.current);
5557 };
5558 const handleTimelineWheel = (event: WheelEvent) => {
5559 if (event.ctrlKey || !(event.target instanceof Element)) {
5560 return;
5561 }
5562
5563 const scrollNode = getTimelineScrollableNode();
5564 if (!scrollNode) return;
5565 const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY);
5566 if (
5567 !scrollNode.contains(event.target) &&
5568 !composerScrollGestureRef.current.collapseSuppressed
5569 )
5570 return;
5571
5572 if (composerScrollCollapseTimeoutRef.current !== null) {
5573 window.clearTimeout(composerScrollCollapseTimeoutRef.current);
5574 }
5575 composerScrollCollapseTimeoutRef.current = window.setTimeout(
5576 finishScrollGesture,
5577 COMPOSER_SCROLL_GESTURE_RESET_MS,
5578 );
5579
5580 const canScrollInGestureDirection =
5581 targetsTimeline &&
5582 (event.deltaY < 0
5583 ? scrollNode.scrollTop > 0
5584 : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight);
5585 const deltaPx =
5586 Math.abs(event.deltaY) *
5587 (event.deltaMode === WheelEvent.DOM_DELTA_LINE
5588 ? 16
5589 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
5590 ? scrollNode.clientHeight
5591 : 1);
5592 const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, {
5593 now: window.performance.now(),
5594 deltaPx,
5595 collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX,
5596 collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current,
5597 canScrollInGestureDirection,
5598 scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(),
5599 });
5600 if (!shouldCollapse) {
5601 return;
5602 }
5603
5604 setIsComposerScrollCollapsed(true);
5605 };
5606
5607 document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true });
5608 return () => {
5609 document.removeEventListener("wheel", handleTimelineWheel, true);
5610 finishScrollGesture();
5611 };
5612 }, [
5613 activeThreadId,
5614 canTrackComposerScrollGesture,
5615 getTimelineScrollableNode,
5616 isTimelineAtLogicalEnd,
5617 setIsComposerScrollCollapsed,
5618 ]);
5619
5620 const restingHiddenBlockCount = composerControlsCollapsed
5621 ? restingControlsHiddenBlockCount
5622 : expandedControlsLayout.hiddenBlockCount;
5623 const iconOnlyBlockCount = composerControlsCollapsed
5624 ? restingControlsIconOnlyBlockCount
5625 : expandedControlsLayout.iconOnlyBlockCount;
5626 const restingProviderTraitsPicker = renderProviderTraitsPicker({
5627 ...providerTraitsPickerInput,
5628 size: composerControlsCollapsed ? "xs" : "sm",
5629 hidden: composerControlsHidden || restingHiddenBlockCount > 1,
5630 });
5631 const restingBlockDefs = [
5632 ...(providerTraitsPicker
5633 ? [
5634 {
5635 id: "traits",
5636 content: (
5637 <>
5638 <ComposerControlSeparator size={composerControlsCollapsed ? "xs" : "sm"} />
5639 {restingProviderTraitsPicker}
5640 </>
5641 ),
5642 },
5643 ]
5644 : []),
5645 {
5646 id: "mode",
5647 content: (
5648 <ComposerFooterModeControls
5649 showInteractionModeToggle={planModeUiEnabled}
5650 interactionMode={interactionMode}
5651 runtimeMode={compatibleRuntimeMode}
5652 runtimeModeOptions={compatibleRuntimeModeOptions}
5653 size={composerControlsCollapsed ? "xs" : "sm"}
5654 hidden={composerControlsHidden || restingHiddenBlockCount > 0}
5655 onToggleInteractionMode={toggleInteractionMode}
5656 onRuntimeModeChange={handleRuntimeModeChange}
5657 />
5658 ),
5659 },
5660 ];
5661 const hiddenRestingBlockIds = restingBlockDefs
5662 .slice(restingBlockDefs.length - restingHiddenBlockCount)
5663 .map((def) => def.id);
5664 const composerControls = showProviderUnavailable ? (
5665 <ComposerControl
5666 type="button"
5667 disabled={!providerSetupInstanceId}
5668 onClick={() => {
5669 if (providerSetupInstanceId) {
5670 onOpenProviderSetup(providerSetupInstanceId);
5671 }
5672 }}
5673 data-chat-provider-unavailable="true"
5674 className="shrink-0"
5675 >
5676 <CircleAlertIcon className="size-4" />
5677 {providerSetupInstanceId ? "Open provider settings" : "No provider available"}
5678 </ComposerControl>
5679 ) : (
5680 <>
5681 {composerControlsCollapsed &&
5682 restingControlsHost !== null &&
5683 restingControlsHaveLeadingContext ? (
5684 <ComposerControlSeparator
5685 size="xs"
5686 className="@max-[400px]/composer-surface:hidden"
5687 data-resting-controls-separator="true"
5688 />
5689 ) : null}
5690 <ProviderModelPicker
5691 environmentId={environmentId}
5692 compact={false}
5693 isComposerOwned
5694 disabled={providerCatalogPending || isSendBusy}
5695 {...(routeKind === "draft" && supportsMultipleModels
5696 ? {
5697 ...(multipleModelSelections !== null
5698 ? { selectedModels: multipleModelSelections }
5699 : {}),
5700 onToggleModel: (instanceId: ProviderInstanceId, model: string) => {
5701 const current = multipleModelSelections ?? [selectedModelSelection];
5702 const matchesModel = (selection: ModelSelection) => {
5703 if (selection.instanceId !== instanceId) return false;
5704 const entry = providerInstanceEntries.find(
5705 (entry) => entry.instanceId === selection.instanceId,
5706 );
5707 const resolvedModel = resolveModelPickerSelectedModel({
5708 driverKind: entry?.driverKind,
5709 model: selection.model,
5710 options: modelOptionsByInstance.get(selection.instanceId) ?? [],
5711 });
5712 return (resolvedModel?.slug ?? selection.model) === model;
5713 };
5714 const exists = current.some(matchesModel);
5715 const next = exists
5716 ? current.filter((selection) => !matchesModel(selection))
5717 : [...current, createModelSelection(instanceId, model)];
5718 if (next.length > 1) {
5719 setMultipleModelSelections(next);
5720 } else {
5721 setMultipleModelSelections(null);
5722 const remaining = next[0] ?? selectedModelSelection;
5723 onProviderModelSelect(remaining.instanceId, remaining.model, {
5724 focusComposer: false,
5725 });
5726 }
5727 },
5728 }
5729 : {})}
5730 activeInstanceId={
5731 providerCatalogPending
5732 ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)
5733 : selectedInstanceId
5734 }
5735 model={
5736 providerCatalogPending
5737 ? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback)
5738 : selectedModelForPickerWithCustomFallback
5739 }
5740 lockedProvider={lockedProvider}
5741 lockedContinuationGroupKey={lockedContinuationGroupKey}
5742 instanceEntries={providerInstanceEntries}
5743 keybindings={keybindings}
5744 modelOptionsByInstance={modelOptionsByInstance}
5745 size={composerControlsCollapsed ? "xs" : "sm"}
5746 triggerClassName={
5747 composerControlsCollapsed
5748 ? cn(
5749 "min-w-13 shrink text-xs!",
5750 !showInlineRestingControls &&
5751 "@max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none",
5752 )
5753 : "-ms-2.5 min-w-13"
5754 }
5755 terminalOpen={terminalOpen}
5756 open={isComposerModelPickerOpen}
5757 instanceIndicatorBackground={
5758 composerControlsCollapsed
5759 ? "color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent)"
5760 : "var(--contrast-input)"
5761 }
5762 {...(composerProviderState.modelPickerIconClassName || composerControlsCollapsed
5763 ? {
5764 activeProviderIconClassName: cn(
5765 composerProviderState.modelPickerIconClassName,
5766 composerControlsCollapsed &&
5767 // Package glyphs get opaque fills and one opacity, so overlapping paths don't band.
5768 "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-icon-detail]]:fill-transparent! [&[data-provider-package-icon]]:opacity-70 [&[data-provider-package-icon]_path:not([data-icon-detail])]:fill-muted-foreground!",
5769 ),
5770 }
5771 : {})}
5772 onOpenChange={setIsComposerModelPickerOpen}
5773 getModelDisabledReason={getModelDisabledReason}
5774 onInstanceModelChange={(instanceId, model) => {
5775 setMultipleModelSelections(null);
5776 onProviderModelSelect(instanceId, model);
5777 }}
5778 onOpenProviderSetup={onOpenProviderSetup}
5779 />
5780
5781 <>
5782 {restingBlockDefs.map((def, index) => {
5783 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
5784 return (
5785 <div
5786 key={def.id}
5787 data-resting-block={def.id}
5788 data-composer-block-icon-only={
5789 index >= restingBlockDefs.length - iconOnlyBlockCount ? "true" : "false"
5790 }
5791 aria-hidden={hidden || undefined}
5792 inert={hidden || undefined}
5793 className={cn(
5794 "flex w-max min-w-max shrink-0 items-center gap-1",
5795 hidden && "pointer-events-none invisible absolute",
5796 index >= restingBlockDefs.length - iconOnlyBlockCount &&
5797 "[&_[data-composer-control-label]]:pointer-events-none [&_[data-composer-control-label]]:invisible [&_[data-composer-control-label]]:absolute [&_[data-composer-control-label]]:w-max [&_[data-composer-control-label]]:max-w-none [&_[data-composer-control-compact-icon]]:[visibility:inherit] [&_[data-composer-control-compact-icon]]:relative",
5798 )}
5799 >
5800 {def.content}
5801 </div>
5802 );
5803 })}
5804 <div
5805 data-resting-controls-overflow
5806 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}
5807 inert={hiddenRestingBlockIds.length === 0 || undefined}
5808 className={cn(
5809 "min-w-0 shrink-0",
5810 hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute",
5811 )}
5812 >
5813 <CompactComposerControlsMenu
5814 interactionMode={interactionMode}
5815 runtimeMode={compatibleRuntimeMode}
5816 runtimeModeOptions={compatibleRuntimeModeOptions}
5817 size={composerControlsCollapsed ? "xs" : "sm"}
5818 hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
5819 showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.includes("mode")}
5820 traitsMenuContent={
5821 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
5822 }
5823 onToggleInteractionMode={toggleInteractionMode}
5824 onRuntimeModeChange={handleRuntimeModeChange}
5825 />
5826 </div>
5827 </>
5828 </>
5829 );
5830 const showTasksTab =
5831 !hasBannerItems &&
5832 !showComposerTopDrawer &&
5833 !isTasksDrawerOpen &&
5834 !isComposerCollapsedMobile &&
5835 activeTasksProgress !== null &&
5836 activeTaskSteps !== null &&
5837 activeTasksProgress.totalSteps > 0;
5838 const activityStackContent = hasBannerItems ? (
5839 shownSyncPhase ? (
5840 <ComposerActivityRow phase={shownSyncPhase} />
5841 ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
5842 <ComposerTasksContent
5843 expanded={isTasksDrawerOpen}
5844 onToggle={toggleTasksDrawer}
5845 progress={activeTasksProgress}
5846 steps={activeTaskSteps}
5847 />
5848 ) : null
5849 ) : null;
5850 const activityStackItem: ComposerBannerStackContent | null = activityStackContent
5851 ? {
5852 id: "composer-activity",
5853 variant: "default",
5854 priority: "activity",
5855 content: activityStackContent,
5856 }
5857 : null;
5858 const bannerStackItems = activityStackItem
5859 ? [activityStackItem, ...props.bannerItems]
5860 : props.bannerItems;
5861 useEffect(() => {
5862 if (activeTasksProgress === null || activeTaskSteps === null) {
5863 setIsTasksDrawerOpen(false);
5864 }
5865 }, [activeTaskSteps, activeTasksProgress]);
5866
5867 useEffect(() => {
5868 if (hasBlockingComposerTopDrawer) {
5869 setIsTasksDrawerOpen(false);
5870 }
5871 }, [hasBlockingComposerTopDrawer]);
5872
5873 useEffect(() => {
5874 setIsTasksDrawerOpen(false);
5875 }, [activeThreadId]);
5876
5877 // Close the stash menu whenever the trigger-driven command menu opens so
5878 // the two popovers never stack in the same layer, and when the user
5879 // resumes typing (the menu is a transient picker, not a panel).
5880 useEffect(() => {
5881 if (composerMenuOpen) {
5882 setIsStashMenuOpen(false);
5883 }
5884 }, [composerMenuOpen]);
5885 useEffect(() => {
5886 setIsStashMenuOpen(false);
5887 }, [prompt]);
5888
5889 useEffect(() => {
5890 const handler = (event: globalThis.KeyboardEvent) => {
5891 const command = resolveShortcutCommand(event, keybindings, {
5892 context: {
5893 terminalFocus: getTerminalFocusOwner() !== null,
5894 terminalOpen,
5895 modelPickerOpen: isComposerModelPickerOpen,
5896 },
5897 });
5898 if (command !== "composer.stash") return;
5899 // Always claim the shortcut so the browser save dialog never opens,
5900 // even when the composer is in a state that can't stash.
5901 event.preventDefault();
5902 event.stopPropagation();
5903 if (isCommandPaletteOpen() || isRevertingCheckpoint) {
5904 return;
5905 }
5906 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
5907 setIsStashMenuOpen((open) => !open);
5908 return;
5909 }
5910 if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) {
5911 return;
5912 }
5913 void stashCurrentPrompt();
5914 };
5915 window.addEventListener("keydown", handler, true);
5916 return () => window.removeEventListener("keydown", handler, true);
5917 }, [
5918 activePendingProgress,
5919 isComposerApprovalState,
5920 isComposerModelPickerOpen,
5921 keybindings,
5922 pendingUserInputs.length,
5923 projectSelectionRequired,
5924 stashCurrentPrompt,
5925 isRevertingCheckpoint,
5926 terminalOpen,
5927 ]);
5928
5929 // ------------------------------------------------------------------
5930 // Callbacks: attachments
5931 // ------------------------------------------------------------------
5932 /** Resolves true when at least one chip was inserted for the accepted attachments. */
5933 const addComposerAttachments = async (
5934 files: File[],
5935 options?: {
5936 readonly source?: ChatFileAttachment["source"];
5937 readonly selection?: { start: number; end: number };
5938 readonly skipImageInlineChip?: boolean;
5939 },
5940 ): Promise<boolean> => {
5941 if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false;
5942 if (
5943 pendingUserInputs.length > 0 &&
5944 (!supportsQuestionAttachments ||
5945 activePendingProgress?.activeQuestion?.allowCustomAnswer === false ||
5946 activePendingIsResponding)
5947 ) {
5948 toastManager.add({
5949 type: "error",
5950 title: "This question cannot accept attachments.",
5951 });
5952 return false;
5953 }
5954 // Captured before the awaits below: the user may switch threads while a
5955 // large image is being compressed, and the attachments and errors belong
5956 // to the thread the paste happened in.
5957 const threadId = activeThreadId;
5958 // Images landing with no prose live on the shelf with no chip. Read before
5959 // the awaits below: compression is async and the prompt may change while it
5960 // runs. An explicit selection replace and states where the editor refuses
5961 // input (connecting, approval, pending questions, project selection) still
5962 // get chips so the image is never invisible, unless paste-as-text explicitly
5963 // requests no inline image chip.
5964 const imageAttachmentsGetChips =
5965 !options?.skipImageInlineChip &&
5966 (options?.selection !== undefined ||
5967 isConnecting ||
5968 isComposerApprovalState ||
5969 pendingUserInputs.length > 0 ||
5970 projectSelectionRequired ||
5971 stripInlineContextReferences(promptRef.current).trim().length > 0);
5972
5973 // Validation happens synchronously so concurrent pastes see each other:
5974 // accepted files reserve their attachment slots (via the pending counter)
5975 // before the first await, keeping the total under the limit.
5976 const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0;
5977 let reservedCount = countReservedAttachments();
5978 // A pick that matches a needs-reattach marker replaces it in the draft, so
5979 // it must not consume a slot; a draft full of markers would otherwise hit
5980 // the capacity error before the replacement path could run.
5981 const reattachMarkers = composerFilesRef.current.filter(composerFileNeedsReattach);
5982 const replacedReattachMarkerIds = new Set<string>();
5983 const acceptedImages: File[] = [];
5984 const acceptedFiles: ComposerFileAttachment[] = [];
5985 let error: string | null = null;
5986 for (const file of files) {
5987 const attachmentKind = classifyComposerAttachmentFile(file);
5988 const fileMimeType =
5989 attachmentKind === "file"
5990 ? (videoMimeType({ name: file.name, mimeType: file.type }) ??
5991 (file.type || "application/octet-stream"))
5992 : file.type;
5993 const matchingReattachMarker =
5994 attachmentKind === "file"
5995 ? reattachMarkers.find(
5996 (marker) =>
5997 !replacedReattachMarkerIds.has(marker.id) &&
5998 composerFileMatchesReattachMarker(marker, {
5999 name: file.name || "file",
6000 mimeType: fileMimeType,
6001 sizeBytes: file.size,
6002 }),
6003 )
6004 : undefined;
6005 if (matchingReattachMarker) {
6006 replacedReattachMarkerIds.add(matchingReattachMarker.id);
6007 }
6008 if (!matchingReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
6009 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
6010 // Keep scanning: a later file in this batch can still replace a
6011 // needs-reattach marker without needing a free slot.
6012 continue;
6013 }
6014 if (attachmentKind === "unsupported-image") {
6015 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
6016 continue;
6017 }
6018 if (attachmentKind === "image") {
6019 acceptedImages.push(normalizeComposerImageFileMimeType(file));
6020 } else {
6021 if (fileStagingLimit === null) {
6022 error = "This server does not support file attachments.";
6023 continue;
6024 }
6025 if (file.size <= 0) {
6026 error = `'${file.name}' is empty or could not be read.`;
6027 continue;
6028 }
6029 if (file.size > fileStagingLimit) {
6030 error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
6031 continue;
6032 }
6033 const attachmentFile =
6034 file.type === fileMimeType
6035 ? file
6036 : new File([file], file.name, { type: fileMimeType, lastModified: file.lastModified });
6037 acceptedFiles.push({
6038 type: "file",
6039 id: randomUUID(),
6040 name: attachmentFile.name || "file",
6041 mimeType: fileMimeType,
6042 sizeBytes: attachmentFile.size,
6043 file: attachmentFile,
6044 ...(options?.source ? { source: options.source } : {}),
6045 });
6046 }
6047 if (!matchingReattachMarker) {
6048 reservedCount += 1;
6049 }
6050 }
6051 setThreadError(threadId, error);
6052 let insertedAny = false;
6053 if (acceptedFiles.length > 0) {
6054 // Only files the draft actually took get a chip; a duplicate is deduped by the store
6055 // and a chip for it would point at nothing.
6056 const storedIds = new Set(addComposerFilesToDraft(acceptedFiles));
6057 const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id));
6058 if (storedFiles.length > 0) {
6059 insertedAny = insertAttachmentReferences(
6060 storedFiles.map(fileContextReference),
6061 options?.selection,
6062 );
6063 }
6064 if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) {
6065 const attached = storedFiles[0]!;
6066 toastManager.add({
6067 type: "info",
6068 title: `Large paste attached as ${attached.name}`,
6069 description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${
6070 isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V"
6071 } to keep a large paste inline.`,
6072 data: { hideCopyButton: true },
6073 });
6074 }
6075 }
6076 if (acceptedImages.length === 0) return insertedAny;
6077
6078 pendingImageCompressionsRef.current.set(
6079 attachmentTargetKey,
6080 pendingCount + acceptedImages.length,
6081 );
6082 if (questionAttachmentTarget)
6083 changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length);
6084 try {
6085 const nextImages: ComposerImageAttachment[] = [];
6086 let compressionError: string | null = null;
6087 for (const file of acceptedImages) {
6088 // Images over the wire cap are downscaled to fit rather than
6089 // refused; files already within it pass through byte-for-byte.
6090 const compressed = await prepareImageForAttachment(
6091 file,
6092 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6093 );
6094 if (!compressed.ok) {
6095 compressionError =
6096 compressed.reason === "unreadable"
6097 ? `'${file.name}' could not be read as an image.`
6098 : `'${file.name}' is too large to attach, even after compression.`;
6099 continue;
6100 }
6101 const attachmentFile = compressed.file;
6102 const previewUrl = URL.createObjectURL(attachmentFile);
6103 nextImages.push({
6104 type: "image",
6105 id: randomUUID(),
6106 name: attachmentFile.name || "image",
6107 mimeType: attachmentFile.type,
6108 sizeBytes: attachmentFile.size,
6109 previewUrl,
6110 file: attachmentFile,
6111 });
6112 }
6113 if (
6114 questionAttachmentTarget &&
6115 !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget]
6116 ) {
6117 for (const image of nextImages) URL.revokeObjectURL(image.previewUrl);
6118 return false;
6119 }
6120 const storedImageIds = new Set(
6121 nextImages.length === 1 && nextImages[0]
6122 ? addComposerImage(nextImages[0])
6123 : nextImages.length > 1
6124 ? addComposerImagesToDraft(nextImages)
6125 : [],
6126 );
6127 const storedImages = nextImages.filter((image) => storedImageIds.has(image.id));
6128 if (storedImages.length > 0 && imageAttachmentsGetChips) {
6129 insertedAny =
6130 insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny;
6131 }
6132 // Only failures are reported here. Success must not pass `null`: by
6133 // now other work (a failed send, an overlapping paste) may have set a
6134 // thread error this call knows nothing about, and clearing it would
6135 // swallow that message.
6136 if (compressionError !== null) {
6137 setThreadError(threadId, compressionError);
6138 }
6139 } finally {
6140 if (questionAttachmentTarget)
6141 changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length);
6142 const remaining =
6143 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length;
6144 if (remaining > 0) {
6145 pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining);
6146 } else {
6147 pendingImageCompressionsRef.current.delete(attachmentTargetKey);
6148 }
6149 }
6150 return insertedAny;
6151 };
6152
6153 /**
6154 * Chips for freshly attached files land at the caret; when the editor cannot take
6155 * input (approval, pending questions) they are appended so the file is never invisible.
6156 * Images skip this when they land with no prose and the editor takes input:
6157 * the shelf thumbnail is enough.
6158 */
6159 const insertAttachmentReferences = (
6160 references: ReadonlyArray<ComposerContextReference>,
6161 selection?: { start: number; end: number },
6162 ): boolean => {
6163 if (references.length === 0) return false;
6164 // Question answers carry attachments beside the answer, never as chips. Falling back to
6165 // the thread prompt here would hide the file behind a reference the question never shows.
6166 if (questionAttachmentTarget) return false;
6167 if (selection) {
6168 const edit = inlineContextReferenceReplacement(promptRef.current, selection, references);
6169 return applyPromptReplacement(edit.start, edit.end, edit.text);
6170 }
6171 const text = references.map(formatInlineContextReference).join(" ");
6172 const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true });
6173 if (!inserted) {
6174 setPrompt(ensureInlineContextReferences(promptRef.current, references));
6175 }
6176 return true;
6177 };
6178
6179 const removeComposerImage = (imageId: string) => {
6180 const image = composerImagesRef.current.find((candidate) => candidate.id === imageId);
6181 const referenced = collectInlineContextIds(promptRef.current).includes(
6182 image ? imageContextReference(image).contextId : "",
6183 );
6184 if (!referenced) {
6185 removeComposerImageFromDraft(imageId);
6186 return;
6187 }
6188 const confirmation = requestConfirmDialog(
6189 `Remove ${image?.name ?? "this image"} from the message?\nIt is referenced in your text; removing it also removes every reference.`,
6190 { variant: "destructive" },
6191 );
6192 if (!confirmation) {
6193 removeComposerImageFromDraft(imageId);
6194 return;
6195 }
6196 void confirmation.then((confirmed) => {
6197 if (confirmed) removeComposerImageFromDraft(imageId);
6198 });
6199 };
6200
6201 // ------------------------------------------------------------------
6202 // Callbacks: paste / drag
6203 // ------------------------------------------------------------------
6204 const foldPastedText = (
6205 plainText: string,
6206 bypassAutoAttachment: boolean,
6207 selectionOverride?: { start: number; end: number },
6208 ): boolean => {
6209 // Editor answers keep pasted text in the answer, regardless of paste size.
6210 if (isLiteralPendingAnswer) return false;
6211 const questionCanAttach =
6212 pendingUserInputs.length === 0 ||
6213 (supportsQuestionAttachments &&
6214 activePendingProgress?.activeQuestion?.allowCustomAnswer !== false &&
6215 !activePendingIsResponding);
6216 const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS;
6217 const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange();
6218 const wouldExceedInputLimit = wouldTextPasteExceedLimit({
6219 valueLength: promptRef.current.length,
6220 selection: selection ?? { start: 0, end: 0 },
6221 textLength: plainText.length,
6222 maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
6223 });
6224 const shouldFold =
6225 pastedTextDisposition({
6226 text: plainText,
6227 bypassAutoAttachment,
6228 wouldExceedInputLimit,
6229 canAttach: true,
6230 }) === "attachment";
6231 if (!shouldFold) {
6232 return false;
6233 }
6234
6235 const canStageAttachment =
6236 Boolean(activeThreadId) &&
6237 !isRevertingCheckpointRef.current &&
6238 questionCanAttach &&
6239 hasAttachmentSlot;
6240 if (!canStageAttachment || fileStagingLimit === null) {
6241 if (!wouldExceedInputLimit) {
6242 return false;
6243 }
6244 toastManager.add({
6245 type: "error",
6246 title: "Pasted text is too large for this message",
6247 description: "Remove some text or an attachment, then paste again.",
6248 data: { hideCopyButton: true },
6249 });
6250 return true;
6251 }
6252
6253 if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) {
6254 pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() };
6255 }
6256 const reservedNames = pastedTextFileNamesRef.current.names;
6257 for (const file of composerFilesRef.current) reservedNames.add(file.name);
6258 const foldedFileName = nextPastedTextFileName([...reservedNames]);
6259 reservedNames.add(foldedFileName);
6260 const foldedFile = new File([plainText], foldedFileName, {
6261 type: "text/plain;charset=utf-8",
6262 });
6263 if (foldedFile.size > fileStagingLimit) {
6264 reservedNames.delete(foldedFileName);
6265 if (!wouldExceedInputLimit) return false;
6266 toastManager.add({
6267 type: "error",
6268 title: "Pasted text is too large to attach",
6269 description: "Reduce the clipboard contents or save a smaller excerpt as a file.",
6270 data: { hideCopyButton: true },
6271 });
6272 return true;
6273 }
6274
6275 void addComposerAttachments([foldedFile], {
6276 source: { _tag: "pasted-text" },
6277 ...(selection ? { selection } : {}),
6278 });
6279 return true;
6280 };
6281
6282 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
6283 const files = Array.from(event.clipboardData.files);
6284 const plainText = event.clipboardData.getData("text/plain");
6285 const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current;
6286 pasteAsTextShortcutUntilRef.current = 0;
6287 // Claimable pastes go through even when agent questions are pending or the
6288 // composer is at its attachment limit: `addComposerAttachments` surfaces
6289 // those as a toast and a thread error. An early return here would swallow
6290 // the paste with no feedback.
6291 if (files.length > 0 && shouldHandleComposerAttachmentPaste({ files, plainText })) {
6292 event.preventDefault();
6293 event.stopPropagation();
6294 void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment });
6295 return;
6296 }
6297
6298 // Copied T3 chips need the structured importer to bring their records and files along.
6299 if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return;
6300 if (!foldPastedText(plainText, bypassAutoAttachment)) {
6301 return;
6302 }
6303
6304 event.preventDefault();
6305 event.stopPropagation();
6306 };
6307
6308 const insertComposerText = useCallback(
6309 (
6310 text: string,
6311 position: "cursor" | "end",
6312 options?: {
6313 ensureLeadingBoundary?: boolean;
6314 citationCommentAnchor?: AssistantCitationSourceAnchor;
6315 clipboardData?: DataTransfer;
6316 },
6317 ): boolean => {
6318 if (
6319 text.length === 0 ||
6320 isConnecting ||
6321 isComposerApprovalState ||
6322 pendingUserInputs.length > 0 ||
6323 projectSelectionRequired ||
6324 (options?.citationCommentAnchor && !composerEditorRef.current)
6325 ) {
6326 return false;
6327 }
6328 if (options?.clipboardData) {
6329 text = importPastedComposerText(options.clipboardData, importContextFragment);
6330 }
6331 const prompt = promptRef.current;
6332 const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length;
6333 const needsLeadingSpace =
6334 (options?.ensureLeadingBoundary ?? false) &&
6335 cursor > 0 &&
6336 !/\s/.test(prompt[cursor - 1] ?? "");
6337 const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text);
6338 return applyPromptReplacement(
6339 cursor,
6340 rangeEnd,
6341 needsLeadingSpace ? ` ${text}` : text,
6342 options?.citationCommentAnchor
6343 ? {
6344 citationComment: {
6345 start: cursor + (needsLeadingSpace ? 1 : 0),
6346 sourceAnchor: options.citationCommentAnchor,
6347 // An existing space after the cursor is consumed and re-emitted, so it stays.
6348 insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor },
6349 },
6350 focusEditorAfterReplace: false,
6351 }
6352 : undefined,
6353 );
6354 },
6355 [
6356 applyPromptReplacement,
6357 isComposerApprovalState,
6358 isConnecting,
6359 pendingUserInputs.length,
6360 projectSelectionRequired,
6361 promptRef,
6362 readComposerSnapshot,
6363 importContextFragment,
6364 ],
6365 );
6366
6367 const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>(
6368 (text, options) => {
6369 const inserted = insertComposerText(text, "end", options);
6370 if (inserted && isComposerCollapsedMobile) {
6371 // The expanded editor is hidden at phone widths, so its scheduled
6372 // focus cannot expand the composer by itself. Reveal it before the
6373 // focus frame runs to preserve type-to-focus and external inserts.
6374 expandMobileComposer();
6375 }
6376 return inserted;
6377 },
6378 [expandMobileComposer, insertComposerText, isComposerCollapsedMobile],
6379 );
6380
6381 // Context produced by other panels (diff comments, preview picks) asks the store to place
6382 // its chip; while this composer is mounted for the draft, that means the caret.
6383 const insertContextReferencesAtCaret = useCallback(
6384 (references: ReadonlyArray<ComposerContextReference>): boolean =>
6385 insertComposerText(`${references.map(formatInlineContextReference).join(" ")} `, "cursor", {
6386 ensureLeadingBoundary: true,
6387 }),
6388 [insertComposerText],
6389 );
6390 const setContextInsertionHandler = useComposerDraftStore(
6391 (store) => store.setContextInsertionHandler,
6392 );
6393 useEffect(() => {
6394 return setContextInsertionHandler(composerDraftTarget, insertContextReferencesAtCaret);
6395 }, [composerDraftTarget, insertContextReferencesAtCaret, setContextInsertionHandler]);
6396
6397 // File-tree drags land as mentions. Handled in the capture phase so the
6398 // editor never sees the drop; the load-bearing rules (native stop, "move"
6399 // effect, no eager focus) live in makeComposerMentionDragHandlers.
6400 const composerMentionDragHandlers = makeComposerMentionDragHandlers({
6401 insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }),
6402 setDragActive: setIsDragOverComposer,
6403 onInsertRejected: () => {
6404 toastManager.add({
6405 type: "error",
6406 title: "Unable to add to chat",
6407 description: "The composer is busy; try again once it is ready.",
6408 });
6409 },
6410 });
6411
6412 const attachDroppedThreadFile = useEffectEvent((file: File) => addComposerAttachments([file]));
6413 const importDroppedThread = useEffectEvent(
6414 async (ref: ScopedThreadRef, isActive: () => boolean) => {
6415 const targetKey = attachmentTargetKey;
6416 pendingDraftWork.begin(targetKey);
6417 const loadingToast = toastManager.add({
6418 type: "loading",
6419 title: "Loading thread context…",
6420 timeout: 0,
6421 data: { threadRef: routeThreadRef, hideCopyButton: true },
6422 });
6423 try {
6424 await importComposerThreadAttachment({
6425 targetKey,
6426 pendingImports: pendingThreadImportsRef.current,
6427 countReservedAttachments,
6428 load: async () => {
6429 const result = await loadThreadTranscript({
6430 environmentId: ref.environmentId,
6431 input: { threadId: ref.threadId },
6432 });
6433 if (result._tag !== "Success") {
6434 throw squashAtomCommandFailure(result);
6435 }
6436 return threadContextAttachment(ref.environmentId, result.value);
6437 },
6438 isActive,
6439 attach: attachDroppedThreadFile,
6440 onLimitReached: () => {
6441 if (activeThreadId) {
6442 setThreadError(
6443 activeThreadId,
6444 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
6445 );
6446 }
6447 },
6448 });
6449 } catch (error) {
6450 if (!isActive()) return;
6451 toastManager.add({
6452 type: "error",
6453 title: "Unable to read the dropped thread",
6454 description:
6455 isEnvironmentRequestInvalidError(error) &&
6456 error.reason === "thread_transcript_too_large"
6457 ? "This thread exceeds the attachment size limit. Try a shorter thread."
6458 : "Check that its environment is connected and up to date, then try again.",
6459 });
6460 } finally {
6461 toastManager.close(loadingToast);
6462 pendingDraftWork.end(targetKey);
6463 }
6464 },
6465 );
6466
6467 useThreadContextDrop(composerFormRef, environmentId, attachmentTargetKey, (refs, isActive) => {
6468 const records = refs.flatMap((ref) => {
6469 if (ref.environmentId !== environmentId) {
6470 void importDroppedThread(
6471 ref,
6472 () => isActive() && attachmentTargetKeyRef.current === attachmentTargetKey,
6473 );
6474 return [];
6475 }
6476 const shell = readThreadShell(ref);
6477 return shell ? [threadContextRecord(ref, shell.title)] : [];
6478 });
6479 if (records.length === 0) return;
6480 if (questionAttachmentTarget !== null) {
6481 const canAnswer =
6482 canOperateThread &&
6483 !isConnecting &&
6484 !isComposerApprovalState &&
6485 !activePendingIsResponding &&
6486 activePendingProgress?.activeQuestion?.allowCustomAnswer !== false;
6487 const text = `${records
6488 .map((record) => formatInlineContextReference(threadContextReference(record)))
6489 .join(" ")} `;
6490 const snapshot = readComposerSnapshot();
6491 const cursor = snapshot.expandedCursor;
6492 const leadingSpace = cursor > 0 && !/\s/.test(snapshot.value[cursor - 1] ?? "");
6493 const inserted =
6494 canAnswer &&
6495 applyPromptReplacement(
6496 cursor,
6497 extendReplacementRangeForTrailingSpace(snapshot.value, cursor, text),
6498 leadingSpace ? ` ${text}` : text,
6499 );
6500 if (!inserted) {
6501 toastManager.add({
6502 type: "error",
6503 title: "Unable to add thread",
6504 description: "The composer is busy; try again once it is ready.",
6505 });
6506 return;
6507 }
6508 addComposerDraftThreadContexts(questionAttachmentTarget, records, {
6509 appendReference: false,
6510 });
6511 if (isComposerCollapsedMobile) expandMobileComposer();
6512 } else {
6513 addComposerDraftThreadContexts(composerDraftTarget, records);
6514 }
6515 });
6516
6517 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {
6518 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
6519 event.stopPropagation();
6520 const nextTarget = event.relatedTarget;
6521 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
6522 setIsDragOverComposer(false);
6523 };
6524
6525 // A cancelled drag (Escape) can end without a dragleave on the hovered
6526 // target, which would leave the drop highlight stuck. dragend always fires
6527 // on the in-page drag source and bubbles to window, so it is the reset of
6528 // last resort while the highlight is up.
6529 useEffect(() => {
6530 if (!isDragOverComposer) return;
6531 const onWindowDragEnd = () => {
6532 setIsDragOverComposer(false);
6533 };
6534 window.addEventListener("dragend", onWindowDragEnd);
6535 return () => window.removeEventListener("dragend", onWindowDragEnd);
6536 }, [isDragOverComposer]);
6537 const handleInterruptPrimaryAction = useCallback(() => {
6538 void onInterrupt();
6539 }, [onInterrupt]);
6540 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
6541 void onImplementPlanInNewThread();
6542 }, [onImplementPlanInNewThread]);
6543 // The phone composer collapses when the editor loses focus. Desktop only
6544 // rests on a timeline scroll, so losing focus there changes nothing.
6545 const scheduleComposerCollapseCheck = useCallback(() => {
6546 if (!isMobileViewport || mobileComposerExpandInFlightRef.current) {
6547 return;
6548 }
6549 if (composerBlurFrameRef.current !== null) {
6550 window.cancelAnimationFrame(composerBlurFrameRef.current);
6551 }
6552 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
6553 composerBlurFrameRef.current = null;
6554 if (mobileComposerExpandInFlightRef.current) {
6555 return;
6556 }
6557 const composerSurface = composerSurfaceRef.current;
6558 const composerForm = composerFormRef.current;
6559 const activeElement = document.activeElement;
6560 if (isInsideRestingComposerControlScope(activeElement)) {
6561 return;
6562 }
6563 if (
6564 activeElement instanceof Node &&
6565 ((composerSurface && composerSurface.contains(activeElement)) ||
6566 (composerForm && composerForm.contains(activeElement)))
6567 ) {
6568 return;
6569 }
6570 setIsComposerFocused(false);
6571 });
6572 }, [isMobileViewport, setIsComposerFocused]);
6573
6574 useEffect(() => {
6575 return () => {
6576 if (composerBlurFrameRef.current !== null) {
6577 window.cancelAnimationFrame(composerBlurFrameRef.current);
6578 }
6579 if (mobileComposerExpandFrameRef.current !== null) {
6580 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
6581 }
6582 if (mobileComposerExpandReleaseFrameRef.current !== null) {
6583 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
6584 }
6585 };
6586 }, []);
6587
6588 // ------------------------------------------------------------------
6589 // Imperative handle
6590 // ------------------------------------------------------------------
6591 const openModelPicker = useCallback(() => {
6592 if (composerControlsHidden) {
6593 if (composerBlurFrameRef.current !== null) {
6594 window.cancelAnimationFrame(composerBlurFrameRef.current);
6595 composerBlurFrameRef.current = null;
6596 }
6597 setIsComposerScrollCollapsed(false);
6598 setIsComposerFocused(true);
6599 }
6600 setIsComposerModelPickerOpen(true);
6601 }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]);
6602
6603 useImperativeHandle(
6604 composerRef,
6605 () => ({
6606 focusAtEnd: () => {
6607 composerEditorRef.current?.focusAtEnd();
6608 },
6609 focusAt: (cursor: number) => {
6610 composerEditorRef.current?.focusAt(cursor);
6611 },
6612 restoreAfterTimelineReachedEnd,
6613 collapseForTimelineScrollKey: (key) => {
6614 const scrollNode = getTimelineScrollableNode();
6615 if (
6616 composerScrollCollapseEligibleRef.current &&
6617 scrollNode &&
6618 shouldCollapseComposerForScrollKey({
6619 key,
6620 scrollTop: scrollNode.scrollTop,
6621 scrollHeight: scrollNode.scrollHeight,
6622 clientHeight: scrollNode.clientHeight,
6623 isAtLogicalEnd: isTimelineAtLogicalEnd(),
6624 })
6625 ) {
6626 setIsComposerScrollCollapsed(true);
6627 }
6628 },
6629 addDroppedFiles: (files: File[]) => {
6630 void addComposerAttachments(files).then((inserted) => {
6631 if (!inserted) focusComposer();
6632 });
6633 },
6634 addDroppedFolders: (folders: File[]) => {
6635 const target = folderDropTarget({
6636 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
6637 environmentId,
6638 primaryEnvironmentId,
6639 });
6640 if (target === "remote") {
6641 toastManager.add({
6642 type: "error",
6643 title: "Folders can't be dropped into remote environments",
6644 });
6645 return;
6646 }
6647 for (const folder of folders) {
6648 const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile);
6649 if (path === null) {
6650 toastManager.add({
6651 type: "error",
6652 title: `Couldn't get the path of "${folder.name}"`,
6653 description: "Type the folder path with @ instead.",
6654 });
6655 continue;
6656 }
6657 insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, {
6658 ensureLeadingBoundary: true,
6659 });
6660 }
6661 focusComposer();
6662 },
6663 hasPendingAttachments: () =>
6664 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 ||
6665 pendingDraftWork.has(attachmentTargetKey),
6666 insertTextAtEnd: insertComposerTextAtEnd,
6667 pasteTextAtEnd: (text: string, options) => {
6668 const bypassAutoAttachment =
6669 options?.bypassAutoAttachment === true ||
6670 Date.now() <= pasteAsTextShortcutUntilRef.current;
6671 pasteAsTextShortcutUntilRef.current = 0;
6672 const promptLength = promptRef.current.length;
6673 if (
6674 !foldPastedText(text, bypassAutoAttachment, {
6675 start: promptLength,
6676 end: promptLength,
6677 })
6678 ) {
6679 return false;
6680 }
6681 focusComposer();
6682 return true;
6683 },
6684 citeAssistantText: (citation, sourceAnchor) =>
6685 insertComposerText(
6686 formatAssistantCitationForComposer(citation, citation.comment),
6687 "cursor",
6688 { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
6689 ),
6690 openModelPicker,
6691 toggleModelPicker: () => {
6692 if (isComposerModelPickerOpen) {
6693 setIsComposerModelPickerOpen(false);
6694 } else {
6695 openModelPicker();
6696 }
6697 },
6698 openControl: (command) => {
6699 if (composerBlurFrameRef.current !== null) {
6700 window.cancelAnimationFrame(composerBlurFrameRef.current);
6701 composerBlurFrameRef.current = null;
6702 }
6703 flushSync(() => {
6704 setIsComposerScrollCollapsed(false);
6705 setIsComposerFocused(true);
6706 });
6707 const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]');
6708 const trigger = Array.from(
6709 shell?.querySelectorAll<HTMLButtonElement>(
6710 `button[data-composer-shortcut~="${command}"]:not(:disabled)`,
6711 ) ?? [],
6712 ).find(
6713 (element) =>
6714 !element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }),
6715 );
6716 if (!trigger) return;
6717 trigger.focus({ preventScroll: true });
6718 trigger.click();
6719 },
6720 compactContext: compactThreadContext,
6721 isModelPickerOpen: () => isComposerModelPickerOpen,
6722 isCaretAtStart: () => {
6723 const range = composerEditorRef.current?.readSelectionRange();
6724 return range !== undefined && range.start === 0 && range.end === 0;
6725 },
6726 readSnapshot: () => {
6727 return readComposerSnapshot();
6728 },
6729 resetCursorState: (options?: {
6730 cursor?: number;
6731 prompt?: string;
6732 detectTrigger?: boolean;
6733 }) => {
6734 const promptForState = options?.prompt ?? promptRef.current;
6735 const cursor = clampCollapsedComposerCursor(
6736 promptForState,
6737 options?.cursor ?? 0,
6738 isLiteralPendingAnswer,
6739 );
6740 setComposerHighlightedItemId(null);
6741 setComposerCursor(cursor);
6742 resetComposerTrigger(
6743 options?.detectTrigger && !isLiteralPendingAnswer
6744 ? detectComposerTrigger(
6745 promptForState,
6746 expandCollapsedComposerCursor(promptForState, cursor),
6747 )
6748 : null,
6749 );
6750 },
6751 addTerminalContext: (selection: TerminalContextSelection) => {
6752 if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return;
6753 const snapshot = readComposerSnapshot();
6754 const context = {
6755 id: randomUUID(),
6756 threadId: activeThread.id,
6757 createdAt: new Date().toISOString(),
6758 ...selection,
6759 };
6760 const insertion = insertInlineContextReference(
6761 snapshot.value,
6762 snapshot.expandedCursor,
6763 terminalContextReference(context),
6764 );
6765 const nextCollapsedCursor = collapseExpandedComposerCursor(
6766 insertion.prompt,
6767 insertion.cursor,
6768 );
6769 const inserted = insertComposerDraftTerminalContext(
6770 composerDraftTarget,
6771 insertion.prompt,
6772 context,
6773 composerTerminalContexts.length,
6774 );
6775 if (!inserted) return;
6776 promptRef.current = insertion.prompt;
6777 setComposerCursor(nextCollapsedCursor);
6778 setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
6779 window.requestAnimationFrame(() => {
6780 composerEditorRef.current?.focusAt(nextCollapsedCursor);
6781 });
6782 },
6783 getSendContext: () => ({
6784 answeringPendingUserInput: activePendingProgress !== null,
6785 prompt: promptRef.current,
6786 images: composerImagesRef.current,
6787 files: composerFilesRef.current,
6788 terminalContexts: composerTerminalContextsRef.current,
6789 previewAnnotations: composerPreviewAnnotations,
6790 reviewComments: composerReviewComments,
6791 threadContexts: composerThreadContexts,
6792 selectedPromptEffort,
6793 selectedModelOptionsForDispatch,
6794 selectedModelSelection,
6795 multipleModelSelections:
6796 routeKind === "draft" && multipleModelSelections !== null
6797 ? multipleModelSelections.map((selection) =>
6798 selection.instanceId === selectedModelSelection.instanceId &&
6799 selection.model === selectedModelSelection.model
6800 ? selectedModelSelection
6801 : selection,
6802 )
6803 : null,
6804 providerAvailable:
6805 multipleModelSelections !== null ||
6806 (!noProviderAvailable && providerSendBlockReason === null),
6807 selectedProvider,
6808 selectedModel,
6809 selectedProviderModels,
6810 interactionMode,
6811 interactionModeEnabled: planModeUiEnabled,
6812 }),
6813 setMultipleModelSelections,
6814 validateProviderInput: (providerInput: string) => {
6815 const validationMessage = getComposerSubmissionValidationMessage({
6816 prompt: promptRef.current,
6817 providerInput,
6818 submissionTarget: "provider-turn",
6819 });
6820 providerInputRejectedRef.current = validationMessage !== null;
6821 setProviderInputSubmissionError(validationMessage);
6822 return validationMessage === null;
6823 },
6824 }),
6825 [
6826 activeThread,
6827 addComposerAttachments,
6828 foldPastedText,
6829 composerDraftTarget,
6830 composerCursor,
6831 activePendingProgress,
6832 composerTerminalContexts,
6833 insertComposerDraftTerminalContext,
6834 insertComposerText,
6835 insertComposerTextAtEnd,
6836 promptRef,
6837 composerImagesRef,
6838 composerFilesRef,
6839 composerTerminalContextsRef,
6840 composerPreviewAnnotations,
6841 composerReviewComments,
6842 focusComposer,
6843 environmentId,
6844 primaryEnvironmentId,
6845 isConnecting,
6846 isComposerApprovalState,
6847 isChoiceOnlyPendingQuestion,
6848 isLiteralPendingAnswer,
6849 pendingUserInputs.length,
6850 projectSelectionRequired,
6851 applyPromptReplacement,
6852 isComposerModelPickerOpen,
6853 openModelPicker,
6854 readComposerSnapshot,
6855 resetComposerTrigger,
6856 setComposerTrigger,
6857 selectedModel,
6858 selectedModelOptionsForDispatch,
6859 selectedModelSelection,
6860 multipleModelSelections,
6861 setMultipleModelSelections,
6862 routeKind,
6863 noProviderAvailable,
6864 providerSendBlockReason,
6865 selectedPromptEffort,
6866 selectedProvider,
6867 selectedProviderModels,
6868 interactionMode,
6869 planModeUiEnabled,
6870 compactThreadContext,
6871 restoreAfterTimelineReachedEnd,
6872 getTimelineScrollableNode,
6873 isTimelineAtLogicalEnd,
6874 setIsComposerScrollCollapsed,
6875 ],
6876 );
6877
6878 // Render
6879 // ------------------------------------------------------------------
6880 return (
6881 <form
6882 ref={composerFormRef}
6883 onSubmit={submitComposer}
6884 onPointerDownCapture={(event) => {
6885 const target = event.target;
6886 if (isInsideRestingComposerControlScope(target)) return;
6887 if (isInsideCollapsedComposerControls(target)) return;
6888 if (!(target instanceof Element)) return;
6889 const isInteractive = Boolean(
6890 target.closest('button, a, input, select, [role="button"], [role="menuitem"]'),
6891 );
6892 if (isInteractive) return;
6893
6894 setIsComposerScrollCollapsed(false);
6895 if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) {
6896 // Clicking resting-surface padding would otherwise blur the editor.
6897 // Treat that padding like the editor without stealing native caret
6898 // placement from text.
6899 event.preventDefault();
6900 setIsComposerFocused(true);
6901 scheduleComposerFocus();
6902 }
6903 }}
6904 onFocusCapture={(event) => {
6905 const activeElement = event.target;
6906 if (
6907 activeElement instanceof Element &&
6908 activeElement.closest('[data-testid="composer-editor"]')
6909 ) {
6910 onTypingGuardFocus();
6911 }
6912 if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) {
6913 return;
6914 }
6915 if (isInsideCollapsedComposerControls(activeElement)) {
6916 return;
6917 }
6918 // Focus returning from another window or tab lands on the element
6919 // that already held it, which is not a request to expand a
6920 // scroll-collapsed composer.
6921 if (!windowRefocusInFlightRef.current) {
6922 setIsComposerScrollCollapsed(false);
6923 }
6924 if (composerBlurFrameRef.current !== null) {
6925 window.cancelAnimationFrame(composerBlurFrameRef.current);
6926 composerBlurFrameRef.current = null;
6927 }
6928 setIsComposerFocused(true);
6929 }}
6930 onBlurCapture={(event) => {
6931 if (
6932 event.target instanceof Element &&
6933 event.target.closest('[data-testid="composer-editor"]')
6934 ) {
6935 onTypingGuardBlur();
6936 }
6937 scheduleComposerCollapseCheck();
6938 }}
6939 onDragEnterCapture={(event) => {
6940 if (isInsideRestingComposerControlScope(event.target)) return;
6941 composerMentionDragHandlers.onDragEnter(event);
6942 }}
6943 onDragOverCapture={(event) => {
6944 if (isInsideRestingComposerControlScope(event.target)) return;
6945 composerMentionDragHandlers.onDragOver(event);
6946 }}
6947 onDragLeaveCapture={(event) => {
6948 if (isInsideRestingComposerControlScope(event.target)) return;
6949 onComposerMentionDragLeaveCapture(event);
6950 }}
6951 onDropCapture={(event) => {
6952 if (isInsideRestingComposerControlScope(event.target)) return;
6953 composerMentionDragHandlers.onDrop(event);
6954 }}
6955 className="mx-auto w-full min-w-0 max-w-(--chat-content-max-width)"
6956 data-chat-composer-form="true"
6957 {...threadContextDropTargetProps()}
6958 >
6959 {composerControlsCollapsed && restingControlsHost
6960 ? createPortal(
6961 <div
6962 ref={attachRestingComposerControls}
6963 data-chat-composer-resting-controls="true"
6964 aria-hidden={restingControlsVisible ? undefined : true}
6965 inert={restingControlsVisible ? undefined : true}
6966 className={cn(
6967 "relative flex w-max min-w-0 max-w-full items-center gap-1 font-normal text-muted-foreground/70 [&_button]:text-xs!",
6968 !restingControlsVisible && "invisible",
6969 )}
6970 >
6971 {composerControls}
6972 </div>,
6973 restingControlsHost,
6974 )
6975 : null}
6976 <ComposerBanner.Dock>
6977 <ComposerBanner.Column>
6978 <ComposerBannerStack
6979 key={activeThreadId}
6980 className="relative z-0"
6981 items={bannerStackItems}
6982 attachedAbove={props.queuedRunsControl}
6983 />
6984 {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (
6985 <ComposerBanner.Attachment>
6986 <ComposerBanner.Root data-chat-composer-activity-strip="true">
6987 {shownSyncPhase ? <ComposerActivityRow phase={shownSyncPhase} /> : inlineTasksBadge}
6988 </ComposerBanner.Root>
6989 </ComposerBanner.Attachment>
6990 ) : null}
6991 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
6992 <ComposerBanner.Attachment>
6993 <ComposerBanner.Root
6994 data-chat-composer-top-drawer="true"
6995 variant={activePendingApproval ? "warning" : "info"}
6996 density={activePendingApproval ? "spacious" : "default"}
6997 >
6998 {activePendingApproval ? (
6999 <ComposerBanner.Row
7000 layout="approval"
7001 data-chat-composer-collapsed-controls="true"
7002 >
7003 <ComposerBanner.Icon>
7004 <ShieldIcon />
7005 </ComposerBanner.Icon>
7006 <ComposerBanner.Content>
7007 <ComposerPendingApprovalPanel
7008 approval={activePendingApproval}
7009 pendingCount={pendingApprovals.length}
7010 />
7011 </ComposerBanner.Content>
7012 <ComposerBanner.Actions>
7013 <ComposerPendingApprovalActions
7014 requestId={activePendingApproval.requestId}
7015 disabled={!canOperateThread}
7016 isResponding={respondingRequestIds.includes(
7017 activePendingApproval.requestId,
7018 )}
7019 options={activePendingApproval.options}
7020 canRespond={activePendingApproval.responseCapability === "live"}
7021 onRespondToApproval={onRespondToApproval}
7022 />
7023 </ComposerBanner.Actions>
7024 </ComposerBanner.Row>
7025 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
7026 <ComposerPendingUserInputPanel
7027 pendingUserInputs={pendingUserInputs}
7028 cwd={gitCwd ?? undefined}
7029 threadRef={routeThreadRef}
7030 disabled={!canOperateThread}
7031 respondingRequestIds={
7032 activePendingIsResponding && activePendingUserInput
7033 ? [...respondingRequestIds, activePendingUserInput.requestId]
7034 : respondingRequestIds
7035 }
7036 answers={activePendingDraftAnswers}
7037 questionIndex={activePendingQuestionIndex}
7038 onToggleOption={onSelectActivePendingUserInputOption}
7039 onAdvance={onAdvanceActivePendingUserInput}
7040 onDismiss={onDismissActivePendingUserInput}
7041 />
7042 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
7043 <ComposerPlanFollowUpBanner
7044 key={activeProposedPlan.id}
7045 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
7046 />
7047 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
7048 <div data-chat-composer-collapsed-controls="true">
7049 <ComposerPendingUserInputPanel
7050 pendingUserInputs={pendingUserInputs}
7051 cwd={gitCwd ?? undefined}
7052 threadRef={routeThreadRef}
7053 disabled={!canOperateThread}
7054 respondingRequestIds={
7055 activePendingIsResponding && activePendingUserInput
7056 ? [...respondingRequestIds, activePendingUserInput.requestId]
7057 : respondingRequestIds
7058 }
7059 answers={activePendingDraftAnswers}
7060 questionIndex={activePendingQuestionIndex}
7061 onToggleOption={onSelectActivePendingUserInputOption}
7062 onAdvance={onAdvanceActivePendingUserInput}
7063 onDismiss={onDismissActivePendingUserInput}
7064 />
7065 {!isChoiceOnlyPendingQuestion ||
7066 activePendingProgress?.activeQuestion?.multiSelect ? (
7067 <ComposerBanner.Body>
7068 <div
7069 data-chat-composer-mobile-pending-compact="true"
7070 className={cn(
7071 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
7072 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
7073 )}
7074 >
7075 {!isChoiceOnlyPendingQuestion ? (
7076 <button
7077 type="button"
7078 className={cn(
7079 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
7080 activePendingProgress?.customAnswer
7081 ? "text-foreground"
7082 : "text-placeholder",
7083 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
7084 )}
7085 onPointerDown={(event) => event.preventDefault()}
7086 onClick={expandMobileComposer}
7087 aria-label="Write custom answer"
7088 >
7089 {activePendingProgress?.customAnswer || "Write custom answer"}
7090 </button>
7091 ) : null}
7092 {activePendingProgress?.activeQuestion?.multiSelect ? (
7093 <ComposerPrimaryActions
7094 compact
7095 canOperateThread={canOperateThread}
7096 pendingAction={pendingPrimaryAction}
7097 isRunning={false}
7098 canInterrupt={false}
7099 showPlanFollowUpPrompt={false}
7100 promptHasText={false}
7101 isSendBusy={isSendBusy}
7102 sendDisabledReason={sendDisabledReason}
7103 isConnecting={isConnecting}
7104 isEnvironmentUnavailable={
7105 environmentUnavailable !== null ||
7106 noProviderAvailable ||
7107 projectSelectionRequired
7108 }
7109 isPreparingWorktree={false}
7110 hasSendableContent={false}
7111 preserveComposerFocusOnPointerDown
7112 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
7113 onInterrupt={handleInterruptPrimaryAction}
7114 onImplementPlanInNewThread={
7115 handleImplementPlanInNewThreadPrimaryAction
7116 }
7117 />
7118 ) : null}
7119 </div>
7120 </ComposerBanner.Body>
7121 ) : null}
7122 </div>
7123 ) : null}
7124 </ComposerBanner.Root>
7125 </ComposerBanner.Attachment>
7126 ) : null}
7127 {!activityStackItem &&
7128 isTasksDrawerOpen &&
7129 !hasBlockingComposerTopDrawer &&
7130 activeTasksProgress &&
7131 activeTaskSteps ? (
7132 <ComposerTasksDrawer
7133 onCollapse={toggleTasksDrawer}
7134 progress={activeTasksProgress}
7135 steps={activeTaskSteps}
7136 />
7137 ) : null}
7138 {showTasksTab ? (
7139 <ComposerBanner.Attachment>
7140 <ComposerTasksBadge
7141 expanded={false}
7142 onToggle={toggleTasksDrawer}
7143 progress={activeTasksProgress}
7144 steps={activeTaskSteps}
7145 />
7146 </ComposerBanner.Attachment>
7147 ) : null}
7148 </ComposerBanner.Column>
7149 {!isComposerApprovalState ? (
7150 <ComposerStashBadge
7151 count={stashQueue.length}
7152 menuOpen={isStashMenuOpen}
7153 pulseKey={stashPulse.key}
7154 pulsing={stashPulse.active}
7155 onToggleMenu={toggleStashMenu}
7156 />
7157 ) : null}
7158 </ComposerBanner.Dock>
7159 <div className="relative">
7160 <ComposerSurface.Main
7161 ref={composerMainSurfaceRef}
7162 data-inline-resting-controls={restingControlsHost === null ? "true" : undefined}
7163 data-model-only-strip={
7164 restingControlsHost?.closest("[data-composer-model-strip]") ? "true" : undefined
7165 }
7166 className={composerProviderState.composerFrameClassName}
7167 >
7168 <div
7169 ref={composerSurfaceRef}
7170 data-chat-composer-surface="true"
7171 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
7172 className={cn(
7173 "rounded-3xl transition-[background-color] duration-200",
7174 "in-data-[thread-context-over]:bg-accent/45 in-data-[thread-context-over]:ring-1 in-data-[thread-context-over]:ring-inset in-data-[thread-context-over]:ring-primary/70",
7175 isDragOverComposer ? "bg-accent/45 ring-1 ring-inset ring-primary/70" : null,
7176 projectSelectionRequired ? "opacity-75" : null,
7177 composerProviderState.composerSurfaceClassName,
7178 )}
7179 >
7180 {showCollapsedMobilePromptRow ? (
7181 <div className="flex items-center justify-between gap-2 px-3 py-2">
7182 <button
7183 type="button"
7184 data-chat-composer-transition-prompt="true"
7185 className={cn(
7186 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-sm focus:outline-none",
7187 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
7188 ? "text-foreground"
7189 : "text-placeholder",
7190 )}
7191 onPointerDown={(event) => event.preventDefault()}
7192 onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer}
7193 disabled={isChoiceOnlyPendingQuestion}
7194 aria-label="Expand composer"
7195 >
7196 {activePendingProgress
7197 ? isChoiceOnlyPendingQuestion
7198 ? "Choose an option above"
7199 : activePendingProgress.customAnswer ||
7200 "Type your own answer, or leave this blank to use the selected option"
7201 : prompt.trim() ||
7202 (showProviderUnavailable
7203 ? "Enable a provider in Settings"
7204 : "Ask anything...")}
7205 </button>
7206 {collapsedComposerImagePreviews}
7207 <button
7208 type="button"
7209 data-chat-composer-transition-actions="true"
7210 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-64"
7211 disabled={collapsedComposerPrimaryActionDisabled}
7212 aria-label={collapsedComposerPrimaryActionLabel}
7213 onPointerDown={(event) => event.preventDefault()}
7214 onClick={(event) => {
7215 event.stopPropagation();
7216 if (showResumeAction) onResume();
7217 // Compacting first only sends from the expanded composer, where the chip shows it.
7218 else if (props.resumeCompactionTokens !== null && !props.keepFullHistory)
7219 expandMobileComposer();
7220 else submitComposer();
7221 }}
7222 >
7223 {showResumeAction ? (
7224 <PlayIcon className="size-4 fill-current" aria-hidden="true" />
7225 ) : (
7226 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
7227 <path
7228 d="M8 3L8 13M8 3L4 7M8 3L12 7"
7229 stroke="currentColor"
7230 strokeWidth="2"
7231 strokeLinecap="round"
7232 strokeLinejoin="round"
7233 />
7234 </svg>
7235 )}
7236 </button>
7237 </div>
7238 ) : null}
7239
7240 <div
7241 ref={setComposerMenuAnchor}
7242 data-chat-composer-body="true"
7243 className={cn(
7244 "relative px-3 pb-2 sm:px-4",
7245 "pt-3.5 sm:pt-4",
7246 isComposerApprovalState && "pb-3 sm:pb-4",
7247 isComposerCollapsedMobile && "hidden",
7248 isComposerResting && "py-2 sm:py-2",
7249 )}
7250 >
7251 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
7252 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
7253 <ComposerStashMenu
7254 entries={stashQueue}
7255 stashShortcutLabel={shortcutLabelForCommand(keybindings, "composer.stash", {
7256 context: {
7257 terminalFocus: false,
7258 terminalOpen,
7259 modelPickerOpen: false,
7260 },
7261 })}
7262 onRestore={restoreStashEntry}
7263 onDelete={deleteStashEntry}
7264 onClose={() => setIsStashMenuOpen(false)}
7265 />
7266 </ComposerCommandMenuLayer>
7267 )}
7268
7269 <div role="status" aria-atomic="true" className="sr-only">
7270 {composerSuggestionsVisible && composerMenuItems.length === 0
7271 ? isComposerMenuLoading
7272 ? composerTriggerKind === "emoji"
7273 ? "Loading emoji…"
7274 : composerTriggerKind === "pull-request"
7275 ? "Finding pull request..."
7276 : "Searching workspace files..."
7277 : composerMenuEmptyState
7278 : ""}
7279 </div>
7280 {composerSuggestionsVisible && (
7281 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
7282 <ComposerCommandMenu
7283 listId={composerSuggestionListId}
7284 items={composerMenuItems}
7285 resolvedTheme={resolvedTheme}
7286 isLoading={isComposerMenuLoading}
7287 triggerKind={composerTriggerKind}
7288 emptyStateText={composerMenuEmptyState}
7289 activeItemId={activeComposerMenuItem?.id ?? null}
7290 onHighlightedItemChange={onComposerMenuItemHighlighted}
7291 onSelect={onSelectComposerItem}
7292 />
7293 </ComposerCommandMenuLayer>
7294 )}
7295
7296 {!isComposerCollapsedMobile &&
7297 !isComposerApprovalState &&
7298 pendingUserInputs.length === 0 &&
7299 editingQueuedAttachments !== null &&
7300 editingQueuedAttachments.length > 0 && (
7301 <div className="mb-3 flex flex-wrap gap-2">
7302 {editingQueuedAttachments.map(({ attachment, url }) => (
7303 <div
7304 key={attachment.id}
7305 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
7306 >
7307 {attachment.type === "image" && url ? (
7308 <img
7309 src={url}
7310 alt={attachment.name}
7311 className="h-full w-full object-cover"
7312 />
7313 ) : (
7314 <div className="flex h-full w-full items-center justify-center px-1 text-center text-3xs text-secondary-label">
7315 {attachment.name}
7316 </div>
7317 )}
7318 <Button
7319 variant="media-close"
7320 size="icon-xs"
7321 className="absolute right-1 top-1"
7322 onClick={() => onRemoveEditingQueuedAttachment(attachment.id)}
7323 aria-label={`Remove ${attachment.name}`}
7324 >
7325 <XIcon />
7326 </Button>
7327 </div>
7328 ))}
7329 </div>
7330 )}
7331
7332 {!isComposerCollapsedMobile &&
7333 !isComposerApprovalState &&
7334 (uncommittedSnapShotIds.length > 0 ||
7335 composerVideos.length > 0 ||
7336 expandedComposerImages.length > 0) && (
7337 <div
7338 className={cn(
7339 "mb-3 flex max-w-full gap-2",
7340 pendingSnapShotIds.length > 0 ||
7341 expandedComposerImages.some((image) => image.source?.kind === "snap-shot")
7342 ? "snap-x snap-proximity overflow-x-auto overscroll-x-contain pb-1 scrollbar-thumb-foreground/18 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-3 [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-foreground/18 [&::-webkit-scrollbar-thumb]:bg-clip-content [&::-webkit-scrollbar-thumb:hover]:bg-foreground/28 [&::-webkit-scrollbar-track]:mx-1 [&::-webkit-scrollbar-track]:bg-transparent"
7343 : "flex-wrap",
7344 )}
7345 >
7346 {expandedComposerImages
7347 .map((image) => {
7348 const upload = supportsAttachmentUploads
7349 ? uploadsByImageId[image.id]
7350 : undefined;
7351 const snapShotAnimationPending =
7352 image.source?.kind === "snap-shot" && pendingSnapShotIdSet.has(image.id);
7353 const snapShotArrival =
7354 image.source?.kind === "snap-shot" &&
7355 shouldAnimateSnapShotArrival(image.source.capturedAt);
7356 return (
7357 <SnapShotAttachmentFrame
7358 key={image.id}
7359 data-chat-composer-expanded-image="true"
7360 aria-hidden={snapShotAnimationPending || undefined}
7361 inert={snapShotAnimationPending || undefined}
7362 arrival={snapShotArrival}
7363 animateArrival={
7364 settings.snapShotAnimations &&
7365 !snapShotAnimationPending &&
7366 snapShotArrival
7367 }
7368 animationId={snapShotAnimationPending ? image.id : undefined}
7369 animationSource={snapShotAnimationPending ? image.source : undefined}
7370 className={cn(
7371 "group/attachment shrink-0 snap-start bg-background",
7372 image.source?.kind === "snap-shot"
7373 ? SNAP_SHOT_ATTACHMENT_FRAME_CLASS
7374 : "relative h-16 w-16 overflow-hidden rounded-lg border border-border/80",
7375 snapShotAnimationPending && "invisible",
7376 )}
7377 >
7378 {image.previewUrl ? (
7379 <button
7380 type="button"
7381 className="h-full w-full cursor-zoom-in"
7382 aria-label={`Preview ${image.name}`}
7383 onClick={() => {
7384 const preview = buildExpandedImagePreview(
7385 composerImages,
7386 image.id,
7387 );
7388 if (!preview) return;
7389 onExpandImage(preview);
7390 }}
7391 >
7392 <ComposerImageThumbnail
7393 file={image.file}
7394 alt={image.name}
7395 className="h-full w-full object-cover"
7396 fallback={
7397 <span className="flex h-full items-center justify-center px-1 text-3xs text-secondary-label">
7398 {image.name}
7399 </span>
7400 }
7401 />
7402 </button>
7403 ) : (
7404 <div className="flex h-full w-full items-center justify-center px-1 text-center text-3xs text-secondary-label">
7405 {image.name}
7406 </div>
7407 )}
7408 {image.source?.kind === "snap-shot" ? (
7409 <SnapShotAttachmentDetails
7410 source={image.source}
7411 className={cn(
7412 upload?.status === "uploading" && "bottom-4",
7413 upload?.status === "failed" && "bottom-8",
7414 )}
7415 />
7416 ) : null}
7417 {nonPersistedComposerImageIdSet.has(image.id) && (
7418 <Tooltip>
7419 <TooltipTrigger
7420 render={
7421 <span
7422 role="img"
7423 aria-label="Draft attachment may not persist"
7424 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-warning-foreground"
7425 >
7426 <CircleAlertIcon className="size-3" />
7427 </span>
7428 }
7429 />
7430 <TooltipPopup side="top">
7431 Draft attachment could not be saved locally and may be lost on
7432 navigation.
7433 </TooltipPopup>
7434 </Tooltip>
7435 )}
7436 {upload?.status === "uploading" && (
7437 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-3xs text-foreground">
7438 {formatAttachmentUploadProgress(upload.progress)}
7439 </span>
7440 )}
7441 {upload?.status === "failed" && (
7442 <Tooltip>
7443 <TooltipTrigger
7444 render={
7445 <Button
7446 variant="overlay"
7447 size="icon-xs"
7448 className="absolute bottom-1 left-1"
7449 onClick={() =>
7450 retryAttachmentUpload({
7451 environmentId,
7452 image,
7453 draftTarget: attachmentDraftTarget,
7454 })
7455 }
7456 disabled={!canOperateThread}
7457 aria-label={`Retry upload for ${image.name}`}
7458 />
7459 }
7460 >
7461 <RefreshIcon />
7462 </TooltipTrigger>
7463 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
7464 </Tooltip>
7465 )}
7466 {/* Snap-shot frames reveal their remove button on hover or focus. */}
7467 <span
7468 className={cn(
7469 "absolute right-1 top-1 flex",
7470 image.source?.kind === "snap-shot" &&
7471 "opacity-0 transition-opacity pointer-coarse:opacity-100 group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100",
7472 )}
7473 >
7474 <Button
7475 variant="media-close"
7476 size="icon-xs"
7477 onClick={() => removeComposerImage(image.id)}
7478 aria-label={`Remove ${image.name}`}
7479 >
7480 <XIcon />
7481 </Button>
7482 </span>
7483 </SnapShotAttachmentFrame>
7484 );
7485 })
7486 .concat(
7487 uncommittedSnapShotIds.map((captureId) => (
7488 <SnapShotAttachmentFrame
7489 key={captureId}
7490 aria-hidden="true"
7491 animationId={captureId}
7492 animationSource={
7493 pendingSnapShotAnimations.find((capture) => capture.id === captureId)
7494 ?.source
7495 }
7496 className={cn(
7497 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
7498 "invisible shrink-0 snap-start bg-background",
7499 )}
7500 />
7501 )),
7502 )}
7503 {composerVideos.map((file) => {
7504 const fileCanUpload =
7505 supportsAttachmentUploads &&
7506 maxFileAttachmentBytes !== null &&
7507 file.sizeBytes <= maxFileAttachmentBytes;
7508 const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
7509 return (
7510 <div
7511 key={file.id}
7512 className="relative h-16 w-16 shrink-0 snap-start overflow-hidden rounded-lg border border-border/80 bg-black"
7513 >
7514 <button
7515 type="button"
7516 className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white"
7517 aria-label={`Play ${file.name}`}
7518 onClick={() => {
7519 if (file.file !== null) {
7520 const preview = buildExpandedImagePreview([file], file.id);
7521 if (preview) onExpandImage(preview);
7522 return;
7523 }
7524 if (!file.uploadedAttachmentId) return;
7525 onFileOpen({ ...file, id: file.uploadedAttachmentId });
7526 }}
7527 >
7528 {file.file && (
7529 <>
7530 <ComposerVideoThumbnail file={file.file} />
7531 <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" />
7532 </>
7533 )}
7534 <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" />
7535 </button>
7536 {upload?.status === "uploading" && (
7537 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-3xs text-foreground">
7538 {formatAttachmentUploadProgress(upload.progress)}
7539 </span>
7540 )}
7541 {upload?.status === "failed" && (
7542 <Tooltip>
7543 <TooltipTrigger
7544 render={
7545 <Button
7546 variant="overlay"
7547 size="icon-xs"
7548 className="absolute bottom-1 left-1"
7549 onClick={() =>
7550 retryAttachmentUpload({
7551 environmentId,
7552 image: file,
7553 draftTarget: attachmentDraftTarget,
7554 })
7555 }
7556 disabled={!canOperateThread}
7557 aria-label={`Retry upload for ${file.name}`}
7558 />
7559 }
7560 >
7561 <RefreshIcon />
7562 </TooltipTrigger>
7563 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
7564 </Tooltip>
7565 )}
7566 <Button
7567 variant="media-close"
7568 size="icon-xs"
7569 className="absolute right-1 top-1"
7570 onClick={() => removeComposerFileFromDraft(file.id)}
7571 aria-label={`Remove ${file.name}`}
7572 >
7573 <XIcon />
7574 </Button>
7575 </div>
7576 );
7577 })}
7578 </div>
7579 )}
7580
7581 {!isComposerCollapsedMobile &&
7582 !isComposerApprovalState &&
7583 composerOtherFiles.length > 0 && (
7584 <div className="mb-3 flex flex-col gap-1">
7585 {composerOtherFiles.map((file) => {
7586 const fileCanUpload =
7587 supportsAttachmentUploads &&
7588 maxFileAttachmentBytes !== null &&
7589 file.sizeBytes <= maxFileAttachmentBytes;
7590 const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
7591 const needsReattach = composerFileNeedsReattach(file);
7592 const canReattachFile =
7593 fileStagingLimit !== null && file.sizeBytes <= fileStagingLimit;
7594 return (
7595 <div
7596 key={file.id}
7597 className="flex min-w-0 items-center gap-2 py-1 text-sm text-foreground"
7598 >
7599 <PierreEntryIcon
7600 pathValue={file.name}
7601 kind="file"
7602 theme={resolvedTheme}
7603 />
7604 <button
7605 type="button"
7606 disabled={needsReattach}
7607 className="min-w-0 flex-1 truncate text-left hover:underline focus-visible:outline-2"
7608 onClick={() => setPreviewFileId(file.id)}
7609 >
7610 {file.name}
7611 </button>
7612 <span className="shrink-0 text-xs text-secondary-label">
7613 {needsReattach
7614 ? canReattachFile
7615 ? "Attach again"
7616 : "Remove to send"
7617 : upload?.status === "uploading"
7618 ? formatAttachmentUploadProgress(upload.progress)
7619 : formatAttachmentSize(file.sizeBytes)}
7620 </span>
7621 {!needsReattach && upload?.status === "failed" ? (
7622 <Tooltip>
7623 <TooltipTrigger
7624 render={
7625 <Button
7626 variant="ghost"
7627 size="icon-xs"
7628 onClick={() =>
7629 retryAttachmentUpload({
7630 environmentId,
7631 image: file,
7632 draftTarget: attachmentDraftTarget,
7633 })
7634 }
7635 disabled={!canOperateThread}
7636 aria-label={`Retry upload for ${file.name}`}
7637 />
7638 }
7639 >
7640 <RefreshIcon />
7641 </TooltipTrigger>
7642 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
7643 </Tooltip>
7644 ) : null}
7645 <Button
7646 variant="ghost"
7647 size="icon-xs"
7648 onClick={() => removeComposerFileFromDraft(file.id)}
7649 aria-label={`Remove ${file.name}`}
7650 >
7651 <XIcon />
7652 </Button>
7653 </div>
7654 );
7655 })}
7656 </div>
7657 )}
7658
7659 <div
7660 className={cn("relative", isComposerResting && "flex min-w-0 items-center gap-1")}
7661 style={
7662 isComposerResting && restingActionsWidth !== null
7663 ? { paddingRight: `calc(${restingActionsWidth}px + 1rem)` }
7664 : undefined
7665 }
7666 >
7667 {previewFile ? (
7668 <Dialog
7669 open
7670 onOpenChange={(open) => {
7671 if (!open) setPreviewFileId(null);
7672 }}
7673 >
7674 <DialogPopup
7675 {...composerFloatingLayerProps}
7676 className="h-[min(85vh,52rem)] max-w-4xl overflow-hidden"
7677 showCloseButton={false}
7678 >
7679 <DialogTitle className="sr-only">{previewFile.name}</DialogTitle>
7680 <AttachmentFilePreview
7681 key={previewFile.id}
7682 name={previewFile.name}
7683 mimeType={previewFile.mimeType}
7684 sizeBytes={previewFile.sizeBytes}
7685 file={previewFile.file}
7686 origin="Draft"
7687 {...(previewFile.uploadedAttachmentId && previewFile.uploadEnvironmentId
7688 ? {
7689 asset: {
7690 environmentId: previewFile.uploadEnvironmentId,
7691 attachmentId: previewFile.uploadedAttachmentId,
7692 },
7693 }
7694 : {})}
7695 onRemove={() => {
7696 removeComposerFileFromDraft(previewFile.id);
7697 setPreviewFileId(null);
7698 }}
7699 onClose={() => setPreviewFileId(null)}
7700 />
7701 </DialogPopup>
7702 </Dialog>
7703 ) : null}
7704 <ComposerContextActionsContext value={composerContextActions}>
7705 <ComposerPromptEditor
7706 persistenceKey={activePendingProgress ? undefined : composerDraftTargetKey}
7707 key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`}
7708 ariaLabel="Message"
7709 suggestionListId={composerSuggestionListId}
7710 activeSuggestionId={
7711 composerSuggestionListVisible && activeComposerMenuItem
7712 ? composerSuggestionOptionId(
7713 composerSuggestionListId,
7714 activeComposerMenuItem.id,
7715 )
7716 : undefined
7717 }
7718 editorRef={composerEditorRef}
7719 richTextEnabled={settings.composerRichTextEnabled}
7720 literalText={isLiteralPendingAnswer}
7721 value={
7722 isComposerApprovalState
7723 ? ""
7724 : activePendingProgress
7725 ? activePendingProgress.customAnswer
7726 : prompt
7727 }
7728 cursor={composerCursor}
7729 contextRecords={composerContextRecords}
7730 buildContextClipboardFragment={buildContextClipboardFragment}
7731 importContextFragment={importContextFragment}
7732 skills={selectedProviderSkills}
7733 containerClassName={cn(isComposerResting && "min-w-0 flex-1")}
7734 className={cn(
7735 showMobilePendingAnswerActions && "max-sm:pb-12",
7736 isComposerResting &&
7737 "my-0 max-h-8 min-h-8 overflow-hidden py-0 whitespace-pre! leading-8",
7738 isComposerApprovalState && "min-h-10",
7739 )}
7740 placeholderClassName={cn(
7741 isComposerResting &&
7742 "flex items-center overflow-hidden whitespace-nowrap leading-8",
7743 )}
7744 onChange={onPromptChange}
7745 onVisibleSelectionChange={expandComposerForEditorChange}
7746 onCommandKeyDown={onComposerCommandKey}
7747 onPageScrollKeyDown={onPageScrollKeyDown}
7748 onPageScrollKeyUp={onPageScrollKeyUp}
7749 onPageScrollRelease={onPageScrollRelease}
7750 onCitationSubmitAndSend={submitCitationAndSend}
7751 onCitationDraftSave={saveCitationDraftToPrompt}
7752 onPaste={onComposerPaste}
7753 placeholder={
7754 isComposerApprovalState
7755 ? "Resolve this approval request to continue"
7756 : activePendingProgress
7757 ? isChoiceOnlyPendingQuestion
7758 ? "Choose an option above"
7759 : "Type your own answer, or leave this blank to use the selected option"
7760 : showPlanFollowUpPrompt && activeProposedPlan
7761 ? "Add feedback to refine the plan, or leave this blank to implement it"
7762 : projectSelectionRequired
7763 ? "Choose a project above to start a thread"
7764 : showProviderUnavailable
7765 ? "Enable a provider in Settings to send a message"
7766 : phase === "disconnected"
7767 ? DISCONNECTED_COMPOSER_PLACEHOLDER
7768 : "Ask anything, @tag files/folders, $use skills, or / for commands"
7769 }
7770 disabled={
7771 isConnecting ||
7772 isComposerApprovalState ||
7773 projectSelectionRequired ||
7774 isChoiceOnlyPendingQuestion ||
7775 activePendingIsResponding
7776 }
7777 />
7778 </ComposerContextActionsContext>
7779 {isComposerResting ? collapsedComposerImagePreviews : null}
7780 {showMobilePendingAnswerActions ? (
7781 <div
7782 data-chat-composer-mobile-pending-actions="true"
7783 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
7784 >
7785 <ComposerPrimaryActions
7786 compact
7787 canOperateThread={canOperateThread}
7788 pendingAction={pendingPrimaryAction}
7789 isRunning={false}
7790 canInterrupt={false}
7791 showPlanFollowUpPrompt={false}
7792 promptHasText={false}
7793 isSendBusy={isSendBusy}
7794 sendDisabledReason={sendDisabledReason}
7795 isConnecting={isConnecting}
7796 isEnvironmentUnavailable={
7797 environmentUnavailable !== null ||
7798 noProviderAvailable ||
7799 projectSelectionRequired
7800 }
7801 isPreparingWorktree={false}
7802 hasSendableContent={false}
7803 preserveComposerFocusOnPointerDown
7804 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
7805 onInterrupt={handleInterruptPrimaryAction}
7806 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
7807 />
7808 </div>
7809 ) : null}
7810 </div>
7811 </div>
7812
7813 <ComposerPromptLengthValidation
7814 message={providerInputSubmissionError ?? composerSubmissionError}
7815 />
7816
7817 {/* Bottom toolbar */}
7818 {isComposerCollapsedMobile || isComposerApprovalState ? null : (
7819 <div
7820 data-chat-composer-footer="true"
7821 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
7822 className={cn(
7823 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-3 pb-3 sm:px-4 sm:pb-4",
7824 pendingUserInputs.length > 0 && "pt-2",
7825 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
7826 showMobilePendingAnswerActions && "hidden sm:flex",
7827 isComposerResting &&
7828 "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0",
7829 isComposerResting &&
7830 (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"),
7831 )}
7832 >
7833 <div
7834 ref={expandedControlsLayout.attachControls}
7835 data-chat-composer-controls="left"
7836 data-chat-composer-footer-controls="true"
7837 className={cn(
7838 "relative -m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
7839 isComposerResting && "hidden",
7840 )}
7841 >
7842 {composerControlsCollapsed ? null : composerControls}
7843 </div>
7844
7845 {/* Right side: send / stop button */}
7846 <div
7847 ref={setRestingActionsElement}
7848 data-chat-composer-actions="right"
7849 data-chat-composer-transition-actions="true"
7850 data-chat-composer-primary-actions-compact={
7851 isComposerPrimaryActionsCompact ? "true" : "false"
7852 }
7853 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
7854 >
7855 {routeKind === "server" ? (
7856 <ComposerDevServerStatus
7857 key={gitCwd}
7858 threadRef={routeThreadRef}
7859 target={{ _tag: "Thread", threadId: routeThreadRef.threadId }}
7860 />
7861 ) : activeProjectId && gitCwd ? (
7862 <ComposerDevServerStatus
7863 threadRef={routeThreadRef}
7864 target={{
7865 _tag: "Project",
7866 projectId: activeProjectId,
7867 workspacePath: gitCwd,
7868 }}
7869 />
7870 ) : null}
7871 {showComposerAttachAction ? (
7872 <>
7873 <input
7874 ref={attachmentInputRef}
7875 type="file"
7876 multiple
7877 className="hidden"
7878 onChange={(event) => {
7879 const files = Array.from(event.currentTarget.files ?? []);
7880 event.currentTarget.value = "";
7881 // Inserting a chip refocuses the editor after the draft renders;
7882 // focusing synchronously here would report the editor's stale text
7883 // over the prompt that was just written.
7884 void addComposerAttachments(files).then((inserted) => {
7885 if (!inserted) focusComposer();
7886 });
7887 }}
7888 />
7889 <Tooltip>
7890 <TooltipTrigger
7891 render={
7892 <Button
7893 type="button"
7894 variant="ghost"
7895 size="icon-sm"
7896 onPointerDown={(event) => event.preventDefault()}
7897 onClick={() => attachmentInputRef.current?.click()}
7898 aria-label="Attach files"
7899 />
7900 }
7901 >
7902 <PaperclipIcon />
7903 </TooltipTrigger>
7904 <TooltipPopup>Attach files</TooltipPopup>
7905 </Tooltip>
7906 </>
7907 ) : null}
7908 <ComposerFooterPrimaryActions
7909 compact={isComposerResting || isComposerPrimaryActionsCompact}
7910 canOperateThread={canOperateThread}
7911 activeContextWindow={
7912 settings.contextWindowMeterEnabled ? activeContextWindow : null
7913 }
7914 reserveContextWindowMeter={reserveContextWindowMeter}
7915 activeThreadModelDisplayName={activeThreadModelDisplayName}
7916 pendingAction={pendingPrimaryAction}
7917 isRunning={phase === "running"}
7918 canInterrupt={canInterrupt}
7919 followUpBehavior={settings.followUpBehavior}
7920 alternateShortcutLabel={shortcutLabelForCommand(
7921 keybindings,
7922 "composer.sendAlternate",
7923 {
7924 context: {
7925 composerFocus: true,
7926 draftThreadRoute: routeKind === "draft",
7927 turnRunning: true,
7928 },
7929 },
7930 )}
7931 showPlanFollowUpPrompt={
7932 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
7933 }
7934 promptHasText={prompt.trim().length > 0}
7935 isSendBusy={isSendBusy}
7936 sendDisabledReason={sendDisabledReason}
7937 isConnecting={isConnecting}
7938 isEnvironmentUnavailable={
7939 environmentUnavailable !== null ||
7940 noProviderAvailable ||
7941 projectSelectionRequired
7942 }
7943 isPreparingWorktree={isPreparingWorktree}
7944 hasSendableContent={composerSendState.hasSendableContent}
7945 canResume={showResumeAction}
7946 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
7947 isEditingQueuedMessage={isEditingQueuedMessage}
7948 onSubmitMessage={handleSubmitMessage}
7949 onResume={onResume}
7950 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
7951 onInterrupt={handleInterruptPrimaryAction}
7952 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
7953 compactBeforeSendTokens={props.resumeCompactionTokens}
7954 keepFullHistory={props.keepFullHistory}
7955 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
7956 compactDisabled={
7957 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
7958 }
7959 compactDisabledReason={resolvedCompactDisabledReason}
7960 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
7961 />
7962 </div>
7963 </div>
7964 )}
7965 {showInlineRestingControls ? (
7966 <div className="h-8">
7967 <div
7968 ref={setInlineRestingControlsHost}
7969 className="absolute bottom-2 inset-x-4 min-w-0"
7970 >
7971 <div
7972 ref={restingComposerControlsRef}
7973 data-chat-composer-resting-controls="true"
7974 aria-hidden={restingControlsVisible ? undefined : true}
7975 inert={restingControlsVisible ? undefined : true}
7976 className={cn(
7977 "relative flex w-max min-w-0 max-w-full items-center gap-1 text-muted-foreground/70 [&_button]:text-xs!",
7978 !restingControlsVisible && "invisible",
7979 )}
7980 >
7981 {composerControls}
7982 </div>
7983 </div>
7984 </div>
7985 ) : null}
7986 </div>
7987 </ComposerSurface.Main>
7988 </div>
7989 </form>
7990 );
7991});