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) {
474474 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
475475 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
476476 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 );
478480 const promptRef = useRef(prompt);
479481 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
480482 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
......@@ -604,7 +606,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
604606 markThreadVisited,
605607 ]);
606608
607 const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL);
609 const baseThreadModel = resolveModelSlug(
610 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
611 );
608612 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
609613 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
610614 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);
......@@ -614,8 +618,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
614618 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
615619 const nowIso = new Date(nowTick).toISOString();
616620 const threadActivities = activeThread?.activities ?? [];
617 const workLogEntries = deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined);
618 const latestTurnHasToolActivity = hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);
621 const workLogEntries = deriveWorkLogEntries(
622 threadActivities,
623 activeLatestTurn?.turnId ?? undefined,
624 );
625 const latestTurnHasToolActivity = hasToolActivityForTurn(
626 threadActivities,
627 activeLatestTurn?.turnId,
628 );
619629 const pendingApprovals = derivePendingApprovals(threadActivities);
620630 useEffect(() => {
621631 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
......@@ -680,13 +690,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
680690 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
681691 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
682692 }, []);
683 const timelineMessages = (() => {
684 const serverMessages = activeThread?.messages ?? [];
693 const serverMessages = activeThread?.messages;
694 const timelineMessages = useMemo(() => {
695 const messages = serverMessages ?? [];
685696 const serverMessagesWithPreviewHandoff =
686697 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
687 ? serverMessages
688 : serverMessages.map((message) => {
689 if (message.role !== "user" || !message.attachments || message.attachments.length === 0) {
698 ? messages
699 : // Spread only fires for the few messages that actually changed;
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 ) {
690709 return message;
691710 }
692711 const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id];
......@@ -724,7 +743,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
724743 return serverMessagesWithPreviewHandoff;
725744 }
726745 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
727 })();
746 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
728747 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);
729748 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
730749 useTurnDiffSummaries(activeThread);
......@@ -937,8 +956,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
937956 (activeThread.messages.length > 0 ||
938957 (activeThread.session !== null && activeThread.session.status !== "closed")),
939958 );
940 const hasReachedTerminalLimit =
941 terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
959 const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
942960 const setThreadError = useCallback(
943961 (targetThreadId: ThreadId | null, error: string | null) => {
944962 if (!targetThreadId) return;
......@@ -1390,12 +1408,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13901408 return () => {
13911409 window.clearTimeout(timer);
13921410 };
1393 }, [
1394 activeThread?.id,
1395 activeThread?.messages,
1396 handoffAttachmentPreviews,
1397 optimisticUserMessages,
1398 ]);
1411 }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]);
13991412
14001413 useEffect(() => {
14011414 promptRef.current = prompt;
......@@ -1810,7 +1823,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
18101823 const shouldCreateWorktree =
18111824 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
18121825 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 );
18141830 return;
18151831 }
18161832
......@@ -1965,8 +1981,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19651981 setSendPhase("sending-turn");
19661982 const turnAttachments = await turnAttachmentsPromise;
19671983 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";
1968 const sandboxMode =
1969 runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
1984 const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
19701985 await api.orchestration.dispatchCommand({
19711986 type: "thread.turn.start",
19721987 commandId: newCommandId(),
......@@ -2169,11 +2184,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
21692184 workspaceEntriesQuery.isLoading ||
21702185 workspaceEntriesQuery.isFetching);
21712186
2172 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
2173 promptRef.current = nextPrompt;
2174 setPrompt(nextPrompt);
2175 setComposerCursor(nextCursor);
2176 }, [setComposerCursor, setPrompt]);
2187 const onPromptChange = useCallback(
2188 (nextPrompt: string, nextCursor: number) => {
2189 promptRef.current = nextPrompt;
2190 setPrompt(nextPrompt);
2191 setComposerCursor(nextCursor);
2192 },
2193 [setComposerCursor, setPrompt],
2194 );
21772195
21782196 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
21792197 if (composerMenuOpen && composerMenuItems.length > 0) {
......@@ -2402,8 +2420,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
24022420 </span>
24032421 }
24042422 />
2405 <TooltipPopup side="top" className="max-w-64 whitespace-normal leading-tight">
2406 Draft attachment could not be saved locally and may be lost on navigation.
2423 <TooltipPopup
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.
24072429 </TooltipPopup>
24082430 </Tooltip>
24092431 )}
apps/web/src/components/ThreadTerminalDrawer.tsx+3
......@@ -389,6 +389,9 @@ function TerminalViewport({
389389 fitAddonRef.current = null;
390390 terminal.dispose();
391391 };
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
392395 }, [cwd, runtimeEnv, terminalId, threadId]);
393396
394397 useEffect(() => {
apps/web/src/routes/__root.tsx+4-4
......@@ -126,6 +126,9 @@ function errorDetails(error: unknown): string {
126126function EventRouter() {
127127 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
128128 const setProjectExpanded = useStore((store) => store.setProjectExpanded);
129 const removeOrphanedTerminalStates = useTerminalStateStore(
130 (store) => store.removeOrphanedTerminalStates,
131 );
129132 const queryClient = useQueryClient();
130133 const navigate = useNavigate();
131134 const pathname = useRouterState({ select: (state) => state.location.pathname });
......@@ -143,9 +146,6 @@ function EventRouter() {
143146 let syncing = false;
144147 let pending = false;
145148
146 const removeOrphanedTerminalStates =
147 useTerminalStateStore.getState().removeOrphanedTerminalStates;
148
149149 const flushSnapshotSync = async (): Promise<void> => {
150150 const snapshot = await api.orchestration.getSnapshot();
151151 if (disposed) return;
......@@ -276,7 +276,7 @@ function EventRouter() {
276276 unsubWelcome();
277277 unsubServerConfigUpdated();
278278 };
279 }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]);
279 }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]);
280280
281281 return null;
282282}