Skip to content

Commit 22d0bce

Browse files
authored
🤖 fix: sync workspace store during metadata updates (#641)
## Summary - ensure WorkspaceProvider syncs WorkspaceStore immediately whenever metadata changes so new workspaces exist before React mounts - add tests that cover initial metadata load plus live metadata events to confirm subscriptions and reset global store between runs ## Testing - bun test src/browser/contexts/WorkspaceContext.test.tsx - make typecheck _Generated with `mux`_
1 parent dccf6a4 commit 22d0bce

File tree

2 files changed

+84
-4
lines changed

2 files changed

+84
-4
lines changed

src/browser/contexts/WorkspaceContext.test.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GlobalWindow } from "happy-dom";
77
import type { WorkspaceContext } from "./WorkspaceContext";
88
import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext";
99
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
10+
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
1011

1112
// Helper to create test workspace metadata with default runtime config
1213
const createWorkspaceMetadata = (
@@ -25,6 +26,9 @@ describe("WorkspaceContext", () => {
2526
afterEach(() => {
2627
cleanup();
2728

29+
// Reset global workspace store to avoid cross-test leakage
30+
useWorkspaceStoreRaw().dispose();
31+
2832
// @ts-expect-error - Resetting global state in tests
2933
globalThis.window = undefined;
3034
// @ts-expect-error - Resetting global state in tests
@@ -33,6 +37,57 @@ describe("WorkspaceContext", () => {
3337
globalThis.localStorage = undefined;
3438
});
3539

40+
test("syncs workspace store subscriptions when metadata loads", async () => {
41+
const initialWorkspaces: FrontendWorkspaceMetadata[] = [
42+
createWorkspaceMetadata({
43+
id: "ws-sync-load",
44+
projectPath: "/alpha",
45+
projectName: "alpha",
46+
name: "main",
47+
namedWorkspacePath: "/alpha-main",
48+
}),
49+
];
50+
51+
const { workspace: workspaceApi } = createMockAPI({
52+
workspace: {
53+
list: () => Promise.resolve(initialWorkspaces),
54+
},
55+
});
56+
57+
const ctx = await setup();
58+
59+
await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1));
60+
await waitFor(() =>
61+
expect(
62+
workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-sync-load")
63+
).toBe(true)
64+
);
65+
});
66+
67+
test("subscribes to new workspace immediately when metadata event fires", async () => {
68+
const { workspace: workspaceApi } = createMockAPI({
69+
workspace: {
70+
list: () => Promise.resolve([]),
71+
},
72+
});
73+
74+
await setup();
75+
76+
await waitFor(() => expect(workspaceApi.onMetadata.mock.calls.length).toBeGreaterThan(0));
77+
const metadataListener: Parameters<IPCApi["workspace"]["onMetadata"]>[0] =
78+
workspaceApi.onMetadata.mock.calls[0][0];
79+
80+
const newWorkspace = createWorkspaceMetadata({ id: "ws-from-event" });
81+
act(() => {
82+
metadataListener({ workspaceId: newWorkspace.id, metadata: newWorkspace });
83+
});
84+
85+
await waitFor(() =>
86+
expect(
87+
workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-from-event")
88+
).toBe(true)
89+
);
90+
});
3691
test("loads workspace metadata on mount", async () => {
3792
const initialWorkspaces: FrontendWorkspaceMetadata[] = [
3893
createWorkspaceMetadata({
@@ -884,7 +939,7 @@ type MockedWorkspaceAPI = Pick<
884939
{
885940
[K in keyof IPCApi["workspace"]]: ReturnType<typeof mock<IPCApi["workspace"][K]>>;
886941
},
887-
"create" | "list" | "remove" | "rename" | "getInfo" | "onMetadata"
942+
"create" | "list" | "remove" | "rename" | "getInfo" | "onMetadata" | "onChat"
888943
>;
889944

890945
// Just type the list method directly since Pick with conditional types causes issues
@@ -941,6 +996,12 @@ function createMockAPI(options: MockAPIOptions = {}) {
941996
// Empty cleanup function
942997
})
943998
),
999+
onChat: mock(
1000+
options.workspace?.onChat ??
1001+
((_workspaceId: string, _callback: Parameters<IPCApi["workspace"]["onChat"]>[1]) => () => {
1002+
// Empty cleanup function
1003+
})
1004+
),
9441005
};
9451006

9461007
// Create projects API with proper types

src/browser/contexts/WorkspaceContext.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
useMemo,
77
useState,
88
type ReactNode,
9+
type SetStateAction,
910
} from "react";
1011
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
1112
import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar";
1213
import type { RuntimeConfig } from "@/common/types/runtime";
1314
import { deleteWorkspaceStorage } from "@/common/constants/storage";
1415
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1516
import { useProjectContext } from "@/browser/contexts/ProjectContext";
17+
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
1618

1719
/**
1820
* Ensure workspace metadata has createdAt timestamp.
@@ -81,9 +83,25 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
8183
// Get project refresh function from ProjectContext
8284
const { refreshProjects } = useProjectContext();
8385

84-
const [workspaceMetadata, setWorkspaceMetadata] = useState<
86+
const workspaceStore = useWorkspaceStoreRaw();
87+
const [workspaceMetadata, setWorkspaceMetadataState] = useState<
8588
Map<string, FrontendWorkspaceMetadata>
8689
>(new Map());
90+
const setWorkspaceMetadata = useCallback(
91+
(update: SetStateAction<Map<string, FrontendWorkspaceMetadata>>) => {
92+
setWorkspaceMetadataState((prev) => {
93+
const next = typeof update === "function" ? update(prev) : update;
94+
// IMPORTANT: Sync the imperative WorkspaceStore first so hooks (AIView,
95+
// LeftSidebar, etc.) never render with a selected workspace ID before
96+
// the store has subscribed and created its aggregator. Otherwise the
97+
// render path hits WorkspaceStore.assertGet() and throws the
98+
// "Workspace <id> not found - must call addWorkspace() first" assert.
99+
workspaceStore.syncWorkspaces(next);
100+
return next;
101+
});
102+
},
103+
[workspaceStore]
104+
);
87105
const [loading, setLoading] = useState(true);
88106
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);
89107

@@ -107,7 +125,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
107125
console.error("Failed to load workspace metadata:", error);
108126
setWorkspaceMetadata(new Map());
109127
}
110-
}, []);
128+
}, [setWorkspaceMetadata]);
111129

112130
// Load metadata once on mount
113131
useEffect(() => {
@@ -215,7 +233,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
215233
return () => {
216234
unsubscribe();
217235
};
218-
}, [refreshProjects]);
236+
}, [refreshProjects, setWorkspaceMetadata]);
219237

220238
const createWorkspace = useCallback(
221239
async (
@@ -385,6 +403,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
385403
removeWorkspace,
386404
renameWorkspace,
387405
refreshWorkspaceMetadata,
406+
setWorkspaceMetadata,
388407
selectedWorkspace,
389408
setSelectedWorkspace,
390409
pendingNewWorkspaceProject,

0 commit comments

Comments
 (0)