From d4a4095936451ff4bb7ef090ab804620980f94a5 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 14 Nov 2025 14:36:04 -0500 Subject: [PATCH 1/5] Split projects into it's own context with tests We should not use props to pass context. It's very complex for testing and inefficient long-term. In a future PR I will split out workspaces. --- bun.lock | 12 + package.json | 1 + src/App.tsx | 176 +++------- src/components/AppLoader.tsx | 19 +- src/components/LeftSidebar.tsx | 19 -- src/components/ProjectSidebar.tsx | 106 +++--- src/contexts/AppContext.tsx | 7 - src/contexts/ProjectContext.test.tsx | 392 ++++++++++++++++++++++ src/contexts/ProjectContext.tsx | 261 ++++++++++++++ src/hooks/useProjectManagement.ts | 61 ---- src/hooks/useSortedWorkspacesByProject.ts | 45 +++ src/hooks/useWorkspaceManagement.ts | 35 +- 12 files changed, 844 insertions(+), 290 deletions(-) create mode 100644 src/contexts/ProjectContext.test.tsx create mode 100644 src/contexts/ProjectContext.tsx delete mode 100644 src/hooks/useProjectManagement.ts create mode 100644 src/hooks/useSortedWorkspacesByProject.ts diff --git a/bun.lock b/bun.lock index 5b0f1e9e1..d6ad77a8f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -88,6 +89,7 @@ "eslint-plugin-storybook": "10.0.0", "eslint-plugin-tailwindcss": "4.0.0-beta.0", "geist": "^1.5.1", + "happy-dom": "^20.0.10", "jest": "^30.1.3", "mermaid": "^11.12.0", "nodemon": "^3.1.10", @@ -955,6 +957,8 @@ "@types/wait-on": ["@types/wait-on@5.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -1757,6 +1761,8 @@ "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -2991,6 +2997,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -3241,6 +3249,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -3513,6 +3523,8 @@ "global-prefix/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "jest-circus/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/package.json b/package.json index f56fce506..82038bda5 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "eslint-plugin-storybook": "10.0.0", "eslint-plugin-tailwindcss": "4.0.0-beta.0", "geist": "^1.5.1", + "happy-dom": "^20.0.10", "jest": "^30.1.3", "mermaid": "^11.12.0", "nodemon": "^3.1.10", diff --git a/src/App.tsx b/src/App.tsx index 7610bcb38..12a64c9e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { 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"; @@ -12,11 +13,10 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; -import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; +import { useWorkspaceStoreRaw } 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"; @@ -27,8 +27,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour 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 { getThinkingLevelKey, getRuntimeKey } from "./constants/storage"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; @@ -37,9 +36,6 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; function AppInner() { // Get app-level state from context const { - projects, - addProject, - removeProject, workspaceMetadata, setWorkspaceMetadata, removeWorkspace, @@ -47,10 +43,18 @@ function AppInner() { selectedWorkspace, setSelectedWorkspace, } = useApp(); - const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false); - - // Track when we're in "new workspace creation" mode (show FirstMessageInput) - const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); + const { + projects, + addProject, + removeProject: removeProjectFromContext, + isProjectCreateModalOpen, + openProjectCreateModal, + closeProjectCreateModal, + pendingNewWorkspaceProject, + beginWorkspaceCreation, + clearPendingWorkspaceCreation, + getBranchesForProject, + } = useProjectContext(); // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; @@ -67,7 +71,13 @@ function AppInner() { const startWorkspaceCreation = useStartWorkspaceCreation({ projects, - setPendingNewWorkspaceProject, + setPendingNewWorkspaceProject: (projectPath: string | null) => { + if (projectPath) { + beginWorkspaceCreation(projectPath); + } else { + clearPendingWorkspaceCreation(); + } + }, setSelectedWorkspace, }); @@ -179,96 +189,32 @@ function AppInner() { if (selectedWorkspace?.projectPath === path) { setSelectedWorkspace(null); } - await removeProject(path); + if (pendingNewWorkspaceProject === path) { + clearPendingWorkspaceCreation(); + } + await removeProjectFromContext(path); }, - [removeProject, selectedWorkspace, setSelectedWorkspace] + [ + clearPendingWorkspaceCreation, + pendingNewWorkspaceProject, + removeProjectFromContext, + selectedWorkspace, + setSelectedWorkspace, + ] ); const handleAddWorkspace = useCallback( (projectPath: string) => { - startWorkspaceCreation(projectPath); - }, - [startWorkspaceCreation] - ); - - // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders - const handleAddProjectCallback = useCallback(() => { - setProjectCreateModalOpen(true); - }, []); - - const handleAddWorkspaceCallback = useCallback( - (projectPath: string) => { - void handleAddWorkspace(projectPath); - }, - [handleAddWorkspace] - ); - - 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); - } + // Show FirstMessageInput for this project + beginWorkspaceCreation(projectPath); + // Clear any selected workspace so FirstMessageInput is shown + setSelectedWorkspace(null); }, - [] + [beginWorkspaceCreation, setSelectedWorkspace] ); // NEW: Get workspace recency from store - 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 sortedWorkspacesByProject = useSortedWorkspacesByProject(); const handleNavigateWorkspace = useCallback( (direction: "next" | "prev") => { @@ -367,27 +313,6 @@ 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); @@ -406,8 +331,8 @@ function AppInner() { ); const addProjectFromPalette = useCallback(() => { - setProjectCreateModalOpen(true); - }, []); + openProjectCreateModal(); + }, [openProjectCreateModal]); const removeProjectFromPalette = useCallback( (path: string) => { @@ -553,17 +478,10 @@ function AppInner() {
@@ -614,13 +532,13 @@ function AppInner() { telemetry.workspaceCreated(metadata.id); // Clear pending state - setPendingNewWorkspaceProject(null); + clearPendingWorkspaceCreation(); }} onCancel={ pendingNewWorkspaceProject ? () => { // User cancelled workspace creation - clear pending state - setPendingNewWorkspaceProject(null); + clearPendingWorkspaceCreation(); } : undefined } @@ -652,8 +570,8 @@ function AppInner() { })} /> setProjectCreateModalOpen(false)} + isOpen={isProjectCreateModalOpen} + onClose={closeProjectCreateModal} onSuccess={addProject} />
diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index c6b4ed5ed..cf0079d4c 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 { useProjectManagement } from "../hooks/useProjectManagement"; 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"; /** * AppLoader handles all initialization before rendering the main App: @@ -20,20 +20,27 @@ import { AppProvider } from "../contexts/AppContext"; * the need for conditional guards in effects. */ export function AppLoader() { + return ( + + + + ); +} + +function AppLoaderInner() { // Workspace selection - restored from localStorage immediately const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( "selectedWorkspace", null ); - // Load projects - const projectManagement = useProjectManagement(); + const { refreshProjects } = useProjectContext(); // Load workspace metadata // Pass empty callbacks for now - App will provide the actual handlers const workspaceManagement = useWorkspaceManagement({ selectedWorkspace, - onProjectsUpdate: projectManagement.setProjects, + onProjectsRefresh: refreshProjects, onSelectedWorkspaceUpdate: setSelectedWorkspace, }); @@ -149,10 +156,6 @@ export function AppLoader() { // Render App with all initialized data via context return ( void; - onAddProject: () => void; - onAddWorkspace: (projectPath: string) => 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, workspaceMetadata, selectedWorkspace, removeWorkspace, renameWorkspace } = - useApp(); - return ( <> {/* Hamburger menu button - only visible on mobile */} @@ -72,11 +58,6 @@ export function LeftSidebar(props: LeftSidebarProps) { {!collapsed && } diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 480d181e6..c57be18a6 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"; @@ -20,6 +19,10 @@ import type { Secret } from "@/types/secrets"; import { ForceDeleteModal } from "./ForceDeleteModal"; import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; import { RenameProvider } from "@/contexts/WorkspaceRenameContext"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { useSortedWorkspacesByProject } from "@/hooks/useSortedWorkspacesByProject"; +import { useApp } from "@/contexts/AppContext"; +import { useWorkspaceRecency } from "@/stores/WorkspaceStore"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -153,49 +156,38 @@ const ProjectDragLayer: React.FC = () => { }; interface ProjectSidebarProps { - projects: Map; - workspaceMetadata: Map; - selectedWorkspace: WorkspaceSelection | null; onSelectWorkspace: (selection: WorkspaceSelection) => void; - onAddProject: () => void; - onAddWorkspace: (projectPath: string) => void; - onRemoveProject: (path: string) => void; - onRemoveWorkspace: ( - workspaceId: string, - options?: { force?: boolean } - ) => Promise<{ success: boolean; error?: string }>; - onRenameWorkspace: ( - workspaceId: string, - newName: string - ) => Promise<{ success: boolean; error?: string }>; 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 = ({ - projects, - selectedWorkspace, onSelectWorkspace, - onAddProject, - onAddWorkspace, - onRemoveProject, - onRemoveWorkspace, - onRenameWorkspace, lastReadTimestamps, onToggleUnread: _onToggleUnread, collapsed, onToggleCollapsed, - onGetSecrets, - onUpdateSecrets, - sortedWorkspacesByProject, - workspaceRecency, }) => { + const { + projects, + openProjectCreateModal, + beginWorkspaceCreation, + clearPendingWorkspaceCreation, + pendingNewWorkspaceProject, + removeProject: removeProjectFromContext, + getSecrets, + updateSecrets, + } = useProjectContext(); + const { + selectedWorkspace, + setSelectedWorkspace, + removeWorkspace: removeWorkspaceFromApp, + renameWorkspace, + } = useApp(); + const sortedWorkspacesByProject = useSortedWorkspacesByProject(); + const workspaceRecency = useWorkspaceRecency(); // Workspace-specific subscriptions moved to WorkspaceListItem component // Store as array in localStorage, convert to Set for usage @@ -233,6 +225,36 @@ 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") { @@ -293,7 +315,7 @@ const ProjectSidebarInner: React.FC = ({ const handleRemoveWorkspace = useCallback( async (workspaceId: string, buttonElement: HTMLElement) => { - const result = await onRemoveWorkspace(workspaceId); + const result = await removeWorkspaceFromApp(workspaceId); if (!result.success) { const error = result.error ?? "Failed to remove workspace"; const rect = buttonElement.getBoundingClientRect(); @@ -312,11 +334,11 @@ const ProjectSidebarInner: React.FC = ({ }); } }, - [onRemoveWorkspace] + [removeWorkspaceFromApp] ); const handleOpenSecrets = async (projectPath: string) => { - const secrets = await onGetSecrets(projectPath); + const secrets = await getSecrets(projectPath); setSecretsModalState({ isOpen: true, projectPath, @@ -331,7 +353,7 @@ const ProjectSidebarInner: React.FC = ({ setForceDeleteModal(null); // Use the same state update logic as regular removal - const result = await onRemoveWorkspace(workspaceId, { force: true }); + const result = await removeWorkspaceFromApp(workspaceId, { force: true }); if (!result.success) { const errorMessage = result.error ?? "Failed to remove workspace"; console.error("Force delete failed:", result.error); @@ -342,7 +364,7 @@ const ProjectSidebarInner: React.FC = ({ const handleSaveSecrets = async (secrets: Secret[]) => { if (secretsModalState) { - await onUpdateSecrets(secretsModalState.projectPath, secrets); + await updateSecrets(secretsModalState.projectPath, secrets); } }; @@ -402,16 +424,16 @@ const ProjectSidebarInner: React.FC = ({ // Create new workspace for the project of the selected workspace if (matchesKeybind(e, KEYBINDS.NEW_WORKSPACE) && selectedWorkspace) { e.preventDefault(); - onAddWorkspace(selectedWorkspace.projectPath); + handleAddWorkspace(selectedWorkspace.projectPath); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedWorkspace, onAddWorkspace]); + }, [selectedWorkspace, handleAddWorkspace]); return ( - +
= ({

Agents