Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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
Expand All @@ -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<IPCApi["workspace"]["onMetadata"]>[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({
Expand Down Expand Up @@ -884,7 +939,7 @@ type MockedWorkspaceAPI = Pick<
{
[K in keyof IPCApi["workspace"]]: ReturnType<typeof mock<IPCApi["workspace"][K]>>;
},
"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
Expand Down Expand Up @@ -941,6 +996,12 @@ function createMockAPI(options: MockAPIOptions = {}) {
// Empty cleanup function
})
),
onChat: mock(
options.workspace?.onChat ??
((_workspaceId: string, _callback: Parameters<IPCApi["workspace"]["onChat"]>[1]) => () => {
// Empty cleanup function
})
),
};

// Create projects API with proper types
Expand Down
25 changes: 22 additions & 3 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
useMemo,
useState,
type ReactNode,
type SetStateAction,
} from "react";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar";
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.
Expand Down Expand Up @@ -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<string, FrontendWorkspaceMetadata>
>(new Map());
const setWorkspaceMetadata = useCallback(
(update: SetStateAction<Map<string, FrontendWorkspaceMetadata>>) => {
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 <id> not found - must call addWorkspace() first" assert.
workspaceStore.syncWorkspaces(next);
return next;
});
},
[workspaceStore]
);
const [loading, setLoading] = useState(true);
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);

Expand All @@ -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(() => {
Expand Down Expand Up @@ -215,7 +233,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
return () => {
unsubscribe();
};
}, [refreshProjects]);
}, [refreshProjects, setWorkspaceMetadata]);

const createWorkspace = useCallback(
async (
Expand Down Expand Up @@ -385,6 +403,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
removeWorkspace,
renameWorkspace,
refreshWorkspaceMetadata,
setWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
pendingNewWorkspaceProject,
Expand Down