From 4ae9fcf66177a7aa8e61a7e0413f94b731cf115b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 16 Nov 2025 15:56:44 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20sync=20workspace=20store?= =?UTF-8?q?=20before=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contexts/WorkspaceContext.test.tsx | 63 ++++++++++++++++++- src/browser/contexts/WorkspaceContext.tsx | 25 +++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 119f12817f..a894a21f8d 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -7,6 +7,7 @@ import { GlobalWindow } from "happy-dom"; import type { WorkspaceContext } from "./WorkspaceContext"; import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; // Helper to create test workspace metadata with default runtime config const createWorkspaceMetadata = ( @@ -25,6 +26,9 @@ describe("WorkspaceContext", () => { afterEach(() => { cleanup(); + // Reset global workspace store to avoid cross-test leakage + useWorkspaceStoreRaw().dispose(); + // @ts-expect-error - Resetting global state in tests globalThis.window = undefined; // @ts-expect-error - Resetting global state in tests @@ -33,6 +37,57 @@ describe("WorkspaceContext", () => { globalThis.localStorage = undefined; }); + test("syncs workspace store subscriptions when metadata loads", async () => { + const initialWorkspaces: FrontendWorkspaceMetadata[] = [ + createWorkspaceMetadata({ + id: "ws-sync-load", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + }), + ]; + + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve(initialWorkspaces), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + await waitFor(() => + expect( + workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-sync-load") + ).toBe(true) + ); + }); + + test("subscribes to new workspace immediately when metadata event fires", async () => { + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + }, + }); + + await setup(); + + await waitFor(() => expect(workspaceApi.onMetadata.mock.calls.length).toBeGreaterThan(0)); + const metadataListener: Parameters[0] = + workspaceApi.onMetadata.mock.calls[0][0]; + + const newWorkspace = createWorkspaceMetadata({ id: "ws-from-event" }); + act(() => { + metadataListener({ workspaceId: newWorkspace.id, metadata: newWorkspace }); + }); + + await waitFor(() => + expect( + workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-from-event") + ).toBe(true) + ); + }); test("loads workspace metadata on mount", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ createWorkspaceMetadata({ @@ -884,7 +939,7 @@ type MockedWorkspaceAPI = Pick< { [K in keyof IPCApi["workspace"]]: ReturnType>; }, - "create" | "list" | "remove" | "rename" | "getInfo" | "onMetadata" + "create" | "list" | "remove" | "rename" | "getInfo" | "onMetadata" | "onChat" >; // Just type the list method directly since Pick with conditional types causes issues @@ -941,6 +996,12 @@ function createMockAPI(options: MockAPIOptions = {}) { // Empty cleanup function }) ), + onChat: mock( + options.workspace?.onChat ?? + ((_workspaceId: string, _callback: Parameters[1]) => () => { + // Empty cleanup function + }) + ), }; // Create projects API with proper types diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 2289fe5c02..74d8441aca 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -6,6 +6,7 @@ import { useMemo, useState, type ReactNode, + type SetStateAction, } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; @@ -13,6 +14,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage } from "@/common/constants/storage"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; /** * Ensure workspace metadata has createdAt timestamp. @@ -81,9 +83,25 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Get project refresh function from ProjectContext const { refreshProjects } = useProjectContext(); - const [workspaceMetadata, setWorkspaceMetadata] = useState< + const workspaceStore = useWorkspaceStoreRaw(); + const [workspaceMetadata, setWorkspaceMetadataState] = useState< Map >(new Map()); + const setWorkspaceMetadata = useCallback( + (update: SetStateAction>) => { + setWorkspaceMetadataState((prev) => { + const next = typeof update === "function" ? update(prev) : update; + // IMPORTANT: Sync the imperative WorkspaceStore first so hooks (AIView, + // LeftSidebar, etc.) never render with a selected workspace ID before + // the store has subscribed and created its aggregator. Otherwise the + // render path hits WorkspaceStore.assertGet() and throws the + // "Workspace not found - must call addWorkspace() first" assert. + workspaceStore.syncWorkspaces(next); + return next; + }); + }, + [workspaceStore] + ); const [loading, setLoading] = useState(true); const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); @@ -107,7 +125,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { console.error("Failed to load workspace metadata:", error); setWorkspaceMetadata(new Map()); } - }, []); + }, [setWorkspaceMetadata]); // Load metadata once on mount useEffect(() => { @@ -215,7 +233,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return () => { unsubscribe(); }; - }, [refreshProjects]); + }, [refreshProjects, setWorkspaceMetadata]); const createWorkspace = useCallback( async ( @@ -385,6 +403,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { removeWorkspace, renameWorkspace, refreshWorkspaceMetadata, + setWorkspaceMetadata, selectedWorkspace, setSelectedWorkspace, pendingNewWorkspaceProject,