Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Refactor web store access to granular Zustand selectors - #141

Merged
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors
Mar 2, 2026
Merged

Refactor web store access to granular Zustand selectors#141
juliusmarminge merged 18 commits into
mainfrom
t3code/zustand-store-granular-selectors

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaced broad useStore() reads in core web UI components with granular selector-based subscriptions to reduce unnecessary re-renders.
  • Migrated multiple dispatch-style store updates to explicit store action selectors (thread error, branch, terminal, runtime mode, visited state, terminal sizing).
  • Updated ChatView command menu highlight handling to local list-based navigation logic (removed hidden CommandInput event forwarding).
  • Improved image preview interaction/accessibility by wrapping preview images in buttons and refining expanded-preview overlay click layering.
  • Adjusted virtualization wiring in MessagesTimeline to pass a concrete scroll container element instead of a ref object.
  • Added local React Doctor skill docs under apps/web/.agents/react-doctor/ for post-change React diagnostics workflow.

Testing

  • Not run (not included in provided commit context).
  • Suggested concrete checks: run bun lint and bun typecheck in the repo root.
  • Suggested concrete checks: verify chat thread interactions in the web app (composer menu navigation, image preview open/close, terminal split/new/close, runtime mode toggle, diff panel turn-strip scrolling).

Note

Medium Risk
Broad refactor of core chat UI state management (threads/projects/runtime mode) and terminal UI state extraction/persistence, which can introduce subtle regressions in navigation, terminal behavior, and rendering lifecycles. No auth/security changes, but it touches frequently-used UI paths.

Overview
Replaces the old reducer/context store with a Zustand store exposing explicit actions (e.g. markThreadVisited, setError, setThreadBranch, setRuntimeMode) and updates components to subscribe via granular selectors instead of broad useStore() reads.

Extracts terminal UI state out of Thread into a new persisted useTerminalStateStore (open/height/ids/groups/running activity), wires terminal activity events + orphan cleanup in the root event router, and updates ChatView/Sidebar/ThreadTerminalDrawer to use the new terminal state/actions.

Includes several UX/lifecycle tweaks: composer command-menu highlight navigation no longer relies on hidden input event forwarding, message timeline virtualization now takes a concrete scroll element, image previews are button-wrapped with improved overlay click layering, and DiffPanel turn-strip wheel scrolling is handled via React onWheel with safer scroll-state updates.

Written by Cursor Bugbot for commit ae56cd1. This will update automatically on new commits. Configure here.

Note

Refactor web app state to use granular Zustand selectors and move per-thread terminal UI into a persisted useTerminalStateStore used by Sidebar, ChatView, and routing

Replace context/reducer with a Zustand store exposing selector-based methods for projects/threads and introduce a persisted terminal state store; update Sidebar, ChatView, BranchToolbar, DiffPanel, and routes to consume selectors and terminal actions; remove terminal fields from types.Thread and adjust tests. Key logic lives in store.ts and terminalStateStore.ts.

📍Where to Start

Start with the store migration in store.ts to see selectors and actions, then review terminal state design in terminalStateStore.ts before scanning consumer changes in apps/web/src/components/Sidebar.tsx and apps/web/src/components/ChatView.tsx.

Macroscope summarized ae56cd1.

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch t3code/zustand-store-granular-selectors

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: React onWheel is passive, preventing scroll interception
    • Restored imperative addEventListener with { passive: false } for the wheel handler and removed the React onWheel JSX prop, so event.preventDefault() works correctly for vertical-to-horizontal scroll conversion.
  • ✅ Fixed: Arrow key highlight doesn't update Autocomplete visual state
    • Restored the hidden CommandInput element and the original nudgeComposerMenuHighlight implementation that dispatches synthetic KeyboardEvents to drive base-ui's internal highlight state.
  • ✅ Fixed: Removed try/finally leaves switching mode flag stuck on error
    • Wrapped the Promise.all call in a try/finally block to guarantee setIsSwitchingRuntimeMode(false) always executes.
  • ✅ Fixed: Removed finally blocks risk permanently stuck send state
    • Restored finally blocks in onSend, onRevertUserMessage, and onRespondToApproval to guarantee critical cleanup (sendInFlightRef, setSendPhase, setIsRevertingCheckpoint, setRespondingRequestIds) always executes.
  • ✅ Fixed: Unused toggleThreadTerminal action defined in store
    • Removed the unused toggleThreadTerminal action from the AppStore interface, the reducer action type union, the reducer case, and the store implementation.

Create PR

Or push these changes by commenting:

@cursor push 2cf69ce16e
Preview (2cf69ce16e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -97,7 +97,7 @@
import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { Command, CommandItem, CommandList } from "./ui/command";+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
import {
@@ -389,6 +389,7 @@
triggerKind: ComposerTriggerKind | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
+ commandInputRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<Command
@@ -400,6 +401,9 @@
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">+ <CommandInput autoFocus={false} ref={props.commandInputRef} />+ </div>
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
@@ -504,6 +508,7 @@
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
const sendInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
@@ -1128,19 +1133,22 @@
if (runningThreadIds.length === 0) return;
setIsSwitchingRuntimeMode(true);
- await Promise.all(- runningThreadIds.map((threadId) =>- api.orchestration- .dispatchCommand({- type: "thread.session.stop",- commandId: newCommandId(),- threadId,- createdAt: new Date().toISOString(),- })- .catch(() => undefined),- ),- );- setIsSwitchingRuntimeMode(false);+ try {+ await Promise.all(+ runningThreadIds.map((threadId) =>+ api.orchestration+ .dispatchCommand({+ type: "thread.session.stop",+ commandId: newCommandId(),+ threadId,+ createdAt: new Date().toISOString(),+ })+ .catch(() => undefined),+ ),+ );+ } finally {+ setIsSwitchingRuntimeMode(false);+ }
};
useEffect(() => {
@@ -1670,8 +1678,9 @@
activeThread.id,
err instanceof Error ? err.message : "Failed to revert thread state.",
);
+ } finally {+ setIsRevertingCheckpoint(false);
}
- setIsRevertingCheckpoint(false);
},
[activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
);
@@ -1877,9 +1886,10 @@
threadIdForSend,
err instanceof Error ? err.message : "Failed to send message.",
);
+ } finally {+ sendInFlightRef.current = false;+ setSendPhase("idle");
}
- sendInFlightRef.current = false;- setSendPhase("idle");
};
const onInterrupt = async () => {
@@ -1915,8 +1925,9 @@
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
+ } finally {+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setThreadErrorAction],
);
@@ -2003,24 +2014,13 @@
const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
setComposerHighlightedItemId(itemId);
}, []);
- const nudgeComposerMenuHighlight = useCallback(- (key: "ArrowDown" | "ArrowUp") => {- if (composerMenuItems.length === 0) {- return;- }- const highlightedIndex = composerMenuItems.findIndex(- (item) => item.id === composerHighlightedItemId,- );- const normalizedIndex =- highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0;- const offset = key === "ArrowDown" ? 1 : -1;- const nextIndex =- (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length;- const nextItem = composerMenuItems[nextIndex];- setComposerHighlightedItemId(nextItem?.id ?? null);- },- [composerHighlightedItemId, composerMenuItems],- );+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {+ const commandInput = composerCommandInputRef.current;+ if (!commandInput) return;+ commandInput.dispatchEvent(+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),+ );+ }, []);
const isComposerMenuLoading =
composerTriggerKind === "path" &&
((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
@@ -2215,6 +2215,7 @@
triggerKind={composerTriggerKind}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
+ commandInputRef={composerCommandInputRef}
/>
</div>
)}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx--- a/apps/web/src/components/DiffPanel.tsx+++ b/apps/web/src/components/DiffPanel.tsx@@ -4,7 +4,7 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
-import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
@@ -346,7 +346,7 @@
if (!element) return;
element.scrollBy({ left: offset, behavior: "smooth" });
}, []);
- const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {+ const onTurnStripWheel = useCallback((event: WheelEvent) => {
const element = turnStripRef.current;
if (!element) return;
if (element.scrollWidth <= element.clientWidth + 1) return;
@@ -364,6 +364,7 @@
const onScroll = () => updateTurnStripScrollState();
element.addEventListener("scroll", onScroll, { passive: true });
+ element.addEventListener("wheel", onTurnStripWheel, { passive: false });
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
resizeObserver.observe(element);
@@ -371,9 +372,10 @@
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener("scroll", onScroll);
+ element.removeEventListener("wheel", onTurnStripWheel);
resizeObserver.disconnect();
};
- }, [updateTurnStripScrollState]);+ }, [updateTurnStripScrollState, onTurnStripWheel]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
@@ -431,7 +433,6 @@
<div
ref={turnStripRef}
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
- onWheel={onTurnStripWheel}
>
<button
type="button"
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts--- a/apps/web/src/store.ts+++ b/apps/web/src/store.ts@@ -35,7 +35,6 @@
hasRunningSubprocess: boolean;
}
| { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
- | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
| { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
| { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
| { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
@@ -71,7 +70,6 @@
hasRunningSubprocess: boolean,
) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- toggleThreadTerminal: (threadId: ThreadId) => void;
setThreadTerminalOpen: (threadId: ThreadId, open: boolean) => void;
setThreadTerminalHeight: (threadId: ThreadId, height: number) => void;
splitThreadTerminal: (threadId: ThreadId, terminalId: string) => void;
@@ -621,15 +619,6 @@
),
};
- case "TOGGLE_THREAD_TERMINAL":- return {- ...state,- threads: updateThread(state.threads, action.threadId, (t) => ({- ...t,- terminalOpen: !t.terminalOpen,- })),- };-
case "SET_THREAD_TERMINAL_OPEN":
return {
...state,
@@ -860,7 +849,6 @@
}),
setProjectExpanded: (projectId, expanded) =>
applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
- toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
setThreadTerminalOpen: (threadId, open) =>
applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
setThreadTerminalHeight: (threadId, height) =>

Comment threadapps/web/src/components/DiffPanel.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/store.ts Outdated
Comment threadapps/web/src/components/ChatView.tsx
- Replace broad `useStore` state reads with targeted selector/action hooks across chat, diff, sidebar, and routing views
- Remove dispatch-style calls in favor of direct store actions to reduce unnecessary rerenders
- Improve chat UI interactions (composer menu navigation, image preview buttons, modal click targets)
- Add local `react-doctor` agent skill docs under `apps/web/.agents/react-doctor`
- Replace `useCallback`-wrapped `useStore` selectors with inline selectors
- Apply across chat, diff, branch toolbar, and chat thread route components
- Remove Action union/reducer dispatch path in `store.ts`
- Extract per-action state transition helpers and apply via `setAppState`
- Update `store.test.ts` to validate behavior through `useStore` actions
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from 02fa1b0 to d6c1bd7CompareMarch 2, 2026 18:21
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/threadTerminalState.ts Outdated

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sent message not scrolled into view when scrolled up
    • Restored shouldAutoScrollRef.current = true and scrollMessagesToBottom() after setOptimisticUserMessages in onSend, which were accidentally removed during the Zustand refactor in commit c283d2a.

Create PR

Or push these changes by commenting:

@cursor push 992afef58e
Preview (992afef58e)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1735,6 +1735,9 @@
},
]);
+ shouldAutoScrollRef.current = true;+ scrollMessagesToBottom();+
setThreadError(threadIdForSend, null);
promptRef.current = "";
clearComposerDraftContent(threadIdForSend);

Comment threadapps/web/src/components/ChatView.tsx
juliusmarmingeand others added 8 commits March 2, 2026 11:01
Adds local terminal state management for draft threads before they're
promoted to server threads. Terminal operations (open, split, create,
close, activate, resize) now work on draft threads and hydrate into
the server thread state when the draft is promoted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor
…irectly
- Delete draftThreadTerminalState.ts (was only re-exporting shared module)
- Replace draftThreadTerminalState.test.ts with threadTerminalState.test.ts
using createDefaultThreadTerminalState and reduceThreadTerminalState
- Draft threads are first-class: same helpers and types as persisted threads
Made-with: Cursor
- Main store: replace useReducer with Zustand; pure state transition
functions (syncServerReadModel, markThreadVisited, setError, etc.)
called via store methods. Persist via subscribe.
- Terminal store: single store keyed by threadId; replace
dispatchTerminalAction with direct methods (setTerminalOpen,
setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal,
closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState
used internally.
- Update all consumers to call dispatch.method(...) and terminal
store methods. Fix store tests to use markThreadUnread pure fn.
- Fix Fragment import in store.ts; fix unused get param for lint.
Made-with: Cursor
Drop the temporary selector overload from useStore and migrate the remaining selector call sites to the unified { state, dispatch } API so the store surface stays single-path.
Made-with: Cursor
- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar
- Split terminal reducer into pure thread terminal operations used by the Zustand store
- Drop persisted default thread entries and add tests for the new operation helpers
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior
Remove duplicate selector declarations introduced during rebase and restore the streamlined AppStore shape so Zustand selector usage remains consistent.
Restore Sidebar cleanup semantics and pure store test assertions so lint/typecheck stay green after rebasing.
Made-with: Cursor
@juliusmarminge
juliusmarmingeforce-pushed the t3code/zustand-store-granular-selectors branch from b789773 to 69a0b7fCompareMarch 2, 2026 19:05
Comment threadapps/web/src/store.ts
Comment threadapps/web/src/components/ChatView.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Duplicate setRespondingRequestIds cleanup call in approval handler
    • Removed the redundant setRespondingRequestIds call after the try/catch/finally block since the finally block already guarantees cleanup.
  • ✅ Fixed: Sidebar terminal status indicators won't live-update
    • Changed Sidebar to subscribe to terminalStateByThreadId (which changes on updates) instead of the stable getTerminalState function reference, and read runningTerminalIds directly from the map.
  • ✅ Fixed: Stale terminalState closure in runProjectScript callback
    • Added terminalState to the runProjectScript useCallback dependency array so the callback always uses current terminal state.
  • ✅ Fixed: Direct store error setter skips local draft threads
    • Replaced setStoreThreadError with the setThreadError wrapper which handles both server threads and local draft threads.

Create PR

Or push these changes by commenting:

@cursor push 21ed0a6ca0
Preview (21ed0a6ca0)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -1146,6 +1146,7 @@
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
+ terminalState,
],
);
const persistProjectScripts = useCallback(
@@ -1871,7 +1872,7 @@
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
- setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2091,7 +2092,6 @@
} finally {
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
}
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -240,7 +240,9 @@
(store) => store.getDraftThreadByProjectId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
- const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);+ const terminalStateByThreadId = useTerminalStateStore(+ (state) => state.terminalStateByThreadId,+ );
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -826,7 +828,7 @@
);
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
const terminalStatus = terminalStatusFromRunningIds(
- getTerminalState(thread.id).runningTerminalIds,+ terminalStateByThreadId[thread.id]?.runningTerminalIds ?? [],
);
return (

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/Sidebar.tsx Outdated
- Mount `ChatView` with `key={threadId}` to fully reset UI state on thread switch
- Replace effect-driven local state with direct derived values and cleaner cleanup paths
- Simplify async command/script flows and minor context memo handling in sidebar/ui components

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed memoization defeats memo on MessagesTimeline
    • Restored useMemo for timelineMessages, timelineEntries, and revertTurnCountByUserMessageId, and useCallback for onRevertUserMessage, so that stable references are passed to the memo-wrapped MessagesTimeline and it no longer re-renders on every keystroke.

Create PR

Or push these changes by commenting:

@cursor push 87361b01eb
Preview (87361b01eb)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -673,7 +673,7 @@
delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
}, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
}, []);
- const timelineMessages = (() => {+ const timelineMessages = useMemo(() => {
const serverMessages = activeThread?.messages ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
@@ -717,8 +717,11 @@
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- })();- const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries);+ }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);+ const timelineEntries = useMemo(+ () => deriveTimelineEntries(timelineMessages, workLogEntries),+ [timelineMessages, workLogEntries],+ );
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
@@ -729,7 +732,7 @@
}
return byMessageId;
}, [turnDiffSummaries]);
- const revertTurnCountByUserMessageId = (() => {+ const revertTurnCountByUserMessageId = useMemo(() => {
const byUserMessageId = new Map<MessageId, number>();
for (let index = 0; index < timelineEntries.length; index += 1) {
const entry = timelineEntries[index];
@@ -760,7 +763,7 @@
}
return byUserMessageId;
- })();+ }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const completionSummary = (() => {
if (!latestTurnSettled) return null;
@@ -2219,13 +2222,16 @@
},
[navigate, threadId],
);
- const onRevertUserMessage = (messageId: MessageId) => {- const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);- if (typeof targetTurnCount !== "number") {- return;- }- void onRevertToTurnCount(targetTurnCount);- };+ const onRevertUserMessage = useCallback(+ (messageId: MessageId) => {+ const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);+ if (typeof targetTurnCount !== "number") {+ return;+ }+ void onRevertToTurnCount(targetTurnCount);+ },+ [revertTurnCountByUserMessageId, onRevertToTurnCount],+ );
// Empty state: no active thread
if (!activeThread) {

Comment threadapps/web/src/components/ChatView.tsx Outdated
- 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>

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal toggle no longer auto-focuses terminal panel
    • Added setTerminalFocusRequestId((value) => value + 1) in the !previous && current (opening) branch of the terminal open useEffect, restoring the focus trigger that was lost during the refactor.
  • ✅ Fixed: Deleted thread terminal state leaks in localStorage
    • Added clearTerminalState(threadId) call in the Sidebar delete handler and a new removeOrphanedTerminalStates action called after syncServerReadModel to prune stale entries for deleted threads.
  • ✅ Fixed: Optimistic message blob URLs leak on unmount
    • Added a ref tracking optimisticUserMessages and revoke their blob URLs via revokeUserMessagePreviewUrls in the existing unmount cleanup effect alongside clearAttachmentPreviewHandoffs.

Create PR

Or push these changes by commenting:

@cursor push 2a6310194a
Preview (2a6310194a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx--- a/apps/web/src/components/ChatView.tsx+++ b/apps/web/src/components/ChatView.tsx@@ -479,6 +479,8 @@
const [isDragOverComposer, setIsDragOverComposer] = useState(false);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
+ const optimisticUserMessagesRef = useRef(optimisticUserMessages);+ optimisticUserMessagesRef.current = optimisticUserMessages;
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
Record<ThreadId, string | null>
>({});
@@ -634,6 +636,9 @@
useEffect(() => {
return () => {
clearAttachmentPreviewHandoffs();
+ for (const message of optimisticUserMessagesRef.current) {+ revokeUserMessagePreviewUrls(message);+ }
};
}, [clearAttachmentPreviewHandoffs]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
@@ -1542,6 +1547,7 @@
if (!previous && current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
+ setTerminalFocusRequestId((value) => value + 1);
return;
} else if (previous && !current) {
terminalOpenByThreadRef.current[activeThreadId] = current;
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx--- a/apps/web/src/components/Sidebar.tsx+++ b/apps/web/src/components/Sidebar.tsx@@ -241,6 +241,7 @@
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
+ const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const clearProjectDraftThreadId = useComposerDraftStore(
@@ -578,6 +579,7 @@
});
clearComposerDraftForThread(threadId);
clearProjectDraftThreadById(thread.projectId, thread.id);
+ clearTerminalState(threadId);
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
void navigate({
@@ -619,6 +621,7 @@
appSettings.confirmThreadDelete,
clearComposerDraftForThread,
clearProjectDraftThreadById,
+ clearTerminalState,
markThreadUnread,
navigate,
projects,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx--- a/apps/web/src/routes/__root.tsx+++ b/apps/web/src/routes/__root.tsx@@ -145,11 +145,20 @@
let syncing = false;
let pending = false;
+ const removeOrphanedTerminalStates =+ useTerminalStateStore.getState().removeOrphanedTerminalStates;+
const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
syncServerReadModel(snapshot);
+ const activeThreadIds = new Set(+ snapshot.threads+ .filter((t) => t.deletedAt === null)+ .map((t) => t.id),+ );+ removeOrphanedTerminalStates(activeThreadIds);
if (pending) {
pending = false;
await flushSnapshotSync();
diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts--- a/apps/web/src/terminalStateStore.ts+++ b/apps/web/src/terminalStateStore.ts@@ -461,6 +461,7 @@
hasRunningSubprocess: boolean,
) => void;
clearTerminalState: (threadId: ThreadId) => void;
+ removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
}
export const useTerminalStateStore = create<TerminalStateStoreState>()(
@@ -505,6 +506,18 @@
),
clearTerminalState: (threadId) =>
updateTerminal(threadId, () => createDefaultThreadTerminalState()),
+ removeOrphanedTerminalStates: (activeThreadIds) =>+ set((state) => {+ const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(+ (id) => !activeThreadIds.has(id as ThreadId),+ );+ if (orphanedIds.length === 0) return state;+ const next = { ...state.terminalStateByThreadId };+ for (const id of orphanedIds) {+ delete next[id as ThreadId];+ }+ return { terminalStateByThreadId: next };+ }),
};
},
{

Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/components/Sidebar.tsx
Comment threadapps/web/src/routes/__root.tsx Outdated
@juliusmarminge

Copy link
Copy Markdown
MemberAuthor

@cursor push 2a6310194a

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@cursor

cursorBot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

cursoragentand others added 4 commits March 2, 2026 12:38
…evoke optimistic message blob URLs on unmount
- Add setTerminalFocusRequestId call when terminal opens via toggle so the
terminal panel receives focus automatically
- Call clearTerminalState when deleting threads in the Sidebar and add
removeOrphanedTerminalStates to prune stale entries after read model sync
- Track optimisticUserMessages via ref and revoke their blob URLs in the
unmount cleanup effect to prevent memory leaks
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
…s 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>
…ential 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>
Comment threadapps/web/src/terminalStateStore.ts
@juliusmarminge
juliusmarminge merged commit fd4ff8c into mainMar 2, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@juliusmarminge@cursoragent