authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 16:37:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 16:37:47-07:00
log869789b4edd7ff71ed717feb1dc1935aef36e5ad
tree98fede81cc1f0836551ee7fb120c4e38f0127715
parent9bedd7140ce22f7f05d3990cdb50cafcceae37de
signature Signed by PGP key B5690EEEBB952194

Extract ChatComposer to fix composer keystroke re-renders (#1857)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

9 files changed, 2359 insertions(+), 1730 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-3
......@@ -1,6 +1,6 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { useMemo } from "react";
3import { memo, useMemo } from "react";
44
55import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
66import { useStore } from "../store";
......@@ -27,7 +27,7 @@ interface BranchToolbarProps {
2727 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
2828}
2929
30export function BranchToolbar({
30export const BranchToolbar = memo(function BranchToolbar({
3131 environmentId,
3232 threadId,
3333 draftId,
......@@ -101,4 +101,4 @@ export function BranchToolbar({
101101 />
102102 </div>
103103 );
104}
104});
apps/web/src/components/ChatView.logic.ts+12
......@@ -2,6 +2,7 @@ import {
22 type EnvironmentId,
33 ProjectId,
44 type ModelSelection,
5 type ProviderKind,
56 type ScopedThreadRef,
67 type ThreadId,
78 type TurnId,
......@@ -225,6 +226,17 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
225226 );
226227}
227228
229export function deriveLockedProvider(input: {
230 thread: Thread | null | undefined;
231 selectedProvider: ProviderKind | null;
232 threadProvider: ProviderKind | null;
233}): ProviderKind | null {
234 if (!threadHasStarted(input.thread)) {
235 return null;
236 }
237 return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null;
238}
239
228240export async function waitForStartedServerThread(
229241 threadRef: ScopedThreadRef,
230242 timeoutMs = 1_000,
apps/web/src/components/ChatView.tsx+200-1675
......@@ -7,11 +7,8 @@ import {
77 type ModelSelection,
88 type ProjectScript,
99 type ProviderKind,
10 type ProjectEntry,
1110 type ProjectId,
1211 type ProviderApprovalDecision,
13 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
14 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1512 type ServerProvider,
1613 type ScopedThreadRef,
1714 type ThreadId,
......@@ -28,29 +25,21 @@ import {
2825 scopeProjectRef,
2926 scopeThreadRef,
3027} from "@t3tools/client-runtime";
31import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
28import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
3229import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3330import { truncate } from "@t3tools/shared/String";
3431import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
35import { useQuery } from "@tanstack/react-query";
36import { useDebouncedValue } from "@tanstack/react-pacer";
3732import { useNavigate, useSearch } from "@tanstack/react-router";
3833import { useShallow } from "zustand/react/shallow";
3934import { useGitStatus } from "~/lib/gitStatusState";
40import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
4135import { usePrimaryEnvironmentId } from "../environments/primary";
4236import { readEnvironmentApi } from "../environmentApi";
4337import { isElectron } from "../env";
4438import { readLocalApi } from "../localApi";
4539import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
4640import {
47 clampCollapsedComposerCursor,
48 type ComposerTrigger,
4941 collapseExpandedComposerCursor,
50 detectComposerTrigger,
51 expandCollapsedComposerCursor,
5242 parseStandaloneComposerSlashCommand,
53 replaceTextRange,
5443} from "../composer-logic";
5544import {
5645 deriveCompletionDividerBeforeEntryId,
......@@ -86,7 +75,6 @@ import { useUiStateStore } from "../uiStateStore";
8675import {
8776 buildPlanImplementationThreadTitle,
8877 buildPlanImplementationPrompt,
89 proposedPlanTitle,
9078 resolvePlanFollowUpSubmission,
9179} from "../proposedPlan";
9280import {
......@@ -99,31 +87,14 @@ import {
9987 type Thread,
10088 type TurnDiffSummary,
10189} from "../types";
102import { basenameOfPath } from "../vscode-icons";
10390import { useTheme } from "../hooks/useTheme";
10491import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
10592import { BranchToolbar } from "./BranchToolbar";
10693import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
10794import PlanSidebar from "./PlanSidebar";
10895import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
109import {
110 BotIcon,
111 ChevronDownIcon,
112 ChevronLeftIcon,
113 ChevronRightIcon,
114 CircleAlertIcon,
115 ListTodoIcon,
116 LockIcon,
117 LockOpenIcon,
118 type LucideIcon,
119 PenLineIcon,
120 XIcon,
121} from "lucide-react";
122import { Button } from "./ui/button";
123import { Separator } from "./ui/separator";
124import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
96import { ChevronDownIcon } from "lucide-react";
12597import { cn, randomUUID } from "~/lib/utils";
126import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
12798import { toastManager } from "./ui/toast";
12899import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
129100import { type NewProjectScriptInput } from "./ProjectScriptsControl";
......@@ -133,11 +104,7 @@ import {
133104 projectScriptIdFromCommand,
134105} from "~/projectScripts";
135106import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
136import {
137 getProviderModelCapabilities,
138 getProviderModels,
139 resolveSelectableProvider,
140} from "../providerModels";
107import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
141108import { useSettings } from "../hooks/useSettings";
142109import { resolveAppModelSelection } from "../modelSelection";
143110import { isTerminalFocused } from "../lib/terminalFocus";
......@@ -150,49 +117,24 @@ import { buildDraftThreadRouteParams } from "../threadRoutes";
150117import {
151118 type ComposerImageAttachment,
152119 type DraftThreadEnvMode,
153 type PersistedComposerImageAttachment,
154120 useComposerDraftStore,
155 useEffectiveComposerModelState,
156 useComposerThreadDraft,
157121 type DraftId,
158122} from "../composerDraftStore";
159123import {
160124 appendTerminalContextsToPrompt,
161125 formatTerminalContextLabel,
162 insertInlineTerminalContextPlaceholder,
163 removeInlineTerminalContextPlaceholder,
164126 type TerminalContextDraft,
165127 type TerminalContextSelection,
166128} from "../lib/terminalContext";
167import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
168import {
169 resolveComposerFooterContentWidth,
170 shouldForceCompactComposerFooterForFit,
171 shouldUseCompactComposerPrimaryActions,
172 shouldUseCompactComposerFooter,
173} from "./composerFooterLayout";
174129import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
175import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
130import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
131import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
176132import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
177133import { MessagesTimeline } from "./chat/MessagesTimeline";
178134import { ChatHeader } from "./chat/ChatHeader";
179import { ContextWindowMeter } from "./chat/ContextWindowMeter";
180import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
135import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
181136import { NoActiveThreadState } from "./NoActiveThreadState";
182import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
183import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
184import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
185import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
186import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions";
187import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
188import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
189import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
190137import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
191import {
192 getComposerProviderState,
193 renderProviderTraitsMenuContent,
194 renderProviderTraitsPicker,
195} from "./chat/composerProviderRegistry";
196138import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
197139import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
198140import {
......@@ -200,7 +142,6 @@ import {
200142 buildExpiredTerminalContextToastCopy,
201143 buildLocalDraftThread,
202144 buildTemporaryWorktreeBranchName,
203 cloneComposerImageForRetry,
204145 collectUserMessageBlobPreviewUrls,
205146 createLocalDispatchSnapshot,
206147 deriveComposerSendState,
......@@ -209,12 +150,13 @@ import {
209150 LastInvokedScriptByProjectSchema,
210151 type LocalDispatchSnapshot,
211152 PullRequestDialogState,
153 cloneComposerImageForRetry,
154 deriveLockedProvider,
212155 readFileAsDataUrl,
213156 reconcileMountedTerminalThreadIds,
214157 revokeBlobPreviewUrl,
215158 revokeUserMessagePreviewUrls,
216159 shouldWriteThreadErrorToCurrentServerThread,
217 threadHasStarted,
218160 waitForStartedServerThread,
219161} from "./ChatView.logic";
220162import { useLocalStorage } from "~/hooks/useLocalStorage";
......@@ -225,12 +167,10 @@ import {
225167} from "~/rpc/serverState";
226168import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
227169
228const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
229170const IMAGE_ONLY_BOOTSTRAP_PROMPT =
230171 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
231172const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
232173const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
233const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
234174const EMPTY_PROVIDERS: ServerProvider[] = [];
235175const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
236176
......@@ -362,38 +302,9 @@ function formatOutgoingPrompt(params: {
362302 }
363303 return params.text;
364304}
365const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
366305const SCRIPT_TERMINAL_COLS = 120;
367306const SCRIPT_TERMINAL_ROWS = 30;
368307
369const extendReplacementRangeForTrailingSpace = (
370 text: string,
371 rangeEnd: number,
372 replacement: string,
373): number => {
374 if (!replacement.endsWith(" ")) {
375 return rangeEnd;
376 }
377 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
378};
379
380const syncTerminalContextsByIds = (
381 contexts: ReadonlyArray<TerminalContextDraft>,
382 ids: ReadonlyArray<string>,
383): TerminalContextDraft[] => {
384 const contextsById = new Map(contexts.map((context) => [context.id, context]));
385 return ids.flatMap((id) => {
386 const context = contextsById.get(id);
387 return context ? [context] : [];
388 });
389};
390
391const terminalContextIdListsEqual = (
392 contexts: ReadonlyArray<TerminalContextDraft>,
393 ids: ReadonlyArray<string>,
394): boolean =>
395 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
396
397308type ChatViewProps =
398309 | {
399310 environmentId: EnvironmentId;
......@@ -414,29 +325,6 @@ interface TerminalLaunchContext {
414325 worktreePath: string | null;
415326}
416327
417const runtimeModeConfig: Record<
418 RuntimeMode,
419 { label: string; description: string; icon: LucideIcon }
420> = {
421 "approval-required": {
422 label: "Supervised",
423 description: "Ask before commands and file changes.",
424 icon: LockIcon,
425 },
426 "auto-accept-edits": {
427 label: "Auto-accept edits",
428 description: "Auto-approve edits, ask before other actions.",
429 icon: PenLineIcon,
430 },
431 "full-access": {
432 label: "Full access",
433 description: "Allow commands and edits without prompts.",
434 icon: LockOpenIcon,
435 },
436};
437
438const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
439
440328type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
441329
442330function useLocalDispatchState(input: {
......@@ -712,53 +600,31 @@ export default function ChatView(props: ChatViewProps) {
712600 select: (params) => parseDiffRouteSearch(params),
713601 });
714602 const { resolvedTheme } = useTheme();
715 const composerDraft = useComposerThreadDraft(composerDraftTarget);
716 const prompt = composerDraft.prompt;
717 const composerImages = composerDraft.images;
718 const composerTerminalContexts = composerDraft.terminalContexts;
719 const composerSendState = useMemo(
720 () =>
721 deriveComposerSendState({
722 prompt,
723 imageCount: composerImages.length,
724 terminalContexts: composerTerminalContexts,
725 }),
726 [composerImages.length, composerTerminalContexts, prompt],
727 );
728 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
729 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
730 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
731 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
732 const setComposerDraftInteractionMode = useComposerDraftStore(
733 (store) => store.setInteractionMode,
734 );
735 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
736 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
737 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
738 const insertComposerDraftTerminalContext = useComposerDraftStore(
739 (store) => store.insertTerminalContext,
603 // Granular store selectors — avoid subscribing to prompt changes.
604 const composerRuntimeMode = useComposerDraftStore(
605 (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null,
740606 );
741 const addComposerDraftTerminalContexts = useComposerDraftStore(
742 (store) => store.addTerminalContexts,
607 const composerInteractionMode = useComposerDraftStore(
608 (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null,
743609 );
744 const removeComposerDraftTerminalContext = useComposerDraftStore(
745 (store) => store.removeTerminalContext,
610 const composerActiveProvider = useComposerDraftStore(
611 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
746612 );
613 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
614 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
747615 const setComposerDraftTerminalContexts = useComposerDraftStore(
748616 (store) => store.setTerminalContexts,
749617 );
750 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
751 (store) => store.clearPersistedAttachments,
752 );
753 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
754 (store) => store.syncPersistedAttachments,
618 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
619 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
620 const setComposerDraftInteractionMode = useComposerDraftStore(
621 (store) => store.setInteractionMode,
755622 );
756623 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
757624 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
758625 const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
759626 (store) => store.getDraftSessionByLogicalProjectKey,
760627 );
761 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
762628 const getDraftSession = useComposerDraftStore((store) => store.getDraftSession);
763629 const setLogicalProjectDraftThreadId = useComposerDraftStore(
764630 (store) => store.setLogicalProjectDraftThreadId,
......@@ -770,14 +636,15 @@ export default function ChatView(props: ChatViewProps) {
770636 ? store.getDraftSession(draftId)
771637 : null,
772638 );
773 const promptRef = useRef(prompt);
639 const promptRef = useRef("");
640 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
641 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
642 const composerRef = useRef<ChatComposerHandle>(null);
774643 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
775 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
776644 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
777645 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
778646 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
779647 optimisticUserMessagesRef.current = optimisticUserMessages;
780 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
781648 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
782649 Record<string, string | null>
783650 >({});
......@@ -794,8 +661,6 @@ export default function ChatView(props: ChatViewProps) {
794661 useState<Record<string, number>>({});
795662 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
796663 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
797 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
798 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
799664 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
800665 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
801666 // When set, the thread-change reset effect will open the sidebar instead of closing it.
......@@ -803,7 +668,6 @@ export default function ChatView(props: ChatViewProps) {
803668 const planSidebarOpenOnNextThreadRef = useRef(false);
804669 const [nowTick, setNowTick] = useState(() => Date.now());
805670 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
806 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
807671 const [pullRequestDialogState, setPullRequestDialogState] =
808672 useState<PullRequestDialogState | null>(null);
809673 const [terminalLaunchContext, setTerminalLaunchContext] = useState<TerminalLaunchContext | null>(
......@@ -812,12 +676,6 @@ export default function ChatView(props: ChatViewProps) {
812676 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
813677 Record<string, string[]>
814678 >({});
815 const [composerCursor, setComposerCursor] = useState(() =>
816 collapseExpandedComposerCursor(prompt, prompt.length),
817 );
818 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
819 detectComposerTrigger(prompt, prompt.length),
820 );
821679 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
822680 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
823681 {},
......@@ -836,21 +694,9 @@ export default function ChatView(props: ChatViewProps) {
836694 top: number;
837695 } | null>(null);
838696 const pendingInteractionAnchorFrameRef = useRef<number | null>(null);
839 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
840 const composerFormRef = useRef<HTMLFormElement>(null);
841 const composerFormHeightRef = useRef(0);
842 const composerFooterRef = useRef<HTMLDivElement>(null);
843 const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
844 const composerFooterActionsRef = useRef<HTMLDivElement>(null);
845 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
846 const composerSelectLockRef = useRef(false);
847 const composerMenuOpenRef = useRef(false);
848 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
849 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
850697 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
851698 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
852699 const sendInFlightRef = useRef(false);
853 const dragDepthRef = useRef(0);
854700 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
855701 const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => {
856702 messagesScrollRef.current = element;
......@@ -903,59 +749,6 @@ export default function ChatView(props: ChatViewProps) {
903749 [mountedTerminalThreadKeys],
904750 );
905751
906 const setPrompt = useCallback(
907 (nextPrompt: string) => {
908 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
909 },
910 [composerDraftTarget, setComposerDraftPrompt],
911 );
912 const addComposerImage = useCallback(
913 (image: ComposerImageAttachment) => {
914 addComposerDraftImage(composerDraftTarget, image);
915 },
916 [addComposerDraftImage, composerDraftTarget],
917 );
918 const addComposerImagesToDraft = useCallback(
919 (images: ComposerImageAttachment[]) => {
920 addComposerDraftImages(composerDraftTarget, images);
921 },
922 [addComposerDraftImages, composerDraftTarget],
923 );
924 const addComposerTerminalContextsToDraft = useCallback(
925 (contexts: TerminalContextDraft[]) => {
926 addComposerDraftTerminalContexts(composerDraftTarget, contexts);
927 },
928 [addComposerDraftTerminalContexts, composerDraftTarget],
929 );
930 const removeComposerImageFromDraft = useCallback(
931 (imageId: string) => {
932 removeComposerDraftImage(composerDraftTarget, imageId);
933 },
934 [composerDraftTarget, removeComposerDraftImage],
935 );
936 const removeComposerTerminalContextFromDraft = useCallback(
937 (contextId: string) => {
938 const contextIndex = composerTerminalContexts.findIndex(
939 (context) => context.id === contextId,
940 );
941 if (contextIndex < 0) {
942 return;
943 }
944 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
945 promptRef.current = nextPrompt.prompt;
946 setPrompt(nextPrompt.prompt);
947 removeComposerDraftTerminalContext(composerDraftTarget, contextId);
948 setComposerCursor(nextPrompt.cursor);
949 setComposerTrigger(
950 detectComposerTrigger(
951 nextPrompt.prompt,
952 expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor),
953 ),
954 );
955 },
956 [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt],
957 );
958
959752 const fallbackDraftProjectRef = draftThread
960753 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
961754 : null;
......@@ -983,12 +776,9 @@ export default function ChatView(props: ChatViewProps) {
983776 );
984777 const isServerThread = routeKind === "server" && serverThread !== undefined;
985778 const activeThread = isServerThread ? serverThread : localDraftThread;
986 const runtimeMode =
987 composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
779 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
988780 const interactionMode =
989 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
990 const runtimeModeOption = runtimeModeConfig[runtimeMode];
991 const RuntimeModeIcon = runtimeModeOption.icon;
781 composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
992782 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
993783 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
994784 const diffOpen = rawSearch.diff === "1";
......@@ -1016,10 +806,6 @@ export default function ChatView(props: ChatViewProps) {
1016806 return threadIds;
1017807 }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]),
1018808 );
1019 const activeContextWindow = useMemo(
1020 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
1021 [activeThread?.activities],
1022 );
1023809 useEffect(() => {
1024810 setMountedTerminalThreadKeys((currentThreadIds) => {
1025811 const nextThreadIds = reconcileMountedTerminalThreadIds({
......@@ -1103,7 +889,6 @@ export default function ChatView(props: ChatViewProps) {
1103889 initialReference: reference ?? null,
1104890 key: Date.now(),
1105891 });
1106 setComposerHighlightedItemId(null);
1107892 },
1108893 [canCheckoutPullRequestIntoThread],
1109894 );
......@@ -1215,14 +1000,14 @@ export default function ChatView(props: ChatViewProps) {
12151000 serverThread?.id,
12161001 ]);
12171002
1218 const sessionProvider = activeThread?.session?.provider ?? null;
1219 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
1003 const selectedProviderByThreadId = composerActiveProvider ?? null;
12201004 const threadProvider =
12211005 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;
1222 const hasThreadStarted = threadHasStarted(activeThread);
1223 const lockedProvider: ProviderKind | null = hasThreadStarted
1224 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
1225 : null;
1006 const lockedProvider = deriveLockedProvider({
1007 thread: activeThread,
1008 selectedProvider: selectedProviderByThreadId,
1009 threadProvider,
1010 });
12261011 const primaryServerConfig = useServerConfig();
12271012 const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) =>
12281013 activeThread?.environmentId ? s.byId[activeThread.environmentId] : null,
......@@ -1240,37 +1025,6 @@ export default function ChatView(props: ChatViewProps) {
12401025 selectedProviderByThreadId ?? threadProvider ?? "codex",
12411026 );
12421027 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
1243 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
1244 threadRef: composerDraftTarget,
1245 providers: providerStatuses,
1246 selectedProvider,
1247 threadModelSelection: activeThread?.modelSelection,
1248 projectModelSelection: activeProject?.defaultModelSelection,
1249 settings,
1250 });
1251 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);
1252 const composerProviderState = useMemo(
1253 () =>
1254 getComposerProviderState({
1255 provider: selectedProvider,
1256 model: selectedModel,
1257 models: selectedProviderModels,
1258 prompt,
1259 modelOptions: composerModelOptions,
1260 }),
1261 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
1262 );
1263 const selectedPromptEffort = composerProviderState.promptEffort;
1264 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
1265 const selectedModelSelection = useMemo<ModelSelection>(
1266 () => ({
1267 provider: selectedProvider,
1268 model: selectedModel,
1269 ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
1270 }),
1271 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
1272 );
1273 const selectedModelForPicker = selectedModel;
12741028 const phase = derivePhase(activeThread?.session ?? null);
12751029 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
12761030 const workLogEntries = useMemo(
......@@ -1372,75 +1126,6 @@ export default function ChatView(props: ChatViewProps) {
13721126 activeThread?.session ?? null,
13731127 localDispatchStartedAt,
13741128 );
1375 const isComposerApprovalState = activePendingApproval !== null;
1376 const hasComposerHeader =
1377 isComposerApprovalState ||
1378 pendingUserInputs.length > 0 ||
1379 (showPlanFollowUpPrompt && activeProposedPlan !== null);
1380 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
1381 const composerFooterActionLayoutKey = useMemo(() => {
1382 if (activePendingProgress) {
1383 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
1384 }
1385 if (phase === "running") {
1386 return "running";
1387 }
1388 if (showPlanFollowUpPrompt) {
1389 return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
1390 }
1391 return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
1392 }, [
1393 activePendingIsResponding,
1394 activePendingProgress,
1395 composerSendState.hasSendableContent,
1396 isConnecting,
1397 isPreparingWorktree,
1398 isSendBusy,
1399 phase,
1400 prompt,
1401 showPlanFollowUpPrompt,
1402 ]);
1403 const lastSyncedPendingInputRef = useRef<{
1404 requestId: string | null;
1405 questionId: string | null;
1406 } | null>(null);
1407 useEffect(() => {
1408 const nextCustomAnswer = activePendingProgress?.customAnswer;
1409 if (typeof nextCustomAnswer !== "string") {
1410 lastSyncedPendingInputRef.current = null;
1411 return;
1412 }
1413 const nextRequestId = activePendingUserInput?.requestId ?? null;
1414 const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
1415 const questionChanged =
1416 lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
1417 lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
1418 const textChangedExternally = promptRef.current !== nextCustomAnswer;
1419
1420 lastSyncedPendingInputRef.current = {
1421 requestId: nextRequestId,
1422 questionId: nextQuestionId,
1423 };
1424
1425 if (!questionChanged && !textChangedExternally) {
1426 return;
1427 }
1428
1429 promptRef.current = nextCustomAnswer;
1430 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
1431 setComposerCursor(nextCursor);
1432 setComposerTrigger(
1433 detectComposerTrigger(
1434 nextCustomAnswer,
1435 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
1436 ),
1437 );
1438 setComposerHighlightedItemId(null);
1439 }, [
1440 activePendingProgress?.customAnswer,
1441 activePendingUserInput?.requestId,
1442 activePendingProgress?.activeQuestion?.id,
1443 ]);
14441129 useEffect(() => {
14451130 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
14461131 }, [attachmentPreviewHandoffByMessageId]);
......@@ -1708,139 +1393,9 @@ export default function ChatView(props: ChatViewProps) {
17081393 worktreePath: activeThread?.worktreePath ?? null,
17091394 })
17101395 : null;
1711 const composerTriggerKind = composerTrigger?.kind ?? null;
1712 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
1713 const isPathTrigger = composerTriggerKind === "path";
1714 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
1715 pathTriggerQuery,
1716 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
1717 (debouncerState) => ({ isPending: debouncerState.isPending }),
1718 );
1719 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
17201396 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
17211397 const keybindings = useServerKeybindings();
17221398 const availableEditors = useServerAvailableEditors();
1723 const modelOptionsByProvider = useMemo<
1724 Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>>
1725 >(
1726 () => ({
1727 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
1728 claudeAgent:
1729 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
1730 }),
1731 [providerStatuses],
1732 );
1733 const selectedModelForPickerWithCustomFallback = useMemo(() => {
1734 const currentOptions = modelOptionsByProvider[selectedProvider];
1735 return currentOptions.some((option) => option.slug === selectedModelForPicker)
1736 ? selectedModelForPicker
1737 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
1738 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
1739 const searchableModelOptions = useMemo(
1740 () =>
1741 AVAILABLE_PROVIDER_OPTIONS.filter(
1742 (option) => lockedProvider === null || option.value === lockedProvider,
1743 ).flatMap((option) =>
1744 modelOptionsByProvider[option.value].map(({ slug, name }) => ({
1745 provider: option.value,
1746 providerLabel: option.label,
1747 slug,
1748 name,
1749 searchSlug: slug.toLowerCase(),
1750 searchName: name.toLowerCase(),
1751 searchProvider: option.label.toLowerCase(),
1752 })),
1753 ),
1754 [lockedProvider, modelOptionsByProvider],
1755 );
1756 const workspaceEntriesQuery = useQuery(
1757 projectSearchEntriesQueryOptions({
1758 environmentId,
1759 cwd: gitCwd,
1760 query: effectivePathQuery,
1761 enabled: isPathTrigger,
1762 limit: 80,
1763 }),
1764 );
1765 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
1766 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
1767 if (!composerTrigger) return [];
1768 if (composerTrigger.kind === "path") {
1769 return workspaceEntries.map((entry) => ({
1770 id: `path:${entry.kind}:${entry.path}`,
1771 type: "path",
1772 path: entry.path,
1773 pathKind: entry.kind,
1774 label: basenameOfPath(entry.path),
1775 description: entry.parentPath ?? "",
1776 }));
1777 }
1778
1779 if (composerTrigger.kind === "slash-command") {
1780 const slashCommandItems = [
1781 {
1782 id: "slash:model",
1783 type: "slash-command",
1784 command: "model",
1785 label: "/model",
1786 description: "Switch response model for this thread",
1787 },
1788 {
1789 id: "slash:plan",
1790 type: "slash-command",
1791 command: "plan",
1792 label: "/plan",
1793 description: "Switch this thread into plan mode",
1794 },
1795 {
1796 id: "slash:default",
1797 type: "slash-command",
1798 command: "default",
1799 label: "/default",
1800 description: "Switch this thread back to normal build mode",
1801 },
1802 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1803 const query = composerTrigger.query.trim().toLowerCase();
1804 if (!query) {
1805 return [...slashCommandItems];
1806 }
1807 return slashCommandItems.filter(
1808 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
1809 );
1810 }
1811
1812 return searchableModelOptions
1813 .filter(({ searchSlug, searchName, searchProvider }) => {
1814 const query = composerTrigger.query.trim().toLowerCase();
1815 if (!query) return true;
1816 return (
1817 searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query)
1818 );
1819 })
1820 .map(({ provider, providerLabel, slug, name }) => ({
1821 id: `model:${provider}:${slug}`,
1822 type: "model",
1823 provider,
1824 model: slug,
1825 label: name,
1826 description: `${providerLabel} · ${slug}`,
1827 }));
1828 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1829 const composerMenuOpen = Boolean(composerTrigger);
1830 const activeComposerMenuItem = useMemo(
1831 () =>
1832 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
1833 composerMenuItems[0] ??
1834 null,
1835 [composerHighlightedItemId, composerMenuItems],
1836 );
1837 composerMenuOpenRef.current = composerMenuOpen;
1838 composerMenuItemsRef.current = composerMenuItems;
1839 activeComposerMenuItemRef.current = activeComposerMenuItem;
1840 const nonPersistedComposerImageIdSet = useMemo(
1841 () => new Set(nonPersistedComposerImageIds),
1842 [nonPersistedComposerImageIds],
1843 );
18441399 const activeProviderStatus = useMemo(
18451400 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
18461401 [selectedProvider, providerStatuses],
......@@ -1933,29 +1488,6 @@ export default function ChatView(props: ChatViewProps) {
19331488 [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext],
19341489 );
19351490
1936 // Auto-correct the draft model selection when the environment changes and
1937 // the previously-selected provider/model is no longer available. This keeps
1938 // the stored draft state consistent with the resolved picker values and
1939 // prevents stale model references when the user sends a message.
1940 const prevEnvironmentIdRef = useRef(activeThread?.environmentId);
1941 useEffect(() => {
1942 const currentEnvId = activeThread?.environmentId;
1943 if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) {
1944 prevEnvironmentIdRef.current = currentEnvId;
1945 return;
1946 }
1947 prevEnvironmentIdRef.current = currentEnvId;
1948
1949 // The resolved provider/model already account for the new environment's
1950 // provider list. Persist that resolved selection into the draft.
1951 if (activeThread) {
1952 setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), {
1953 provider: selectedProvider,
1954 model: selectedModel,
1955 });
1956 }
1957 }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]);
1958
19591491 const activeTerminalGroup =
19601492 terminalState.terminalGroups.find(
19611493 (group) => group.id === terminalState.activeTerminalGroupId,
......@@ -1994,55 +1526,16 @@ export default function ChatView(props: ChatViewProps) {
19941526 );
19951527
19961528 const focusComposer = useCallback(() => {
1997 composerEditorRef.current?.focusAtEnd();
1529 composerRef.current?.focusAtEnd();
19981530 }, []);
19991531 const scheduleComposerFocus = useCallback(() => {
20001532 window.requestAnimationFrame(() => {
20011533 focusComposer();
20021534 });
20031535 }, [focusComposer]);
2004 const addTerminalContextToDraft = useCallback(
2005 (selection: TerminalContextSelection) => {
2006 if (!activeThread) {
2007 return;
2008 }
2009 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
2010 value: promptRef.current,
2011 cursor: composerCursor,
2012 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
2013 terminalContextIds: composerTerminalContexts.map((context) => context.id),
2014 };
2015 const insertion = insertInlineTerminalContextPlaceholder(
2016 snapshot.value,
2017 snapshot.expandedCursor,
2018 );
2019 const nextCollapsedCursor = collapseExpandedComposerCursor(
2020 insertion.prompt,
2021 insertion.cursor,
2022 );
2023 const inserted = insertComposerDraftTerminalContext(
2024 scopeThreadRef(activeThread.environmentId, activeThread.id),
2025 insertion.prompt,
2026 {
2027 id: randomUUID(),
2028 threadId: activeThread.id,
2029 createdAt: new Date().toISOString(),
2030 ...selection,
2031 },
2032 insertion.contextIndex,
2033 );
2034 if (!inserted) {
2035 return;
2036 }
2037 promptRef.current = insertion.prompt;
2038 setComposerCursor(nextCollapsedCursor);
2039 setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
2040 window.requestAnimationFrame(() => {
2041 composerEditorRef.current?.focusAt(nextCollapsedCursor);
2042 });
2043 },
2044 [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
2045 );
1536 const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
1537 composerRef.current?.addTerminalContext(selection);
1538 }, []);
20461539 const setTerminalOpen = useCallback(
20471540 (open: boolean) => {
20481541 if (!activeThreadRef) return;
......@@ -2600,80 +2093,6 @@ export default function ChatView(props: ChatViewProps) {
26002093 window.clearTimeout(timeout);
26012094 };
26022095 }, [activeThread?.id, scheduleStickToBottom]);
2603 useLayoutEffect(() => {
2604 const composerForm = composerFormRef.current;
2605 if (!composerForm) return;
2606 const measureComposerFormWidth = () => composerForm.clientWidth;
2607 const measureFooterCompactness = () => {
2608 const composerFormWidth = measureComposerFormWidth();
2609 const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, {
2610 hasWideActions: composerFooterHasWideActions,
2611 });
2612 const footer = composerFooterRef.current;
2613 const footerStyle = footer ? window.getComputedStyle(footer) : null;
2614 const footerContentWidth = resolveComposerFooterContentWidth({
2615 footerWidth: footer?.clientWidth ?? null,
2616 paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null,
2617 paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null,
2618 });
2619 const fitInput = {
2620 footerContentWidth,
2621 leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null,
2622 actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null,
2623 };
2624 const nextFooterCompact =
2625 heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput);
2626 const nextPrimaryActionsCompact =
2627 nextFooterCompact &&
2628 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
2629 hasWideActions: composerFooterHasWideActions,
2630 });
2631
2632 return {
2633 primaryActionsCompact: nextPrimaryActionsCompact,
2634 footerCompact: nextFooterCompact,
2635 };
2636 };
2637
2638 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
2639 const initialCompactness = measureFooterCompactness();
2640 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
2641 setIsComposerFooterCompact(initialCompactness.footerCompact);
2642 if (typeof ResizeObserver === "undefined") return;
2643
2644 const observer = new ResizeObserver((entries) => {
2645 const [entry] = entries;
2646 if (!entry) return;
2647
2648 const nextCompactness = measureFooterCompactness();
2649 setIsComposerPrimaryActionsCompact((previous) =>
2650 previous === nextCompactness.primaryActionsCompact
2651 ? previous
2652 : nextCompactness.primaryActionsCompact,
2653 );
2654 setIsComposerFooterCompact((previous) =>
2655 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
2656 );
2657
2658 const nextHeight = entry.contentRect.height;
2659 const previousHeight = composerFormHeightRef.current;
2660 composerFormHeightRef.current = nextHeight;
2661
2662 if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
2663 if (!shouldAutoScrollRef.current) return;
2664 scheduleStickToBottom();
2665 });
2666
2667 observer.observe(composerForm);
2668 return () => {
2669 observer.disconnect();
2670 };
2671 }, [
2672 activeThread?.id,
2673 composerFooterActionLayoutKey,
2674 composerFooterHasWideActions,
2675 scheduleStickToBottom,
2676 ]);
26772096 useEffect(() => {
26782097 if (!shouldAutoScrollRef.current) return;
26792098 scheduleStickToBottom();
......@@ -2696,18 +2115,6 @@ export default function ChatView(props: ChatViewProps) {
26962115 planSidebarDismissedForTurnRef.current = null;
26972116 }, [activeThread?.id]);
26982117
2699 useEffect(() => {
2700 if (!composerMenuOpen) {
2701 setComposerHighlightedItemId(null);
2702 return;
2703 }
2704 setComposerHighlightedItemId((existing) =>
2705 existing && composerMenuItems.some((item) => item.id === existing)
2706 ? existing
2707 : (composerMenuItems[0]?.id ?? null),
2708 );
2709 }, [composerMenuItems, composerMenuOpen]);
2710
27112118 useEffect(() => {
27122119 setIsRevertingCheckpoint(false);
27132120 }, [activeThread?.id]);
......@@ -2722,14 +2129,6 @@ export default function ChatView(props: ChatViewProps) {
27222129 };
27232130 }, [activeThread?.id, focusComposer, terminalState.terminalOpen]);
27242131
2725 useEffect(() => {
2726 composerImagesRef.current = composerImages;
2727 }, [composerImages]);
2728
2729 useEffect(() => {
2730 composerTerminalContextsRef.current = composerTerminalContexts;
2731 }, [composerTerminalContexts]);
2732
27332132 useEffect(() => {
27342133 if (!activeThread?.id) return;
27352134 if (activeThread.messages.length === 0) {
......@@ -2758,11 +2157,6 @@ export default function ChatView(props: ChatViewProps) {
27582157 };
27592158 }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]);
27602159
2761 useEffect(() => {
2762 promptRef.current = prompt;
2763 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
2764 }, [prompt]);
2765
27662160 useEffect(() => {
27672161 setOptimisticUserMessages((existing) => {
27682162 for (const message of existing) {
......@@ -2771,128 +2165,12 @@ export default function ChatView(props: ChatViewProps) {
27712165 return [];
27722166 });
27732167 resetLocalDispatch();
2774 setComposerHighlightedItemId(null);
2775 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
2776 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
2777 dragDepthRef.current = 0;
2778 setIsDragOverComposer(false);
27792168 setExpandedImage(null);
27802169 }, [draftId, resetLocalDispatch, threadId]);
27812170
2782 useEffect(() => {
2783 let cancelled = false;
2784 void (async () => {
2785 if (composerImages.length === 0) {
2786 clearComposerDraftPersistedAttachments(composerDraftTarget);
2787 return;
2788 }
2789 const getPersistedAttachmentsForThread = () =>
2790 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
2791 try {
2792 const currentPersistedAttachments = getPersistedAttachmentsForThread();
2793 const existingPersistedById = new Map(
2794 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
2795 );
2796 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
2797 await Promise.all(
2798 composerImages.map(async (image) => {
2799 try {
2800 const dataUrl = await readFileAsDataUrl(image.file);
2801 stagedAttachmentById.set(image.id, {
2802 id: image.id,
2803 name: image.name,
2804 mimeType: image.mimeType,
2805 sizeBytes: image.sizeBytes,
2806 dataUrl,
2807 });
2808 } catch {
2809 const existingPersisted = existingPersistedById.get(image.id);
2810 if (existingPersisted) {
2811 stagedAttachmentById.set(image.id, existingPersisted);
2812 }
2813 }
2814 }),
2815 );
2816 const serialized = Array.from(stagedAttachmentById.values());
2817 if (cancelled) {
2818 return;
2819 }
2820 // Stage attachments in persisted draft state first so persist middleware can write them.
2821 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
2822 } catch {
2823 const currentImageIds = new Set(composerImages.map((image) => image.id));
2824 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
2825 const fallbackPersistedIds = fallbackPersistedAttachments
2826 .map((attachment) => attachment.id)
2827 .filter((id) => currentImageIds.has(id));
2828 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
2829 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
2830 fallbackPersistedIdSet.has(attachment.id),
2831 );
2832 if (cancelled) {
2833 return;
2834 }
2835 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
2836 }
2837 })();
2838 return () => {
2839 cancelled = true;
2840 };
2841 }, [
2842 composerDraftTarget,
2843 clearComposerDraftPersistedAttachments,
2844 composerImages,
2845 getComposerDraft,
2846 syncComposerDraftPersistedAttachments,
2847 ]);
2848
28492171 const closeExpandedImage = useCallback(() => {
28502172 setExpandedImage(null);
28512173 }, []);
2852 const navigateExpandedImage = useCallback((direction: -1 | 1) => {
2853 setExpandedImage((existing) => {
2854 if (!existing || existing.images.length <= 1) {
2855 return existing;
2856 }
2857 const nextIndex =
2858 (existing.index + direction + existing.images.length) % existing.images.length;
2859 if (nextIndex === existing.index) {
2860 return existing;
2861 }
2862 return { ...existing, index: nextIndex };
2863 });
2864 }, []);
2865
2866 useEffect(() => {
2867 if (!expandedImage) {
2868 return;
2869 }
2870
2871 const onKeyDown = (event: globalThis.KeyboardEvent) => {
2872 if (event.key === "Escape") {
2873 event.preventDefault();
2874 event.stopPropagation();
2875 closeExpandedImage();
2876 return;
2877 }
2878 if (expandedImage.images.length <= 1) {
2879 return;
2880 }
2881 if (event.key === "ArrowLeft") {
2882 event.preventDefault();
2883 event.stopPropagation();
2884 navigateExpandedImage(-1);
2885 return;
2886 }
2887 if (event.key !== "ArrowRight") return;
2888 event.preventDefault();
2889 event.stopPropagation();
2890 navigateExpandedImage(1);
2891 };
2892
2893 window.addEventListener("keydown", onKeyDown);
2894 return () => window.removeEventListener("keydown", onKeyDown);
2895 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
28962174
28972175 const activeWorktreePath = activeThread?.worktreePath ?? null;
28982176 const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
......@@ -3097,117 +2375,6 @@ export default function ChatView(props: ChatViewProps) {
30972375 toggleTerminalVisibility,
30982376 ]);
30992377
3100 const addComposerImages = (files: File[]) => {
3101 if (!activeThreadId || files.length === 0) return;
3102
3103 if (pendingUserInputs.length > 0) {
3104 toastManager.add({
3105 type: "error",
3106 title: "Attach images after answering plan questions.",
3107 });
3108 return;
3109 }
3110
3111 const nextImages: ComposerImageAttachment[] = [];
3112 let nextImageCount = composerImagesRef.current.length;
3113 let error: string | null = null;
3114 for (const file of files) {
3115 if (!file.type.startsWith("image/")) {
3116 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
3117 continue;
3118 }
3119 if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
3120 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
3121 continue;
3122 }
3123 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
3124 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
3125 break;
3126 }
3127
3128 const previewUrl = URL.createObjectURL(file);
3129 nextImages.push({
3130 type: "image",
3131 id: randomUUID(),
3132 name: file.name || "image",
3133 mimeType: file.type,
3134 sizeBytes: file.size,
3135 previewUrl,
3136 file,
3137 });
3138 nextImageCount += 1;
3139 }
3140
3141 if (nextImages.length === 1 && nextImages[0]) {
3142 addComposerImage(nextImages[0]);
3143 } else if (nextImages.length > 1) {
3144 addComposerImagesToDraft(nextImages);
3145 }
3146 setThreadError(activeThreadId, error);
3147 };
3148
3149 const removeComposerImage = (imageId: string) => {
3150 removeComposerImageFromDraft(imageId);
3151 };
3152
3153 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
3154 const files = Array.from(event.clipboardData.files);
3155 if (files.length === 0) {
3156 return;
3157 }
3158 const imageFiles = files.filter((file) => file.type.startsWith("image/"));
3159 if (imageFiles.length === 0) {
3160 return;
3161 }
3162 event.preventDefault();
3163 addComposerImages(imageFiles);
3164 };
3165
3166 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
3167 if (!event.dataTransfer.types.includes("Files")) {
3168 return;
3169 }
3170 event.preventDefault();
3171 dragDepthRef.current += 1;
3172 setIsDragOverComposer(true);
3173 };
3174
3175 const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
3176 if (!event.dataTransfer.types.includes("Files")) {
3177 return;
3178 }
3179 event.preventDefault();
3180 event.dataTransfer.dropEffect = "copy";
3181 setIsDragOverComposer(true);
3182 };
3183
3184 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
3185 if (!event.dataTransfer.types.includes("Files")) {
3186 return;
3187 }
3188 event.preventDefault();
3189 const nextTarget = event.relatedTarget;
3190 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
3191 return;
3192 }
3193 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
3194 if (dragDepthRef.current === 0) {
3195 setIsDragOverComposer(false);
3196 }
3197 };
3198
3199 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
3200 if (!event.dataTransfer.types.includes("Files")) {
3201 return;
3202 }
3203 event.preventDefault();
3204 dragDepthRef.current = 0;
3205 setIsDragOverComposer(false);
3206 const files = Array.from(event.dataTransfer.files);
3207 addComposerImages(files);
3208 focusComposer();
3209 };
3210
32112378 const onRevertToTurnCount = useCallback(
32122379 async (turnCount: number) => {
32132380 const api = readEnvironmentApi(environmentId);
......@@ -3266,6 +2433,17 @@ export default function ChatView(props: ChatViewProps) {
32662433 onAdvanceActivePendingUserInput();
32672434 return;
32682435 }
2436 const sendCtx = composerRef.current?.getSendContext();
2437 if (!sendCtx) return;
2438 const {
2439 images: composerImages,
2440 terminalContexts: composerTerminalContexts,
2441 selectedProvider: ctxSelectedProvider,
2442 selectedModel: ctxSelectedModel,
2443 selectedProviderModels: ctxSelectedProviderModels,
2444 selectedPromptEffort: ctxSelectedPromptEffort,
2445 selectedModelSelection: ctxSelectedModelSelection,
2446 } = sendCtx;
32692447 const promptForSend = promptRef.current;
32702448 const {
32712449 trimmedPrompt: trimmed,
......@@ -3283,10 +2461,8 @@ export default function ChatView(props: ChatViewProps) {
32832461 planMarkdown: activeProposedPlan.planMarkdown,
32842462 });
32852463 promptRef.current = "";
3286 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
3287 setComposerHighlightedItemId(null);
3288 setComposerCursor(0);
3289 setComposerTrigger(null);
2464 clearComposerDraftContent(composerDraftTarget);
2465 composerRef.current?.resetCursorState();
32902466 await onSubmitPlanFollowUp({
32912467 text: followUp.text,
32922468 interactionMode: followUp.interactionMode,
......@@ -3300,10 +2476,8 @@ export default function ChatView(props: ChatViewProps) {
33002476 if (standaloneSlashCommand) {
33012477 handleInteractionModeChange(standaloneSlashCommand);
33022478 promptRef.current = "";
3303 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
3304 setComposerHighlightedItemId(null);
3305 setComposerCursor(0);
3306 setComposerTrigger(null);
2479 clearComposerDraftContent(composerDraftTarget);
2480 composerRef.current?.resetCursorState();
33072481 return;
33082482 }
33092483 if (!hasSendableContent) {
......@@ -3349,10 +2523,10 @@ export default function ChatView(props: ChatViewProps) {
33492523 const messageIdForSend = newMessageId();
33502524 const messageCreatedAt = new Date().toISOString();
33512525 const outgoingMessageText = formatOutgoingPrompt({
3352 provider: selectedProvider,
3353 model: selectedModel,
3354 models: selectedProviderModels,
3355 effort: selectedPromptEffort,
2526 provider: ctxSelectedProvider,
2527 model: ctxSelectedModel,
2528 models: ctxSelectedProviderModels,
2529 effort: ctxSelectedPromptEffort,
33562530 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
33572531 });
33582532 const turnAttachmentsPromise = Promise.all(
......@@ -3400,10 +2574,8 @@ export default function ChatView(props: ChatViewProps) {
34002574 });
34012575 }
34022576 promptRef.current = "";
3403 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend));
3404 setComposerHighlightedItemId(null);
3405 setComposerCursor(0);
3406 setComposerTrigger(null);
2577 clearComposerDraftContent(composerDraftTarget);
2578 composerRef.current?.resetCursorState();
34072579
34082580 let turnStartSucceeded = false;
34092581 await (async () => {
......@@ -3426,12 +2598,14 @@ export default function ChatView(props: ChatViewProps) {
34262598 }
34272599 const title = truncate(titleSeed);
34282600 const threadCreateModelSelection: ModelSelection = {
3429 provider: selectedProvider,
2601 provider: ctxSelectedProvider,
34302602 model:
3431 selectedModel ||
2603 ctxSelectedModel ||
34322604 activeProject.defaultModelSelection?.model ||
34332605 DEFAULT_MODEL_BY_PROVIDER.codex,
3434 ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}),
2606 ...(ctxSelectedModelSelection.options
2607 ? { options: ctxSelectedModelSelection.options }
2608 : {}),
34352609 };
34362610
34372611 // Auto-title from first message
......@@ -3448,7 +2622,7 @@ export default function ChatView(props: ChatViewProps) {
34482622 await persistThreadSettingsForNextTurn({
34492623 threadId: threadIdForSend,
34502624 createdAt: messageCreatedAt,
3451 ...(selectedModel ? { modelSelection: selectedModelSelection } : {}),
2625 ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}),
34522626 runtimeMode,
34532627 interactionMode,
34542628 });
......@@ -3495,7 +2669,7 @@ export default function ChatView(props: ChatViewProps) {
34952669 text: outgoingMessageText,
34962670 attachments: turnAttachments,
34972671 },
3498 modelSelection: selectedModelSelection,
2672 modelSelection: ctxSelectedModelSelection,
34992673 titleSeed: title,
35002674 runtimeMode,
35012675 interactionMode,
......@@ -3519,11 +2693,17 @@ export default function ChatView(props: ChatViewProps) {
35192693 return next.length === existing.length ? existing : next;
35202694 });
35212695 promptRef.current = promptForSend;
3522 setPrompt(promptForSend);
3523 setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length));
3524 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
3525 addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot);
3526 setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length));
2696 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
2697 composerImagesRef.current = retryComposerImages;
2698 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
2699 setComposerDraftPrompt(composerDraftTarget, promptForSend);
2700 addComposerDraftImages(composerDraftTarget, retryComposerImages);
2701 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
2702 composerRef.current?.resetCursorState({
2703 cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length),
2704 prompt: promptForSend,
2705 detectTrigger: true,
2706 });
35272707 }
35282708 setThreadError(
35292709 threadIdForSend,
......@@ -3644,8 +2824,7 @@ export default function ChatView(props: ChatViewProps) {
36442824 };
36452825 });
36462826 promptRef.current = "";
3647 setComposerCursor(0);
3648 setComposerTrigger(null);
2827 composerRef.current?.resetCursorState({ cursor: 0 });
36492828 },
36502829 [activePendingProgress?.activeQuestion, activePendingUserInput],
36512830 );
......@@ -3656,7 +2835,7 @@ export default function ChatView(props: ChatViewProps) {
36562835 value: string,
36572836 nextCursor: number,
36582837 expandedCursor: number,
3659 cursorAdjacentToMention: boolean,
2838 _cursorAdjacentToMention: boolean,
36602839 ) => {
36612840 if (!activePendingUserInput) {
36622841 return;
......@@ -3672,10 +2851,14 @@ export default function ChatView(props: ChatViewProps) {
36722851 ),
36732852 },
36742853 }));
3675 setComposerCursor(nextCursor);
3676 setComposerTrigger(
3677 cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor),
3678 );
2854 const snapshot = composerRef.current?.readSnapshot();
2855 if (
2856 snapshot?.value !== value ||
2857 snapshot.cursor !== nextCursor ||
2858 snapshot.expandedCursor !== expandedCursor
2859 ) {
2860 composerRef.current?.focusAt(nextCursor);
2861 }
36792862 },
36802863 [activePendingUserInput],
36812864 );
......@@ -3731,14 +2914,26 @@ export default function ChatView(props: ChatViewProps) {
37312914 return;
37322915 }
37332916
2917 const sendCtx = composerRef.current?.getSendContext();
2918 if (!sendCtx) {
2919 return;
2920 }
2921 const {
2922 selectedProvider: ctxSelectedProvider,
2923 selectedModel: ctxSelectedModel,
2924 selectedProviderModels: ctxSelectedProviderModels,
2925 selectedPromptEffort: ctxSelectedPromptEffort,
2926 selectedModelSelection: ctxSelectedModelSelection,
2927 } = sendCtx;
2928
37342929 const threadIdForSend = activeThread.id;
37352930 const messageIdForSend = newMessageId();
37362931 const messageCreatedAt = new Date().toISOString();
37372932 const outgoingMessageText = formatOutgoingPrompt({
3738 provider: selectedProvider,
3739 model: selectedModel,
3740 models: selectedProviderModels,
3741 effort: selectedPromptEffort,
2933 provider: ctxSelectedProvider,
2934 model: ctxSelectedModel,
2935 models: ctxSelectedProviderModels,
2936 effort: ctxSelectedPromptEffort,
37422937 text: trimmed,
37432938 });
37442939
......@@ -3762,7 +2957,7 @@ export default function ChatView(props: ChatViewProps) {
37622957 await persistThreadSettingsForNextTurn({
37632958 threadId: threadIdForSend,
37642959 createdAt: messageCreatedAt,
3765 modelSelection: selectedModelSelection,
2960 modelSelection: ctxSelectedModelSelection,
37662961 runtimeMode,
37672962 interactionMode: nextInteractionMode,
37682963 });
......@@ -3784,7 +2979,7 @@ export default function ChatView(props: ChatViewProps) {
37842979 text: outgoingMessageText,
37852980 attachments: [],
37862981 },
3787 modelSelection: selectedModelSelection,
2982 modelSelection: ctxSelectedModelSelection,
37882983 titleSeed: activeThread.title,
37892984 runtimeMode,
37902985 interactionMode: nextInteractionMode,
......@@ -3829,13 +3024,8 @@ export default function ChatView(props: ChatViewProps) {
38293024 persistThreadSettingsForNextTurn,
38303025 resetLocalDispatch,
38313026 runtimeMode,
3832 selectedPromptEffort,
3833 selectedModelSelection,
3834 selectedProvider,
3835 selectedProviderModels,
38363027 setComposerDraftInteractionMode,
38373028 setThreadError,
3838 selectedModel,
38393029 environmentId,
38403030 ],
38413031 );
......@@ -3855,19 +3045,31 @@ export default function ChatView(props: ChatViewProps) {
38553045 return;
38563046 }
38573047
3048 const sendCtx = composerRef.current?.getSendContext();
3049 if (!sendCtx) {
3050 return;
3051 }
3052 const {
3053 selectedProvider: ctxSelectedProvider,
3054 selectedModel: ctxSelectedModel,
3055 selectedProviderModels: ctxSelectedProviderModels,
3056 selectedPromptEffort: ctxSelectedPromptEffort,
3057 selectedModelSelection: ctxSelectedModelSelection,
3058 } = sendCtx;
3059
38583060 const createdAt = new Date().toISOString();
38593061 const nextThreadId = newThreadId();
38603062 const planMarkdown = activeProposedPlan.planMarkdown;
38613063 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
38623064 const outgoingImplementationPrompt = formatOutgoingPrompt({
3863 provider: selectedProvider,
3864 model: selectedModel,
3865 models: selectedProviderModels,
3866 effort: selectedPromptEffort,
3065 provider: ctxSelectedProvider,
3066 model: ctxSelectedModel,
3067 models: ctxSelectedProviderModels,
3068 effort: ctxSelectedPromptEffort,
38673069 text: implementationPrompt,
38683070 });
38693071 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
3870 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
3072 const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;
38713073
38723074 sendInFlightRef.current = true;
38733075 beginLocalDispatch({ preparingWorktree: false });
......@@ -3901,7 +3103,7 @@ export default function ChatView(props: ChatViewProps) {
39013103 text: outgoingImplementationPrompt,
39023104 attachments: [],
39033105 },
3904 modelSelection: selectedModelSelection,
3106 modelSelection: ctxSelectedModelSelection,
39053107 titleSeed: nextThreadTitle,
39063108 runtimeMode,
39073109 interactionMode: "default",
......@@ -3953,11 +3155,6 @@ export default function ChatView(props: ChatViewProps) {
39533155 navigate,
39543156 resetLocalDispatch,
39553157 runtimeMode,
3956 selectedPromptEffort,
3957 selectedModelSelection,
3958 selectedProvider,
3959 selectedProviderModels,
3960 selectedModel,
39613158 environmentId,
39623159 ]);
39633160
......@@ -3996,42 +3193,6 @@ export default function ChatView(props: ChatViewProps) {
39963193 settings,
39973194 ],
39983195 );
3999 const setPromptFromTraits = useCallback(
4000 (nextPrompt: string) => {
4001 const currentPrompt = promptRef.current;
4002 if (nextPrompt === currentPrompt) {
4003 scheduleComposerFocus();
4004 return;
4005 }
4006 promptRef.current = nextPrompt;
4007 setPrompt(nextPrompt);
4008 const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
4009 setComposerCursor(nextCursor);
4010 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
4011 scheduleComposerFocus();
4012 },
4013 [scheduleComposerFocus, setPrompt],
4014 );
4015 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
4016 provider: selectedProvider,
4017 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
4018 ...(routeKind === "draft" && draftId ? { draftId } : {}),
4019 model: selectedModel,
4020 models: selectedProviderModels,
4021 modelOptions: composerModelOptions?.[selectedProvider],
4022 prompt,
4023 onPromptChange: setPromptFromTraits,
4024 });
4025 const providerTraitsPicker = renderProviderTraitsPicker({
4026 provider: selectedProvider,
4027 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
4028 ...(routeKind === "draft" && draftId ? { draftId } : {}),
4029 model: selectedModel,
4030 models: selectedProviderModels,
4031 modelOptions: composerModelOptions?.[selectedProvider],
4032 prompt,
4033 onPromptChange: setPromptFromTraits,
4034 });
40353196 const onEnvModeChange = useCallback(
40363197 (mode: DraftThreadEnvMode) => {
40373198 if (isLocalDraftThread) {
......@@ -4051,257 +3212,6 @@ export default function ChatView(props: ChatViewProps) {
40513212 ],
40523213 );
40533214
4054 const applyPromptReplacement = useCallback(
4055 (
4056 rangeStart: number,
4057 rangeEnd: number,
4058 replacement: string,
4059 options?: { expectedText?: string },
4060 ): boolean => {
4061 const currentText = promptRef.current;
4062 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
4063 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
4064 if (
4065 options?.expectedText !== undefined &&
4066 currentText.slice(safeStart, safeEnd) !== options.expectedText
4067 ) {
4068 return false;
4069 }
4070 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
4071 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
4072 promptRef.current = next.text;
4073 const activePendingQuestion = activePendingProgress?.activeQuestion;
4074 if (activePendingQuestion && activePendingUserInput) {
4075 setPendingUserInputAnswersByRequestId((existing) => ({
4076 ...existing,
4077 [activePendingUserInput.requestId]: {
4078 ...existing[activePendingUserInput.requestId],
4079 [activePendingQuestion.id]: setPendingUserInputCustomAnswer(
4080 existing[activePendingUserInput.requestId]?.[activePendingQuestion.id],
4081 next.text,
4082 ),
4083 },
4084 }));
4085 } else {
4086 setPrompt(next.text);
4087 }
4088 setComposerCursor(nextCursor);
4089 setComposerTrigger(
4090 detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)),
4091 );
4092 window.requestAnimationFrame(() => {
4093 composerEditorRef.current?.focusAt(nextCursor);
4094 });
4095 return true;
4096 },
4097 [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
4098 );
4099
4100 const readComposerSnapshot = useCallback((): {
4101 value: string;
4102 cursor: number;
4103 expandedCursor: number;
4104 terminalContextIds: string[];
4105 } => {
4106 const editorSnapshot = composerEditorRef.current?.readSnapshot();
4107 if (editorSnapshot) {
4108 return editorSnapshot;
4109 }
4110 return {
4111 value: promptRef.current,
4112 cursor: composerCursor,
4113 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
4114 terminalContextIds: composerTerminalContexts.map((context) => context.id),
4115 };
4116 }, [composerCursor, composerTerminalContexts]);
4117
4118 const resolveActiveComposerTrigger = useCallback((): {
4119 snapshot: { value: string; cursor: number; expandedCursor: number };
4120 trigger: ComposerTrigger | null;
4121 } => {
4122 const snapshot = readComposerSnapshot();
4123 return {
4124 snapshot,
4125 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
4126 };
4127 }, [readComposerSnapshot]);
4128
4129 const onSelectComposerItem = useCallback(
4130 (item: ComposerCommandItem) => {
4131 if (composerSelectLockRef.current) return;
4132 composerSelectLockRef.current = true;
4133 window.requestAnimationFrame(() => {
4134 composerSelectLockRef.current = false;
4135 });
4136 const { snapshot, trigger } = resolveActiveComposerTrigger();
4137 if (!trigger) return;
4138 if (item.type === "path") {
4139 const replacement = `@${item.path} `;
4140 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4141 snapshot.value,
4142 trigger.rangeEnd,
4143 replacement,
4144 );
4145 const applied = applyPromptReplacement(
4146 trigger.rangeStart,
4147 replacementRangeEnd,
4148 replacement,
4149 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4150 );
4151 if (applied) {
4152 setComposerHighlightedItemId(null);
4153 }
4154 return;
4155 }
4156 if (item.type === "slash-command") {
4157 if (item.command === "model") {
4158 const replacement = "/model ";
4159 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
4160 snapshot.value,
4161 trigger.rangeEnd,
4162 replacement,
4163 );
4164 const applied = applyPromptReplacement(
4165 trigger.rangeStart,
4166 replacementRangeEnd,
4167 replacement,
4168 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
4169 );
4170 if (applied) {
4171 setComposerHighlightedItemId(null);
4172 }
4173 return;
4174 }
4175 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
4176 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
4177 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4178 });
4179 if (applied) {
4180 setComposerHighlightedItemId(null);
4181 }
4182 return;
4183 }
4184 onProviderModelSelect(item.provider, item.model);
4185 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
4186 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4187 });
4188 if (applied) {
4189 setComposerHighlightedItemId(null);
4190 }
4191 },
4192 [
4193 applyPromptReplacement,
4194 handleInteractionModeChange,
4195 onProviderModelSelect,
4196 resolveActiveComposerTrigger,
4197 ],
4198 );
4199 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
4200 setComposerHighlightedItemId(itemId);
4201 }, []);
4202 const nudgeComposerMenuHighlight = useCallback(
4203 (key: "ArrowDown" | "ArrowUp") => {
4204 if (composerMenuItems.length === 0) {
4205 return;
4206 }
4207 const highlightedIndex = composerMenuItems.findIndex(
4208 (item) => item.id === composerHighlightedItemId,
4209 );
4210 const normalizedIndex =
4211 highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
4212 const offset = key === "ArrowDown" ? 1 : -1;
4213 const nextIndex =
4214 (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
4215 const nextItem = composerMenuItems[nextIndex];
4216 setComposerHighlightedItemId(nextItem?.id ?? null);
4217 },
4218 [composerHighlightedItemId, composerMenuItems],
4219 );
4220 const isComposerMenuLoading =
4221 composerTriggerKind === "path" &&
4222 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
4223 workspaceEntriesQuery.isLoading ||
4224 workspaceEntriesQuery.isFetching);
4225
4226 const onPromptChange = useCallback(
4227 (
4228 nextPrompt: string,
4229 nextCursor: number,
4230 expandedCursor: number,
4231 cursorAdjacentToMention: boolean,
4232 terminalContextIds: string[],
4233 ) => {
4234 if (activePendingProgress?.activeQuestion && activePendingUserInput) {
4235 onChangeActivePendingUserInputCustomAnswer(
4236 activePendingProgress.activeQuestion.id,
4237 nextPrompt,
4238 nextCursor,
4239 expandedCursor,
4240 cursorAdjacentToMention,
4241 );
4242 return;
4243 }
4244 promptRef.current = nextPrompt;
4245 setPrompt(nextPrompt);
4246 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
4247 setComposerDraftTerminalContexts(
4248 composerDraftTarget,
4249 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
4250 );
4251 }
4252 setComposerCursor(nextCursor);
4253 setComposerTrigger(
4254 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
4255 );
4256 },
4257 [
4258 activePendingProgress?.activeQuestion,
4259 activePendingUserInput,
4260 composerTerminalContexts,
4261 onChangeActivePendingUserInputCustomAnswer,
4262 setPrompt,
4263 composerDraftTarget,
4264 setComposerDraftTerminalContexts,
4265 ],
4266 );
4267
4268 const onComposerCommandKey = (
4269 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
4270 event: KeyboardEvent,
4271 ) => {
4272 if (key === "Tab" && event.shiftKey) {
4273 toggleInteractionMode();
4274 return true;
4275 }
4276
4277 const { trigger } = resolveActiveComposerTrigger();
4278 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
4279
4280 if (menuIsActive) {
4281 const currentItems = composerMenuItemsRef.current;
4282 if (key === "ArrowDown" && currentItems.length > 0) {
4283 nudgeComposerMenuHighlight("ArrowDown");
4284 return true;
4285 }
4286 if (key === "ArrowUp" && currentItems.length > 0) {
4287 nudgeComposerMenuHighlight("ArrowUp");
4288 return true;
4289 }
4290 if (key === "Tab" || key === "Enter") {
4291 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
4292 if (selectedItem) {
4293 onSelectComposerItem(selectedItem);
4294 return true;
4295 }
4296 }
4297 }
4298
4299 if (key === "Enter" && !event.shiftKey) {
4300 void onSend();
4301 return true;
4302 }
4303 return false;
4304 };
43053215 const onToggleWorkGroup = useCallback((groupId: string) => {
43063216 setExpandedWorkGroups((existing) => ({
43073217 ...existing,
......@@ -4311,7 +3221,6 @@ export default function ChatView(props: ChatViewProps) {
43113221 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
43123222 setExpandedImage(preview);
43133223 }, []);
4314 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
43153224 const onOpenTurnDiff = useCallback(
43163225 (turnId: TurnId, filePath?: string) => {
43173226 if (!isServerThread) {
......@@ -4333,13 +3242,16 @@ export default function ChatView(props: ChatViewProps) {
43333242 },
43343243 [environmentId, isServerThread, navigate, threadId],
43353244 );
4336 const onRevertUserMessage = (messageId: MessageId) => {
4337 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
4338 if (typeof targetTurnCount !== "number") {
4339 return;
4340 }
4341 void onRevertToTurnCount(targetTurnCount);
4342 };
3245 const onRevertUserMessage = useCallback(
3246 (messageId: MessageId) => {
3247 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
3248 if (typeof targetTurnCount !== "number") {
3249 return;
3250 }
3251 void onRevertToTurnCount(targetTurnCount);
3252 },
3253 [onRevertToTurnCount, revertTurnCountByUserMessageId],
3254 );
43433255
43443256 // Empty state: no active thread
43453257 if (!activeThread) {
......@@ -4374,9 +3286,7 @@ export default function ChatView(props: ChatViewProps) {
43743286 diffToggleShortcutLabel={diffPanelShortcutLabel}
43753287 gitCwd={gitCwd}
43763288 diffOpen={diffOpen}
4377 onRunProjectScript={(script) => {
4378 void runProjectScript(script);
4379 }}
3289 onRunProjectScript={runProjectScript}
43803290 onAddProjectScript={saveProjectScript}
43813291 onUpdateProjectScript={updateProjectScript}
43823292 onDeleteProjectScript={deleteProjectScript}
......@@ -4456,392 +3366,71 @@ export default function ChatView(props: ChatViewProps) {
44563366
44573367 {/* Input bar */}
44583368 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
4459 <form
4460 ref={composerFormRef}
4461 onSubmit={onSend}
4462 className="mx-auto w-full min-w-0 max-w-208"
4463 data-chat-composer-form="true"
4464 >
4465 <div
4466 className={cn(
4467 "group rounded-[22px] p-px transition-colors duration-200",
4468 composerProviderState.composerFrameClassName,
4469 )}
4470 onDragEnter={onComposerDragEnter}
4471 onDragOver={onComposerDragOver}
4472 onDragLeave={onComposerDragLeave}
4473 onDrop={onComposerDrop}
4474 >
4475 <div
4476 className={cn(
4477 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
4478 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
4479 composerProviderState.composerSurfaceClassName,
4480 )}
4481 >
4482 {activePendingApproval ? (
4483 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
4484 <ComposerPendingApprovalPanel
4485 approval={activePendingApproval}
4486 pendingCount={pendingApprovals.length}
4487 />
4488 </div>
4489 ) : pendingUserInputs.length > 0 ? (
4490 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
4491 <ComposerPendingUserInputPanel
4492 pendingUserInputs={pendingUserInputs}
4493 respondingRequestIds={respondingRequestIds}
4494 answers={activePendingDraftAnswers}
4495 questionIndex={activePendingQuestionIndex}
4496 onToggleOption={onSelectActivePendingUserInputOption}
4497 onAdvance={onAdvanceActivePendingUserInput}
4498 />
4499 </div>
4500 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
4501 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
4502 <ComposerPlanFollowUpBanner
4503 key={activeProposedPlan.id}
4504 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
4505 />
4506 </div>
4507 ) : null}
4508 <div
4509 className={cn(
4510 "relative px-3 pb-2 sm:px-4",
4511 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
4512 )}
4513 >
4514 {composerMenuOpen && !isComposerApprovalState && (
4515 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
4516 <ComposerCommandMenu
4517 items={composerMenuItems}
4518 resolvedTheme={resolvedTheme}
4519 isLoading={isComposerMenuLoading}
4520 triggerKind={composerTriggerKind}
4521 activeItemId={activeComposerMenuItem?.id ?? null}
4522 onHighlightedItemChange={onComposerMenuItemHighlighted}
4523 onSelect={onSelectComposerItem}
4524 />
4525 </div>
4526 )}
4527
4528 {!isComposerApprovalState &&
4529 pendingUserInputs.length === 0 &&
4530 composerImages.length > 0 && (
4531 <div className="mb-3 flex flex-wrap gap-2">
4532 {composerImages.map((image) => (
4533 <div
4534 key={image.id}
4535 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
4536 >
4537 {image.previewUrl ? (
4538 <button
4539 type="button"
4540 className="h-full w-full cursor-zoom-in"
4541 aria-label={`Preview ${image.name}`}
4542 onClick={() => {
4543 const preview = buildExpandedImagePreview(
4544 composerImages,
4545 image.id,
4546 );
4547 if (!preview) return;
4548 setExpandedImage(preview);
4549 }}
4550 >
4551 <img
4552 src={image.previewUrl}
4553 alt={image.name}
4554 className="h-full w-full object-cover"
4555 />
4556 </button>
4557 ) : (
4558 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
4559 {image.name}
4560 </div>
4561 )}
4562 {nonPersistedComposerImageIdSet.has(image.id) && (
4563 <Tooltip>
4564 <TooltipTrigger
4565 render={
4566 <span
4567 role="img"
4568 aria-label="Draft attachment may not persist"
4569 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
4570 >
4571 <CircleAlertIcon className="size-3" />
4572 </span>
4573 }
4574 />
4575 <TooltipPopup
4576 side="top"
4577 className="max-w-64 whitespace-normal leading-tight"
4578 >
4579 Draft attachment could not be saved locally and may be lost on
4580 navigation.
4581 </TooltipPopup>
4582 </Tooltip>
4583 )}
4584 <Button
4585 variant="ghost"
4586 size="icon-xs"
4587 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
4588 onClick={() => removeComposerImage(image.id)}
4589 aria-label={`Remove ${image.name}`}
4590 >
4591 <XIcon />
4592 </Button>
4593 </div>
4594 ))}
4595 </div>
4596 )}
4597 <ComposerPromptEditor
4598 ref={composerEditorRef}
4599 value={
4600 isComposerApprovalState
4601 ? ""
4602 : activePendingProgress
4603 ? activePendingProgress.customAnswer
4604 : prompt
4605 }
4606 cursor={composerCursor}
4607 terminalContexts={
4608 !isComposerApprovalState && pendingUserInputs.length === 0
4609 ? composerTerminalContexts
4610 : []
4611 }
4612 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
4613 onChange={onPromptChange}
4614 onCommandKeyDown={onComposerCommandKey}
4615 onPaste={onComposerPaste}
4616 placeholder={
4617 isComposerApprovalState
4618 ? (activePendingApproval?.detail ??
4619 "Resolve this approval request to continue")
4620 : activePendingProgress
4621 ? "Type your own answer, or leave this blank to use the selected option"
4622 : showPlanFollowUpPrompt && activeProposedPlan
4623 ? "Add feedback to refine the plan, or leave this blank to implement it"
4624 : phase === "disconnected"
4625 ? "Ask for follow-up changes or attach images"
4626 : "Ask anything, @tag files/folders, or use / to show available commands"
4627 }
4628 disabled={isConnecting || isComposerApprovalState}
4629 />
4630 </div>
4631
4632 {/* Bottom toolbar */}
4633 {activePendingApproval ? (
4634 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
4635 <ComposerPendingApprovalActions
4636 requestId={activePendingApproval.requestId}
4637 isResponding={respondingRequestIds.includes(
4638 activePendingApproval.requestId,
4639 )}
4640 onRespondToApproval={onRespondToApproval}
4641 />
4642 </div>
4643 ) : (
4644 <div
4645 ref={composerFooterRef}
4646 data-chat-composer-footer="true"
4647 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
4648 className={cn(
4649 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3",
4650 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
4651 )}
4652 >
4653 <div
4654 ref={composerFooterLeadingRef}
4655 className={cn(
4656 "flex min-w-0 flex-1 items-center",
4657 isComposerFooterCompact
4658 ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
4659 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
4660 )}
4661 >
4662 {/* Provider/model picker */}
4663 <ProviderModelPicker
4664 compact={isComposerFooterCompact}
4665 provider={selectedProvider}
4666 model={selectedModelForPickerWithCustomFallback}
4667 lockedProvider={lockedProvider}
4668 providers={providerStatuses}
4669 modelOptionsByProvider={modelOptionsByProvider}
4670 {...(composerProviderState.modelPickerIconClassName
4671 ? {
4672 activeProviderIconClassName:
4673 composerProviderState.modelPickerIconClassName,
4674 }
4675 : {})}
4676 onProviderModelChange={onProviderModelSelect}
4677 />
4678
4679 {isComposerFooterCompact ? (
4680 <CompactComposerControlsMenu
4681 activePlan={Boolean(
4682 activePlan || sidebarProposedPlan || planSidebarOpen,
4683 )}
4684 interactionMode={interactionMode}
4685 planSidebarOpen={planSidebarOpen}
4686 runtimeMode={runtimeMode}
4687 traitsMenuContent={providerTraitsMenuContent}
4688 onToggleInteractionMode={toggleInteractionMode}
4689 onTogglePlanSidebar={togglePlanSidebar}
4690 onRuntimeModeChange={handleRuntimeModeChange}
4691 />
4692 ) : (
4693 <>
4694 {providerTraitsPicker ? (
4695 <>
4696 <Separator
4697 orientation="vertical"
4698 className="mx-0.5 hidden h-4 sm:block"
4699 />
4700 {providerTraitsPicker}
4701 </>
4702 ) : null}
4703
4704 <Separator
4705 orientation="vertical"
4706 className="mx-0.5 hidden h-4 sm:block"
4707 />
4708
4709 <Button
4710 variant="ghost"
4711 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
4712 size="sm"
4713 type="button"
4714 onClick={toggleInteractionMode}
4715 title={
4716 interactionMode === "plan"
4717 ? "Plan mode — click to return to normal build mode"
4718 : "Default mode — click to enter plan mode"
4719 }
4720 >
4721 <BotIcon />
4722 <span className="sr-only sm:not-sr-only">
4723 {interactionMode === "plan" ? "Plan" : "Build"}
4724 </span>
4725 </Button>
4726
4727 <Separator
4728 orientation="vertical"
4729 className="mx-0.5 hidden h-4 sm:block"
4730 />
4731
4732 <Select
4733 value={runtimeMode}
4734 onValueChange={(value) => handleRuntimeModeChange(value!)}
4735 >
4736 <SelectTrigger
4737 variant="ghost"
4738 size="sm"
4739 aria-label="Runtime mode"
4740 title={runtimeModeOption.description}
4741 >
4742 <RuntimeModeIcon className="size-4" />
4743 <SelectValue>{runtimeModeOption.label}</SelectValue>
4744 </SelectTrigger>
4745 <SelectPopup alignItemWithTrigger={false}>
4746 {runtimeModeOptions.map((mode) => {
4747 const option = runtimeModeConfig[mode];
4748 const OptionIcon = option.icon;
4749 return (
4750 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
4751 <div className="grid min-w-0 gap-0.5">
4752 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
4753 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
4754 {option.label}
4755 </span>
4756 <span className="text-muted-foreground text-xs leading-4">
4757 {option.description}
4758 </span>
4759 </div>
4760 </SelectItem>
4761 );
4762 })}
4763 </SelectPopup>
4764 </Select>
4765
4766 {activePlan || sidebarProposedPlan || planSidebarOpen ? (
4767 <>
4768 <Separator
4769 orientation="vertical"
4770 className="mx-0.5 hidden h-4 sm:block"
4771 />
4772 <Button
4773 variant="ghost"
4774 className={cn(
4775 "shrink-0 whitespace-nowrap px-2 sm:px-3",
4776 planSidebarOpen
4777 ? "text-blue-400 hover:text-blue-300"
4778 : "text-muted-foreground/70 hover:text-foreground/80",
4779 )}
4780 size="sm"
4781 type="button"
4782 onClick={togglePlanSidebar}
4783 title={
4784 planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"
4785 }
4786 >
4787 <ListTodoIcon />
4788 <span className="sr-only sm:not-sr-only">Plan</span>
4789 </Button>
4790 </>
4791 ) : null}
4792 </>
4793 )}
4794 </div>
4795
4796 {/* Right side: send / stop button */}
4797 <div
4798 ref={composerFooterActionsRef}
4799 data-chat-composer-actions="right"
4800 data-chat-composer-primary-actions-compact={
4801 isComposerPrimaryActionsCompact ? "true" : "false"
4802 }
4803 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
4804 >
4805 {activeContextWindow ? (
4806 <ContextWindowMeter usage={activeContextWindow} />
4807 ) : null}
4808 {isPreparingWorktree ? (
4809 <span className="text-muted-foreground/70 text-xs">
4810 Preparing worktree...
4811 </span>
4812 ) : null}
4813 <ComposerPrimaryActions
4814 compact={isComposerPrimaryActionsCompact}
4815 pendingAction={
4816 activePendingProgress
4817 ? {
4818 questionIndex: activePendingProgress.questionIndex,
4819 isLastQuestion: activePendingProgress.isLastQuestion,
4820 canAdvance: activePendingProgress.canAdvance,
4821 isResponding: activePendingIsResponding,
4822 isComplete: Boolean(activePendingResolvedAnswers),
4823 }
4824 : null
4825 }
4826 isRunning={phase === "running"}
4827 showPlanFollowUpPrompt={
4828 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
4829 }
4830 promptHasText={prompt.trim().length > 0}
4831 isSendBusy={isSendBusy}
4832 isConnecting={isConnecting}
4833 isPreparingWorktree={isPreparingWorktree}
4834 hasSendableContent={composerSendState.hasSendableContent}
4835 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
4836 onInterrupt={() => void onInterrupt()}
4837 onImplementPlanInNewThread={() => void onImplementPlanInNewThread()}
4838 />
4839 </div>
4840 </div>
4841 )}
4842 </div>
4843 </div>
4844 </form>
3369 <ChatComposer
3370 ref={composerRef}
3371 composerDraftTarget={composerDraftTarget}
3372 environmentId={environmentId}
3373 routeKind={routeKind}
3374 routeThreadRef={routeThreadRef}
3375 draftId={draftId}
3376 activeThreadId={activeThreadId}
3377 activeThreadEnvironmentId={activeThread?.environmentId}
3378 activeThread={activeThread}
3379 isServerThread={isServerThread}
3380 isLocalDraftThread={isLocalDraftThread}
3381 phase={phase}
3382 isConnecting={isConnecting}
3383 isSendBusy={isSendBusy}
3384 isPreparingWorktree={isPreparingWorktree}
3385 activePendingApproval={activePendingApproval}
3386 pendingApprovals={pendingApprovals}
3387 pendingUserInputs={pendingUserInputs}
3388 activePendingProgress={activePendingProgress}
3389 activePendingResolvedAnswers={activePendingResolvedAnswers}
3390 activePendingIsResponding={activePendingIsResponding}
3391 activePendingDraftAnswers={activePendingDraftAnswers}
3392 activePendingQuestionIndex={activePendingQuestionIndex}
3393 respondingRequestIds={respondingRequestIds}
3394 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
3395 activeProposedPlan={activeProposedPlan}
3396 activePlan={activePlan as { turnId?: TurnId } | null}
3397 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
3398 planSidebarOpen={planSidebarOpen}
3399 runtimeMode={runtimeMode}
3400 interactionMode={interactionMode}
3401 lockedProvider={lockedProvider}
3402 providerStatuses={providerStatuses as ServerProvider[]}
3403 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
3404 activeThreadModelSelection={activeThread?.modelSelection}
3405 activeThreadActivities={activeThread?.activities}
3406 resolvedTheme={resolvedTheme}
3407 settings={settings}
3408 gitCwd={gitCwd}
3409 promptRef={promptRef}
3410 composerImagesRef={composerImagesRef}
3411 composerTerminalContextsRef={composerTerminalContextsRef}
3412 shouldAutoScrollRef={shouldAutoScrollRef}
3413 scheduleStickToBottom={scheduleStickToBottom}
3414 onSend={onSend}
3415 onInterrupt={onInterrupt}
3416 onImplementPlanInNewThread={onImplementPlanInNewThread}
3417 onRespondToApproval={onRespondToApproval}
3418 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
3419 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
3420 onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion}
3421 onChangeActivePendingUserInputCustomAnswer={
3422 onChangeActivePendingUserInputCustomAnswer
3423 }
3424 onProviderModelSelect={onProviderModelSelect}
3425 toggleInteractionMode={toggleInteractionMode}
3426 handleRuntimeModeChange={handleRuntimeModeChange}
3427 handleInteractionModeChange={handleInteractionModeChange}
3428 togglePlanSidebar={togglePlanSidebar}
3429 focusComposer={focusComposer}
3430 scheduleComposerFocus={scheduleComposerFocus}
3431 setThreadError={setThreadError}
3432 onExpandImage={onExpandTimelineImage}
3433 />
48453434 </div>
48463435
48473436 {isGitRepo && (
......@@ -4921,72 +3510,8 @@ export default function ChatView(props: ChatViewProps) {
49213510 />
49223511 ))}
49233512
4924 {expandedImage && expandedImageItem && (
4925 <div
4926 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
4927 role="dialog"
4928 aria-modal="true"
4929 aria-label="Expanded image preview"
4930 >
4931 <button
4932 type="button"
4933 className="absolute inset-0 z-0 cursor-zoom-out"
4934 aria-label="Close image preview"
4935 onClick={closeExpandedImage}
4936 />
4937 {expandedImage.images.length > 1 && (
4938 <Button
4939 type="button"
4940 size="icon"
4941 variant="ghost"
4942 className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
4943 aria-label="Previous image"
4944 onClick={() => {
4945 navigateExpandedImage(-1);
4946 }}
4947 >
4948 <ChevronLeftIcon className="size-5" />
4949 </Button>
4950 )}
4951 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
4952 <Button
4953 type="button"
4954 size="icon-xs"
4955 variant="ghost"
4956 className="absolute right-2 top-2"
4957 onClick={closeExpandedImage}
4958 aria-label="Close image preview"
4959 >
4960 <XIcon />
4961 </Button>
4962 <img
4963 src={expandedImageItem.src}
4964 alt={expandedImageItem.name}
4965 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
4966 draggable={false}
4967 />
4968 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
4969 {expandedImageItem.name}
4970 {expandedImage.images.length > 1
4971 ? ` (${expandedImage.index + 1}/${expandedImage.images.length})`
4972 : ""}
4973 </p>
4974 </div>
4975 {expandedImage.images.length > 1 && (
4976 <Button
4977 type="button"
4978 size="icon"
4979 variant="ghost"
4980 className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
4981 aria-label="Next image"
4982 onClick={() => {
4983 navigateExpandedImage(1);
4984 }}
4985 >
4986 <ChevronRightIcon className="size-5" />
4987 </Button>
4988 )}
4989 </div>
3513 {expandedImage && (
3514 <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} />
49903515 )}
49913516 </div>
49923517 );
apps/web/src/components/Sidebar.tsx+55-23
......@@ -79,7 +79,7 @@ import {
7979import { useGitStatus } from "../lib/gitStatusState";
8080import { readLocalApi } from "../localApi";
8181import { useComposerDraftStore } from "../composerDraftStore";
82import { useHandleNewThread } from "../hooks/useHandleNewThread";
82import { useNewThreadHandler } from "../hooks/useHandleNewThread";
8383
8484import { useThreadActions } from "../hooks/useThreadActions";
8585import {
......@@ -932,7 +932,7 @@ interface SidebarProjectItemProps {
932932 isThreadListExpanded: boolean;
933933 activeRouteThreadKey: string | null;
934934 newThreadShortcutLabel: string | null;
935 handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"];
935 handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"];
936936 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
937937 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
938938 threadJumpLabelByKey: ReadonlyMap<string, string>;
......@@ -2033,7 +2033,7 @@ interface SidebarProjectsContentProps {
20332033 handleProjectDragStart: (event: DragStartEvent) => void;
20342034 handleProjectDragEnd: (event: DragEndEvent) => void;
20352035 handleProjectDragCancel: (event: DragCancelEvent) => void;
2036 handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"];
2036 handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"];
20372037 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
20382038 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
20392039 sortedProjects: readonly SidebarProjectSnapshot[];
......@@ -2338,7 +2338,7 @@ export default function Sidebar() {
23382338 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);
23392339 const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode);
23402340 const { updateSettings } = useUpdateSettings();
2341 const { handleNewThread } = useHandleNewThread();
2341 const { handleNewThread } = useNewThreadHandler();
23422342 const { archiveThread, deleteThread } = useThreadActions();
23432343 const routeThreadRef = useParams({
23442344 strict: false,
......@@ -2827,31 +2827,62 @@ export default function Sidebar() {
28272827 );
28282828 const [threadJumpLabelByKey, setThreadJumpLabelByKey] =
28292829 useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS);
2830 const threadJumpLabelsRef = useRef<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS);
2831 threadJumpLabelsRef.current = threadJumpLabelByKey;
2832 const showThreadJumpHintsRef = useRef(showThreadJumpHints);
2833 showThreadJumpHintsRef.current = showThreadJumpHints;
28302834 const visibleThreadJumpLabelByKey = showThreadJumpHints
28312835 ? threadJumpLabelByKey
28322836 : EMPTY_THREAD_JUMP_LABELS;
28332837 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
28342838
28352839 useEffect(() => {
2840 const clearThreadJumpHints = () => {
2841 setThreadJumpLabelByKey((current) =>
2842 current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS,
2843 );
2844 updateThreadJumpHintsVisibility(false);
2845 };
2846 const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) =>
2847 !event.metaKey &&
2848 !event.ctrlKey &&
2849 !event.altKey &&
2850 !event.shiftKey &&
2851 event.key !== "Meta" &&
2852 event.key !== "Control" &&
2853 event.key !== "Alt" &&
2854 event.key !== "Shift" &&
2855 !showThreadJumpHintsRef.current &&
2856 threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS;
2857
28362858 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
2859 if (shouldIgnoreThreadJumpHintUpdate(event)) {
2860 return;
2861 }
28372862 const shortcutContext = getCurrentSidebarShortcutContext();
28382863 const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
28392864 platform,
28402865 context: shortcutContext,
28412866 });
2842 setThreadJumpLabelByKey((current) => {
2843 if (!shouldShowHints) {
2844 return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2867 if (!shouldShowHints) {
2868 if (
2869 showThreadJumpHintsRef.current ||
2870 threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS
2871 ) {
2872 clearThreadJumpHints();
28452873 }
2846 const nextLabelMap = buildThreadJumpLabelMap({
2847 keybindings,
2848 platform,
2849 terminalOpen: shortcutContext.terminalOpen,
2850 threadJumpCommandByKey,
2874 } else {
2875 setThreadJumpLabelByKey((current) => {
2876 const nextLabelMap = buildThreadJumpLabelMap({
2877 keybindings,
2878 platform,
2879 terminalOpen: shortcutContext.terminalOpen,
2880 threadJumpCommandByKey,
2881 });
2882 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
28512883 });
2852 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2853 });
2854 updateThreadJumpHintsVisibility(shouldShowHints);
2884 updateThreadJumpHintsVisibility(true);
2885 }
28552886
28562887 if (event.defaultPrevented || event.repeat) {
28572888 return;
......@@ -2902,15 +2933,19 @@ export default function Sidebar() {
29022933 };
29032934
29042935 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
2936 if (shouldIgnoreThreadJumpHintUpdate(event)) {
2937 return;
2938 }
29052939 const shortcutContext = getCurrentSidebarShortcutContext();
29062940 const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
29072941 platform,
29082942 context: shortcutContext,
29092943 });
2944 if (!shouldShowHints) {
2945 clearThreadJumpHints();
2946 return;
2947 }
29102948 setThreadJumpLabelByKey((current) => {
2911 if (!shouldShowHints) {
2912 return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2913 }
29142949 const nextLabelMap = buildThreadJumpLabelMap({
29152950 keybindings,
29162951 platform,
......@@ -2919,14 +2954,11 @@ export default function Sidebar() {
29192954 });
29202955 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
29212956 });
2922 updateThreadJumpHintsVisibility(shouldShowHints);
2957 updateThreadJumpHintsVisibility(true);
29232958 };
29242959
29252960 const onWindowBlur = () => {
2926 setThreadJumpLabelByKey((current) =>
2927 current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS,
2928 );
2929 updateThreadJumpHintsVisibility(false);
2961 clearThreadJumpHints();
29302962 };
29312963
29322964 window.addEventListener("keydown", onWindowKeyDown);
apps/web/src/components/chat/ChatComposer.tsx created+1899
......@@ -0,0 +1,1899 @@
1import type {
2 ApprovalRequestId,
3 EnvironmentId,
4 ModelSelection,
5 ProjectEntry,
6 ProviderApprovalDecision,
7 ProviderInteractionMode,
8 ProviderKind,
9 RuntimeMode,
10 ScopedThreadRef,
11 ServerProvider,
12 ThreadId,
13 TurnId,
14} from "@t3tools/contracts";
15import {
16 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
17 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
18} from "@t3tools/contracts";
19import { normalizeModelSlug } from "@t3tools/shared/model";
20import {
21 forwardRef,
22 memo,
23 useCallback,
24 useEffect,
25 useImperativeHandle,
26 useLayoutEffect,
27 useMemo,
28 useRef,
29 useState,
30} from "react";
31import { useQuery } from "@tanstack/react-query";
32import { useDebouncedValue } from "@tanstack/react-pacer";
33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import {
35 clampCollapsedComposerCursor,
36 type ComposerTrigger,
37 collapseExpandedComposerCursor,
38 detectComposerTrigger,
39 expandCollapsedComposerCursor,
40 replaceTextRange,
41} from "../../composer-logic";
42import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
43import {
44 type ComposerImageAttachment,
45 type DraftId,
46 type PersistedComposerImageAttachment,
47 useComposerDraftStore,
48 useComposerThreadDraft,
49 useEffectiveComposerModelState,
50} from "../../composerDraftStore";
51import {
52 type TerminalContextDraft,
53 type TerminalContextSelection,
54 insertInlineTerminalContextPlaceholder,
55 removeInlineTerminalContextPlaceholder,
56} from "../../lib/terminalContext";
57import {
58 resolveComposerFooterContentWidth,
59 shouldForceCompactComposerFooterForFit,
60 shouldUseCompactComposerPrimaryActions,
61 shouldUseCompactComposerFooter,
62} from "../composerFooterLayout";
63import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
64import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker";
65import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
66import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
67import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
68import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
69import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
70import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
71import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
72import {
73 getComposerProviderState,
74 renderProviderTraitsMenuContent,
75 renderProviderTraitsPicker,
76} from "./composerProviderRegistry";
77import { ContextWindowMeter } from "./ContextWindowMeter";
78import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
79import { basenameOfPath } from "../../vscode-icons";
80import { cn, randomUUID } from "~/lib/utils";
81import { Separator } from "../ui/separator";
82import { Button } from "../ui/button";
83import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
84import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
85import { toastManager } from "../ui/toast";
86import {
87 BotIcon,
88 CircleAlertIcon,
89 ListTodoIcon,
90 type LucideIcon,
91 LockIcon,
92 LockOpenIcon,
93 PenLineIcon,
94 XIcon,
95} from "lucide-react";
96import { proposedPlanTitle } from "../../proposedPlan";
97import { resolveSelectableProvider, getProviderModels } from "../../providerModels";
98import type { UnifiedSettings } from "@t3tools/contracts/settings";
99import type { SessionPhase, Thread } from "../../types";
100import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
101import type { PendingApproval, PendingUserInput } from "../../session-logic";
102import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
103
104const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
105
106const runtimeModeConfig: Record<
107 RuntimeMode,
108 { label: string; description: string; icon: LucideIcon }
109> = {
110 "approval-required": {
111 label: "Supervised",
112 description: "Ask before commands and file changes.",
113 icon: LockIcon,
114 },
115 "auto-accept-edits": {
116 label: "Auto-accept edits",
117 description: "Auto-approve edits, ask before other actions.",
118 icon: PenLineIcon,
119 },
120 "full-access": {
121 label: "Full access",
122 description: "Allow commands and edits without prompts.",
123 icon: LockOpenIcon,
124 },
125};
126
127const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
128const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
129const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
130
131const extendReplacementRangeForTrailingSpace = (
132 text: string,
133 rangeEnd: number,
134 replacement: string,
135): number => {
136 if (!replacement.endsWith(" ")) {
137 return rangeEnd;
138 }
139 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
140};
141
142const syncTerminalContextsByIds = (
143 contexts: ReadonlyArray<TerminalContextDraft>,
144 ids: ReadonlyArray<string>,
145): TerminalContextDraft[] => {
146 const contextsById = new Map(contexts.map((context) => [context.id, context]));
147 return ids.flatMap((id) => {
148 const context = contextsById.get(id);
149 return context ? [context] : [];
150 });
151};
152
153const terminalContextIdListsEqual = (
154 contexts: ReadonlyArray<TerminalContextDraft>,
155 ids: ReadonlyArray<string>,
156): boolean =>
157 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
158
159const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
160 interactionMode: ProviderInteractionMode;
161 runtimeMode: RuntimeMode;
162 showPlanToggle: boolean;
163 planSidebarOpen: boolean;
164 onToggleInteractionMode: () => void;
165 onRuntimeModeChange: (mode: RuntimeMode) => void;
166 onTogglePlanSidebar: () => void;
167}) {
168 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
169 const RuntimeModeIcon = runtimeModeOption.icon;
170
171 return (
172 <>
173 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
174
175 <Button
176 variant="ghost"
177 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
178 size="sm"
179 type="button"
180 onClick={props.onToggleInteractionMode}
181 title={
182 props.interactionMode === "plan"
183 ? "Plan mode — click to return to normal build mode"
184 : "Default mode — click to enter plan mode"
185 }
186 >
187 <BotIcon />
188 <span className="sr-only sm:not-sr-only">
189 {props.interactionMode === "plan" ? "Plan" : "Build"}
190 </span>
191 </Button>
192
193 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
194
195 <Select
196 value={props.runtimeMode}
197 onValueChange={(value) => props.onRuntimeModeChange(value!)}
198 >
199 <SelectTrigger
200 variant="ghost"
201 size="sm"
202 aria-label="Runtime mode"
203 title={runtimeModeOption.description}
204 >
205 <RuntimeModeIcon className="size-4" />
206 <SelectValue>{runtimeModeOption.label}</SelectValue>
207 </SelectTrigger>
208 <SelectPopup alignItemWithTrigger={false}>
209 {runtimeModeOptions.map((mode) => {
210 const option = runtimeModeConfig[mode];
211 const OptionIcon = option.icon;
212 return (
213 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
214 <div className="grid min-w-0 gap-0.5">
215 <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
216 <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
217 {option.label}
218 </span>
219 <span className="text-muted-foreground text-xs leading-4">
220 {option.description}
221 </span>
222 </div>
223 </SelectItem>
224 );
225 })}
226 </SelectPopup>
227 </Select>
228
229 {props.showPlanToggle ? (
230 <>
231 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
232 <Button
233 variant="ghost"
234 className={cn(
235 "shrink-0 whitespace-nowrap px-2 sm:px-3",
236 props.planSidebarOpen
237 ? "text-blue-400 hover:text-blue-300"
238 : "text-muted-foreground/70 hover:text-foreground/80",
239 )}
240 size="sm"
241 type="button"
242 onClick={props.onTogglePlanSidebar}
243 title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
244 >
245 <ListTodoIcon />
246 <span className="sr-only sm:not-sr-only">Plan</span>
247 </Button>
248 </>
249 ) : null}
250 </>
251 );
252});
253
254const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
255 compact: boolean;
256 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
257 isPreparingWorktree: boolean;
258 pendingAction: {
259 questionIndex: number;
260 isLastQuestion: boolean;
261 canAdvance: boolean;
262 isResponding: boolean;
263 isComplete: boolean;
264 } | null;
265 isRunning: boolean;
266 showPlanFollowUpPrompt: boolean;
267 promptHasText: boolean;
268 isSendBusy: boolean;
269 isConnecting: boolean;
270 hasSendableContent: boolean;
271 onPreviousPendingQuestion: () => void;
272 onInterrupt: () => void;
273 onImplementPlanInNewThread: () => void;
274}) {
275 return (
276 <>
277 {props.activeContextWindow ? <ContextWindowMeter usage={props.activeContextWindow} /> : null}
278 {props.isPreparingWorktree ? (
279 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
280 ) : null}
281 <ComposerPrimaryActions
282 compact={props.compact}
283 pendingAction={props.pendingAction}
284 isRunning={props.isRunning}
285 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
286 promptHasText={props.promptHasText}
287 isSendBusy={props.isSendBusy}
288 isConnecting={props.isConnecting}
289 isPreparingWorktree={props.isPreparingWorktree}
290 hasSendableContent={props.hasSendableContent}
291 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
292 onInterrupt={props.onInterrupt}
293 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
294 />
295 </>
296 );
297});
298
299// --------------------------------------------------------------------------
300// Handle exposed to ChatView
301// --------------------------------------------------------------------------
302
303export interface ChatComposerHandle {
304 focusAtEnd: () => void;
305 focusAt: (cursor: number) => void;
306 readSnapshot: () => {
307 value: string;
308 cursor: number;
309 expandedCursor: number;
310 terminalContextIds: string[];
311 };
312 /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */
313 resetCursorState: (options?: {
314 cursor?: number;
315 prompt?: string;
316 detectTrigger?: boolean;
317 }) => void;
318 /** Insert a terminal context from the terminal drawer. */
319 addTerminalContext: (selection: TerminalContextSelection) => void;
320 /** Get the current prompt/effort/model state for use in send. */
321 getSendContext: () => {
322 prompt: string;
323 images: ComposerImageAttachment[];
324 terminalContexts: TerminalContextDraft[];
325 selectedPromptEffort: string | null;
326 selectedModelOptionsForDispatch: unknown;
327 selectedModelSelection: ModelSelection;
328 selectedProvider: ProviderKind;
329 selectedModel: string;
330 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
331 };
332}
333
334// --------------------------------------------------------------------------
335// Props
336// --------------------------------------------------------------------------
337
338export interface ChatComposerProps {
339 composerDraftTarget: ScopedThreadRef | DraftId;
340 environmentId: EnvironmentId;
341 routeKind: "server" | "draft";
342 routeThreadRef: ScopedThreadRef;
343 draftId: DraftId | null;
344
345 // Thread context
346 activeThreadId: ThreadId | null;
347 activeThreadEnvironmentId: EnvironmentId | undefined;
348 activeThread: Thread | undefined;
349 isServerThread: boolean;
350 isLocalDraftThread: boolean;
351
352 // Session phase
353 phase: SessionPhase;
354 isConnecting: boolean;
355 isSendBusy: boolean;
356 isPreparingWorktree: boolean;
357
358 // Pending approvals / inputs
359 activePendingApproval: PendingApproval | null;
360 pendingApprovals: PendingApproval[];
361 pendingUserInputs: PendingUserInput[];
362 activePendingProgress: {
363 questionIndex: number;
364 isLastQuestion: boolean;
365 canAdvance: boolean;
366 customAnswer: string;
367 activeQuestion: { id: string } | null;
368 } | null;
369 activePendingResolvedAnswers: Record<string, unknown> | null;
370 activePendingIsResponding: boolean;
371 activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>;
372 activePendingQuestionIndex: number;
373 respondingRequestIds: ApprovalRequestId[];
374
375 // Plan
376 showPlanFollowUpPrompt: boolean;
377 activeProposedPlan: Thread["proposedPlans"][number] | null;
378 activePlan: { turnId?: TurnId } | null;
379 sidebarProposedPlan: { turnId?: TurnId } | null;
380 planSidebarOpen: boolean;
381
382 // Mode
383 runtimeMode: RuntimeMode;
384 interactionMode: ProviderInteractionMode;
385
386 // Provider / model
387 lockedProvider: ProviderKind | null;
388 providerStatuses: ServerProvider[];
389 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
390 activeThreadModelSelection: ModelSelection | null | undefined;
391
392 // Context window
393 activeThreadActivities: Thread["activities"] | undefined;
394
395 // Misc
396 resolvedTheme: "light" | "dark";
397 settings: UnifiedSettings;
398 gitCwd: string | null;
399
400 // Refs the parent needs kept in sync
401 promptRef: React.MutableRefObject<string>;
402 composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>;
403 composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>;
404
405 // Scroll
406 shouldAutoScrollRef: React.MutableRefObject<boolean>;
407 scheduleStickToBottom: () => void;
408
409 // Callbacks
410 onSend: (e?: { preventDefault: () => void }) => void;
411 onInterrupt: () => void;
412 onImplementPlanInNewThread: () => void;
413 onRespondToApproval: (
414 requestId: ApprovalRequestId,
415 decision: ProviderApprovalDecision,
416 ) => Promise<void>;
417 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;
418 onAdvanceActivePendingUserInput: () => void;
419 onPreviousActivePendingUserInputQuestion: () => void;
420 onChangeActivePendingUserInputCustomAnswer: (
421 questionId: string,
422 value: string,
423 nextCursor: number,
424 expandedCursor: number,
425 cursorAdjacentToMention: boolean,
426 ) => void;
427
428 onProviderModelSelect: (provider: ProviderKind, model: string) => void;
429 toggleInteractionMode: () => void;
430 handleRuntimeModeChange: (mode: RuntimeMode) => void;
431 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
432 togglePlanSidebar: () => void;
433
434 focusComposer: () => void;
435 scheduleComposerFocus: () => void;
436 setThreadError: (threadId: ThreadId | null, error: string | null) => void;
437 onExpandImage: (preview: ExpandedImagePreview) => void;
438}
439
440// --------------------------------------------------------------------------
441// Component
442// --------------------------------------------------------------------------
443
444export const ChatComposer = memo(
445 forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) {
446 const {
447 composerDraftTarget,
448 environmentId,
449 routeKind,
450 routeThreadRef,
451 draftId,
452 activeThreadId,
453 activeThreadEnvironmentId: _activeThreadEnvironmentId,
454 activeThread,
455 isServerThread: _isServerThread,
456 isLocalDraftThread: _isLocalDraftThread,
457 phase,
458 isConnecting,
459 isSendBusy,
460 isPreparingWorktree,
461 activePendingApproval,
462 pendingApprovals,
463 pendingUserInputs,
464 activePendingProgress,
465 activePendingResolvedAnswers,
466 activePendingIsResponding,
467 activePendingDraftAnswers,
468 activePendingQuestionIndex,
469 respondingRequestIds,
470 showPlanFollowUpPrompt,
471 activeProposedPlan,
472 activePlan,
473 sidebarProposedPlan,
474 planSidebarOpen,
475 runtimeMode,
476 interactionMode,
477 lockedProvider,
478 providerStatuses,
479 activeProjectDefaultModelSelection,
480 activeThreadModelSelection,
481 activeThreadActivities,
482 resolvedTheme,
483 settings,
484 gitCwd,
485 promptRef,
486 composerImagesRef,
487 composerTerminalContextsRef,
488 shouldAutoScrollRef,
489 scheduleStickToBottom,
490 onSend,
491 onInterrupt,
492 onImplementPlanInNewThread,
493 onRespondToApproval,
494 onSelectActivePendingUserInputOption,
495 onAdvanceActivePendingUserInput,
496 onPreviousActivePendingUserInputQuestion,
497 onChangeActivePendingUserInputCustomAnswer,
498 onProviderModelSelect,
499 toggleInteractionMode,
500 handleRuntimeModeChange,
501 handleInteractionModeChange,
502 togglePlanSidebar,
503 focusComposer,
504 scheduleComposerFocus,
505 setThreadError,
506 onExpandImage,
507 } = props;
508
509 // ------------------------------------------------------------------
510 // Store subscriptions (prompt / images / terminal contexts)
511 // ------------------------------------------------------------------
512 const composerDraft = useComposerThreadDraft(composerDraftTarget);
513 const prompt = composerDraft.prompt;
514 const composerImages = composerDraft.images;
515 const composerTerminalContexts = composerDraft.terminalContexts;
516 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
517
518 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
519 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
520 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
521 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
522 const insertComposerDraftTerminalContext = useComposerDraftStore(
523 (store) => store.insertTerminalContext,
524 );
525 const removeComposerDraftTerminalContext = useComposerDraftStore(
526 (store) => store.removeTerminalContext,
527 );
528 const setComposerDraftTerminalContexts = useComposerDraftStore(
529 (store) => store.setTerminalContexts,
530 );
531 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
532 (store) => store.clearPersistedAttachments,
533 );
534 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
535 (store) => store.syncPersistedAttachments,
536 );
537 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
538
539 // ------------------------------------------------------------------
540 // Model state
541 // ------------------------------------------------------------------
542 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
543 const threadProvider =
544 activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null;
545
546 const unlockedSelectedProvider = resolveSelectableProvider(
547 providerStatuses,
548 selectedProviderByThreadId ?? threadProvider ?? "codex",
549 );
550 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
551
552 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
553 threadRef: composerDraftTarget,
554 providers: providerStatuses,
555 selectedProvider,
556 threadModelSelection: activeThreadModelSelection,
557 projectModelSelection: activeProjectDefaultModelSelection,
558 settings,
559 });
560
561 const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider);
562
563 const composerProviderState = useMemo(
564 () =>
565 getComposerProviderState({
566 provider: selectedProvider,
567 model: selectedModel,
568 models: selectedProviderModels,
569 prompt,
570 modelOptions: composerModelOptions,
571 }),
572 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
573 );
574
575 const selectedPromptEffort = composerProviderState.promptEffort;
576 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
577 const selectedModelSelection = useMemo<ModelSelection>(
578 () => ({
579 provider: selectedProvider,
580 model: selectedModel,
581 ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
582 }),
583 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
584 );
585 const selectedModelForPicker = selectedModel;
586 const modelOptionsByProvider = useMemo<
587 Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>>
588 >(
589 () => ({
590 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
591 claudeAgent:
592 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
593 }),
594 [providerStatuses],
595 );
596 const selectedModelForPickerWithCustomFallback = useMemo(() => {
597 const currentOptions = modelOptionsByProvider[selectedProvider];
598 return currentOptions.some((option) => option.slug === selectedModelForPicker)
599 ? selectedModelForPicker
600 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
601 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
602 const searchableModelOptions = useMemo(
603 () =>
604 AVAILABLE_PROVIDER_OPTIONS.filter(
605 (option) => lockedProvider === null || option.value === lockedProvider,
606 ).flatMap((option) =>
607 modelOptionsByProvider[option.value].map(({ slug, name }) => ({
608 provider: option.value,
609 providerLabel: option.label,
610 slug,
611 name,
612 searchSlug: slug.toLowerCase(),
613 searchName: name.toLowerCase(),
614 searchProvider: option.label.toLowerCase(),
615 })),
616 ),
617 [lockedProvider, modelOptionsByProvider],
618 );
619
620 // ------------------------------------------------------------------
621 // Context window
622 // ------------------------------------------------------------------
623 const activeContextWindow = useMemo(
624 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
625 [activeThreadActivities],
626 );
627
628 // ------------------------------------------------------------------
629 // Composer-local state
630 // ------------------------------------------------------------------
631 const [composerCursor, setComposerCursor] = useState(() =>
632 collapseExpandedComposerCursor(prompt, prompt.length),
633 );
634 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
635 detectComposerTrigger(prompt, prompt.length),
636 );
637 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
638 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
639 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
640 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
641
642 // ------------------------------------------------------------------
643 // Refs
644 // ------------------------------------------------------------------
645 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
646 const composerFormRef = useRef<HTMLFormElement>(null);
647 const composerFormHeightRef = useRef(0);
648 const composerFooterRef = useRef<HTMLDivElement>(null);
649 const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
650 const composerFooterActionsRef = useRef<HTMLDivElement>(null);
651 const composerSelectLockRef = useRef(false);
652 const composerMenuOpenRef = useRef(false);
653 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
654 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
655 const dragDepthRef = useRef(0);
656
657 // ------------------------------------------------------------------
658 // Derived: composer send state
659 // ------------------------------------------------------------------
660 const composerSendState = useMemo(
661 () =>
662 deriveComposerSendState({
663 prompt,
664 imageCount: composerImages.length,
665 terminalContexts: composerTerminalContexts,
666 }),
667 [composerImages.length, composerTerminalContexts, prompt],
668 );
669
670 // ------------------------------------------------------------------
671 // Derived: composer trigger / menu
672 // ------------------------------------------------------------------
673 const composerTriggerKind = composerTrigger?.kind ?? null;
674 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
675 const isPathTrigger = composerTriggerKind === "path";
676 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
677 pathTriggerQuery,
678 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
679 (debouncerState) => ({ isPending: debouncerState.isPending }),
680 );
681 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
682 const workspaceEntriesQuery = useQuery(
683 projectSearchEntriesQueryOptions({
684 environmentId,
685 cwd: gitCwd,
686 query: effectivePathQuery,
687 enabled: isPathTrigger,
688 limit: 80,
689 }),
690 );
691 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
692
693 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
694 if (!composerTrigger) return [];
695 if (composerTrigger.kind === "path") {
696 return workspaceEntries.map((entry) => ({
697 id: `path:${entry.kind}:${entry.path}`,
698 type: "path",
699 path: entry.path,
700 pathKind: entry.kind,
701 label: basenameOfPath(entry.path),
702 description: entry.parentPath ?? "",
703 }));
704 }
705 if (composerTrigger.kind === "slash-command") {
706 const slashCommandItems = [
707 {
708 id: "slash:model",
709 type: "slash-command",
710 command: "model",
711 label: "/model",
712 description: "Switch response model for this thread",
713 },
714 {
715 id: "slash:plan",
716 type: "slash-command",
717 command: "plan",
718 label: "/plan",
719 description: "Switch this thread into plan mode",
720 },
721 {
722 id: "slash:default",
723 type: "slash-command",
724 command: "default",
725 label: "/default",
726 description: "Switch this thread back to normal build mode",
727 },
728 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
729 const query = composerTrigger.query.trim().toLowerCase();
730 if (!query) {
731 return [...slashCommandItems];
732 }
733 return slashCommandItems.filter(
734 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
735 );
736 }
737 return searchableModelOptions
738 .filter(({ searchSlug, searchName, searchProvider }) => {
739 const query = composerTrigger.query.trim().toLowerCase();
740 if (!query) return true;
741 return (
742 searchSlug.includes(query) ||
743 searchName.includes(query) ||
744 searchProvider.includes(query)
745 );
746 })
747 .map(({ provider, providerLabel, slug, name }) => ({
748 id: `model:${provider}:${slug}`,
749 type: "model",
750 provider,
751 model: slug,
752 label: name,
753 description: `${providerLabel} · ${slug}`,
754 }));
755 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
756
757 const composerMenuOpen = Boolean(composerTrigger);
758 const activeComposerMenuItem = useMemo(
759 () =>
760 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
761 composerMenuItems[0] ??
762 null,
763 [composerHighlightedItemId, composerMenuItems],
764 );
765
766 composerMenuOpenRef.current = composerMenuOpen;
767 composerMenuItemsRef.current = composerMenuItems;
768 activeComposerMenuItemRef.current = activeComposerMenuItem;
769
770 const nonPersistedComposerImageIdSet = useMemo(
771 () => new Set(nonPersistedComposerImageIds),
772 [nonPersistedComposerImageIds],
773 );
774
775 const isComposerApprovalState = activePendingApproval !== null;
776 const activePendingUserInput = pendingUserInputs[0] ?? null;
777 const hasComposerHeader =
778 isComposerApprovalState ||
779 pendingUserInputs.length > 0 ||
780 (showPlanFollowUpPrompt && activeProposedPlan !== null);
781
782 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
783 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
784 const composerFooterActionLayoutKey = useMemo(() => {
785 if (activePendingProgress) {
786 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
787 }
788 if (phase === "running") {
789 return "running";
790 }
791 if (showPlanFollowUpPrompt) {
792 return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
793 }
794 return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
795 }, [
796 activePendingIsResponding,
797 activePendingProgress,
798 composerSendState.hasSendableContent,
799 isConnecting,
800 isPreparingWorktree,
801 isSendBusy,
802 phase,
803 prompt,
804 showPlanFollowUpPrompt,
805 ]);
806
807 const isComposerMenuLoading =
808 composerTriggerKind === "path" &&
809 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
810 workspaceEntriesQuery.isLoading ||
811 workspaceEntriesQuery.isFetching);
812
813 // ------------------------------------------------------------------
814 // Provider traits UI
815 // ------------------------------------------------------------------
816 const setPromptFromTraits = useCallback(
817 (nextPrompt: string) => {
818 const currentPrompt = promptRef.current;
819 if (nextPrompt === currentPrompt) {
820 scheduleComposerFocus();
821 return;
822 }
823 promptRef.current = nextPrompt;
824 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
825 const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
826 setComposerCursor(nextCursor);
827 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
828 scheduleComposerFocus();
829 },
830 [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
831 );
832
833 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
834 provider: selectedProvider,
835 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
836 ...(routeKind === "draft" && draftId ? { draftId } : {}),
837 model: selectedModel,
838 models: selectedProviderModels,
839 modelOptions: composerModelOptions?.[selectedProvider],
840 prompt,
841 onPromptChange: setPromptFromTraits,
842 });
843 const providerTraitsPicker = renderProviderTraitsPicker({
844 provider: selectedProvider,
845 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
846 ...(routeKind === "draft" && draftId ? { draftId } : {}),
847 model: selectedModel,
848 models: selectedProviderModels,
849 modelOptions: composerModelOptions?.[selectedProvider],
850 prompt,
851 onPromptChange: setPromptFromTraits,
852 });
853 const pendingPrimaryAction = useMemo(
854 () =>
855 activePendingProgress
856 ? {
857 questionIndex: activePendingProgress.questionIndex,
858 isLastQuestion: activePendingProgress.isLastQuestion,
859 canAdvance: activePendingProgress.canAdvance,
860 isResponding: activePendingIsResponding,
861 isComplete: Boolean(activePendingResolvedAnswers),
862 }
863 : null,
864 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
865 );
866
867 // ------------------------------------------------------------------
868 // Prompt helpers
869 // ------------------------------------------------------------------
870 const setPrompt = useCallback(
871 (nextPrompt: string) => {
872 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
873 },
874 [composerDraftTarget, setComposerDraftPrompt],
875 );
876
877 const addComposerImage = useCallback(
878 (image: ComposerImageAttachment) => {
879 addComposerDraftImage(composerDraftTarget, image);
880 },
881 [composerDraftTarget, addComposerDraftImage],
882 );
883
884 const addComposerImagesToDraft = useCallback(
885 (images: ComposerImageAttachment[]) => {
886 addComposerDraftImages(composerDraftTarget, images);
887 },
888 [composerDraftTarget, addComposerDraftImages],
889 );
890
891 const removeComposerImageFromDraft = useCallback(
892 (imageId: string) => {
893 removeComposerDraftImage(composerDraftTarget, imageId);
894 },
895 [composerDraftTarget, removeComposerDraftImage],
896 );
897
898 const removeComposerTerminalContextFromDraft = useCallback(
899 (contextId: string) => {
900 const contextIndex = composerTerminalContexts.findIndex(
901 (context) => context.id === contextId,
902 );
903 if (contextIndex < 0) return;
904 const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
905 promptRef.current = removal.prompt;
906 setPrompt(removal.prompt);
907 removeComposerDraftTerminalContext(composerDraftTarget, contextId);
908 const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor);
909 setComposerCursor(nextCursor);
910 setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor));
911 },
912 [
913 composerDraftTarget,
914 composerTerminalContexts,
915 promptRef,
916 removeComposerDraftTerminalContext,
917 setPrompt,
918 ],
919 );
920
921 // ------------------------------------------------------------------
922 // Sync refs back to parent
923 // ------------------------------------------------------------------
924 useEffect(() => {
925 promptRef.current = prompt;
926 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
927 }, [prompt, promptRef]);
928
929 useEffect(() => {
930 composerImagesRef.current = composerImages;
931 }, [composerImages, composerImagesRef]);
932
933 useEffect(() => {
934 composerTerminalContextsRef.current = composerTerminalContexts;
935 }, [composerTerminalContexts, composerTerminalContextsRef]);
936
937 // ------------------------------------------------------------------
938 // Composer menu highlight sync
939 // ------------------------------------------------------------------
940 useEffect(() => {
941 if (!composerMenuOpen) {
942 setComposerHighlightedItemId(null);
943 return;
944 }
945 setComposerHighlightedItemId((existing) =>
946 existing && composerMenuItems.some((item) => item.id === existing)
947 ? existing
948 : (composerMenuItems[0]?.id ?? null),
949 );
950 }, [composerMenuItems, composerMenuOpen]);
951
952 const lastSyncedPendingInputRef = useRef<{
953 requestId: string | null;
954 questionId: string | null;
955 } | null>(null);
956
957 useEffect(() => {
958 const nextCustomAnswer = activePendingProgress?.customAnswer;
959 if (typeof nextCustomAnswer !== "string") {
960 lastSyncedPendingInputRef.current = null;
961 return;
962 }
963
964 const nextRequestId = activePendingUserInput?.requestId ?? null;
965 const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
966 const questionChanged =
967 lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
968 lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
969 const textChangedExternally = promptRef.current !== nextCustomAnswer;
970
971 lastSyncedPendingInputRef.current = {
972 requestId: nextRequestId,
973 questionId: nextQuestionId,
974 };
975
976 if (!questionChanged && !textChangedExternally) {
977 return;
978 }
979
980 promptRef.current = nextCustomAnswer;
981 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
982 setComposerCursor(nextCursor);
983 setComposerTrigger(
984 detectComposerTrigger(
985 nextCustomAnswer,
986 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
987 ),
988 );
989 setComposerHighlightedItemId(null);
990 }, [
991 activePendingProgress?.customAnswer,
992 activePendingProgress?.activeQuestion?.id,
993 activePendingUserInput?.requestId,
994 promptRef,
995 ]);
996
997 // ------------------------------------------------------------------
998 // Reset compositor state on thread/draft change
999 // ------------------------------------------------------------------
1000 useEffect(() => {
1001 setComposerHighlightedItemId(null);
1002 setComposerCursor(
1003 collapseExpandedComposerCursor(promptRef.current, promptRef.current.length),
1004 );
1005 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1006 dragDepthRef.current = 0;
1007 setIsDragOverComposer(false);
1008 }, [draftId, activeThreadId, promptRef]);
1009
1010 // ------------------------------------------------------------------
1011 // Footer compact layout observation
1012 // ------------------------------------------------------------------
1013 useLayoutEffect(() => {
1014 const composerForm = composerFormRef.current;
1015 if (!composerForm) return;
1016 const measureComposerFormWidth = () => composerForm.clientWidth;
1017 const measureFooterCompactness = () => {
1018 const composerFormWidth = measureComposerFormWidth();
1019 const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, {
1020 hasWideActions: composerFooterHasWideActions,
1021 });
1022 const footer = composerFooterRef.current;
1023 const footerStyle = footer ? window.getComputedStyle(footer) : null;
1024 const footerContentWidth = resolveComposerFooterContentWidth({
1025 footerWidth: footer?.clientWidth ?? null,
1026 paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null,
1027 paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null,
1028 });
1029 const fitInput = {
1030 footerContentWidth,
1031 leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null,
1032 actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null,
1033 };
1034 const nextFooterCompact =
1035 heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput);
1036 const nextPrimaryActionsCompact =
1037 nextFooterCompact &&
1038 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
1039 hasWideActions: composerFooterHasWideActions,
1040 });
1041 return {
1042 primaryActionsCompact: nextPrimaryActionsCompact,
1043 footerCompact: nextFooterCompact,
1044 };
1045 };
1046
1047 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1048 const initialCompactness = measureFooterCompactness();
1049 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
1050 setIsComposerFooterCompact(initialCompactness.footerCompact);
1051 if (typeof ResizeObserver === "undefined") return;
1052
1053 const observer = new ResizeObserver((entries) => {
1054 const [entry] = entries;
1055 if (!entry) return;
1056 const nextCompactness = measureFooterCompactness();
1057 setIsComposerPrimaryActionsCompact((previous) =>
1058 previous === nextCompactness.primaryActionsCompact
1059 ? previous
1060 : nextCompactness.primaryActionsCompact,
1061 );
1062 setIsComposerFooterCompact((previous) =>
1063 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
1064 );
1065 const nextHeight = entry.contentRect.height;
1066 const previousHeight = composerFormHeightRef.current;
1067 composerFormHeightRef.current = nextHeight;
1068 if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1069 if (!shouldAutoScrollRef.current) return;
1070 scheduleStickToBottom();
1071 });
1072
1073 observer.observe(composerForm);
1074 return () => {
1075 observer.disconnect();
1076 };
1077 }, [
1078 activeThreadId,
1079 composerFooterActionLayoutKey,
1080 composerFooterHasWideActions,
1081 scheduleStickToBottom,
1082 shouldAutoScrollRef,
1083 ]);
1084
1085 // ------------------------------------------------------------------
1086 // Image persist effect
1087 // ------------------------------------------------------------------
1088 useEffect(() => {
1089 let cancelled = false;
1090 void (async () => {
1091 if (composerImages.length === 0) {
1092 clearComposerDraftPersistedAttachments(composerDraftTarget);
1093 return;
1094 }
1095 const getPersistedAttachmentsForThread = () =>
1096 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
1097 try {
1098 const currentPersistedAttachments = getPersistedAttachmentsForThread();
1099 const existingPersistedById = new Map(
1100 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
1101 );
1102 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
1103 await Promise.all(
1104 composerImages.map(async (image) => {
1105 try {
1106 const dataUrl = await readFileAsDataUrl(image.file);
1107 stagedAttachmentById.set(image.id, {
1108 id: image.id,
1109 name: image.name,
1110 mimeType: image.mimeType,
1111 sizeBytes: image.sizeBytes,
1112 dataUrl,
1113 });
1114 } catch {
1115 const existingPersisted = existingPersistedById.get(image.id);
1116 if (existingPersisted) {
1117 stagedAttachmentById.set(image.id, existingPersisted);
1118 }
1119 }
1120 }),
1121 );
1122 const serialized = Array.from(stagedAttachmentById.values());
1123 if (cancelled) return;
1124 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
1125 } catch {
1126 const currentImageIds = new Set(composerImages.map((image) => image.id));
1127 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
1128 const fallbackPersistedIds = fallbackPersistedAttachments
1129 .map((attachment) => attachment.id)
1130 .filter((id) => currentImageIds.has(id));
1131 const fallbackPersistedIdSet = new Set(fallbackPersistedIds);
1132 const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) =>
1133 fallbackPersistedIdSet.has(attachment.id),
1134 );
1135 if (cancelled) return;
1136 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
1137 }
1138 })();
1139 return () => {
1140 cancelled = true;
1141 };
1142 }, [
1143 composerDraftTarget,
1144 clearComposerDraftPersistedAttachments,
1145 composerImages,
1146 getComposerDraft,
1147 syncComposerDraftPersistedAttachments,
1148 ]);
1149
1150 // ------------------------------------------------------------------
1151 // Callbacks: prompt change
1152 // ------------------------------------------------------------------
1153 const onPromptChange = useCallback(
1154 (
1155 nextPrompt: string,
1156 nextCursor: number,
1157 expandedCursor: number,
1158 cursorAdjacentToMention: boolean,
1159 terminalContextIds: string[],
1160 ) => {
1161 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
1162 setComposerCursor(nextCursor);
1163 setComposerTrigger(
1164 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1165 );
1166 onChangeActivePendingUserInputCustomAnswer(
1167 activePendingProgress.activeQuestion.id,
1168 nextPrompt,
1169 nextCursor,
1170 expandedCursor,
1171 cursorAdjacentToMention,
1172 );
1173 return;
1174 }
1175 promptRef.current = nextPrompt;
1176 setPrompt(nextPrompt);
1177 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
1178 setComposerDraftTerminalContexts(
1179 composerDraftTarget,
1180 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
1181 );
1182 }
1183 setComposerCursor(nextCursor);
1184 setComposerTrigger(
1185 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
1186 );
1187 },
1188 [
1189 activePendingProgress?.activeQuestion,
1190 pendingUserInputs.length,
1191 onChangeActivePendingUserInputCustomAnswer,
1192 promptRef,
1193 setPrompt,
1194 composerDraftTarget,
1195 composerTerminalContexts,
1196 setComposerDraftTerminalContexts,
1197 ],
1198 );
1199
1200 // ------------------------------------------------------------------
1201 // Callbacks: prompt replacement / menu
1202 // ------------------------------------------------------------------
1203 const applyPromptReplacement = useCallback(
1204 (
1205 rangeStart: number,
1206 rangeEnd: number,
1207 replacement: string,
1208 options?: { expectedText?: string },
1209 ): boolean => {
1210 const currentText = promptRef.current;
1211 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
1212 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
1213 if (
1214 options?.expectedText !== undefined &&
1215 currentText.slice(safeStart, safeEnd) !== options.expectedText
1216 ) {
1217 return false;
1218 }
1219 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1220 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
1221 const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
1222 promptRef.current = next.text;
1223 const activePendingQuestion = activePendingProgress?.activeQuestion;
1224 if (activePendingQuestion && activePendingUserInput) {
1225 onChangeActivePendingUserInputCustomAnswer(
1226 activePendingQuestion.id,
1227 next.text,
1228 nextCursor,
1229 nextExpandedCursor,
1230 false,
1231 );
1232 } else {
1233 setPrompt(next.text);
1234 }
1235 setComposerCursor(nextCursor);
1236 setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
1237 window.requestAnimationFrame(() => {
1238 composerEditorRef.current?.focusAt(nextCursor);
1239 });
1240 return true;
1241 },
1242 [
1243 activePendingProgress?.activeQuestion,
1244 activePendingUserInput,
1245 onChangeActivePendingUserInputCustomAnswer,
1246 promptRef,
1247 setPrompt,
1248 ],
1249 );
1250
1251 const readComposerSnapshot = useCallback((): {
1252 value: string;
1253 cursor: number;
1254 expandedCursor: number;
1255 terminalContextIds: string[];
1256 } => {
1257 const editorSnapshot = composerEditorRef.current?.readSnapshot();
1258 if (editorSnapshot) {
1259 return editorSnapshot;
1260 }
1261 return {
1262 value: promptRef.current,
1263 cursor: composerCursor,
1264 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1265 terminalContextIds: composerTerminalContexts.map((context) => context.id),
1266 };
1267 }, [composerCursor, composerTerminalContexts, promptRef]);
1268
1269 const resolveActiveComposerTrigger = useCallback((): {
1270 snapshot: { value: string; cursor: number; expandedCursor: number };
1271 trigger: ComposerTrigger | null;
1272 } => {
1273 const snapshot = readComposerSnapshot();
1274 return {
1275 snapshot,
1276 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
1277 };
1278 }, [readComposerSnapshot]);
1279
1280 const onSelectComposerItem = useCallback(
1281 (item: ComposerCommandItem) => {
1282 if (composerSelectLockRef.current) return;
1283 composerSelectLockRef.current = true;
1284 window.requestAnimationFrame(() => {
1285 composerSelectLockRef.current = false;
1286 });
1287 const { snapshot, trigger } = resolveActiveComposerTrigger();
1288 if (!trigger) return;
1289 if (item.type === "path") {
1290 const replacement = `@${item.path} `;
1291 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1292 snapshot.value,
1293 trigger.rangeEnd,
1294 replacement,
1295 );
1296 const applied = applyPromptReplacement(
1297 trigger.rangeStart,
1298 replacementRangeEnd,
1299 replacement,
1300 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1301 );
1302 if (applied) {
1303 setComposerHighlightedItemId(null);
1304 }
1305 return;
1306 }
1307 if (item.type === "slash-command") {
1308 if (item.command === "model") {
1309 const replacement = "/model ";
1310 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1311 snapshot.value,
1312 trigger.rangeEnd,
1313 replacement,
1314 );
1315 const applied = applyPromptReplacement(
1316 trigger.rangeStart,
1317 replacementRangeEnd,
1318 replacement,
1319 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
1320 );
1321 if (applied) {
1322 setComposerHighlightedItemId(null);
1323 }
1324 return;
1325 }
1326 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
1327 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1328 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1329 });
1330 if (applied) {
1331 setComposerHighlightedItemId(null);
1332 }
1333 return;
1334 }
1335 onProviderModelSelect(item.provider, item.model);
1336 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
1337 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
1338 });
1339 if (applied) {
1340 setComposerHighlightedItemId(null);
1341 }
1342 },
1343 [
1344 applyPromptReplacement,
1345 handleInteractionModeChange,
1346 onProviderModelSelect,
1347 resolveActiveComposerTrigger,
1348 ],
1349 );
1350
1351 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
1352 setComposerHighlightedItemId(itemId);
1353 }, []);
1354
1355 const nudgeComposerMenuHighlight = useCallback(
1356 (key: "ArrowDown" | "ArrowUp") => {
1357 if (composerMenuItems.length === 0) return;
1358 const highlightedIndex = composerMenuItems.findIndex(
1359 (item) => item.id === composerHighlightedItemId,
1360 );
1361 const normalizedIndex =
1362 highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;
1363 const offset = key === "ArrowDown" ? 1 : -1;
1364 const nextIndex =
1365 (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;
1366 const nextItem = composerMenuItems[nextIndex];
1367 setComposerHighlightedItemId(nextItem?.id ?? null);
1368 },
1369 [composerHighlightedItemId, composerMenuItems],
1370 );
1371
1372 // ------------------------------------------------------------------
1373 // Callbacks: command key
1374 // ------------------------------------------------------------------
1375 const onComposerCommandKey = (
1376 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
1377 event: KeyboardEvent,
1378 ) => {
1379 if (key === "Tab" && event.shiftKey) {
1380 toggleInteractionMode();
1381 return true;
1382 }
1383 const { trigger } = resolveActiveComposerTrigger();
1384 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
1385 if (menuIsActive) {
1386 const currentItems = composerMenuItemsRef.current;
1387 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
1388 if (key === "ArrowDown" && currentItems.length > 0) {
1389 nudgeComposerMenuHighlight("ArrowDown");
1390 return true;
1391 }
1392 if (key === "ArrowUp" && currentItems.length > 0) {
1393 nudgeComposerMenuHighlight("ArrowUp");
1394 return true;
1395 }
1396 if ((key === "Enter" || key === "Tab") && selectedItem) {
1397 onSelectComposerItem(selectedItem);
1398 return true;
1399 }
1400 }
1401 if (key === "Enter" && !event.shiftKey) {
1402 void onSend();
1403 return true;
1404 }
1405 return false;
1406 };
1407
1408 // ------------------------------------------------------------------
1409 // Callbacks: images
1410 // ------------------------------------------------------------------
1411 const addComposerImages = (files: File[]) => {
1412 if (!activeThreadId || files.length === 0) return;
1413 if (pendingUserInputs.length > 0) {
1414 toastManager.add({
1415 type: "error",
1416 title: "Attach images after answering plan questions.",
1417 });
1418 return;
1419 }
1420 const nextImages: ComposerImageAttachment[] = [];
1421 let nextImageCount = composerImagesRef.current.length;
1422 let error: string | null = null;
1423 for (const file of files) {
1424 if (!file.type.startsWith("image/")) {
1425 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
1426 continue;
1427 }
1428 if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
1429 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
1430 continue;
1431 }
1432 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1433 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
1434 break;
1435 }
1436 const previewUrl = URL.createObjectURL(file);
1437 nextImages.push({
1438 type: "image",
1439 id: randomUUID(),
1440 name: file.name || "image",
1441 mimeType: file.type,
1442 sizeBytes: file.size,
1443 previewUrl,
1444 file,
1445 });
1446 nextImageCount += 1;
1447 }
1448 if (nextImages.length === 1 && nextImages[0]) {
1449 addComposerImage(nextImages[0]);
1450 } else if (nextImages.length > 1) {
1451 addComposerImagesToDraft(nextImages);
1452 }
1453 setThreadError(activeThreadId, error);
1454 };
1455
1456 const removeComposerImage = (imageId: string) => {
1457 removeComposerImageFromDraft(imageId);
1458 };
1459
1460 // ------------------------------------------------------------------
1461 // Callbacks: paste / drag
1462 // ------------------------------------------------------------------
1463 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
1464 const files = Array.from(event.clipboardData.files);
1465 if (files.length === 0) return;
1466 const imageFiles = files.filter((file) => file.type.startsWith("image/"));
1467 if (imageFiles.length === 0) return;
1468 event.preventDefault();
1469 addComposerImages(imageFiles);
1470 };
1471
1472 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
1473 if (!event.dataTransfer.types.includes("Files")) return;
1474 event.preventDefault();
1475 dragDepthRef.current += 1;
1476 setIsDragOverComposer(true);
1477 };
1478
1479 const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
1480 if (!event.dataTransfer.types.includes("Files")) return;
1481 event.preventDefault();
1482 event.dataTransfer.dropEffect = "copy";
1483 setIsDragOverComposer(true);
1484 };
1485
1486 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
1487 if (!event.dataTransfer.types.includes("Files")) return;
1488 event.preventDefault();
1489 const nextTarget = event.relatedTarget;
1490 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
1491 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
1492 if (dragDepthRef.current === 0) {
1493 setIsDragOverComposer(false);
1494 }
1495 };
1496
1497 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
1498 if (!event.dataTransfer.types.includes("Files")) return;
1499 event.preventDefault();
1500 dragDepthRef.current = 0;
1501 setIsDragOverComposer(false);
1502 const files = Array.from(event.dataTransfer.files);
1503 addComposerImages(files);
1504 focusComposer();
1505 };
1506 const handleInterruptPrimaryAction = useCallback(() => {
1507 void onInterrupt();
1508 }, [onInterrupt]);
1509 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
1510 void onImplementPlanInNewThread();
1511 }, [onImplementPlanInNewThread]);
1512
1513 // ------------------------------------------------------------------
1514 // Imperative handle
1515 // ------------------------------------------------------------------
1516 useImperativeHandle(
1517 ref,
1518 () => ({
1519 focusAtEnd: () => {
1520 composerEditorRef.current?.focusAtEnd();
1521 },
1522 focusAt: (cursor: number) => {
1523 composerEditorRef.current?.focusAt(cursor);
1524 },
1525 readSnapshot: () => {
1526 return readComposerSnapshot();
1527 },
1528 resetCursorState: (options?: {
1529 cursor?: number;
1530 prompt?: string;
1531 detectTrigger?: boolean;
1532 }) => {
1533 const promptForState = options?.prompt ?? promptRef.current;
1534 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
1535 setComposerHighlightedItemId(null);
1536 setComposerCursor(cursor);
1537 setComposerTrigger(
1538 options?.detectTrigger
1539 ? detectComposerTrigger(
1540 promptForState,
1541 expandCollapsedComposerCursor(promptForState, cursor),
1542 )
1543 : null,
1544 );
1545 },
1546 addTerminalContext: (selection: TerminalContextSelection) => {
1547 if (!activeThread) return;
1548 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1549 value: promptRef.current,
1550 cursor: composerCursor,
1551 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1552 terminalContextIds: composerTerminalContexts.map((context) => context.id),
1553 };
1554 const insertion = insertInlineTerminalContextPlaceholder(
1555 snapshot.value,
1556 snapshot.expandedCursor,
1557 );
1558 const nextCollapsedCursor = collapseExpandedComposerCursor(
1559 insertion.prompt,
1560 insertion.cursor,
1561 );
1562 const inserted = insertComposerDraftTerminalContext(
1563 composerDraftTarget,
1564 insertion.prompt,
1565 {
1566 id: randomUUID(),
1567 threadId: activeThread.id,
1568 createdAt: new Date().toISOString(),
1569 ...selection,
1570 },
1571 insertion.contextIndex,
1572 );
1573 if (!inserted) return;
1574 promptRef.current = insertion.prompt;
1575 setComposerCursor(nextCollapsedCursor);
1576 setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
1577 window.requestAnimationFrame(() => {
1578 composerEditorRef.current?.focusAt(nextCollapsedCursor);
1579 });
1580 },
1581 getSendContext: () => ({
1582 prompt: promptRef.current,
1583 images: composerImagesRef.current,
1584 terminalContexts: composerTerminalContextsRef.current,
1585 selectedPromptEffort,
1586 selectedModelOptionsForDispatch,
1587 selectedModelSelection,
1588 selectedProvider,
1589 selectedModel,
1590 selectedProviderModels,
1591 }),
1592 }),
1593 [
1594 activeThread,
1595 composerDraftTarget,
1596 composerCursor,
1597 composerTerminalContexts,
1598 insertComposerDraftTerminalContext,
1599 promptRef,
1600 composerImagesRef,
1601 composerTerminalContextsRef,
1602 readComposerSnapshot,
1603 selectedModel,
1604 selectedModelOptionsForDispatch,
1605 selectedModelSelection,
1606 selectedPromptEffort,
1607 selectedProvider,
1608 selectedProviderModels,
1609 ],
1610 );
1611
1612 // Render
1613 // ------------------------------------------------------------------
1614 return (
1615 <form
1616 ref={composerFormRef}
1617 onSubmit={onSend}
1618 className="mx-auto w-full min-w-0 max-w-208"
1619 data-chat-composer-form="true"
1620 >
1621 <div
1622 className={cn(
1623 "group rounded-[22px] p-px transition-colors duration-200",
1624 composerProviderState.composerFrameClassName,
1625 )}
1626 onDragEnter={onComposerDragEnter}
1627 onDragOver={onComposerDragOver}
1628 onDragLeave={onComposerDragLeave}
1629 onDrop={onComposerDrop}
1630 >
1631 <div
1632 className={cn(
1633 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1634 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1635 composerProviderState.composerSurfaceClassName,
1636 )}
1637 >
1638 {activePendingApproval ? (
1639 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1640 <ComposerPendingApprovalPanel
1641 approval={activePendingApproval}
1642 pendingCount={pendingApprovals.length}
1643 />
1644 </div>
1645 ) : pendingUserInputs.length > 0 ? (
1646 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1647 <ComposerPendingUserInputPanel
1648 pendingUserInputs={pendingUserInputs}
1649 respondingRequestIds={respondingRequestIds}
1650 answers={activePendingDraftAnswers}
1651 questionIndex={activePendingQuestionIndex}
1652 onToggleOption={onSelectActivePendingUserInputOption}
1653 onAdvance={onAdvanceActivePendingUserInput}
1654 />
1655 </div>
1656 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
1657 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1658 <ComposerPlanFollowUpBanner
1659 key={activeProposedPlan.id}
1660 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
1661 />
1662 </div>
1663 ) : null}
1664
1665 <div
1666 className={cn(
1667 "relative px-3 pb-2 sm:px-4",
1668 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
1669 )}
1670 >
1671 {composerMenuOpen && !isComposerApprovalState && (
1672 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
1673 <ComposerCommandMenu
1674 items={composerMenuItems}
1675 resolvedTheme={resolvedTheme}
1676 isLoading={isComposerMenuLoading}
1677 triggerKind={composerTriggerKind}
1678 activeItemId={activeComposerMenuItem?.id ?? null}
1679 onHighlightedItemChange={onComposerMenuItemHighlighted}
1680 onSelect={onSelectComposerItem}
1681 />
1682 </div>
1683 )}
1684
1685 {!isComposerApprovalState &&
1686 pendingUserInputs.length === 0 &&
1687 composerImages.length > 0 && (
1688 <div className="mb-3 flex flex-wrap gap-2">
1689 {composerImages.map((image) => (
1690 <div
1691 key={image.id}
1692 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
1693 >
1694 {image.previewUrl ? (
1695 <button
1696 type="button"
1697 className="h-full w-full cursor-zoom-in"
1698 aria-label={`Preview ${image.name}`}
1699 onClick={() => {
1700 const preview = buildExpandedImagePreview(composerImages, image.id);
1701 if (!preview) return;
1702 onExpandImage(preview);
1703 }}
1704 >
1705 <img
1706 src={image.previewUrl}
1707 alt={image.name}
1708 className="h-full w-full object-cover"
1709 />
1710 </button>
1711 ) : (
1712 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
1713 {image.name}
1714 </div>
1715 )}
1716 {nonPersistedComposerImageIdSet.has(image.id) && (
1717 <Tooltip>
1718 <TooltipTrigger
1719 render={
1720 <span
1721 role="img"
1722 aria-label="Draft attachment may not persist"
1723 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
1724 >
1725 <CircleAlertIcon className="size-3" />
1726 </span>
1727 }
1728 />
1729 <TooltipPopup
1730 side="top"
1731 className="max-w-64 whitespace-normal leading-tight"
1732 >
1733 Draft attachment could not be saved locally and may be lost on
1734 navigation.
1735 </TooltipPopup>
1736 </Tooltip>
1737 )}
1738 <Button
1739 variant="ghost"
1740 size="icon-xs"
1741 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
1742 onClick={() => removeComposerImage(image.id)}
1743 aria-label={`Remove ${image.name}`}
1744 >
1745 <XIcon />
1746 </Button>
1747 </div>
1748 ))}
1749 </div>
1750 )}
1751
1752 <ComposerPromptEditor
1753 ref={composerEditorRef}
1754 value={
1755 isComposerApprovalState
1756 ? ""
1757 : activePendingProgress
1758 ? activePendingProgress.customAnswer
1759 : prompt
1760 }
1761 cursor={composerCursor}
1762 terminalContexts={
1763 !isComposerApprovalState && pendingUserInputs.length === 0
1764 ? composerTerminalContexts
1765 : []
1766 }
1767 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
1768 onChange={onPromptChange}
1769 onCommandKeyDown={onComposerCommandKey}
1770 onPaste={onComposerPaste}
1771 placeholder={
1772 isComposerApprovalState
1773 ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
1774 : activePendingProgress
1775 ? "Type your own answer, or leave this blank to use the selected option"
1776 : showPlanFollowUpPrompt && activeProposedPlan
1777 ? "Add feedback to refine the plan, or leave this blank to implement it"
1778 : phase === "disconnected"
1779 ? "Ask for follow-up changes or attach images"
1780 : "Ask anything, @tag files/folders, or use / to show available commands"
1781 }
1782 disabled={isConnecting || isComposerApprovalState}
1783 />
1784 </div>
1785
1786 {/* Bottom toolbar */}
1787 {activePendingApproval ? (
1788 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
1789 <ComposerPendingApprovalActions
1790 requestId={activePendingApproval.requestId}
1791 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
1792 onRespondToApproval={onRespondToApproval}
1793 />
1794 </div>
1795 ) : (
1796 <div
1797 ref={composerFooterRef}
1798 data-chat-composer-footer="true"
1799 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
1800 className={cn(
1801 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3",
1802 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
1803 )}
1804 >
1805 <div
1806 ref={composerFooterLeadingRef}
1807 className={cn(
1808 "flex min-w-0 flex-1 items-center",
1809 isComposerFooterCompact
1810 ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1811 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
1812 )}
1813 >
1814 <ProviderModelPicker
1815 compact={isComposerFooterCompact}
1816 provider={selectedProvider}
1817 model={selectedModelForPickerWithCustomFallback}
1818 lockedProvider={lockedProvider}
1819 providers={providerStatuses}
1820 modelOptionsByProvider={modelOptionsByProvider}
1821 {...(composerProviderState.modelPickerIconClassName
1822 ? {
1823 activeProviderIconClassName:
1824 composerProviderState.modelPickerIconClassName,
1825 }
1826 : {})}
1827 onProviderModelChange={onProviderModelSelect}
1828 />
1829
1830 {isComposerFooterCompact ? (
1831 <CompactComposerControlsMenu
1832 activePlan={showPlanSidebarToggle}
1833 interactionMode={interactionMode}
1834 planSidebarOpen={planSidebarOpen}
1835 runtimeMode={runtimeMode}
1836 traitsMenuContent={providerTraitsMenuContent}
1837 onToggleInteractionMode={toggleInteractionMode}
1838 onTogglePlanSidebar={togglePlanSidebar}
1839 onRuntimeModeChange={handleRuntimeModeChange}
1840 />
1841 ) : (
1842 <>
1843 {providerTraitsPicker ? (
1844 <>
1845 <Separator
1846 orientation="vertical"
1847 className="mx-0.5 hidden h-4 sm:block"
1848 />
1849 {providerTraitsPicker}
1850 </>
1851 ) : null}
1852 <ComposerFooterModeControls
1853 interactionMode={interactionMode}
1854 runtimeMode={runtimeMode}
1855 showPlanToggle={showPlanSidebarToggle}
1856 planSidebarOpen={planSidebarOpen}
1857 onToggleInteractionMode={toggleInteractionMode}
1858 onRuntimeModeChange={handleRuntimeModeChange}
1859 onTogglePlanSidebar={togglePlanSidebar}
1860 />
1861 </>
1862 )}
1863 </div>
1864
1865 {/* Right side: send / stop button */}
1866 <div
1867 ref={composerFooterActionsRef}
1868 data-chat-composer-actions="right"
1869 data-chat-composer-primary-actions-compact={
1870 isComposerPrimaryActionsCompact ? "true" : "false"
1871 }
1872 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
1873 >
1874 <ComposerFooterPrimaryActions
1875 compact={isComposerPrimaryActionsCompact}
1876 activeContextWindow={activeContextWindow}
1877 pendingAction={pendingPrimaryAction}
1878 isRunning={phase === "running"}
1879 showPlanFollowUpPrompt={
1880 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
1881 }
1882 promptHasText={prompt.trim().length > 0}
1883 isSendBusy={isSendBusy}
1884 isConnecting={isConnecting}
1885 isPreparingWorktree={isPreparingWorktree}
1886 hasSendableContent={composerSendState.hasSendableContent}
1887 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
1888 onInterrupt={handleInterruptPrimaryAction}
1889 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
1890 />
1891 </div>
1892 </div>
1893 )}
1894 </div>
1895 </div>
1896 </form>
1897 );
1898 }),
1899);
apps/web/src/components/chat/ExpandedImageDialog.tsx created+120
......@@ -0,0 +1,120 @@
1import { memo, useCallback, useEffect, useState } from "react";
2import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";
4import type { ExpandedImagePreview } from "./ExpandedImagePreview";
5
6interface ExpandedImageDialogProps {
7 preview: ExpandedImagePreview;
8 onClose: () => void;
9}
10
11export const ExpandedImageDialog = memo(function ExpandedImageDialog({
12 preview: initialPreview,
13 onClose,
14}: ExpandedImageDialogProps) {
15 const [preview, setPreview] = useState(initialPreview);
16
17 // Sync when the parent hands us a new preview reference.
18 useEffect(() => {
19 setPreview(initialPreview);
20 }, [initialPreview]);
21
22 const navigateImage = useCallback((direction: -1 | 1) => {
23 setPreview((existing) => {
24 if (existing.images.length <= 1) return existing;
25 const nextIndex =
26 (existing.index + direction + existing.images.length) % existing.images.length;
27 if (nextIndex === existing.index) return existing;
28 return { ...existing, index: nextIndex };
29 });
30 }, []);
31
32 useEffect(() => {
33 const onKeyDown = (event: globalThis.KeyboardEvent) => {
34 if (event.key === "Escape") {
35 event.preventDefault();
36 event.stopPropagation();
37 onClose();
38 return;
39 }
40 if (preview.images.length <= 1) return;
41 if (event.key === "ArrowLeft") {
42 event.preventDefault();
43 event.stopPropagation();
44 navigateImage(-1);
45 return;
46 }
47 if (event.key !== "ArrowRight") return;
48 event.preventDefault();
49 event.stopPropagation();
50 navigateImage(1);
51 };
52 window.addEventListener("keydown", onKeyDown);
53 return () => window.removeEventListener("keydown", onKeyDown);
54 }, [navigateImage, onClose, preview.images.length]);
55
56 const item = preview.images[preview.index];
57 if (!item) return null;
58
59 return (
60 <div
61 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
62 role="dialog"
63 aria-modal="true"
64 aria-label="Expanded image preview"
65 >
66 <button
67 type="button"
68 className="absolute inset-0 z-0 cursor-zoom-out"
69 aria-label="Close image preview"
70 onClick={onClose}
71 />
72 {preview.images.length > 1 && (
73 <Button
74 type="button"
75 size="icon"
76 variant="ghost"
77 className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
78 aria-label="Previous image"
79 onClick={() => navigateImage(-1)}
80 >
81 <ChevronLeftIcon className="size-5" />
82 </Button>
83 )}
84 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
85 <Button
86 type="button"
87 size="icon-xs"
88 variant="ghost"
89 className="absolute right-2 top-2"
90 onClick={onClose}
91 aria-label="Close image preview"
92 >
93 <XIcon />
94 </Button>
95 <img
96 src={item.src}
97 alt={item.name}
98 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
99 draggable={false}
100 />
101 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
102 {item.name}
103 {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""}
104 </p>
105 </div>
106 {preview.images.length > 1 && (
107 <Button
108 type="button"
109 size="icon"
110 variant="ghost"
111 className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
112 aria-label="Next image"
113 onClick={() => navigateImage(1)}
114 >
115 <ChevronRightIcon className="size-5" />
116 </Button>
117 )}
118 </div>
119 );
120});
apps/web/src/composerDraftStore.ts+29-1
......@@ -38,6 +38,7 @@ import {
3838} from "./lib/terminalContext";
3939import { create } from "zustand";
4040import { createJSONStorage, persist } from "zustand/middleware";
41import { useShallow } from "zustand/react/shallow";
4142import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
4243import { getDefaultServerModel } from "./providerModels";
4344import { UnifiedSettings } from "@t3tools/contracts/settings";
......@@ -378,6 +379,11 @@ export interface EffectiveComposerModelState {
378379 modelOptions: ProviderModelOptions | null;
379380}
380381
382interface ComposerDraftModelState {
383 activeProvider: ProviderKind | null;
384 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
385}
386
381387function providerModelOptionsFromSelection(
382388 modelSelection: ModelSelection | null | undefined,
383389): ProviderModelOptions | null {
......@@ -420,6 +426,10 @@ Object.freeze(EMPTY_IDS);
420426Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
421427const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> =
422428 Object.freeze({});
429const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({
430 activeProvider: null,
431 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
432});
423433
424434const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
425435 prompt: "",
......@@ -2764,6 +2774,22 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose
27642774 });
27652775}
27662776
2777export function useComposerDraftModelState(
2778 threadRef: ComposerThreadTarget,
2779): ComposerDraftModelState {
2780 return useComposerDraftStore(
2781 useShallow((state) => {
2782 const draft = getComposerDraftState(state, threadRef);
2783 return draft
2784 ? {
2785 activeProvider: draft.activeProvider,
2786 modelSelectionByProvider: draft.modelSelectionByProvider,
2787 }
2788 : EMPTY_COMPOSER_DRAFT_MODEL_STATE;
2789 }),
2790 );
2791}
2792
27672793export function useEffectiveComposerModelState(input: {
27682794 threadRef?: ComposerThreadTarget;
27692795 draftId?: DraftId;
......@@ -2773,7 +2799,9 @@ export function useEffectiveComposerModelState(input: {
27732799 projectModelSelection: ModelSelection | null | undefined;
27742800 settings: UnifiedSettings;
27752801}): EffectiveComposerModelState {
2776 const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""));
2802 const draft = useComposerDraftModelState(
2803 input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""),
2804 );
27772805
27782806 return useMemo(
27792807 () =>
apps/web/src/hooks/useHandleNewThread.ts+39-26
......@@ -16,39 +16,15 @@ import { createThreadSelectorByRef } from "../storeSelectors";
1616import { resolveThreadRouteTarget } from "../threadRoutes";
1717import { useUiStateStore } from "../uiStateStore";
1818
19export function useHandleNewThread() {
19function useNewThreadState() {
2020 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
21 const projectOrder = useUiStateStore((store) => store.projectOrder);
2221 const router = useRouter();
23 const routeTarget = useParams({
24 strict: false,
25 select: (params) => resolveThreadRouteTarget(params),
26 });
27 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
28 const activeThread = useStore(
29 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
30 );
31 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
32 const activeDraftThread = useComposerDraftStore(() =>
33 routeTarget
34 ? routeTarget.kind === "server"
35 ? getDraftThread(routeTarget.threadRef)
36 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
37 : null,
38 );
39 const orderedProjects = useMemo(() => {
40 return orderItemsByPreferredIds({
41 items: projects,
42 preferredIds: projectOrder,
43 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
44 });
45 }, [projectOrder, projects]);
4622 const getCurrentRouteTarget = useCallback(() => {
4723 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
4824 return resolveThreadRouteTarget(currentRouteParams);
4925 }, [router]);
5026
51 const handleNewThread = useCallback(
27 return useCallback(
5228 (
5329 projectRef: ScopedProjectRef,
5430 options?: {
......@@ -154,6 +130,43 @@ export function useHandleNewThread() {
154130 },
155131 [getCurrentRouteTarget, router, projects],
156132 );
133}
134
135export function useNewThreadHandler() {
136 const handleNewThread = useNewThreadState();
137
138 return {
139 handleNewThread,
140 };
141}
142
143export function useHandleNewThread() {
144 const projectOrder = useUiStateStore((store) => store.projectOrder);
145 const routeTarget = useParams({
146 strict: false,
147 select: (params) => resolveThreadRouteTarget(params),
148 });
149 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
150 const activeThread = useStore(
151 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
152 );
153 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
154 const activeDraftThread = useComposerDraftStore(() =>
155 routeTarget
156 ? routeTarget.kind === "server"
157 ? getDraftThread(routeTarget.threadRef)
158 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
159 : null,
160 );
161 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
162 const orderedProjects = useMemo(() => {
163 return orderItemsByPreferredIds({
164 items: projects,
165 preferredIds: projectOrder,
166 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
167 });
168 }, [projectOrder, projects]);
169 const handleNewThread = useNewThreadState();
157170
158171 return {
159172 activeDraftThread,
apps/web/src/hooks/useThreadActions.ts+2-2
......@@ -6,7 +6,7 @@ import { useCallback } from "react";
66
77import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
88import { useComposerDraftStore } from "../composerDraftStore";
9import { useHandleNewThread } from "./useHandleNewThread";
9import { useNewThreadHandler } from "./useHandleNewThread";
1010import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
1111import { invalidateGitQueries } from "../lib/gitReactQuery";
1212import { newCommandId } from "../lib/utils";
......@@ -32,7 +32,7 @@ export function useThreadActions() {
3232 );
3333 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
3434 const router = useRouter();
35 const { handleNewThread } = useHandleNewThread();
35 const { handleNewThread } = useNewThreadHandler();
3636 const queryClient = useQueryClient();
3737
3838 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {