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 @@...@@ -35,8 +35,8 @@
35 "effect": "catalog:",35 "effect": "catalog:",
36 "lexical": "^0.41.0",36 "lexical": "^0.41.0",
37 "lucide-react": "^0.564.0",37 "lucide-react": "^0.564.0",
38 "react": "^19.0.0",38 "react": "19.2.6",
39 "react-dom": "^19.0.0",39 "react-dom": "19.2.6",
40 "react-markdown": "^10.1.0",40 "react-markdown": "^10.1.0",
41 "remark-gfm": "^4.0.1",41 "remark-gfm": "^4.0.1",
42 "tailwind-merge": "^3.4.0",42 "tailwind-merge": "^3.4.0",
...@@ -48,12 +48,12 @@...@@ -48,12 +48,12 @@
48 "@tailwindcss/vite": "^4.0.0",48 "@tailwindcss/vite": "^4.0.0",
49 "@tanstack/router-plugin": "^1.161.0",49 "@tanstack/router-plugin": "^1.161.0",
50 "@types/babel__core": "^7.20.5",50 "@types/babel__core": "^7.20.5",
51 "@types/react": "^19.0.0",51 "@types/react": "~19.2.14",
52 "@types/react-dom": "^19.0.0",52 "@types/react-dom": "~19.2.3",
53 "@vercel/config": "^0.3.0",53 "@vercel/config": "^0.3.0",
54 "@vitejs/plugin-react": "^6.0.0",54 "@vitejs/plugin-react": "^6.0.0",
55 "@vitest/browser-playwright": "^4.0.18",55 "@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",
57 "msw": "2.12.11",57 "msw": "2.12.11",
58 "playwright": "^1.58.2",58 "playwright": "^1.58.2",
59 "tailwindcss": "^4.0.0",59 "tailwindcss": "^4.0.0",
apps/web/src/components/ChatView.tsx+1-1
...@@ -3607,7 +3607,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3607,7 +3607,7 @@ export default function ChatView(props: ChatViewProps) {
3607 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />3607 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
3608 <div className="relative z-10">3608 <div className="relative z-10">
3609 <ChatComposer3609 <ChatComposer
3610 ref={composerRef}3610 composerRef={composerRef}
3611 composerDraftTarget={composerDraftTarget}3611 composerDraftTarget={composerDraftTarget}
3612 environmentId={environmentId}3612 environmentId={environmentId}
3613 routeKind={routeKind}3613 routeKind={routeKind}
apps/web/src/components/CommandPalette.tsx+2-2
...@@ -364,12 +364,12 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -364,12 +364,12 @@ export function CommandPalette({ children }: { children: ReactNode }) {
364 }, [keybindings, terminalOpen, toggleOpen]);364 }, [keybindings, terminalOpen, toggleOpen]);
365365
366 return (366 return (
367 <ComposerHandleContext.Provider value={composerHandleRef}>367 <ComposerHandleContext value={composerHandleRef}>
368 <CommandDialog open={open} onOpenChange={setOpen}>368 <CommandDialog open={open} onOpenChange={setOpen}>
369 {children}369 {children}
370 <CommandPaletteDialog />370 <CommandPaletteDialog />
371 </CommandDialog>371 </CommandDialog>
372 </ComposerHandleContext.Provider>372 </ComposerHandleContext>
373 );373 );
374}374}
375375
apps/web/src/components/ComposerPromptEditor.tsx+88-102
...@@ -38,17 +38,14 @@ import {...@@ -38,17 +38,14 @@ import {
38} from "lexical";38} from "lexical";
39import {39import {
40 createContext,40 createContext,
41 forwardRef,41 use,
42 useCallback,42 useCallback,
43 useContext,
44 useEffect,43 useEffect,
44 useEffectEvent,
45 useImperativeHandle,45 useImperativeHandle,
46 useLayoutEffect,46 useLayoutEffect,
47 useMemo,47 useMemo,
48 useRef,48 useRef,
49 type ClipboardEventHandler,
50 type ReactElement,
51 type Ref,
52} from "react";49} from "react";
5350
54import {51import {
...@@ -160,7 +157,7 @@ function ComposerMentionDecorator(props: { path: string }) {...@@ -160,7 +157,7 @@ function ComposerMentionDecorator(props: { path: string }) {
160 );157 );
161}158}
162159
163class ComposerMentionNode extends DecoratorNode<ReactElement> {160class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
164 __path: string;161 __path: string;
165162
166 static override getType(): string {163 static override getType(): string {
...@@ -208,7 +205,7 @@ class ComposerMentionNode extends DecoratorNode<ReactElement> {...@@ -208,7 +205,7 @@ class ComposerMentionNode extends DecoratorNode<ReactElement> {
208 return true;205 return true;
209 }206 }
210207
211 override decorate(): ReactElement {208 override decorate(): React.ReactElement {
212 return <ComposerMentionDecorator path={this.__path} />;209 return <ComposerMentionDecorator path={this.__path} />;
213 }210 }
214}211}
...@@ -278,7 +275,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s...@@ -278,7 +275,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s
278 );275 );
279}276}
280277
281class ComposerSkillNode extends DecoratorNode<ReactElement> {278class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
282 __skillName: string;279 __skillName: string;
283 __skillLabel: string;280 __skillLabel: string;
284 __skillDescription: string | null;281 __skillDescription: string | null;
...@@ -346,7 +343,7 @@ class ComposerSkillNode extends DecoratorNode<ReactElement> {...@@ -346,7 +343,7 @@ class ComposerSkillNode extends DecoratorNode<ReactElement> {
346 return true;343 return true;
347 }344 }
348345
349 override decorate(): ReactElement {346 override decorate(): React.ReactElement {
350 return (347 return (
351 <ComposerSkillDecorator348 <ComposerSkillDecorator
352 skillLabel={this.__skillLabel}349 skillLabel={this.__skillLabel}
...@@ -368,7 +365,7 @@ function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft...@@ -368,7 +365,7 @@ function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft
368 return <ComposerPendingTerminalContextChip context={props.context} />;365 return <ComposerPendingTerminalContextChip context={props.context} />;
369}366}
370367
371class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {368class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {
372 __context: TerminalContextDraft;369 __context: TerminalContextDraft;
373370
374 static override getType(): string {371 static override getType(): string {
...@@ -417,7 +414,7 @@ class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {...@@ -417,7 +414,7 @@ class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {
417 return true;414 return true;
418 }415 }
419416
420 override decorate(): ReactElement {417 override decorate(): React.ReactElement {
421 return <ComposerTerminalContextDecorator context={this.__context} />;418 return <ComposerTerminalContextDecorator context={this.__context} />;
422 }419 }
423}420}
...@@ -900,11 +897,8 @@ interface ComposerPromptEditorProps {...@@ -900,11 +897,8 @@ interface ComposerPromptEditorProps {
900 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",897 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
901 event: KeyboardEvent,898 event: KeyboardEvent,
902 ) => boolean;899 ) => boolean;
903 onPaste: ClipboardEventHandler<HTMLElement>;900 onPaste: React.ClipboardEventHandler<HTMLElement>;
904}901 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
905
906interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {
907 editorRef: Ref<ComposerPromptEditorHandle>;
908}902}
909903
910function ComposerCommandKeyPlugin(props: {904function ComposerCommandKeyPlugin(props: {
...@@ -1059,7 +1053,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() {...@@ -1059,7 +1053,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() {
10591053
1060function ComposerInlineTokenBackspacePlugin() {1054function ComposerInlineTokenBackspacePlugin() {
1061 const [editor] = useLexicalComposerContext();1055 const [editor] = useLexicalComposerContext();
1062 const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext);1056 const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext);
10631057
1064 useEffect(() => {1058 useEffect(() => {
1065 return editor.registerCommand(1059 return editor.registerCommand(
...@@ -1150,68 +1144,65 @@ function ComposerSurroundSelectionPlugin(props: {...@@ -1150,68 +1144,65 @@ function ComposerSurroundSelectionPlugin(props: {
1150 skillMetadataRef.current = skillMetadataByName(props.skills);1144 skillMetadataRef.current = skillMetadataByName(props.skills);
1151 }, [props.skills]);1145 }, [props.skills]);
11521146
1153 const applySurroundInsertion = useCallback(1147 const applySurroundInsertion = useEffectEvent((inputData: string): boolean => {
1154 (inputData: string): boolean => {1148 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
1155 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);1149 const pendingSurroundSelection = pendingSurroundSelectionRef.current;
1156 const pendingSurroundSelection = pendingSurroundSelectionRef.current;1150 if (!surroundCloseSymbol) {
1157 if (!surroundCloseSymbol) {1151 pendingSurroundSelectionRef.current = null;
1158 pendingSurroundSelectionRef.current = null;1152 return false;
1159 return false;1153 }
1160 }
11611154
1162 let handled = false;1155 let handled = false;
1163 editor.update(() => {1156 editor.update(() => {
1164 const selectionSnapshot =1157 const selectionSnapshot =
1165 pendingSurroundSelection ??1158 pendingSurroundSelection ??
1166 (() => {1159 (() => {
1167 const selection = $getSelection();1160 const selection = $getSelection();
1168 if (!$isRangeSelection(selection) || selection.isCollapsed()) {1161 if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1169 return null;1162 return null;
1170 }1163 }
1171 if ($selectionTouchesInlineToken(selection)) {1164 if ($selectionTouchesInlineToken(selection)) {
1172 return null;1165 return null;
1173 }1166 }
1174 const range = getSelectionRangeForExpandedComposerOffsets(selection);1167 const range = getSelectionRangeForExpandedComposerOffsets(selection);
1175 if (!range || range.start === range.end) {1168 if (!range || range.start === range.end) {
1176 return null;1169 return null;
1177 }1170 }
1178 const value = $getRoot().getTextContent();1171 const value = $getRoot().getTextContent();
1179 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {1172 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1180 return null;1173 return null;
1181 }1174 }
1182 return {1175 return {
1183 value,1176 value,
1184 expandedStart: range.start,1177 expandedStart: range.start,
1185 expandedEnd: range.end,1178 expandedEnd: range.end,
1186 };1179 };
1187 })();1180 })();
11881181
1189 if (!selectionSnapshot || !surroundCloseSymbol) {1182 if (!selectionSnapshot || !surroundCloseSymbol) {
1190 return;1183 return;
1191 }1184 }
11921185
1193 const selectedText = selectionSnapshot.value.slice(1186 const selectedText = selectionSnapshot.value.slice(
1194 selectionSnapshot.expandedStart,1187 selectionSnapshot.expandedStart,
1195 selectionSnapshot.expandedEnd,1188 selectionSnapshot.expandedEnd,
1196 );1189 );
1197 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;1190 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1198 $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current);1191 $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current);
1199 const selectionStart = collapseExpandedComposerCursor(1192 const selectionStart = collapseExpandedComposerCursor(
1200 nextValue,1193 nextValue,
1201 selectionSnapshot.expandedStart,1194 selectionSnapshot.expandedStart,
1202 );1195 );
1203 $setSelectionRangeAtComposerOffsets(1196 $setSelectionRangeAtComposerOffsets(
1204 selectionStart + inputData.length,1197 selectionStart + inputData.length,
1205 selectionStart + inputData.length + selectedText.length,1198 selectionStart + inputData.length + selectedText.length,
1206 );1199 );
1207 handled = true;1200 handled = true;
1208 pendingSurroundSelectionRef.current = null;1201 pendingSurroundSelectionRef.current = null;
1209 });1202 });
12101203
1211 return handled;1204 return handled;
1212 },1205 });
1213 [editor],
1214 );
12151206
1216 useEffect(() => {1207 useEffect(() => {
1217 const onKeyDown = (event: KeyboardEvent) => {1208 const onKeyDown = (event: KeyboardEvent) => {
...@@ -1388,7 +1379,7 @@ function ComposerSurroundSelectionPlugin(props: {...@@ -1388,7 +1379,7 @@ function ComposerSurroundSelectionPlugin(props: {
1388 }1379 }
1389 unregisterRootListener();1380 unregisterRootListener();
1390 };1381 };
1391 }, [applySurroundInsertion, editor]);1382 }, [editor]);
13921383
1393 return null;1384 return null;
1394}1385}
...@@ -1406,7 +1397,7 @@ function ComposerPromptEditorInner({...@@ -1406,7 +1397,7 @@ function ComposerPromptEditorInner({
1406 onCommandKeyDown,1397 onCommandKeyDown,
1407 onPaste,1398 onPaste,
1408 editorRef,1399 editorRef,
1409}: ComposerPromptEditorInnerProps) {1400}: ComposerPromptEditorProps) {
1410 const [editor] = useLexicalComposerContext();1401 const [editor] = useLexicalComposerContext();
1411 const onChangeRef = useRef(onChange);1402 const onChangeRef = useRef(onChange);
1412 const initialCursor = clampCollapsedComposerCursor(value, cursor);1403 const initialCursor = clampCollapsedComposerCursor(value, cursor);
...@@ -1614,13 +1605,13 @@ function ComposerPromptEditorInner({...@@ -1614,13 +1605,13 @@ function ComposerPromptEditorInner({
1614 }, []);1605 }, []);
16151606
1616 return (1607 return (
1617 <ComposerTerminalContextActionsContext.Provider value={terminalContextActions}>1608 <ComposerTerminalContextActionsContext value={terminalContextActions}>
1618 <div className="relative">1609 <div className="relative">
1619 <PlainTextPlugin1610 <PlainTextPlugin
1620 contentEditable={1611 contentEditable={
1621 <ContentEditable1612 <ContentEditable
1622 className={cn(1613 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]",
1624 className,1615 className,
1625 )}1616 )}
1626 data-testid="composer-editor"1617 data-testid="composer-editor"
...@@ -1646,29 +1637,24 @@ function ComposerPromptEditorInner({...@@ -1646,29 +1637,24 @@ function ComposerPromptEditorInner({
1646 <ComposerInlineTokenBackspacePlugin />1637 <ComposerInlineTokenBackspacePlugin />
1647 <HistoryPlugin />1638 <HistoryPlugin />
1648 </div>1639 </div>
1649 </ComposerTerminalContextActionsContext.Provider>1640 </ComposerTerminalContextActionsContext>
1650 );1641 );
1651}1642}
16521643
1653export const ComposerPromptEditor = forwardRef<1644export function ComposerPromptEditor({
1654 ComposerPromptEditorHandle,1645 value,
1655 ComposerPromptEditorProps1646 cursor,
1656>(function ComposerPromptEditor(1647 terminalContexts,
1657 {1648 skills,
1658 value,1649 disabled,
1659 cursor,1650 placeholder,
1660 terminalContexts,1651 className,
1661 skills,1652 onRemoveTerminalContext,
1662 disabled,1653 onChange,
1663 placeholder,1654 onCommandKeyDown,
1664 className,1655 onPaste,
1665 onRemoveTerminalContext,1656 editorRef,
1666 onChange,1657}: ComposerPromptEditorProps) {
1667 onCommandKeyDown,
1668 onPaste,
1669 },
1670 ref,
1671) {
1672 const initialValueRef = useRef(value);1658 const initialValueRef = useRef(value);
1673 const initialTerminalContextsRef = useRef(terminalContexts);1659 const initialTerminalContextsRef = useRef(terminalContexts);
1674 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));1660 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
...@@ -1703,10 +1689,10 @@ export const ComposerPromptEditor = forwardRef<...@@ -1703,10 +1689,10 @@ export const ComposerPromptEditor = forwardRef<
1703 onRemoveTerminalContext={onRemoveTerminalContext}1689 onRemoveTerminalContext={onRemoveTerminalContext}
1704 onChange={onChange}1690 onChange={onChange}
1705 onPaste={onPaste}1691 onPaste={onPaste}
1706 editorRef={ref}1692 editorRef={editorRef}
1707 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}1693 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
1708 {...(className ? { className } : {})}1694 {...(className ? { className } : {})}
1709 />1695 />
1710 </LexicalComposer>1696 </LexicalComposer>
1711 );1697 );
1712});1698}
apps/web/src/components/chat/ChatComposer.tsx+1766-1781
...@@ -20,7 +20,6 @@ import {...@@ -20,7 +20,6 @@ import {
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";21import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
22import {22import {
23 forwardRef,
24 memo,23 memo,
25 useCallback,24 useCallback,
26 useEffect,25 useEffect,
...@@ -446,12 +445,13 @@ export interface ChatComposerProps {...@@ -446,12 +445,13 @@ export interface ChatComposerProps {
446 gitCwd: string | null;445 gitCwd: string | null;
447446
448 // Refs the parent needs kept in sync447 // Refs the parent needs kept in sync
449 promptRef: React.MutableRefObject<string>;448 promptRef: React.RefObject<string>;
450 composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>;449 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
451 composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>;450 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
451 composerRef: React.RefObject<ChatComposerHandle | null>;
452452
453 // Scroll453 // Scroll
454 shouldAutoScrollRef: React.MutableRefObject<boolean>;454 shouldAutoScrollRef: React.RefObject<boolean>;
455 scheduleStickToBottom: () => void;455 scheduleStickToBottom: () => void;
456456
457 // Callbacks457 // Callbacks
...@@ -489,427 +489,423 @@ export interface ChatComposerProps {...@@ -489,427 +489,423 @@ export interface ChatComposerProps {
489// Component489// Component
490// --------------------------------------------------------------------------490// --------------------------------------------------------------------------
491491
492export const ChatComposer = memo(492export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {
493 forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) {493 const {
494 const {494 composerDraftTarget,
495 composerDraftTarget,495 environmentId,
496 environmentId,496 routeKind,
497 routeKind,497 routeThreadRef,
498 routeThreadRef,498 draftId,
499 draftId,499 activeThreadId,
500 activeThreadId,500 activeThreadEnvironmentId: _activeThreadEnvironmentId,
501 activeThreadEnvironmentId: _activeThreadEnvironmentId,501 activeThread,
502 activeThread,502 isServerThread: _isServerThread,
503 isServerThread: _isServerThread,503 isLocalDraftThread: _isLocalDraftThread,
504 isLocalDraftThread: _isLocalDraftThread,504 phase,
505 phase,505 isConnecting,
506 isConnecting,506 isSendBusy,
507 isSendBusy,507 isPreparingWorktree,
508 isPreparingWorktree,508 environmentUnavailable,
509 environmentUnavailable,509 activePendingApproval,
510 activePendingApproval,510 pendingApprovals,
511 pendingApprovals,511 pendingUserInputs,
512 pendingUserInputs,512 activePendingProgress,
513 activePendingProgress,513 activePendingResolvedAnswers,
514 activePendingResolvedAnswers,514 activePendingIsResponding,
515 activePendingIsResponding,515 activePendingDraftAnswers,
516 activePendingDraftAnswers,516 activePendingQuestionIndex,
517 activePendingQuestionIndex,517 respondingRequestIds,
518 respondingRequestIds,518 showPlanFollowUpPrompt,
519 showPlanFollowUpPrompt,519 activeProposedPlan,
520 activeProposedPlan,520 activePlan,
521 activePlan,521 sidebarProposedPlan,
522 sidebarProposedPlan,522 planSidebarLabel,
523 planSidebarLabel,523 planSidebarOpen,
524 planSidebarOpen,524 runtimeMode,
525 runtimeMode,525 interactionMode,
526 interactionMode,526 lockedProvider,
527 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,
528 providerStatuses,612 providerStatuses,
529 activeProjectDefaultModelSelection,613 explicitSelectedInstanceId,
530 activeThreadModelSelection,614 ) ?? ProviderDriverKind.make("codex");
531 activeThreadActivities,615 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
532 resolvedTheme,616 const lockedContinuationGroupKey = useMemo((): string | null => {
533 settings,617 if (!lockedProvider || !activeThread) return null;
534 keybindings,618 const lockedInstanceId =
535 terminalOpen,619 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
536 gitCwd,620 if (!lockedInstanceId) return null;
537 promptRef,621 return (
538 composerImagesRef,622 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
539 composerTerminalContextsRef,623 ?.continuationGroupKey ?? null
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 );624 );
601 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;625 }, [
602 const threadProvider =626 activeThread,
603 activeThread?.session?.providerInstanceId ??627 activeThreadModelSelection?.instanceId,
604 activeThreadModelSelection?.instanceId ??628 lockedProvider,
605 activeProjectDefaultModelSelection?.instanceId ??629 providerInstanceEntries,
606 null;630 ]);
607 const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider;631
608632 // Resolve which configured instance the composer is currently targeting.
609 const unlockedSelectedProvider =633 // Priority:
610 resolveProviderDriverKindForInstanceSelection(634 // 1. The composer draft's `activeProvider` — the user's unsaved pick
611 providerInstanceEntries,635 // from the model picker (must win, otherwise the UI appears to
612 providerStatuses,636 // ignore picker selections).
613 explicitSelectedInstanceId,637 // 2. Thread's persisted instance id (server-side saved selection).
614 ) ?? ProviderDriverKind.make("codex");638 // 3. Project default's instance id.
615 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;639 // 4. First enabled entry matching the current driver kind.
616 const lockedContinuationGroupKey = useMemo((): string | null => {640 // 5. First enabled entry overall / default instance for the kind.
617 if (!lockedProvider || !activeThread) return null;641 //
618 const lockedInstanceId =642 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
619 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;643 const candidates: Array<string | null | undefined> = [
620 if (!lockedInstanceId) return null;644 composerDraft.activeProvider,
621 return (645 activeThread?.session?.providerInstanceId,
622 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
623 ?.continuationGroupKey ?? null
624 );
625 }, [
626 activeThread,
627 activeThreadModelSelection?.instanceId,646 activeThreadModelSelection?.instanceId,
628 lockedProvider,647 activeProjectDefaultModelSelection?.instanceId,
629 providerInstanceEntries,648 ];
630 ]);649 for (const candidate of candidates) {
631650 if (!candidate) continue;
632 // Resolve which configured instance the composer is currently targeting.651 const match = providerInstanceEntries.find(
633 // Priority:652 (entry) => entry.instanceId === candidate && entry.enabled,
634 // 1. The composer draft's `activeProvider` — the user's unsaved pick653 );
635 // from the model picker (must win, otherwise the UI appears to654 if (match) {
636 // ignore picker selections).655 // When locked to a specific driver kind, ignore persisted instance
637 // 2. Thread's persisted instance id (server-side saved selection).656 // ids from a different kind or continuation group.
638 // 3. Project default's instance id.657 if (lockedProvider && match.driverKind !== lockedProvider) continue;
639 // 4. First enabled entry matching the current driver kind.658 if (
640 // 5. First enabled entry overall / default instance for the kind.659 lockedContinuationGroupKey &&
641 //660 match.continuationGroupKey !== lockedContinuationGroupKey
642 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {661 ) {
643 const candidates: Array<string | null | undefined> = [662 continue;
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;
665 }663 }
664 return match.instanceId;
666 }665 }
667 if (explicitSelectedInstanceId) {666 }
668 return ProviderInstanceId.make(explicitSelectedInstanceId);667 if (explicitSelectedInstanceId) {
669 }668 return ProviderInstanceId.make(explicitSelectedInstanceId);
670 const byKind = providerInstanceEntries.find(669 }
671 (entry) =>670 const byKind = providerInstanceEntries.find(
672 entry.enabled &&671 (entry) =>
673 entry.driverKind === selectedProvider &&672 entry.enabled &&
674 (!lockedContinuationGroupKey ||673 entry.driverKind === selectedProvider &&
675 entry.continuationGroupKey === lockedContinuationGroupKey),674 (!lockedContinuationGroupKey || 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],
714 );675 );
715 const selectedProviderStatus = useMemo(676 if (byKind) return byKind.instanceId;
716 () => selectedProviderEntry?.snapshot ?? null,677 const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled);
717 [selectedProviderEntry],678 return (
718 );679 anyEnabled?.instanceId ??
719 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(680 providerInstanceEntries[0]?.instanceId ??
720 () => selectedProviderEntry?.models ?? [],681 activeThreadModelSelection?.instanceId ??
721 [selectedProviderEntry],682 activeProjectDefaultModelSelection?.instanceId ??
683 ProviderInstanceId.make("codex")
722 );684 );
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(707 // Resolve the active instance's snapshot by `instanceId` so a custom
725 () =>708 // instance gets its own slash commands, skills, and model list — not
726 getComposerProviderState({709 // the first snapshot for the same driver kind.
727 provider: selectedProvider,710 const selectedProviderEntry = useMemo(
728 model: selectedModel,711 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
729 models: selectedProviderModels,712 [providerInstanceEntries, selectedInstanceId],
730 prompt,713 );
731 modelOptions: composerModelOptions?.[selectedProvider],714 const selectedProviderStatus = useMemo(
732 }),715 () => selectedProviderEntry?.snapshot ?? null,
733 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],716 [selectedProviderEntry],
734 );717 );
718 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
719 () => selectedProviderEntry?.models ?? [],
720 [selectedProviderEntry],
721 );
735722
736 const selectedPromptEffort = composerProviderState.promptEffort;723 const composerProviderState = useMemo(
737 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;724 () =>
738 const composerProviderControls = useMemo(725 getComposerProviderState({
739 () => ({726 provider: selectedProvider,
740 showInteractionModeToggle: getProviderInteractionModeToggle(727 model: selectedModel,
741 providerStatuses,728 models: selectedProviderModels,
742 selectedProvider,729 prompt,
743 ),730 modelOptions: composerModelOptions?.[selectedProvider],
744 }),731 }),
745 [providerStatuses, selectedProvider],732 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
746 );733 );
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 );
780734
781 // ------------------------------------------------------------------735 const selectedPromptEffort = composerProviderState.promptEffort;
782 // Composer-local state736 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
783 // ------------------------------------------------------------------737 const composerProviderControls = useMemo(
784 const [composerCursor, setComposerCursor] = useState(() =>738 () => ({
785 collapseExpandedComposerCursor(prompt, prompt.length),739 showInteractionModeToggle: getProviderInteractionModeToggle(
786 );740 providerStatuses,
787 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>741 selectedProvider,
788 detectComposerTrigger(prompt, prompt.length),742 ),
789 );743 }),
790 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);744 [providerStatuses, selectedProvider],
791 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(745 );
792 null,746 const selectedModelSelection = useMemo<ModelSelection>(
793 );747 () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
794 const [isDragOverComposer, setIsDragOverComposer] = useState(false);748 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
795 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);749 );
796 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);750 const selectedModelForPicker = selectedModel;
797 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);751 // Instance-keyed option list so the picker can show each configured
798 const [isComposerFocused, setIsComposerFocused] = useState(false);752 // instance (built-in + custom) as a first-class sidebar entry. The
799 const isMobileViewport = useMediaQuery("max-sm");753 // options are server-reported models plus that exact instance's
800 const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;754 // configured custom models; selected slugs are not injected into lists.
801755 const modelOptionsByInstance = useMemo<
802 // ------------------------------------------------------------------756 ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>>
803 // Refs757 >(() => {
804 // ------------------------------------------------------------------758 const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
805 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);759 for (const entry of providerInstanceEntries) {
806 const composerFormRef = useRef<HTMLFormElement>(null);760 out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
807 const composerSurfaceRef = useRef<HTMLDivElement>(null);761 }
808 const composerFormHeightRef = useRef(0);762 return out;
809 const composerSelectLockRef = useRef(false);763 }, [providerInstanceEntries, settings]);
810 const composerMenuOpenRef = useRef(false);764 const selectedModelForPickerWithCustomFallback = useMemo(() => {
811 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);765 const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? [];
812 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);766 return currentOptions.some((option) => option.slug === selectedModelForPicker)
813 const composerBlurFrameRef = useRef<number | null>(null);767 ? selectedModelForPicker
814 const mobileComposerExpandFrameRef = useRef<number | null>(null);768 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
815 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);769 }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]);
816 const mobileComposerExpandInFlightRef = useRef(false);770
817 const dragDepthRef = useRef(0);771 // ------------------------------------------------------------------
818772 // Context window
819 // ------------------------------------------------------------------773 // ------------------------------------------------------------------
820 // Derived: composer send state774 const activeContextWindow = useMemo(
821 // ------------------------------------------------------------------775 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
822 const composerSendState = useMemo(776 [activeThreadActivities],
823 () =>777 );
824 deriveComposerSendState({
825 prompt,
826 imageCount: composerImages.length,
827 terminalContexts: composerTerminalContexts,
828 }),
829 [composerImages.length, composerTerminalContexts, prompt],
830 );
831778
832 // ------------------------------------------------------------------779 // ------------------------------------------------------------------
833 // Derived: composer trigger / menu780 // Composer-local state
834 // ------------------------------------------------------------------781 // ------------------------------------------------------------------
835 const composerTriggerKind = composerTrigger?.kind ?? null;782 const [composerCursor, setComposerCursor] = useState(() =>
836 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";783 collapseExpandedComposerCursor(prompt, prompt.length),
837 const isPathTrigger = composerTriggerKind === "path";784 );
838 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(785 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
839 pathTriggerQuery,786 detectComposerTrigger(prompt, prompt.length),
840 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },787 );
841 (debouncerState) => ({ isPending: debouncerState.isPending }),788 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
842 );789 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
843 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";790 null,
844 const workspaceEntriesQuery = useQuery(791 );
845 projectSearchEntriesQueryOptions({792 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
846 environmentId,793 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
847 cwd: gitCwd,794 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
848 query: effectivePathQuery,795 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
849 enabled: isPathTrigger,796 const [isComposerFocused, setIsComposerFocused] = useState(false);
850 limit: 80,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,
851 }),826 }),
852 );827 [composerImages.length, composerTerminalContexts, prompt],
853 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;828 );
854829
855 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {830 // ------------------------------------------------------------------
856 if (!composerTrigger) return [];831 // Derived: composer trigger / menu
857 if (composerTrigger.kind === "path") {832 // ------------------------------------------------------------------
858 return workspaceEntries.map((entry) => ({833 const composerTriggerKind = composerTrigger?.kind ?? null;
859 id: `path:${entry.kind}:${entry.path}`,834 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
860 type: "path",835 const isPathTrigger = composerTriggerKind === "path";
861 path: entry.path,836 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
862 pathKind: entry.kind,837 pathTriggerQuery,
863 label: basenameOfPath(entry.path),838 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
864 description: entry.parentPath ?? "",839 (debouncerState) => ({ isPending: debouncerState.isPending }),
865 }));840 );
866 }841 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
867 if (composerTrigger.kind === "slash-command") {842 const workspaceEntriesQuery = useQuery(
868 const builtInSlashCommandItems = [843 projectSearchEntriesQueryOptions({
869 {844 environmentId,
870 id: "slash:model",845 cwd: gitCwd,
871 type: "slash-command",846 query: effectivePathQuery,
872 command: "model",847 enabled: isPathTrigger,
873 label: "/model",848 limit: 80,
874 description: "Switch response model for this thread",849 }),
875 },850 );
876 {851 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
877 id: "slash:plan",852
878 type: "slash-command",853 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
879 command: "plan",854 if (!composerTrigger) return [];
880 label: "/plan",855 if (composerTrigger.kind === "path") {
881 description: "Switch this thread into plan mode",856 return workspaceEntries.map((entry) => ({
882 },857 id: `path:${entry.kind}:${entry.path}`,
883 {858 type: "path",
884 id: "slash:default",859 path: entry.path,
885 type: "slash-command",860 pathKind: entry.kind,
886 command: "default",861 label: basenameOfPath(entry.path),
887 label: "/default",862 description: entry.parentPath ?? "",
888 description: "Switch this thread back to normal build mode",863 }));
889 },864 }
890 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;865 if (composerTrigger.kind === "slash-command") {
891 const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(866 const builtInSlashCommandItems = [
892 (command) => ({867 {
893 id: `provider-slash-command:${selectedProvider}:${command.name}`,868 id: "slash:model",
894 type: "provider-slash-command" as const,869 type: "slash-command",
895 provider: selectedProvider,870 command: "model",
896 command,871 label: "/model",
897 label: `/${command.name}`,872 description: "Switch response model for this thread",
898 description: command.description ?? command.input?.hint ?? "Run provider command",873 },
899 }),874 {
900 );875 id: "slash:plan",
901 const query = composerTrigger.query.trim().toLowerCase();876 type: "slash-command",
902 const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];877 command: "plan",
903 if (!query) {878 label: "/plan",
904 return slashCommandItems;879 description: "Switch this thread into plan mode",
905 }880 },
906 return searchSlashCommandItems(slashCommandItems, query);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;
907 }903 }
908 if (composerTrigger.kind === "skill") {904 return searchSlashCommandItems(slashCommandItems, query);
909 return searchProviderSkills(905 }
910 selectedProviderStatus?.skills ?? [],906 if (composerTrigger.kind === "skill") {
911 composerTrigger.query,907 return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
912 ).map((skill) => ({908 (skill) => ({
913 id: `skill:${selectedProvider}:${skill.name}`,909 id: `skill:${selectedProvider}:${skill.name}`,
914 type: "skill" as const,910 type: "skill" as const,
915 provider: selectedProvider,911 provider: selectedProvider,
...@@ -919,1125 +915,1084 @@ export const ChatComposer = memo(...@@ -919,1125 +915,1084 @@ export const ChatComposer = memo(
919 skill.shortDescription ??915 skill.shortDescription ??
920 skill.description ??916 skill.description ??
921 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),917 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
922 }));918 }),
923 }919 );
924 return [];920 }
925 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);921 return [];
926922 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);
927 const composerMenuOpen = Boolean(composerTrigger);923
928 const composerMenuSearchKey = composerTrigger924 const composerMenuOpen = Boolean(composerTrigger);
929 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`925 const composerMenuSearchKey = composerTrigger
930 : null;926 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
931 const activeComposerMenuItem = useMemo(() => {927 : null;
932 const activeItemId = resolveComposerMenuActiveItemId({928 const activeComposerMenuItem = useMemo(() => {
933 items: composerMenuItems,929 const activeItemId = resolveComposerMenuActiveItemId({
934 highlightedItemId: composerHighlightedItemId,930 items: composerMenuItems,
935 currentSearchKey: composerMenuSearchKey,931 highlightedItemId: composerHighlightedItemId,
936 highlightedSearchKey: composerHighlightedSearchKey,932 currentSearchKey: composerMenuSearchKey,
937 });933 highlightedSearchKey: composerHighlightedSearchKey,
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,
1041 });934 });
1042 const pendingPrimaryAction = useMemo(935 return composerMenuItems.find((item) => item.id === activeItemId) ?? null;
1043 () =>936 }, [
1044 activePendingProgress937 composerHighlightedItemId,
1045 ? {938 composerHighlightedSearchKey,
1046 questionIndex: activePendingProgress.questionIndex,939 composerMenuItems,
1047 isLastQuestion: activePendingProgress.isLastQuestion,940 composerMenuSearchKey,
1048 canAdvance: activePendingProgress.canAdvance,941 ]);
1049 isResponding: activePendingIsResponding,942
1050 isComplete: Boolean(activePendingResolvedAnswers),943 composerMenuOpenRef.current = composerMenuOpen;
1051 }944 composerMenuItemsRef.current = composerMenuItems;
1052 : null,945 activeComposerMenuItemRef.current = activeComposerMenuItem;
1053 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],946
1054 );947 const nonPersistedComposerImageIdSet = useMemo(
1055 const collapsedComposerPrimaryActionDisabled =948 () => new Set(nonPersistedComposerImageIds),
1056 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;949 [nonPersistedComposerImageIds],
1057 const collapsedComposerPrimaryActionLabel = "Send message";950 );
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 );
1114951
1115 // ------------------------------------------------------------------952 const isComposerApprovalState = activePendingApproval !== null;
1116 // Sync refs back to parent953 const activePendingUserInput = pendingUserInputs[0] ?? null;
1117 // ------------------------------------------------------------------954 const hasComposerHeader =
1118 useEffect(() => {955 isComposerApprovalState ||
1119 promptRef.current = prompt;956 pendingUserInputs.length > 0 ||
1120 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));957 (showPlanFollowUpPrompt && activeProposedPlan !== null);
1121 }, [prompt, promptRef]);958 const showCollapsedMobilePromptRow =
1122959 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
1123 useEffect(() => {960
1124 composerImagesRef.current = composerImages;961 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
1125 }, [composerImages, composerImagesRef]);962 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
1126963 const composerFooterActionLayoutKey = useMemo(() => {
1127 useEffect(() => {964 if (activePendingProgress) {
1128 composerTerminalContextsRef.current = composerTerminalContexts;965 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
1129 }, [composerTerminalContexts, composerTerminalContextsRef]);966 }
1130967 if (phase === "running") {
1131 // ------------------------------------------------------------------968 return "running";
1132 // Composer menu highlight sync969 }
1133 // ------------------------------------------------------------------970 if (showPlanFollowUpPrompt) {
1134 useEffect(() => {971 return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
1135 if (!composerMenuOpen) {972 }
1136 setComposerHighlightedItemId(null);973 return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
1137 setComposerHighlightedSearchKey(null);974 }, [
1138 return;975 activePendingIsResponding,
1139 }976 activePendingProgress,
1140 const nextActiveItemId = resolveComposerMenuActiveItemId({977 composerSendState.hasSendableContent,
1141 items: composerMenuItems,978 isConnecting,
1142 highlightedItemId: composerHighlightedItemId,979 isPreparingWorktree,
1143 currentSearchKey: composerMenuSearchKey,980 isSendBusy,
1144 highlightedSearchKey: composerHighlightedSearchKey,981 phase,
1145 });982 prompt,
1146 setComposerHighlightedItemId((existing) =>983 showPlanFollowUpPrompt,
1147 existing === nextActiveItemId ? existing : nextActiveItemId,984 ]);
1148 );985
1149 setComposerHighlightedSearchKey((existing) =>986 const isComposerMenuLoading =
1150 existing === composerMenuSearchKey ? existing : composerMenuSearchKey,987 composerTriggerKind === "path" &&
1151 );988 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
1152 }, [989 workspaceEntriesQuery.isLoading ||
1153 composerHighlightedItemId,990 workspaceEntriesQuery.isFetching);
1154 composerHighlightedSearchKey,991 const composerMenuEmptyState = useMemo(() => {
1155 composerMenuItems,992 if (composerTriggerKind === "skill") {
1156 composerMenuOpen,993 return "No skills found. Try / to browse provider commands.";
1157 composerMenuSearchKey,994 }
1158 ]);995 return composerTriggerKind === "path"
1159996 ? "No matching files or folders."
1160 const lastSyncedPendingInputRef = useRef<{997 : "No matching command.";
1161 requestId: string | null;998 }, [composerTriggerKind]);
1162 questionId: string | null;999
1163 } | null>(null);1000 // ------------------------------------------------------------------
11641001 // Provider traits UI
1165 useEffect(() => {1002 // ------------------------------------------------------------------
1166 const nextCustomAnswer = activePendingProgress?.customAnswer;1003 const setPromptFromTraits = useCallback(
1167 if (typeof nextCustomAnswer !== "string") {1004 (nextPrompt: string) => {
1168 lastSyncedPendingInputRef.current = null;1005 if (nextPrompt === promptRef.current) {
1006 scheduleComposerFocus();
1169 return;1007 return;
1170 }1008 }
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;1019 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
1173 const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;1020 provider: selectedProvider,
1174 const questionChanged =1021 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
1175 lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||1022 ...(routeKind === "draft" && draftId ? { draftId } : {}),
1176 lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;1023 model: selectedModel,
1177 const textChangedExternally = promptRef.current !== nextCustomAnswer;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 = {1068 const addComposerImage = useCallback(
1180 requestId: nextRequestId,1069 (image: ComposerImageAttachment) => {
1181 questionId: nextQuestionId,1070 addComposerDraftImage(composerDraftTarget, image);
1182 };1071 },
1072 [composerDraftTarget, addComposerDraftImage],
1073 );
11831074
1184 if (!questionChanged && !textChangedExternally) {1075 const addComposerImagesToDraft = useCallback(
1185 return;1076 (images: ComposerImageAttachment[]) => {
1186 }1077 addComposerDraftImages(composerDraftTarget, images);
1078 },
1079 [composerDraftTarget, addComposerDraftImages],
1080 );
11871081
1188 promptRef.current = nextCustomAnswer;1082 const removeComposerImageFromDraft = useCallback(
1189 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);1083 (imageId: string) => {
1190 setComposerCursor(nextCursor);1084 removeComposerDraftImage(composerDraftTarget, imageId);
1191 setComposerTrigger(1085 },
1192 detectComposerTrigger(1086 [composerDraftTarget, removeComposerDraftImage],
1193 nextCustomAnswer,1087 );
1194 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),1088
1195 ),1089 const removeComposerTerminalContextFromDraft = useCallback(
1090 (contextId: string) => {
1091 const contextIndex = composerTerminalContexts.findIndex(
1092 (context) => context.id === contextId,
1196 );1093 );
1197 setComposerHighlightedItemId(null);1094 if (contextIndex < 0) return;
1198 }, [1095 const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
1199 activePendingProgress?.customAnswer,1096 promptRef.current = removal.prompt;
1200 activePendingProgress?.activeQuestion?.id,1097 setPrompt(removal.prompt);
1201 activePendingUserInput?.requestId,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,
1202 promptRef,1106 promptRef,
1203 ]);1107 removeComposerDraftTerminalContext,
1108 setPrompt,
1109 ],
1110 );
12041111
1205 // ------------------------------------------------------------------1112 // ------------------------------------------------------------------
1206 // Reset compositor state on thread/draft change1113 // Sync refs back to parent
1207 // ------------------------------------------------------------------1114 // ------------------------------------------------------------------
1208 useEffect(() => {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) {
1209 setComposerHighlightedItemId(null);1133 setComposerHighlightedItemId(null);
1210 setComposerCursor(1134 setComposerHighlightedSearchKey(null);
1211 collapseExpandedComposerCursor(promptRef.current, promptRef.current.length),1135 return;
1212 );1136 }
1213 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));1137 const nextActiveItemId = resolveComposerMenuActiveItemId({
1214 dragDepthRef.current = 0;1138 items: composerMenuItems,
1215 setIsDragOverComposer(false);1139 highlightedItemId: composerHighlightedItemId,
1216 }, [draftId, activeThreadId, promptRef]);1140 currentSearchKey: composerMenuSearchKey,
12171141 highlightedSearchKey: composerHighlightedSearchKey,
1218 // ------------------------------------------------------------------1142 });
1219 // Footer compact layout observation1143 setComposerHighlightedItemId((existing) =>
1220 // ------------------------------------------------------------------1144 existing === nextActiveItemId ? existing : nextActiveItemId,
1221 useLayoutEffect(() => {1145 );
1222 const composerForm = composerFormRef.current;1146 setComposerHighlightedSearchKey((existing) =>
1223 if (!composerForm) return;1147 existing === composerMenuSearchKey ? existing : composerMenuSearchKey,
1224 const measureComposerFormWidth = () => composerForm.clientWidth;1148 );
1225 const measureFooterCompactness = () => {1149 }, [
1226 const composerFormWidth = measureComposerFormWidth();1150 composerHighlightedItemId,
1227 const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {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, {
1228 hasWideActions: composerFooterHasWideActions,1228 hasWideActions: composerFooterHasWideActions,
1229 });1229 });
1230 const primaryActionsCompact =1230 return {
1231 footerCompact &&1231 primaryActionsCompact,
1232 shouldUseCompactComposerPrimaryActions(composerFormWidth, {1232 footerCompact,
1233 hasWideActions: composerFooterHasWideActions,
1234 });
1235 return {
1236 primaryActionsCompact,
1237 footerCompact,
1238 };
1239 };1233 };
1234 };
12401235
1241 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;1236 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1242 const initialCompactness = measureFooterCompactness();1237 const initialCompactness = measureFooterCompactness();
1243 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);1238 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1244 setIsComposerFooterCompact(initialCompactness.footerCompact);1239 setIsComposerFooterCompact(initialCompactness.footerCompact);
1245 if (typeof ResizeObserver === "undefined") return;1240 if (typeof ResizeObserver === "undefined") return;
12461241
1247 const observer = new ResizeObserver((entries) => {1242 const observer = new ResizeObserver((entries) => {
1248 const [entry] = entries;1243 const [entry] = entries;
1249 if (!entry) return;1244 if (!entry) return;
1250 const nextCompactness = measureFooterCompactness();1245 const nextCompactness = measureFooterCompactness();
1251 setIsComposerPrimaryActionsCompact((previous) =>1246 setIsComposerPrimaryActionsCompact((previous) =>
1252 previous === nextCompactness.primaryActionsCompact1247 previous === nextCompactness.primaryActionsCompact
1253 ? previous1248 ? previous
1254 : nextCompactness.primaryActionsCompact,1249 : nextCompactness.primaryActionsCompact,
1255 );1250 );
1256 setIsComposerFooterCompact((previous) =>1251 setIsComposerFooterCompact((previous) =>
1257 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,1252 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
1258 );1253 );
1259 const nextHeight = entry.contentRect.height;1254 const nextHeight = entry.contentRect.height;
1260 const previousHeight = composerFormHeightRef.current;1255 const previousHeight = composerFormHeightRef.current;
1261 composerFormHeightRef.current = nextHeight;1256 composerFormHeightRef.current = nextHeight;
1262 if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;1257 if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1263 if (!shouldAutoScrollRef.current) return;1258 if (!shouldAutoScrollRef.current) return;
1264 scheduleStickToBottom();1259 scheduleStickToBottom();
1265 });1260 });
12661261
1267 observer.observe(composerForm);1262 observer.observe(composerForm);
1268 return () => {1263 return () => {
1269 observer.disconnect();1264 observer.disconnect();
1270 };1265 };
1271 }, [1266 }, [
1272 activeThreadId,1267 activeThreadId,
1273 composerFooterActionLayoutKey,1268 composerFooterActionLayoutKey,
1274 composerFooterHasWideActions,1269 composerFooterHasWideActions,
1275 scheduleStickToBottom,1270 scheduleStickToBottom,
1276 shouldAutoScrollRef,1271 shouldAutoScrollRef,
1277 ]);1272 ]);
12781273
1279 // ------------------------------------------------------------------1274 // ------------------------------------------------------------------
1280 // Image persist effect1275 // Image persist effect
1281 // ------------------------------------------------------------------1276 // ------------------------------------------------------------------
1282 useEffect(() => {1277 useEffect(() => {
1283 let cancelled = false;1278 let cancelled = false;
1284 void (async () => {1279 void (async () => {
1285 if (composerImages.length === 0) {1280 if (composerImages.length === 0) {
1286 clearComposerDraftPersistedAttachments(composerDraftTarget);1281 clearComposerDraftPersistedAttachments(composerDraftTarget);
1287 return;1282 return;
1288 }1283 }
1289 const getPersistedAttachmentsForThread = () =>1284 const getPersistedAttachmentsForThread = () =>
1290 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];1285 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
1291 try {1286 try {
1292 const currentPersistedAttachments = getPersistedAttachmentsForThread();1287 const currentPersistedAttachments = getPersistedAttachmentsForThread();
1293 const existingPersistedById = new Map(1288 const existingPersistedById = new Map(
1294 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),1289 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1295 );1290 );
1296 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();1291 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1297 await Promise.all(1292 await Promise.all(
1298 composerImages.map(async (image) => {1293 composerImages.map(async (image) => {
1299 try {1294 try {
1300 const dataUrl = await readFileAsDataUrl(image.file);1295 const dataUrl = await readFileAsDataUrl(image.file);
1301 stagedAttachmentById.set(image.id, {1296 stagedAttachmentById.set(image.id, {
1302 id: image.id,1297 id: image.id,
1303 name: image.name,1298 name: image.name,
1304 mimeType: image.mimeType,1299 mimeType: image.mimeType,
1305 sizeBytes: image.sizeBytes,1300 sizeBytes: image.sizeBytes,
1306 dataUrl,1301 dataUrl,
1307 });1302 });
1308 } catch {1303 } catch {
1309 const existingPersisted = existingPersistedById.get(image.id);1304 const existingPersisted = existingPersistedById.get(image.id);
1310 if (existingPersisted) {1305 if (existingPersisted) {
1311 stagedAttachmentById.set(image.id, existingPersisted);1306 stagedAttachmentById.set(image.id, existingPersisted);
1312 }
1313 }1307 }
1314 }),1308 }
1315 );1309 }),
1316 const serialized = Array.from(stagedAttachmentById.values());1310 );
1317 if (cancelled) return;1311 const serialized = Array.from(stagedAttachmentById.values());
1318 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);1312 if (cancelled) return;
1319 } catch {1313 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
1320 const currentImageIds = new Set(composerImages.map((image) => image.id));1314 } catch {
1321 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();1315 const currentImageIds = new Set(composerImages.map((image) => image.id));
1322 const fallbackPersistedIds = fallbackPersistedAttachments1316 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
1323 .map((attachment) => attachment.id)1317 const fallbackPersistedIds = fallbackPersistedAttachments
1324 .filter((id) => currentImageIds.has(id));1318 .map((attachment) => attachment.id)
1325 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);1319 .filter((id) => currentImageIds.has(id));
1326 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>1320 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
1327 fallbackPersistedIdSet.has(attachment.id),1321 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1328 );1322 fallbackPersistedIdSet.has(attachment.id),
1329 if (cancelled) return;1323 );
1330 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);1324 if (cancelled) return;
1331 }1325 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
1332 })();1326 }
1333 return () => {1327 })();
1334 cancelled = true;1328 return () => {
1335 };1329 cancelled = true;
1336 }, [1330 };
1337 composerDraftTarget,1331 }, [
1338 clearComposerDraftPersistedAttachments,1332 composerDraftTarget,
1339 composerImages,1333 clearComposerDraftPersistedAttachments,
1340 getComposerDraft,1334 composerImages,
1341 syncComposerDraftPersistedAttachments,1335 getComposerDraft,
1342 ]);1336 syncComposerDraftPersistedAttachments,
13431337 ]);
1344 // ------------------------------------------------------------------1338
1345 // Callbacks: prompt change1339 // ------------------------------------------------------------------
1346 // ------------------------------------------------------------------1340 // Callbacks: prompt change
1347 const onPromptChange = useCallback(1341 // ------------------------------------------------------------------
1348 (1342 const onPromptChange = useCallback(
1349 nextPrompt: string,1343 (
1350 nextCursor: number,1344 nextPrompt: string,
1351 expandedCursor: number,1345 nextCursor: number,
1352 cursorAdjacentToMention: boolean,1346 expandedCursor: number,
1353 terminalContextIds: string[],1347 cursorAdjacentToMention: boolean,
1354 ) => {1348 terminalContextIds: string[],
1355 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {1349 ) => {
1356 setComposerCursor(nextCursor);1350 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
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 }
1377 setComposerCursor(nextCursor);1351 setComposerCursor(nextCursor);
1378 setComposerTrigger(1352 setComposerTrigger(
1379 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),1353 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1380 );1354 );
1381 },1355 onChangeActivePendingUserInputCustomAnswer(
1382 [1356 activePendingProgress.activeQuestion.id,
1383 activePendingProgress?.activeQuestion,1357 nextPrompt,
1384 pendingUserInputs.length,1358 nextCursor,
1385 onChangeActivePendingUserInputCustomAnswer,1359 expandedCursor,
1386 promptRef,1360 cursorAdjacentToMention,
1387 setPrompt,1361 );
1388 composerDraftTarget,1362 return;
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;
1456 }1363 }
1457 return {1364 promptRef.current = nextPrompt;
1458 value: promptRef.current,1365 setPrompt(nextPrompt);
1459 cursor: composerCursor,1366 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
1460 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),1367 setComposerDraftTerminalContexts(
1461 terminalContextIds: composerTerminalContexts.map((context) => context.id),1368 composerDraftTarget,
1462 };1369 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
1463 }, [composerCursor, composerTerminalContexts, promptRef]);1370 );
14641371 }
1465 const resolveActiveComposerTrigger = useCallback((): {1372 setComposerCursor(nextCursor);
1466 snapshot: { value: string; cursor: number; expandedCursor: number };1373 setComposerTrigger(
1467 trigger: ComposerTrigger | null;1374 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1468 } => {1375 );
1469 const snapshot = readComposerSnapshot();1376 },
1470 return {1377 [
1471 snapshot,1378 activePendingProgress?.activeQuestion,
1472 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),1379 pendingUserInputs.length,
1473 };1380 onChangeActivePendingUserInputCustomAnswer,
1474 }, [readComposerSnapshot]);1381 promptRef,
1382 setPrompt,
1383 composerDraftTarget,
1384 composerTerminalContexts,
1385 setComposerDraftTerminalContexts,
1386 ],
1387 );
14751388
1476 const onSelectComposerItem = useCallback(1389 // ------------------------------------------------------------------
1477 (item: ComposerCommandItem) => {1390 // Callbacks: prompt replacement / menu
1478 if (composerSelectLockRef.current) return;1391 // ------------------------------------------------------------------
1479 composerSelectLockRef.current = true;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) {
1480 window.requestAnimationFrame(() => {1427 window.requestAnimationFrame(() => {
1481 composerSelectLockRef.current = false;1428 composerEditorRef.current?.focusAt(nextCursor);
1482 });1429 });
1483 const { snapshot, trigger } = resolveActiveComposerTrigger();1430 }
1484 if (!trigger) return;1431 return true;
1485 if (item.type === "path") {1432 },
1486 const replacement = `@${item.path} `;1433 [
1487 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(1434 activePendingProgress?.activeQuestion,
1488 snapshot.value,1435 activePendingUserInput,
1489 trigger.rangeEnd,1436 onChangeActivePendingUserInputCustomAnswer,
1490 replacement,1437 promptRef,
1491 );1438 setPrompt,
1492 const applied = applyPromptReplacement(1439 ],
1493 trigger.rangeStart,1440 );
1494 replacementRangeEnd,1441
1495 replacement,1442 const readComposerSnapshot = useCallback((): {
1496 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },1443 value: string;
1497 );1444 cursor: number;
1498 if (applied) {1445 expandedCursor: number;
1499 setComposerHighlightedItemId(null);1446 terminalContextIds: string[];
1500 }1447 } => {
1501 return;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);
1502 }1495 }
1503 if (item.type === "slash-command") {1496 return;
1504 if (item.command === "model") {1497 }
1505 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {1498 if (item.type === "slash-command") {
1506 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),1499 if (item.command === "model") {
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");
1516 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {1500 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1517 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),1501 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1502 focusEditorAfterReplace: false,
1518 });1503 });
1519 if (applied) {1504 if (applied) {
1520 setComposerHighlightedItemId(null);1505 setComposerHighlightedItemId(null);
1506 setIsComposerModelPickerOpen(true);
1521 }1507 }
1522 return;1508 return;
1523 }1509 }
1524 if (item.type === "provider-slash-command") {1510 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
1525 const replacement = `/${item.command.name} `;1511 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1526 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(1512 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1527 snapshot.value,1513 });
1528 trigger.rangeEnd,1514 if (applied) {
1529 replacement,1515 setComposerHighlightedItemId(null);
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;
1559 }1516 }
1560 },1517 return;
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);
1607 }1518 }
1608 return showPlanFollowUpPrompt || composerSendState.hasSendableContent;1519 if (item.type === "provider-slash-command") {
1609 }, [1520 const replacement = `/${item.command.name} `;
1610 activePendingProgress,1521 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1611 activePendingResolvedAnswers,1522 snapshot.value,
1612 composerSendState.hasSendableContent,1523 trigger.rangeEnd,
1613 isConnecting,1524 replacement,
1614 isMobileViewport,1525 );
1615 isSendBusy,1526 const applied = applyPromptReplacement(
1616 phase,1527 trigger.rangeStart,
1617 showPlanFollowUpPrompt,1528 replacementRangeEnd,
1618 ]);1529 replacement,
16191530 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1620 const submitComposer = useCallback(1531 );
1621 (event?: { preventDefault: () => void }) => {1532 if (applied) {
1622 onSend(event);1533 setComposerHighlightedItemId(null);
1623 if (shouldBlurMobileComposerOnSubmit()) {
1624 blurMobileComposerAfterSend();
1625 }1534 }
1626 },1535 return;
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);
1636 }1536 }
1637 if (mobileComposerExpandReleaseFrameRef.current !== null) {1537 if (item.type === "skill") {
1638 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);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;
1639 }1554 }
1640 mobileComposerExpandInFlightRef.current = true;1555 },
1641 setIsComposerFocused(true);1556 [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger],
1642 mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {1557 );
1643 mobileComposerExpandFrameRef.current = null;1558
1644 composerEditorRef.current?.focusAtEnd();1559 const onComposerMenuItemHighlighted = useCallback(
1645 mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {1560 (itemId: string | null) => {
1646 mobileComposerExpandReleaseFrameRef.current = null;1561 setComposerHighlightedItemId(itemId);
1647 mobileComposerExpandInFlightRef.current = false;1562 setComposerHighlightedSearchKey(composerMenuSearchKey);
1648 });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;
1649 });1643 });
1650 }, []);1644 });
16511645 }, []);
1652 // ------------------------------------------------------------------1646
1653 // Callbacks: command key1647 // ------------------------------------------------------------------
1654 // ------------------------------------------------------------------1648 // Callbacks: command key
1655 const onComposerCommandKey = (1649 // ------------------------------------------------------------------
1656 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",1650 const onComposerCommandKey = (
1657 event: KeyboardEvent,1651 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
1658 ) => {1652 event: KeyboardEvent,
1659 if (key === "Tab" && event.shiftKey) {1653 ) => {
1660 toggleInteractionMode();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");
1661 return true;1665 return true;
1662 }1666 }
1663 const { trigger } = resolveActiveComposerTrigger();1667 if (key === "ArrowUp" && currentItems.length > 0) {
1664 const menuIsActive = composerMenuOpenRef.current || trigger !== null;1668 nudgeComposerMenuHighlight("ArrowUp");
1665 if (menuIsActive) {1669 return true;
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 }
1680 }1670 }
1681 if (key === "Enter" && !event.shiftKey) {1671 if ((key === "Enter" || key === "Tab") && selectedItem) {
1682 submitComposer();1672 onSelectComposerItem(selectedItem);
1683 return true;1673 return true;
1684 }1674 }
1685 return false;1675 }
1686 };1676 if (key === "Enter" && !event.shiftKey) {
1677 submitComposer();
1678 return true;
1679 }
1680 return false;
1681 };
16871682
1688 // ------------------------------------------------------------------1683 // ------------------------------------------------------------------
1689 // Callbacks: images1684 // Callbacks: images
1690 // ------------------------------------------------------------------1685 // ------------------------------------------------------------------
1691 const addComposerImages = (files: File[]) => {1686 const addComposerImages = (files: File[]) => {
1692 if (!activeThreadId || files.length === 0) return;1687 if (!activeThreadId || files.length === 0) return;
1693 if (pendingUserInputs.length > 0) {1688 if (pendingUserInputs.length > 0) {
1694 toastManager.add({1689 toastManager.add({
1695 type: "error",1690 type: "error",
1696 title: "Attach images after answering plan questions.",1691 title: "Attach images after answering plan questions.",
1697 });1692 });
1698 return;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;
1699 }1702 }
1700 const nextImages: ComposerImageAttachment[] = [];1703 if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
1701 let nextImageCount = composerImagesRef.current.length;1704 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
1702 let error: string | null = null;1705 continue;
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;
1727 }1706 }
1728 if (nextImages.length === 1 && nextImages[0]) {1707 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1729 addComposerImage(nextImages[0]);1708 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
1730 } else if (nextImages.length > 1) {1709 break;
1731 addComposerImagesToDraft(nextImages);
1732 }1710 }
1733 setThreadError(activeThreadId, error);1711 const previewUrl = URL.createObjectURL(file);
1734 };1712 nextImages.push({
17351713 type: "image",
1736 const removeComposerImage = (imageId: string) => {1714 id: randomUUID(),
1737 removeComposerImageFromDraft(imageId);1715 name: file.name || "image",
1738 };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 // ------------------------------------------------------------------1731 const removeComposerImage = (imageId: string) => {
1741 // Callbacks: paste / drag1732 removeComposerImageFromDraft(imageId);
1742 // ------------------------------------------------------------------1733 };
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 };
17511734
1752 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {1735 // ------------------------------------------------------------------
1753 if (!event.dataTransfer.types.includes("Files")) return;1736 // Callbacks: paste / drag
1754 event.preventDefault();1737 // ------------------------------------------------------------------
1755 dragDepthRef.current += 1;1738 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
1756 setIsDragOverComposer(true);1739 const files = Array.from(event.clipboardData.files);
1757 };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>) => {1747 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
1760 if (!event.dataTransfer.types.includes("Files")) return;1748 if (!event.dataTransfer.types.includes("Files")) return;
1761 event.preventDefault();1749 event.preventDefault();
1762 event.dataTransfer.dropEffect = "copy";1750 dragDepthRef.current += 1;
1763 setIsDragOverComposer(true);1751 setIsDragOverComposer(true);
1764 };1752 };
17651753
1766 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {1754 const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
1767 if (!event.dataTransfer.types.includes("Files")) return;1755 if (!event.dataTransfer.types.includes("Files")) return;
1768 event.preventDefault();1756 event.preventDefault();
1769 const nextTarget = event.relatedTarget;1757 event.dataTransfer.dropEffect = "copy";
1770 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;1758 setIsDragOverComposer(true);
1771 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);1759 };
1772 if (dragDepthRef.current === 0) {
1773 setIsDragOverComposer(false);
1774 }
1775 };
17761760
1777 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {1761 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
1778 if (!event.dataTransfer.types.includes("Files")) return;1762 if (!event.dataTransfer.types.includes("Files")) return;
1779 event.preventDefault();1763 event.preventDefault();
1780 dragDepthRef.current = 0;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) {
1781 setIsDragOverComposer(false);1768 setIsDragOverComposer(false);
1782 const files = Array.from(event.dataTransfer.files);1769 }
1783 addComposerImages(files);1770 };
1784 focusComposer();1771
1785 };1772 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
1786 const handleInterruptPrimaryAction = useCallback(() => {1773 if (!event.dataTransfer.types.includes("Files")) return;
1787 void onInterrupt();1774 event.preventDefault();
1788 }, [onInterrupt]);1775 dragDepthRef.current = 0;
1789 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {1776 setIsDragOverComposer(false);
1790 void onImplementPlanInNewThread();1777 const files = Array.from(event.dataTransfer.files);
1791 }, [onImplementPlanInNewThread]);1778 addComposerImages(files);
1792 const scheduleComposerCollapseCheck = useCallback(() => {1779 focusComposer();
1793 if (!isMobileViewport) {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) {
1794 return;1800 return;
1795 }1801 }
1796 if (mobileComposerExpandInFlightRef.current) {1802 const composerSurface = composerSurfaceRef.current;
1803 const activeElement = document.activeElement;
1804 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
1797 return;1805 return;
1798 }1806 }
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 () => {
1799 if (composerBlurFrameRef.current !== null) {1820 if (composerBlurFrameRef.current !== null) {
1800 window.cancelAnimationFrame(composerBlurFrameRef.current);1821 window.cancelAnimationFrame(composerBlurFrameRef.current);
1801 }1822 }
1802 composerBlurFrameRef.current = window.requestAnimationFrame(() => {1823 if (mobileComposerExpandFrameRef.current !== null) {
1803 composerBlurFrameRef.current = null;1824 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1804 if (mobileComposerExpandInFlightRef.current) {1825 }
1805 return;1826 if (mobileComposerExpandReleaseFrameRef.current !== null) {
1806 }1827 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1807 const composerSurface = composerSurfaceRef.current;1828 }
1808 const activeElement = document.activeElement;1829 };
1809 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {1830 }, []);
1810 return;1831
1811 }1832 // ------------------------------------------------------------------
1812 if (1833 // Imperative handle
1813 composerSurface &&1834 // ------------------------------------------------------------------
1814 activeElement instanceof Node &&1835 useImperativeHandle(
1815 composerSurface.contains(activeElement)1836 composerRef,
1816 ) {1837 () => ({
1817 return;1838 focusAtEnd: () => {
1818 }1839 composerEditorRef.current?.focusAtEnd();
1819 setIsComposerFocused(false);1840 },
1820 });1841 focusAt: (cursor: number) => {
1821 }, [isMobileViewport]);1842 composerEditorRef.current?.focusAt(cursor);
18221843 },
1823 useEffect(() => {1844 openModelPicker: () => {
1824 return () => {1845 setIsComposerModelPickerOpen(true);
1825 if (composerBlurFrameRef.current !== null) {1846 },
1826 window.cancelAnimationFrame(composerBlurFrameRef.current);1847 toggleModelPicker: () => {
1827 }1848 setIsComposerModelPickerOpen((open) => !open);
1828 if (mobileComposerExpandFrameRef.current !== null) {1849 },
1829 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);1850 isModelPickerOpen: () => isComposerModelPickerOpen,
1830 }1851 readSnapshot: () => {
1831 if (mobileComposerExpandReleaseFrameRef.current !== null) {1852 return readComposerSnapshot();
1832 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);1853 },
1833 }1854 resetCursorState: (options?: {
1834 };1855 cursor?: number;
1835 }, []);1856 prompt?: string;
18361857 detectTrigger?: boolean;
1837 // ------------------------------------------------------------------1858 }) => {
1838 // Imperative handle1859 const promptForState = options?.prompt ?? promptRef.current;
1839 // ------------------------------------------------------------------1860 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
1840 useImperativeHandle(1861 setComposerHighlightedItemId(null);
1841 ref,1862 setComposerCursor(cursor);
1842 () => ({1863 setComposerTrigger(
1843 focusAtEnd: () => {1864 options?.detectTrigger
1844 composerEditorRef.current?.focusAtEnd();1865 ? detectComposerTrigger(
1845 },1866 promptForState,
1846 focusAt: (cursor: number) => {1867 expandCollapsedComposerCursor(promptForState, cursor),
1847 composerEditorRef.current?.focusAt(cursor);1868 )
1848 },1869 : null,
1849 openModelPicker: () => {1870 );
1850 setIsComposerModelPickerOpen(true);1871 },
1851 },1872 addTerminalContext: (selection: TerminalContextSelection) => {
1852 toggleModelPicker: () => {1873 if (!activeThread) return;
1853 setIsComposerModelPickerOpen((open) => !open);1874 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1854 },1875 value: promptRef.current,
1855 isModelPickerOpen: () => isComposerModelPickerOpen,1876 cursor: composerCursor,
1856 readSnapshot: () => {1877 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1857 return readComposerSnapshot();1878 terminalContextIds: composerTerminalContexts.map((context) => context.id),
1858 },1879 };
1859 resetCursorState: (options?: {1880 const insertion = insertInlineTerminalContextPlaceholder(
1860 cursor?: number;1881 snapshot.value,
1861 prompt?: string;1882 snapshot.expandedCursor,
1862 detectTrigger?: boolean;1883 );
1863 }) => {1884 const nextCollapsedCursor = collapseExpandedComposerCursor(
1864 const promptForState = options?.prompt ?? promptRef.current;1885 insertion.prompt,
1865 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);1886 insertion.cursor,
1866 setComposerHighlightedItemId(null);1887 );
1867 setComposerCursor(cursor);1888 const inserted = insertComposerDraftTerminalContext(
1868 setComposerTrigger(1889 composerDraftTarget,
1869 options?.detectTrigger1890 insertion.prompt,
1870 ? detectComposerTrigger(1891 {
1871 promptForState,1892 id: randomUUID(),
1872 expandCollapsedComposerCursor(promptForState, cursor),1893 threadId: activeThread.id,
1873 )1894 createdAt: new Date().toISOString(),
1874 : null,1895 ...selection,
1875 );1896 },
1876 },1897 insertion.contextIndex,
1877 addTerminalContext: (selection: TerminalContextSelection) => {1898 );
1878 if (!activeThread) return;1899 if (!inserted) return;
1879 const snapshot = composerEditorRef.current?.readSnapshot() ?? {1900 promptRef.current = insertion.prompt;
1880 value: promptRef.current,1901 setComposerCursor(nextCollapsedCursor);
1881 cursor: composerCursor,1902 setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
1882 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),1903 window.requestAnimationFrame(() => {
1883 terminalContextIds: composerTerminalContexts.map((context) => context.id),1904 composerEditorRef.current?.focusAt(nextCollapsedCursor);
1884 };1905 });
1885 const insertion = insertInlineTerminalContextPlaceholder(1906 },
1886 snapshot.value,1907 getSendContext: () => ({
1887 snapshot.expandedCursor,1908 prompt: promptRef.current,
1888 );1909 images: composerImagesRef.current,
1889 const nextCollapsedCursor = collapseExpandedComposerCursor(1910 terminalContexts: composerTerminalContextsRef.current,
1890 insertion.prompt,1911 selectedPromptEffort,
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,
1936 selectedModelOptionsForDispatch,1912 selectedModelOptionsForDispatch,
1937 selectedModelSelection,1913 selectedModelSelection,
1938 selectedPromptEffort,
1939 selectedProvider,1914 selectedProvider,
1915 selectedModel,
1940 selectedProviderModels,1916 selectedProviderModels,
1941 ],1917 }),
1942 );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 // Render1939 // Render
1945 // ------------------------------------------------------------------1940 // ------------------------------------------------------------------
1946 return (1941 return (
1947 <form1942 <form
1948 ref={composerFormRef}1943 ref={composerFormRef}
1949 onSubmit={submitComposer}1944 onSubmit={submitComposer}
1950 className="mx-auto w-full min-w-0 max-w-208"1945 className="mx-auto w-full min-w-0 max-w-208"
1951 data-chat-composer-form="true"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}
1952 >1957 >
1953 <div1958 <div
1959 ref={composerSurfaceRef}
1960 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
1954 className={cn(1961 className={cn(
1955 "group rounded-[22px] p-px transition-colors duration-200",1962 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1956 composerProviderState.composerFrameClassName,1963 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1964 environmentUnavailable ? "opacity-75" : null,
1965 composerProviderState.composerSurfaceClassName,
1957 )}1966 )}
1958 onDragEnter={onComposerDragEnter}1967 onFocusCapture={(event) => {
1959 onDragOver={onComposerDragOver}1968 const activeElement = event.target;
1960 onDragLeave={onComposerDragLeave}1969 if (
1961 onDrop={onComposerDrop}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 }}
1962 >1985 >
1963 <div1986 {!isComposerCollapsedMobile &&
1964 ref={composerSurfaceRef}1987 (activePendingApproval ? (
1965 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}1988 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
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 >
2024 <ComposerPendingApprovalPanel1989 <ComposerPendingApprovalPanel
2025 approval={activePendingApproval}1990 approval={activePendingApproval}
2026 pendingCount={pendingApprovals.length}1991 pendingCount={pendingApprovals.length}
2027 />1992 />
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>
2035 </div>1993 </div>
2036 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (1994 ) : pendingUserInputs.length > 0 ? (
2037 <div1995 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2038 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2039 data-chat-composer-collapsed-controls="true"
2040 >
2041 <ComposerPendingUserInputPanel1996 <ComposerPendingUserInputPanel
2042 pendingUserInputs={pendingUserInputs}1997 pendingUserInputs={pendingUserInputs}
2043 respondingRequestIds={respondingRequestIds}1998 respondingRequestIds={respondingRequestIds}
...@@ -2046,240 +2001,70 @@ export const ChatComposer = memo(...@@ -2046,240 +2001,70 @@ export const ChatComposer = memo(
2046 onToggleOption={onSelectActivePendingUserInputOption}2001 onToggleOption={onSelectActivePendingUserInputOption}
2047 onAdvance={onAdvanceActivePendingUserInput}2002 onAdvance={onAdvanceActivePendingUserInput}
2048 />2003 />
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>
2092 </div>2004 </div>
2093 ) : null}2005 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
20942006 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2095 {showCollapsedMobilePromptRow ? (2007 <ComposerPlanFollowUpBanner
2096 <div className="flex items-center justify-between gap-2 px-3 py-2">2008 key={activeProposedPlan.id}
2097 <button2009 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2098 type="button"2010 />
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>
2135 </div>2011 </div>
2136 ) : null}2012 ) : null)}
21372013
2014 {isComposerCollapsedMobile && activePendingApproval ? (
2138 <div2015 <div
2139 className={cn(2016 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2140 "relative px-3 pb-2 sm:px-4",2017 data-chat-composer-collapsed-controls="true"
2141 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2142 isComposerCollapsedMobile && "hidden",
2143 )}
2144 >2018 >
2145 {composerMenuOpen && !isComposerApprovalState && (2019 <ComposerPendingApprovalPanel
2146 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">2020 approval={activePendingApproval}
2147 <ComposerCommandMenu2021 pendingCount={pendingApprovals.length}
2148 items={composerMenuItems}2022 />
2149 resolvedTheme={resolvedTheme}2023 <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4">
2150 isLoading={isComposerMenuLoading}2024 <ComposerPendingApprovalActions
2151 triggerKind={composerTriggerKind}2025 requestId={activePendingApproval.requestId}
2152 groupSlashCommandSections={2026 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2153 composerTrigger?.kind === "slash-command" &&2027 onRespondToApproval={onRespondToApproval}
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 }
2277 />2028 />
2278 {showMobilePendingAnswerActions ? (2029 </div>
2279 <div2030 </div>
2280 data-chat-composer-mobile-pending-actions="true"2031 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2281 className="absolute bottom-0 right-0 flex justify-end"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"
2282 >2064 >
2065 {activePendingProgress?.customAnswer || "Write custom answer"}
2066 </button>
2067 {activePendingProgress?.activeQuestion?.multiSelect ? (
2283 <ComposerPrimaryActions2068 <ComposerPrimaryActions
2284 compact2069 compact
2285 pendingAction={pendingPrimaryAction}2070 pendingAction={pendingPrimaryAction}
...@@ -2296,128 +2081,328 @@ export const ChatComposer = memo(...@@ -2296,128 +2081,328 @@ export const ChatComposer = memo(
2296 onInterrupt={handleInterruptPrimaryAction}2081 onInterrupt={handleInterruptPrimaryAction}
2297 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}2082 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2298 />2083 />
2299 </div>2084 ) : null}
2300 ) : null}2085 </div>
2301 </div>2086 </div>
2302 </div>2087 </div>
2088 ) : null}
23032089
2304 {/* Bottom toolbar */}2090 {showCollapsedMobilePromptRow ? (
2305 {isComposerCollapsedMobile ? null : activePendingApproval ? (2091 <div className="flex items-center justify-between gap-2 px-3 py-2">
2306 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">2092 <button
2307 <ComposerPendingApprovalActions2093 type="button"
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"}
2317 className={cn(2094 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",2095 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2319 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",2096 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2320 showMobilePendingAnswerActions && "hidden sm:flex",2097 ? "text-foreground"
2098 : "text-muted-foreground/35",
2321 )}2099 )}
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 }}
2322 >2119 >
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">2120 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2324 <ProviderModelPicker2121 <path
2325 compact={isComposerFooterCompact}2122 d="M8 3L8 13M8 3L4 7M8 3L12 7"
2326 activeInstanceId={selectedInstanceId}2123 stroke="currentColor"
2327 model={selectedModelForPickerWithCustomFallback}2124 strokeWidth="2"
2328 lockedProvider={lockedProvider}2125 strokeLinecap="round"
2329 lockedContinuationGroupKey={lockedContinuationGroupKey}2126 strokeLinejoin="round"
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}
2345 />2127 />
2128 </svg>
2129 </button>
2130 </div>
2131 ) : null}
23462132
2347 {isComposerFooterCompact ? (2133 <div
2348 <CompactComposerControlsMenu2134 className={cn(
2349 activePlan={showPlanSidebarToggle}2135 "relative px-3 pb-2 sm:px-4",
2350 interactionMode={interactionMode}2136 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2351 planSidebarLabel={planSidebarLabel}2137 isComposerCollapsedMobile && "hidden",
2352 planSidebarOpen={planSidebarOpen}2138 )}
2353 runtimeMode={runtimeMode}2139 >
2354 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}2140 {composerMenuOpen && !isComposerApprovalState && (
2355 traitsMenuContent={providerTraitsMenuContent}2141 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2356 onToggleInteractionMode={toggleInteractionMode}2142 <ComposerCommandMenu
2357 onTogglePlanSidebar={togglePlanSidebar}2143 items={composerMenuItems}
2358 onRuntimeModeChange={handleRuntimeModeChange}2144 resolvedTheme={resolvedTheme}
2359 />2145 isLoading={isComposerMenuLoading}
2360 ) : (2146 triggerKind={composerTriggerKind}
2361 <>2147 groupSlashCommandSections={
2362 {providerTraitsPicker ? (2148 composerTrigger?.kind === "slash-command" &&
2363 <>2149 composerTrigger.query.trim().length === 0
2364 <Separator2150 }
2365 orientation="vertical"2151 emptyStateText={composerMenuEmptyState}
2366 className="mx-0.5 hidden h-4 sm:block"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"
2367 />2184 />
2368 {providerTraitsPicker}2185 </button>
2369 </>2186 ) : (
2370 ) : null}2187 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
2371 <ComposerFooterModeControls2188 {image.name}
2372 showInteractionModeToggle={2189 </div>
2373 composerProviderControls.showInteractionModeToggle2190 )}
2374 }2191 {nonPersistedComposerImageIdSet.has(image.id) && (
2375 interactionMode={interactionMode}2192 <Tooltip>
2376 runtimeMode={runtimeMode}2193 <TooltipTrigger
2377 showPlanToggle={showPlanSidebarToggle}2194 render={
2378 planSidebarLabel={planSidebarLabel}2195 <span
2379 planSidebarOpen={planSidebarOpen}2196 role="img"
2380 onToggleInteractionMode={toggleInteractionMode}2197 aria-label="Draft attachment may not persist"
2381 onRuntimeModeChange={handleRuntimeModeChange}2198 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
2382 onTogglePlanSidebar={togglePlanSidebar}2199 >
2383 />2200 <CircleAlertIcon className="size-3" />
2384 </>2201 </span>
2385 )}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 ))}
2386 </div>2224 </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 ? (
2389 <div2273 <div
2390 data-chat-composer-actions="right"2274 data-chat-composer-mobile-pending-actions="true"
2391 data-chat-composer-primary-actions-compact={2275 className="absolute bottom-0 right-0 flex justify-end"
2392 isComposerPrimaryActionsCompact ? "true" : "false"
2393 }
2394 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
2395 >2276 >
2396 <ComposerFooterPrimaryActions2277 <ComposerPrimaryActions
2397 compact={isComposerPrimaryActionsCompact}2278 compact
2398 activeContextWindow={activeContextWindow}
2399 pendingAction={pendingPrimaryAction}2279 pendingAction={pendingPrimaryAction}
2400 isRunning={phase === "running"}2280 isRunning={false}
2401 showPlanFollowUpPrompt={2281 showPlanFollowUpPrompt={false}
2402 pendingUserInputs.length === 0 && showPlanFollowUpPrompt2282 promptHasText={false}
2403 }
2404 promptHasText={prompt.trim().length > 0}
2405 isSendBusy={isSendBusy}2283 isSendBusy={isSendBusy}
2406 isConnecting={isConnecting}2284 isConnecting={isConnecting}
2407 isEnvironmentUnavailable={environmentUnavailable !== null}2285 isEnvironmentUnavailable={environmentUnavailable !== null}
2408 isPreparingWorktree={isPreparingWorktree}2286 isPreparingWorktree={false}
2409 hasSendableContent={composerSendState.hasSendableContent}2287 hasSendableContent={false}
2410 preserveComposerFocusOnPointerDown={isMobileViewport}2288 preserveComposerFocusOnPointerDown
2411 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}2289 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2412 onInterrupt={handleInterruptPrimaryAction}2290 onInterrupt={handleInterruptPrimaryAction}
2413 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}2291 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2414 />2292 />
2415 </div>2293 </div>
2416 </div>2294 ) : null}
2417 )}2295 </div>
2418 </div>2296 </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 )}
2419 </div>2404 </div>
2420 </form>2405 </div>
2421 );2406 </form>
2422 }),2407 );
2423);2408});
apps/web/src/components/chat/MessagesTimeline.browser.tsx+10-12
...@@ -13,18 +13,16 @@ const getStateSpy = vi.fn(() => ({ isAtEnd: true }));...@@ -13,18 +13,16 @@ const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
13vi.mock("@legendapp/list/react", async () => {13vi.mock("@legendapp/list/react", async () => {
14 const React = await import("react");14 const React = await import("react");
1515
16 const LegendList = React.forwardRef(function MockLegendList(16 function LegendList(props: {
17 props: {17 data: Array<{ id: string }>;
18 data: Array<{ id: string }>;18 keyExtractor: (item: { id: string }) => string;
19 keyExtractor: (item: { id: string }) => string;19 renderItem: (args: { item: { id: string } }) => React.ReactNode;
20 renderItem: (args: { item: { id: string } }) => React.ReactNode;20 ListHeaderComponent?: React.ReactNode;
21 ListHeaderComponent?: React.ReactNode;21 ListFooterComponent?: React.ReactNode;
22 ListFooterComponent?: React.ReactNode;22 ref?: React.Ref<LegendListRef>;
23 },23 }) {
24 ref: React.ForwardedRef<LegendListRef>,
25 ) {
26 React.useImperativeHandle(24 React.useImperativeHandle(
27 ref,25 props.ref,
28 () =>26 () =>
29 ({27 ({
30 scrollToEnd: scrollToEndSpy,28 scrollToEnd: scrollToEndSpy,
...@@ -41,7 +39,7 @@ vi.mock("@legendapp/list/react", async () => {...@@ -41,7 +39,7 @@ vi.mock("@legendapp/list/react", async () => {
41 {props.ListFooterComponent}39 {props.ListFooterComponent}
42 </div>40 </div>
43 );41 );
44 });42 }
4543
46 return { LegendList };44 return { LegendList };
47});45});
apps/web/src/components/chat/MessagesTimeline.test.tsx+19-23
...@@ -1,32 +1,28 @@...@@ -1,32 +1,28 @@
1import { EnvironmentId, MessageId } from "@t3tools/contracts";1import { EnvironmentId, MessageId } from "@t3tools/contracts";
2import { createRef } from "react";2import { createRef, type ReactNode, type Ref } from "react";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import { beforeAll, describe, expect, it, vi } from "vitest";4import { beforeAll, describe, expect, it, vi } from "vitest";
5import type { LegendListRef } from "@legendapp/list/react";5import type { LegendListRef } from "@legendapp/list/react";
66
7vi.mock("@legendapp/list/react", async () => {7vi.mock("@legendapp/list/react", async () => {
8 const React = await import("react");8 const legendListTestId = "legend-list";
99
10 const LegendList = React.forwardRef(function MockLegendList(10 const LegendList = (props: {
11 props: {11 data: Array<{ id: string }>;
12 data: Array<{ id: string }>;12 keyExtractor: (item: { id: string }) => string;
13 keyExtractor: (item: { id: string }) => string;13 renderItem: (args: { item: { id: string } }) => ReactNode;
14 renderItem: (args: { item: { id: string } }) => React.ReactNode;14 ListHeaderComponent?: ReactNode;
15 ListHeaderComponent?: React.ReactNode;15 ListFooterComponent?: ReactNode;
16 ListFooterComponent?: React.ReactNode;16 ref?: Ref<LegendListRef>;
17 },17 }) => (
18 _ref: React.ForwardedRef<LegendListRef>,18 <div data-testid={legendListTestId}>
19 ) {19 {props.ListHeaderComponent}
20 return (20 {props.data.map((item) => (
21 <div data-testid="legend-list">21 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
22 {props.ListHeaderComponent}22 ))}
23 {props.data.map((item) => (23 {props.ListFooterComponent}
24 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>24 </div>
25 ))}25 );
26 {props.ListFooterComponent}
27 </div>
28 );
29 });
3026
31 return { LegendList };27 return { LegendList };
32});28});
apps/web/src/components/chat/MessagesTimeline.tsx+5-5
...@@ -69,7 +69,7 @@ import { SkillInlineText } from "./SkillInlineText";...@@ -69,7 +69,7 @@ import { SkillInlineText } from "./SkillInlineText";
69import { formatWorkspaceRelativePath } from "../../filePathDisplay";69import { formatWorkspaceRelativePath } from "../../filePathDisplay";
7070
71// ---------------------------------------------------------------------------71// ---------------------------------------------------------------------------
72// Context — shared state consumed by every row component via useContext.72// Context — shared state consumed by every row component via Context.
73// Propagates through LegendList's memo boundaries for shared callbacks and73// Propagates through LegendList's memo boundaries for shared callbacks and
74// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking74// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
75// components (WorkingTimer, LiveElapsed) handle it.75// components (WorkingTimer, LiveElapsed) handle it.
...@@ -264,8 +264,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -264,8 +264,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
264 }264 }
265265
266 return (266 return (
267 <TimelineRowCtx.Provider value={sharedState}>267 <TimelineRowCtx value={sharedState}>
268 <TimelineRowActivityCtx.Provider value={activityState}>268 <TimelineRowActivityCtx value={activityState}>
269 <LegendList<MessagesTimelineRow>269 <LegendList<MessagesTimelineRow>
270 ref={listRef}270 ref={listRef}
271 data={rows}271 data={rows}
...@@ -281,8 +281,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -281,8 +281,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
281 ListHeaderComponent={TIMELINE_LIST_HEADER}281 ListHeaderComponent={TIMELINE_LIST_HEADER}
282 ListFooterComponent={TIMELINE_LIST_FOOTER}282 ListFooterComponent={TIMELINE_LIST_FOOTER}
283 />283 />
284 </TimelineRowActivityCtx.Provider>284 </TimelineRowActivityCtx>
285 </TimelineRowCtx.Provider>285 </TimelineRowCtx>
286 );286 );
287});287});
288288
apps/web/src/components/ui/combobox.tsx+4-4
...@@ -22,9 +22,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(...@@ -22,9 +22,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(
22 const chipsRef = React.useRef<Element | null>(null);22 const chipsRef = React.useRef<Element | null>(null);
23 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);23 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);
24 return (24 return (
25 <ComboboxContext.Provider value={value}>25 <ComboboxContext value={value}>
26 <ComboboxPrimitive.Root {...props} />26 <ComboboxPrimitive.Root {...props} />
27 </ComboboxContext.Provider>27 </ComboboxContext>
28 );28 );
29}29}
3030
...@@ -151,7 +151,7 @@ function ComboboxPopup({...@@ -151,7 +151,7 @@ function ComboboxPopup({
151 side?: ComboboxPrimitive.Positioner.Props["side"];151 side?: ComboboxPrimitive.Positioner.Props["side"];
152 anchor?: ComboboxPrimitive.Positioner.Props["anchor"];152 anchor?: ComboboxPrimitive.Positioner.Props["anchor"];
153}) {153}) {
154 const { chipsRef } = React.useContext(ComboboxContext);154 const { chipsRef } = React.use(ComboboxContext);
155 const anchor = anchorProp ?? chipsRef;155 const anchor = anchorProp ?? chipsRef;
156156
157 return (157 return (
...@@ -324,7 +324,7 @@ function ComboboxChips({...@@ -324,7 +324,7 @@ function ComboboxChips({
324}: ComboboxPrimitive.Chips.Props & {324}: ComboboxPrimitive.Chips.Props & {
325 startAddon?: React.ReactNode;325 startAddon?: React.ReactNode;
326}) {326}) {
327 const { chipsRef } = React.useContext(ComboboxContext);327 const { chipsRef } = React.use(ComboboxContext);
328328
329 return (329 return (
330 <ComboboxPrimitive.Chips330 <ComboboxPrimitive.Chips
apps/web/src/components/ui/number-field.tsx+3-3
...@@ -25,7 +25,7 @@ export function NumberField({...@@ -25,7 +25,7 @@ export function NumberField({
25 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);25 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
2626
27 return (27 return (
28 <NumberFieldContext.Provider value={contextValue}>28 <NumberFieldContext value={contextValue}>
29 <NumberFieldPrimitive.Root29 <NumberFieldPrimitive.Root
30 className={cn("flex w-full flex-col items-start gap-2", className)}30 className={cn("flex w-full flex-col items-start gap-2", className)}
31 data-size={size}31 data-size={size}
...@@ -33,7 +33,7 @@ export function NumberField({...@@ -33,7 +33,7 @@ export function NumberField({
33 id={fieldId}33 id={fieldId}
34 {...props}34 {...props}
35 />35 />
36 </NumberFieldContext.Provider>36 </NumberFieldContext>
37 );37 );
38}38}
3939
...@@ -112,7 +112,7 @@ export function NumberFieldScrubArea({...@@ -112,7 +112,7 @@ export function NumberFieldScrubArea({
112}: NumberFieldPrimitive.ScrubArea.Props & {112}: NumberFieldPrimitive.ScrubArea.Props & {
113 label: string;113 label: string;
114}): React.ReactElement {114}): React.ReactElement {
115 const context = React.useContext(NumberFieldContext);115 const context = React.use(NumberFieldContext);
116116
117 if (!context) {117 if (!context) {
118 throw new Error(118 throw new Error(
apps/web/src/components/ui/sidebar.tsx+11-11
...@@ -77,7 +77,7 @@ const SidebarContext = React.createContext<SidebarContextProps | null>(null);...@@ -77,7 +77,7 @@ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
77const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);77const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
7878
79function useSidebar() {79function useSidebar() {
80 const context = React.useContext(SidebarContext);80 const context = React.use(SidebarContext);
81 if (!context) {81 if (!context) {
82 throw new Error("useSidebar must be used within a SidebarProvider.");82 throw new Error("useSidebar must be used within a SidebarProvider.");
83 }83 }
...@@ -148,7 +148,7 @@ function SidebarProvider({...@@ -148,7 +148,7 @@ function SidebarProvider({
148 );148 );
149149
150 return (150 return (
151 <SidebarContext.Provider value={contextValue}>151 <SidebarContext value={contextValue}>
152 <div152 <div
153 className={cn(153 className={cn(
154 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",154 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
...@@ -166,7 +166,7 @@ function SidebarProvider({...@@ -166,7 +166,7 @@ function SidebarProvider({
166 >166 >
167 {children}167 {children}
168 </div>168 </div>
169 </SidebarContext.Provider>169 </SidebarContext>
170 );170 );
171}171}
172172
...@@ -206,7 +206,7 @@ function Sidebar({...@@ -206,7 +206,7 @@ function Sidebar({
206206
207 if (collapsible === "none") {207 if (collapsible === "none") {
208 return (208 return (
209 <SidebarInstanceContext.Provider value={instanceContextValue}>209 <SidebarInstanceContext value={instanceContextValue}>
210 <div210 <div
211 className={cn(211 className={cn(
212 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",212 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
...@@ -217,13 +217,13 @@ function Sidebar({...@@ -217,13 +217,13 @@ function Sidebar({
217 >217 >
218 {children}218 {children}
219 </div>219 </div>
220 </SidebarInstanceContext.Provider>220 </SidebarInstanceContext>
221 );221 );
222 }222 }
223223
224 if (isMobile) {224 if (isMobile) {
225 return (225 return (
226 <SidebarInstanceContext.Provider value={instanceContextValue}>226 <SidebarInstanceContext value={instanceContextValue}>
227 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>227 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
228 <SheetPopup228 <SheetPopup
229 className={cn(229 className={cn(
...@@ -255,12 +255,12 @@ function Sidebar({...@@ -255,12 +255,12 @@ function Sidebar({
255 </div>255 </div>
256 </SheetPopup>256 </SheetPopup>
257 </Sheet>257 </Sheet>
258 </SidebarInstanceContext.Provider>258 </SidebarInstanceContext>
259 );259 );
260 }260 }
261261
262 return (262 return (
263 <SidebarInstanceContext.Provider value={instanceContextValue}>263 <SidebarInstanceContext value={instanceContextValue}>
264 <div264 <div
265 className="group peer hidden text-sidebar-foreground md:block"265 className="group peer hidden text-sidebar-foreground md:block"
266 data-collapsible={state === "collapsed" ? collapsible : ""}266 data-collapsible={state === "collapsed" ? collapsible : ""}
...@@ -305,7 +305,7 @@ function Sidebar({...@@ -305,7 +305,7 @@ function Sidebar({
305 </div>305 </div>
306 </div>306 </div>
307 </div>307 </div>
308 </SidebarInstanceContext.Provider>308 </SidebarInstanceContext>
309 );309 );
310}310}
311311
...@@ -345,7 +345,7 @@ function SidebarRail({...@@ -345,7 +345,7 @@ function SidebarRail({
345 ...props345 ...props
346}: React.ComponentProps<"button">) {346}: React.ComponentProps<"button">) {
347 const { open, toggleSidebar } = useSidebar();347 const { open, toggleSidebar } = useSidebar();
348 const sidebarInstance = React.useContext(SidebarInstanceContext);348 const sidebarInstance = React.use(SidebarInstanceContext);
349 const railRef = React.useRef<HTMLButtonElement | null>(null);349 const railRef = React.useRef<HTMLButtonElement | null>(null);
350 const suppressClickRef = React.useRef(false);350 const suppressClickRef = React.useRef(false);
351 const resizeStateRef = React.useRef<{351 const resizeStateRef = React.useRef<{
...@@ -889,7 +889,7 @@ function SidebarMenuSkeleton({...@@ -889,7 +889,7 @@ function SidebarMenuSkeleton({
889}: React.ComponentProps<"div"> & {889}: React.ComponentProps<"div"> & {
890 showIcon?: boolean;890 showIcon?: boolean;
891}) {891}) {
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.
893 const width = React.useMemo(() => {893 const width = React.useMemo(() => {
894 return `${Math.floor(Math.random() * 40) + 50}%`;894 return `${Math.floor(Math.random() * 40) + 50}%`;
895 }, []);895 }, []);
apps/web/src/components/ui/toggle-group.tsx+2-2
...@@ -43,7 +43,7 @@ function ToggleGroup({...@@ -43,7 +43,7 @@ function ToggleGroup({
43 orientation={orientation}43 orientation={orientation}
44 {...props}44 {...props}
45 >45 >
46 <ToggleGroupContext.Provider value={value}>{children}</ToggleGroupContext.Provider>46 <ToggleGroupContext value={value}>{children}</ToggleGroupContext>
47 </ToggleGroupPrimitive>47 </ToggleGroupPrimitive>
48 );48 );
49}49}
...@@ -55,7 +55,7 @@ function Toggle({...@@ -55,7 +55,7 @@ function Toggle({
55 size,55 size,
56 ...props56 ...props
57}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {57}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
58 const context = React.useContext(ToggleGroupContext);58 const context = React.use(ToggleGroupContext);
5959
60 const resolvedVariant = variant ?? context.variant;60 const resolvedVariant = variant ?? context.variant;
61 const resolvedSize = size ?? context.size;61 const resolvedSize = size ?? context.size;
apps/web/src/composerHandleContext.ts+3-3
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1import { createContext, useContext, type MutableRefObject } from "react";1import { createContext, use } from "react";
2import type { ChatComposerHandle } from "./components/chat/ChatComposer";2import type { ChatComposerHandle } from "./components/chat/ChatComposer";
33
4export type ComposerHandleRef = MutableRefObject<ChatComposerHandle | null>;4export type ComposerHandleRef = React.RefObject<ChatComposerHandle | null>;
55
6export const ComposerHandleContext = createContext<ComposerHandleRef | null>(null);6export const ComposerHandleContext = createContext<ComposerHandleRef | null>(null);
77
8export function useComposerHandleContext(): ComposerHandleRef | null {8export function useComposerHandleContext(): ComposerHandleRef | null {
9 return useContext(ComposerHandleContext);9 return use(ComposerHandleContext);
10}10}
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 @@...@@ -108,8 +108,8 @@
108 "effect": "catalog:",108 "effect": "catalog:",
109 "lexical": "^0.41.0",109 "lexical": "^0.41.0",
110 "lucide-react": "^0.564.0",110 "lucide-react": "^0.564.0",
111 "react": "^19.0.0",111 "react": "19.2.6",
112 "react-dom": "^19.0.0",112 "react-dom": "19.2.6",
113 "react-markdown": "^10.1.0",113 "react-markdown": "^10.1.0",
114 "remark-gfm": "^4.0.1",114 "remark-gfm": "^4.0.1",
115 "tailwind-merge": "^3.4.0",115 "tailwind-merge": "^3.4.0",
...@@ -121,12 +121,12 @@...@@ -121,12 +121,12 @@
121 "@tailwindcss/vite": "^4.0.0",121 "@tailwindcss/vite": "^4.0.0",
122 "@tanstack/router-plugin": "^1.161.0",122 "@tanstack/router-plugin": "^1.161.0",
123 "@types/babel__core": "^7.20.5",123 "@types/babel__core": "^7.20.5",
124 "@types/react": "^19.0.0",124 "@types/react": "~19.2.14",
125 "@types/react-dom": "^19.0.0",125 "@types/react-dom": "~19.2.3",
126 "@vercel/config": "^0.3.0",126 "@vercel/config": "^0.3.0",
127 "@vitejs/plugin-react": "^6.0.0",127 "@vitejs/plugin-react": "^6.0.0",
128 "@vitest/browser-playwright": "^4.0.18",128 "@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",
130 "msw": "2.12.11",130 "msw": "2.12.11",
131 "playwright": "^1.58.2",131 "playwright": "^1.58.2",
132 "tailwindcss": "^4.0.0",132 "tailwindcss": "^4.0.0",
...@@ -1011,7 +1011,7 @@...@@ -1011,7 +1011,7 @@
10111011
1012 "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=="],1012 "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
1016 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],1016 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
10171017
...@@ -1737,9 +1737,9 @@...@@ -1737,9 +1737,9 @@
17371737
1738 "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=="],1738 "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
1744 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],1744 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],
17451745