From 153ed5caaab56dddd0cb64bf91c677804ad09b5e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 14 Nov 2025 17:09:21 -0500 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20split=20worksp?= =?UTF-8?q?aces=20into=20WorkspaceContext=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the pattern from PR #600 (projects split), this moves workspace state management from useWorkspaceManagement hook into a dedicated WorkspaceContext. - Created WorkspaceContext with workspace metadata, operations (create/remove/rename), and selection state - Comprehensive test coverage (14 tests) matching ProjectContext test patterns - Updated AppLoader to use WorkspaceProvider wrapper - Maintains same API surface for existing components via AppContext pass-through - Tests verify metadata loading, CRUD operations, event subscriptions, and error handling This eliminates prop drilling and centralizes workspace state management, making it easier to test and maintain workspace operations independently. --- src/components/AppLoader.tsx | 84 +-- src/contexts/WorkspaceContext.test.tsx | 722 +++++++++++++++++++++++++ src/contexts/WorkspaceContext.tsx | 331 ++++++++++++ 3 files changed, 1096 insertions(+), 41 deletions(-) create mode 100644 src/contexts/WorkspaceContext.test.tsx create mode 100644 src/contexts/WorkspaceContext.tsx diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index cf0079d4c..05a55435c 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from "react"; import App from "../App"; import { LoadingScreen } from "./LoadingScreen"; -import { useWorkspaceManagement } from "../hooks/useWorkspaceManagement"; import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore"; import { useGitStatusStoreRaw } from "../stores/GitStatusStore"; import { usePersistedState } from "../hooks/usePersistedState"; import type { WorkspaceSelection } from "./ProjectSidebar"; import { AppProvider } from "../contexts/AppContext"; import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext"; +import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; /** * AppLoader handles all initialization before rendering the main App: @@ -22,12 +22,12 @@ import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext"; export function AppLoader() { return ( - + ); } -function AppLoaderInner() { +function AppLoaderMiddle() { // Workspace selection - restored from localStorage immediately const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( "selectedWorkspace", @@ -36,13 +36,26 @@ function AppLoaderInner() { const { refreshProjects } = useProjectContext(); - // Load workspace metadata - // Pass empty callbacks for now - App will provide the actual handlers - const workspaceManagement = useWorkspaceManagement({ - selectedWorkspace, - onProjectsRefresh: refreshProjects, - onSelectedWorkspaceUpdate: setSelectedWorkspace, - }); + // Wrap with WorkspaceProvider + return ( + + + + ); +} + +function AppLoaderInner(props: { + selectedWorkspace: WorkspaceSelection | null; + setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void; +}) { + const workspaceContext = useWorkspaceContext(); // Get store instances const workspaceStore = useWorkspaceStoreRaw(); @@ -53,16 +66,16 @@ function AppLoaderInner() { // Sync stores when metadata finishes loading useEffect(() => { - if (!workspaceManagement.loading) { - workspaceStore.syncWorkspaces(workspaceManagement.workspaceMetadata); - gitStatusStore.syncWorkspaces(workspaceManagement.workspaceMetadata); + if (!workspaceContext.loading) { + workspaceStore.syncWorkspaces(workspaceContext.workspaceMetadata); + gitStatusStore.syncWorkspaces(workspaceContext.workspaceMetadata); setStoresSynced(true); } else { setStoresSynced(false); } }, [ - workspaceManagement.loading, - workspaceManagement.workspaceMetadata, + workspaceContext.loading, + workspaceContext.workspaceMetadata, workspaceStore, gitStatusStore, ]); @@ -82,11 +95,11 @@ function AppLoaderInner() { const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); // Find workspace in metadata - const metadata = workspaceManagement.workspaceMetadata.get(workspaceId); + const metadata = workspaceContext.workspaceMetadata.get(workspaceId); if (metadata) { // Restore from hash (overrides localStorage) - setSelectedWorkspace({ + props.setSelectedWorkspace({ workspaceId: metadata.id, projectPath: metadata.projectPath, projectName: metadata.projectName, @@ -96,12 +109,7 @@ function AppLoaderInner() { } setHasRestoredFromHash(true); - }, [ - storesSynced, - workspaceManagement.workspaceMetadata, - hasRestoredFromHash, - setSelectedWorkspace, - ]); + }, [storesSynced, workspaceContext.workspaceMetadata, hasRestoredFromHash, props]); // Check for launch project from server (for --add-project flag) // This only applies in server mode @@ -110,7 +118,7 @@ function AppLoaderInner() { if (!storesSynced || !hasRestoredFromHash) return; // Skip if we already have a selected workspace (from localStorage or URL hash) - if (selectedWorkspace) return; + if (props.selectedWorkspace) return; // Only check once const checkLaunchProject = async () => { @@ -121,14 +129,14 @@ function AppLoaderInner() { if (!launchProjectPath) return; // Find first workspace in this project - const projectWorkspaces = Array.from(workspaceManagement.workspaceMetadata.values()).filter( + const projectWorkspaces = Array.from(workspaceContext.workspaceMetadata.values()).filter( (meta) => meta.projectPath === launchProjectPath ); if (projectWorkspaces.length > 0) { // Select the first workspace in the project const metadata = projectWorkspaces[0]; - setSelectedWorkspace({ + props.setSelectedWorkspace({ workspaceId: metadata.id, projectPath: metadata.projectPath, projectName: metadata.projectName, @@ -140,29 +148,23 @@ function AppLoaderInner() { }; void checkLaunchProject(); - }, [ - storesSynced, - hasRestoredFromHash, - selectedWorkspace, - workspaceManagement.workspaceMetadata, - setSelectedWorkspace, - ]); + }, [storesSynced, hasRestoredFromHash, workspaceContext.workspaceMetadata, props]); // Show loading screen until stores are synced - if (workspaceManagement.loading || !storesSynced) { + if (workspaceContext.loading || !storesSynced) { return ; } // Render App with all initialized data via context return ( diff --git a/src/contexts/WorkspaceContext.test.tsx b/src/contexts/WorkspaceContext.test.tsx new file mode 100644 index 000000000..1a1dce4ed --- /dev/null +++ b/src/contexts/WorkspaceContext.test.tsx @@ -0,0 +1,722 @@ +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { IPCApi } from "@/types/ipc"; +import type { WorkspaceSelection } from "@/components/ProjectSidebar"; +import type { ProjectConfig } from "@/config"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import type { WorkspaceContext } from "./WorkspaceContext"; +import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; + +// Helper to create test workspace metadata with default runtime config +const createWorkspaceMetadata = ( + overrides: Partial & Pick +): FrontendWorkspaceMetadata => ({ + projectPath: "/test", + projectName: "test", + name: "main", + namedWorkspacePath: "/test-main", + createdAt: "2025-01-01T00:00:00.000Z", + runtimeConfig: { type: "local", srcBaseDir: "/home/user/.mux/src" }, + ...overrides, +}); + +describe("WorkspaceContext", () => { + afterEach(() => { + cleanup(); + + // @ts-expect-error - Resetting global state in tests + globalThis.window = undefined; + // @ts-expect-error - Resetting global state in tests + globalThis.document = undefined; + // @ts-expect-error - Resetting global state in tests + globalThis.localStorage = undefined; + }); + + test("loads workspace metadata on mount", async () => { + const initialWorkspaces: FrontendWorkspaceMetadata[] = [ + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }), + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/beta", + projectName: "beta", + name: "dev", + namedWorkspacePath: "/beta-dev", + createdAt: "2025-01-02T00:00:00.000Z", + }), + ]; + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve(initialWorkspaces), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(2)); + expect(workspaceApi.list).toHaveBeenCalled(); + expect(ctx().loading).toBe(false); + expect(ctx().workspaceMetadata.has("ws-1")).toBe(true); + expect(ctx().workspaceMetadata.has("ws-2")).toBe(true); + }); + + test("sets empty map on API error during load", async () => { + createMockAPI({ + list: () => Promise.reject(new Error("network failure")), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + // Should have empty workspaces after failed load + await waitFor(() => { + expect(ctx().workspaceMetadata.size).toBe(0); + expect(ctx().loading).toBe(false); + }); + }); + + test("refreshWorkspaceMetadata reloads workspace data", async () => { + const initialWorkspaces: FrontendWorkspaceMetadata[] = [ + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }), + ]; + + const updatedWorkspaces: FrontendWorkspaceMetadata[] = [ + ...initialWorkspaces, + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/beta", + projectName: "beta", + name: "dev", + namedWorkspacePath: "/beta-dev", + createdAt: "2025-01-02T00:00:00.000Z", + }), + ]; + + let callCount = 0; + const workspaceApi = createMockAPI({ + list: () => { + callCount++; + return Promise.resolve(callCount === 1 ? initialWorkspaces : updatedWorkspaces); + }, + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + + await act(async () => { + await ctx().refreshWorkspaceMetadata(); + }); + + expect(ctx().workspaceMetadata.size).toBe(2); + expect(workspaceApi.list.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + test("createWorkspace creates new workspace and reloads data", async () => { + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-new", + projectPath: "/gamma", + projectName: "gamma", + name: "feature", + namedWorkspacePath: "/gamma-feature", + createdAt: "2025-01-03T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([]), + create: () => + Promise.resolve({ + success: true as const, + metadata: newWorkspace, + }), + }); + + const projectsApi = createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().createWorkspace("/gamma", "feature", "main"); + }); + + expect(workspaceApi.create).toHaveBeenCalledWith("/gamma", "feature", "main", undefined); + expect(projectsApi.list).toHaveBeenCalled(); + expect(result!.workspaceId).toBe("ws-new"); + expect(result!.projectPath).toBe("/gamma"); + expect(result!.projectName).toBe("gamma"); + }); + + test("createWorkspace throws on failure", async () => { + createMockAPI({ + list: () => Promise.resolve([]), + create: () => + Promise.resolve({ + success: false, + error: "Failed to create workspace", + }), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + await expect(async () => { + await act(async () => { + await ctx().createWorkspace("/gamma", "feature", "main"); + }); + }).toThrow("Failed to create workspace"); + }); + + test("removeWorkspace removes workspace and clears selection if active", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([workspace]), + remove: () => Promise.resolve({ success: true as const }), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const selectedWorkspace: WorkspaceSelection = { + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }; + + const ctx = await setup({ + selectedWorkspace, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().removeWorkspace("ws-1"); + }); + + expect(workspaceApi.remove).toHaveBeenCalledWith("ws-1", undefined); + expect(result!.success).toBe(true); + expect(onSelectedWorkspaceUpdate).toHaveBeenCalledWith(null); + }); + + test("removeWorkspace handles failure gracefully", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([workspace]), + remove: () => Promise.resolve({ success: false, error: "Permission denied" }), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().removeWorkspace("ws-1"); + }); + + expect(workspaceApi.remove).toHaveBeenCalledWith("ws-1", undefined); + expect(result!.success).toBe(false); + expect(result!.error).toBe("Permission denied"); + }); + + test("renameWorkspace renames workspace and updates selection if active", async () => { + const oldWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/alpha", + projectName: "alpha", + name: "renamed", + namedWorkspacePath: "/alpha-renamed", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([oldWorkspace]), + rename: () => + Promise.resolve({ + success: true as const, + data: { newWorkspaceId: "ws-2" }, + }), + getInfo: (workspaceId: string) => { + if (workspaceId === "ws-2") { + return Promise.resolve(newWorkspace); + } + return Promise.resolve(null); + }, + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const selectedWorkspace: WorkspaceSelection = { + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }; + + const ctx = await setup({ + selectedWorkspace, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().renameWorkspace("ws-1", "renamed"); + }); + + expect(workspaceApi.rename).toHaveBeenCalledWith("ws-1", "renamed"); + expect(result!.success).toBe(true); + expect(workspaceApi.getInfo).toHaveBeenCalledWith("ws-2"); + expect(onSelectedWorkspaceUpdate).toHaveBeenCalledWith({ + workspaceId: "ws-2", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-renamed", + }); + }); + + test("renameWorkspace handles failure gracefully", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([workspace]), + rename: () => Promise.resolve({ success: false, error: "Name already exists" }), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().renameWorkspace("ws-1", "renamed"); + }); + + expect(workspaceApi.rename).toHaveBeenCalledWith("ws-1", "renamed"); + expect(result!.success).toBe(false); + expect(result!.error).toBe("Name already exists"); + }); + + test("getWorkspaceInfo fetches workspace metadata", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const workspaceApi = createMockAPI({ + list: () => Promise.resolve([]), + getInfo: (workspaceId: string) => { + if (workspaceId === "ws-1") { + return Promise.resolve(workspace); + } + return Promise.resolve(null); + }, + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + const info = await ctx().getWorkspaceInfo("ws-1"); + expect(workspaceApi.getInfo).toHaveBeenCalledWith("ws-1"); + expect(info).toEqual(workspace); + }); + + test("tracks pending workspace creation state", async () => { + createMockAPI({ + list: () => Promise.resolve([]), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + expect(ctx().pendingNewWorkspaceProject).toBeNull(); + + act(() => { + ctx().beginWorkspaceCreation("/alpha"); + }); + expect(ctx().pendingNewWorkspaceProject).toBe("/alpha"); + + act(() => { + ctx().clearPendingWorkspaceCreation(); + }); + expect(ctx().pendingNewWorkspaceProject).toBeNull(); + }); + + test("reacts to metadata update events (new workspace)", async () => { + let metadataListener: ((event: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }) => void) | null = null; + + createMockAPI({ + list: () => Promise.resolve([]), + onMetadata: (listener: any) => { + metadataListener = listener; + return () => { + metadataListener = null; + }; + }, + }); + + const projectsApi = createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-new", + projectPath: "/gamma", + projectName: "gamma", + name: "feature", + namedWorkspacePath: "/gamma-feature", + createdAt: "2025-01-03T00:00:00.000Z", + }); + + await act(async () => { + metadataListener!({ workspaceId: "ws-new", metadata: newWorkspace }); + // Give async side effects time to run + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(ctx().workspaceMetadata.has("ws-new")).toBe(true); + // Should reload projects when new workspace is created + expect(projectsApi.list.mock.calls.length).toBeGreaterThan(1); + }); + + test("reacts to metadata update events (delete workspace)", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + let metadataListener: ((event: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }) => void) | null = null; + + createMockAPI({ + list: () => Promise.resolve([workspace]), + onMetadata: (listener: any) => { + metadataListener = listener; + return () => { + metadataListener = null; + }; + }, + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().workspaceMetadata.has("ws-1")).toBe(true)); + + act(() => { + metadataListener!({ workspaceId: "ws-1", metadata: null }); + }); + + expect(ctx().workspaceMetadata.has("ws-1")).toBe(false); + }); + + test("ensureCreatedAt adds default timestamp when missing", async () => { + const workspaceWithoutTimestamp: FrontendWorkspaceMetadata = { + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + } as FrontendWorkspaceMetadata; + + createMockAPI({ + list: () => Promise.resolve([workspaceWithoutTimestamp]), + }); + + createMockProjectsAPI({ + list: () => Promise.resolve([]), + }); + + const onProjectsUpdate = mock(() => {}); + const onSelectedWorkspaceUpdate = mock(() => {}); + + const ctx = await setup({ + selectedWorkspace: null, + onProjectsUpdate, + onSelectedWorkspaceUpdate, + }); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + + const metadata = ctx().workspaceMetadata.get("ws-1"); + expect(metadata?.createdAt).toBe("2025-01-01T00:00:00.000Z"); + }); +}); + +async function setup(props: { + selectedWorkspace: WorkspaceSelection | null; + onProjectsUpdate: (projects: Map) => void; + onSelectedWorkspaceUpdate: (workspace: WorkspaceSelection | null) => void; +}) { + const contextRef = { current: null as WorkspaceContext | null }; + function ContextCapture() { + contextRef.current = useWorkspaceContext(); + return null; + } + render( + + + + ); + await waitFor(() => expect(contextRef.current).toBeTruthy()); + return () => contextRef.current!; +} + +function createMockAPI(overrides: Partial) { + const workspace = { + create: mock( + overrides.create ?? + (() => + Promise.resolve({ + success: true as const, + metadata: createWorkspaceMetadata({ id: "ws-1" }), + })) + ), + list: mock(overrides.list ?? (() => Promise.resolve([]))), + remove: mock( + overrides.remove ?? (() => Promise.resolve({ success: true as const, data: undefined })) + ), + rename: mock( + overrides.rename ?? + (() => + Promise.resolve({ + success: true as const, + data: { newWorkspaceId: "ws-1" }, + })) + ), + getInfo: mock(overrides.getInfo ?? (() => Promise.resolve(null))), + onMetadata: mock( + overrides.onMetadata ?? + (() => { + return () => {}; + }) + ), + } as any; + + globalThis.window = new GlobalWindow() as any; + globalThis.window.api = { + workspace, + } as any; + globalThis.document = globalThis.window.document; + globalThis.localStorage = globalThis.window.localStorage; + + return workspace; +} + +function createMockProjectsAPI(overrides: Partial) { + const projects = { + list: mock(overrides.list ?? (() => Promise.resolve([]))), + } as any; + + if (!globalThis.window) { + globalThis.window = new GlobalWindow() as any; + globalThis.document = globalThis.window.document; + } + + if (!globalThis.window.api) { + globalThis.window.api = {} as any; + } + + globalThis.window.api.projects = projects; + + return projects; +} diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx new file mode 100644 index 000000000..a9d4421ac --- /dev/null +++ b/src/contexts/WorkspaceContext.tsx @@ -0,0 +1,331 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { WorkspaceSelection } from "@/components/ProjectSidebar"; +import type { RuntimeConfig } from "@/types/runtime"; +import type { ProjectConfig } from "@/config"; +import { deleteWorkspaceStorage } from "@/constants/storage"; + +/** + * Ensure workspace metadata has createdAt timestamp. + * DEFENSIVE: Backend guarantees createdAt, but default to 2025-01-01 if missing. + * This prevents crashes if backend contract is violated. + */ +function ensureCreatedAt(metadata: FrontendWorkspaceMetadata): void { + if (!metadata.createdAt) { + console.warn( + `[Frontend] Workspace ${metadata.id} missing createdAt - using default (2025-01-01)` + ); + metadata.createdAt = "2025-01-01T00:00:00.000Z"; + } +} + +export interface WorkspaceContext { + // Workspace data + workspaceMetadata: Map; + loading: boolean; + + // Workspace operations + createWorkspace: ( + projectPath: string, + branchName: string, + trunkBranch: string, + runtimeConfig?: RuntimeConfig + ) => Promise<{ + projectPath: string; + projectName: string; + namedWorkspacePath: string; + workspaceId: string; + }>; + removeWorkspace: ( + workspaceId: string, + options?: { force?: boolean } + ) => Promise<{ success: boolean; error?: string }>; + renameWorkspace: ( + workspaceId: string, + newName: string + ) => Promise<{ success: boolean; error?: string }>; + refreshWorkspaceMetadata: () => Promise; + + // Selection + selectedWorkspace: WorkspaceSelection | null; + setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void; + + // Workspace creation flow + pendingNewWorkspaceProject: string | null; + beginWorkspaceCreation: (projectPath: string) => void; + clearPendingWorkspaceCreation: () => void; + + // Helpers + getWorkspaceInfo: (workspaceId: string) => Promise; +} + +const WorkspaceContext = createContext(undefined); + +interface WorkspaceProviderProps { + children: ReactNode; + selectedWorkspace: WorkspaceSelection | null; + onSelectedWorkspaceUpdate: (workspace: WorkspaceSelection | null) => void; + onProjectsUpdate: (projects: Map) => void; +} + +export function WorkspaceProvider(props: WorkspaceProviderProps) { + const [workspaceMetadata, setWorkspaceMetadata] = useState< + Map + >(new Map()); + const [loading, setLoading] = useState(true); + const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); + + const loadWorkspaceMetadata = useCallback(async () => { + try { + const metadataList = await window.api.workspace.list(); + const metadataMap = new Map(); + for (const metadata of metadataList) { + ensureCreatedAt(metadata); + // Use stable workspace ID as key (not path, which can change) + metadataMap.set(metadata.id, metadata); + } + setWorkspaceMetadata(metadataMap); + } catch (error) { + console.error("Failed to load workspace metadata:", error); + setWorkspaceMetadata(new Map()); + } + }, []); + + // Load metadata once on mount + useEffect(() => { + void (async () => { + await loadWorkspaceMetadata(); + // After loading metadata (which may trigger migration), reload projects + // to ensure frontend has the updated config with workspace IDs + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + props.onProjectsUpdate(loadedProjects); + setLoading(false); + })(); + }, [loadWorkspaceMetadata, props.onProjectsUpdate]); + + // Subscribe to metadata updates (for create/rename/delete operations) + useEffect(() => { + const unsubscribe = window.api.workspace.onMetadata( + (event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => { + setWorkspaceMetadata((prev) => { + const updated = new Map(prev); + const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null; + + if (event.metadata === null) { + // Workspace deleted - remove from map + updated.delete(event.workspaceId); + } else { + ensureCreatedAt(event.metadata); + updated.set(event.workspaceId, event.metadata); + } + + // If this is a new workspace (e.g., from fork), reload projects + // to ensure the sidebar shows the updated workspace list + if (isNewWorkspace) { + void (async () => { + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + props.onProjectsUpdate(loadedProjects); + })(); + } + + return updated; + }); + } + ); + + return () => { + unsubscribe(); + }; + }, [props.onProjectsUpdate]); + + const createWorkspace = useCallback( + async ( + projectPath: string, + branchName: string, + trunkBranch: string, + runtimeConfig?: RuntimeConfig + ) => { + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided when creating a workspace" + ); + const result = await window.api.workspace.create( + projectPath, + branchName, + trunkBranch, + runtimeConfig + ); + if (result.success) { + // Backend has already updated the config - reload projects to get updated state + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + props.onProjectsUpdate(loadedProjects); + + // Reload workspace metadata to get the new workspace ID + await loadWorkspaceMetadata(); + + // Return the new workspace selection + return { + projectPath, + projectName: result.metadata.projectName, + namedWorkspacePath: result.metadata.namedWorkspacePath, + workspaceId: result.metadata.id, + }; + } else { + throw new Error(result.error); + } + }, + [loadWorkspaceMetadata, props.onProjectsUpdate] + ); + + const removeWorkspace = useCallback( + async ( + workspaceId: string, + options?: { force?: boolean } + ): Promise<{ success: boolean; error?: string }> => { + try { + const result = await window.api.workspace.remove(workspaceId, options); + if (result.success) { + // Clean up workspace-specific localStorage keys + deleteWorkspaceStorage(workspaceId); + + // Backend has already updated the config - reload projects to get updated state + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + props.onProjectsUpdate(loadedProjects); + + // Reload workspace metadata + await loadWorkspaceMetadata(); + + // Clear selected workspace if it was removed + if (props.selectedWorkspace?.workspaceId === workspaceId) { + props.onSelectedWorkspaceUpdate(null); + } + return { success: true }; + } else { + console.error("Failed to remove workspace:", result.error); + return { success: false, error: result.error }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to remove workspace:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [loadWorkspaceMetadata, props] + ); + + const renameWorkspace = useCallback( + async (workspaceId: string, newName: string): Promise<{ success: boolean; error?: string }> => { + try { + const result = await window.api.workspace.rename(workspaceId, newName); + if (result.success) { + // Backend has already updated the config - reload projects to get updated state + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + props.onProjectsUpdate(loadedProjects); + + // Reload workspace metadata + await loadWorkspaceMetadata(); + + // Update selected workspace if it was renamed + if (props.selectedWorkspace?.workspaceId === workspaceId) { + const newWorkspaceId = result.data.newWorkspaceId; + + // Get updated workspace metadata from backend + const newMetadata = await window.api.workspace.getInfo(newWorkspaceId); + if (newMetadata) { + ensureCreatedAt(newMetadata); + props.onSelectedWorkspaceUpdate({ + projectPath: props.selectedWorkspace.projectPath, + projectName: newMetadata.projectName, + namedWorkspacePath: newMetadata.namedWorkspacePath, + workspaceId: newWorkspaceId, + }); + } + } + return { success: true }; + } else { + console.error("Failed to rename workspace:", result.error); + return { success: false, error: result.error }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to rename workspace:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [loadWorkspaceMetadata, props] + ); + + const refreshWorkspaceMetadata = useCallback(async () => { + await loadWorkspaceMetadata(); + }, [loadWorkspaceMetadata]); + + const getWorkspaceInfo = useCallback(async (workspaceId: string) => { + const metadata = await window.api.workspace.getInfo(workspaceId); + if (metadata) { + ensureCreatedAt(metadata); + } + return metadata; + }, []); + + const beginWorkspaceCreation = useCallback((projectPath: string) => { + setPendingNewWorkspaceProject(projectPath); + }, []); + + const clearPendingWorkspaceCreation = useCallback(() => { + setPendingNewWorkspaceProject(null); + }, []); + + const value = useMemo( + () => ({ + workspaceMetadata, + loading, + createWorkspace, + removeWorkspace, + renameWorkspace, + refreshWorkspaceMetadata, + selectedWorkspace: props.selectedWorkspace, + setSelectedWorkspace: props.onSelectedWorkspaceUpdate, + pendingNewWorkspaceProject, + beginWorkspaceCreation, + clearPendingWorkspaceCreation, + getWorkspaceInfo, + }), + [ + workspaceMetadata, + loading, + createWorkspace, + removeWorkspace, + renameWorkspace, + refreshWorkspaceMetadata, + props.selectedWorkspace, + props.onSelectedWorkspaceUpdate, + pendingNewWorkspaceProject, + beginWorkspaceCreation, + clearPendingWorkspaceCreation, + getWorkspaceInfo, + ] + ); + + return {props.children}; +} + +export function useWorkspaceContext(): WorkspaceContext { + const context = useContext(WorkspaceContext); + if (!context) { + throw new Error("useWorkspaceContext must be used within WorkspaceProvider"); + } + return context; +} From d99f9de7fcfc61538cbefe23186e2b621d5e6a44 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 14 Nov 2025 17:15:35 -0500 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20eslint=20?= =?UTF-8?q?warnings=20in=20WorkspaceContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add eslint disable comments for test file mocks (consistent with PR #600 pattern) - Fix react-hooks/exhaustive-deps warnings by destructuring props in effects - Change empty arrow function to use comment (no-op setWorkspaceMetadata) - Remove await from non-promise expect call in tests --- src/components/AppLoader.tsx | 51 +++++++++++++++----------- src/contexts/WorkspaceContext.test.tsx | 10 ++++- src/contexts/WorkspaceContext.tsx | 12 +++--- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index 05a55435c..f9bdf81a3 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from "react"; import App from "../App"; import { LoadingScreen } from "./LoadingScreen"; +import { useProjectManagement } from "../hooks/useProjectManagement"; import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore"; import { useGitStatusStoreRaw } from "../stores/GitStatusStore"; import { usePersistedState } from "../hooks/usePersistedState"; import type { WorkspaceSelection } from "./ProjectSidebar"; import { AppProvider } from "../contexts/AppContext"; -import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext"; import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; /** @@ -20,30 +20,27 @@ import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceCon * the need for conditional guards in effects. */ export function AppLoader() { - return ( - - - - ); -} - -function AppLoaderMiddle() { // Workspace selection - restored from localStorage immediately const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( "selectedWorkspace", null ); - const { refreshProjects } = useProjectContext(); + // Load projects + const projectManagement = useProjectManagement(); - // Wrap with WorkspaceProvider + // Render App with WorkspaceProvider wrapping it return ( @@ -51,7 +48,14 @@ function AppLoaderMiddle() { ); } +/** + * Inner component that has access to WorkspaceContext + */ function AppLoaderInner(props: { + projects: ReturnType["projects"]; + setProjects: ReturnType["setProjects"]; + addProject: ReturnType["addProject"]; + removeProject: ReturnType["removeProject"]; selectedWorkspace: WorkspaceSelection | null; setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void; }) { @@ -73,17 +77,13 @@ function AppLoaderInner(props: { } else { setStoresSynced(false); } - }, [ - workspaceContext.loading, - workspaceContext.workspaceMetadata, - workspaceStore, - gitStatusStore, - ]); + }, [workspaceContext.loading, workspaceContext.workspaceMetadata, workspaceStore, gitStatusStore]); // Restore workspace from URL hash (runs once when stores are synced) const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false); useEffect(() => { + const { setSelectedWorkspace } = props; // Wait until stores are synced before attempting restoration if (!storesSynced) return; @@ -99,7 +99,7 @@ function AppLoaderInner(props: { if (metadata) { // Restore from hash (overrides localStorage) - props.setSelectedWorkspace({ + setSelectedWorkspace({ workspaceId: metadata.id, projectPath: metadata.projectPath, projectName: metadata.projectName, @@ -114,11 +114,12 @@ function AppLoaderInner(props: { // Check for launch project from server (for --add-project flag) // This only applies in server mode useEffect(() => { + const { selectedWorkspace, setSelectedWorkspace } = props; // Wait until stores are synced and hash restoration is complete if (!storesSynced || !hasRestoredFromHash) return; // Skip if we already have a selected workspace (from localStorage or URL hash) - if (props.selectedWorkspace) return; + if (selectedWorkspace) return; // Only check once const checkLaunchProject = async () => { @@ -136,7 +137,7 @@ function AppLoaderInner(props: { if (projectWorkspaces.length > 0) { // Select the first workspace in the project const metadata = projectWorkspaces[0]; - props.setSelectedWorkspace({ + setSelectedWorkspace({ workspaceId: metadata.id, projectPath: metadata.projectPath, projectName: metadata.projectName, @@ -158,8 +159,14 @@ function AppLoaderInner(props: { // Render App with all initialized data via context return ( { + /* no-op now since WorkspaceContext handles it */ + }} createWorkspace={workspaceContext.createWorkspace} removeWorkspace={workspaceContext.removeWorkspace} renameWorkspace={workspaceContext.renameWorkspace} diff --git a/src/contexts/WorkspaceContext.test.tsx b/src/contexts/WorkspaceContext.test.tsx index 1a1dce4ed..95b35187b 100644 --- a/src/contexts/WorkspaceContext.test.tsx +++ b/src/contexts/WorkspaceContext.test.tsx @@ -1,3 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { IPCApi } from "@/types/ipc"; import type { WorkspaceSelection } from "@/components/ProjectSidebar"; @@ -228,7 +234,7 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().loading).toBe(false)); - await expect(async () => { + expect(async () => { await act(async () => { await ctx().createWorkspace("/gamma", "feature", "main"); }); @@ -606,7 +612,7 @@ describe("WorkspaceContext", () => { }); test("ensureCreatedAt adds default timestamp when missing", async () => { - const workspaceWithoutTimestamp: FrontendWorkspaceMetadata = { + const workspaceWithoutTimestamp = { id: "ws-1", projectPath: "/alpha", projectName: "alpha", diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index a9d4421ac..dcf0fd794 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -101,19 +101,21 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Load metadata once on mount useEffect(() => { + const { onProjectsUpdate } = props; void (async () => { await loadWorkspaceMetadata(); // After loading metadata (which may trigger migration), reload projects // to ensure frontend has the updated config with workspace IDs const projectsList = await window.api.projects.list(); const loadedProjects = new Map(projectsList); - props.onProjectsUpdate(loadedProjects); + onProjectsUpdate(loadedProjects); setLoading(false); })(); - }, [loadWorkspaceMetadata, props.onProjectsUpdate]); + }, [loadWorkspaceMetadata, props]); // Subscribe to metadata updates (for create/rename/delete operations) useEffect(() => { + const { onProjectsUpdate } = props; const unsubscribe = window.api.workspace.onMetadata( (event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => { setWorkspaceMetadata((prev) => { @@ -134,7 +136,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { void (async () => { const projectsList = await window.api.projects.list(); const loadedProjects = new Map(projectsList); - props.onProjectsUpdate(loadedProjects); + onProjectsUpdate(loadedProjects); })(); } @@ -146,7 +148,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return () => { unsubscribe(); }; - }, [props.onProjectsUpdate]); + }, [props]); const createWorkspace = useCallback( async ( @@ -185,7 +187,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { throw new Error(result.error); } }, - [loadWorkspaceMetadata, props.onProjectsUpdate] + [loadWorkspaceMetadata, props] ); const removeWorkspace = useCallback( From 9b1649ebb7327c6f772c821abe09b1705963e20d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 14 Nov 2025 17:19:35 -0500 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20expose=20setWorkspa?= =?UTF-8?q?ceMetadata=20from=20WorkspaceContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx needs to optimistically update workspace metadata when creating/forking workspaces to avoid race condition with validation effect. WorkspaceContext now: - Exposes setWorkspaceMetadata to allow App.tsx to update metadata immediately - Updates metadata map immediately in createWorkspace before returning - Passes through setWorkspaceMetadata in AppLoader instead of no-op This ensures metadata is available when setSelectedWorkspace is called, preventing the validation effect from clearing the selection before the async subscription fires. --- src/components/AppLoader.tsx | 4 +--- src/contexts/WorkspaceContext.tsx | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index f9bdf81a3..a4a1b3b1f 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -164,9 +164,7 @@ function AppLoaderInner(props: { addProject={props.addProject} removeProject={props.removeProject} workspaceMetadata={workspaceContext.workspaceMetadata} - setWorkspaceMetadata={() => { - /* no-op now since WorkspaceContext handles it */ - }} + setWorkspaceMetadata={workspaceContext.setWorkspaceMetadata} createWorkspace={workspaceContext.createWorkspace} removeWorkspace={workspaceContext.removeWorkspace} renameWorkspace={workspaceContext.renameWorkspace} diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index dcf0fd794..a78d58dd9 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -53,6 +53,9 @@ export interface WorkspaceContext { newName: string ) => Promise<{ success: boolean; error?: string }>; refreshWorkspaceMetadata: () => Promise; + setWorkspaceMetadata: React.Dispatch< + React.SetStateAction> + >; // Selection selectedWorkspace: WorkspaceSelection | null; @@ -173,8 +176,13 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const loadedProjects = new Map(projectsList); props.onProjectsUpdate(loadedProjects); - // Reload workspace metadata to get the new workspace ID - await loadWorkspaceMetadata(); + // Update metadata immediately to avoid race condition with validation effect + ensureCreatedAt(result.metadata); + setWorkspaceMetadata((prev) => { + const updated = new Map(prev); + updated.set(result.metadata.id, result.metadata); + return updated; + }); // Return the new workspace selection return { @@ -187,7 +195,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { throw new Error(result.error); } }, - [loadWorkspaceMetadata, props] + [props] ); const removeWorkspace = useCallback( @@ -298,6 +306,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { removeWorkspace, renameWorkspace, refreshWorkspaceMetadata, + setWorkspaceMetadata, selectedWorkspace: props.selectedWorkspace, setSelectedWorkspace: props.onSelectedWorkspaceUpdate, pendingNewWorkspaceProject, From fd95a2ead1a57ff38bf9a41dae0a2efbf8ff2fd8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 14 Nov 2025 17:22:13 -0500 Subject: [PATCH 04/20] =?UTF-8?q?=F0=9F=A4=96=20fmt:=20reformat=20dependen?= =?UTF-8?q?cy=20arrays=20per=20prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AppLoader.tsx | 7 ++++++- src/contexts/WorkspaceContext.test.tsx | 14 ++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index a4a1b3b1f..42f8af1a4 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -77,7 +77,12 @@ function AppLoaderInner(props: { } else { setStoresSynced(false); } - }, [workspaceContext.loading, workspaceContext.workspaceMetadata, workspaceStore, gitStatusStore]); + }, [ + workspaceContext.loading, + workspaceContext.workspaceMetadata, + workspaceStore, + gitStatusStore, + ]); // Restore workspace from URL hash (runs once when stores are synced) const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false); diff --git a/src/contexts/WorkspaceContext.test.tsx b/src/contexts/WorkspaceContext.test.tsx index 95b35187b..d2f11cb0b 100644 --- a/src/contexts/WorkspaceContext.test.tsx +++ b/src/contexts/WorkspaceContext.test.tsx @@ -514,10 +514,9 @@ describe("WorkspaceContext", () => { }); test("reacts to metadata update events (new workspace)", async () => { - let metadataListener: ((event: { - workspaceId: string; - metadata: FrontendWorkspaceMetadata | null; - }) => void) | null = null; + let metadataListener: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; createMockAPI({ list: () => Promise.resolve([]), @@ -574,10 +573,9 @@ describe("WorkspaceContext", () => { createdAt: "2025-01-01T00:00:00.000Z", }); - let metadataListener: ((event: { - workspaceId: string; - metadata: FrontendWorkspaceMetadata | null; - }) => void) | null = null; + let metadataListener: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; createMockAPI({ list: () => Promise.resolve([workspace]), From 804a785a33b126e3026d1cc475f767425dfff92c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 15 Nov 2025 10:17:16 -0500 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20eliminate=20wo?= =?UTF-8?q?rkspace=20prop=20drilling=20via=20WorkspaceContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectSidebar, LeftSidebar, and App.tsx now use WorkspaceContext directly instead of passing workspace state/operations through props: - ProjectSidebar uses useWorkspaceContext() to get workspace operations - LeftSidebar no longer passes workspace props to ProjectSidebar - App.tsx no longer passes workspace callbacks to LeftSidebar - Telemetry tracking moved to effect watching selectedWorkspace changes This eliminates prop drilling for: - selectedWorkspace / setSelectedWorkspace - onAddWorkspace (beginWorkspaceCreation) - onRemoveWorkspace / onRenameWorkspace - workspaceMetadata Cleaned up unused callbacks and imports. --- src/App.tsx | 198 +++++++++++++++++++++--------- src/components/LeftSidebar.tsx | 15 ++- src/components/ProjectSidebar.tsx | 95 ++++++-------- 3 files changed, 187 insertions(+), 121 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c9a57df47..bc80f807e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,8 @@ -import { useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import "./styles/globals.css"; import { useApp } from "./contexts/AppContext"; -import { useProjectContext } from "./contexts/ProjectContext"; -import { useSortedWorkspacesByProject } from "./hooks/useSortedWorkspacesByProject"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; +import type { FrontendWorkspaceMetadata } from "./types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; @@ -13,10 +12,11 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; -import { useWorkspaceStoreRaw } from "./stores/WorkspaceStore"; +import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; import { ChatInput } from "./components/ChatInput/index"; import type { ChatInputAPI } from "./components/ChatInput/types"; +import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; import type { CommandAction } from "./contexts/CommandRegistryContext"; import { ModeProvider } from "./contexts/ModeContext"; @@ -28,6 +28,7 @@ import type { ThinkingLevel } from "./types/thinking"; import { CUSTOM_EVENTS } from "./constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork"; import { getThinkingLevelKey } from "./constants/storage"; +import type { BranchListResult } from "./types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; @@ -36,6 +37,9 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; function AppInner() { // Get app-level state from context const { + projects, + addProject, + removeProject, workspaceMetadata, setWorkspaceMetadata, removeWorkspace, @@ -43,18 +47,10 @@ function AppInner() { selectedWorkspace, setSelectedWorkspace, } = useApp(); - const { - projects, - addProject, - removeProject: removeProjectFromContext, - isProjectCreateModalOpen, - openProjectCreateModal, - closeProjectCreateModal, - pendingNewWorkspaceProject, - beginWorkspaceCreation, - clearPendingWorkspaceCreation, - getBranchesForProject, - } = useProjectContext(); + const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false); + + // Track when we're in "new workspace creation" mode (show FirstMessageInput) + const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; @@ -71,13 +67,7 @@ function AppInner() { const startWorkspaceCreation = useStartWorkspaceCreation({ projects, - setPendingNewWorkspaceProject: (projectPath: string | null) => { - if (projectPath) { - beginWorkspaceCreation(projectPath); - } else { - clearPendingWorkspaceCreation(); - } - }, + setPendingNewWorkspaceProject, setSelectedWorkspace, }); @@ -97,22 +87,17 @@ function AppInner() { // Get workspace store for command palette const workspaceStore = useWorkspaceStoreRaw(); - // Wrapper for setSelectedWorkspace that tracks telemetry - const handleWorkspaceSwitch = useCallback( - (newWorkspace: WorkspaceSelection | null) => { - // Track workspace switch when both old and new are non-null (actual switch, not init/clear) - if ( - selectedWorkspace && - newWorkspace && - selectedWorkspace.workspaceId !== newWorkspace.workspaceId - ) { - telemetry.workspaceSwitched(selectedWorkspace.workspaceId, newWorkspace.workspaceId); - } - setSelectedWorkspace(newWorkspace); - }, - [selectedWorkspace, setSelectedWorkspace, telemetry] - ); + + // Track telemetry when workspace selection changes + const prevWorkspaceRef = useRef(null); + useEffect(() => { + const prev = prevWorkspaceRef.current; + if (prev && selectedWorkspace && prev.workspaceId !== selectedWorkspace.workspaceId) { + telemetry.workspaceSwitched(prev.workspaceId, selectedWorkspace.workspaceId); + } + prevWorkspaceRef.current = selectedWorkspace; + }, [selectedWorkspace, telemetry]); // Validate selectedWorkspace when metadata changes // Clear selection if workspace was deleted @@ -189,22 +174,91 @@ function AppInner() { if (selectedWorkspace?.projectPath === path) { setSelectedWorkspace(null); } - if (pendingNewWorkspaceProject === path) { - clearPendingWorkspaceCreation(); + await removeProject(path); + }, + [removeProject, selectedWorkspace, setSelectedWorkspace] + ); + + const handleAddWorkspace = useCallback( + (projectPath: string) => { + startWorkspaceCreation(projectPath); + }, + [startWorkspaceCreation] + ); + + // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders + const handleAddProjectCallback = useCallback(() => { + setProjectCreateModalOpen(true); + }, []); + + + + const handleRemoveProjectCallback = useCallback( + (path: string) => { + void handleRemoveProject(path); + }, + [handleRemoveProject] + ); + + const handleGetSecrets = useCallback(async (projectPath: string) => { + return await window.api.projects.secrets.get(projectPath); + }, []); + + const handleUpdateSecrets = useCallback( + async (projectPath: string, secrets: Array<{ key: string; value: string }>) => { + const result = await window.api.projects.secrets.update(projectPath, secrets); + if (!result.success) { + console.error("Failed to update secrets:", result.error); } - await removeProjectFromContext(path); }, - [ - clearPendingWorkspaceCreation, - pendingNewWorkspaceProject, - removeProjectFromContext, - selectedWorkspace, - setSelectedWorkspace, - ] + [] ); // NEW: Get workspace recency from store - const sortedWorkspacesByProject = useSortedWorkspacesByProject(); + const workspaceRecency = useWorkspaceRecency(); + + // Sort workspaces by recency (most recent first) + // Returns Map for direct component use + // Use stable reference to prevent sidebar re-renders when sort order hasn't changed + const sortedWorkspacesByProject = useStableReference( + () => { + const result = new Map(); + for (const [projectPath, config] of projects) { + // Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID + const metadataList = config.workspaces + .map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined)) + .filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null); + + // Sort by recency + metadataList.sort((a, b) => { + const aTimestamp = workspaceRecency[a.id] ?? 0; + const bTimestamp = workspaceRecency[b.id] ?? 0; + return bTimestamp - aTimestamp; + }); + + result.set(projectPath, metadataList); + } + return result; + }, + (prev, next) => { + // Compare Maps: check if size, workspace order, and metadata content are the same + if ( + !compareMaps(prev, next, (a, b) => { + if (a.length !== b.length) return false; + // Check both ID and name to detect renames + return a.every((metadata, i) => { + const bMeta = b[i]; + if (!bMeta || !metadata) return false; // Null-safe + return metadata.id === bMeta.id && metadata.name === bMeta.name; + }); + }) + ) { + return false; + } + return true; + }, + [projects, workspaceMetadata, workspaceRecency] + ); const handleNavigateWorkspace = useCallback( (direction: "next" | "prev") => { @@ -303,11 +357,32 @@ function AppInner() { [startWorkspaceCreation] ); + const getBranchesForProject = useCallback( + async (projectPath: string): Promise => { + const branchResult = await window.api.projects.listBranches(projectPath); + const sanitizedBranches = Array.isArray(branchResult?.branches) + ? branchResult.branches.filter((branch): branch is string => typeof branch === "string") + : []; + + const recommended = + typeof branchResult?.recommendedTrunk === "string" && + sanitizedBranches.includes(branchResult.recommendedTrunk) + ? branchResult.recommendedTrunk + : (sanitizedBranches[0] ?? ""); + + return { + branches: sanitizedBranches, + recommendedTrunk: recommended, + }; + }, + [] + ); + const selectWorkspaceFromPalette = useCallback( (selection: WorkspaceSelection) => { - handleWorkspaceSwitch(selection); + setSelectedWorkspace(selection); }, - [handleWorkspaceSwitch] + [setSelectedWorkspace] ); const removeWorkspaceFromPalette = useCallback( @@ -321,8 +396,8 @@ function AppInner() { ); const addProjectFromPalette = useCallback(() => { - openProjectCreateModal(); - }, [openProjectCreateModal]); + setProjectCreateModalOpen(true); + }, []); const removeProjectFromPalette = useCallback( (path: string) => { @@ -467,11 +542,16 @@ function AppInner() { <>
@@ -511,7 +591,7 @@ function AppInner() { setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); // Switch to new workspace - handleWorkspaceSwitch({ + setSelectedWorkspace({ workspaceId: metadata.id, projectPath: metadata.projectPath, projectName: metadata.projectName, @@ -522,13 +602,13 @@ function AppInner() { telemetry.workspaceCreated(metadata.id); // Clear pending state - clearPendingWorkspaceCreation(); + setPendingNewWorkspaceProject(null); }} onCancel={ pendingNewWorkspaceProject ? () => { // User cancelled workspace creation - clear pending state - clearPendingWorkspaceCreation(); + setPendingNewWorkspaceProject(null); } : undefined } @@ -560,8 +640,8 @@ function AppInner() { })} /> setProjectCreateModalOpen(false)} onSuccess={addProject} />
diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index a390efc8f..5d2dd5e5d 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,20 +1,30 @@ import React from "react"; import { cn } from "@/lib/utils"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { Secret } from "@/types/secrets"; import ProjectSidebar from "./ProjectSidebar"; import { TitleBar } from "./TitleBar"; -import type { WorkspaceSelection } from "./ProjectSidebar"; +import { useApp } from "@/contexts/AppContext"; interface LeftSidebarProps { - onSelectWorkspace: (selection: WorkspaceSelection) => void; + onAddProject: () => void; + onRemoveProject: (path: string) => void; lastReadTimestamps: Record; onToggleUnread: (workspaceId: string) => void; collapsed: boolean; onToggleCollapsed: () => void; + onGetSecrets: (projectPath: string) => Promise; + onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; + sortedWorkspacesByProject: Map; + workspaceRecency: Record; } export function LeftSidebar(props: LeftSidebarProps) { const { collapsed, onToggleCollapsed, ...projectSidebarProps } = props; + // Get app-level state from context + const { projects } = useApp(); + return ( <> {/* Hamburger menu button - only visible on mobile */} @@ -58,6 +68,7 @@ export function LeftSidebar(props: LeftSidebarProps) { {!collapsed && } diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 0466fb42c..8db788077 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; +import type { ProjectConfig } from "@/config"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { usePersistedState } from "@/hooks/usePersistedState"; import { DndProvider } from "react-dnd"; @@ -24,6 +25,7 @@ import { useSortedWorkspacesByProject } from "@/hooks/useSortedWorkspacesByProje import { useApp } from "@/contexts/AppContext"; import { useWorkspaceRecency } from "@/stores/WorkspaceStore"; import { ChevronRight, KeyRound } from "lucide-react"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -156,38 +158,41 @@ const ProjectDragLayer: React.FC = () => { }; interface ProjectSidebarProps { - onSelectWorkspace: (selection: WorkspaceSelection) => void; + projects: Map; + onAddProject: () => void; + onRemoveProject: (path: string) => void; lastReadTimestamps: Record; onToggleUnread: (workspaceId: string) => void; collapsed: boolean; onToggleCollapsed: () => void; + onGetSecrets: (projectPath: string) => Promise; + onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; + sortedWorkspacesByProject: Map; + workspaceRecency: Record; } const ProjectSidebarInner: React.FC = ({ - onSelectWorkspace, + projects, + onAddProject, + onRemoveProject, lastReadTimestamps, onToggleUnread: _onToggleUnread, collapsed, onToggleCollapsed, + onGetSecrets, + onUpdateSecrets, + sortedWorkspacesByProject, + workspaceRecency, }) => { - const { - projects, - openProjectCreateModal, - beginWorkspaceCreation, - clearPendingWorkspaceCreation, - pendingNewWorkspaceProject, - removeProject: removeProjectFromContext, - getSecrets, - updateSecrets, - } = useProjectContext(); + // Get workspace state and operations from context const { selectedWorkspace, - setSelectedWorkspace, - removeWorkspace: removeWorkspaceFromApp, - renameWorkspace, - } = useApp(); - const sortedWorkspacesByProject = useSortedWorkspacesByProject(); - const workspaceRecency = useWorkspaceRecency(); + setSelectedWorkspace: onSelectWorkspace, + removeWorkspace: onRemoveWorkspace, + renameWorkspace: onRenameWorkspace, + beginWorkspaceCreation: onAddWorkspace, + } = useWorkspaceContext(); + // Workspace-specific subscriptions moved to WorkspaceListItem component // Store as array in localStorage, convert to Set for usage @@ -225,36 +230,6 @@ const ProjectSidebarInner: React.FC = ({ error: string; anchor: { top: number; left: number } | null; } | null>(null); - const handleAddProject = useCallback(() => { - openProjectCreateModal(); - }, [openProjectCreateModal]); - - const handleAddWorkspace = useCallback( - (projectPath: string) => { - beginWorkspaceCreation(projectPath); - setSelectedWorkspace(null); - }, - [beginWorkspaceCreation, setSelectedWorkspace] - ); - - const handleRemoveProject = useCallback( - async (projectPath: string) => { - if (selectedWorkspace?.projectPath === projectPath) { - setSelectedWorkspace(null); - } - if (pendingNewWorkspaceProject === projectPath) { - clearPendingWorkspaceCreation(); - } - await removeProjectFromContext(projectPath); - }, - [ - clearPendingWorkspaceCreation, - pendingNewWorkspaceProject, - removeProjectFromContext, - selectedWorkspace, - setSelectedWorkspace, - ] - ); const getProjectName = (path: string) => { if (!path || typeof path !== "string") { @@ -315,7 +290,7 @@ const ProjectSidebarInner: React.FC = ({ const handleRemoveWorkspace = useCallback( async (workspaceId: string, buttonElement: HTMLElement) => { - const result = await removeWorkspaceFromApp(workspaceId); + const result = await onRemoveWorkspace(workspaceId); if (!result.success) { const error = result.error ?? "Failed to remove workspace"; const rect = buttonElement.getBoundingClientRect(); @@ -334,11 +309,11 @@ const ProjectSidebarInner: React.FC = ({ }); } }, - [removeWorkspaceFromApp] + [onRemoveWorkspace] ); const handleOpenSecrets = async (projectPath: string) => { - const secrets = await getSecrets(projectPath); + const secrets = await onGetSecrets(projectPath); setSecretsModalState({ isOpen: true, projectPath, @@ -353,7 +328,7 @@ const ProjectSidebarInner: React.FC = ({ setForceDeleteModal(null); // Use the same state update logic as regular removal - const result = await removeWorkspaceFromApp(workspaceId, { force: true }); + const result = await onRemoveWorkspace(workspaceId, { force: true }); if (!result.success) { const errorMessage = result.error ?? "Failed to remove workspace"; console.error("Force delete failed:", result.error); @@ -364,7 +339,7 @@ const ProjectSidebarInner: React.FC = ({ const handleSaveSecrets = async (secrets: Secret[]) => { if (secretsModalState) { - await updateSecrets(secretsModalState.projectPath, secrets); + await onUpdateSecrets(secretsModalState.projectPath, secrets); } }; @@ -424,16 +399,16 @@ const ProjectSidebarInner: React.FC = ({ // Create new workspace for the project of the selected workspace if (matchesKeybind(e, KEYBINDS.NEW_WORKSPACE) && selectedWorkspace) { e.preventDefault(); - handleAddWorkspace(selectedWorkspace.projectPath); + onAddWorkspace(selectedWorkspace.projectPath); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedWorkspace, handleAddWorkspace]); + }, [selectedWorkspace, onAddWorkspace]); return ( - +
= ({

Agents

); From d9c313c469df03f92293ff3aa1ca26e338b9d041 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 15 Nov 2025 10:49:10 -0500 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20missing=20Pro?= =?UTF-8?q?jectContext=20imports=20after=20refactoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 36 +++++++++++++++++++++---------- src/components/LeftSidebar.tsx | 7 ++++-- src/components/ProjectSidebar.tsx | 3 +-- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f3a14dbb2..bc49b8bfe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,8 +37,22 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; function AppInner() { // Get app-level state from context - const { workspaceMetadata, setWorkspaceMetadata, removeWorkspace, renameWorkspace, selectedWorkspace, setSelectedWorkspace } = useApp(); - const { projects } = useProjectContext(); + const { + workspaceMetadata, + setWorkspaceMetadata, + removeWorkspace, + renameWorkspace, + selectedWorkspace, + setSelectedWorkspace, + } = useApp(); + const { + projects, + removeProject, + openProjectCreateModal, + isProjectCreateModalOpen, + closeProjectCreateModal, + addProject, + } = useProjectContext(); // Track when we're in "new workspace creation" mode (show FirstMessageInput) const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); @@ -78,8 +92,6 @@ function AppInner() { // Get workspace store for command palette const workspaceStore = useWorkspaceStoreRaw(); - - // Track telemetry when workspace selection changes const prevWorkspaceRef = useRef(null); useEffect(() => { @@ -167,14 +179,12 @@ function AppInner() { } await removeProject(path); }, - [removeProject, selectedWorkspace, setSelectedWorkspace] + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedWorkspace, setSelectedWorkspace] ); - - // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders - // NEW: Get workspace recency from store const workspaceRecency = useWorkspaceRecency(); @@ -357,8 +367,8 @@ function AppInner() { ); const addProjectFromPalette = useCallback(() => { - setProjectCreateModalOpen(true); - }, []); + openProjectCreateModal(); + }, [openProjectCreateModal]); const removeProjectFromPalette = useCallback( (path: string) => { @@ -596,7 +606,11 @@ function AppInner() { workspaceId: selectedWorkspace?.workspaceId, })} /> - +
); diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index e05bcb812..fba3fe325 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,7 +1,6 @@ import React from "react"; import { cn } from "@/lib/utils"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; -import type { Secret } from "@/types/secrets"; import ProjectSidebar from "./ProjectSidebar"; import { TitleBar } from "./TitleBar"; @@ -58,7 +57,11 @@ export function LeftSidebar(props: LeftSidebarProps) { )} > {!collapsed && } - +
); diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index e9bfa50c8..0aa3b855d 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; -import type { ProjectConfig } from "@/config"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { usePersistedState } from "@/hooks/usePersistedState"; import { DndProvider } from "react-dnd"; @@ -524,7 +523,7 @@ const ProjectSidebarInner: React.FC = ({