History

Refactor composer refs and context providers (#2664)

Julius Marminge committed 6 months agov0.0.24-nightly.20260513.273

Commit b83e9c91 parent a41f48915 files · +1930 −1965Browse files

Unified · Split

15 files
  1. apps/web/package.json +5 −5
  2. apps/web/src/components/ChatView.tsx +1 −1
  3. apps/web/src/components/CommandPalette.tsx +2 −2
  4. apps/web/src/components/ComposerPromptEditor.tsx +88 −102
  5. apps/web/src/components/chat/ChatComposer.tsx +1766 −1781
  6. apps/web/src/components/chat/MessagesTimeline.browser.tsx +10 −12
  7. apps/web/src/components/chat/MessagesTimeline.test.tsx +19 −23
  8. apps/web/src/components/chat/MessagesTimeline.tsx +5 −5
  9. apps/web/src/components/ui/combobox.tsx +4 −4
  10. apps/web/src/components/ui/number-field.tsx +3 −3
  11. apps/web/src/components/ui/sidebar.tsx +11 −11
  12. apps/web/src/components/ui/toggle-group.tsx +2 −2
  13. apps/web/src/composerHandleContext.ts +3 −3
  14. apps/web/src/rpc/atomRegistry.ts +3 −3
  15. bun.lock +8 −8
apps/web/package.json+5−5
@@ -35,8 +35,8 @@ · 34 unmodified lines
3535 "effect": "catalog:",
3636 "lexical": "^0.41.0",
3737 "lucide-react": "^0.564.0",
38− "react": "^19.0.0",
39− "react-dom": "^19.0.0",
38+ "react": "19.2.6",
39+ "react-dom": "19.2.6",
4040 "react-markdown": "^10.1.0",
4141 "remark-gfm": "^4.0.1",
4242 "tailwind-merge": "^3.4.0",
@@ -48,12 +48,12 @@ · 5 unmodified lines
4848 "@tailwindcss/vite": "^4.0.0",
4949 "@tanstack/router-plugin": "^1.161.0",
5050 "@types/babel__core": "^7.20.5",
51− "@types/react": "^19.0.0",
52− "@types/react-dom": "^19.0.0",
51+ "@types/react": "~19.2.14",
52+ "@types/react-dom": "~19.2.3",
5353 "@vercel/config": "^0.3.0",
5454 "@vitejs/plugin-react": "^6.0.0",
5555 "@vitest/browser-playwright": "^4.0.18",
56− "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
56+ "babel-plugin-react-compiler": "1.0.0",
5757 "msw": "2.12.11",
5858 "playwright": "^1.58.2",
5959 "tailwindcss": "^4.0.0",
apps/web/src/components/ChatView.tsx+1−1
@@ -3607,7 +3607,7 @@ export default function ChatView(props: ChatViewProps) { · 3606 unmodified lines
36073607 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
36083608 <div className="relative z-10">
36093609 <ChatComposer
3610− ref={composerRef}
3610+ composerRef={composerRef}
36113611 composerDraftTarget={composerDraftTarget}
36123612 environmentId={environmentId}
36133613 routeKind={routeKind}
apps/web/src/components/CommandPalette.tsx+2−2
@@ -364,12 +364,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 363 unmodified lines
364364 }, [keybindings, terminalOpen, toggleOpen]);
365365
366366 return (
367− <ComposerHandleContext.Provider value={composerHandleRef}>
367+ <ComposerHandleContext value={composerHandleRef}>
368368 <CommandDialog open={open} onOpenChange={setOpen}>
369369 {children}
370370 <CommandPaletteDialog />
371371 </CommandDialog>
372− </ComposerHandleContext.Provider>
372+ </ComposerHandleContext>
373373 );
374374 }
375375
apps/web/src/components/ComposerPromptEditor.tsx+88−102
@@ -38,17 +38,14 @@ import { · 37 unmodified lines
3838 } from "lexical";
3939 import {
4040 createContext,
41− forwardRef,
41+ use,
4242 useCallback,
43− useContext,
4443 useEffect,
44+ useEffectEvent,
4545 useImperativeHandle,
4646 useLayoutEffect,
4747 useMemo,
4848 useRef,
49− type ClipboardEventHandler,
50− type ReactElement,
51− type Ref,
5249 } from "react";
5350
5451 import {
@@ -160,7 +157,7 @@ function ComposerMentionDecorator(props: { path: string }) { · 105 unmodified lines
160157 );
161158 }
162159
163−class ComposerMentionNode extends DecoratorNode<ReactElement> {
160+class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
164161 __path: string;
165162
166163 static override getType(): string {
@@ -208,7 +205,7 @@ class ComposerMentionNode extends DecoratorNode<ReactElement> { · 41 unmodified lines
208205 return true;
209206 }
210207
211− override decorate(): ReactElement {
208+ override decorate(): React.ReactElement {
212209 return <ComposerMentionDecorator path={this.__path} />;
213210 }
214211 }
@@ -278,7 +275,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s · 63 unmodified lines
278275 );
279276 }
280277
281−class ComposerSkillNode extends DecoratorNode<ReactElement> {
278+class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
282279 __skillName: string;
283280 __skillLabel: string;
284281 __skillDescription: string | null;
@@ -346,7 +343,7 @@ class ComposerSkillNode extends DecoratorNode<ReactElement> { · 61 unmodified lines
346343 return true;
347344 }
348345
349− override decorate(): ReactElement {
346+ override decorate(): React.ReactElement {
350347 return (
351348 <ComposerSkillDecorator
352349 skillLabel={this.__skillLabel}
@@ -368,7 +365,7 @@ function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft · 15 unmodified lines
368365 return <ComposerPendingTerminalContextChip context={props.context} />;
369366 }
370367
371−class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {
368+class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {
372369 __context: TerminalContextDraft;
373370
374371 static override getType(): string {
@@ -417,7 +414,7 @@ class ComposerTerminalContextNode extends DecoratorNode<ReactElement> { · 42 unmodified lines
417414 return true;
418415 }
419416
420− override decorate(): ReactElement {
417+ override decorate(): React.ReactElement {
421418 return <ComposerTerminalContextDecorator context={this.__context} />;
422419 }
423420 }
@@ -900,11 +897,8 @@ interface ComposerPromptEditorProps { · 476 unmodified lines
900897 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
901898 event: KeyboardEvent,
902899 ) => boolean;
903− onPaste: ClipboardEventHandler<HTMLElement>;
904−}
905−
906−interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {
907− editorRef: Ref<ComposerPromptEditorHandle>;
900+ onPaste: React.ClipboardEventHandler<HTMLElement>;
901+ editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
908902 }
909903
910904 function ComposerCommandKeyPlugin(props: {
@@ -1059,7 +1053,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() { · 148 unmodified lines
10591053
10601054 function ComposerInlineTokenBackspacePlugin() {
10611055 const [editor] = useLexicalComposerContext();
1062− const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext);
1056+ const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext);
10631057
10641058 useEffect(() => {
10651059 return editor.registerCommand(
@@ -1150,68 +1144,65 @@ function ComposerSurroundSelectionPlugin(props: { · 84 unmodified lines
11501144 skillMetadataRef.current = skillMetadataByName(props.skills);
11511145 }, [props.skills]);
11521146
1153− const applySurroundInsertion = useCallback(
1154− (inputData: string): boolean => {
1155− const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
1156− const pendingSurroundSelection = pendingSurroundSelectionRef.current;
1157− if (!surroundCloseSymbol) {
1158− pendingSurroundSelectionRef.current = null;
1159− return false;
1160− }
1147+ const applySurroundInsertion = useEffectEvent((inputData: string): boolean => {
1148+ const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
1149+ const pendingSurroundSelection = pendingSurroundSelectionRef.current;
1150+ if (!surroundCloseSymbol) {
1151+ pendingSurroundSelectionRef.current = null;
1152+ return false;
1153+ }
11611154
1162− let handled = false;
1163− editor.update(() => {
1164− const selectionSnapshot =
1165− pendingSurroundSelection ??
1166− (() => {
1167− const selection = $getSelection();
1168− if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1169− return null;
1170− }
1171− if ($selectionTouchesInlineToken(selection)) {
1172− return null;
1173− }
1174− const range = getSelectionRangeForExpandedComposerOffsets(selection);
1175− if (!range || range.start === range.end) {
1176− return null;
1177− }
1178− const value = $getRoot().getTextContent();
1179− if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1180− return null;
1181− }
1182− return {
1183− value,
1184− expandedStart: range.start,
1185− expandedEnd: range.end,
1186− };
1187− })();
1188−
1189− if (!selectionSnapshot || !surroundCloseSymbol) {
1190− return;
1191− }
1155+ let handled = false;
1156+ editor.update(() => {
1157+ const selectionSnapshot =
1158+ pendingSurroundSelection ??
1159+ (() => {
1160+ const selection = $getSelection();
1161+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1162+ return null;
1163+ }
1164+ if ($selectionTouchesInlineToken(selection)) {
1165+ return null;
1166+ }
1167+ const range = getSelectionRangeForExpandedComposerOffsets(selection);
1168+ if (!range || range.start === range.end) {
1169+ return null;
1170+ }
1171+ const value = $getRoot().getTextContent();
1172+ if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1173+ return null;
1174+ }
1175+ return {
1176+ value,
1177+ expandedStart: range.start,
1178+ expandedEnd: range.end,
1179+ };
1180+ })();
1181+
1182+ if (!selectionSnapshot || !surroundCloseSymbol) {
1183+ return;
1184+ }
11921185
1193− const selectedText = selectionSnapshot.value.slice(
1194− selectionSnapshot.expandedStart,
1195− selectionSnapshot.expandedEnd,
1196− );
1197− const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1198− $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current);
1199− const selectionStart = collapseExpandedComposerCursor(
1200− nextValue,
1201− selectionSnapshot.expandedStart,
1202− );
1203− $setSelectionRangeAtComposerOffsets(
1204− selectionStart + inputData.length,
1205− selectionStart + inputData.length + selectedText.length,
1206− );
1207− handled = true;
1208− pendingSurroundSelectionRef.current = null;
1209− });
1186+ const selectedText = selectionSnapshot.value.slice(
1187+ selectionSnapshot.expandedStart,
1188+ selectionSnapshot.expandedEnd,
1189+ );
1190+ const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1191+ $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current);
1192+ const selectionStart = collapseExpandedComposerCursor(
1193+ nextValue,
1194+ selectionSnapshot.expandedStart,
1195+ );
1196+ $setSelectionRangeAtComposerOffsets(
1197+ selectionStart + inputData.length,
1198+ selectionStart + inputData.length + selectedText.length,
1199+ );
1200+ handled = true;
1201+ pendingSurroundSelectionRef.current = null;
1202+ });
12101203
1211− return handled;
1212− },
1213− [editor],
1214− );
1204+ return handled;
1205+ });
12151206
12161207 useEffect(() => {
12171208 const onKeyDown = (event: KeyboardEvent) => {
@@ -1388,7 +1379,7 @@ function ComposerSurroundSelectionPlugin(props: { · 170 unmodified lines
13881379 }
13891380 unregisterRootListener();
13901381 };
1391− }, [applySurroundInsertion, editor]);
1382+ }, [editor]);
13921383
13931384 return null;
13941385 }
@@ -1406,7 +1397,7 @@ function ComposerPromptEditorInner({ · 11 unmodified lines
14061397 onCommandKeyDown,
14071398 onPaste,
14081399 editorRef,
1409−}: ComposerPromptEditorInnerProps) {
1400+}: ComposerPromptEditorProps) {
14101401 const [editor] = useLexicalComposerContext();
14111402 const onChangeRef = useRef(onChange);
14121403 const initialCursor = clampCollapsedComposerCursor(value, cursor);
@@ -1614,13 +1605,13 @@ function ComposerPromptEditorInner({ · 201 unmodified lines
16141605 }, []);
16151606
16161607 return (
1617− <ComposerTerminalContextActionsContext.Provider value={terminalContextActions}>
1608+ <ComposerTerminalContextActionsContext value={terminalContextActions}>
16181609 <div className="relative">
16191610 <PlainTextPlugin
16201611 contentEditable={
16211612 <ContentEditable
16221613 className={cn(
1623− "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent text-[16px] leading-relaxed text-foreground focus:outline-none sm:text-[14px]",
1614+ "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent text-[16px] leading-relaxed text-foreground focus:outline-none sm:text-[14px]",
16241615 className,
16251616 )}
16261617 data-testid="composer-editor"
@@ -1646,29 +1637,24 @@ function ComposerPromptEditorInner({ · 19 unmodified lines
16461637 <ComposerInlineTokenBackspacePlugin />
16471638 <HistoryPlugin />
16481639 </div>
1649− </ComposerTerminalContextActionsContext.Provider>
1640+ </ComposerTerminalContextActionsContext>
16501641 );
16511642 }
16521643
1653−export const ComposerPromptEditor = forwardRef<
1654− ComposerPromptEditorHandle,
1655− ComposerPromptEditorProps
1656−>(function ComposerPromptEditor(
1657− {
1658− value,
1659− cursor,
1660− terminalContexts,
1661− skills,
1662− disabled,
1663− placeholder,
1664− className,
1665− onRemoveTerminalContext,
1666− onChange,
1667− onCommandKeyDown,
1668− onPaste,
1669− },
1670− ref,
1671−) {
1644+export function ComposerPromptEditor({
1645+ value,
1646+ cursor,
1647+ terminalContexts,
1648+ skills,
1649+ disabled,
1650+ placeholder,
1651+ className,
1652+ onRemoveTerminalContext,
1653+ onChange,
1654+ onCommandKeyDown,
1655+ onPaste,
1656+ editorRef,
1657+}: ComposerPromptEditorProps) {
16721658 const initialValueRef = useRef(value);
16731659 const initialTerminalContextsRef = useRef(terminalContexts);
16741660 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
@@ -1703,10 +1689,10 @@ export const ComposerPromptEditor = forwardRef< · 28 unmodified lines
17031689 onRemoveTerminalContext={onRemoveTerminalContext}
17041690 onChange={onChange}
17051691 onPaste={onPaste}
1706− editorRef={ref}
1692+ editorRef={editorRef}
17071693 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
17081694 {...(className ? { className } : {})}
17091695 />
17101696 </LexicalComposer>
17111697 );
1712−});
1698+}
apps/web/src/components/chat/ChatComposer.tsx+1766−1781
@@ -20,7 +20,6 @@ import { · 19 unmodified lines
2020 } from "@t3tools/contracts";
2121 import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2222 import {
23− forwardRef,
2423 memo,
2524 useCallback,
2625 useEffect,
@@ -446,12 +445,13 @@ export interface ChatComposerProps { · 419 unmodified lines
446445 gitCwd: string | null;
447446
448447 // Refs the parent needs kept in sync
449− promptRef: React.MutableRefObject<string>;
450− composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>;
451− composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>;
448+ promptRef: React.RefObject<string>;
449+ composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
450+ composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
451+ composerRef: React.RefObject<ChatComposerHandle | null>;
452452
453453 // Scroll
454− shouldAutoScrollRef: React.MutableRefObject<boolean>;
454+ shouldAutoScrollRef: React.RefObject<boolean>;
455455 scheduleStickToBottom: () => void;
456456
457457 // Callbacks
@@ -489,427 +489,423 @@ export interface ChatComposerProps { · 31 unmodified lines
489489 // Component
490490 // --------------------------------------------------------------------------
491491
492−export const ChatComposer = memo(
493− forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) {
494− const {
495− composerDraftTarget,
496− environmentId,
497− routeKind,
498− routeThreadRef,
499− draftId,
500− activeThreadId,
501− activeThreadEnvironmentId: _activeThreadEnvironmentId,
502− activeThread,
503− isServerThread: _isServerThread,
504− isLocalDraftThread: _isLocalDraftThread,
505− phase,
506− isConnecting,
507− isSendBusy,
508− isPreparingWorktree,
509− environmentUnavailable,
510− activePendingApproval,
511− pendingApprovals,
512− pendingUserInputs,
513− activePendingProgress,
514− activePendingResolvedAnswers,
515− activePendingIsResponding,
516− activePendingDraftAnswers,
517− activePendingQuestionIndex,
518− respondingRequestIds,
519− showPlanFollowUpPrompt,
520− activeProposedPlan,
521− activePlan,
522− sidebarProposedPlan,
523− planSidebarLabel,
524− planSidebarOpen,
525− runtimeMode,
526− interactionMode,
527− lockedProvider,
492+export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {
493+ const {
494+ composerDraftTarget,
495+ environmentId,
496+ routeKind,
497+ routeThreadRef,
498+ draftId,
499+ activeThreadId,
500+ activeThreadEnvironmentId: _activeThreadEnvironmentId,
501+ activeThread,
502+ isServerThread: _isServerThread,
503+ isLocalDraftThread: _isLocalDraftThread,
504+ phase,
505+ isConnecting,
506+ isSendBusy,
507+ isPreparingWorktree,
508+ environmentUnavailable,
509+ activePendingApproval,
510+ pendingApprovals,
511+ pendingUserInputs,
512+ activePendingProgress,
513+ activePendingResolvedAnswers,
514+ activePendingIsResponding,
515+ activePendingDraftAnswers,
516+ activePendingQuestionIndex,
517+ respondingRequestIds,
518+ showPlanFollowUpPrompt,
519+ activeProposedPlan,
520+ activePlan,
521+ sidebarProposedPlan,
522+ planSidebarLabel,
523+ planSidebarOpen,
524+ runtimeMode,
525+ interactionMode,
526+ lockedProvider,
527+ providerStatuses,
528+ activeProjectDefaultModelSelection,
529+ activeThreadModelSelection,
530+ activeThreadActivities,
531+ resolvedTheme,
532+ settings,
533+ keybindings,
534+ terminalOpen,
535+ gitCwd,
536+ promptRef,
537+ composerRef,
538+ composerImagesRef,
539+ composerTerminalContextsRef,
540+ shouldAutoScrollRef,
541+ scheduleStickToBottom,
542+ onSend,
543+ onInterrupt,
544+ onImplementPlanInNewThread,
545+ onRespondToApproval,
546+ onSelectActivePendingUserInputOption,
547+ onAdvanceActivePendingUserInput,
548+ onPreviousActivePendingUserInputQuestion,
549+ onChangeActivePendingUserInputCustomAnswer,
550+ onProviderModelSelect,
551+ toggleInteractionMode,
552+ handleRuntimeModeChange,
553+ handleInteractionModeChange,
554+ togglePlanSidebar,
555+ focusComposer,
556+ scheduleComposerFocus,
557+ setThreadError,
558+ onExpandImage,
559+ } = props;
560+
561+ // ------------------------------------------------------------------
562+ // Store subscriptions (prompt / images / terminal contexts)
563+ // ------------------------------------------------------------------
564+ const composerDraft = useComposerThreadDraft(composerDraftTarget);
565+ const prompt = composerDraft.prompt;
566+ const composerImages = composerDraft.images;
567+ const composerTerminalContexts = composerDraft.terminalContexts;
568+ const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
569+
570+ const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
571+ const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
572+ const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
573+ const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
574+ const insertComposerDraftTerminalContext = useComposerDraftStore(
575+ (store) => store.insertTerminalContext,
576+ );
577+ const removeComposerDraftTerminalContext = useComposerDraftStore(
578+ (store) => store.removeTerminalContext,
579+ );
580+ const setComposerDraftTerminalContexts = useComposerDraftStore(
581+ (store) => store.setTerminalContexts,
582+ );
583+ const clearComposerDraftPersistedAttachments = useComposerDraftStore(
584+ (store) => store.clearPersistedAttachments,
585+ );
586+ const syncComposerDraftPersistedAttachments = useComposerDraftStore(
587+ (store) => store.syncPersistedAttachments,
588+ );
589+ const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
590+
591+ // ------------------------------------------------------------------
592+ // Model state
593+ // ------------------------------------------------------------------
594+ // Instance-aware projection of the wire provider list. One entry per
595+ // configured instance (default built-in + any custom `providerInstances.*`),
596+ // sorted default-first per driver kind for a stable picker order.
597+ const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
598+ () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)),
599+ [providerStatuses],
600+ );
601+ const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
602+ const threadProvider =
603+ activeThread?.session?.providerInstanceId ??
604+ activeThreadModelSelection?.instanceId ??
605+ activeProjectDefaultModelSelection?.instanceId ??
606+ null;
607+ const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider;
608+
609+ const unlockedSelectedProvider =
610+ resolveProviderDriverKindForInstanceSelection(
611+ providerInstanceEntries,
528612 providerStatuses,
529− activeProjectDefaultModelSelection,
530− activeThreadModelSelection,
531− activeThreadActivities,
532− resolvedTheme,
533− settings,
534− keybindings,
535− terminalOpen,
536− gitCwd,
537− promptRef,
538− composerImagesRef,
539− composerTerminalContextsRef,
540− shouldAutoScrollRef,
541− scheduleStickToBottom,
542− onSend,
543− onInterrupt,
544− onImplementPlanInNewThread,
545− onRespondToApproval,
546− onSelectActivePendingUserInputOption,
547− onAdvanceActivePendingUserInput,
548− onPreviousActivePendingUserInputQuestion,
549− onChangeActivePendingUserInputCustomAnswer,
550− onProviderModelSelect,
551− toggleInteractionMode,
552− handleRuntimeModeChange,
553− handleInteractionModeChange,
554− togglePlanSidebar,
555− focusComposer,
556− scheduleComposerFocus,
557− setThreadError,
558− onExpandImage,
559− } = props;
560−
561− // ------------------------------------------------------------------
562− // Store subscriptions (prompt / images / terminal contexts)
563− // ------------------------------------------------------------------
564− const composerDraft = useComposerThreadDraft(composerDraftTarget);
565− const prompt = composerDraft.prompt;
566− const composerImages = composerDraft.images;
567− const composerTerminalContexts = composerDraft.terminalContexts;
568− const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
569−
570− const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
571− const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
572− const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
573− const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
574− const insertComposerDraftTerminalContext = useComposerDraftStore(
575− (store) => store.insertTerminalContext,
576− );
577− const removeComposerDraftTerminalContext = useComposerDraftStore(
578− (store) => store.removeTerminalContext,
579− );
580− const setComposerDraftTerminalContexts = useComposerDraftStore(
581− (store) => store.setTerminalContexts,
582− );
583− const clearComposerDraftPersistedAttachments = useComposerDraftStore(
584− (store) => store.clearPersistedAttachments,
585− );
586− const syncComposerDraftPersistedAttachments = useComposerDraftStore(
587− (store) => store.syncPersistedAttachments,
588− );
589− const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
590−
591− // ------------------------------------------------------------------
592− // Model state
593− // ------------------------------------------------------------------
594− // Instance-aware projection of the wire provider list. One entry per
595− // configured instance (default built-in + any custom `providerInstances.*`),
596− // sorted default-first per driver kind for a stable picker order.
597− const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
598− () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)),
599− [providerStatuses],
613+ explicitSelectedInstanceId,
614+ ) ?? ProviderDriverKind.make("codex");
615+ const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
616+ const lockedContinuationGroupKey = useMemo((): string | null => {
617+ if (!lockedProvider || !activeThread) return null;
618+ const lockedInstanceId =
619+ activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
620+ if (!lockedInstanceId) return null;
621+ return (
622+ providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
623+ ?.continuationGroupKey ?? null
600624 );
601− const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
602− const threadProvider =
603− activeThread?.session?.providerInstanceId ??
604− activeThreadModelSelection?.instanceId ??
605− activeProjectDefaultModelSelection?.instanceId ??
606− null;
607− const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider;
608−
609− const unlockedSelectedProvider =
610− resolveProviderDriverKindForInstanceSelection(
611− providerInstanceEntries,
612− providerStatuses,
613− explicitSelectedInstanceId,
614− ) ?? ProviderDriverKind.make("codex");
615− const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
616− const lockedContinuationGroupKey = useMemo((): string | null => {
617− if (!lockedProvider || !activeThread) return null;
618− const lockedInstanceId =
619− activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
620− if (!lockedInstanceId) return null;
621− return (
622− providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
623− ?.continuationGroupKey ?? null
624− );
625− }, [
626− activeThread,
625+ }, [
626+ activeThread,
627+ activeThreadModelSelection?.instanceId,
628+ lockedProvider,
629+ providerInstanceEntries,
630+ ]);
631+
632+ // Resolve which configured instance the composer is currently targeting.
633+ // Priority:
634+ // 1. The composer draft's `activeProvider` — the user's unsaved pick
635+ // from the model picker (must win, otherwise the UI appears to
636+ // ignore picker selections).
637+ // 2. Thread's persisted instance id (server-side saved selection).
638+ // 3. Project default's instance id.
639+ // 4. First enabled entry matching the current driver kind.
640+ // 5. First enabled entry overall / default instance for the kind.
641+ //
642+ const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
643+ const candidates: Array<string | null | undefined> = [
644+ composerDraft.activeProvider,
645+ activeThread?.session?.providerInstanceId,
627646 activeThreadModelSelection?.instanceId,
628− lockedProvider,
629− providerInstanceEntries,
630− ]);
631−
632− // Resolve which configured instance the composer is currently targeting.
633− // Priority:
634− // 1. The composer draft's `activeProvider` — the user's unsaved pick
635− // from the model picker (must win, otherwise the UI appears to
636− // ignore picker selections).
637− // 2. Thread's persisted instance id (server-side saved selection).
638− // 3. Project default's instance id.
639− // 4. First enabled entry matching the current driver kind.
640− // 5. First enabled entry overall / default instance for the kind.
641− //
642− const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
643− const candidates: Array<string | null | undefined> = [
644− composerDraft.activeProvider,
645− activeThread?.session?.providerInstanceId,
646− activeThreadModelSelection?.instanceId,
647− activeProjectDefaultModelSelection?.instanceId,
648− ];
649− for (const candidate of candidates) {
650− if (!candidate) continue;
651− const match = providerInstanceEntries.find(
652− (entry) => entry.instanceId === candidate && entry.enabled,
653− );
654− if (match) {
655− // When locked to a specific driver kind, ignore persisted instance
656− // ids from a different kind or continuation group.
657− if (lockedProvider && match.driverKind !== lockedProvider) continue;
658− if (
659− lockedContinuationGroupKey &&
660− match.continuationGroupKey !== lockedContinuationGroupKey
661− ) {
662− continue;
663− }
664− return match.instanceId;
647+ activeProjectDefaultModelSelection?.instanceId,
648+ ];
649+ for (const candidate of candidates) {
650+ if (!candidate) continue;
651+ const match = providerInstanceEntries.find(
652+ (entry) => entry.instanceId === candidate && entry.enabled,
653+ );
654+ if (match) {
655+ // When locked to a specific driver kind, ignore persisted instance
656+ // ids from a different kind or continuation group.
657+ if (lockedProvider && match.driverKind !== lockedProvider) continue;
658+ if (
659+ lockedContinuationGroupKey &&
660+ match.continuationGroupKey !== lockedContinuationGroupKey
661+ ) {
662+ continue;
665663 }
664+ return match.instanceId;
666665 }
667− if (explicitSelectedInstanceId) {
668− return ProviderInstanceId.make(explicitSelectedInstanceId);
669− }
670− const byKind = providerInstanceEntries.find(
671− (entry) =>
672− entry.enabled &&
673− entry.driverKind === selectedProvider &&
674− (!lockedContinuationGroupKey ||
675− entry.continuationGroupKey === lockedContinuationGroupKey),
676− );
677− if (byKind) return byKind.instanceId;
678− const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled);
679− return (
680− anyEnabled?.instanceId ??
681− providerInstanceEntries[0]?.instanceId ??
682− activeThreadModelSelection?.instanceId ??
683− activeProjectDefaultModelSelection?.instanceId ??
684− ProviderInstanceId.make("codex")
685− );
686− }, [
687− activeProjectDefaultModelSelection?.instanceId,
688− activeThread?.session?.providerInstanceId,
689− activeThreadModelSelection?.instanceId,
690− composerDraft.activeProvider,
691− explicitSelectedInstanceId,
692− lockedContinuationGroupKey,
693− lockedProvider,
694− providerInstanceEntries,
695− selectedProvider,
696− ]);
697−
698− const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
699− threadRef: composerDraftTarget,
700− providers: providerStatuses,
701− selectedProvider,
702− selectedInstanceId,
703− threadModelSelection: activeThreadModelSelection,
704− projectModelSelection: activeProjectDefaultModelSelection,
705− settings,
706− });
707−
708− // Resolve the active instance's snapshot by `instanceId` so a custom
709− // instance gets its own slash commands, skills, and model list — not
710− // the first snapshot for the same driver kind.
711− const selectedProviderEntry = useMemo(
712− () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
713− [providerInstanceEntries, selectedInstanceId],
666+ }
667+ if (explicitSelectedInstanceId) {
668+ return ProviderInstanceId.make(explicitSelectedInstanceId);
669+ }
670+ const byKind = providerInstanceEntries.find(
671+ (entry) =>
672+ entry.enabled &&
673+ entry.driverKind === selectedProvider &&
674+ (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey),
714675 );
715− const selectedProviderStatus = useMemo(
716− () => selectedProviderEntry?.snapshot ?? null,
717− [selectedProviderEntry],
718− );
719− const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
720− () => selectedProviderEntry?.models ?? [],
721− [selectedProviderEntry],
676+ if (byKind) return byKind.instanceId;
677+ const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled);
678+ return (
679+ anyEnabled?.instanceId ??
680+ providerInstanceEntries[0]?.instanceId ??
681+ activeThreadModelSelection?.instanceId ??
682+ activeProjectDefaultModelSelection?.instanceId ??
683+ ProviderInstanceId.make("codex")
722684 );
685+ }, [
686+ activeProjectDefaultModelSelection?.instanceId,
687+ activeThread?.session?.providerInstanceId,
688+ activeThreadModelSelection?.instanceId,
689+ composerDraft.activeProvider,
690+ explicitSelectedInstanceId,
691+ lockedContinuationGroupKey,
692+ lockedProvider,
693+ providerInstanceEntries,
694+ selectedProvider,
695+ ]);
696+
697+ const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
698+ threadRef: composerDraftTarget,
699+ providers: providerStatuses,
700+ selectedProvider,
701+ selectedInstanceId,
702+ threadModelSelection: activeThreadModelSelection,
703+ projectModelSelection: activeProjectDefaultModelSelection,
704+ settings,
705+ });
723706
724− const composerProviderState = useMemo(
725− () =>
726− getComposerProviderState({
727− provider: selectedProvider,
728− model: selectedModel,
729− models: selectedProviderModels,
730− prompt,
731− modelOptions: composerModelOptions?.[selectedProvider],
732− }),
733− [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
734− );
707+ // Resolve the active instance's snapshot by `instanceId` so a custom
708+ // instance gets its own slash commands, skills, and model list — not
709+ // the first snapshot for the same driver kind.
710+ const selectedProviderEntry = useMemo(
711+ () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
712+ [providerInstanceEntries, selectedInstanceId],
713+ );
714+ const selectedProviderStatus = useMemo(
715+ () => selectedProviderEntry?.snapshot ?? null,
716+ [selectedProviderEntry],
717+ );
718+ const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
719+ () => selectedProviderEntry?.models ?? [],
720+ [selectedProviderEntry],
721+ );
735722
736− const selectedPromptEffort = composerProviderState.promptEffort;
737− const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
738− const composerProviderControls = useMemo(
739− () => ({
740− showInteractionModeToggle: getProviderInteractionModeToggle(
741− providerStatuses,
742− selectedProvider,
743− ),
723+ const composerProviderState = useMemo(
724+ () =>
725+ getComposerProviderState({
726+ provider: selectedProvider,
727+ model: selectedModel,
728+ models: selectedProviderModels,
729+ prompt,
730+ modelOptions: composerModelOptions?.[selectedProvider],
744731 }),
745− [providerStatuses, selectedProvider],
746− );
747− const selectedModelSelection = useMemo<ModelSelection>(
748− () =>
749− createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
750− [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
751− );
752− const selectedModelForPicker = selectedModel;
753− // Instance-keyed option list so the picker can show each configured
754− // instance (built-in + custom) as a first-class sidebar entry. The
755− // options are server-reported models plus that exact instance's
756− // configured custom models; selected slugs are not injected into lists.
757− const modelOptionsByInstance = useMemo<
758− ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
759− >(() => {
760− const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
761− for (const entry of providerInstanceEntries) {
762− out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
763− }
764− return out;
765− }, [providerInstanceEntries, settings]);
766− const selectedModelForPickerWithCustomFallback = useMemo(() => {
767− const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
768− return currentOptions.some((option) => option.slug === selectedModelForPicker)
769− ? selectedModelForPicker
770− : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
771− }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
772−
773− // ------------------------------------------------------------------
774− // Context window
775− // ------------------------------------------------------------------
776− const activeContextWindow = useMemo(
777− () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
778− [activeThreadActivities],
779− );
732+ [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
733+ );
780734
781− // ------------------------------------------------------------------
782− // Composer-local state
783− // ------------------------------------------------------------------
784− const [composerCursor, setComposerCursor] = useState(() =>
785− collapseExpandedComposerCursor(prompt, prompt.length),
786− );
787− const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
788− detectComposerTrigger(prompt, prompt.length),
789− );
790− const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
791− const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
792− null,
793− );
794− const [isDragOverComposer, setIsDragOverComposer] = useState(false);
795− const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
796− const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
797− const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
798− const [isComposerFocused, setIsComposerFocused] = useState(false);
799− const isMobileViewport = useMediaQuery("max-sm");
800− const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
801−
802− // ------------------------------------------------------------------
803− // Refs
804− // ------------------------------------------------------------------
805− const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
806− const composerFormRef = useRef<HTMLFormElement>(null);
807− const composerSurfaceRef = useRef<HTMLDivElement>(null);
808− const composerFormHeightRef = useRef(0);
809− const composerSelectLockRef = useRef(false);
810− const composerMenuOpenRef = useRef(false);
811− const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
812− const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
813− const composerBlurFrameRef = useRef<number | null>(null);
814− const mobileComposerExpandFrameRef = useRef<number | null>(null);
815− const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
816− const mobileComposerExpandInFlightRef = useRef(false);
817− const dragDepthRef = useRef(0);
818−
819− // ------------------------------------------------------------------
820− // Derived: composer send state
821− // ------------------------------------------------------------------
822− const composerSendState = useMemo(
823− () =>
824− deriveComposerSendState({
825− prompt,
826− imageCount: composerImages.length,
827− terminalContexts: composerTerminalContexts,
828− }),
829− [composerImages.length, composerTerminalContexts, prompt],
830− );
735+ const selectedPromptEffort = composerProviderState.promptEffort;
736+ const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
737+ const composerProviderControls = useMemo(
738+ () => ({
739+ showInteractionModeToggle: getProviderInteractionModeToggle(
740+ providerStatuses,
741+ selectedProvider,
742+ ),
743+ }),
744+ [providerStatuses, selectedProvider],
745+ );
746+ const selectedModelSelection = useMemo<ModelSelection>(
747+ () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
748+ [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
749+ );
750+ const selectedModelForPicker = selectedModel;
751+ // Instance-keyed option list so the picker can show each configured
752+ // instance (built-in + custom) as a first-class sidebar entry. The
753+ // options are server-reported models plus that exact instance's
754+ // configured custom models; selected slugs are not injected into lists.
755+ const modelOptionsByInstance = useMemo<
756+ ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
757+ >(() => {
758+ const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
759+ for (const entry of providerInstanceEntries) {
760+ out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
761+ }
762+ return out;
763+ }, [providerInstanceEntries, settings]);
764+ const selectedModelForPickerWithCustomFallback = useMemo(() => {
765+ const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
766+ return currentOptions.some((option) => option.slug === selectedModelForPicker)
767+ ? selectedModelForPicker
768+ : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
769+ }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
770+
771+ // ------------------------------------------------------------------
772+ // Context window
773+ // ------------------------------------------------------------------
774+ const activeContextWindow = useMemo(
775+ () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
776+ [activeThreadActivities],
777+ );
831778
832− // ------------------------------------------------------------------
833− // Derived: composer trigger / menu
834− // ------------------------------------------------------------------
835− const composerTriggerKind = composerTrigger?.kind ?? null;
836− const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
837− const isPathTrigger = composerTriggerKind === "path";
838− const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
839− pathTriggerQuery,
840− { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
841− (debouncerState) => ({ isPending: debouncerState.isPending }),
842− );
843− const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
844− const workspaceEntriesQuery = useQuery(
845− projectSearchEntriesQueryOptions({
846− environmentId,
847− cwd: gitCwd,
848− query: effectivePathQuery,
849− enabled: isPathTrigger,
850− limit: 80,
779+ // ------------------------------------------------------------------
780+ // Composer-local state
781+ // ------------------------------------------------------------------
782+ const [composerCursor, setComposerCursor] = useState(() =>
783+ collapseExpandedComposerCursor(prompt, prompt.length),
784+ );
785+ const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
786+ detectComposerTrigger(prompt, prompt.length),
787+ );
788+ const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
789+ const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
790+ null,
791+ );
792+ const [isDragOverComposer, setIsDragOverComposer] = useState(false);
793+ const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
794+ const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
795+ const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
796+ const [isComposerFocused, setIsComposerFocused] = useState(false);
797+ const isMobileViewport = useMediaQuery("max-sm");
798+ const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
799+
800+ // ------------------------------------------------------------------
801+ // Refs
802+ // ------------------------------------------------------------------
803+ const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
804+ const composerFormRef = useRef<HTMLFormElement>(null);
805+ const composerSurfaceRef = useRef<HTMLDivElement>(null);
806+ const composerFormHeightRef = useRef(0);
807+ const composerSelectLockRef = useRef(false);
808+ const composerMenuOpenRef = useRef(false);
809+ const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
810+ const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
811+ const composerBlurFrameRef = useRef<number | null>(null);
812+ const mobileComposerExpandFrameRef = useRef<number | null>(null);
813+ const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
814+ const mobileComposerExpandInFlightRef = useRef(false);
815+ const dragDepthRef = useRef(0);
816+
817+ // ------------------------------------------------------------------
818+ // Derived: composer send state
819+ // ------------------------------------------------------------------
820+ const composerSendState = useMemo(
821+ () =>
822+ deriveComposerSendState({
823+ prompt,
824+ imageCount: composerImages.length,
825+ terminalContexts: composerTerminalContexts,
851826 }),
852− );
853− const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
854−
855− const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
856− if (!composerTrigger) return [];
857− if (composerTrigger.kind === "path") {
858− return workspaceEntries.map((entry) => ({
859− id: `path:${entry.kind}:${entry.path}`,
860− type: "path",
861− path: entry.path,
862− pathKind: entry.kind,
863− label: basenameOfPath(entry.path),
864− description: entry.parentPath ?? "",
865− }));
866− }
867− if (composerTrigger.kind === "slash-command") {
868− const builtInSlashCommandItems = [
869− {
870− id: "slash:model",
871− type: "slash-command",
872− command: "model",
873− label: "/model",
874− description: "Switch response model for this thread",
875− },
876− {
877− id: "slash:plan",
878− type: "slash-command",
879− command: "plan",
880− label: "/plan",
881− description: "Switch this thread into plan mode",
882− },
883− {
884− id: "slash:default",
885− type: "slash-command",
886− command: "default",
887− label: "/default",
888− description: "Switch this thread back to normal build mode",
889− },
890− ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
891− const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(
892− (command) => ({
893− id: `provider-slash-command:${selectedProvider}:${command.name}`,
894− type: "provider-slash-command" as const,
895− provider: selectedProvider,
896− command,
897− label: `/${command.name}`,
898− description: command.description ?? command.input?.hint ?? "Run provider command",
899− }),
900− );
901− const query = composerTrigger.query.trim().toLowerCase();
902− const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];
903− if (!query) {
904− return slashCommandItems;
905− }
906− return searchSlashCommandItems(slashCommandItems, query);
827+ [composerImages.length, composerTerminalContexts, prompt],
828+ );
829+
830+ // ------------------------------------------------------------------
831+ // Derived: composer trigger / menu
832+ // ------------------------------------------------------------------
833+ const composerTriggerKind = composerTrigger?.kind ?? null;
834+ const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
835+ const isPathTrigger = composerTriggerKind === "path";
836+ const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
837+ pathTriggerQuery,
838+ { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
839+ (debouncerState) => ({ isPending: debouncerState.isPending }),
840+ );
841+ const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
842+ const workspaceEntriesQuery = useQuery(
843+ projectSearchEntriesQueryOptions({
844+ environmentId,
845+ cwd: gitCwd,
846+ query: effectivePathQuery,
847+ enabled: isPathTrigger,
848+ limit: 80,
849+ }),
850+ );
851+ const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
852+
853+ const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
854+ if (!composerTrigger) return [];
855+ if (composerTrigger.kind === "path") {
856+ return workspaceEntries.map((entry) => ({
857+ id: `path:${entry.kind}:${entry.path}`,
858+ type: "path",
859+ path: entry.path,
860+ pathKind: entry.kind,
861+ label: basenameOfPath(entry.path),
862+ description: entry.parentPath ?? "",
863+ }));
864+ }
865+ if (composerTrigger.kind === "slash-command") {
866+ const builtInSlashCommandItems = [
867+ {
868+ id: "slash:model",
869+ type: "slash-command",
870+ command: "model",
871+ label: "/model",
872+ description: "Switch response model for this thread",
873+ },
874+ {
875+ id: "slash:plan",
876+ type: "slash-command",
877+ command: "plan",
878+ label: "/plan",
879+ description: "Switch this thread into plan mode",
880+ },
881+ {
882+ id: "slash:default",
883+ type: "slash-command",
884+ command: "default",
885+ label: "/default",
886+ description: "Switch this thread back to normal build mode",
887+ },
888+ ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
889+ const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(
890+ (command) => ({
891+ id: `provider-slash-command:${selectedProvider}:${command.name}`,
892+ type: "provider-slash-command" as const,
893+ provider: selectedProvider,
894+ command,
895+ label: `/${command.name}`,
896+ description: command.description ?? command.input?.hint ?? "Run provider command",
897+ }),
898+ );
899+ const query = composerTrigger.query.trim().toLowerCase();
900+ const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];
901+ if (!query) {
902+ return slashCommandItems;
907903 }
908− if (composerTrigger.kind === "skill") {
909− return searchProviderSkills(
910− selectedProviderStatus?.skills ?? [],
911− composerTrigger.query,
912− ).map((skill) => ({
904+ return searchSlashCommandItems(slashCommandItems, query);
905+ }
906+ if (composerTrigger.kind === "skill") {
907+ return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
908+ (skill) => ({
913909 id: `skill:${selectedProvider}:${skill.name}`,
914910 type: "skill" as const,
915911 provider: selectedProvider,
@@ -919,1125 +915,1084 @@ export const ChatComposer = memo( · 3 unmodified lines
919915 skill.shortDescription ??
920916 skill.description ??
921917 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
922− }));
923− }
924− return [];
925− }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);
926−
927− const composerMenuOpen = Boolean(composerTrigger);
928− const composerMenuSearchKey = composerTrigger
929− ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
930− : null;
931− const activeComposerMenuItem = useMemo(() => {
932− const activeItemId = resolveComposerMenuActiveItemId({
933− items: composerMenuItems,
934− highlightedItemId: composerHighlightedItemId,
935− currentSearchKey: composerMenuSearchKey,
936− highlightedSearchKey: composerHighlightedSearchKey,
937− });
938− return composerMenuItems.find((item) => item.id === activeItemId) ?? null;
939− }, [
940− composerHighlightedItemId,
941− composerHighlightedSearchKey,
942− composerMenuItems,
943− composerMenuSearchKey,
944− ]);
945−
946− composerMenuOpenRef.current = composerMenuOpen;
947− composerMenuItemsRef.current = composerMenuItems;
948− activeComposerMenuItemRef.current = activeComposerMenuItem;
949−
950− const nonPersistedComposerImageIdSet = useMemo(
951− () => new Set(nonPersistedComposerImageIds),
952− [nonPersistedComposerImageIds],
953− );
954−
955− const isComposerApprovalState = activePendingApproval !== null;
956− const activePendingUserInput = pendingUserInputs[0] ?? null;
957− const hasComposerHeader =
958− isComposerApprovalState ||
959− pendingUserInputs.length > 0 ||
960− (showPlanFollowUpPrompt && activeProposedPlan !== null);
961− const showCollapsedMobilePromptRow =
962− isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
963−
964− const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
965− const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
966− const composerFooterActionLayoutKey = useMemo(() => {
967− if (activePendingProgress) {
968− return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
969− }
970− if (phase === "running") {
971− return "running";
972− }
973− if (showPlanFollowUpPrompt) {
974− return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
975− }
976− return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
977− }, [
978− activePendingIsResponding,
979− activePendingProgress,
980− composerSendState.hasSendableContent,
981− isConnecting,
982− isPreparingWorktree,
983− isSendBusy,
984− phase,
985− prompt,
986− showPlanFollowUpPrompt,
987− ]);
988−
989− const isComposerMenuLoading =
990− composerTriggerKind === "path" &&
991− ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
992− workspaceEntriesQuery.isLoading ||
993− workspaceEntriesQuery.isFetching);
994− const composerMenuEmptyState = useMemo(() => {
995− if (composerTriggerKind === "skill") {
996− return "No skills found. Try / to browse provider commands.";
997− }
998− return composerTriggerKind === "path"
999− ? "No matching files or folders."
1000− : "No matching command.";
1001− }, [composerTriggerKind]);
1002−
1003− // ------------------------------------------------------------------
1004− // Provider traits UI
1005− // ------------------------------------------------------------------
1006− const setPromptFromTraits = useCallback(
1007− (nextPrompt: string) => {
1008− if (nextPrompt === promptRef.current) {
1009− scheduleComposerFocus();
1010− return;
1011− }
1012− promptRef.current = nextPrompt;
1013− setComposerDraftPrompt(composerDraftTarget, nextPrompt);
1014− const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
1015− setComposerCursor(nextCursor);
1016− setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
1017− scheduleComposerFocus();
1018− },
1019− [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
1020− );
1021−
1022− const providerTraitsMenuContent = renderProviderTraitsMenuContent({
1023− provider: selectedProvider,
1024− ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
1025− ...(routeKind === "draft" && draftId ? { draftId } : {}),
1026− model: selectedModel,
1027− models: selectedProviderModels,
1028− modelOptions: composerModelOptions?.[selectedProvider],
1029− prompt,
1030− onPromptChange: setPromptFromTraits,
1031− });
1032− const providerTraitsPicker = renderProviderTraitsPicker({
1033− provider: selectedProvider,
1034− ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
1035− ...(routeKind === "draft" && draftId ? { draftId } : {}),
1036− model: selectedModel,
1037− models: selectedProviderModels,
1038− modelOptions: composerModelOptions?.[selectedProvider],
1039− prompt,
1040− onPromptChange: setPromptFromTraits,
918+ }),
919+ );
920+ }
921+ return [];
922+ }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);
923+
924+ const composerMenuOpen = Boolean(composerTrigger);
925+ const composerMenuSearchKey = composerTrigger
926+ ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
927+ : null;
928+ const activeComposerMenuItem = useMemo(() => {
929+ const activeItemId = resolveComposerMenuActiveItemId({
930+ items: composerMenuItems,
931+ highlightedItemId: composerHighlightedItemId,
932+ currentSearchKey: composerMenuSearchKey,
933+ highlightedSearchKey: composerHighlightedSearchKey,
1041934 });
1042− const pendingPrimaryAction = useMemo(
1043− () =>
1044− activePendingProgress
1045− ? {
1046− questionIndex: activePendingProgress.questionIndex,
1047− isLastQuestion: activePendingProgress.isLastQuestion,
1048− canAdvance: activePendingProgress.canAdvance,
1049− isResponding: activePendingIsResponding,
1050− isComplete: Boolean(activePendingResolvedAnswers),
1051− }
1052− : null,
1053− [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1054− );
1055− const collapsedComposerPrimaryActionDisabled =
1056− phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1057− const collapsedComposerPrimaryActionLabel = "Send message";
1058− const showMobilePendingAnswerActions =
1059− isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
1060−
1061− // ------------------------------------------------------------------
1062− // Prompt helpers
1063− // ------------------------------------------------------------------
1064− const setPrompt = useCallback(
1065− (nextPrompt: string) => {
1066− setComposerDraftPrompt(composerDraftTarget, nextPrompt);
1067− },
1068− [composerDraftTarget, setComposerDraftPrompt],
1069− );
1070−
1071− const addComposerImage = useCallback(
1072− (image: ComposerImageAttachment) => {
1073− addComposerDraftImage(composerDraftTarget, image);
1074− },
1075− [composerDraftTarget, addComposerDraftImage],
1076− );
1077−
1078− const addComposerImagesToDraft = useCallback(
1079− (images: ComposerImageAttachment[]) => {
1080− addComposerDraftImages(composerDraftTarget, images);
1081− },
1082− [composerDraftTarget, addComposerDraftImages],
1083− );
1084−
1085− const removeComposerImageFromDraft = useCallback(
1086− (imageId: string) => {
1087− removeComposerDraftImage(composerDraftTarget, imageId);
1088− },
1089− [composerDraftTarget, removeComposerDraftImage],
1090− );
1091−
1092− const removeComposerTerminalContextFromDraft = useCallback(
1093− (contextId: string) => {
1094− const contextIndex = composerTerminalContexts.findIndex(
1095− (context) => context.id === contextId,
1096− );
1097− if (contextIndex < 0) return;
1098− const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
1099− promptRef.current = removal.prompt;
1100− setPrompt(removal.prompt);
1101− removeComposerDraftTerminalContext(composerDraftTarget, contextId);
1102− const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor);
1103− setComposerCursor(nextCursor);
1104− setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor));
1105− },
1106− [
1107− composerDraftTarget,
1108− composerTerminalContexts,
1109− promptRef,
1110− removeComposerDraftTerminalContext,
1111− setPrompt,
1112− ],
1113− );
935+ return composerMenuItems.find((item) => item.id === activeItemId) ?? null;
936+ }, [
937+ composerHighlightedItemId,
938+ composerHighlightedSearchKey,
939+ composerMenuItems,
940+ composerMenuSearchKey,
941+ ]);
942+
943+ composerMenuOpenRef.current = composerMenuOpen;
944+ composerMenuItemsRef.current = composerMenuItems;
945+ activeComposerMenuItemRef.current = activeComposerMenuItem;
946+
947+ const nonPersistedComposerImageIdSet = useMemo(
948+ () => new Set(nonPersistedComposerImageIds),
949+ [nonPersistedComposerImageIds],
950+ );
1114951
1115− // ------------------------------------------------------------------
1116− // Sync refs back to parent
1117− // ------------------------------------------------------------------
1118− useEffect(() => {
1119− promptRef.current = prompt;
1120− setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
1121− }, [prompt, promptRef]);
1122−
1123− useEffect(() => {
1124− composerImagesRef.current = composerImages;
1125− }, [composerImages, composerImagesRef]);
1126−
1127− useEffect(() => {
1128− composerTerminalContextsRef.current = composerTerminalContexts;
1129− }, [composerTerminalContexts, composerTerminalContextsRef]);
1130−
1131− // ------------------------------------------------------------------
1132− // Composer menu highlight sync
1133− // ------------------------------------------------------------------
1134− useEffect(() => {
1135− if (!composerMenuOpen) {
1136− setComposerHighlightedItemId(null);
1137− setComposerHighlightedSearchKey(null);
1138− return;
1139− }
1140− const nextActiveItemId = resolveComposerMenuActiveItemId({
1141− items: composerMenuItems,
1142− highlightedItemId: composerHighlightedItemId,
1143− currentSearchKey: composerMenuSearchKey,
1144− highlightedSearchKey: composerHighlightedSearchKey,
1145− });
1146− setComposerHighlightedItemId((existing) =>
1147− existing === nextActiveItemId ? existing : nextActiveItemId,
1148− );
1149− setComposerHighlightedSearchKey((existing) =>
1150− existing === composerMenuSearchKey ? existing : composerMenuSearchKey,
1151− );
1152− }, [
1153− composerHighlightedItemId,
1154− composerHighlightedSearchKey,
1155− composerMenuItems,
1156− composerMenuOpen,
1157− composerMenuSearchKey,
1158− ]);
1159−
1160− const lastSyncedPendingInputRef = useRef<{
1161− requestId: string | null;
1162− questionId: string | null;
1163− } | null>(null);
1164−
1165− useEffect(() => {
1166− const nextCustomAnswer = activePendingProgress?.customAnswer;
1167− if (typeof nextCustomAnswer !== "string") {
1168− lastSyncedPendingInputRef.current = null;
952+ const isComposerApprovalState = activePendingApproval !== null;
953+ const activePendingUserInput = pendingUserInputs[0] ?? null;
954+ const hasComposerHeader =
955+ isComposerApprovalState ||
956+ pendingUserInputs.length > 0 ||
957+ (showPlanFollowUpPrompt && activeProposedPlan !== null);
958+ const showCollapsedMobilePromptRow =
959+ isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
960+
961+ const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
962+ const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
963+ const composerFooterActionLayoutKey = useMemo(() => {
964+ if (activePendingProgress) {
965+ return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
966+ }
967+ if (phase === "running") {
968+ return "running";
969+ }
970+ if (showPlanFollowUpPrompt) {
971+ return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
972+ }
973+ return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
974+ }, [
975+ activePendingIsResponding,
976+ activePendingProgress,
977+ composerSendState.hasSendableContent,
978+ isConnecting,
979+ isPreparingWorktree,
980+ isSendBusy,
981+ phase,
982+ prompt,
983+ showPlanFollowUpPrompt,
984+ ]);
985+
986+ const isComposerMenuLoading =
987+ composerTriggerKind === "path" &&
988+ ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
989+ workspaceEntriesQuery.isLoading ||
990+ workspaceEntriesQuery.isFetching);
991+ const composerMenuEmptyState = useMemo(() => {
992+ if (composerTriggerKind === "skill") {
993+ return "No skills found. Try / to browse provider commands.";
994+ }
995+ return composerTriggerKind === "path"
996+ ? "No matching files or folders."
997+ : "No matching command.";
998+ }, [composerTriggerKind]);
999+
1000+ // ------------------------------------------------------------------
1001+ // Provider traits UI
1002+ // ------------------------------------------------------------------
1003+ const setPromptFromTraits = useCallback(
1004+ (nextPrompt: string) => {
1005+ if (nextPrompt === promptRef.current) {
1006+ scheduleComposerFocus();
11691007 return;
11701008 }
1009+ promptRef.current = nextPrompt;
1010+ setComposerDraftPrompt(composerDraftTarget, nextPrompt);
1011+ const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
1012+ setComposerCursor(nextCursor);
1013+ setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
1014+ scheduleComposerFocus();
1015+ },
1016+ [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
1017+ );
11711018
1172− const nextRequestId = activePendingUserInput?.requestId ?? null;
1173− const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
1174− const questionChanged =
1175− lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
1176− lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
1177− const textChangedExternally = promptRef.current !== nextCustomAnswer;
1019+ const providerTraitsMenuContent = renderProviderTraitsMenuContent({
1020+ provider: selectedProvider,
1021+ ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
1022+ ...(routeKind === "draft" && draftId ? { draftId } : {}),
1023+ model: selectedModel,
1024+ models: selectedProviderModels,
1025+ modelOptions: composerModelOptions?.[selectedProvider],
1026+ prompt,
1027+ onPromptChange: setPromptFromTraits,
1028+ });
1029+ const providerTraitsPicker = renderProviderTraitsPicker({
1030+ provider: selectedProvider,
1031+ ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
1032+ ...(routeKind === "draft" && draftId ? { draftId } : {}),
1033+ model: selectedModel,
1034+ models: selectedProviderModels,
1035+ modelOptions: composerModelOptions?.[selectedProvider],
1036+ prompt,
1037+ onPromptChange: setPromptFromTraits,
1038+ });
1039+ const pendingPrimaryAction = useMemo(
1040+ () =>
1041+ activePendingProgress
1042+ ? {
1043+ questionIndex: activePendingProgress.questionIndex,
1044+ isLastQuestion: activePendingProgress.isLastQuestion,
1045+ canAdvance: activePendingProgress.canAdvance,
1046+ isResponding: activePendingIsResponding,
1047+ isComplete: Boolean(activePendingResolvedAnswers),
1048+ }
1049+ : null,
1050+ [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1051+ );
1052+ const collapsedComposerPrimaryActionDisabled =
1053+ phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1054+ const collapsedComposerPrimaryActionLabel = "Send message";
1055+ const showMobilePendingAnswerActions =
1056+ isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
1057+
1058+ // ------------------------------------------------------------------
1059+ // Prompt helpers
1060+ // ------------------------------------------------------------------
1061+ const setPrompt = useCallback(
1062+ (nextPrompt: string) => {
1063+ setComposerDraftPrompt(composerDraftTarget, nextPrompt);
1064+ },
1065+ [composerDraftTarget, setComposerDraftPrompt],
1066+ );
11781067
1179− lastSyncedPendingInputRef.current = {
1180− requestId: nextRequestId,
1181− questionId: nextQuestionId,
1182− };
1068+ const addComposerImage = useCallback(
1069+ (image: ComposerImageAttachment) => {
1070+ addComposerDraftImage(composerDraftTarget, image);
1071+ },
1072+ [composerDraftTarget, addComposerDraftImage],
1073+ );
11831074
1184− if (!questionChanged && !textChangedExternally) {
1185− return;
1186− }
1075+ const addComposerImagesToDraft = useCallback(
1076+ (images: ComposerImageAttachment[]) => {
1077+ addComposerDraftImages(composerDraftTarget, images);
1078+ },
1079+ [composerDraftTarget, addComposerDraftImages],
1080+ );
11871081
1188− promptRef.current = nextCustomAnswer;
1189− const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
1190− setComposerCursor(nextCursor);
1191− setComposerTrigger(
1192− detectComposerTrigger(
1193− nextCustomAnswer,
1194− expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
1195− ),
1082+ const removeComposerImageFromDraft = useCallback(
1083+ (imageId: string) => {
1084+ removeComposerDraftImage(composerDraftTarget, imageId);
1085+ },
1086+ [composerDraftTarget, removeComposerDraftImage],
1087+ );
1088+
1089+ const removeComposerTerminalContextFromDraft = useCallback(
1090+ (contextId: string) => {
1091+ const contextIndex = composerTerminalContexts.findIndex(
1092+ (context) => context.id === contextId,
11961093 );
1197− setComposerHighlightedItemId(null);
1198− }, [
1199− activePendingProgress?.customAnswer,
1200− activePendingProgress?.activeQuestion?.id,
1201− activePendingUserInput?.requestId,
1094+ if (contextIndex < 0) return;
1095+ const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
1096+ promptRef.current = removal.prompt;
1097+ setPrompt(removal.prompt);
1098+ removeComposerDraftTerminalContext(composerDraftTarget, contextId);
1099+ const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor);
1100+ setComposerCursor(nextCursor);
1101+ setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor));
1102+ },
1103+ [
1104+ composerDraftTarget,
1105+ composerTerminalContexts,
12021106 promptRef,
1203− ]);
1107+ removeComposerDraftTerminalContext,
1108+ setPrompt,
1109+ ],
1110+ );
12041111
1205− // ------------------------------------------------------------------
1206− // Reset compositor state on thread/draft change
1207− // ------------------------------------------------------------------
1208− useEffect(() => {
1112+ // ------------------------------------------------------------------
1113+ // Sync refs back to parent
1114+ // ------------------------------------------------------------------
1115+ useEffect(() => {
1116+ promptRef.current = prompt;
1117+ setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
1118+ }, [prompt, promptRef]);
1119+
1120+ useEffect(() => {
1121+ composerImagesRef.current = composerImages;
1122+ }, [composerImages, composerImagesRef]);
1123+
1124+ useEffect(() => {
1125+ composerTerminalContextsRef.current = composerTerminalContexts;
1126+ }, [composerTerminalContexts, composerTerminalContextsRef]);
1127+
1128+ // ------------------------------------------------------------------
1129+ // Composer menu highlight sync
1130+ // ------------------------------------------------------------------
1131+ useEffect(() => {
1132+ if (!composerMenuOpen) {
12091133 setComposerHighlightedItemId(null);
1210− setComposerCursor(
1211− collapseExpandedComposerCursor(promptRef.current, promptRef.current.length),
1212− );
1213− setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1214− dragDepthRef.current = 0;
1215− setIsDragOverComposer(false);
1216− }, [draftId, activeThreadId, promptRef]);
1217−
1218− // ------------------------------------------------------------------
1219− // Footer compact layout observation
1220− // ------------------------------------------------------------------
1221− useLayoutEffect(() => {
1222− const composerForm = composerFormRef.current;
1223− if (!composerForm) return;
1224− const measureComposerFormWidth = () => composerForm.clientWidth;
1225− const measureFooterCompactness = () => {
1226− const composerFormWidth = measureComposerFormWidth();
1227− const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {
1134+ setComposerHighlightedSearchKey(null);
1135+ return;
1136+ }
1137+ const nextActiveItemId = resolveComposerMenuActiveItemId({
1138+ items: composerMenuItems,
1139+ highlightedItemId: composerHighlightedItemId,
1140+ currentSearchKey: composerMenuSearchKey,
1141+ highlightedSearchKey: composerHighlightedSearchKey,
1142+ });
1143+ setComposerHighlightedItemId((existing) =>
1144+ existing === nextActiveItemId ? existing : nextActiveItemId,
1145+ );
1146+ setComposerHighlightedSearchKey((existing) =>
1147+ existing === composerMenuSearchKey ? existing : composerMenuSearchKey,
1148+ );
1149+ }, [
1150+ composerHighlightedItemId,
1151+ composerHighlightedSearchKey,
1152+ composerMenuItems,
1153+ composerMenuOpen,
1154+ composerMenuSearchKey,
1155+ ]);
1156+
1157+ const lastSyncedPendingInputRef = useRef<{
1158+ requestId: string | null;
1159+ questionId: string | null;
1160+ } | null>(null);
1161+
1162+ useEffect(() => {
1163+ const nextCustomAnswer = activePendingProgress?.customAnswer;
1164+ if (typeof nextCustomAnswer !== "string") {
1165+ lastSyncedPendingInputRef.current = null;
1166+ return;
1167+ }
1168+
1169+ const nextRequestId = activePendingUserInput?.requestId ?? null;
1170+ const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
1171+ const questionChanged =
1172+ lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
1173+ lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
1174+ const textChangedExternally = promptRef.current !== nextCustomAnswer;
1175+
1176+ lastSyncedPendingInputRef.current = {
1177+ requestId: nextRequestId,
1178+ questionId: nextQuestionId,
1179+ };
1180+
1181+ if (!questionChanged && !textChangedExternally) {
1182+ return;
1183+ }
1184+
1185+ promptRef.current = nextCustomAnswer;
1186+ const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
1187+ setComposerCursor(nextCursor);
1188+ setComposerTrigger(
1189+ detectComposerTrigger(
1190+ nextCustomAnswer,
1191+ expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
1192+ ),
1193+ );
1194+ setComposerHighlightedItemId(null);
1195+ }, [
1196+ activePendingProgress?.customAnswer,
1197+ activePendingProgress?.activeQuestion?.id,
1198+ activePendingUserInput?.requestId,
1199+ promptRef,
1200+ ]);
1201+
1202+ // ------------------------------------------------------------------
1203+ // Reset compositor state on thread/draft change
1204+ // ------------------------------------------------------------------
1205+ useEffect(() => {
1206+ setComposerHighlightedItemId(null);
1207+ setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
1208+ setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1209+ dragDepthRef.current = 0;
1210+ setIsDragOverComposer(false);
1211+ }, [draftId, activeThreadId, promptRef]);
1212+
1213+ // ------------------------------------------------------------------
1214+ // Footer compact layout observation
1215+ // ------------------------------------------------------------------
1216+ useLayoutEffect(() => {
1217+ const composerForm = composerFormRef.current;
1218+ if (!composerForm) return;
1219+ const measureComposerFormWidth = () => composerForm.clientWidth;
1220+ const measureFooterCompactness = () => {
1221+ const composerFormWidth = measureComposerFormWidth();
1222+ const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {
1223+ hasWideActions: composerFooterHasWideActions,
1224+ });
1225+ const primaryActionsCompact =
1226+ footerCompact &&
1227+ shouldUseCompactComposerPrimaryActions(composerFormWidth, {
12281228 hasWideActions: composerFooterHasWideActions,
12291229 });
1230− const primaryActionsCompact =
1231− footerCompact &&
1232− shouldUseCompactComposerPrimaryActions(composerFormWidth, {
1233− hasWideActions: composerFooterHasWideActions,
1234− });
1235− return {
1236− primaryActionsCompact,
1237− footerCompact,
1238− };
1230+ return {
1231+ primaryActionsCompact,
1232+ footerCompact,
12391233 };
1234+ };
12401235
1241− composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1242− const initialCompactness = measureFooterCompactness();
1243− setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1244− setIsComposerFooterCompact(initialCompactness.footerCompact);
1245− if (typeof ResizeObserver === "undefined") return;
1246−
1247− const observer = new ResizeObserver((entries) => {
1248− const [entry] = entries;
1249− if (!entry) return;
1250− const nextCompactness = measureFooterCompactness();
1251− setIsComposerPrimaryActionsCompact((previous) =>
1252− previous === nextCompactness.primaryActionsCompact
1253− ? previous
1254− : nextCompactness.primaryActionsCompact,
1255− );
1256− setIsComposerFooterCompact((previous) =>
1257− previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
1258− );
1259− const nextHeight = entry.contentRect.height;
1260− const previousHeight = composerFormHeightRef.current;
1261− composerFormHeightRef.current = nextHeight;
1262− if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1263− if (!shouldAutoScrollRef.current) return;
1264− scheduleStickToBottom();
1265− });
1236+ composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1237+ const initialCompactness = measureFooterCompactness();
1238+ setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1239+ setIsComposerFooterCompact(initialCompactness.footerCompact);
1240+ if (typeof ResizeObserver === "undefined") return;
1241+
1242+ const observer = new ResizeObserver((entries) => {
1243+ const [entry] = entries;
1244+ if (!entry) return;
1245+ const nextCompactness = measureFooterCompactness();
1246+ setIsComposerPrimaryActionsCompact((previous) =>
1247+ previous === nextCompactness.primaryActionsCompact
1248+ ? previous
1249+ : nextCompactness.primaryActionsCompact,
1250+ );
1251+ setIsComposerFooterCompact((previous) =>
1252+ previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
1253+ );
1254+ const nextHeight = entry.contentRect.height;
1255+ const previousHeight = composerFormHeightRef.current;
1256+ composerFormHeightRef.current = nextHeight;
1257+ if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1258+ if (!shouldAutoScrollRef.current) return;
1259+ scheduleStickToBottom();
1260+ });
12661261
1267− observer.observe(composerForm);
1268− return () => {
1269− observer.disconnect();
1270− };
1271− }, [
1272− activeThreadId,
1273− composerFooterActionLayoutKey,
1274− composerFooterHasWideActions,
1275− scheduleStickToBottom,
1276− shouldAutoScrollRef,
1277− ]);
1278−
1279− // ------------------------------------------------------------------
1280− // Image persist effect
1281− // ------------------------------------------------------------------
1282− useEffect(() => {
1283− let cancelled = false;
1284− void (async () => {
1285− if (composerImages.length === 0) {
1286− clearComposerDraftPersistedAttachments(composerDraftTarget);
1287− return;
1288− }
1289− const getPersistedAttachmentsForThread = () =>
1290− getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
1291− try {
1292− const currentPersistedAttachments = getPersistedAttachmentsForThread();
1293− const existingPersistedById = new Map(
1294− currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1295− );
1296− const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1297− await Promise.all(
1298− composerImages.map(async (image) => {
1299− try {
1300− const dataUrl = await readFileAsDataUrl(image.file);
1301− stagedAttachmentById.set(image.id, {
1302− id: image.id,
1303− name: image.name,
1304− mimeType: image.mimeType,
1305− sizeBytes: image.sizeBytes,
1306− dataUrl,
1307− });
1308− } catch {
1309− const existingPersisted = existingPersistedById.get(image.id);
1310− if (existingPersisted) {
1311− stagedAttachmentById.set(image.id, existingPersisted);
1312− }
1262+ observer.observe(composerForm);
1263+ return () => {
1264+ observer.disconnect();
1265+ };
1266+ }, [
1267+ activeThreadId,
1268+ composerFooterActionLayoutKey,
1269+ composerFooterHasWideActions,
1270+ scheduleStickToBottom,
1271+ shouldAutoScrollRef,
1272+ ]);
1273+
1274+ // ------------------------------------------------------------------
1275+ // Image persist effect
1276+ // ------------------------------------------------------------------
1277+ useEffect(() => {
1278+ let cancelled = false;
1279+ void (async () => {
1280+ if (composerImages.length === 0) {
1281+ clearComposerDraftPersistedAttachments(composerDraftTarget);
1282+ return;
1283+ }
1284+ const getPersistedAttachmentsForThread = () =>
1285+ getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
1286+ try {
1287+ const currentPersistedAttachments = getPersistedAttachmentsForThread();
1288+ const existingPersistedById = new Map(
1289+ currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1290+ );
1291+ const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1292+ await Promise.all(
1293+ composerImages.map(async (image) => {
1294+ try {
1295+ const dataUrl = await readFileAsDataUrl(image.file);
1296+ stagedAttachmentById.set(image.id, {
1297+ id: image.id,
1298+ name: image.name,
1299+ mimeType: image.mimeType,
1300+ sizeBytes: image.sizeBytes,
1301+ dataUrl,
1302+ });
1303+ } catch {
1304+ const existingPersisted = existingPersistedById.get(image.id);
1305+ if (existingPersisted) {
1306+ stagedAttachmentById.set(image.id, existingPersisted);
13131307 }
1314− }),
1315− );
1316− const serialized = Array.from(stagedAttachmentById.values());
1317− if (cancelled) return;
1318− syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
1319− } catch {
1320− const currentImageIds = new Set(composerImages.map((image) => image.id));
1321− const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
1322− const fallbackPersistedIds = fallbackPersistedAttachments
1323− .map((attachment) => attachment.id)
1324− .filter((id) => currentImageIds.has(id));
1325− const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
1326− const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1327− fallbackPersistedIdSet.has(attachment.id),
1328− );
1329− if (cancelled) return;
1330− syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
1331− }
1332− })();
1333− return () => {
1334− cancelled = true;
1335− };
1336− }, [
1337− composerDraftTarget,
1338− clearComposerDraftPersistedAttachments,
1339− composerImages,
1340− getComposerDraft,
1341− syncComposerDraftPersistedAttachments,
1342− ]);
1343−
1344− // ------------------------------------------------------------------
1345− // Callbacks: prompt change
1346− // ------------------------------------------------------------------
1347− const onPromptChange = useCallback(
1348− (
1349− nextPrompt: string,
1350− nextCursor: number,
1351− expandedCursor: number,
1352− cursorAdjacentToMention: boolean,
1353− terminalContextIds: string[],
1354− ) => {
1355− if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
1356− setComposerCursor(nextCursor);
1357− setComposerTrigger(
1358− cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1359− );
1360− onChangeActivePendingUserInputCustomAnswer(
1361− activePendingProgress.activeQuestion.id,
1362− nextPrompt,
1363− nextCursor,
1364− expandedCursor,
1365− cursorAdjacentToMention,
1366− );
1367− return;
1368− }
1369− promptRef.current = nextPrompt;
1370− setPrompt(nextPrompt);
1371− if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
1372− setComposerDraftTerminalContexts(
1373− composerDraftTarget,
1374− syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
1375− );
1376− }
1308+ }
1309+ }),
1310+ );
1311+ const serialized = Array.from(stagedAttachmentById.values());
1312+ if (cancelled) return;
1313+ syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
1314+ } catch {
1315+ const currentImageIds = new Set(composerImages.map((image) => image.id));
1316+ const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
1317+ const fallbackPersistedIds = fallbackPersistedAttachments
1318+ .map((attachment) => attachment.id)
1319+ .filter((id) => currentImageIds.has(id));
1320+ const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
1321+ const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1322+ fallbackPersistedIdSet.has(attachment.id),
1323+ );
1324+ if (cancelled) return;
1325+ syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
1326+ }
1327+ })();
1328+ return () => {
1329+ cancelled = true;
1330+ };
1331+ }, [
1332+ composerDraftTarget,
1333+ clearComposerDraftPersistedAttachments,
1334+ composerImages,
1335+ getComposerDraft,
1336+ syncComposerDraftPersistedAttachments,
1337+ ]);
1338+
1339+ // ------------------------------------------------------------------
1340+ // Callbacks: prompt change
1341+ // ------------------------------------------------------------------
1342+ const onPromptChange = useCallback(
1343+ (
1344+ nextPrompt: string,
1345+ nextCursor: number,
1346+ expandedCursor: number,
1347+ cursorAdjacentToMention: boolean,
1348+ terminalContextIds: string[],
1349+ ) => {
1350+ if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
13771351 setComposerCursor(nextCursor);
13781352 setComposerTrigger(
13791353 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
13801354 );
1381− },
1382− [
1383− activePendingProgress?.activeQuestion,
1384− pendingUserInputs.length,
1385− onChangeActivePendingUserInputCustomAnswer,
1386− promptRef,
1387− setPrompt,
1388− composerDraftTarget,
1389− composerTerminalContexts,
1390− setComposerDraftTerminalContexts,
1391− ],
1392− );
1393−
1394− // ------------------------------------------------------------------
1395− // Callbacks: prompt replacement / menu
1396− // ------------------------------------------------------------------
1397− const applyPromptReplacement = useCallback(
1398− (
1399− rangeStart: number,
1400− rangeEnd: number,
1401− replacement: string,
1402− options?: { expectedText?: string; focusEditorAfterReplace?: boolean },
1403− ): boolean => {
1404− const currentText = promptRef.current;
1405− const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
1406− const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
1407− if (
1408− options?.expectedText !== undefined &&
1409− currentText.slice(safeStart, safeEnd) !== options.expectedText
1410− ) {
1411− return false;
1412− }
1413− const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1414− const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
1415− const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
1416− promptRef.current = next.text;
1417− const activePendingQuestion = activePendingProgress?.activeQuestion;
1418− if (activePendingQuestion && activePendingUserInput) {
1419− onChangeActivePendingUserInputCustomAnswer(
1420− activePendingQuestion.id,
1421− next.text,
1422− nextCursor,
1423− nextExpandedCursor,
1424− false,
1425− );
1426− } else {
1427− setPrompt(next.text);
1428− }
1429− setComposerCursor(nextCursor);
1430− setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
1431− if (options?.focusEditorAfterReplace !== false) {
1432− window.requestAnimationFrame(() => {
1433− composerEditorRef.current?.focusAt(nextCursor);
1434− });
1435− }
1436− return true;
1437− },
1438− [
1439− activePendingProgress?.activeQuestion,
1440− activePendingUserInput,
1441− onChangeActivePendingUserInputCustomAnswer,
1442− promptRef,
1443− setPrompt,
1444− ],
1445− );
1446−
1447− const readComposerSnapshot = useCallback((): {
1448− value: string;
1449− cursor: number;
1450− expandedCursor: number;
1451− terminalContextIds: string[];
1452− } => {
1453− const editorSnapshot = composerEditorRef.current?.readSnapshot();
1454− if (editorSnapshot) {
1455− return editorSnapshot;
1355+ onChangeActivePendingUserInputCustomAnswer(
1356+ activePendingProgress.activeQuestion.id,
1357+ nextPrompt,
1358+ nextCursor,
1359+ expandedCursor,
1360+ cursorAdjacentToMention,
1361+ );
1362+ return;
14561363 }
1457− return {
1458− value: promptRef.current,
1459− cursor: composerCursor,
1460− expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1461− terminalContextIds: composerTerminalContexts.map((context) => context.id),
1462− };
1463− }, [composerCursor, composerTerminalContexts, promptRef]);
1464−
1465− const resolveActiveComposerTrigger = useCallback((): {
1466− snapshot: { value: string; cursor: number; expandedCursor: number };
1467− trigger: ComposerTrigger | null;
1468− } => {
1469− const snapshot = readComposerSnapshot();
1470− return {
1471− snapshot,
1472− trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
1473− };
1474− }, [readComposerSnapshot]);
1364+ promptRef.current = nextPrompt;
1365+ setPrompt(nextPrompt);
1366+ if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
1367+ setComposerDraftTerminalContexts(
1368+ composerDraftTarget,
1369+ syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
1370+ );
1371+ }
1372+ setComposerCursor(nextCursor);
1373+ setComposerTrigger(
1374+ cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1375+ );
1376+ },
1377+ [
1378+ activePendingProgress?.activeQuestion,
1379+ pendingUserInputs.length,
1380+ onChangeActivePendingUserInputCustomAnswer,
1381+ promptRef,
1382+ setPrompt,
1383+ composerDraftTarget,
1384+ composerTerminalContexts,
1385+ setComposerDraftTerminalContexts,
1386+ ],
1387+ );
14751388
1476− const onSelectComposerItem = useCallback(
1477− (item: ComposerCommandItem) => {
1478− if (composerSelectLockRef.current) return;
1479− composerSelectLockRef.current = true;
1389+ // ------------------------------------------------------------------
1390+ // Callbacks: prompt replacement / menu
1391+ // ------------------------------------------------------------------
1392+ const applyPromptReplacement = useCallback(
1393+ (
1394+ rangeStart: number,
1395+ rangeEnd: number,
1396+ replacement: string,
1397+ options?: { expectedText?: string; focusEditorAfterReplace?: boolean },
1398+ ): boolean => {
1399+ const currentText = promptRef.current;
1400+ const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
1401+ const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
1402+ if (
1403+ options?.expectedText !== undefined &&
1404+ currentText.slice(safeStart, safeEnd) !== options.expectedText
1405+ ) {
1406+ return false;
1407+ }
1408+ const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1409+ const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
1410+ const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
1411+ promptRef.current = next.text;
1412+ const activePendingQuestion = activePendingProgress?.activeQuestion;
1413+ if (activePendingQuestion && activePendingUserInput) {
1414+ onChangeActivePendingUserInputCustomAnswer(
1415+ activePendingQuestion.id,
1416+ next.text,
1417+ nextCursor,
1418+ nextExpandedCursor,
1419+ false,
1420+ );
1421+ } else {
1422+ setPrompt(next.text);
1423+ }
1424+ setComposerCursor(nextCursor);
1425+ setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
1426+ if (options?.focusEditorAfterReplace !== false) {
14801427 window.requestAnimationFrame(() => {
1481− composerSelectLockRef.current = false;
1428+ composerEditorRef.current?.focusAt(nextCursor);
14821429 });
1483− const { snapshot, trigger } = resolveActiveComposerTrigger();
1484− if (!trigger) return;
1485− if (item.type === "path") {
1486− const replacement = `@${item.path} `;
1487− const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1488− snapshot.value,
1489− trigger.rangeEnd,
1490− replacement,
1491− );
1492− const applied = applyPromptReplacement(
1493− trigger.rangeStart,
1494− replacementRangeEnd,
1495− replacement,
1496− { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1497− );
1498− if (applied) {
1499− setComposerHighlightedItemId(null);
1500− }
1501− return;
1430+ }
1431+ return true;
1432+ },
1433+ [
1434+ activePendingProgress?.activeQuestion,
1435+ activePendingUserInput,
1436+ onChangeActivePendingUserInputCustomAnswer,
1437+ promptRef,
1438+ setPrompt,
1439+ ],
1440+ );
1441+
1442+ const readComposerSnapshot = useCallback((): {
1443+ value: string;
1444+ cursor: number;
1445+ expandedCursor: number;
1446+ terminalContextIds: string[];
1447+ } => {
1448+ const editorSnapshot = composerEditorRef.current?.readSnapshot();
1449+ if (editorSnapshot) {
1450+ return editorSnapshot;
1451+ }
1452+ return {
1453+ value: promptRef.current,
1454+ cursor: composerCursor,
1455+ expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1456+ terminalContextIds: composerTerminalContexts.map((context) => context.id),
1457+ };
1458+ }, [composerCursor, composerTerminalContexts, promptRef]);
1459+
1460+ const resolveActiveComposerTrigger = useCallback((): {
1461+ snapshot: { value: string; cursor: number; expandedCursor: number };
1462+ trigger: ComposerTrigger | null;
1463+ } => {
1464+ const snapshot = readComposerSnapshot();
1465+ return {
1466+ snapshot,
1467+ trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
1468+ };
1469+ }, [readComposerSnapshot]);
1470+
1471+ const onSelectComposerItem = useCallback(
1472+ (item: ComposerCommandItem) => {
1473+ if (composerSelectLockRef.current) return;
1474+ composerSelectLockRef.current = true;
1475+ window.requestAnimationFrame(() => {
1476+ composerSelectLockRef.current = false;
1477+ });
1478+ const { snapshot, trigger } = resolveActiveComposerTrigger();
1479+ if (!trigger) return;
1480+ if (item.type === "path") {
1481+ const replacement = `@${item.path} `;
1482+ const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1483+ snapshot.value,
1484+ trigger.rangeEnd,
1485+ replacement,
1486+ );
1487+ const applied = applyPromptReplacement(
1488+ trigger.rangeStart,
1489+ replacementRangeEnd,
1490+ replacement,
1491+ { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1492+ );
1493+ if (applied) {
1494+ setComposerHighlightedItemId(null);
15021495 }
1503− if (item.type === "slash-command") {
1504− if (item.command === "model") {
1505− const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1506− expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1507− focusEditorAfterReplace: false,
1508− });
1509− if (applied) {
1510− setComposerHighlightedItemId(null);
1511− setIsComposerModelPickerOpen(true);
1512− }
1513− return;
1514− }
1515− void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
1496+ return;
1497+ }
1498+ if (item.type === "slash-command") {
1499+ if (item.command === "model") {
15161500 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
15171501 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1502+ focusEditorAfterReplace: false,
15181503 });
15191504 if (applied) {
15201505 setComposerHighlightedItemId(null);
1506+ setIsComposerModelPickerOpen(true);
15211507 }
15221508 return;
15231509 }
1524− if (item.type === "provider-slash-command") {
1525− const replacement = `/${item.command.name} `;
1526− const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1527− snapshot.value,
1528− trigger.rangeEnd,
1529− replacement,
1530− );
1531− const applied = applyPromptReplacement(
1532− trigger.rangeStart,
1533− replacementRangeEnd,
1534− replacement,
1535− { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1536− );
1537− if (applied) {
1538− setComposerHighlightedItemId(null);
1539− }
1540− return;
1541− }
1542− if (item.type === "skill") {
1543− const replacement = `$${item.skill.name} `;
1544− const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1545− snapshot.value,
1546− trigger.rangeEnd,
1547− replacement,
1548− );
1549− const applied = applyPromptReplacement(
1550− trigger.rangeStart,
1551− replacementRangeEnd,
1552− replacement,
1553− { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1554− );
1555− if (applied) {
1556− setComposerHighlightedItemId(null);
1557− }
1558− return;
1510+ void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
1511+ const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1512+ expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1513+ });
1514+ if (applied) {
1515+ setComposerHighlightedItemId(null);
15591516 }
1560− },
1561− [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger],
1562− );
1563−
1564− const onComposerMenuItemHighlighted = useCallback(
1565− (itemId: string | null) => {
1566− setComposerHighlightedItemId(itemId);
1567− setComposerHighlightedSearchKey(composerMenuSearchKey);
1568− },
1569− [composerMenuSearchKey],
1570− );
1571−
1572− const nudgeComposerMenuHighlight = useCallback(
1573− (key: "ArrowDown" | "ArrowUp") => {
1574− if (composerMenuItems.length === 0) return;
1575− const highlightedIndex = composerMenuItems.findIndex(
1576− (item) => item.id === composerHighlightedItemId,
1577− );
1578− const normalizedIndex =
1579− highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
1580− const offset = key === "ArrowDown" ? 1 : -1;
1581− const nextIndex =
1582− (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
1583− const nextItem = composerMenuItems[nextIndex];
1584− setComposerHighlightedItemId(nextItem?.id ?? null);
1585− },
1586− [composerHighlightedItemId, composerMenuItems],
1587− );
1588−
1589− const blurMobileComposerAfterSend = useCallback(() => {
1590− if (!isMobileViewport) return;
1591− if (composerBlurFrameRef.current !== null) {
1592− window.cancelAnimationFrame(composerBlurFrameRef.current);
1593− composerBlurFrameRef.current = null;
1594− }
1595− const activeElement = document.activeElement;
1596− if (activeElement instanceof HTMLElement) {
1597− activeElement.blur();
1598− }
1599− setIsComposerFocused(false);
1600− }, [isMobileViewport]);
1601−
1602− const shouldBlurMobileComposerOnSubmit = useCallback(() => {
1603− if (!isMobileViewport) return false;
1604− if (isSendBusy || isConnecting || phase === "running") return false;
1605− if (activePendingProgress) {
1606− return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
1517+ return;
16071518 }
1608− return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
1609− }, [
1610− activePendingProgress,
1611− activePendingResolvedAnswers,
1612− composerSendState.hasSendableContent,
1613− isConnecting,
1614− isMobileViewport,
1615− isSendBusy,
1616− phase,
1617− showPlanFollowUpPrompt,
1618− ]);
1619−
1620− const submitComposer = useCallback(
1621− (event?: { preventDefault: () => void }) => {
1622− onSend(event);
1623− if (shouldBlurMobileComposerOnSubmit()) {
1624− blurMobileComposerAfterSend();
1519+ if (item.type === "provider-slash-command") {
1520+ const replacement = `/${item.command.name} `;
1521+ const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1522+ snapshot.value,
1523+ trigger.rangeEnd,
1524+ replacement,
1525+ );
1526+ const applied = applyPromptReplacement(
1527+ trigger.rangeStart,
1528+ replacementRangeEnd,
1529+ replacement,
1530+ { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1531+ );
1532+ if (applied) {
1533+ setComposerHighlightedItemId(null);
16251534 }
1626− },
1627− [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1628− );
1629− const expandMobileComposer = useCallback(() => {
1630− if (composerBlurFrameRef.current !== null) {
1631− window.cancelAnimationFrame(composerBlurFrameRef.current);
1632− composerBlurFrameRef.current = null;
1633− }
1634− if (mobileComposerExpandFrameRef.current !== null) {
1635− window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1535+ return;
16361536 }
1637− if (mobileComposerExpandReleaseFrameRef.current !== null) {
1638− window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1537+ if (item.type === "skill") {
1538+ const replacement = `$${item.skill.name} `;
1539+ const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1540+ snapshot.value,
1541+ trigger.rangeEnd,
1542+ replacement,
1543+ );
1544+ const applied = applyPromptReplacement(
1545+ trigger.rangeStart,
1546+ replacementRangeEnd,
1547+ replacement,
1548+ { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1549+ );
1550+ if (applied) {
1551+ setComposerHighlightedItemId(null);
1552+ }
1553+ return;
16391554 }
1640− mobileComposerExpandInFlightRef.current = true;
1641− setIsComposerFocused(true);
1642− mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
1643− mobileComposerExpandFrameRef.current = null;
1644− composerEditorRef.current?.focusAtEnd();
1645− mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
1646− mobileComposerExpandReleaseFrameRef.current = null;
1647− mobileComposerExpandInFlightRef.current = false;
1648− });
1555+ },
1556+ [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger],
1557+ );
1558+
1559+ const onComposerMenuItemHighlighted = useCallback(
1560+ (itemId: string | null) => {
1561+ setComposerHighlightedItemId(itemId);
1562+ setComposerHighlightedSearchKey(composerMenuSearchKey);
1563+ },
1564+ [composerMenuSearchKey],
1565+ );
1566+
1567+ const nudgeComposerMenuHighlight = useCallback(
1568+ (key: "ArrowDown" | "ArrowUp") => {
1569+ if (composerMenuItems.length === 0) return;
1570+ const highlightedIndex = composerMenuItems.findIndex(
1571+ (item) => item.id === composerHighlightedItemId,
1572+ );
1573+ const normalizedIndex =
1574+ highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
1575+ const offset = key === "ArrowDown" ? 1 : -1;
1576+ const nextIndex =
1577+ (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
1578+ const nextItem = composerMenuItems[nextIndex];
1579+ setComposerHighlightedItemId(nextItem?.id ?? null);
1580+ },
1581+ [composerHighlightedItemId, composerMenuItems],
1582+ );
1583+
1584+ const blurMobileComposerAfterSend = useCallback(() => {
1585+ if (!isMobileViewport) return;
1586+ if (composerBlurFrameRef.current !== null) {
1587+ window.cancelAnimationFrame(composerBlurFrameRef.current);
1588+ composerBlurFrameRef.current = null;
1589+ }
1590+ const activeElement = document.activeElement;
1591+ if (activeElement instanceof HTMLElement) {
1592+ activeElement.blur();
1593+ }
1594+ setIsComposerFocused(false);
1595+ }, [isMobileViewport]);
1596+
1597+ const shouldBlurMobileComposerOnSubmit = useCallback(() => {
1598+ if (!isMobileViewport) return false;
1599+ if (isSendBusy || isConnecting || phase === "running") return false;
1600+ if (activePendingProgress) {
1601+ return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
1602+ }
1603+ return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
1604+ }, [
1605+ activePendingProgress,
1606+ activePendingResolvedAnswers,
1607+ composerSendState.hasSendableContent,
1608+ isConnecting,
1609+ isMobileViewport,
1610+ isSendBusy,
1611+ phase,
1612+ showPlanFollowUpPrompt,
1613+ ]);
1614+
1615+ const submitComposer = useCallback(
1616+ (event?: { preventDefault: () => void }) => {
1617+ onSend(event);
1618+ if (shouldBlurMobileComposerOnSubmit()) {
1619+ blurMobileComposerAfterSend();
1620+ }
1621+ },
1622+ [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1623+ );
1624+ const expandMobileComposer = useCallback(() => {
1625+ if (composerBlurFrameRef.current !== null) {
1626+ window.cancelAnimationFrame(composerBlurFrameRef.current);
1627+ composerBlurFrameRef.current = null;
1628+ }
1629+ if (mobileComposerExpandFrameRef.current !== null) {
1630+ window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1631+ }
1632+ if (mobileComposerExpandReleaseFrameRef.current !== null) {
1633+ window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1634+ }
1635+ mobileComposerExpandInFlightRef.current = true;
1636+ setIsComposerFocused(true);
1637+ mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
1638+ mobileComposerExpandFrameRef.current = null;
1639+ composerEditorRef.current?.focusAtEnd();
1640+ mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
1641+ mobileComposerExpandReleaseFrameRef.current = null;
1642+ mobileComposerExpandInFlightRef.current = false;
16491643 });
1650− }, []);
1651−
1652− // ------------------------------------------------------------------
1653− // Callbacks: command key
1654− // ------------------------------------------------------------------
1655− const onComposerCommandKey = (
1656− key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
1657− event: KeyboardEvent,
1658− ) => {
1659− if (key === "Tab" && event.shiftKey) {
1660− toggleInteractionMode();
1644+ });
1645+ }, []);
1646+
1647+ // ------------------------------------------------------------------
1648+ // Callbacks: command key
1649+ // ------------------------------------------------------------------
1650+ const onComposerCommandKey = (
1651+ key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
1652+ event: KeyboardEvent,
1653+ ) => {
1654+ if (key === "Tab" && event.shiftKey) {
1655+ toggleInteractionMode();
1656+ return true;
1657+ }
1658+ const { trigger } = resolveActiveComposerTrigger();
1659+ const menuIsActive = composerMenuOpenRef.current || trigger !== null;
1660+ if (menuIsActive) {
1661+ const currentItems = composerMenuItemsRef.current;
1662+ const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
1663+ if (key === "ArrowDown" && currentItems.length > 0) {
1664+ nudgeComposerMenuHighlight("ArrowDown");
16611665 return true;
16621666 }
1663− const { trigger } = resolveActiveComposerTrigger();
1664− const menuIsActive = composerMenuOpenRef.current || trigger !== null;
1665− if (menuIsActive) {
1666− const currentItems = composerMenuItemsRef.current;
1667− const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
1668− if (key === "ArrowDown" && currentItems.length > 0) {
1669− nudgeComposerMenuHighlight("ArrowDown");
1670− return true;
1671− }
1672− if (key === "ArrowUp" && currentItems.length > 0) {
1673− nudgeComposerMenuHighlight("ArrowUp");
1674− return true;
1675− }
1676− if ((key === "Enter" || key === "Tab") && selectedItem) {
1677− onSelectComposerItem(selectedItem);
1678− return true;
1679− }
1667+ if (key === "ArrowUp" && currentItems.length > 0) {
1668+ nudgeComposerMenuHighlight("ArrowUp");
1669+ return true;
16801670 }
1681− if (key === "Enter" && !event.shiftKey) {
1682− submitComposer();
1671+ if ((key === "Enter" || key === "Tab") && selectedItem) {
1672+ onSelectComposerItem(selectedItem);
16831673 return true;
16841674 }
1685− return false;
1686− };
1675+ }
1676+ if (key === "Enter" && !event.shiftKey) {
1677+ submitComposer();
1678+ return true;
1679+ }
1680+ return false;
1681+ };
16871682
1688− // ------------------------------------------------------------------
1689− // Callbacks: images
1690− // ------------------------------------------------------------------
1691− const addComposerImages = (files: File[]) => {
1692− if (!activeThreadId || files.length === 0) return;
1693− if (pendingUserInputs.length > 0) {
1694− toastManager.add({
1695− type: "error",
1696− title: "Attach images after answering plan questions.",
1697− });
1698− return;
1683+ // ------------------------------------------------------------------
1684+ // Callbacks: images
1685+ // ------------------------------------------------------------------
1686+ const addComposerImages = (files: File[]) => {
1687+ if (!activeThreadId || files.length === 0) return;
1688+ if (pendingUserInputs.length > 0) {
1689+ toastManager.add({
1690+ type: "error",
1691+ title: "Attach images after answering plan questions.",
1692+ });
1693+ return;
1694+ }
1695+ const nextImages: ComposerImageAttachment[] = [];
1696+ let nextImageCount = composerImagesRef.current.length;
1697+ let error: string | null = null;
1698+ for (const file of files) {
1699+ if (!file.type.startsWith("image/")) {
1700+ error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
1701+ continue;
16991702 }
1700− const nextImages: ComposerImageAttachment[] = [];
1701− let nextImageCount = composerImagesRef.current.length;
1702− let error: string | null = null;
1703− for (const file of files) {
1704− if (!file.type.startsWith("image/")) {
1705− error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
1706− continue;
1707− }
1708− if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
1709− error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
1710− continue;
1711− }
1712− if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1713− error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
1714− break;
1715− }
1716− const previewUrl = URL.createObjectURL(file);
1717− nextImages.push({
1718− type: "image",
1719− id: randomUUID(),
1720− name: file.name || "image",
1721− mimeType: file.type,
1722− sizeBytes: file.size,
1723− previewUrl,
1724− file,
1725− });
1726− nextImageCount += 1;
1703+ if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
1704+ error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
1705+ continue;
17271706 }
1728− if (nextImages.length === 1 && nextImages[0]) {
1729− addComposerImage(nextImages[0]);
1730− } else if (nextImages.length > 1) {
1731− addComposerImagesToDraft(nextImages);
1707+ if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1708+ error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
1709+ break;
17321710 }
1733− setThreadError(activeThreadId, error);
1734− };
1735−
1736− const removeComposerImage = (imageId: string) => {
1737− removeComposerImageFromDraft(imageId);
1738− };
1711+ const previewUrl = URL.createObjectURL(file);
1712+ nextImages.push({
1713+ type: "image",
1714+ id: randomUUID(),
1715+ name: file.name || "image",
1716+ mimeType: file.type,
1717+ sizeBytes: file.size,
1718+ previewUrl,
1719+ file,
1720+ });
1721+ nextImageCount += 1;
1722+ }
1723+ if (nextImages.length === 1 && nextImages[0]) {
1724+ addComposerImage(nextImages[0]);
1725+ } else if (nextImages.length > 1) {
1726+ addComposerImagesToDraft(nextImages);
1727+ }
1728+ setThreadError(activeThreadId, error);
1729+ };
17391730
1740− // ------------------------------------------------------------------
1741− // Callbacks: paste / drag
1742− // ------------------------------------------------------------------
1743− const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
1744− const files = Array.from(event.clipboardData.files);
1745− if (files.length === 0) return;
1746− const imageFiles = files.filter((file) => file.type.startsWith("image/"));
1747− if (imageFiles.length === 0) return;
1748− event.preventDefault();
1749− addComposerImages(imageFiles);
1750− };
1731+ const removeComposerImage = (imageId: string) => {
1732+ removeComposerImageFromDraft(imageId);
1733+ };
17511734
1752− const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
1753− if (!event.dataTransfer.types.includes("Files")) return;
1754− event.preventDefault();
1755− dragDepthRef.current += 1;
1756− setIsDragOverComposer(true);
1757− };
1735+ // ------------------------------------------------------------------
1736+ // Callbacks: paste / drag
1737+ // ------------------------------------------------------------------
1738+ const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
1739+ const files = Array.from(event.clipboardData.files);
1740+ if (files.length === 0) return;
1741+ const imageFiles = files.filter((file) => file.type.startsWith("image/"));
1742+ if (imageFiles.length === 0) return;
1743+ event.preventDefault();
1744+ addComposerImages(imageFiles);
1745+ };
17581746
1759− const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
1760− if (!event.dataTransfer.types.includes("Files")) return;
1761− event.preventDefault();
1762− event.dataTransfer.dropEffect = "copy";
1763− setIsDragOverComposer(true);
1764− };
1747+ const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
1748+ if (!event.dataTransfer.types.includes("Files")) return;
1749+ event.preventDefault();
1750+ dragDepthRef.current += 1;
1751+ setIsDragOverComposer(true);
1752+ };
17651753
1766− const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
1767− if (!event.dataTransfer.types.includes("Files")) return;
1768− event.preventDefault();
1769− const nextTarget = event.relatedTarget;
1770− if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
1771− dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
1772− if (dragDepthRef.current === 0) {
1773− setIsDragOverComposer(false);
1774− }
1775− };
1754+ const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
1755+ if (!event.dataTransfer.types.includes("Files")) return;
1756+ event.preventDefault();
1757+ event.dataTransfer.dropEffect = "copy";
1758+ setIsDragOverComposer(true);
1759+ };
17761760
1777− const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
1778− if (!event.dataTransfer.types.includes("Files")) return;
1779− event.preventDefault();
1780− dragDepthRef.current = 0;
1761+ const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
1762+ if (!event.dataTransfer.types.includes("Files")) return;
1763+ event.preventDefault();
1764+ const nextTarget = event.relatedTarget;
1765+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
1766+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
1767+ if (dragDepthRef.current === 0) {
17811768 setIsDragOverComposer(false);
1782− const files = Array.from(event.dataTransfer.files);
1783− addComposerImages(files);
1784− focusComposer();
1785− };
1786− const handleInterruptPrimaryAction = useCallback(() => {
1787− void onInterrupt();
1788− }, [onInterrupt]);
1789− const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
1790− void onImplementPlanInNewThread();
1791− }, [onImplementPlanInNewThread]);
1792− const scheduleComposerCollapseCheck = useCallback(() => {
1793− if (!isMobileViewport) {
1769+ }
1770+ };
1771+
1772+ const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
1773+ if (!event.dataTransfer.types.includes("Files")) return;
1774+ event.preventDefault();
1775+ dragDepthRef.current = 0;
1776+ setIsDragOverComposer(false);
1777+ const files = Array.from(event.dataTransfer.files);
1778+ addComposerImages(files);
1779+ focusComposer();
1780+ };
1781+ const handleInterruptPrimaryAction = useCallback(() => {
1782+ void onInterrupt();
1783+ }, [onInterrupt]);
1784+ const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
1785+ void onImplementPlanInNewThread();
1786+ }, [onImplementPlanInNewThread]);
1787+ const scheduleComposerCollapseCheck = useCallback(() => {
1788+ if (!isMobileViewport) {
1789+ return;
1790+ }
1791+ if (mobileComposerExpandInFlightRef.current) {
1792+ return;
1793+ }
1794+ if (composerBlurFrameRef.current !== null) {
1795+ window.cancelAnimationFrame(composerBlurFrameRef.current);
1796+ }
1797+ composerBlurFrameRef.current = window.requestAnimationFrame(() => {
1798+ composerBlurFrameRef.current = null;
1799+ if (mobileComposerExpandInFlightRef.current) {
17941800 return;
17951801 }
1796− if (mobileComposerExpandInFlightRef.current) {
1802+ const composerSurface = composerSurfaceRef.current;
1803+ const activeElement = document.activeElement;
1804+ if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
17971805 return;
17981806 }
1807+ if (
1808+ composerSurface &&
1809+ activeElement instanceof Node &&
1810+ composerSurface.contains(activeElement)
1811+ ) {
1812+ return;
1813+ }
1814+ setIsComposerFocused(false);
1815+ });
1816+ }, [isMobileViewport]);
1817+
1818+ useEffect(() => {
1819+ return () => {
17991820 if (composerBlurFrameRef.current !== null) {
18001821 window.cancelAnimationFrame(composerBlurFrameRef.current);
18011822 }
1802− composerBlurFrameRef.current = window.requestAnimationFrame(() => {
1803− composerBlurFrameRef.current = null;
1804− if (mobileComposerExpandInFlightRef.current) {
1805− return;
1806− }
1807− const composerSurface = composerSurfaceRef.current;
1808− const activeElement = document.activeElement;
1809− if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
1810− return;
1811− }
1812− if (
1813− composerSurface &&
1814− activeElement instanceof Node &&
1815− composerSurface.contains(activeElement)
1816− ) {
1817− return;
1818− }
1819− setIsComposerFocused(false);
1820− });
1821− }, [isMobileViewport]);
1822−
1823− useEffect(() => {
1824− return () => {
1825− if (composerBlurFrameRef.current !== null) {
1826− window.cancelAnimationFrame(composerBlurFrameRef.current);
1827− }
1828− if (mobileComposerExpandFrameRef.current !== null) {
1829− window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1830− }
1831− if (mobileComposerExpandReleaseFrameRef.current !== null) {
1832− window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1833− }
1834− };
1835− }, []);
1836−
1837− // ------------------------------------------------------------------
1838− // Imperative handle
1839− // ------------------------------------------------------------------
1840− useImperativeHandle(
1841− ref,
1842− () => ({
1843− focusAtEnd: () => {
1844− composerEditorRef.current?.focusAtEnd();
1845− },
1846− focusAt: (cursor: number) => {
1847− composerEditorRef.current?.focusAt(cursor);
1848− },
1849− openModelPicker: () => {
1850− setIsComposerModelPickerOpen(true);
1851− },
1852− toggleModelPicker: () => {
1853− setIsComposerModelPickerOpen((open) => !open);
1854− },
1855− isModelPickerOpen: () => isComposerModelPickerOpen,
1856− readSnapshot: () => {
1857− return readComposerSnapshot();
1858− },
1859− resetCursorState: (options?: {
1860− cursor?: number;
1861− prompt?: string;
1862− detectTrigger?: boolean;
1863− }) => {
1864− const promptForState = options?.prompt ?? promptRef.current;
1865− const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
1866− setComposerHighlightedItemId(null);
1867− setComposerCursor(cursor);
1868− setComposerTrigger(
1869− options?.detectTrigger
1870− ? detectComposerTrigger(
1871− promptForState,
1872− expandCollapsedComposerCursor(promptForState, cursor),
1873− )
1874− : null,
1875− );
1876− },
1877− addTerminalContext: (selection: TerminalContextSelection) => {
1878− if (!activeThread) return;
1879− const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1880− value: promptRef.current,
1881− cursor: composerCursor,
1882− expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1883− terminalContextIds: composerTerminalContexts.map((context) => context.id),
1884− };
1885− const insertion = insertInlineTerminalContextPlaceholder(
1886− snapshot.value,
1887− snapshot.expandedCursor,
1888− );
1889− const nextCollapsedCursor = collapseExpandedComposerCursor(
1890− insertion.prompt,
1891− insertion.cursor,
1892− );
1893− const inserted = insertComposerDraftTerminalContext(
1894− composerDraftTarget,
1895− insertion.prompt,
1896− {
1897− id: randomUUID(),
1898− threadId: activeThread.id,
1899− createdAt: new Date().toISOString(),
1900− ...selection,
1901− },
1902− insertion.contextIndex,
1903− );
1904− if (!inserted) return;
1905− promptRef.current = insertion.prompt;
1906− setComposerCursor(nextCollapsedCursor);
1907− setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
1908− window.requestAnimationFrame(() => {
1909− composerEditorRef.current?.focusAt(nextCollapsedCursor);
1910− });
1911− },
1912− getSendContext: () => ({
1913− prompt: promptRef.current,
1914− images: composerImagesRef.current,
1915− terminalContexts: composerTerminalContextsRef.current,
1916− selectedPromptEffort,
1917− selectedModelOptionsForDispatch,
1918− selectedModelSelection,
1919− selectedProvider,
1920− selectedModel,
1921− selectedProviderModels,
1922− }),
1923− }),
1924− [
1925− activeThread,
1926− composerDraftTarget,
1927− composerCursor,
1928− composerTerminalContexts,
1929− insertComposerDraftTerminalContext,
1930− promptRef,
1931− composerImagesRef,
1932− composerTerminalContextsRef,
1933− isComposerModelPickerOpen,
1934− readComposerSnapshot,
1935− selectedModel,
1823+ if (mobileComposerExpandFrameRef.current !== null) {
1824+ window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1825+ }
1826+ if (mobileComposerExpandReleaseFrameRef.current !== null) {
1827+ window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1828+ }
1829+ };
1830+ }, []);
1831+
1832+ // ------------------------------------------------------------------
1833+ // Imperative handle
1834+ // ------------------------------------------------------------------
1835+ useImperativeHandle(
1836+ composerRef,
1837+ () => ({
1838+ focusAtEnd: () => {
1839+ composerEditorRef.current?.focusAtEnd();
1840+ },
1841+ focusAt: (cursor: number) => {
1842+ composerEditorRef.current?.focusAt(cursor);
1843+ },
1844+ openModelPicker: () => {
1845+ setIsComposerModelPickerOpen(true);
1846+ },
1847+ toggleModelPicker: () => {
1848+ setIsComposerModelPickerOpen((open) => !open);
1849+ },
1850+ isModelPickerOpen: () => isComposerModelPickerOpen,
1851+ readSnapshot: () => {
1852+ return readComposerSnapshot();
1853+ },
1854+ resetCursorState: (options?: {
1855+ cursor?: number;
1856+ prompt?: string;
1857+ detectTrigger?: boolean;
1858+ }) => {
1859+ const promptForState = options?.prompt ?? promptRef.current;
1860+ const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
1861+ setComposerHighlightedItemId(null);
1862+ setComposerCursor(cursor);
1863+ setComposerTrigger(
1864+ options?.detectTrigger
1865+ ? detectComposerTrigger(
1866+ promptForState,
1867+ expandCollapsedComposerCursor(promptForState, cursor),
1868+ )
1869+ : null,
1870+ );
1871+ },
1872+ addTerminalContext: (selection: TerminalContextSelection) => {
1873+ if (!activeThread) return;
1874+ const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1875+ value: promptRef.current,
1876+ cursor: composerCursor,
1877+ expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1878+ terminalContextIds: composerTerminalContexts.map((context) => context.id),
1879+ };
1880+ const insertion = insertInlineTerminalContextPlaceholder(
1881+ snapshot.value,
1882+ snapshot.expandedCursor,
1883+ );
1884+ const nextCollapsedCursor = collapseExpandedComposerCursor(
1885+ insertion.prompt,
1886+ insertion.cursor,
1887+ );
1888+ const inserted = insertComposerDraftTerminalContext(
1889+ composerDraftTarget,
1890+ insertion.prompt,
1891+ {
1892+ id: randomUUID(),
1893+ threadId: activeThread.id,
1894+ createdAt: new Date().toISOString(),
1895+ ...selection,
1896+ },
1897+ insertion.contextIndex,
1898+ );
1899+ if (!inserted) return;
1900+ promptRef.current = insertion.prompt;
1901+ setComposerCursor(nextCollapsedCursor);
1902+ setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
1903+ window.requestAnimationFrame(() => {
1904+ composerEditorRef.current?.focusAt(nextCollapsedCursor);
1905+ });
1906+ },
1907+ getSendContext: () => ({
1908+ prompt: promptRef.current,
1909+ images: composerImagesRef.current,
1910+ terminalContexts: composerTerminalContextsRef.current,
1911+ selectedPromptEffort,
19361912 selectedModelOptionsForDispatch,
19371913 selectedModelSelection,
1938− selectedPromptEffort,
19391914 selectedProvider,
1915+ selectedModel,
19401916 selectedProviderModels,
1941− ],
1942− );
1917+ }),
1918+ }),
1919+ [
1920+ activeThread,
1921+ composerDraftTarget,
1922+ composerCursor,
1923+ composerTerminalContexts,
1924+ insertComposerDraftTerminalContext,
1925+ promptRef,
1926+ composerImagesRef,
1927+ composerTerminalContextsRef,
1928+ isComposerModelPickerOpen,
1929+ readComposerSnapshot,
1930+ selectedModel,
1931+ selectedModelOptionsForDispatch,
1932+ selectedModelSelection,
1933+ selectedPromptEffort,
1934+ selectedProvider,
1935+ selectedProviderModels,
1936+ ],
1937+ );
19431938
1944− // Render
1945− // ------------------------------------------------------------------
1946− return (
1947− <form
1948− ref={composerFormRef}
1949− onSubmit={submitComposer}
1950− className="mx-auto w-full min-w-0 max-w-208"
1951− data-chat-composer-form="true"
1939+ // Render
1940+ // ------------------------------------------------------------------
1941+ return (
1942+ <form
1943+ ref={composerFormRef}
1944+ onSubmit={submitComposer}
1945+ className="mx-auto w-full min-w-0 max-w-208"
1946+ data-chat-composer-form="true"
1947+ >
1948+ <div
1949+ className={cn(
1950+ "group rounded-[22px] p-px transition-colors duration-200",
1951+ composerProviderState.composerFrameClassName,
1952+ )}
1953+ onDragEnter={onComposerDragEnter}
1954+ onDragOver={onComposerDragOver}
1955+ onDragLeave={onComposerDragLeave}
1956+ onDrop={onComposerDrop}
19521957 >
19531958 <div
1959+ ref={composerSurfaceRef}
1960+ data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
19541961 className={cn(
1955− "group rounded-[22px] p-px transition-colors duration-200",
1956− composerProviderState.composerFrameClassName,
1962+ "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1963+ isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1964+ environmentUnavailable ? "opacity-75" : null,
1965+ composerProviderState.composerSurfaceClassName,
19571966 )}
1958− onDragEnter={onComposerDragEnter}
1959− onDragOver={onComposerDragOver}
1960− onDragLeave={onComposerDragLeave}
1961− onDrop={onComposerDrop}
1967+ onFocusCapture={(event) => {
1968+ const activeElement = event.target;
1969+ if (
1970+ isComposerCollapsedMobile &&
1971+ activeElement instanceof HTMLElement &&
1972+ activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
1973+ ) {
1974+ return;
1975+ }
1976+ if (composerBlurFrameRef.current !== null) {
1977+ window.cancelAnimationFrame(composerBlurFrameRef.current);
1978+ composerBlurFrameRef.current = null;
1979+ }
1980+ setIsComposerFocused(true);
1981+ }}
1982+ onBlurCapture={() => {
1983+ scheduleComposerCollapseCheck();
1984+ }}
19621985 >
1963− <div
1964− ref={composerSurfaceRef}
1965− data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
1966− className={cn(
1967− "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1968− isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1969− environmentUnavailable ? "opacity-75" : null,
1970− composerProviderState.composerSurfaceClassName,
1971− )}
1972− onFocusCapture={(event) => {
1973− const activeElement = event.target;
1974− if (
1975− isComposerCollapsedMobile &&
1976− activeElement instanceof HTMLElement &&
1977− activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
1978− ) {
1979− return;
1980− }
1981− if (composerBlurFrameRef.current !== null) {
1982− window.cancelAnimationFrame(composerBlurFrameRef.current);
1983− composerBlurFrameRef.current = null;
1984− }
1985− setIsComposerFocused(true);
1986− }}
1987− onBlurCapture={() => {
1988− scheduleComposerCollapseCheck();
1989− }}
1990− >
1991− {!isComposerCollapsedMobile &&
1992− (activePendingApproval ? (
1993− <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1994− <ComposerPendingApprovalPanel
1995− approval={activePendingApproval}
1996− pendingCount={pendingApprovals.length}
1997− />
1998− </div>
1999− ) : pendingUserInputs.length > 0 ? (
2000− <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2001− <ComposerPendingUserInputPanel
2002− pendingUserInputs={pendingUserInputs}
2003− respondingRequestIds={respondingRequestIds}
2004− answers={activePendingDraftAnswers}
2005− questionIndex={activePendingQuestionIndex}
2006− onToggleOption={onSelectActivePendingUserInputOption}
2007− onAdvance={onAdvanceActivePendingUserInput}
2008− />
2009− </div>
2010− ) : showPlanFollowUpPrompt && activeProposedPlan ? (
2011− <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2012− <ComposerPlanFollowUpBanner
2013− key={activeProposedPlan.id}
2014− planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2015− />
2016− </div>
2017− ) : null)}
2018−
2019− {isComposerCollapsedMobile && activePendingApproval ? (
2020− <div
2021− className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2022− data-chat-composer-collapsed-controls="true"
2023− >
1986+ {!isComposerCollapsedMobile &&
1987+ (activePendingApproval ? (
1988+ <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
20241989 <ComposerPendingApprovalPanel
20251990 approval={activePendingApproval}
20261991 pendingCount={pendingApprovals.length}
20271992 />
2028− <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4">
2029− <ComposerPendingApprovalActions
2030− requestId={activePendingApproval.requestId}
2031− isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2032− onRespondToApproval={onRespondToApproval}
2033− />
2034− </div>
20351993 </div>
2036− ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2037− <div
2038− className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2039− data-chat-composer-collapsed-controls="true"
2040− >
1994+ ) : pendingUserInputs.length > 0 ? (
1995+ <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
20411996 <ComposerPendingUserInputPanel
20421997 pendingUserInputs={pendingUserInputs}
20431998 respondingRequestIds={respondingRequestIds}
@@ -2046,240 +2001,70 @@ export const ChatComposer = memo( · 2 unmodified lines
20462001 onToggleOption={onSelectActivePendingUserInputOption}
20472002 onAdvance={onAdvanceActivePendingUserInput}
20482003 />
2049− <div className="px-3 pb-3 sm:px-4">
2050− <div
2051− data-chat-composer-mobile-pending-compact="true"
2052− className={cn(
2053− "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",
2054− !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
2055− )}
2056− >
2057− <button
2058− type="button"
2059− className={cn(
2060− "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
2061− activePendingProgress?.customAnswer
2062− ? "text-foreground"
2063− : "text-muted-foreground/60",
2064− !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
2065− )}
2066− onPointerDown={(event) => event.preventDefault()}
2067− onClick={expandMobileComposer}
2068− aria-label="Write custom answer"
2069− >
2070− {activePendingProgress?.customAnswer || "Write custom answer"}
2071− </button>
2072− {activePendingProgress?.activeQuestion?.multiSelect ? (
2073− <ComposerPrimaryActions
2074− compact
2075− pendingAction={pendingPrimaryAction}
2076− isRunning={false}
2077− showPlanFollowUpPrompt={false}
2078− promptHasText={false}
2079− isSendBusy={isSendBusy}
2080− isConnecting={isConnecting}
2081− isEnvironmentUnavailable={environmentUnavailable !== null}
2082− isPreparingWorktree={false}
2083− hasSendableContent={false}
2084− preserveComposerFocusOnPointerDown
2085− onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2086− onInterrupt={handleInterruptPrimaryAction}
2087− onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2088− />
2089− ) : null}
2090− </div>
2091− </div>
20922004 </div>
2093− ) : null}
2094−
2095− {showCollapsedMobilePromptRow ? (
2096− <div className="flex items-center justify-between gap-2 px-3 py-2">
2097− <button
2098− type="button"
2099− className={cn(
2100− "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2101− (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2102− ? "text-foreground"
2103− : "text-muted-foreground/35",
2104− )}
2105− onPointerDown={(event) => event.preventDefault()}
2106− onClick={expandMobileComposer}
2107− aria-label="Expand composer"
2108− >
2109− {activePendingProgress
2110− ? activePendingProgress.customAnswer ||
2111− "Type your own answer, or leave this blank to use the selected option"
2112− : prompt.trim() || "Ask anything..."}
2113− </button>
2114− <button
2115− type="button"
2116− className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
2117− disabled={collapsedComposerPrimaryActionDisabled}
2118− aria-label={collapsedComposerPrimaryActionLabel}
2119− onPointerDown={(event) => event.preventDefault()}
2120− onClick={(event) => {
2121− event.stopPropagation();
2122− submitComposer();
2123− }}
2124− >
2125− <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2126− <path
2127− d="M8 3L8 13M8 3L4 7M8 3L12 7"
2128− stroke="currentColor"
2129− strokeWidth="2"
2130− strokeLinecap="round"
2131− strokeLinejoin="round"
2132− />
2133− </svg>
2134− </button>
2005+ ) : showPlanFollowUpPrompt && activeProposedPlan ? (
2006+ <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2007+ <ComposerPlanFollowUpBanner
2008+ key={activeProposedPlan.id}
2009+ planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2010+ />
21352011 </div>
2136− ) : null}
2012+ ) : null)}
21372013
2014+ {isComposerCollapsedMobile && activePendingApproval ? (
21382015 <div
2139− className={cn(
2140− "relative px-3 pb-2 sm:px-4",
2141− hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2142− isComposerCollapsedMobile && "hidden",
2143− )}
2016+ className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2017+ data-chat-composer-collapsed-controls="true"
21442018 >
2145− {composerMenuOpen && !isComposerApprovalState && (
2146− <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2147− <ComposerCommandMenu
2148− items={composerMenuItems}
2149− resolvedTheme={resolvedTheme}
2150− isLoading={isComposerMenuLoading}
2151− triggerKind={composerTriggerKind}
2152− groupSlashCommandSections={
2153− composerTrigger?.kind === "slash-command" &&
2154− composerTrigger.query.trim().length === 0
2155− }
2156− emptyStateText={composerMenuEmptyState}
2157− activeItemId={activeComposerMenuItem?.id ?? null}
2158− onHighlightedItemChange={onComposerMenuItemHighlighted}
2159− onSelect={onSelectComposerItem}
2160− />
2161− </div>
2162− )}
2163−
2164− {!isComposerCollapsedMobile &&
2165− !isComposerApprovalState &&
2166− pendingUserInputs.length === 0 &&
2167− composerImages.length > 0 && (
2168− <div className="mb-3 flex flex-wrap gap-2">
2169− {composerImages.map((image) => (
2170− <div
2171− key={image.id}
2172− className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
2173− >
2174− {image.previewUrl ? (
2175− <button
2176− type="button"
2177− className="h-full w-full cursor-zoom-in"
2178− aria-label={`Preview ${image.name}`}
2179− onClick={() => {
2180− const preview = buildExpandedImagePreview(composerImages, image.id);
2181− if (!preview) return;
2182− onExpandImage(preview);
2183− }}
2184− >
2185− <img
2186− src={image.previewUrl}
2187− alt={image.name}
2188− className="h-full w-full object-cover"
2189− />
2190− </button>
2191− ) : (
2192− <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
2193− {image.name}
2194− </div>
2195− )}
2196− {nonPersistedComposerImageIdSet.has(image.id) && (
2197− <Tooltip>
2198− <TooltipTrigger
2199− render={
2200− <span
2201− role="img"
2202− aria-label="Draft attachment may not persist"
2203− className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
2204− >
2205− <CircleAlertIcon className="size-3" />
2206− </span>
2207− }
2208− />
2209− <TooltipPopup
2210− side="top"
2211− className="max-w-64 whitespace-normal leading-tight"
2212− >
2213− Draft attachment could not be saved locally and may be lost on
2214− navigation.
2215− </TooltipPopup>
2216− </Tooltip>
2217− )}
2218− <Button
2219− variant="ghost"
2220− size="icon-xs"
2221− className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
2222− onClick={() => removeComposerImage(image.id)}
2223− aria-label={`Remove ${image.name}`}
2224− >
2225− <XIcon />
2226− </Button>
2227− </div>
2228− ))}
2229− </div>
2230− )}
2231−
2232− <div className="relative">
2233− <ComposerPromptEditor
2234− ref={composerEditorRef}
2235− value={
2236− isComposerApprovalState
2237− ? ""
2238− : activePendingProgress
2239− ? activePendingProgress.customAnswer
2240− : prompt
2241− }
2242− cursor={composerCursor}
2243− terminalContexts={
2244− !isComposerApprovalState && pendingUserInputs.length === 0
2245− ? composerTerminalContexts
2246− : []
2247− }
2248− skills={selectedProviderStatus?.skills ?? []}
2249− {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
2250− onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
2251− onChange={onPromptChange}
2252− onCommandKeyDown={onComposerCommandKey}
2253− onPaste={onComposerPaste}
2254− placeholder={
2255− isComposerApprovalState
2256− ? (activePendingApproval?.detail ??
2257− "Resolve this approval request to continue")
2258− : activePendingProgress
2259− ? "Type your own answer, or leave this blank to use the selected option"
2260− : showPlanFollowUpPrompt && activeProposedPlan
2261− ? "Add feedback to refine the plan, or leave this blank to implement it"
2262− : environmentUnavailable
2263− ? `${environmentUnavailable.label} is ${
2264− environmentUnavailable.connectionState === "connecting"
2265− ? "connecting"
2266− : "disconnected"
2267− }`
2268− : phase === "disconnected"
2269− ? "Ask for follow-up changes or attach images"
2270− : "Ask anything, @tag files/folders, $use skills, or / for commands"
2271− }
2272− disabled={
2273− isConnecting ||
2274− isComposerApprovalState ||
2275− (environmentUnavailable !== null && activePendingProgress === null)
2276− }
2019+ <ComposerPendingApprovalPanel
2020+ approval={activePendingApproval}
2021+ pendingCount={pendingApprovals.length}
2022+ />
2023+ <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4">
2024+ <ComposerPendingApprovalActions
2025+ requestId={activePendingApproval.requestId}
2026+ isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2027+ onRespondToApproval={onRespondToApproval}
22772028 />
2278− {showMobilePendingAnswerActions ? (
2279− <div
2280− data-chat-composer-mobile-pending-actions="true"
2281− className="absolute bottom-0 right-0 flex justify-end"
2029+ </div>
2030+ </div>
2031+ ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2032+ <div
2033+ className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2034+ data-chat-composer-collapsed-controls="true"
2035+ >
2036+ <ComposerPendingUserInputPanel
2037+ pendingUserInputs={pendingUserInputs}
2038+ respondingRequestIds={respondingRequestIds}
2039+ answers={activePendingDraftAnswers}
2040+ questionIndex={activePendingQuestionIndex}
2041+ onToggleOption={onSelectActivePendingUserInputOption}
2042+ onAdvance={onAdvanceActivePendingUserInput}
2043+ />
2044+ <div className="px-3 pb-3 sm:px-4">
2045+ <div
2046+ data-chat-composer-mobile-pending-compact="true"
2047+ className={cn(
2048+ "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",
2049+ !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
2050+ )}
2051+ >
2052+ <button
2053+ type="button"
2054+ className={cn(
2055+ "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
2056+ activePendingProgress?.customAnswer
2057+ ? "text-foreground"
2058+ : "text-muted-foreground/60",
2059+ !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
2060+ )}
2061+ onPointerDown={(event) => event.preventDefault()}
2062+ onClick={expandMobileComposer}
2063+ aria-label="Write custom answer"
22822064 >
2065+ {activePendingProgress?.customAnswer || "Write custom answer"}
2066+ </button>
2067+ {activePendingProgress?.activeQuestion?.multiSelect ? (
22832068 <ComposerPrimaryActions
22842069 compact
22852070 pendingAction={pendingPrimaryAction}
@@ -2296,128 +2081,328 @@ export const ChatComposer = memo( · 10 unmodified lines
22962081 onInterrupt={handleInterruptPrimaryAction}
22972082 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
22982083 />
2299− </div>
2300− ) : null}
2084+ ) : null}
2085+ </div>
23012086 </div>
23022087 </div>
2088+ ) : null}
23032089
2304− {/* Bottom toolbar */}
2305− {isComposerCollapsedMobile ? null : activePendingApproval ? (
2306− <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
2307− <ComposerPendingApprovalActions
2308− requestId={activePendingApproval.requestId}
2309− isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2310− onRespondToApproval={onRespondToApproval}
2311− />
2312− </div>
2313− ) : (
2314− <div
2315− data-chat-composer-footer="true"
2316− data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
2090+ {showCollapsedMobilePromptRow ? (
2091+ <div className="flex items-center justify-between gap-2 px-3 py-2">
2092+ <button
2093+ type="button"
23172094 className={cn(
2318− "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3",
2319− isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2320− showMobilePendingAnswerActions && "hidden sm:flex",
2095+ "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2096+ (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2097+ ? "text-foreground"
2098+ : "text-muted-foreground/35",
23212099 )}
2100+ onPointerDown={(event) => event.preventDefault()}
2101+ onClick={expandMobileComposer}
2102+ aria-label="Expand composer"
2103+ >
2104+ {activePendingProgress
2105+ ? activePendingProgress.customAnswer ||
2106+ "Type your own answer, or leave this blank to use the selected option"
2107+ : prompt.trim() || "Ask anything..."}
2108+ </button>
2109+ <button
2110+ type="button"
2111+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
2112+ disabled={collapsedComposerPrimaryActionDisabled}
2113+ aria-label={collapsedComposerPrimaryActionLabel}
2114+ onPointerDown={(event) => event.preventDefault()}
2115+ onClick={(event) => {
2116+ event.stopPropagation();
2117+ submitComposer();
2118+ }}
23222119 >
2323− <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
2324− <ProviderModelPicker
2325− compact={isComposerFooterCompact}
2326− activeInstanceId={selectedInstanceId}
2327− model={selectedModelForPickerWithCustomFallback}
2328− lockedProvider={lockedProvider}
2329− lockedContinuationGroupKey={lockedContinuationGroupKey}
2330− instanceEntries={providerInstanceEntries}
2331− keybindings={keybindings}
2332− modelOptionsByInstance={modelOptionsByInstance}
2333− terminalOpen={terminalOpen}
2334− open={isComposerModelPickerOpen}
2335− {...(composerProviderState.modelPickerIconClassName
2336− ? {
2337− activeProviderIconClassName:
2338− composerProviderState.modelPickerIconClassName,
2339− }
2340− : {})}
2341− onOpenChange={(open) => {
2342− setIsComposerModelPickerOpen(open);
2343− }}
2344− onInstanceModelChange={onProviderModelSelect}
2120+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2121+ <path
2122+ d="M8 3L8 13M8 3L4 7M8 3L12 7"
2123+ stroke="currentColor"
2124+ strokeWidth="2"
2125+ strokeLinecap="round"
2126+ strokeLinejoin="round"
23452127 />
2128+ </svg>
2129+ </button>
2130+ </div>
2131+ ) : null}
23462132
2347− {isComposerFooterCompact ? (
2348− <CompactComposerControlsMenu
2349− activePlan={showPlanSidebarToggle}
2350− interactionMode={interactionMode}
2351− planSidebarLabel={planSidebarLabel}
2352− planSidebarOpen={planSidebarOpen}
2353− runtimeMode={runtimeMode}
2354− showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
2355− traitsMenuContent={providerTraitsMenuContent}
2356− onToggleInteractionMode={toggleInteractionMode}
2357− onTogglePlanSidebar={togglePlanSidebar}
2358− onRuntimeModeChange={handleRuntimeModeChange}
2359− />
2360− ) : (
2361− <>
2362− {providerTraitsPicker ? (
2363− <>
2364− <Separator
2365− orientation="vertical"
2366− className="mx-0.5 hidden h-4 sm:block"
2133+ <div
2134+ className={cn(
2135+ "relative px-3 pb-2 sm:px-4",
2136+ hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2137+ isComposerCollapsedMobile && "hidden",
2138+ )}
2139+ >
2140+ {composerMenuOpen && !isComposerApprovalState && (
2141+ <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2142+ <ComposerCommandMenu
2143+ items={composerMenuItems}
2144+ resolvedTheme={resolvedTheme}
2145+ isLoading={isComposerMenuLoading}
2146+ triggerKind={composerTriggerKind}
2147+ groupSlashCommandSections={
2148+ composerTrigger?.kind === "slash-command" &&
2149+ composerTrigger.query.trim().length === 0
2150+ }
2151+ emptyStateText={composerMenuEmptyState}
2152+ activeItemId={activeComposerMenuItem?.id ?? null}
2153+ onHighlightedItemChange={onComposerMenuItemHighlighted}
2154+ onSelect={onSelectComposerItem}
2155+ />
2156+ </div>
2157+ )}
2158+
2159+ {!isComposerCollapsedMobile &&
2160+ !isComposerApprovalState &&
2161+ pendingUserInputs.length === 0 &&
2162+ composerImages.length > 0 && (
2163+ <div className="mb-3 flex flex-wrap gap-2">
2164+ {composerImages.map((image) => (
2165+ <div
2166+ key={image.id}
2167+ className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
2168+ >
2169+ {image.previewUrl ? (
2170+ <button
2171+ type="button"
2172+ className="h-full w-full cursor-zoom-in"
2173+ aria-label={`Preview ${image.name}`}
2174+ onClick={() => {
2175+ const preview = buildExpandedImagePreview(composerImages, image.id);
2176+ if (!preview) return;
2177+ onExpandImage(preview);
2178+ }}
2179+ >
2180+ <img
2181+ src={image.previewUrl}
2182+ alt={image.name}
2183+ className="h-full w-full object-cover"
23672184 />
2368− {providerTraitsPicker}
2369− </>
2370− ) : null}
2371− <ComposerFooterModeControls
2372− showInteractionModeToggle={
2373− composerProviderControls.showInteractionModeToggle
2374− }
2375− interactionMode={interactionMode}
2376− runtimeMode={runtimeMode}
2377− showPlanToggle={showPlanSidebarToggle}
2378− planSidebarLabel={planSidebarLabel}
2379− planSidebarOpen={planSidebarOpen}
2380− onToggleInteractionMode={toggleInteractionMode}
2381− onRuntimeModeChange={handleRuntimeModeChange}
2382− onTogglePlanSidebar={togglePlanSidebar}
2383− />
2384− </>
2385− )}
2185+ </button>
2186+ ) : (
2187+ <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
2188+ {image.name}
2189+ </div>
2190+ )}
2191+ {nonPersistedComposerImageIdSet.has(image.id) && (
2192+ <Tooltip>
2193+ <TooltipTrigger
2194+ render={
2195+ <span
2196+ role="img"
2197+ aria-label="Draft attachment may not persist"
2198+ className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
2199+ >
2200+ <CircleAlertIcon className="size-3" />
2201+ </span>
2202+ }
2203+ />
2204+ <TooltipPopup
2205+ side="top"
2206+ className="max-w-64 whitespace-normal leading-tight"
2207+ >
2208+ Draft attachment could not be saved locally and may be lost on
2209+ navigation.
2210+ </TooltipPopup>
2211+ </Tooltip>
2212+ )}
2213+ <Button
2214+ variant="ghost"
2215+ size="icon-xs"
2216+ className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
2217+ onClick={() => removeComposerImage(image.id)}
2218+ aria-label={`Remove ${image.name}`}
2219+ >
2220+ <XIcon />
2221+ </Button>
2222+ </div>
2223+ ))}
23862224 </div>
2225+ )}
23872226
2388− {/* Right side: send / stop button */}
2227+ <div className="relative">
2228+ <ComposerPromptEditor
2229+ editorRef={composerEditorRef}
2230+ value={
2231+ isComposerApprovalState
2232+ ? ""
2233+ : activePendingProgress
2234+ ? activePendingProgress.customAnswer
2235+ : prompt
2236+ }
2237+ cursor={composerCursor}
2238+ terminalContexts={
2239+ !isComposerApprovalState && pendingUserInputs.length === 0
2240+ ? composerTerminalContexts
2241+ : []
2242+ }
2243+ skills={selectedProviderStatus?.skills ?? []}
2244+ {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
2245+ onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
2246+ onChange={onPromptChange}
2247+ onCommandKeyDown={onComposerCommandKey}
2248+ onPaste={onComposerPaste}
2249+ placeholder={
2250+ isComposerApprovalState
2251+ ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
2252+ : activePendingProgress
2253+ ? "Type your own answer, or leave this blank to use the selected option"
2254+ : showPlanFollowUpPrompt && activeProposedPlan
2255+ ? "Add feedback to refine the plan, or leave this blank to implement it"
2256+ : environmentUnavailable
2257+ ? `${environmentUnavailable.label} is ${
2258+ environmentUnavailable.connectionState === "connecting"
2259+ ? "connecting"
2260+ : "disconnected"
2261+ }`
2262+ : phase === "disconnected"
2263+ ? "Ask for follow-up changes or attach images"
2264+ : "Ask anything, @tag files/folders, $use skills, or / for commands"
2265+ }
2266+ disabled={
2267+ isConnecting ||
2268+ isComposerApprovalState ||
2269+ (environmentUnavailable !== null && activePendingProgress === null)
2270+ }
2271+ />
2272+ {showMobilePendingAnswerActions ? (
23892273 <div
2390− data-chat-composer-actions="right"
2391− data-chat-composer-primary-actions-compact={
2392− isComposerPrimaryActionsCompact ? "true" : "false"
2393− }
2394− className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
2274+ data-chat-composer-mobile-pending-actions="true"
2275+ className="absolute bottom-0 right-0 flex justify-end"
23952276 >
2396− <ComposerFooterPrimaryActions
2397− compact={isComposerPrimaryActionsCompact}
2398− activeContextWindow={activeContextWindow}
2277+ <ComposerPrimaryActions
2278+ compact
23992279 pendingAction={pendingPrimaryAction}
2400− isRunning={phase === "running"}
2401− showPlanFollowUpPrompt={
2402− pendingUserInputs.length === 0 && showPlanFollowUpPrompt
2403− }
2404− promptHasText={prompt.trim().length > 0}
2280+ isRunning={false}
2281+ showPlanFollowUpPrompt={false}
2282+ promptHasText={false}
24052283 isSendBusy={isSendBusy}
24062284 isConnecting={isConnecting}
24072285 isEnvironmentUnavailable={environmentUnavailable !== null}
2408− isPreparingWorktree={isPreparingWorktree}
2409− hasSendableContent={composerSendState.hasSendableContent}
2410− preserveComposerFocusOnPointerDown={isMobileViewport}
2286+ isPreparingWorktree={false}
2287+ hasSendableContent={false}
2288+ preserveComposerFocusOnPointerDown
24112289 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
24122290 onInterrupt={handleInterruptPrimaryAction}
24132291 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
24142292 />
24152293 </div>
2416− </div>
2417− )}
2294+ ) : null}
2295+ </div>
24182296 </div>
2297+
2298+ {/* Bottom toolbar */}
2299+ {isComposerCollapsedMobile ? null : activePendingApproval ? (
2300+ <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
2301+ <ComposerPendingApprovalActions
2302+ requestId={activePendingApproval.requestId}
2303+ isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2304+ onRespondToApproval={onRespondToApproval}
2305+ />
2306+ </div>
2307+ ) : (
2308+ <div
2309+ data-chat-composer-footer="true"
2310+ data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
2311+ className={cn(
2312+ "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3",
2313+ isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2314+ showMobilePendingAnswerActions && "hidden sm:flex",
2315+ )}
2316+ >
2317+ <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
2318+ <ProviderModelPicker
2319+ compact={isComposerFooterCompact}
2320+ activeInstanceId={selectedInstanceId}
2321+ model={selectedModelForPickerWithCustomFallback}
2322+ lockedProvider={lockedProvider}
2323+ lockedContinuationGroupKey={lockedContinuationGroupKey}
2324+ instanceEntries={providerInstanceEntries}
2325+ keybindings={keybindings}
2326+ modelOptionsByInstance={modelOptionsByInstance}
2327+ terminalOpen={terminalOpen}
2328+ open={isComposerModelPickerOpen}
2329+ {...(composerProviderState.modelPickerIconClassName
2330+ ? {
2331+ activeProviderIconClassName: composerProviderState.modelPickerIconClassName,
2332+ }
2333+ : {})}
2334+ onOpenChange={(open) => {
2335+ setIsComposerModelPickerOpen(open);
2336+ }}
2337+ onInstanceModelChange={onProviderModelSelect}
2338+ />
2339+
2340+ {isComposerFooterCompact ? (
2341+ <CompactComposerControlsMenu
2342+ activePlan={showPlanSidebarToggle}
2343+ interactionMode={interactionMode}
2344+ planSidebarLabel={planSidebarLabel}
2345+ planSidebarOpen={planSidebarOpen}
2346+ runtimeMode={runtimeMode}
2347+ showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
2348+ traitsMenuContent={providerTraitsMenuContent}
2349+ onToggleInteractionMode={toggleInteractionMode}
2350+ onTogglePlanSidebar={togglePlanSidebar}
2351+ onRuntimeModeChange={handleRuntimeModeChange}
2352+ />
2353+ ) : (
2354+ <>
2355+ {providerTraitsPicker ? (
2356+ <>
2357+ <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2358+ {providerTraitsPicker}
2359+ </>
2360+ ) : null}
2361+ <ComposerFooterModeControls
2362+ showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
2363+ interactionMode={interactionMode}
2364+ runtimeMode={runtimeMode}
2365+ showPlanToggle={showPlanSidebarToggle}
2366+ planSidebarLabel={planSidebarLabel}
2367+ planSidebarOpen={planSidebarOpen}
2368+ onToggleInteractionMode={toggleInteractionMode}
2369+ onRuntimeModeChange={handleRuntimeModeChange}
2370+ onTogglePlanSidebar={togglePlanSidebar}
2371+ />
2372+ </>
2373+ )}
2374+ </div>
2375+
2376+ {/* Right side: send / stop button */}
2377+ <div
2378+ data-chat-composer-actions="right"
2379+ data-chat-composer-primary-actions-compact={
2380+ isComposerPrimaryActionsCompact ? "true" : "false"
2381+ }
2382+ className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
2383+ >
2384+ <ComposerFooterPrimaryActions
2385+ compact={isComposerPrimaryActionsCompact}
2386+ activeContextWindow={activeContextWindow}
2387+ pendingAction={pendingPrimaryAction}
2388+ isRunning={phase === "running"}
2389+ showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
2390+ promptHasText={prompt.trim().length > 0}
2391+ isSendBusy={isSendBusy}
2392+ isConnecting={isConnecting}
2393+ isEnvironmentUnavailable={environmentUnavailable !== null}
2394+ isPreparingWorktree={isPreparingWorktree}
2395+ hasSendableContent={composerSendState.hasSendableContent}
2396+ preserveComposerFocusOnPointerDown={isMobileViewport}
2397+ onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2398+ onInterrupt={handleInterruptPrimaryAction}
2399+ onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2400+ />
2401+ </div>
2402+ </div>
2403+ )}
24192404 </div>
2420− </form>
2421− );
2422− }),
2423−);
2405+ </div>
2406+ </form>
2407+ );
2408+});
apps/web/src/components/chat/MessagesTimeline.browser.tsx+10−12
@@ -13,18 +13,16 @@ const getStateSpy = vi.fn(() => ({ isAtEnd: true })); · 12 unmodified lines
1313 vi.mock("@legendapp/list/react", async () => {
1414 const React = await import("react");
1515
16− const LegendList = React.forwardRef(function MockLegendList(
17− props: {
18− data: Array<{ id: string }>;
19− keyExtractor: (item: { id: string }) => string;
20− renderItem: (args: { item: { id: string } }) => React.ReactNode;
21− ListHeaderComponent?: React.ReactNode;
22− ListFooterComponent?: React.ReactNode;
23− },
24− ref: React.ForwardedRef<LegendListRef>,
25− ) {
16+ function LegendList(props: {
17+ data: Array<{ id: string }>;
18+ keyExtractor: (item: { id: string }) => string;
19+ renderItem: (args: { item: { id: string } }) => React.ReactNode;
20+ ListHeaderComponent?: React.ReactNode;
21+ ListFooterComponent?: React.ReactNode;
22+ ref?: React.Ref<LegendListRef>;
23+ }) {
2624 React.useImperativeHandle(
27− ref,
25+ props.ref,
2826 () =>
2927 ({
3028 scrollToEnd: scrollToEndSpy,
@@ -41,7 +39,7 @@ vi.mock("@legendapp/list/react", async () => { · 10 unmodified lines
4139 {props.ListFooterComponent}
4240 </div>
4341 );
44− });
42+ }
4543
4644 return { LegendList };
4745 });
apps/web/src/components/chat/MessagesTimeline.test.tsx+19−23
@@ -1,32 +1,28 @@
11 import { EnvironmentId, MessageId } from "@t3tools/contracts";
2−import { createRef } from "react";
2+import { createRef, type ReactNode, type Ref } from "react";
33 import { renderToStaticMarkup } from "react-dom/server";
44 import { beforeAll, describe, expect, it, vi } from "vitest";
55 import type { LegendListRef } from "@legendapp/list/react";
66
77 vi.mock("@legendapp/list/react", async () => {
8− const React = await import("react");
9−
10− const LegendList = React.forwardRef(function MockLegendList(
11− props: {
12− data: Array<{ id: string }>;
13− keyExtractor: (item: { id: string }) => string;
14− renderItem: (args: { item: { id: string } }) => React.ReactNode;
15− ListHeaderComponent?: React.ReactNode;
16− ListFooterComponent?: React.ReactNode;
17− },
18− _ref: React.ForwardedRef<LegendListRef>,
19− ) {
20− return (
21− <div data-testid="legend-list">
22− {props.ListHeaderComponent}
23− {props.data.map((item) => (
24− <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
25− ))}
26− {props.ListFooterComponent}
27− </div>
28− );
29− });
8+ const legendListTestId = "legend-list";
9+
10+ const LegendList = (props: {
11+ data: Array<{ id: string }>;
12+ keyExtractor: (item: { id: string }) => string;
13+ renderItem: (args: { item: { id: string } }) => ReactNode;
14+ ListHeaderComponent?: ReactNode;
15+ ListFooterComponent?: ReactNode;
16+ ref?: Ref<LegendListRef>;
17+ }) => (
18+ <div data-testid={legendListTestId}>
19+ {props.ListHeaderComponent}
20+ {props.data.map((item) => (
21+ <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
22+ ))}
23+ {props.ListFooterComponent}
24+ </div>
25+ );
3026
3127 return { LegendList };
3228 });
apps/web/src/components/chat/MessagesTimeline.tsx+5−5
@@ -69,7 +69,7 @@ import { SkillInlineText } from "./SkillInlineText"; · 68 unmodified lines
6969 import { formatWorkspaceRelativePath } from "../../filePathDisplay";
7070
7171 // ---------------------------------------------------------------------------
72−// Context — shared state consumed by every row component via useContext.
72+// Context — shared state consumed by every row component via Context.
7373 // Propagates through LegendList's memo boundaries for shared callbacks and
7474 // non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
7575 // components (WorkingTimer, LiveElapsed) handle it.
@@ -264,8 +264,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 188 unmodified lines
264264 }
265265
266266 return (
267− <TimelineRowCtx.Provider value={sharedState}>
268− <TimelineRowActivityCtx.Provider value={activityState}>
267+ <TimelineRowCtx value={sharedState}>
268+ <TimelineRowActivityCtx value={activityState}>
269269 <LegendList<MessagesTimelineRow>
270270 ref={listRef}
271271 data={rows}
@@ -281,8 +281,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 9 unmodified lines
281281 ListHeaderComponent={TIMELINE_LIST_HEADER}
282282 ListFooterComponent={TIMELINE_LIST_FOOTER}
283283 />
284− </TimelineRowActivityCtx.Provider>
285− </TimelineRowCtx.Provider>
284+ </TimelineRowActivityCtx>
285+ </TimelineRowCtx>
286286 );
287287 });
288288
apps/web/src/components/ui/combobox.tsx+4−4
@@ -22,9 +22,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>( · 21 unmodified lines
2222 const chipsRef = React.useRef<Element | null>(null);
2323 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);
2424 return (
25− <ComboboxContext.Provider value={value}>
25+ <ComboboxContext value={value}>
2626 <ComboboxPrimitive.Root {...props} />
27− </ComboboxContext.Provider>
27+ </ComboboxContext>
2828 );
2929 }
3030
@@ -151,7 +151,7 @@ function ComboboxPopup({ · 120 unmodified lines
151151 side?: ComboboxPrimitive.Positioner.Props["side"];
152152 anchor?: ComboboxPrimitive.Positioner.Props["anchor"];
153153 }) {
154− const { chipsRef } = React.useContext(ComboboxContext);
154+ const { chipsRef } = React.use(ComboboxContext);
155155 const anchor = anchorProp ?? chipsRef;
156156
157157 return (
@@ -324,7 +324,7 @@ function ComboboxChips({ · 166 unmodified lines
324324 }: ComboboxPrimitive.Chips.Props & {
325325 startAddon?: React.ReactNode;
326326 }) {
327− const { chipsRef } = React.useContext(ComboboxContext);
327+ const { chipsRef } = React.use(ComboboxContext);
328328
329329 return (
330330 <ComboboxPrimitive.Chips
apps/web/src/components/ui/number-field.tsx+3−3
@@ -25,7 +25,7 @@ export function NumberField({ · 24 unmodified lines
2525 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
2626
2727 return (
28− <NumberFieldContext.Provider value={contextValue}>
28+ <NumberFieldContext value={contextValue}>
2929 <NumberFieldPrimitive.Root
3030 className={cn("flex w-full flex-col items-start gap-2", className)}
3131 data-size={size}
@@ -33,7 +33,7 @@ export function NumberField({ · 1 unmodified line
3333 id={fieldId}
3434 {...props}
3535 />
36− </NumberFieldContext.Provider>
36+ </NumberFieldContext>
3737 );
3838 }
3939
@@ -112,7 +112,7 @@ export function NumberFieldScrubArea({ · 72 unmodified lines
112112 }: NumberFieldPrimitive.ScrubArea.Props & {
113113 label: string;
114114 }): React.ReactElement {
115− const context = React.useContext(NumberFieldContext);
115+ const context = React.use(NumberFieldContext);
116116
117117 if (!context) {
118118 throw new Error(
apps/web/src/components/ui/sidebar.tsx+11−11
@@ -77,7 +77,7 @@ const SidebarContext = React.createContext<SidebarContextProps | null>(null); · 76 unmodified lines
7777 const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
7878
7979 function useSidebar() {
80− const context = React.useContext(SidebarContext);
80+ const context = React.use(SidebarContext);
8181 if (!context) {
8282 throw new Error("useSidebar must be used within a SidebarProvider.");
8383 }
@@ -148,7 +148,7 @@ function SidebarProvider({ · 64 unmodified lines
148148 );
149149
150150 return (
151− <SidebarContext.Provider value={contextValue}>
151+ <SidebarContext value={contextValue}>
152152 <div
153153 className={cn(
154154 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
@@ -166,7 +166,7 @@ function SidebarProvider({ · 11 unmodified lines
166166 >
167167 {children}
168168 </div>
169− </SidebarContext.Provider>
169+ </SidebarContext>
170170 );
171171 }
172172
@@ -206,7 +206,7 @@ function Sidebar({ · 33 unmodified lines
206206
207207 if (collapsible === "none") {
208208 return (
209− <SidebarInstanceContext.Provider value={instanceContextValue}>
209+ <SidebarInstanceContext value={instanceContextValue}>
210210 <div
211211 className={cn(
212212 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
@@ -217,13 +217,13 @@ function Sidebar({ · 4 unmodified lines
217217 >
218218 {children}
219219 </div>
220− </SidebarInstanceContext.Provider>
220+ </SidebarInstanceContext>
221221 );
222222 }
223223
224224 if (isMobile) {
225225 return (
226− <SidebarInstanceContext.Provider value={instanceContextValue}>
226+ <SidebarInstanceContext value={instanceContextValue}>
227227 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
228228 <SheetPopup
229229 className={cn(
@@ -255,12 +255,12 @@ function Sidebar({ · 25 unmodified lines
255255 </div>
256256 </SheetPopup>
257257 </Sheet>
258− </SidebarInstanceContext.Provider>
258+ </SidebarInstanceContext>
259259 );
260260 }
261261
262262 return (
263− <SidebarInstanceContext.Provider value={instanceContextValue}>
263+ <SidebarInstanceContext value={instanceContextValue}>
264264 <div
265265 className="group peer hidden text-sidebar-foreground md:block"
266266 data-collapsible={state === "collapsed" ? collapsible : ""}
@@ -305,7 +305,7 @@ function Sidebar({ · 38 unmodified lines
305305 </div>
306306 </div>
307307 </div>
308− </SidebarInstanceContext.Provider>
308+ </SidebarInstanceContext>
309309 );
310310 }
311311
@@ -345,7 +345,7 @@ function SidebarRail({ · 33 unmodified lines
345345 ...props
346346 }: React.ComponentProps<"button">) {
347347 const { open, toggleSidebar } = useSidebar();
348− const sidebarInstance = React.useContext(SidebarInstanceContext);
348+ const sidebarInstance = React.use(SidebarInstanceContext);
349349 const railRef = React.useRef<HTMLButtonElement | null>(null);
350350 const suppressClickRef = React.useRef(false);
351351 const resizeStateRef = React.useRef<{
@@ -889,7 +889,7 @@ function SidebarMenuSkeleton({ · 537 unmodified lines
889889 }: React.ComponentProps<"div"> & {
890890 showIcon?: boolean;
891891 }) {
892− // Random width between 50 to 90%.
892+ // Random width between 50 to 90%. Intentionally wrapped in useMemo to avoid changing width on every render.
893893 const width = React.useMemo(() => {
894894 return `${Math.floor(Math.random() * 40) + 50}%`;
895895 }, []);
apps/web/src/components/ui/toggle-group.tsx+2−2
@@ -43,7 +43,7 @@ function ToggleGroup({ · 42 unmodified lines
4343 orientation={orientation}
4444 {...props}
4545 >
46− <ToggleGroupContext.Provider value={value}>{children}</ToggleGroupContext.Provider>
46+ <ToggleGroupContext value={value}>{children}</ToggleGroupContext>
4747 </ToggleGroupPrimitive>
4848 );
4949 }
@@ -55,7 +55,7 @@ function Toggle({ · 5 unmodified lines
5555 size,
5656 ...props
5757 }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
58− const context = React.useContext(ToggleGroupContext);
58+ const context = React.use(ToggleGroupContext);
5959
6060 const resolvedVariant = variant ?? context.variant;
6161 const resolvedSize = size ?? context.size;
apps/web/src/composerHandleContext.ts+3−3
@@ -1,10 +1,10 @@
1−import { createContext, useContext, type MutableRefObject } from "react";
1+import { createContext, use } from "react";
22 import type { ChatComposerHandle } from "./components/chat/ChatComposer";
33
4−export type ComposerHandleRef = MutableRefObject<ChatComposerHandle | null>;
4+export type ComposerHandleRef = React.RefObject<ChatComposerHandle | null>;
55
66 export const ComposerHandleContext = createContext<ComposerHandleRef | null>(null);
77
88 export function useComposerHandleContext(): ComposerHandleRef | null {
9− return useContext(ComposerHandleContext);
9+ return use(ComposerHandleContext);
1010 }
apps/web/src/rpc/atomRegistry.tsrenamed from apps/web/src/rpc/atomRegistry.tsx+3−3
@@ -1,11 +1,11 @@
11 import { RegistryContext } from "@effect/atom-react";
22 import { AtomRegistry } from "effect/unstable/reactivity";
3−import type { ReactNode } from "react";
3+import { createElement } from "react";
44
55 export let appAtomRegistry = AtomRegistry.make();
66
7−export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) {
8− return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>;
7+export function AppAtomRegistryProvider({ children }: React.PropsWithChildren) {
8+ return createElement(RegistryContext.Provider, { value: appAtomRegistry }, children);
99 }
1010
1111 export function resetAppAtomRegistryForTests() {
bun.lock+8−8
@@ -108,8 +108,8 @@ · 107 unmodified lines
108108 "effect": "catalog:",
109109 "lexical": "^0.41.0",
110110 "lucide-react": "^0.564.0",
111− "react": "^19.0.0",
112− "react-dom": "^19.0.0",
111+ "react": "19.2.6",
112+ "react-dom": "19.2.6",
113113 "react-markdown": "^10.1.0",
114114 "remark-gfm": "^4.0.1",
115115 "tailwind-merge": "^3.4.0",
@@ -121,12 +121,12 @@ · 5 unmodified lines
121121 "@tailwindcss/vite": "^4.0.0",
122122 "@tanstack/router-plugin": "^1.161.0",
123123 "@types/babel__core": "^7.20.5",
124− "@types/react": "^19.0.0",
125− "@types/react-dom": "^19.0.0",
124+ "@types/react": "~19.2.14",
125+ "@types/react-dom": "~19.2.3",
126126 "@vercel/config": "^0.3.0",
127127 "@vitejs/plugin-react": "^6.0.0",
128128 "@vitest/browser-playwright": "^4.0.18",
129− "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
129+ "babel-plugin-react-compiler": "1.0.0",
130130 "msw": "2.12.11",
131131 "playwright": "^1.58.2",
132132 "tailwindcss": "^4.0.0",
@@ -1011,7 +1011,7 @@ · 878 unmodified lines
10111011
10121012 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
10131013
1014− "babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g=="],
1014+ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
10151015
10161016 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
10171017
@@ -1737,9 +1737,9 @@ · 719 unmodified lines
17371737
17381738 "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
17391739
1740− "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
1740+ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
17411741
1742− "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
1742+ "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
17431743
17441744 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],
17451745