From cff91794a21875eeb10ba18034e482fe6ea09121 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 19:33:14 -0500 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=A4=96=20Add=20forward=20compatibil?= =?UTF-8?q?ity=20for=20workspace=20path=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make workspace path access defensive with optional chaining to prevent crashes when switching between branches with different config formats. Changes: - Add optional chaining (?.) to all workspacePath.split() calls - Provide fallbacks using workspaceId when path is undefined - Affects App.tsx and utils/commands/sources.ts This allows the app to work with both: - Old config format (only 'path' field) - New config format (with 'id', 'name', and 'path' fields) Prevents 'Cannot read properties of undefined' errors when config has fields that the current code version doesn't expect. --- src/App.tsx | 4 ++-- src/utils/commands/sources.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 840edeb7e..8f0be437d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -681,13 +681,13 @@ function AppInner() { {selectedWorkspace ? ( diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index dfa4e7ce8..50d09020c 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -158,7 +158,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Remove current workspace (rename action intentionally omitted until we add a proper modal) if (selected) { - const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath.split("/").pop() ?? selected.workspacePath}`; + const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath?.split("/").pop() ?? selected.workspacePath}`; list.push({ id: "ws:open-terminal-current", title: "Open Current Workspace in Terminal", @@ -193,8 +193,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi name: "newName", label: "New name", placeholder: "Enter new workspace name", - initialValue: selected.workspacePath.split("/").pop() ?? "", - getInitialValue: () => selected.workspacePath.split("/").pop() ?? "", + initialValue: selected.workspacePath?.split("/").pop() ?? "", + getInitialValue: () => selected.workspacePath?.split("/").pop() ?? "", validate: (v) => (!v.trim() ? "Name is required" : null), }, ], @@ -221,7 +221,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; const label = `${meta.projectName} / ${workspaceName}`; return { id: meta.workspacePath, @@ -251,7 +251,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; const label = `${meta.projectName} / ${workspaceName}`; return { id: meta.id, @@ -269,7 +269,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === values.workspaceId ); - return meta ? (meta.workspacePath.split("/").pop() ?? "") : ""; + return meta ? (meta.workspacePath?.split("/").pop() ?? "") : ""; }, validate: (v) => (!v.trim() ? "Name is required" : null), }, @@ -294,7 +294,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; const label = `${meta.projectName}/${workspaceName}`; return { id: meta.id, @@ -309,7 +309,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi (m) => m.id === vals.workspaceId ); const workspaceName = meta - ? `${meta.projectName}/${meta.workspacePath.split("/").pop() ?? meta.workspacePath}` + ? `${meta.projectName}/${meta.workspacePath?.split("/").pop() ?? meta.workspacePath}` : vals.workspaceId; const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); if (ok) { From 9f89c1e3b3e87cda4324797ae395e88418397d34 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 19:35:33 -0500 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 6 +- src/App.tsx.backup | 740 +++++++++++++++++++++++++++ src/utils/commands/sources.ts.backup | 585 +++++++++++++++++++++ 3 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 src/App.tsx.backup create mode 100644 src/utils/commands/sources.ts.backup diff --git a/src/App.tsx b/src/App.tsx index 8f0be437d..6851a9f86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -687,7 +687,11 @@ function AppInner() { key={selectedWorkspace.workspaceId} workspaceId={selectedWorkspace.workspaceId} projectName={selectedWorkspace.projectName} - branch={selectedWorkspace.workspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId ?? ""} + branch={ + selectedWorkspace.workspacePath?.split("/").pop() ?? + selectedWorkspace.workspaceId ?? + "" + } workspacePath={selectedWorkspace.workspacePath} /> diff --git a/src/App.tsx.backup b/src/App.tsx.backup new file mode 100644 index 000000000..840edeb7e --- /dev/null +++ b/src/App.tsx.backup @@ -0,0 +1,740 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import styled from "@emotion/styled"; +import { Global, css } from "@emotion/react"; +import { GlobalColors } from "./styles/colors"; +import { GlobalFonts } from "./styles/fonts"; +import { GlobalScrollbars } from "./styles/scrollbars"; +import type { ProjectConfig } from "./config"; +import type { WorkspaceSelection } from "./components/ProjectSidebar"; +import { LeftSidebar } from "./components/LeftSidebar"; +import NewWorkspaceModal from "./components/NewWorkspaceModal"; +import { AIView } from "./components/AIView"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; +import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; +import { useProjectManagement } from "./hooks/useProjectManagement"; +import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement"; +import { useResumeManager } from "./hooks/useResumeManager"; +import { useUnreadTracking } from "./hooks/useUnreadTracking"; +import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; +import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; +import { useGitStatusStoreRaw } from "./stores/GitStatusStore"; + +import { useStableReference, compareMaps } from "./hooks/useStableReference"; +import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; +import type { CommandAction } from "./contexts/CommandRegistryContext"; +import { CommandPalette } from "./components/CommandPalette"; +import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; + +import type { ThinkingLevel } from "./types/thinking"; +import { CUSTOM_EVENTS } from "./constants/events"; +import { getThinkingLevelKey } from "./constants/storage"; +import type { BranchListResult } from "./types/ipc"; + +const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; + +// Global Styles with nice fonts +const globalStyles = css` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + html, + body, + #root { + height: 100vh; + overflow: hidden; + background: #1e1e1e; + color: #fff; + font-family: var(--font-primary); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + code { + font-family: var(--font-monospace); + } + + /* Enable native tooltips */ + [title] { + position: relative; + } + + [title]:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 8px; + padding: 6px 10px; + background: #2d2d30; + color: #cccccc; + border: 1px solid #464647; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + z-index: 1000; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + } + + [title]:hover::before { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 3px; + border-width: 5px; + border-style: solid; + border-color: #2d2d30 transparent transparent transparent; + z-index: 1000; + pointer-events: none; + } +`; + +// Styled Components +const AppContainer = styled.div` + display: flex; + height: 100vh; + overflow: hidden; + background: #1e1e1e; +`; + +const MainContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const ContentArea = styled.div` + flex: 1; + display: flex; + overflow: hidden; +`; + +const WelcomeView = styled.div` + text-align: center; + padding: clamp(40px, 10vh, 100px) 20px; + max-width: 800px; + margin: 0 auto; + width: 100%; + + h2 { + color: #fff; + font-size: clamp(24px, 5vw, 36px); + margin-bottom: 16px; + font-weight: 700; + letter-spacing: -1px; + } + + p { + color: #888; + font-size: clamp(14px, 2vw, 16px); + line-height: 1.6; + } +`; + +function AppInner() { + const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( + "selectedWorkspace", + null + ); + const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); + const [workspaceModalProject, setWorkspaceModalProject] = useState(null); + const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState(""); + const [workspaceModalBranches, setWorkspaceModalBranches] = useState([]); + const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState( + undefined + ); + const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState(null); + const workspaceModalProjectRef = useRef(null); + const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", false); + + const handleToggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => !prev); + }, [setSidebarCollapsed]); + + // Use custom hooks for project and workspace management + const { projects, setProjects, addProject, removeProject } = useProjectManagement(); + + // Workspace management needs to update projects state when workspace operations complete + const handleProjectsUpdate = useCallback( + (newProjects: Map) => { + setProjects(newProjects); + }, + [setProjects] + ); + + const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } = + useWorkspaceManagement({ + selectedWorkspace, + onProjectsUpdate: handleProjectsUpdate, + onSelectedWorkspaceUpdate: setSelectedWorkspace, + }); + + // NEW: Sync workspace metadata with the stores + const workspaceStore = useWorkspaceStoreRaw(); + const gitStatusStore = useGitStatusStoreRaw(); + + useEffect(() => { + // Only sync when metadata has actually loaded (not empty initial state) + if (workspaceMetadata.size > 0) { + workspaceStore.syncWorkspaces(workspaceMetadata); + } + }, [workspaceMetadata, workspaceStore]); + + useEffect(() => { + // Only sync when metadata has actually loaded (not empty initial state) + if (workspaceMetadata.size > 0) { + gitStatusStore.syncWorkspaces(workspaceMetadata); + } + }, [workspaceMetadata, gitStatusStore]); + + // Track last-read timestamps for unread indicators + const { lastReadTimestamps, onToggleUnread } = useUnreadTracking(selectedWorkspace); + + // Auto-resume interrupted streams on app startup and when failures occur + useResumeManager(); + + // Handle auto-continue after compaction (when user uses /compact -c) + useAutoCompactContinue(); + + // Sync selectedWorkspace with URL hash + useEffect(() => { + if (selectedWorkspace) { + // Update URL with workspace ID + const newHash = `#workspace=${encodeURIComponent(selectedWorkspace.workspaceId)}`; + if (window.location.hash !== newHash) { + window.history.replaceState(null, "", newHash); + } + + // Update window title + const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`; + void window.api.window.setTitle(title); + } else { + // Clear hash when no workspace selected + if (window.location.hash) { + window.history.replaceState(null, "", window.location.pathname); + } + void window.api.window.setTitle("cmux"); + } + }, [selectedWorkspace]); + + // Restore workspace from URL on mount (if valid) + useEffect(() => { + const hash = window.location.hash; + if (hash.startsWith("#workspace=")) { + const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); + + // Find workspace in metadata + const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId); + + if (metadata) { + // Find project for this workspace + for (const [projectPath, projectConfig] of projects.entries()) { + const workspace = projectConfig.workspaces.find( + (ws) => ws.path === metadata.workspacePath + ); + if (workspace) { + setSelectedWorkspace({ + workspaceId: metadata.id, + projectPath, + projectName: metadata.projectName, + workspacePath: metadata.workspacePath, + }); + break; + } + } + } + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openWorkspaceInTerminal = useCallback((workspacePath: string) => { + void window.api.workspace.openTerminal(workspacePath); + }, []); + + const handleRemoveProject = useCallback( + async (path: string) => { + if (selectedWorkspace?.projectPath === path) { + setSelectedWorkspace(null); + } + await removeProject(path); + }, + [removeProject, selectedWorkspace, setSelectedWorkspace] + ); + + const handleAddWorkspace = useCallback(async (projectPath: string) => { + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project"; + + workspaceModalProjectRef.current = projectPath; + setWorkspaceModalProject(projectPath); + setWorkspaceModalProjectName(projectName); + setWorkspaceModalBranches([]); + setWorkspaceModalDefaultTrunk(undefined); + setWorkspaceModalLoadError(null); + setWorkspaceModalOpen(true); + + try { + const branchResult = await window.api.projects.listBranches(projectPath); + + // Guard against race condition: only update state if this is still the active project + if (workspaceModalProjectRef.current !== projectPath) { + return; + } + + 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]; + + setWorkspaceModalBranches(sanitizedBranches); + setWorkspaceModalDefaultTrunk(recommended); + setWorkspaceModalLoadError(null); + } catch (err) { + console.error("Failed to load branches for modal:", err); + const message = err instanceof Error ? err.message : "Unknown error"; + setWorkspaceModalLoadError( + `Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.` + ); + } + }, []); + + // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders + const handleAddProjectCallback = useCallback(() => { + void addProject(); + }, [addProject]); + + const handleAddWorkspaceCallback = useCallback( + (projectPath: string) => { + void handleAddWorkspace(projectPath); + }, + [handleAddWorkspace] + ); + + const handleRemoveProjectCallback = useCallback( + (path: string) => { + void handleRemoveProject(path); + }, + [handleRemoveProject] + ); + + const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => { + if (!workspaceModalProject) return; + + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided by the workspace modal" + ); + + const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch); + if (newWorkspace) { + setSelectedWorkspace(newWorkspace); + } + }; + + 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); + } + }, + [] + ); + + // NEW: Get workspace recency from store + const workspaceRecency = useWorkspaceRecency(); + + // Sort workspaces by recency (most recent first) + // 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) { + result.set( + projectPath, + config.workspaces.slice().sort((a, b) => { + const aMeta = workspaceMetadata.get(a.path); + const bMeta = workspaceMetadata.get(b.path); + if (!aMeta || !bMeta) return 0; + + // Get timestamp of most recent user message (0 if never used) + const aTimestamp = workspaceRecency[aMeta.id] ?? 0; + const bTimestamp = workspaceRecency[bMeta.id] ?? 0; + return bTimestamp - aTimestamp; + }) + ); + } + return result; + }, + (prev, next) => { + // Compare Maps: check if both size and workspace order are the same + if ( + !compareMaps(prev, next, (a, b) => { + if (a.length !== b.length) return false; + return a.every((workspace, i) => workspace.path === b[i].path); + }) + ) { + return false; + } + return true; + }, + [projects, workspaceMetadata, workspaceRecency] + ); + + const handleNavigateWorkspace = useCallback( + (direction: "next" | "prev") => { + if (!selectedWorkspace) return; + + // Use sorted workspaces to match visual order in sidebar + const sortedWorkspaces = sortedWorkspacesByProject.get(selectedWorkspace.projectPath); + if (!sortedWorkspaces || sortedWorkspaces.length <= 1) return; + + // Find current workspace index in sorted list + const currentIndex = sortedWorkspaces.findIndex( + (ws) => ws.path === selectedWorkspace.workspacePath + ); + if (currentIndex === -1) return; + + // Calculate next/prev index with wrapping + let targetIndex: number; + if (direction === "next") { + targetIndex = (currentIndex + 1) % sortedWorkspaces.length; + } else { + targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1; + } + + const targetWorkspace = sortedWorkspaces[targetIndex]; + if (!targetWorkspace) return; + + const metadata = workspaceMetadata.get(targetWorkspace.path); + if (!metadata) return; + + setSelectedWorkspace({ + projectPath: selectedWorkspace.projectPath, + projectName: selectedWorkspace.projectName, + workspacePath: targetWorkspace.path, + workspaceId: metadata.id, + }); + }, + [selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace] + ); + + // Register command sources with registry + const { + registerSource, + isOpen: isCommandPaletteOpen, + open: openCommandPalette, + close: closeCommandPalette, + } = useCommandRegistry(); + + const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => { + if (!workspaceId) { + return "off"; + } + + if (typeof window === "undefined" || !window.localStorage) { + return "off"; + } + + try { + const key = getThinkingLevelKey(workspaceId); + const stored = window.localStorage.getItem(key); + if (!stored || stored === "undefined") { + return "off"; + } + const parsed = JSON.parse(stored) as ThinkingLevel; + return THINKING_LEVELS.includes(parsed) ? parsed : "off"; + } catch (error) { + console.warn("Failed to read thinking level", error); + return "off"; + } + }, []); + + const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => { + if (!workspaceId) { + return; + } + + const normalized = THINKING_LEVELS.includes(level) ? level : "off"; + const key = getThinkingLevelKey(workspaceId); + + // Use the utility function which handles localStorage and event dispatch + // ThinkingProvider will pick this up via its listener + updatePersistedState(key, normalized); + + // Dispatch toast notification event for UI feedback + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { + detail: { workspaceId, level: normalized }, + }) + ); + } + }, []); + + const registerParamsRef = useRef(null); + + const openNewWorkspaceFromPalette = useCallback( + (projectPath: string) => { + void handleAddWorkspace(projectPath); + }, + [handleAddWorkspace] + ); + + const createWorkspaceFromPalette = useCallback( + async (projectPath: string, branchName: string, trunkBranch: string) => { + console.assert( + typeof trunkBranch === "string" && trunkBranch.trim().length > 0, + "Expected trunk branch to be provided by the command palette" + ); + const newWs = await createWorkspace(projectPath, branchName, trunkBranch); + if (newWs) setSelectedWorkspace(newWs); + }, + [createWorkspace, setSelectedWorkspace] + ); + + 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: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + }) => { + setSelectedWorkspace(selection); + }, + [setSelectedWorkspace] + ); + + const removeWorkspaceFromPalette = useCallback( + async (workspaceId: string) => removeWorkspace(workspaceId), + [removeWorkspace] + ); + + const renameWorkspaceFromPalette = useCallback( + async (workspaceId: string, newName: string) => renameWorkspace(workspaceId, newName), + [renameWorkspace] + ); + + const addProjectFromPalette = useCallback(() => { + void addProject(); + }, [addProject]); + + const removeProjectFromPalette = useCallback( + (path: string) => { + void handleRemoveProject(path); + }, + [handleRemoveProject] + ); + + const toggleSidebarFromPalette = useCallback(() => { + setSidebarCollapsed((prev) => !prev); + }, [setSidebarCollapsed]); + + const navigateWorkspaceFromPalette = useCallback( + (dir: "next" | "prev") => { + handleNavigateWorkspace(dir); + }, + [handleNavigateWorkspace] + ); + + registerParamsRef.current = { + projects, + workspaceMetadata, + selectedWorkspace, + getThinkingLevel: getThinkingLevelForWorkspace, + onSetThinkingLevel: setThinkingLevelFromPalette, + onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, + onCreateWorkspace: createWorkspaceFromPalette, + getBranchesForProject, + onSelectWorkspace: selectWorkspaceFromPalette, + onRemoveWorkspace: removeWorkspaceFromPalette, + onRenameWorkspace: renameWorkspaceFromPalette, + onAddProject: addProjectFromPalette, + onRemoveProject: removeProjectFromPalette, + onToggleSidebar: toggleSidebarFromPalette, + onNavigateWorkspace: navigateWorkspaceFromPalette, + onOpenWorkspaceInTerminal: openWorkspaceInTerminal, + }; + + useEffect(() => { + const unregister = registerSource(() => { + const params = registerParamsRef.current; + if (!params) return []; + + // Compute streaming models here (only when command palette opens) + const allStates = workspaceStore.getAllStates(); + const streamingModels = new Map(); + for (const [workspaceId, state] of allStates) { + if (state.canInterrupt && state.currentModel) { + streamingModels.set(workspaceId, state.currentModel); + } + } + + const factories = buildCoreSources({ ...params, streamingModels }); + const actions: CommandAction[] = []; + for (const factory of factories) { + actions.push(...factory()); + } + return actions; + }); + return unregister; + }, [registerSource, workspaceStore]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) { + e.preventDefault(); + handleNavigateWorkspace("next"); + } else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) { + e.preventDefault(); + handleNavigateWorkspace("prev"); + } else if (matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE)) { + e.preventDefault(); + if (isCommandPaletteOpen) { + closeCommandPalette(); + } else { + openCommandPalette(); + } + } else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) { + e.preventDefault(); + setSidebarCollapsed((prev) => !prev); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + handleNavigateWorkspace, + setSidebarCollapsed, + isCommandPaletteOpen, + closeCommandPalette, + openCommandPalette, + ]); + + return ( + <> + + + + + + + + + {selectedWorkspace ? ( + + + + ) : ( + +

Welcome to Cmux

+

Select a workspace from the sidebar or add a new one to get started.

+
+ )} +
+
+ ({ + providerNames: [], + workspaceId: selectedWorkspace?.workspaceId, + })} + /> + {workspaceModalOpen && workspaceModalProject && ( + { + workspaceModalProjectRef.current = null; + setWorkspaceModalOpen(false); + setWorkspaceModalProject(null); + setWorkspaceModalProjectName(""); + setWorkspaceModalBranches([]); + setWorkspaceModalDefaultTrunk(undefined); + setWorkspaceModalLoadError(null); + }} + onAdd={handleCreateWorkspace} + /> + )} +
+ + ); +} + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/src/utils/commands/sources.ts.backup b/src/utils/commands/sources.ts.backup new file mode 100644 index 000000000..dfa4e7ce8 --- /dev/null +++ b/src/utils/commands/sources.ts.backup @@ -0,0 +1,585 @@ +import type { CommandAction } from "@/contexts/CommandRegistryContext"; +import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import type { ThinkingLevel } from "@/types/thinking"; +import { CUSTOM_EVENTS } from "@/constants/events"; + +import type { ProjectConfig } from "@/config"; +import type { WorkspaceMetadata } from "@/types/workspace"; +import type { BranchListResult } from "@/types/ipc"; + +export interface BuildSourcesParams { + projects: Map; + workspaceMetadata: Map; + selectedWorkspace: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + } | null; + streamingModels?: Map; + // UI actions + getThinkingLevel: (workspaceId: string) => ThinkingLevel; + onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; + + onOpenNewWorkspaceModal: (projectPath: string) => void; + onCreateWorkspace: ( + projectPath: string, + branchName: string, + trunkBranch: string + ) => Promise; + getBranchesForProject: (projectPath: string) => Promise; + onSelectWorkspace: (sel: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + }) => void; + onRemoveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + onRenameWorkspace: ( + workspaceId: string, + newName: string + ) => Promise<{ success: boolean; error?: string }>; + onAddProject: () => void; + onRemoveProject: (path: string) => void; + onToggleSidebar: () => void; + onNavigateWorkspace: (dir: "next" | "prev") => void; + onOpenWorkspaceInTerminal: (workspacePath: string) => void; +} + +const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; + +const section = { + workspaces: "Workspaces", + navigation: "Navigation", + chat: "Chat", + mode: "Modes & Model", + help: "Help", + projects: "Projects", +}; + +export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { + const actions: Array<() => CommandAction[]> = []; + + const createWorkspaceForSelectedProjectAction = ( + selected: NonNullable + ): CommandAction => { + let cachedBranchInfo: BranchListResult | null = null; + const getBranchInfo = async () => { + cachedBranchInfo ??= await p.getBranchesForProject(selected.projectPath); + return cachedBranchInfo; + }; + + return { + id: "ws:new", + title: "Create New Workspace…", + subtitle: `for ${selected.projectName}`, + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), + run: () => undefined, + prompt: { + title: "New Workspace", + fields: [ + { + type: "text", + name: "branchName", + label: "Workspace branch name", + placeholder: "Enter branch name", + validate: (v) => (!v.trim() ? "Branch name is required" : null), + }, + { + type: "select", + name: "trunkBranch", + label: "Trunk branch", + placeholder: "Search branches…", + getOptions: async () => { + const info = await getBranchInfo(); + return info.branches.map((branch) => ({ + id: branch, + label: branch, + keywords: [branch], + })); + }, + }, + ], + onSubmit: async (vals) => { + const trimmedBranchName = vals.branchName.trim(); + const info = await getBranchInfo(); + const providedTrunk = vals.trunkBranch?.trim(); + const resolvedTrunk = + providedTrunk && info.branches.includes(providedTrunk) + ? providedTrunk + : info.branches.includes(info.recommendedTrunk) + ? info.recommendedTrunk + : info.branches[0]; + + if (!resolvedTrunk) { + throw new Error("Unable to determine trunk branch for workspace creation"); + } + + await p.onCreateWorkspace(selected.projectPath, trimmedBranchName, resolvedTrunk); + }, + }, + }; + }; + + // Workspaces + actions.push(() => { + const list: CommandAction[] = []; + + const selected = p.selectedWorkspace; + if (selected) { + list.push(createWorkspaceForSelectedProjectAction(selected)); + } + + // Switch to workspace + for (const [projectPath, config] of p.projects.entries()) { + const projectName = projectPath.split("/").pop() ?? projectPath; + for (const ws of config.workspaces) { + const meta = p.workspaceMetadata.get(ws.path); + if (!meta) continue; + const isCurrent = selected?.workspaceId === meta.id; + const isStreaming = p.streamingModels?.has(meta.id) ?? false; + list.push({ + id: `ws:switch:${meta.id}`, + title: `${isCurrent ? "• " : ""}Switch to ${ws.path.split("/").pop() ?? ws.path}`, + subtitle: `${projectName}${isStreaming ? " • streaming" : ""}`, + section: section.workspaces, + keywords: [projectName, ws.path], + run: () => + p.onSelectWorkspace({ + projectPath, + projectName, + workspacePath: ws.path, + workspaceId: meta.id, + }), + }); + } + } + + // Remove current workspace (rename action intentionally omitted until we add a proper modal) + if (selected) { + const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath.split("/").pop() ?? selected.workspacePath}`; + list.push({ + id: "ws:open-terminal-current", + title: "Open Current Workspace in Terminal", + subtitle: workspaceDisplayName, + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), + run: () => { + p.onOpenWorkspaceInTerminal(selected.workspacePath); + }, + }); + list.push({ + id: "ws:remove", + title: "Remove Current Workspace…", + subtitle: workspaceDisplayName, + section: section.workspaces, + run: async () => { + const ok = confirm("Remove current workspace? This cannot be undone."); + if (ok) await p.onRemoveWorkspace(selected.workspaceId); + }, + }); + list.push({ + id: "ws:rename", + title: "Rename Current Workspace…", + subtitle: workspaceDisplayName, + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Rename Workspace", + fields: [ + { + type: "text", + name: "newName", + label: "New name", + placeholder: "Enter new workspace name", + initialValue: selected.workspacePath.split("/").pop() ?? "", + getInitialValue: () => selected.workspacePath.split("/").pop() ?? "", + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onRenameWorkspace(selected.workspaceId, vals.newName.trim()); + }, + }, + }); + } + + if (p.workspaceMetadata.size > 0) { + list.push({ + id: "ws:open-terminal", + title: "Open Workspace in Terminal…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Open Workspace in Terminal", + fields: [ + { + type: "select", + name: "workspacePath", + label: "Workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName} / ${workspaceName}`; + return { + id: meta.workspacePath, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + ], + onSubmit: (vals) => { + p.onOpenWorkspaceInTerminal(vals.workspacePath); + }, + }, + }); + list.push({ + id: "ws:rename-any", + title: "Rename Workspace…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Rename Workspace", + fields: [ + { + type: "select", + name: "workspaceId", + label: "Select workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName} / ${workspaceName}`; + return { + id: meta.id, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + { + type: "text", + name: "newName", + label: "New name", + placeholder: "Enter new workspace name", + getInitialValue: (values) => { + const meta = Array.from(p.workspaceMetadata.values()).find( + (m) => m.id === values.workspaceId + ); + return meta ? (meta.workspacePath.split("/").pop() ?? "") : ""; + }, + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onRenameWorkspace(vals.workspaceId, vals.newName.trim()); + }, + }, + }); + list.push({ + id: "ws:remove-any", + title: "Remove Workspace…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Remove Workspace", + fields: [ + { + type: "select", + name: "workspaceId", + label: "Select workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName}/${workspaceName}`; + return { + id: meta.id, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + ], + onSubmit: async (vals) => { + const meta = Array.from(p.workspaceMetadata.values()).find( + (m) => m.id === vals.workspaceId + ); + const workspaceName = meta + ? `${meta.projectName}/${meta.workspacePath.split("/").pop() ?? meta.workspacePath}` + : vals.workspaceId; + const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); + if (ok) { + await p.onRemoveWorkspace(vals.workspaceId); + } + }, + }, + }); + } + + return list; + }); + + // Navigation / Interface + actions.push(() => [ + { + id: "nav:next", + title: "Next Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.NEXT_WORKSPACE), + run: () => p.onNavigateWorkspace("next"), + }, + { + id: "nav:prev", + title: "Previous Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.PREV_WORKSPACE), + run: () => p.onNavigateWorkspace("prev"), + }, + { + id: "nav:toggleSidebar", + title: "Toggle Sidebar", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), + run: () => p.onToggleSidebar(), + }, + ]); + + // Chat utilities + actions.push(() => { + const list: CommandAction[] = []; + if (p.selectedWorkspace) { + const id = p.selectedWorkspace.workspaceId; + list.push({ + id: "chat:clear", + title: "Clear History", + section: section.chat, + run: async () => { + await window.api.workspace.truncateHistory(id, 1.0); + }, + }); + for (const pct of [0.75, 0.5, 0.25]) { + list.push({ + id: `chat:truncate:${pct}`, + title: `Truncate History to ${Math.round((1 - pct) * 100)}%`, + section: section.chat, + run: async () => { + await window.api.workspace.truncateHistory(id, pct); + }, + }); + } + list.push({ + id: "chat:interrupt", + title: "Interrupt Streaming", + section: section.chat, + run: async () => { + await window.api.workspace.interruptStream(id); + }, + }); + list.push({ + id: "chat:jumpBottom", + title: "Jump to Bottom", + section: section.chat, + shortcutHint: formatKeybind(KEYBINDS.JUMP_TO_BOTTOM), + run: () => { + // Dispatch the keybind; AIView listens for it + const ev = new KeyboardEvent("keydown", { key: "G", shiftKey: true }); + window.dispatchEvent(ev); + }, + }); + } + return list; + }); + + // Modes & Model + actions.push(() => { + const list: CommandAction[] = [ + { + id: "mode:toggle", + title: "Toggle Plan/Exec Mode", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_MODE), + run: () => { + const ev = new KeyboardEvent("keydown", { key: "M", ctrlKey: true, shiftKey: true }); + window.dispatchEvent(ev); + }, + }, + { + id: "model:change", + title: "Change Model…", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), + run: () => { + window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); + }, + }, + ]; + + const selectedWorkspace = p.selectedWorkspace; + if (selectedWorkspace) { + const { workspaceId } = selectedWorkspace; + const levelDescriptions: Record = { + off: "Off — fastest responses", + low: "Low — add a bit of reasoning", + medium: "Medium — balanced reasoning", + high: "High — maximum reasoning depth", + }; + const currentLevel = p.getThinkingLevel(workspaceId); + + list.push({ + id: "thinking:set-level", + title: "Set Thinking Effort…", + subtitle: `Current: ${levelDescriptions[currentLevel] ?? currentLevel}`, + section: section.mode, + run: () => undefined, + prompt: { + title: "Select Thinking Effort", + fields: [ + { + type: "select", + name: "thinkingLevel", + label: "Thinking effort", + placeholder: "Choose effort level…", + getOptions: () => + THINKING_LEVELS.map((level) => ({ + id: level, + label: levelDescriptions[level], + keywords: [ + level, + levelDescriptions[level].toLowerCase(), + "thinking", + "reasoning", + ], + })), + }, + ], + onSubmit: (vals) => { + const rawLevel = vals.thinkingLevel; + const level = THINKING_LEVELS.includes(rawLevel as ThinkingLevel) + ? (rawLevel as ThinkingLevel) + : "off"; + p.onSetThinkingLevel(workspaceId, level); + }, + }, + }); + } + + return list; + }); + + // Help / Docs + actions.push(() => [ + { + id: "help:keybinds", + title: "Show Keyboard Shortcuts", + section: section.help, + run: () => { + try { + window.open("https://cmux.io/keybinds.html", "_blank"); + } catch { + /* ignore */ + } + }, + }, + ]); + + // Projects + actions.push(() => { + const branchCache = new Map(); + const getBranchInfoForProject = async (projectPath: string) => { + const cached = branchCache.get(projectPath); + if (cached) return cached; + const info = await p.getBranchesForProject(projectPath); + branchCache.set(projectPath, info); + return info; + }; + + const list: CommandAction[] = [ + { + id: "project:add", + title: "Add Project…", + section: section.projects, + run: () => p.onAddProject(), + }, + { + id: "ws:new-in-project", + title: "Create New Workspace in Project…", + section: section.projects, + run: () => undefined, + prompt: { + title: "New Workspace in Project", + fields: [ + { + type: "select", + name: "projectPath", + label: "Select project", + placeholder: "Search projects…", + getOptions: (_values) => + Array.from(p.projects.keys()).map((projectPath) => ({ + id: projectPath, + label: projectPath.split("/").pop() ?? projectPath, + keywords: [projectPath], + })), + }, + { + type: "text", + name: "branchName", + label: "Workspace branch name", + placeholder: "Enter branch name", + validate: (v) => (!v.trim() ? "Branch name is required" : null), + }, + { + type: "select", + name: "trunkBranch", + label: "Trunk branch", + placeholder: "Search branches…", + getOptions: async (values) => { + if (!values.projectPath) return []; + const info = await getBranchInfoForProject(values.projectPath); + return info.branches.map((branch) => ({ + id: branch, + label: branch, + keywords: [branch], + })); + }, + }, + ], + onSubmit: async (vals) => { + const projectPath = vals.projectPath; + const trimmedBranchName = vals.branchName.trim(); + const info = await getBranchInfoForProject(projectPath); + const providedTrunk = vals.trunkBranch?.trim(); + const resolvedTrunk = + providedTrunk && info.branches.includes(providedTrunk) + ? providedTrunk + : info.branches.includes(info.recommendedTrunk) + ? info.recommendedTrunk + : info.branches[0]; + + if (!resolvedTrunk) { + throw new Error("Unable to determine trunk branch for workspace creation"); + } + + await p.onCreateWorkspace(projectPath, trimmedBranchName, resolvedTrunk); + }, + }, + }, + ]; + + for (const [projectPath] of p.projects.entries()) { + const projectName = projectPath.split("/").pop() ?? projectPath; + list.push({ + id: `project:remove:${projectPath}`, + title: `Remove Project ${projectName}…`, + section: section.projects, + run: () => p.onRemoveProject(projectPath), + }); + } + return list; + }); + + return actions; +} From f428438029f6d0b32d176ba0b4b99a70572a1539 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 19:35:39 -0500 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A4=96=20Remove=20backup=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx.backup | 740 --------------------------- src/utils/commands/sources.ts.backup | 585 --------------------- 2 files changed, 1325 deletions(-) delete mode 100644 src/App.tsx.backup delete mode 100644 src/utils/commands/sources.ts.backup diff --git a/src/App.tsx.backup b/src/App.tsx.backup deleted file mode 100644 index 840edeb7e..000000000 --- a/src/App.tsx.backup +++ /dev/null @@ -1,740 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import styled from "@emotion/styled"; -import { Global, css } from "@emotion/react"; -import { GlobalColors } from "./styles/colors"; -import { GlobalFonts } from "./styles/fonts"; -import { GlobalScrollbars } from "./styles/scrollbars"; -import type { ProjectConfig } from "./config"; -import type { WorkspaceSelection } from "./components/ProjectSidebar"; -import { LeftSidebar } from "./components/LeftSidebar"; -import NewWorkspaceModal from "./components/NewWorkspaceModal"; -import { AIView } from "./components/AIView"; -import { ErrorBoundary } from "./components/ErrorBoundary"; -import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; -import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; -import { useProjectManagement } from "./hooks/useProjectManagement"; -import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement"; -import { useResumeManager } from "./hooks/useResumeManager"; -import { useUnreadTracking } from "./hooks/useUnreadTracking"; -import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; -import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; -import { useGitStatusStoreRaw } from "./stores/GitStatusStore"; - -import { useStableReference, compareMaps } from "./hooks/useStableReference"; -import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; -import type { CommandAction } from "./contexts/CommandRegistryContext"; -import { CommandPalette } from "./components/CommandPalette"; -import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; - -import type { ThinkingLevel } from "./types/thinking"; -import { CUSTOM_EVENTS } from "./constants/events"; -import { getThinkingLevelKey } from "./constants/storage"; -import type { BranchListResult } from "./types/ipc"; - -const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; - -// Global Styles with nice fonts -const globalStyles = css` - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - html, - body, - #root { - height: 100vh; - overflow: hidden; - background: #1e1e1e; - color: #fff; - font-family: var(--font-primary); - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - - code { - font-family: var(--font-monospace); - } - - /* Enable native tooltips */ - [title] { - position: relative; - } - - [title]:hover::after { - content: attr(title); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - margin-bottom: 8px; - padding: 6px 10px; - background: #2d2d30; - color: #cccccc; - border: 1px solid #464647; - border-radius: 4px; - font-size: 11px; - white-space: nowrap; - z-index: 1000; - pointer-events: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); - } - - [title]:hover::before { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - margin-bottom: 3px; - border-width: 5px; - border-style: solid; - border-color: #2d2d30 transparent transparent transparent; - z-index: 1000; - pointer-events: none; - } -`; - -// Styled Components -const AppContainer = styled.div` - display: flex; - height: 100vh; - overflow: hidden; - background: #1e1e1e; -`; - -const MainContent = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -`; - -const ContentArea = styled.div` - flex: 1; - display: flex; - overflow: hidden; -`; - -const WelcomeView = styled.div` - text-align: center; - padding: clamp(40px, 10vh, 100px) 20px; - max-width: 800px; - margin: 0 auto; - width: 100%; - - h2 { - color: #fff; - font-size: clamp(24px, 5vw, 36px); - margin-bottom: 16px; - font-weight: 700; - letter-spacing: -1px; - } - - p { - color: #888; - font-size: clamp(14px, 2vw, 16px); - line-height: 1.6; - } -`; - -function AppInner() { - const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( - "selectedWorkspace", - null - ); - const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); - const [workspaceModalProject, setWorkspaceModalProject] = useState(null); - const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState(""); - const [workspaceModalBranches, setWorkspaceModalBranches] = useState([]); - const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState( - undefined - ); - const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState(null); - const workspaceModalProjectRef = useRef(null); - const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", false); - - const handleToggleSidebar = useCallback(() => { - setSidebarCollapsed((prev) => !prev); - }, [setSidebarCollapsed]); - - // Use custom hooks for project and workspace management - const { projects, setProjects, addProject, removeProject } = useProjectManagement(); - - // Workspace management needs to update projects state when workspace operations complete - const handleProjectsUpdate = useCallback( - (newProjects: Map) => { - setProjects(newProjects); - }, - [setProjects] - ); - - const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } = - useWorkspaceManagement({ - selectedWorkspace, - onProjectsUpdate: handleProjectsUpdate, - onSelectedWorkspaceUpdate: setSelectedWorkspace, - }); - - // NEW: Sync workspace metadata with the stores - const workspaceStore = useWorkspaceStoreRaw(); - const gitStatusStore = useGitStatusStoreRaw(); - - useEffect(() => { - // Only sync when metadata has actually loaded (not empty initial state) - if (workspaceMetadata.size > 0) { - workspaceStore.syncWorkspaces(workspaceMetadata); - } - }, [workspaceMetadata, workspaceStore]); - - useEffect(() => { - // Only sync when metadata has actually loaded (not empty initial state) - if (workspaceMetadata.size > 0) { - gitStatusStore.syncWorkspaces(workspaceMetadata); - } - }, [workspaceMetadata, gitStatusStore]); - - // Track last-read timestamps for unread indicators - const { lastReadTimestamps, onToggleUnread } = useUnreadTracking(selectedWorkspace); - - // Auto-resume interrupted streams on app startup and when failures occur - useResumeManager(); - - // Handle auto-continue after compaction (when user uses /compact -c) - useAutoCompactContinue(); - - // Sync selectedWorkspace with URL hash - useEffect(() => { - if (selectedWorkspace) { - // Update URL with workspace ID - const newHash = `#workspace=${encodeURIComponent(selectedWorkspace.workspaceId)}`; - if (window.location.hash !== newHash) { - window.history.replaceState(null, "", newHash); - } - - // Update window title - const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`; - void window.api.window.setTitle(title); - } else { - // Clear hash when no workspace selected - if (window.location.hash) { - window.history.replaceState(null, "", window.location.pathname); - } - void window.api.window.setTitle("cmux"); - } - }, [selectedWorkspace]); - - // Restore workspace from URL on mount (if valid) - useEffect(() => { - const hash = window.location.hash; - if (hash.startsWith("#workspace=")) { - const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); - - // Find workspace in metadata - const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId); - - if (metadata) { - // Find project for this workspace - for (const [projectPath, projectConfig] of projects.entries()) { - const workspace = projectConfig.workspaces.find( - (ws) => ws.path === metadata.workspacePath - ); - if (workspace) { - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath, - projectName: metadata.projectName, - workspacePath: metadata.workspacePath, - }); - break; - } - } - } - } - // Only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const openWorkspaceInTerminal = useCallback((workspacePath: string) => { - void window.api.workspace.openTerminal(workspacePath); - }, []); - - const handleRemoveProject = useCallback( - async (path: string) => { - if (selectedWorkspace?.projectPath === path) { - setSelectedWorkspace(null); - } - await removeProject(path); - }, - [removeProject, selectedWorkspace, setSelectedWorkspace] - ); - - const handleAddWorkspace = useCallback(async (projectPath: string) => { - const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project"; - - workspaceModalProjectRef.current = projectPath; - setWorkspaceModalProject(projectPath); - setWorkspaceModalProjectName(projectName); - setWorkspaceModalBranches([]); - setWorkspaceModalDefaultTrunk(undefined); - setWorkspaceModalLoadError(null); - setWorkspaceModalOpen(true); - - try { - const branchResult = await window.api.projects.listBranches(projectPath); - - // Guard against race condition: only update state if this is still the active project - if (workspaceModalProjectRef.current !== projectPath) { - return; - } - - 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]; - - setWorkspaceModalBranches(sanitizedBranches); - setWorkspaceModalDefaultTrunk(recommended); - setWorkspaceModalLoadError(null); - } catch (err) { - console.error("Failed to load branches for modal:", err); - const message = err instanceof Error ? err.message : "Unknown error"; - setWorkspaceModalLoadError( - `Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.` - ); - } - }, []); - - // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders - const handleAddProjectCallback = useCallback(() => { - void addProject(); - }, [addProject]); - - const handleAddWorkspaceCallback = useCallback( - (projectPath: string) => { - void handleAddWorkspace(projectPath); - }, - [handleAddWorkspace] - ); - - const handleRemoveProjectCallback = useCallback( - (path: string) => { - void handleRemoveProject(path); - }, - [handleRemoveProject] - ); - - const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => { - if (!workspaceModalProject) return; - - console.assert( - typeof trunkBranch === "string" && trunkBranch.trim().length > 0, - "Expected trunk branch to be provided by the workspace modal" - ); - - const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch); - if (newWorkspace) { - setSelectedWorkspace(newWorkspace); - } - }; - - 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); - } - }, - [] - ); - - // NEW: Get workspace recency from store - const workspaceRecency = useWorkspaceRecency(); - - // Sort workspaces by recency (most recent first) - // 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) { - result.set( - projectPath, - config.workspaces.slice().sort((a, b) => { - const aMeta = workspaceMetadata.get(a.path); - const bMeta = workspaceMetadata.get(b.path); - if (!aMeta || !bMeta) return 0; - - // Get timestamp of most recent user message (0 if never used) - const aTimestamp = workspaceRecency[aMeta.id] ?? 0; - const bTimestamp = workspaceRecency[bMeta.id] ?? 0; - return bTimestamp - aTimestamp; - }) - ); - } - return result; - }, - (prev, next) => { - // Compare Maps: check if both size and workspace order are the same - if ( - !compareMaps(prev, next, (a, b) => { - if (a.length !== b.length) return false; - return a.every((workspace, i) => workspace.path === b[i].path); - }) - ) { - return false; - } - return true; - }, - [projects, workspaceMetadata, workspaceRecency] - ); - - const handleNavigateWorkspace = useCallback( - (direction: "next" | "prev") => { - if (!selectedWorkspace) return; - - // Use sorted workspaces to match visual order in sidebar - const sortedWorkspaces = sortedWorkspacesByProject.get(selectedWorkspace.projectPath); - if (!sortedWorkspaces || sortedWorkspaces.length <= 1) return; - - // Find current workspace index in sorted list - const currentIndex = sortedWorkspaces.findIndex( - (ws) => ws.path === selectedWorkspace.workspacePath - ); - if (currentIndex === -1) return; - - // Calculate next/prev index with wrapping - let targetIndex: number; - if (direction === "next") { - targetIndex = (currentIndex + 1) % sortedWorkspaces.length; - } else { - targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1; - } - - const targetWorkspace = sortedWorkspaces[targetIndex]; - if (!targetWorkspace) return; - - const metadata = workspaceMetadata.get(targetWorkspace.path); - if (!metadata) return; - - setSelectedWorkspace({ - projectPath: selectedWorkspace.projectPath, - projectName: selectedWorkspace.projectName, - workspacePath: targetWorkspace.path, - workspaceId: metadata.id, - }); - }, - [selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace] - ); - - // Register command sources with registry - const { - registerSource, - isOpen: isCommandPaletteOpen, - open: openCommandPalette, - close: closeCommandPalette, - } = useCommandRegistry(); - - const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => { - if (!workspaceId) { - return "off"; - } - - if (typeof window === "undefined" || !window.localStorage) { - return "off"; - } - - try { - const key = getThinkingLevelKey(workspaceId); - const stored = window.localStorage.getItem(key); - if (!stored || stored === "undefined") { - return "off"; - } - const parsed = JSON.parse(stored) as ThinkingLevel; - return THINKING_LEVELS.includes(parsed) ? parsed : "off"; - } catch (error) { - console.warn("Failed to read thinking level", error); - return "off"; - } - }, []); - - const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => { - if (!workspaceId) { - return; - } - - const normalized = THINKING_LEVELS.includes(level) ? level : "off"; - const key = getThinkingLevelKey(workspaceId); - - // Use the utility function which handles localStorage and event dispatch - // ThinkingProvider will pick this up via its listener - updatePersistedState(key, normalized); - - // Dispatch toast notification event for UI feedback - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { - detail: { workspaceId, level: normalized }, - }) - ); - } - }, []); - - const registerParamsRef = useRef(null); - - const openNewWorkspaceFromPalette = useCallback( - (projectPath: string) => { - void handleAddWorkspace(projectPath); - }, - [handleAddWorkspace] - ); - - const createWorkspaceFromPalette = useCallback( - async (projectPath: string, branchName: string, trunkBranch: string) => { - console.assert( - typeof trunkBranch === "string" && trunkBranch.trim().length > 0, - "Expected trunk branch to be provided by the command palette" - ); - const newWs = await createWorkspace(projectPath, branchName, trunkBranch); - if (newWs) setSelectedWorkspace(newWs); - }, - [createWorkspace, setSelectedWorkspace] - ); - - 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: { - projectPath: string; - projectName: string; - workspacePath: string; - workspaceId: string; - }) => { - setSelectedWorkspace(selection); - }, - [setSelectedWorkspace] - ); - - const removeWorkspaceFromPalette = useCallback( - async (workspaceId: string) => removeWorkspace(workspaceId), - [removeWorkspace] - ); - - const renameWorkspaceFromPalette = useCallback( - async (workspaceId: string, newName: string) => renameWorkspace(workspaceId, newName), - [renameWorkspace] - ); - - const addProjectFromPalette = useCallback(() => { - void addProject(); - }, [addProject]); - - const removeProjectFromPalette = useCallback( - (path: string) => { - void handleRemoveProject(path); - }, - [handleRemoveProject] - ); - - const toggleSidebarFromPalette = useCallback(() => { - setSidebarCollapsed((prev) => !prev); - }, [setSidebarCollapsed]); - - const navigateWorkspaceFromPalette = useCallback( - (dir: "next" | "prev") => { - handleNavigateWorkspace(dir); - }, - [handleNavigateWorkspace] - ); - - registerParamsRef.current = { - projects, - workspaceMetadata, - selectedWorkspace, - getThinkingLevel: getThinkingLevelForWorkspace, - onSetThinkingLevel: setThinkingLevelFromPalette, - onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, - onCreateWorkspace: createWorkspaceFromPalette, - getBranchesForProject, - onSelectWorkspace: selectWorkspaceFromPalette, - onRemoveWorkspace: removeWorkspaceFromPalette, - onRenameWorkspace: renameWorkspaceFromPalette, - onAddProject: addProjectFromPalette, - onRemoveProject: removeProjectFromPalette, - onToggleSidebar: toggleSidebarFromPalette, - onNavigateWorkspace: navigateWorkspaceFromPalette, - onOpenWorkspaceInTerminal: openWorkspaceInTerminal, - }; - - useEffect(() => { - const unregister = registerSource(() => { - const params = registerParamsRef.current; - if (!params) return []; - - // Compute streaming models here (only when command palette opens) - const allStates = workspaceStore.getAllStates(); - const streamingModels = new Map(); - for (const [workspaceId, state] of allStates) { - if (state.canInterrupt && state.currentModel) { - streamingModels.set(workspaceId, state.currentModel); - } - } - - const factories = buildCoreSources({ ...params, streamingModels }); - const actions: CommandAction[] = []; - for (const factory of factories) { - actions.push(...factory()); - } - return actions; - }); - return unregister; - }, [registerSource, workspaceStore]); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) { - e.preventDefault(); - handleNavigateWorkspace("next"); - } else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) { - e.preventDefault(); - handleNavigateWorkspace("prev"); - } else if (matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE)) { - e.preventDefault(); - if (isCommandPaletteOpen) { - closeCommandPalette(); - } else { - openCommandPalette(); - } - } else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) { - e.preventDefault(); - setSidebarCollapsed((prev) => !prev); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - handleNavigateWorkspace, - setSidebarCollapsed, - isCommandPaletteOpen, - closeCommandPalette, - openCommandPalette, - ]); - - return ( - <> - - - - - - - - - {selectedWorkspace ? ( - - - - ) : ( - -

Welcome to Cmux

-

Select a workspace from the sidebar or add a new one to get started.

-
- )} -
-
- ({ - providerNames: [], - workspaceId: selectedWorkspace?.workspaceId, - })} - /> - {workspaceModalOpen && workspaceModalProject && ( - { - workspaceModalProjectRef.current = null; - setWorkspaceModalOpen(false); - setWorkspaceModalProject(null); - setWorkspaceModalProjectName(""); - setWorkspaceModalBranches([]); - setWorkspaceModalDefaultTrunk(undefined); - setWorkspaceModalLoadError(null); - }} - onAdd={handleCreateWorkspace} - /> - )} -
- - ); -} - -function App() { - return ( - - - - ); -} - -export default App; diff --git a/src/utils/commands/sources.ts.backup b/src/utils/commands/sources.ts.backup deleted file mode 100644 index dfa4e7ce8..000000000 --- a/src/utils/commands/sources.ts.backup +++ /dev/null @@ -1,585 +0,0 @@ -import type { CommandAction } from "@/contexts/CommandRegistryContext"; -import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; -import type { ThinkingLevel } from "@/types/thinking"; -import { CUSTOM_EVENTS } from "@/constants/events"; - -import type { ProjectConfig } from "@/config"; -import type { WorkspaceMetadata } from "@/types/workspace"; -import type { BranchListResult } from "@/types/ipc"; - -export interface BuildSourcesParams { - projects: Map; - workspaceMetadata: Map; - selectedWorkspace: { - projectPath: string; - projectName: string; - workspacePath: string; - workspaceId: string; - } | null; - streamingModels?: Map; - // UI actions - getThinkingLevel: (workspaceId: string) => ThinkingLevel; - onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; - - onOpenNewWorkspaceModal: (projectPath: string) => void; - onCreateWorkspace: ( - projectPath: string, - branchName: string, - trunkBranch: string - ) => Promise; - getBranchesForProject: (projectPath: string) => Promise; - onSelectWorkspace: (sel: { - projectPath: string; - projectName: string; - workspacePath: string; - workspaceId: string; - }) => void; - onRemoveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; - onRenameWorkspace: ( - workspaceId: string, - newName: string - ) => Promise<{ success: boolean; error?: string }>; - onAddProject: () => void; - onRemoveProject: (path: string) => void; - onToggleSidebar: () => void; - onNavigateWorkspace: (dir: "next" | "prev") => void; - onOpenWorkspaceInTerminal: (workspacePath: string) => void; -} - -const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; - -const section = { - workspaces: "Workspaces", - navigation: "Navigation", - chat: "Chat", - mode: "Modes & Model", - help: "Help", - projects: "Projects", -}; - -export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { - const actions: Array<() => CommandAction[]> = []; - - const createWorkspaceForSelectedProjectAction = ( - selected: NonNullable - ): CommandAction => { - let cachedBranchInfo: BranchListResult | null = null; - const getBranchInfo = async () => { - cachedBranchInfo ??= await p.getBranchesForProject(selected.projectPath); - return cachedBranchInfo; - }; - - return { - id: "ws:new", - title: "Create New Workspace…", - subtitle: `for ${selected.projectName}`, - section: section.workspaces, - shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), - run: () => undefined, - prompt: { - title: "New Workspace", - fields: [ - { - type: "text", - name: "branchName", - label: "Workspace branch name", - placeholder: "Enter branch name", - validate: (v) => (!v.trim() ? "Branch name is required" : null), - }, - { - type: "select", - name: "trunkBranch", - label: "Trunk branch", - placeholder: "Search branches…", - getOptions: async () => { - const info = await getBranchInfo(); - return info.branches.map((branch) => ({ - id: branch, - label: branch, - keywords: [branch], - })); - }, - }, - ], - onSubmit: async (vals) => { - const trimmedBranchName = vals.branchName.trim(); - const info = await getBranchInfo(); - const providedTrunk = vals.trunkBranch?.trim(); - const resolvedTrunk = - providedTrunk && info.branches.includes(providedTrunk) - ? providedTrunk - : info.branches.includes(info.recommendedTrunk) - ? info.recommendedTrunk - : info.branches[0]; - - if (!resolvedTrunk) { - throw new Error("Unable to determine trunk branch for workspace creation"); - } - - await p.onCreateWorkspace(selected.projectPath, trimmedBranchName, resolvedTrunk); - }, - }, - }; - }; - - // Workspaces - actions.push(() => { - const list: CommandAction[] = []; - - const selected = p.selectedWorkspace; - if (selected) { - list.push(createWorkspaceForSelectedProjectAction(selected)); - } - - // Switch to workspace - for (const [projectPath, config] of p.projects.entries()) { - const projectName = projectPath.split("/").pop() ?? projectPath; - for (const ws of config.workspaces) { - const meta = p.workspaceMetadata.get(ws.path); - if (!meta) continue; - const isCurrent = selected?.workspaceId === meta.id; - const isStreaming = p.streamingModels?.has(meta.id) ?? false; - list.push({ - id: `ws:switch:${meta.id}`, - title: `${isCurrent ? "• " : ""}Switch to ${ws.path.split("/").pop() ?? ws.path}`, - subtitle: `${projectName}${isStreaming ? " • streaming" : ""}`, - section: section.workspaces, - keywords: [projectName, ws.path], - run: () => - p.onSelectWorkspace({ - projectPath, - projectName, - workspacePath: ws.path, - workspaceId: meta.id, - }), - }); - } - } - - // Remove current workspace (rename action intentionally omitted until we add a proper modal) - if (selected) { - const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath.split("/").pop() ?? selected.workspacePath}`; - list.push({ - id: "ws:open-terminal-current", - title: "Open Current Workspace in Terminal", - subtitle: workspaceDisplayName, - section: section.workspaces, - shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), - run: () => { - p.onOpenWorkspaceInTerminal(selected.workspacePath); - }, - }); - list.push({ - id: "ws:remove", - title: "Remove Current Workspace…", - subtitle: workspaceDisplayName, - section: section.workspaces, - run: async () => { - const ok = confirm("Remove current workspace? This cannot be undone."); - if (ok) await p.onRemoveWorkspace(selected.workspaceId); - }, - }); - list.push({ - id: "ws:rename", - title: "Rename Current Workspace…", - subtitle: workspaceDisplayName, - section: section.workspaces, - run: () => undefined, - prompt: { - title: "Rename Workspace", - fields: [ - { - type: "text", - name: "newName", - label: "New name", - placeholder: "Enter new workspace name", - initialValue: selected.workspacePath.split("/").pop() ?? "", - getInitialValue: () => selected.workspacePath.split("/").pop() ?? "", - validate: (v) => (!v.trim() ? "Name is required" : null), - }, - ], - onSubmit: async (vals) => { - await p.onRenameWorkspace(selected.workspaceId, vals.newName.trim()); - }, - }, - }); - } - - if (p.workspaceMetadata.size > 0) { - list.push({ - id: "ws:open-terminal", - title: "Open Workspace in Terminal…", - section: section.workspaces, - run: () => undefined, - prompt: { - title: "Open Workspace in Terminal", - fields: [ - { - type: "select", - name: "workspacePath", - label: "Workspace", - placeholder: "Search workspaces…", - getOptions: () => - Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName} / ${workspaceName}`; - return { - id: meta.workspacePath, - label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], - }; - }), - }, - ], - onSubmit: (vals) => { - p.onOpenWorkspaceInTerminal(vals.workspacePath); - }, - }, - }); - list.push({ - id: "ws:rename-any", - title: "Rename Workspace…", - section: section.workspaces, - run: () => undefined, - prompt: { - title: "Rename Workspace", - fields: [ - { - type: "select", - name: "workspaceId", - label: "Select workspace", - placeholder: "Search workspaces…", - getOptions: () => - Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName} / ${workspaceName}`; - return { - id: meta.id, - label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], - }; - }), - }, - { - type: "text", - name: "newName", - label: "New name", - placeholder: "Enter new workspace name", - getInitialValue: (values) => { - const meta = Array.from(p.workspaceMetadata.values()).find( - (m) => m.id === values.workspaceId - ); - return meta ? (meta.workspacePath.split("/").pop() ?? "") : ""; - }, - validate: (v) => (!v.trim() ? "Name is required" : null), - }, - ], - onSubmit: async (vals) => { - await p.onRenameWorkspace(vals.workspaceId, vals.newName.trim()); - }, - }, - }); - list.push({ - id: "ws:remove-any", - title: "Remove Workspace…", - section: section.workspaces, - run: () => undefined, - prompt: { - title: "Remove Workspace", - fields: [ - { - type: "select", - name: "workspaceId", - label: "Select workspace", - placeholder: "Search workspaces…", - getOptions: () => - Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName}/${workspaceName}`; - return { - id: meta.id, - label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], - }; - }), - }, - ], - onSubmit: async (vals) => { - const meta = Array.from(p.workspaceMetadata.values()).find( - (m) => m.id === vals.workspaceId - ); - const workspaceName = meta - ? `${meta.projectName}/${meta.workspacePath.split("/").pop() ?? meta.workspacePath}` - : vals.workspaceId; - const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); - if (ok) { - await p.onRemoveWorkspace(vals.workspaceId); - } - }, - }, - }); - } - - return list; - }); - - // Navigation / Interface - actions.push(() => [ - { - id: "nav:next", - title: "Next Workspace", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.NEXT_WORKSPACE), - run: () => p.onNavigateWorkspace("next"), - }, - { - id: "nav:prev", - title: "Previous Workspace", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.PREV_WORKSPACE), - run: () => p.onNavigateWorkspace("prev"), - }, - { - id: "nav:toggleSidebar", - title: "Toggle Sidebar", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), - run: () => p.onToggleSidebar(), - }, - ]); - - // Chat utilities - actions.push(() => { - const list: CommandAction[] = []; - if (p.selectedWorkspace) { - const id = p.selectedWorkspace.workspaceId; - list.push({ - id: "chat:clear", - title: "Clear History", - section: section.chat, - run: async () => { - await window.api.workspace.truncateHistory(id, 1.0); - }, - }); - for (const pct of [0.75, 0.5, 0.25]) { - list.push({ - id: `chat:truncate:${pct}`, - title: `Truncate History to ${Math.round((1 - pct) * 100)}%`, - section: section.chat, - run: async () => { - await window.api.workspace.truncateHistory(id, pct); - }, - }); - } - list.push({ - id: "chat:interrupt", - title: "Interrupt Streaming", - section: section.chat, - run: async () => { - await window.api.workspace.interruptStream(id); - }, - }); - list.push({ - id: "chat:jumpBottom", - title: "Jump to Bottom", - section: section.chat, - shortcutHint: formatKeybind(KEYBINDS.JUMP_TO_BOTTOM), - run: () => { - // Dispatch the keybind; AIView listens for it - const ev = new KeyboardEvent("keydown", { key: "G", shiftKey: true }); - window.dispatchEvent(ev); - }, - }); - } - return list; - }); - - // Modes & Model - actions.push(() => { - const list: CommandAction[] = [ - { - id: "mode:toggle", - title: "Toggle Plan/Exec Mode", - section: section.mode, - shortcutHint: formatKeybind(KEYBINDS.TOGGLE_MODE), - run: () => { - const ev = new KeyboardEvent("keydown", { key: "M", ctrlKey: true, shiftKey: true }); - window.dispatchEvent(ev); - }, - }, - { - id: "model:change", - title: "Change Model…", - section: section.mode, - shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), - run: () => { - window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); - }, - }, - ]; - - const selectedWorkspace = p.selectedWorkspace; - if (selectedWorkspace) { - const { workspaceId } = selectedWorkspace; - const levelDescriptions: Record = { - off: "Off — fastest responses", - low: "Low — add a bit of reasoning", - medium: "Medium — balanced reasoning", - high: "High — maximum reasoning depth", - }; - const currentLevel = p.getThinkingLevel(workspaceId); - - list.push({ - id: "thinking:set-level", - title: "Set Thinking Effort…", - subtitle: `Current: ${levelDescriptions[currentLevel] ?? currentLevel}`, - section: section.mode, - run: () => undefined, - prompt: { - title: "Select Thinking Effort", - fields: [ - { - type: "select", - name: "thinkingLevel", - label: "Thinking effort", - placeholder: "Choose effort level…", - getOptions: () => - THINKING_LEVELS.map((level) => ({ - id: level, - label: levelDescriptions[level], - keywords: [ - level, - levelDescriptions[level].toLowerCase(), - "thinking", - "reasoning", - ], - })), - }, - ], - onSubmit: (vals) => { - const rawLevel = vals.thinkingLevel; - const level = THINKING_LEVELS.includes(rawLevel as ThinkingLevel) - ? (rawLevel as ThinkingLevel) - : "off"; - p.onSetThinkingLevel(workspaceId, level); - }, - }, - }); - } - - return list; - }); - - // Help / Docs - actions.push(() => [ - { - id: "help:keybinds", - title: "Show Keyboard Shortcuts", - section: section.help, - run: () => { - try { - window.open("https://cmux.io/keybinds.html", "_blank"); - } catch { - /* ignore */ - } - }, - }, - ]); - - // Projects - actions.push(() => { - const branchCache = new Map(); - const getBranchInfoForProject = async (projectPath: string) => { - const cached = branchCache.get(projectPath); - if (cached) return cached; - const info = await p.getBranchesForProject(projectPath); - branchCache.set(projectPath, info); - return info; - }; - - const list: CommandAction[] = [ - { - id: "project:add", - title: "Add Project…", - section: section.projects, - run: () => p.onAddProject(), - }, - { - id: "ws:new-in-project", - title: "Create New Workspace in Project…", - section: section.projects, - run: () => undefined, - prompt: { - title: "New Workspace in Project", - fields: [ - { - type: "select", - name: "projectPath", - label: "Select project", - placeholder: "Search projects…", - getOptions: (_values) => - Array.from(p.projects.keys()).map((projectPath) => ({ - id: projectPath, - label: projectPath.split("/").pop() ?? projectPath, - keywords: [projectPath], - })), - }, - { - type: "text", - name: "branchName", - label: "Workspace branch name", - placeholder: "Enter branch name", - validate: (v) => (!v.trim() ? "Branch name is required" : null), - }, - { - type: "select", - name: "trunkBranch", - label: "Trunk branch", - placeholder: "Search branches…", - getOptions: async (values) => { - if (!values.projectPath) return []; - const info = await getBranchInfoForProject(values.projectPath); - return info.branches.map((branch) => ({ - id: branch, - label: branch, - keywords: [branch], - })); - }, - }, - ], - onSubmit: async (vals) => { - const projectPath = vals.projectPath; - const trimmedBranchName = vals.branchName.trim(); - const info = await getBranchInfoForProject(projectPath); - const providedTrunk = vals.trunkBranch?.trim(); - const resolvedTrunk = - providedTrunk && info.branches.includes(providedTrunk) - ? providedTrunk - : info.branches.includes(info.recommendedTrunk) - ? info.recommendedTrunk - : info.branches[0]; - - if (!resolvedTrunk) { - throw new Error("Unable to determine trunk branch for workspace creation"); - } - - await p.onCreateWorkspace(projectPath, trimmedBranchName, resolvedTrunk); - }, - }, - }, - ]; - - for (const [projectPath] of p.projects.entries()) { - const projectName = projectPath.split("/").pop() ?? projectPath; - list.push({ - id: `project:remove:${projectPath}`, - title: `Remove Project ${projectName}…`, - section: section.projects, - run: () => p.onRemoveProject(projectPath), - }); - } - return list; - }); - - return actions; -} From a05a7ebb610bfdb0e9d6e57cf05f9d2c2cde8b88 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 19:39:33 -0500 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A4=96=20Guard=20AIView=20when=20wo?= =?UTF-8?q?rkspace=20path=20is=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't render AIView if workspacePath is undefined to prevent crashes in features that depend on a valid path (like terminal operations). This ensures we show the welcome screen instead of attempting to render a workspace view with invalid/missing data. --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 6851a9f86..a72436cdd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -679,7 +679,7 @@ function AppInner() { /> - {selectedWorkspace ? ( + {selectedWorkspace && selectedWorkspace.workspacePath ? ( From c5feb4d96d9b54ae0520618b38c54cfb0f5284af Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 19:41:35 -0500 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=A4=96=20Use=20optional=20chaining?= =?UTF-8?q?=20for=20ESLint=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index a72436cdd..9e1e4c52f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -679,7 +679,7 @@ function AppInner() { /> - {selectedWorkspace && selectedWorkspace.workspacePath ? ( + {selectedWorkspace?.workspacePath ? ( From 6af8c4186e81a920b7569ec0314cb45eb81a9489 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:29:58 -0500 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=A4=96=20Add=20comprehensive=20forw?= =?UTF-8?q?ard=20compatibility=20for=20config=20arrays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard all workspaces array access with ?? [] fallback to prevent crashes when config structure differs between branches. Fixed locations: - App.tsx: config.workspaces.slice() and .find() - ProjectSidebar.tsx: config.workspaces.map() - ipcMain.ts: config.workspaces on findIndex, filter, length, push, array assignment - config.ts: for...of loop over projectConfig.workspaces Each location now handles undefined/missing workspaces arrays gracefully, allowing seamless switching between old and new config formats. --- src/App.tsx | 4 +-- src/components/ProjectSidebar.tsx | 52 ++++++++++++++++--------------- src/config.ts | 2 +- src/services/ipcMain.ts | 17 +++++----- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9e1e4c52f..8a602fc0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -239,7 +239,7 @@ function AppInner() { if (metadata) { // Find project for this workspace for (const [projectPath, projectConfig] of projects.entries()) { - const workspace = projectConfig.workspaces.find( + const workspace = (projectConfig.workspaces ?? []).find( (ws) => ws.path === metadata.workspacePath ); if (workspace) { @@ -371,7 +371,7 @@ function AppInner() { for (const [projectPath, config] of projects) { result.set( projectPath, - config.workspaces.slice().sort((a, b) => { + (config.workspaces ?? []).slice().sort((a, b) => { const aMeta = workspaceMetadata.get(a.path); const bMeta = workspaceMetadata.get(b.path); if (!aMeta || !bMeta) return 0; diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index cfc2b1135..6c7adbbf0 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -820,31 +820,33 @@ const ProjectSidebarInner: React.FC = ({ ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - {(sortedWorkspacesByProject.get(projectPath) ?? config.workspaces).map( - (workspace) => { - const metadata = workspaceMetadata.get(workspace.path); - if (!metadata) return null; - - const workspaceId = metadata.id; - const isSelected = - selectedWorkspace?.workspacePath === workspace.path; - - return ( - - ); - } - )} + {( + sortedWorkspacesByProject.get(projectPath) ?? + config.workspaces ?? + [] + ).map((workspace) => { + const metadata = workspaceMetadata.get(workspace.path); + if (!metadata) return null; + + const workspaceId = metadata.id; + const isSelected = + selectedWorkspace?.workspacePath === workspace.path; + + return ( + + ); + })} )} diff --git a/src/config.ts b/src/config.ts index ef733c1b3..87a072130 100644 --- a/src/config.ts +++ b/src/config.ts @@ -182,7 +182,7 @@ export class Config { for (const [projectPath, projectConfig] of config.projects) { const projectName = this.getProjectName(projectPath); - for (const workspace of projectConfig.workspaces) { + for (const workspace of projectConfig.workspaces ?? []) { const workspaceId = this.generateWorkspaceId(projectPath, workspace.path); workspaceMetadata.push({ diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 766bd8428..1f3c17465 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -222,6 +222,7 @@ export class IpcMain { config.projects.set(projectPath, projectConfig); } // Add workspace to project config + if (!projectConfig.workspaces) projectConfig.workspaces = []; projectConfig.workspaces.push({ path: result.path!, }); @@ -297,7 +298,7 @@ export class IpcMain { let workspaceIndex = -1; for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { - const idx = projectConfig.workspaces.findIndex((w) => { + const idx = (projectConfig.workspaces ?? []).findIndex((w) => { const generatedId = this.config.generateWorkspaceId(projectPath, w.path); return generatedId === workspaceId; }); @@ -363,7 +364,7 @@ export class IpcMain { // Update config with new workspace info using atomic edit this.config.editConfig((config) => { const projectConfig = config.projects.get(foundProjectPath); - if (projectConfig && workspaceIndex !== -1) { + if (projectConfig && workspaceIndex !== -1 && projectConfig.workspaces) { projectConfig.workspaces[workspaceIndex] = { path: newWorktreePath, }; @@ -805,9 +806,11 @@ export class IpcMain { const projectsConfig = this.config.loadConfigOrDefault(); let configUpdated = false; for (const [_projectPath, projectConfig] of projectsConfig.projects.entries()) { - const initialCount = projectConfig.workspaces.length; - projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); - if (projectConfig.workspaces.length < initialCount) { + const initialCount = (projectConfig.workspaces ?? []).length; + projectConfig.workspaces = (projectConfig.workspaces ?? []).filter( + (w) => w.path !== workspacePath + ); + if ((projectConfig.workspaces ?? []).length < initialCount) { configUpdated = true; } } @@ -922,9 +925,9 @@ export class IpcMain { } // Check if project has any workspaces - if (projectConfig.workspaces.length > 0) { + if ((projectConfig.workspaces ?? []).length > 0) { return Err( - `Cannot remove project with active workspaces. Please remove all ${projectConfig.workspaces.length} workspace(s) first.` + `Cannot remove project with active workspaces. Please remove all ${(projectConfig.workspaces ?? []).length} workspace(s) first.` ); } From 36a5cc73f89b90529dbb13ccab1ced5e81f3f947 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:38:35 -0500 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=A4=96=20Add=20backward=20compatibi?= =?UTF-8?q?lity=20for=20workspace=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support reading workspace ID from config when present, falling back to generated ID when missing. This allows old code (main branch) to work with new config format (stable-ids branch). Changes: - Workspace interface now has optional id, name, createdAt fields - getAllWorkspaceMetadata() uses stored ID when available - findWorkspace() checks both stored and generated IDs - Workspace rename logic checks both ID formats This fixes the issue where workspaces created on stable-ids branch (with stable IDs like 'f0e1f76700') don't appear when switching to main branch (which expects IDs like 'cmux-f0e1f76700'). --- src/config.ts | 21 +++++++++++++++------ src/services/ipcMain.ts | 7 +++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index 87a072130..a22495591 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,8 +8,11 @@ import type { Secret, SecretsConfig } from "./types/secrets"; export interface Workspace { path: string; // Absolute path to workspace worktree - // NOTE: Workspace ID is NOT stored here - it's generated on-demand from path - // using generateWorkspaceId(). This ensures single source of truth for ID format. + id?: string; // Optional: Stable ID from newer config format (for forward compat) + name?: string; // Optional: Friendly name from newer config format (for forward compat) + createdAt?: string; // Optional: Creation timestamp from newer config format + // NOTE: If id is not present, it's generated on-demand from path + // using generateWorkspaceId(). This ensures compatibility with both old and new formats. } export interface ProjectConfig { @@ -136,9 +139,13 @@ export class Config { const config = this.loadConfigOrDefault(); for (const [projectPath, project] of config.projects) { - for (const workspace of project.workspaces) { - const generatedId = this.generateWorkspaceId(projectPath, workspace.path); - if (generatedId === workspaceId) { + for (const workspace of project.workspaces ?? []) { + // Check stored ID first (new format), then generated ID (old format) + const matchesStoredId = workspace.id === workspaceId; + const matchesGeneratedId = + this.generateWorkspaceId(projectPath, workspace.path) === workspaceId; + + if (matchesStoredId || matchesGeneratedId) { return { workspacePath: workspace.path, projectPath }; } } @@ -183,7 +190,9 @@ export class Config { const projectName = this.getProjectName(projectPath); for (const workspace of projectConfig.workspaces ?? []) { - const workspaceId = this.generateWorkspaceId(projectPath, workspace.path); + // Use stored ID if available (new format), otherwise generate (old format) + const workspaceId = + workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); workspaceMetadata.push({ id: workspaceId, diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 1f3c17465..d07fed29c 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -299,8 +299,11 @@ export class IpcMain { for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { const idx = (projectConfig.workspaces ?? []).findIndex((w) => { - const generatedId = this.config.generateWorkspaceId(projectPath, w.path); - return generatedId === workspaceId; + // Check stored ID first (new format), then generated ID (old format) + const matchesStoredId = w.id === workspaceId; + const matchesGeneratedId = + this.config.generateWorkspaceId(projectPath, w.path) === workspaceId; + return matchesStoredId || matchesGeneratedId; }); if (idx !== -1) { From a06dbdc472825356b0c7c8027ab376e11e61d488 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:41:27 -0500 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=A4=96=20Fall=20back=20to=20config?= =?UTF-8?q?=20when=20metadata.json=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When metadata.json doesn't exist (workspaces created on newer branches), reconstruct metadata from config and save it for future use. This fixes resumeStream errors when switching from stable-ids branch (which stores metadata in config) to main branch (which expects metadata.json files). The fallback: 1. Looks up workspace in config via getAllWorkspaceMetadata() 2. Saves the metadata to metadata.json for future access 3. Returns the metadata so the operation can proceed This allows seamless switching between branches with different metadata storage strategies. --- src/services/aiService.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 6cc87f94c..44857bbde 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -178,8 +178,18 @@ export class AIService extends EventEmitter { return Ok(validated); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - // If metadata doesn't exist, we cannot create valid defaults without the workspace path - // The workspace path must be provided when the workspace is created + // Fallback: Try to reconstruct metadata from config (for forward compatibility) + // This handles workspaces created on newer branches that don't have metadata.json + const allMetadata = this.config.getAllWorkspaceMetadata(); + const metadataFromConfig = allMetadata.find((m) => m.id === workspaceId); + + if (metadataFromConfig) { + // Found in config - save it to metadata.json for future use + await this.saveWorkspaceMetadata(workspaceId, metadataFromConfig); + return Ok(metadataFromConfig); + } + + // If metadata doesn't exist anywhere, workspace is not properly initialized return Err( `Workspace metadata not found for ${workspaceId}. Workspace may not be properly initialized.` ); From 316e418a7510e50abc3dc7a3de645b69e29603b4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:47:27 -0500 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index a22495591..3881353dc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -191,8 +191,7 @@ export class Config { for (const workspace of projectConfig.workspaces ?? []) { // Use stored ID if available (new format), otherwise generate (old format) - const workspaceId = - workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); + const workspaceId = workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); workspaceMetadata.push({ id: workspaceId, From a1da702d4e5bc93373a8e3cbc65f751af0c81f7f Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:48:24 -0500 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=A4=96=20Fix=20workspace=20ID=20mat?= =?UTF-8?q?ching=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't check both stored and generated IDs simultaneously - this causes renamed workspaces to still match their old IDs. Instead, use stored ID if present, otherwise fall back to generated ID. This ensures proper separation between old and new workspace identities. Fixes renameWorkspace integration test failure where old workspace ID was still resolving after rename. --- src/config.ts | 10 +++++----- src/services/ipcMain.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3881353dc..d120868f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -140,12 +140,12 @@ export class Config { for (const [projectPath, project] of config.projects) { for (const workspace of project.workspaces ?? []) { - // Check stored ID first (new format), then generated ID (old format) - const matchesStoredId = workspace.id === workspaceId; - const matchesGeneratedId = - this.generateWorkspaceId(projectPath, workspace.path) === workspaceId; + // If workspace has stored ID, use it (new format) + // Otherwise, generate ID from path (old format) + const workspaceIdToMatch = + workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); - if (matchesStoredId || matchesGeneratedId) { + if (workspaceIdToMatch === workspaceId) { return { workspacePath: workspace.path, projectPath }; } } diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index d07fed29c..ce2115f5d 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -299,11 +299,11 @@ export class IpcMain { for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { const idx = (projectConfig.workspaces ?? []).findIndex((w) => { - // Check stored ID first (new format), then generated ID (old format) - const matchesStoredId = w.id === workspaceId; - const matchesGeneratedId = - this.config.generateWorkspaceId(projectPath, w.path) === workspaceId; - return matchesStoredId || matchesGeneratedId; + // If workspace has stored ID, use it (new format) + // Otherwise, generate ID from path (old format) + const workspaceIdToMatch = + w.id ?? this.config.generateWorkspaceId(projectPath, w.path); + return workspaceIdToMatch === workspaceId; }); if (idx !== -1) { From df9c1c809d8998c8433411c4f7f601fe8fdfc8e6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 15 Oct 2025 20:58:18 -0500 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=A4=96=20Fix=20integration=20test?= =?UTF-8?q?=20duplicate=20workspace=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renameWorkspace tests were manually adding workspaces to config after setupWorkspace had already created them via WORKSPACE_CREATE IPC. This created duplicates in config, causing renamed workspaces to still be found by their old IDs. Fixed by removing the redundant manual config manipulation - WORKSPACE_CREATE already handles both project creation and workspace registration. This was causing getAllWorkspaceMetadata() to return duplicate IDs, which made the fallback logic incorrectly find renamed workspaces by their old IDs. --- tests/ipcMain/renameWorkspace.test.ts | 44 +++++---------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 12cd800d1..238e848b8 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -25,15 +25,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Add project and workspace to config via IPC - await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); - // Manually add workspace to the project (normally done by WORKSPACE_CREATE) - const projectsConfig = env.config.loadConfigOrDefault(); - const projectConfig = projectsConfig.projects.get(tempGitRepo); - if (projectConfig) { - projectConfig.workspaces.push({ path: workspacePath }); - env.config.saveConfig(projectsConfig); - } + // Note: setupWorkspace already called WORKSPACE_CREATE which adds both + // the project and workspace to config, so no manual config manipulation needed const oldSessionDir = env.config.getSessionDir(workspaceId); const oldMetadataResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, @@ -174,15 +167,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Add project and workspace to config via IPC - await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); - // Manually add workspace to the project (normally done by WORKSPACE_CREATE) - const projectsConfig = env.config.loadConfigOrDefault(); - const projectConfig = projectsConfig.projects.get(tempGitRepo); - if (projectConfig) { - projectConfig.workspaces.push({ path: workspacePath }); - env.config.saveConfig(projectsConfig); - } + // Note: setupWorkspace already called WORKSPACE_CREATE which adds both + // the project and workspace to config, so no manual config manipulation needed // Get current metadata const oldMetadata = await env.mockIpcRenderer.invoke( @@ -317,15 +303,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Add project and workspace to config via IPC - await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); - // Manually add workspace to the project (normally done by WORKSPACE_CREATE) - const projectsConfig = env.config.loadConfigOrDefault(); - const projectConfig = projectsConfig.projects.get(tempGitRepo); - if (projectConfig) { - projectConfig.workspaces.push({ path: workspacePath }); - env.config.saveConfig(projectsConfig); - } + // Note: setupWorkspace already called WORKSPACE_CREATE which adds both + // the project and workspace to config, so no manual config manipulation needed // Send a message to create some history env.sentEvents.length = 0; const result = await sendMessageWithModel(env.mockIpcRenderer, workspaceId, "What is 2+2?"); @@ -376,15 +355,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, cleanup } = await setupWorkspace("anthropic"); try { - // Add project and workspace to config via IPC - await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); - // Manually add workspace to the project (normally done by WORKSPACE_CREATE) - const projectsConfig = env.config.loadConfigOrDefault(); - const projectConfig = projectsConfig.projects.get(tempGitRepo); - if (projectConfig) { - projectConfig.workspaces.push({ path: workspacePath }); - env.config.saveConfig(projectsConfig); - } + // Note: setupWorkspace already called WORKSPACE_CREATE which adds both + // the project and workspace to config, so no manual config manipulation needed // Send a message to create history before rename env.sentEvents.length = 0;