authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-12 21:54:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-12 21:54:28+02:00
logb83e9c95e4f3bd1dcb771ee436dcfafcfaf6b3fa
tree7f095cb902859f57fd2b13b3ff8cd35dd98c5bb0
parenta41f4895ce06dadfc1359867344cc4607f944796
signature Signed by PGP key B5690EEEBB952194

Refactor composer refs and context providers (#2664)


16 files changed, 1941 insertions(+), 1976 deletions(-)

apps/web/package.json+5-5
......@@ -35,8 +35,8 @@
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 @@
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) {
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 }) {
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 {
3838} from "lexical";
3939import {
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
5451import {
......@@ -160,7 +157,7 @@ function ComposerMentionDecorator(props: { path: string }) {
160157 );
161158}
162159
163class ComposerMentionNode extends DecoratorNode<ReactElement> {
160class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
164161 __path: string;
165162
166163 static override getType(): string {
......@@ -208,7 +205,7 @@ class ComposerMentionNode extends DecoratorNode<ReactElement> {
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
278275 );
279276}
280277
281class ComposerSkillNode extends DecoratorNode<ReactElement> {
278class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
282279 __skillName: string;
283280 __skillLabel: string;
284281 __skillDescription: string | null;
......@@ -346,7 +343,7 @@ class ComposerSkillNode extends DecoratorNode<ReactElement> {
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
368365 return <ComposerPendingTerminalContextChip context={props.context} />;
369366}
370367
371class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {
368class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {
372369 __context: TerminalContextDraft;
373370
374371 static override getType(): string {
......@@ -417,7 +414,7 @@ class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {
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 {
900897 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
901898 event: KeyboardEvent,
902899 ) => boolean;
903 onPaste: ClipboardEventHandler<HTMLElement>;
904}
905
906interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {
907 editorRef: Ref<ComposerPromptEditorHandle>;
900 onPaste: React.ClipboardEventHandler<HTMLElement>;
901 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
908902}
909903
910904function ComposerCommandKeyPlugin(props: {
......@@ -1059,7 +1053,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() {
10591053
10601054function 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: {
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: {
13881379 }
13891380 unregisterRootListener();
13901381 };
1391 }, [applySurroundInsertion, editor]);
1382 }, [editor]);
13921383
13931384 return null;
13941385}
......@@ -1406,7 +1397,7 @@ function ComposerPromptEditorInner({
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({
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({
16461637 <ComposerInlineTokenBackspacePlugin />
16471638 <HistoryPlugin />
16481639 </div>
1649 </ComposerTerminalContextActionsContext.Provider>
1640 </ComposerTerminalContextActionsContext>
16501641 );
16511642}
16521643
1653export 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) {
1644export 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<
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 {
2020} from "@t3tools/contracts";
2121import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2222import {
23 forwardRef,
2423 memo,
2524 useCallback,
2625 useEffect,
......@@ -446,12 +445,13 @@ export interface ChatComposerProps {
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 {
489489// Component
490490// --------------------------------------------------------------------------
491491
492export 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,
492export 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(
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(
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(
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 }));
1313vi.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 () => {
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 @@
11import { EnvironmentId, MessageId } from "@t3tools/contracts";
2import { createRef } from "react";
2import { createRef, type ReactNode, type Ref } from "react";
33import { renderToStaticMarkup } from "react-dom/server";
44import { beforeAll, describe, expect, it, vi } from "vitest";
55import type { LegendListRef } from "@legendapp/list/react";
66
77vi.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";
6969import { 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({
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({
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>(
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({
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({
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({
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({
3333 id={fieldId}
3434 {...props}
3535 />
36 </NumberFieldContext.Provider>
36 </NumberFieldContext>
3737 );
3838}
3939
......@@ -112,7 +112,7 @@ export function NumberFieldScrubArea({
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);
7777const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
7878
7979function 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({
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({
166166 >
167167 {children}
168168 </div>
169 </SidebarContext.Provider>
169 </SidebarContext>
170170 );
171171}
172172
......@@ -206,7 +206,7 @@ function Sidebar({
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({
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({
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({
305305 </div>
306306 </div>
307307 </div>
308 </SidebarInstanceContext.Provider>
308 </SidebarInstanceContext>
309309 );
310310}
311311
......@@ -345,7 +345,7 @@ function SidebarRail({
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({
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({
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({
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 @@
1import { createContext, useContext, type MutableRefObject } from "react";
1import { createContext, use } from "react";
22import type { ChatComposerHandle } from "./components/chat/ChatComposer";
33
4export type ComposerHandleRef = MutableRefObject<ChatComposerHandle | null>;
4export type ComposerHandleRef = React.RefObject<ChatComposerHandle | null>;
55
66export const ComposerHandleContext = createContext<ComposerHandleRef | null>(null);
77
88export function useComposerHandleContext(): ComposerHandleRef | null {
9 return useContext(ComposerHandleContext);
9 return use(ComposerHandleContext);
1010}
apps/web/src/rpc/atomRegistry.ts created+14
......@@ -0,0 +1,14 @@
1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { createElement } from "react";
4
5export let appAtomRegistry = AtomRegistry.make();
6
7export function AppAtomRegistryProvider({ children }: React.PropsWithChildren) {
8 return createElement(RegistryContext.Provider, { value: appAtomRegistry }, children);
9}
10
11export function resetAppAtomRegistryForTests() {
12 appAtomRegistry.dispose();
13 appAtomRegistry = AtomRegistry.make();
14}
apps/web/src/rpc/atomRegistry.tsx deleted-14
......@@ -1,14 +0,0 @@
1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import type { ReactNode } from "react";
4
5export let appAtomRegistry = AtomRegistry.make();
6
7export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) {
8 return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>;
9}
10
11export function resetAppAtomRegistryForTests() {
12 appAtomRegistry.dispose();
13 appAtomRegistry = AtomRegistry.make();
14}
bun.lock+8-8
......@@ -108,8 +108,8 @@
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 @@
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 @@
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 @@
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