diff --git a/src/App.tsx b/src/App.tsx index c9a57df47..d7a9299c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ -import { useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import "./styles/globals.css"; -import { useApp } from "./contexts/AppContext"; +import { useWorkspaceContext } from "./contexts/WorkspaceContext"; 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 +13,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,13 +29,14 @@ 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"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; function AppInner() { - // Get app-level state from context + // Get workspace state from context const { workspaceMetadata, setWorkspaceMetadata, @@ -42,20 +44,19 @@ function AppInner() { renameWorkspace, selectedWorkspace, setSelectedWorkspace, - } = useApp(); + } = useWorkspaceContext(); const { projects, - addProject, - removeProject: removeProjectFromContext, - isProjectCreateModalOpen, + removeProject, openProjectCreateModal, + isProjectCreateModalOpen, closeProjectCreateModal, - pendingNewWorkspaceProject, - beginWorkspaceCreation, - clearPendingWorkspaceCreation, - getBranchesForProject, + addProject, } = useProjectContext(); + // 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; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); @@ -71,13 +72,7 @@ function AppInner() { const startWorkspaceCreation = useStartWorkspaceCreation({ projects, - setPendingNewWorkspaceProject: (projectPath: string | null) => { - if (projectPath) { - beginWorkspaceCreation(projectPath); - } else { - clearPendingWorkspaceCreation(); - } - }, + setPendingNewWorkspaceProject, setSelectedWorkspace, }); @@ -97,22 +92,15 @@ 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 +177,59 @@ function AppInner() { if (selectedWorkspace?.projectPath === path) { setSelectedWorkspace(null); } - if (pendingNewWorkspaceProject === path) { - clearPendingWorkspaceCreation(); - } - await removeProjectFromContext(path); + await removeProject(path); }, - [ - clearPendingWorkspaceCreation, - pendingNewWorkspaceProject, - removeProjectFromContext, - 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 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 +328,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( @@ -467,11 +513,12 @@ function AppInner() { <>
@@ -511,7 +558,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 +569,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 } diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index cf0079d4c..5b80783ee 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -1,48 +1,38 @@ 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 { ProjectProvider } from "../contexts/ProjectContext"; +import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; /** * AppLoader handles all initialization before rendering the main App: - * 1. Load workspace metadata and projects + * 1. Load workspace metadata and projects (via contexts) * 2. Sync stores with loaded data - * 3. Restore workspace selection from URL hash (if present) - * 4. Only render App when everything is ready + * 3. Only render App when everything is ready * + * WorkspaceContext handles workspace selection restoration (localStorage, URL hash, launch project). + * WorkspaceProvider must be nested inside ProjectProvider so it can call useProjectContext(). * This ensures App.tsx can assume stores are always synced and removes * the need for conditional guards in effects. */ export function AppLoader() { return ( - + + + ); } +/** + * Inner component that has access to both ProjectContext and WorkspaceContext. + * Syncs stores and shows loading screen until ready. + */ function AppLoaderInner() { - // Workspace selection - restored from localStorage immediately - const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( - "selectedWorkspace", - null - ); - - 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, - }); + const workspaceContext = useWorkspaceContext(); // Get store instances const workspaceStore = useWorkspaceStoreRaw(); @@ -53,118 +43,25 @@ 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, ]); - // Restore workspace from URL hash (runs once when stores are synced) - const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false); - - useEffect(() => { - // Wait until stores are synced before attempting restoration - if (!storesSynced) return; - - // Only run once - if (hasRestoredFromHash) return; - - const hash = window.location.hash; - if (hash.startsWith("#workspace=")) { - const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); - - // Find workspace in metadata - const metadata = workspaceManagement.workspaceMetadata.get(workspaceId); - - if (metadata) { - // Restore from hash (overrides localStorage) - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - namedWorkspacePath: metadata.namedWorkspacePath, - }); - } - } - - setHasRestoredFromHash(true); - }, [ - storesSynced, - workspaceManagement.workspaceMetadata, - hasRestoredFromHash, - setSelectedWorkspace, - ]); - - // Check for launch project from server (for --add-project flag) - // This only applies in server mode - useEffect(() => { - // 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 (selectedWorkspace) return; - - // Only check once - const checkLaunchProject = async () => { - // Only available in server mode - if (!window.api.server?.getLaunchProject) return; - - const launchProjectPath = await window.api.server.getLaunchProject(); - if (!launchProjectPath) return; - - // Find first workspace in this project - const projectWorkspaces = Array.from(workspaceManagement.workspaceMetadata.values()).filter( - (meta) => meta.projectPath === launchProjectPath - ); - - if (projectWorkspaces.length > 0) { - // Select the first workspace in the project - const metadata = projectWorkspaces[0]; - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - namedWorkspacePath: metadata.namedWorkspacePath, - }); - } - // If no workspaces exist yet, just leave the project in the sidebar - // The user will need to create a workspace - }; - - void checkLaunchProject(); - }, [ - storesSynced, - hasRestoredFromHash, - selectedWorkspace, - workspaceManagement.workspaceMetadata, - setSelectedWorkspace, - ]); - // 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 ( - - - - ); + // Render App - all state available via contexts + return ; } diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index a390efc8f..fba3fe325 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,15 +1,16 @@ import React from "react"; import { cn } from "@/lib/utils"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import ProjectSidebar from "./ProjectSidebar"; import { TitleBar } from "./TitleBar"; -import type { WorkspaceSelection } from "./ProjectSidebar"; interface LeftSidebarProps { - onSelectWorkspace: (selection: WorkspaceSelection) => void; lastReadTimestamps: Record; onToggleUnread: (workspaceId: string) => void; collapsed: boolean; onToggleCollapsed: () => void; + sortedWorkspacesByProject: Map; + workspaceRecency: Record; } export function LeftSidebar(props: LeftSidebarProps) { diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 0466fb42c..3dd0522a0 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -17,13 +17,11 @@ import { TooltipWrapper, Tooltip } from "./Tooltip"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/types/secrets"; import { ForceDeleteModal } from "./ForceDeleteModal"; -import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; +import { WorkspaceListItem } 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"; import { ChevronRight, KeyRound } from "lucide-react"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -156,38 +154,40 @@ const ProjectDragLayer: React.FC = () => { }; interface ProjectSidebarProps { - onSelectWorkspace: (selection: WorkspaceSelection) => void; lastReadTimestamps: Record; onToggleUnread: (workspaceId: string) => void; collapsed: boolean; onToggleCollapsed: () => void; + sortedWorkspacesByProject: Map; + workspaceRecency: Record; } const ProjectSidebarInner: React.FC = ({ - onSelectWorkspace, lastReadTimestamps, onToggleUnread: _onToggleUnread, collapsed, onToggleCollapsed, + sortedWorkspacesByProject, + workspaceRecency, }) => { + // Get workspace state and operations from context + const { + selectedWorkspace, + setSelectedWorkspace: onSelectWorkspace, + removeWorkspace: onRemoveWorkspace, + renameWorkspace: onRenameWorkspace, + beginWorkspaceCreation: onAddWorkspace, + } = useWorkspaceContext(); + + // Get project state and operations from context const { projects, - openProjectCreateModal, - beginWorkspaceCreation, - clearPendingWorkspaceCreation, - pendingNewWorkspaceProject, - removeProject: removeProjectFromContext, - getSecrets, - updateSecrets, + openProjectCreateModal: onAddProject, + removeProject: onRemoveProject, + getSecrets: onGetSecrets, + updateSecrets: onUpdateSecrets, } = 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 @@ -225,36 +225,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 +285,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 +304,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 +323,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 +334,7 @@ const ProjectSidebarInner: React.FC = ({ const handleSaveSecrets = async (secrets: Secret[]) => { if (secretsModalState) { - await updateSecrets(secretsModalState.projectPath, secrets); + await onUpdateSecrets(secretsModalState.projectPath, secrets); } }; @@ -424,16 +394,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