authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 12:53:45-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log4e45c1a6abef59bc99ae1bea3c7a8be9d8775c66
treecd057aa5afbe91815be657ff5bf2fcde92701cb3
parent32fe80d1647ea9fb466fe5fab67b74fea78f011b

Fix store selector consistency, memoize timelineMessages, and suppress false-positive lint warnings

- Hoist removeOrphanedTerminalStates to a top-level selector in EventRouter for consistency with other store selectors - Convert timelineMessages IIFE to useMemo with proper deps - Suppress no-map-spread in ChatView (spread is conditional, immutability required) - Suppress exhaustive-deps for autoFocus in TerminalViewport (mount-time only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

3 files changed, 57 insertions(+), 32 deletions(-)

apps/web/src/components/ChatView.tsx+50-28
...@@ -474,7 +474,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -474,7 +474,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
474 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);474 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
475 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);475 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
476 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);476 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
477 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);477 const draftThread = useComposerDraftStore(
478 (store) => store.draftThreadsByThreadId[threadId] ?? null,
479 );
478 const promptRef = useRef(prompt);480 const promptRef = useRef(prompt);
479 const [isDragOverComposer, setIsDragOverComposer] = useState(false);481 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
480 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);482 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
...@@ -604,7 +606,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -604,7 +606,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
604 markThreadVisited,606 markThreadVisited,
605 ]);607 ]);
606608
607 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);609 const baseThreadModel = resolveModelSlug(
610 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
611 );
608 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);612 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
609 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;613 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
610 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);614 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);
...@@ -614,8 +618,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -614,8 +618,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
614 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;618 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
615 const nowIso = new Date(nowTick).toISOString();619 const nowIso = new Date(nowTick).toISOString();
616 const threadActivities = activeThread?.activities ?? [];620 const threadActivities = activeThread?.activities ?? [];
617 const workLogEntries = deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined);621 const workLogEntries = deriveWorkLogEntries(
618 const latestTurnHasToolActivity = hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);622 threadActivities,
623 activeLatestTurn?.turnId ?? undefined,
624 );
625 const latestTurnHasToolActivity = hasToolActivityForTurn(
626 threadActivities,
627 activeLatestTurn?.turnId,
628 );
619 const pendingApprovals = derivePendingApprovals(threadActivities);629 const pendingApprovals = derivePendingApprovals(threadActivities);
620 useEffect(() => {630 useEffect(() => {
621 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;631 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
...@@ -680,13 +690,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -680,13 +690,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
680 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];690 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
681 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);691 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
682 }, []);692 }, []);
683 const timelineMessages = (() => {693 const serverMessages = activeThread?.messages;
684 const serverMessages = activeThread?.messages ?? [];694 const timelineMessages = useMemo(() => {
695 const messages = serverMessages ?? [];
685 const serverMessagesWithPreviewHandoff =696 const serverMessagesWithPreviewHandoff =
686 Object.keys(attachmentPreviewHandoffByMessageId).length === 0697 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
687 ? serverMessages698 ? messages
688 : serverMessages.map((message) => {699 : // Spread only fires for the few messages that actually changed;
689 if (message.role !== "user" || !message.attachments || message.attachments.length === 0) {700 // unchanged ones early-return their original reference.
701 // In-place mutation would break React's immutable state contract.
702 // oxlint-disable-next-line no-map-spread
703 messages.map((message) => {
704 if (
705 message.role !== "user" ||
706 !message.attachments ||
707 message.attachments.length === 0
708 ) {
690 return message;709 return message;
691 }710 }
692 const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id];711 const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id];
...@@ -724,7 +743,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -724,7 +743,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
724 return serverMessagesWithPreviewHandoff;743 return serverMessagesWithPreviewHandoff;
725 }744 }
726 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];745 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
727 })();746 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
728 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);747 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);
729 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =748 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
730 useTurnDiffSummaries(activeThread);749 useTurnDiffSummaries(activeThread);
...@@ -937,8 +956,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -937,8 +956,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
937 (activeThread.messages.length > 0 ||956 (activeThread.messages.length > 0 ||
938 (activeThread.session !== null && activeThread.session.status !== "closed")),957 (activeThread.session !== null && activeThread.session.status !== "closed")),
939 );958 );
940 const hasReachedTerminalLimit =959 const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
941 terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
942 const setThreadError = useCallback(960 const setThreadError = useCallback(
943 (targetThreadId: ThreadId | null, error: string | null) => {961 (targetThreadId: ThreadId | null, error: string | null) => {
944 if (!targetThreadId) return;962 if (!targetThreadId) return;
...@@ -1390,12 +1408,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1390,12 +1408,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1390 return () => {1408 return () => {
1391 window.clearTimeout(timer);1409 window.clearTimeout(timer);
1392 };1410 };
1393 }, [1411 }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]);
1394 activeThread?.id,
1395 activeThread?.messages,
1396 handoffAttachmentPreviews,
1397 optimisticUserMessages,
1398 ]);
13991412
1400 useEffect(() => {1413 useEffect(() => {
1401 promptRef.current = prompt;1414 promptRef.current = prompt;
...@@ -1810,7 +1823,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1810,7 +1823,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1810 const shouldCreateWorktree =1823 const shouldCreateWorktree =
1811 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;1824 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
1812 if (shouldCreateWorktree && !activeThread.branch) {1825 if (shouldCreateWorktree && !activeThread.branch) {
1813 setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");1826 setStoreThreadError(
1827 threadIdForSend,
1828 "Select a base branch before sending in New worktree mode.",
1829 );
1814 return;1830 return;
1815 }1831 }
18161832
...@@ -1965,8 +1981,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1965,8 +1981,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1965 setSendPhase("sending-turn");1981 setSendPhase("sending-turn");
1966 const turnAttachments = await turnAttachmentsPromise;1982 const turnAttachments = await turnAttachmentsPromise;
1967 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";1983 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";
1968 const sandboxMode =1984 const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
1969 runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
1970 await api.orchestration.dispatchCommand({1985 await api.orchestration.dispatchCommand({
1971 type: "thread.turn.start",1986 type: "thread.turn.start",
1972 commandId: newCommandId(),1987 commandId: newCommandId(),
...@@ -2169,11 +2184,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2169,11 +2184,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2169 workspaceEntriesQuery.isLoading ||2184 workspaceEntriesQuery.isLoading ||
2170 workspaceEntriesQuery.isFetching);2185 workspaceEntriesQuery.isFetching);
21712186
2172 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {2187 const onPromptChange = useCallback(
2173 promptRef.current = nextPrompt;2188 (nextPrompt: string, nextCursor: number) => {
2174 setPrompt(nextPrompt);2189 promptRef.current = nextPrompt;
2175 setComposerCursor(nextCursor);2190 setPrompt(nextPrompt);
2176 }, [setComposerCursor, setPrompt]);2191 setComposerCursor(nextCursor);
2192 },
2193 [setComposerCursor, setPrompt],
2194 );
21772195
2178 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {2196 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
2179 if (composerMenuOpen && composerMenuItems.length > 0) {2197 if (composerMenuOpen && composerMenuItems.length > 0) {
...@@ -2402,8 +2420,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2402,8 +2420,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2402 </span>2420 </span>
2403 }2421 }
2404 />2422 />
2405 <TooltipPopup side="top" className="max-w-64 whitespace-normal leading-tight">2423 <TooltipPopup
2406 Draft attachment could not be saved locally and may be lost on navigation.2424 side="top"
2425 className="max-w-64 whitespace-normal leading-tight"
2426 >
2427 Draft attachment could not be saved locally and may be lost on
2428 navigation.
2407 </TooltipPopup>2429 </TooltipPopup>
2408 </Tooltip>2430 </Tooltip>
2409 )}2431 )}
apps/web/src/components/ThreadTerminalDrawer.tsx+3
...@@ -389,6 +389,9 @@ function TerminalViewport({...@@ -389,6 +389,9 @@ function TerminalViewport({
389 fitAddonRef.current = null;389 fitAddonRef.current = null;
390 terminal.dispose();390 terminal.dispose();
391 };391 };
392 // autoFocus is intentionally omitted;
393 // it is only read at mount time and must not trigger terminal teardown/recreation.
394 // eslint-disable-next-line react-hooks/exhaustive-deps
392 }, [cwd, runtimeEnv, terminalId, threadId]);395 }, [cwd, runtimeEnv, terminalId, threadId]);
393396
394 useEffect(() => {397 useEffect(() => {
apps/web/src/routes/__root.tsx+4-4
...@@ -126,6 +126,9 @@ function errorDetails(error: unknown): string {...@@ -126,6 +126,9 @@ function errorDetails(error: unknown): string {
126function EventRouter() {126function EventRouter() {
127 const syncServerReadModel = useStore((store) => store.syncServerReadModel);127 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
128 const setProjectExpanded = useStore((store) => store.setProjectExpanded);128 const setProjectExpanded = useStore((store) => store.setProjectExpanded);
129 const removeOrphanedTerminalStates = useTerminalStateStore(
130 (store) => store.removeOrphanedTerminalStates,
131 );
129 const queryClient = useQueryClient();132 const queryClient = useQueryClient();
130 const navigate = useNavigate();133 const navigate = useNavigate();
131 const pathname = useRouterState({ select: (state) => state.location.pathname });134 const pathname = useRouterState({ select: (state) => state.location.pathname });
...@@ -143,9 +146,6 @@ function EventRouter() {...@@ -143,9 +146,6 @@ function EventRouter() {
143 let syncing = false;146 let syncing = false;
144 let pending = false;147 let pending = false;
145148
146 const removeOrphanedTerminalStates =
147 useTerminalStateStore.getState().removeOrphanedTerminalStates;
148
149 const flushSnapshotSync = async (): Promise<void> => {149 const flushSnapshotSync = async (): Promise<void> => {
150 const snapshot = await api.orchestration.getSnapshot();150 const snapshot = await api.orchestration.getSnapshot();
151 if (disposed) return;151 if (disposed) return;
...@@ -276,7 +276,7 @@ function EventRouter() {...@@ -276,7 +276,7 @@ function EventRouter() {
276 unsubWelcome();276 unsubWelcome();
277 unsubServerConfigUpdated();277 unsubServerConfigUpdated();
278 };278 };
279 }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]);279 }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]);
280280
281 return null;281 return null;
282}282}