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 {
124124 useComposerDraftStore,
125125 useComposerThreadDraft,
126126} from "../composerDraftStore";
127import { useTerminalStateStore } from "../terminalStateStore";
127import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
128128import { clamp } from "effect/Number";
129129
130130function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -515,7 +515,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
515515 setMessagesScrollElement(element);
516516 }, []);
517517
518 const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId));
518 const terminalState = useTerminalStateStore((state) =>
519 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
520 );
519521 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
520522 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
521523 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
apps/web/src/components/Sidebar.tsx+4-3
......@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
2323import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { useTerminalStateStore } from "../terminalStateStore";
24import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
2525import { toastManager } from "./ui/toast";
2626import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2727import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -240,7 +240,7 @@ export default function Sidebar() {
240240 (store) => store.getDraftThreadByProjectId,
241241 );
242242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);
243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
244244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246246 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -829,7 +829,8 @@ export default function Sidebar() {
829829 );
830830 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
831831 const terminalStatus = terminalStatusFromRunningIds(
832 getTerminalState(thread.id).runningTerminalIds,
832 selectThreadTerminalState(terminalStateByThreadId, thread.id)
833 .runningTerminalIds,
833834 );
834835
835836 return (
apps/web/src/store.ts+54-49
......@@ -101,7 +101,14 @@ function updateThread(
101101 threadId: ThreadId,
102102 updater: (t: Thread) => Thread,
103103): 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;
105112}
106113
107114function mapProjectsFromReadModel(
......@@ -277,34 +284,30 @@ export function markThreadVisited(
277284): AppState {
278285 const at = visitedAt ?? new Date().toISOString();
279286 const visitedAtMs = Date.parse(at);
280 return {
281 ...state,
282 threads: updateThread(state.threads, threadId, (thread) => {
283 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
284 if (
285 Number.isFinite(previousVisitedAtMs) &&
286 Number.isFinite(visitedAtMs) &&
287 previousVisitedAtMs >= visitedAtMs
288 ) {
289 return thread;
290 }
291 return { ...thread, lastVisitedAt: at };
292 }),
293 };
287 const threads = updateThread(state.threads, threadId, (thread) => {
288 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
289 if (
290 Number.isFinite(previousVisitedAtMs) &&
291 Number.isFinite(visitedAtMs) &&
292 previousVisitedAtMs >= visitedAtMs
293 ) {
294 return thread;
295 }
296 return { ...thread, lastVisitedAt: at };
297 });
298 return threads === state.threads ? state : { ...state, threads };
294299}
295300
296301export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
297 return {
298 ...state,
299 threads: updateThread(state.threads, threadId, (thread) => {
300 if (!thread.latestTurn?.completedAt) return thread;
301 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
302 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
303 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
304 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
305 return { ...thread, lastVisitedAt: unreadVisitedAt };
306 }),
307 };
302 const threads = updateThread(state.threads, threadId, (thread) => {
303 if (!thread.latestTurn?.completedAt) return thread;
304 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
305 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
306 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
307 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
308 return { ...thread, lastVisitedAt: unreadVisitedAt };
309 });
310 return threads === state.threads ? state : { ...state, threads };
308311}
309312
310313export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
......@@ -321,19 +324,21 @@ export function setProjectExpanded(
321324 projectId: Project["id"],
322325 expanded: boolean,
323326): AppState {
324 return {
325 ...state,
326 projects: state.projects.map((p) =>
327 p.id === projectId ? { ...p, expanded } : p,
328 ),
329 };
327 let changed = false;
328 const projects = state.projects.map((p) => {
329 if (p.id !== projectId || p.expanded === expanded) return p;
330 changed = true;
331 return { ...p, expanded };
332 });
333 return changed ? { ...state, projects } : state;
330334}
331335
332336export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
333 return {
334 ...state,
335 threads: updateThread(state.threads, threadId, (t) => ({ ...t, error })),
336 };
337 const threads = updateThread(state.threads, threadId, (t) => {
338 if (t.error === error) return t;
339 return { ...t, error };
340 });
341 return threads === state.threads ? state : { ...state, threads };
337342}
338343
339344export function setThreadBranch(
......@@ -342,21 +347,21 @@ export function setThreadBranch(
342347 branch: string | null,
343348 worktreePath: string | null,
344349): AppState {
345 return {
346 ...state,
347 threads: updateThread(state.threads, threadId, (t) => {
348 const cwdChanged = t.worktreePath !== worktreePath;
349 return {
350 ...t,
351 branch,
352 worktreePath,
353 ...(cwdChanged ? { session: null } : {}),
354 };
355 }),
356 };
350 const threads = updateThread(state.threads, threadId, (t) => {
351 if (t.branch === branch && t.worktreePath === worktreePath) return t;
352 const cwdChanged = t.worktreePath !== worktreePath;
353 return {
354 ...t,
355 branch,
356 worktreePath,
357 ...(cwdChanged ? { session: null } : {}),
358 };
359 });
360 return threads === state.threads ? state : { ...state, threads };
357361}
358362
359363export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
364 if (state.runtimeMode === mode) return state;
360365 return { ...state, runtimeMode: mode };
361366}
362367
......@@ -377,7 +382,7 @@ interface AppStore extends AppState {
377382 setRuntimeMode: (mode: RuntimeMode) => void;
378383}
379384
380export const useStore = create<AppStore>((set, _get) => ({
385export const useStore = create<AppStore>((set) => ({
381386 ...readPersistedState(),
382387 syncServerReadModel: (readModel) =>
383388 set((state) => syncServerReadModel(state, readModel)),
apps/web/src/terminalStateStore.test.ts+8-8
......@@ -1,7 +1,7 @@
11import { ThreadId } from "@t3tools/contracts";
22import { beforeEach, describe, expect, it } from "vitest";
33
4import { useTerminalStateStore } from "./terminalStateStore";
4import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore";
55
66const THREAD_ID = ThreadId.makeUnsafe("thread-1");
77
......@@ -14,7 +14,7 @@ describe("terminalStateStore actions", () => {
1414 });
1515
1616 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);
1818 expect(terminalState).toEqual({
1919 terminalOpen: false,
2020 terminalHeight: 280,
......@@ -31,7 +31,7 @@ describe("terminalStateStore actions", () => {
3131 store.setTerminalOpen(THREAD_ID, true);
3232 store.splitTerminal(THREAD_ID, "terminal-2");
3333
34 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
34 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
3535 expect(terminalState.terminalOpen).toBe(true);
3636 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
3737 expect(terminalState.activeTerminalId).toBe("terminal-2");
......@@ -43,7 +43,7 @@ describe("terminalStateStore actions", () => {
4343 it("creates new terminals in a separate group", () => {
4444 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);
4747 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
4848 expect(terminalState.activeTerminalId).toBe("terminal-2");
4949 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
......@@ -57,12 +57,12 @@ describe("terminalStateStore actions", () => {
5757 const store = useTerminalStateStore.getState();
5858 store.splitTerminal(THREAD_ID, "terminal-2");
5959 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([
6161 "terminal-2",
6262 ]);
6363
6464 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([]);
6666 });
6767
6868 it("resets to default and clears persisted entry when closing the last terminal", () => {
......@@ -70,7 +70,7 @@ describe("terminalStateStore actions", () => {
7070 store.closeTerminal(THREAD_ID, "default");
7171
7272 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"]);
7474 });
7575
7676 it("keeps a valid active terminal after closing an active split terminal", () => {
......@@ -79,7 +79,7 @@ describe("terminalStateStore actions", () => {
7979 store.splitTerminal(THREAD_ID, "terminal-3");
8080 store.closeTerminal(THREAD_ID, "terminal-3");
8181
82 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
82 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
8383 expect(terminalState.activeTerminalId).toBe("terminal-2");
8484 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
8585 expect(terminalState.terminalGroups).toEqual([
apps/web/src/terminalStateStore.ts+9-6
......@@ -163,7 +163,12 @@ const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
163163});
164164
165165function 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 };
167172}
168173
169174function getDefaultThreadTerminalState(): ThreadTerminalState {
......@@ -403,7 +408,7 @@ function setThreadTerminalActivity(
403408 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
404409}
405410
406function selectThreadTerminalState(
411export function selectThreadTerminalState(
407412 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
408413 threadId: ThreadId,
409414): ThreadTerminalState {
......@@ -444,7 +449,6 @@ function updateTerminalStateByThreadId(
444449
445450interface TerminalStateStoreState {
446451 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
447 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
448452 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
449453 setTerminalHeight: (threadId: ThreadId, height: number) => void;
450454 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
......@@ -461,7 +465,7 @@ interface TerminalStateStoreState {
461465
462466export const useTerminalStateStore = create<TerminalStateStoreState>()(
463467 persist(
464 (set, get) => {
468 (set) => {
465469 const updateTerminal = (
466470 threadId: ThreadId,
467471 updater: (state: ThreadTerminalState) => ThreadTerminalState,
......@@ -483,7 +487,6 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
483487
484488 return {
485489 terminalStateByThreadId: {},
486 getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId),
487490 setTerminalOpen: (threadId, open) =>
488491 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
489492 setTerminalHeight: (threadId, height) =>
......@@ -501,7 +504,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
501504 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
502505 ),
503506 clearTerminalState: (threadId) =>
504 updateTerminal(threadId, () => getDefaultThreadTerminalState()),
507 updateTerminal(threadId, () => createDefaultThreadTerminalState()),
505508 };
506509 },
507510 {