Skip to content

Commit ffc74da

Browse files
authored
🤖 Migrate to Zustand for simpler state management (#243)
Migrates workspace state and git status management to Zustand for simpler, more maintainable architecture. ## What Changed **Replaced hooks with stores + Zustand:** - `useWorkspaceAggregators` → `WorkspaceStore` class + Zustand wrapper - `GitStatusContext` → `GitStatusStore` class + Zustand wrapper **Benefits:** - Centralized business logic in testable store classes - Eliminated manual subscription boilerplate - Fine-grained selector-based rendering (components only re-render when their data changes) - Removed provider nesting from App.tsx **Component changes:** - `AIView`: Uses `useWorkspaceState()` and `useGitStatus()` hooks instead of context - `ProjectSidebar`: Extracted `WorkspaceListItem` component - each workspace subscribes independently instead of all workspaces re-rendering together ## Testing - ✅ 438 tests pass (1 skipped) - ✅ Types check (`make typecheck`)
1 parent 70383db commit ffc74da

23 files changed

+2593
-1193
lines changed

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"write-file-atomic": "^6.0.0",
3535
"zod": "^4.1.11",
3636
"zod-to-json-schema": "^3.24.6",
37+
"zustand": "^5.0.8",
3738
},
3839
"devDependencies": {
3940
"@eslint/js": "^9.36.0",
@@ -2244,6 +2245,8 @@
22442245

22452246
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
22462247

2248+
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
2249+
22472250
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
22482251

22492252
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
"undici": "^7.16.0",
6060
"write-file-atomic": "^6.0.0",
6161
"zod": "^4.1.11",
62-
"zod-to-json-schema": "^3.24.6"
62+
"zod-to-json-schema": "^3.24.6",
63+
"zustand": "^5.0.8"
6364
},
6465
"devDependencies": {
6566
"@eslint/js": "^9.36.0",

‎src/App.tsx‎

Lines changed: 97 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ import { usePersistedState, updatePersistedState } from "./hooks/usePersistedSta
1414
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
1515
import { useProjectManagement } from "./hooks/useProjectManagement";
1616
import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement";
17-
import { useWorkspaceAggregators } from "./hooks/useWorkspaceAggregators";
1817
import { useResumeManager } from "./hooks/useResumeManager";
1918
import { useUnreadTracking } from "./hooks/useUnreadTracking";
2019
import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
20+
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
21+
import { useGitStatusStoreRaw } from "./stores/GitStatusStore";
2122

2223
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
2324
import type { CommandAction } from "./contexts/CommandRegistryContext";
2425
import { CommandPalette } from "./components/CommandPalette";
2526
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
26-
import { GitStatusProvider } from "./contexts/GitStatusContext";
2727

2828
import type { ThinkingLevel } from "./types/thinking";
2929
import { CUSTOM_EVENTS } from "./constants/events";
@@ -167,26 +167,32 @@ function AppInner() {
167167
onSelectedWorkspaceUpdate: setSelectedWorkspace,
168168
});
169169

170-
// Use workspace aggregators hook for message state
171-
const { getWorkspaceState, getAggregator, workspaceStates, workspaceRecency } =
172-
useWorkspaceAggregators(workspaceMetadata);
170+
// NEW: Sync workspace metadata with the stores
171+
const workspaceStore = useWorkspaceStoreRaw();
172+
const gitStatusStore = useGitStatusStoreRaw();
173+
174+
useEffect(() => {
175+
// Only sync when metadata has actually loaded (not empty initial state)
176+
if (workspaceMetadata.size > 0) {
177+
workspaceStore.syncWorkspaces(workspaceMetadata);
178+
}
179+
}, [workspaceMetadata, workspaceStore]);
180+
181+
useEffect(() => {
182+
// Only sync when metadata has actually loaded (not empty initial state)
183+
if (workspaceMetadata.size > 0) {
184+
gitStatusStore.syncWorkspaces(workspaceMetadata);
185+
}
186+
}, [workspaceMetadata, gitStatusStore]);
173187

174188
// Track unread message status for all workspaces
175-
const { unreadStatus, toggleUnread } = useUnreadTracking(selectedWorkspace, workspaceStates);
189+
const { unreadStatus, toggleUnread } = useUnreadTracking(selectedWorkspace);
176190

177191
// Auto-resume interrupted streams on app startup and when failures occur
178-
useResumeManager(workspaceStates);
192+
useResumeManager();
179193

180194
// Handle auto-continue after compaction (when user uses /compact -c)
181-
const { handleCompactStart } = useAutoCompactContinue(workspaceStates);
182-
183-
const streamingModels = new Map<string, string>();
184-
for (const metadata of workspaceMetadata.values()) {
185-
const state = getWorkspaceState(metadata.id);
186-
if (state.canInterrupt) {
187-
streamingModels.set(metadata.id, state.currentModel);
188-
}
189-
}
195+
const { handleCompactStart } = useAutoCompactContinue();
190196

191197
// Sync selectedWorkspace with URL hash
192198
useEffect(() => {
@@ -287,6 +293,9 @@ function AppInner() {
287293
[]
288294
);
289295

296+
// NEW: Get workspace recency from store
297+
const workspaceRecency = useWorkspaceRecency();
298+
290299
// Sort workspaces by recency (most recent first)
291300
// This ensures navigation follows the visual order displayed in the sidebar
292301
const sortedWorkspacesByProject = useMemo(() => {
@@ -490,7 +499,6 @@ function AppInner() {
490499
projects,
491500
workspaceMetadata,
492501
selectedWorkspace,
493-
streamingModels,
494502
getThinkingLevel: getThinkingLevelForWorkspace,
495503
onSetThinkingLevel: setThinkingLevelFromPalette,
496504
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
@@ -510,15 +518,25 @@ function AppInner() {
510518
const unregister = registerSource(() => {
511519
const params = registerParamsRef.current;
512520
if (!params) return [];
513-
const factories = buildCoreSources(params);
521+
522+
// Compute streaming models here (only when command palette opens)
523+
const allStates = workspaceStore.getAllStates();
524+
const streamingModels = new Map<string, string>();
525+
for (const [workspaceId, state] of allStates) {
526+
if (state.canInterrupt) {
527+
streamingModels.set(workspaceId, state.currentModel);
528+
}
529+
}
530+
531+
const factories = buildCoreSources({ ...params, streamingModels });
514532
const actions: CommandAction[] = [];
515533
for (const factory of factories) {
516534
actions.push(...factory());
517535
}
518536
return actions;
519537
});
520538
return unregister;
521-
}, [registerSource]);
539+
}, [registerSource, workspaceStore]);
522540

523541
// Handle keyboard shortcuts
524542
useEffect(() => {
@@ -558,72 +576,67 @@ function AppInner() {
558576
<GlobalFonts />
559577
<GlobalScrollbars />
560578
<Global styles={globalStyles} />
561-
<GitStatusProvider workspaceMetadata={workspaceMetadata}>
562-
<AppContainer>
563-
<LeftSidebar
564-
projects={projects}
565-
workspaceMetadata={workspaceMetadata}
566-
selectedWorkspace={selectedWorkspace}
567-
onSelectWorkspace={setSelectedWorkspace}
568-
onAddProject={() => void addProject()}
569-
onAddWorkspace={(projectPath) => void handleAddWorkspace(projectPath)}
570-
onRemoveProject={(path) => void handleRemoveProject(path)}
571-
onRemoveWorkspace={removeWorkspace}
572-
onRenameWorkspace={renameWorkspace}
573-
getWorkspaceState={getWorkspaceState}
574-
unreadStatus={unreadStatus}
575-
onToggleUnread={toggleUnread}
576-
collapsed={sidebarCollapsed}
577-
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
578-
onGetSecrets={handleGetSecrets}
579-
onUpdateSecrets={handleUpdateSecrets}
580-
sortedWorkspacesByProject={sortedWorkspacesByProject}
581-
/>
582-
<MainContent>
583-
<ContentArea>
584-
{selectedWorkspace ? (
585-
<ErrorBoundary
586-
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
587-
>
588-
<AIView
589-
workspaceId={selectedWorkspace.workspaceId}
590-
projectName={selectedWorkspace.projectName}
591-
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
592-
workspacePath={selectedWorkspace.workspacePath}
593-
workspaceState={getWorkspaceState(selectedWorkspace.workspaceId)}
594-
getAggregator={getAggregator}
595-
onCompactStart={(continueMessage) =>
596-
handleCompactStart(selectedWorkspace.workspaceId, continueMessage)
597-
}
598-
/>
599-
</ErrorBoundary>
600-
) : (
601-
<WelcomeView>
602-
<h2>Welcome to Cmux</h2>
603-
<p>Select a workspace from the sidebar or add a new one to get started.</p>
604-
</WelcomeView>
605-
)}
606-
</ContentArea>
607-
</MainContent>
608-
<CommandPalette
609-
getSlashContext={() => ({
610-
providerNames: [],
611-
workspaceId: selectedWorkspace?.workspaceId,
612-
})}
579+
<AppContainer>
580+
<LeftSidebar
581+
projects={projects}
582+
workspaceMetadata={workspaceMetadata}
583+
selectedWorkspace={selectedWorkspace}
584+
onSelectWorkspace={setSelectedWorkspace}
585+
onAddProject={() => void addProject()}
586+
onAddWorkspace={(projectPath) => void handleAddWorkspace(projectPath)}
587+
onRemoveProject={(path) => void handleRemoveProject(path)}
588+
onRemoveWorkspace={removeWorkspace}
589+
onRenameWorkspace={renameWorkspace}
590+
unreadStatus={unreadStatus}
591+
onToggleUnread={toggleUnread}
592+
collapsed={sidebarCollapsed}
593+
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
594+
onGetSecrets={handleGetSecrets}
595+
onUpdateSecrets={handleUpdateSecrets}
596+
sortedWorkspacesByProject={sortedWorkspacesByProject}
597+
/>
598+
<MainContent>
599+
<ContentArea>
600+
{selectedWorkspace ? (
601+
<ErrorBoundary
602+
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
603+
>
604+
<AIView
605+
workspaceId={selectedWorkspace.workspaceId}
606+
projectName={selectedWorkspace.projectName}
607+
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
608+
workspacePath={selectedWorkspace.workspacePath}
609+
onCompactStart={(continueMessage) =>
610+
handleCompactStart(selectedWorkspace.workspaceId, continueMessage)
611+
}
612+
/>
613+
</ErrorBoundary>
614+
) : (
615+
<WelcomeView>
616+
<h2>Welcome to Cmux</h2>
617+
<p>Select a workspace from the sidebar or add a new one to get started.</p>
618+
</WelcomeView>
619+
)}
620+
</ContentArea>
621+
</MainContent>
622+
<CommandPalette
623+
getSlashContext={() => ({
624+
providerNames: [],
625+
workspaceId: selectedWorkspace?.workspaceId,
626+
})}
627+
/>
628+
{workspaceModalOpen && workspaceModalProject && (
629+
<NewWorkspaceModal
630+
isOpen={workspaceModalOpen}
631+
projectPath={workspaceModalProject}
632+
onClose={() => {
633+
setWorkspaceModalOpen(false);
634+
setWorkspaceModalProject(null);
635+
}}
636+
onAdd={handleCreateWorkspace}
613637
/>
614-
{workspaceModalOpen && workspaceModalProject && (
615-
<NewWorkspaceModal
616-
isOpen={workspaceModalOpen}
617-
projectPath={workspaceModalProject}
618-
onClose={() => {
619-
setWorkspaceModalOpen(false);
620-
setWorkspaceModalProject(null);
621-
}}
622-
onAdd={handleCreateWorkspace}
623-
/>
624-
)}
625-
</AppContainer>
626-
</GitStatusProvider>
638+
)}
639+
</AppContainer>
627640
</>
628641
);
629642
}

0 commit comments

Comments
 (0)