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