authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 13:03:01-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
logfd4ff8ca4648e11d945ccf2a8dea65f2037dcddc
tree80f1a0313c4c1ee52d56537a36fb9403f914fa8b
parent1bcfbdf2873ce21fdaf1118bf302a4427fab2226

Restore lost useMemo calls in ChatView and fix threadActivities referential stability

Revert bare function calls / IIFEs back to useMemo for: modelOptions, workLogEntries, latestTurnHasToolActivity, pendingApprovals, timelineEntries, revertTurnCountByUserMessageId, completionSummary, and completionDividerBeforeEntryId. Add EMPTY_ACTIVITIES module-level constant so the ?? [] fallback doesn't create a new array reference every render, which was defeating memoization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

1 files changed, 38 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+38-16
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type TurnId,19 type TurnId,
20 normalizeModelSlug,20 normalizeModelSlug,
21 resolveModelSlug,21 resolveModelSlug,
22 OrchestrationThreadActivity,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";24import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
24import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";25import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -140,6 +141,7 @@ const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;...@@ -140,6 +141,7 @@ const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
140const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;141const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
141const IMAGE_ONLY_BOOTSTRAP_PROMPT =142const IMAGE_ONLY_BOOTSTRAP_PROMPT =
142 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";143 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
144const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
143const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];145const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
144const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];146const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
145const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;147const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
...@@ -611,22 +613,28 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -611,22 +613,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
611 );613 );
612 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);614 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
613 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;615 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
614 const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel);616 const modelOptions = useMemo(
617 () => getAppModelOptions(settings.customCodexModels, selectedModel),
618 [selectedModel, settings.customCodexModels],
619 );
615 const phase = derivePhase(activeThread?.session ?? null);620 const phase = derivePhase(activeThread?.session ?? null);
616 const isSendBusy = sendPhase !== "idle";621 const isSendBusy = sendPhase !== "idle";
617 const isPreparingWorktree = sendPhase === "preparing-worktree";622 const isPreparingWorktree = sendPhase === "preparing-worktree";
618 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;623 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
619 const nowIso = new Date(nowTick).toISOString();624 const nowIso = new Date(nowTick).toISOString();
620 const threadActivities = activeThread?.activities ?? [];625 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
621 const workLogEntries = deriveWorkLogEntries(626 const workLogEntries = useMemo(
622 threadActivities,627 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
623 activeLatestTurn?.turnId ?? undefined,628 [activeLatestTurn?.turnId, threadActivities],
629 );
630 const latestTurnHasToolActivity = useMemo(
631 () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId),
632 [activeLatestTurn?.turnId, threadActivities],
624 );633 );
625 const latestTurnHasToolActivity = hasToolActivityForTurn(634 const pendingApprovals = useMemo(
626 threadActivities,635 () => derivePendingApprovals(threadActivities),
627 activeLatestTurn?.turnId,636 [threadActivities],
628 );637 );
629 const pendingApprovals = derivePendingApprovals(threadActivities);
630 useEffect(() => {638 useEffect(() => {
631 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;639 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
632 }, [attachmentPreviewHandoffByMessageId]);640 }, [attachmentPreviewHandoffByMessageId]);
...@@ -744,7 +752,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -744,7 +752,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
744 }752 }
745 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];753 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
746 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);754 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
747 const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);755 const timelineEntries = useMemo(
756 () => deriveTimelineEntries(timelineMessages, workLogEntries),
757 [timelineMessages, workLogEntries],
758 );
748 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =759 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
749 useTurnDiffSummaries(activeThread);760 useTurnDiffSummaries(activeThread);
750 const turnDiffSummaryByAssistantMessageId = useMemo(() => {761 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
...@@ -755,7 +766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -755,7 +766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
755 }766 }
756 return byMessageId;767 return byMessageId;
757 }, [turnDiffSummaries]);768 }, [turnDiffSummaries]);
758 const revertTurnCountByUserMessageId = (() => {769 const revertTurnCountByUserMessageId = useMemo(() => {
759 const byUserMessageId = new Map<MessageId, number>();770 const byUserMessageId = new Map<MessageId, number>();
760 for (let index = 0; index < timelineEntries.length; index += 1) {771 for (let index = 0; index < timelineEntries.length; index += 1) {
761 const entry = timelineEntries[index];772 const entry = timelineEntries[index];
...@@ -786,9 +797,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -786,9 +797,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
786 }797 }
787798
788 return byUserMessageId;799 return byUserMessageId;
789 })();800 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
790801
791 const completionSummary = (() => {802 const completionSummary = useMemo(() => {
792 if (!latestTurnSettled) return null;803 if (!latestTurnSettled) return null;
793 if (!activeLatestTurn?.startedAt) return null;804 if (!activeLatestTurn?.startedAt) return null;
794 if (!activeLatestTurn.completedAt) return null;805 if (!activeLatestTurn.completedAt) return null;
...@@ -796,8 +807,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -796,8 +807,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
796807
797 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);808 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
798 return elapsed ? `Worked for ${elapsed}` : null;809 return elapsed ? `Worked for ${elapsed}` : null;
799 })();810 }, [
800 const completionDividerBeforeEntryId = (() => {811 activeLatestTurn?.completedAt,
812 activeLatestTurn?.startedAt,
813 latestTurnHasToolActivity,
814 latestTurnSettled,
815 ]);
816 const completionDividerBeforeEntryId = useMemo(() => {
801 if (!latestTurnSettled) return null;817 if (!latestTurnSettled) return null;
802 if (!activeLatestTurn?.startedAt) return null;818 if (!activeLatestTurn?.startedAt) return null;
803 if (!activeLatestTurn.completedAt) return null;819 if (!activeLatestTurn.completedAt) return null;
...@@ -821,7 +837,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -821,7 +837,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
821 }837 }
822 }838 }
823 return inRangeMatch ?? fallbackMatch;839 return inRangeMatch ?? fallbackMatch;
824 })();840 }, [
841 activeLatestTurn?.completedAt,
842 activeLatestTurn?.startedAt,
843 completionSummary,
844 latestTurnSettled,
845 timelineEntries,
846 ]);
825 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;847 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
826 const composerTrigger = useMemo(848 const composerTrigger = useMemo(
827 () => detectComposerTrigger(prompt, composerCursor),849 () => detectComposerTrigger(prompt, composerCursor),