authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 12:04:37-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log6f50867aa1ee94fc7582340ce728655e173db144
tree44f58a309ab47f1ec9777fadc55d9b907bc185a8
parent4fca60469dfc5dab7d8d742e965f4ab260f20c3c

Improve store referential stability and fix reactive terminal state

- Deep clone DEFAULT_THREAD_TERMINAL_STATE in createDefaultThreadTerminalState - Remove imperative getTerminalState from terminal store; export selectThreadTerminalState for reactive selectors instead - Fix Sidebar terminal activity indicator not updating (was using non-reactive getter) - Add no-op bailouts to app store transitions (updateThread, markThreadVisited, markThreadUnread, setError, setThreadBranch, setProjectExpanded, setRuntimeMode) to prevent unnecessary re-renders - Use createDefaultThreadTerminalState in clearTerminalState to avoid storing frozen reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

5 files changed, 79 insertions(+), 68 deletions(-)

apps/web/src/components/ChatView.tsx+4-2
...@@ -124,7 +124,7 @@ import {...@@ -124,7 +124,7 @@ import {
124 useComposerDraftStore,124 useComposerDraftStore,
125 useComposerThreadDraft,125 useComposerThreadDraft,
126} from "../composerDraftStore";126} from "../composerDraftStore";
127import { useTerminalStateStore } from "../terminalStateStore";127import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
128import { clamp } from "effect/Number";128import { clamp } from "effect/Number";
129129
130function formatMessageMeta(createdAt: string, duration: string | null): string {130function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -515,7 +515,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -515,7 +515,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
515 setMessagesScrollElement(element);515 setMessagesScrollElement(element);
516 }, []);516 }, []);
517517
518 const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId));518 const terminalState = useTerminalStateStore((state) =>
519 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
520 );
519 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);521 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
520 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);522 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
521 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);523 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
apps/web/src/components/Sidebar.tsx+4-3
...@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/...@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { useTerminalStateStore } from "../terminalStateStore";24import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
25import { toastManager } from "./ui/toast";25import { toastManager } from "./ui/toast";
26import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";26import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -240,7 +240,7 @@ export default function Sidebar() {...@@ -240,7 +240,7 @@ export default function Sidebar() {
240 (store) => store.getDraftThreadByProjectId,240 (store) => store.getDraftThreadByProjectId,
241 );241 );
242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246 const clearProjectDraftThreadId = useComposerDraftStore(246 const clearProjectDraftThreadId = useComposerDraftStore(
...@@ -829,7 +829,8 @@ export default function Sidebar() {...@@ -829,7 +829,8 @@ export default function Sidebar() {
829 );829 );
830 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);830 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
831 const terminalStatus = terminalStatusFromRunningIds(831 const terminalStatus = terminalStatusFromRunningIds(
832 getTerminalState(thread.id).runningTerminalIds,832 selectThreadTerminalState(terminalStateByThreadId, thread.id)
833 .runningTerminalIds,
833 );834 );
834835
835 return (836 return (
apps/web/src/store.ts+54-49
...@@ -101,7 +101,14 @@ function updateThread(...@@ -101,7 +101,14 @@ function updateThread(
101 threadId: ThreadId,101 threadId: ThreadId,
102 updater: (t: Thread) => Thread,102 updater: (t: Thread) => Thread,
103): Thread[] {103): Thread[] {
104 return threads.map((t) => (t.id === threadId ? updater(t) : t));104 let changed = false;
105 const next = threads.map((t) => {
106 if (t.id !== threadId) return t;
107 const updated = updater(t);
108 if (updated !== t) changed = true;
109 return updated;
110 });
111 return changed ? next : threads;
105}112}
106113
107function mapProjectsFromReadModel(114function mapProjectsFromReadModel(
...@@ -277,34 +284,30 @@ export function markThreadVisited(...@@ -277,34 +284,30 @@ export function markThreadVisited(
277): AppState {284): AppState {
278 const at = visitedAt ?? new Date().toISOString();285 const at = visitedAt ?? new Date().toISOString();
279 const visitedAtMs = Date.parse(at);286 const visitedAtMs = Date.parse(at);
280 return {287 const threads = updateThread(state.threads, threadId, (thread) => {
281 ...state,288 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
282 threads: updateThread(state.threads, threadId, (thread) => {289 if (
283 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;290 Number.isFinite(previousVisitedAtMs) &&
284 if (291 Number.isFinite(visitedAtMs) &&
285 Number.isFinite(previousVisitedAtMs) &&292 previousVisitedAtMs >= visitedAtMs
286 Number.isFinite(visitedAtMs) &&293 ) {
287 previousVisitedAtMs >= visitedAtMs294 return thread;
288 ) {295 }
289 return thread;296 return { ...thread, lastVisitedAt: at };
290 }297 });
291 return { ...thread, lastVisitedAt: at };298 return threads === state.threads ? state : { ...state, threads };
292 }),
293 };
294}299}
295300
296export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {301export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
297 return {302 const threads = updateThread(state.threads, threadId, (thread) => {
298 ...state,303 if (!thread.latestTurn?.completedAt) return thread;
299 threads: updateThread(state.threads, threadId, (thread) => {304 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
300 if (!thread.latestTurn?.completedAt) return thread;305 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
301 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);306 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
302 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;307 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
303 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();308 return { ...thread, lastVisitedAt: unreadVisitedAt };
304 if (thread.lastVisitedAt === unreadVisitedAt) return thread;309 });
305 return { ...thread, lastVisitedAt: unreadVisitedAt };310 return threads === state.threads ? state : { ...state, threads };
306 }),
307 };
308}311}
309312
310export function toggleProject(state: AppState, projectId: Project["id"]): AppState {313export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
...@@ -321,19 +324,21 @@ export function setProjectExpanded(...@@ -321,19 +324,21 @@ export function setProjectExpanded(
321 projectId: Project["id"],324 projectId: Project["id"],
322 expanded: boolean,325 expanded: boolean,
323): AppState {326): AppState {
324 return {327 let changed = false;
325 ...state,328 const projects = state.projects.map((p) => {
326 projects: state.projects.map((p) =>329 if (p.id !== projectId || p.expanded === expanded) return p;
327 p.id === projectId ? { ...p, expanded } : p,330 changed = true;
328 ),331 return { ...p, expanded };
329 };332 });
333 return changed ? { ...state, projects } : state;
330}334}
331335
332export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {336export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
333 return {337 const threads = updateThread(state.threads, threadId, (t) => {
334 ...state,338 if (t.error === error) return t;
335 threads: updateThread(state.threads, threadId, (t) => ({ ...t, error })),339 return { ...t, error };
336 };340 });
341 return threads === state.threads ? state : { ...state, threads };
337}342}
338343
339export function setThreadBranch(344export function setThreadBranch(
...@@ -342,21 +347,21 @@ export function setThreadBranch(...@@ -342,21 +347,21 @@ export function setThreadBranch(
342 branch: string | null,347 branch: string | null,
343 worktreePath: string | null,348 worktreePath: string | null,
344): AppState {349): AppState {
345 return {350 const threads = updateThread(state.threads, threadId, (t) => {
346 ...state,351 if (t.branch === branch && t.worktreePath === worktreePath) return t;
347 threads: updateThread(state.threads, threadId, (t) => {352 const cwdChanged = t.worktreePath !== worktreePath;
348 const cwdChanged = t.worktreePath !== worktreePath;353 return {
349 return {354 ...t,
350 ...t,355 branch,
351 branch,356 worktreePath,
352 worktreePath,357 ...(cwdChanged ? { session: null } : {}),
353 ...(cwdChanged ? { session: null } : {}),358 };
354 };359 });
355 }),360 return threads === state.threads ? state : { ...state, threads };
356 };
357}361}
358362
359export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {363export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
364 if (state.runtimeMode === mode) return state;
360 return { ...state, runtimeMode: mode };365 return { ...state, runtimeMode: mode };
361}366}
362367
...@@ -377,7 +382,7 @@ interface AppStore extends AppState {...@@ -377,7 +382,7 @@ interface AppStore extends AppState {
377 setRuntimeMode: (mode: RuntimeMode) => void;382 setRuntimeMode: (mode: RuntimeMode) => void;
378}383}
379384
380export const useStore = create<AppStore>((set, _get) => ({385export const useStore = create<AppStore>((set) => ({
381 ...readPersistedState(),386 ...readPersistedState(),
382 syncServerReadModel: (readModel) =>387 syncServerReadModel: (readModel) =>
383 set((state) => syncServerReadModel(state, readModel)),388 set((state) => syncServerReadModel(state, readModel)),
apps/web/src/terminalStateStore.test.ts+8-8
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { ThreadId } from "@t3tools/contracts";1import { ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";2import { beforeEach, describe, expect, it } from "vitest";
33
4import { useTerminalStateStore } from "./terminalStateStore";4import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore";
55
6const THREAD_ID = ThreadId.makeUnsafe("thread-1");6const THREAD_ID = ThreadId.makeUnsafe("thread-1");
77
...@@ -14,7 +14,7 @@ describe("terminalStateStore actions", () => {...@@ -14,7 +14,7 @@ describe("terminalStateStore actions", () => {
14 });14 });
1515
16 it("returns a closed default terminal state for unknown threads", () => {16 it("returns a closed default terminal state for unknown threads", () => {
17 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);17 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
18 expect(terminalState).toEqual({18 expect(terminalState).toEqual({
19 terminalOpen: false,19 terminalOpen: false,
20 terminalHeight: 280,20 terminalHeight: 280,
...@@ -31,7 +31,7 @@ describe("terminalStateStore actions", () => {...@@ -31,7 +31,7 @@ describe("terminalStateStore actions", () => {
31 store.setTerminalOpen(THREAD_ID, true);31 store.setTerminalOpen(THREAD_ID, true);
32 store.splitTerminal(THREAD_ID, "terminal-2");32 store.splitTerminal(THREAD_ID, "terminal-2");
3333
34 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);34 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
35 expect(terminalState.terminalOpen).toBe(true);35 expect(terminalState.terminalOpen).toBe(true);
36 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);36 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
37 expect(terminalState.activeTerminalId).toBe("terminal-2");37 expect(terminalState.activeTerminalId).toBe("terminal-2");
...@@ -43,7 +43,7 @@ describe("terminalStateStore actions", () => {...@@ -43,7 +43,7 @@ describe("terminalStateStore actions", () => {
43 it("creates new terminals in a separate group", () => {43 it("creates new terminals in a separate group", () => {
44 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");44 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
4545
46 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);46 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
47 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);47 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
48 expect(terminalState.activeTerminalId).toBe("terminal-2");48 expect(terminalState.activeTerminalId).toBe("terminal-2");
49 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");49 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
...@@ -57,12 +57,12 @@ describe("terminalStateStore actions", () => {...@@ -57,12 +57,12 @@ describe("terminalStateStore actions", () => {
57 const store = useTerminalStateStore.getState();57 const store = useTerminalStateStore.getState();
58 store.splitTerminal(THREAD_ID, "terminal-2");58 store.splitTerminal(THREAD_ID, "terminal-2");
59 store.setTerminalActivity(THREAD_ID, "terminal-2", true);59 store.setTerminalActivity(THREAD_ID, "terminal-2", true);
60 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([60 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([
61 "terminal-2",61 "terminal-2",
62 ]);62 ]);
6363
64 store.setTerminalActivity(THREAD_ID, "terminal-2", false);64 store.setTerminalActivity(THREAD_ID, "terminal-2", false);
65 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([]);65 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([]);
66 });66 });
6767
68 it("resets to default and clears persisted entry when closing the last terminal", () => {68 it("resets to default and clears persisted entry when closing the last terminal", () => {
...@@ -70,7 +70,7 @@ describe("terminalStateStore actions", () => {...@@ -70,7 +70,7 @@ describe("terminalStateStore actions", () => {
70 store.closeTerminal(THREAD_ID, "default");70 store.closeTerminal(THREAD_ID, "default");
7171
72 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();72 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();
73 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).terminalIds).toEqual(["default"]);73 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).terminalIds).toEqual(["default"]);
74 });74 });
7575
76 it("keeps a valid active terminal after closing an active split terminal", () => {76 it("keeps a valid active terminal after closing an active split terminal", () => {
...@@ -79,7 +79,7 @@ describe("terminalStateStore actions", () => {...@@ -79,7 +79,7 @@ describe("terminalStateStore actions", () => {
79 store.splitTerminal(THREAD_ID, "terminal-3");79 store.splitTerminal(THREAD_ID, "terminal-3");
80 store.closeTerminal(THREAD_ID, "terminal-3");80 store.closeTerminal(THREAD_ID, "terminal-3");
8181
82 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);82 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
83 expect(terminalState.activeTerminalId).toBe("terminal-2");83 expect(terminalState.activeTerminalId).toBe("terminal-2");
84 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);84 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
85 expect(terminalState.terminalGroups).toEqual([85 expect(terminalState.terminalGroups).toEqual([
apps/web/src/terminalStateStore.ts+9-6
...@@ -163,7 +163,12 @@ const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({...@@ -163,7 +163,12 @@ const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
163});163});
164164
165function createDefaultThreadTerminalState(): ThreadTerminalState {165function createDefaultThreadTerminalState(): ThreadTerminalState {
166 return { ...DEFAULT_THREAD_TERMINAL_STATE };166 return {
167 ...DEFAULT_THREAD_TERMINAL_STATE,
168 terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds],
169 runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds],
170 terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups),
171 };
167}172}
168173
169function getDefaultThreadTerminalState(): ThreadTerminalState {174function getDefaultThreadTerminalState(): ThreadTerminalState {
...@@ -403,7 +408,7 @@ function setThreadTerminalActivity(...@@ -403,7 +408,7 @@ function setThreadTerminalActivity(
403 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };408 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
404}409}
405410
406function selectThreadTerminalState(411export function selectThreadTerminalState(
407 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,412 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
408 threadId: ThreadId,413 threadId: ThreadId,
409): ThreadTerminalState {414): ThreadTerminalState {
...@@ -444,7 +449,6 @@ function updateTerminalStateByThreadId(...@@ -444,7 +449,6 @@ function updateTerminalStateByThreadId(
444449
445interface TerminalStateStoreState {450interface TerminalStateStoreState {
446 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;451 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
447 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
448 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;452 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
449 setTerminalHeight: (threadId: ThreadId, height: number) => void;453 setTerminalHeight: (threadId: ThreadId, height: number) => void;
450 splitTerminal: (threadId: ThreadId, terminalId: string) => void;454 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
...@@ -461,7 +465,7 @@ interface TerminalStateStoreState {...@@ -461,7 +465,7 @@ interface TerminalStateStoreState {
461465
462export const useTerminalStateStore = create<TerminalStateStoreState>()(466export const useTerminalStateStore = create<TerminalStateStoreState>()(
463 persist(467 persist(
464 (set, get) => {468 (set) => {
465 const updateTerminal = (469 const updateTerminal = (
466 threadId: ThreadId,470 threadId: ThreadId,
467 updater: (state: ThreadTerminalState) => ThreadTerminalState,471 updater: (state: ThreadTerminalState) => ThreadTerminalState,
...@@ -483,7 +487,6 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -483,7 +487,6 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
483487
484 return {488 return {
485 terminalStateByThreadId: {},489 terminalStateByThreadId: {},
486 getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId),
487 setTerminalOpen: (threadId, open) =>490 setTerminalOpen: (threadId, open) =>
488 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),491 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
489 setTerminalHeight: (threadId, height) =>492 setTerminalHeight: (threadId, height) =>
...@@ -501,7 +504,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -501,7 +504,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
501 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),504 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
502 ),505 ),
503 clearTerminalState: (threadId) =>506 clearTerminalState: (threadId) =>
504 updateTerminal(threadId, () => getDefaultThreadTerminalState()),507 updateTerminal(threadId, () => createDefaultThreadTerminalState()),
505 };508 };
506 },509 },
507 {510 {