diff --git a/src/App.tsx b/src/App.tsx index 840edeb7e..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; @@ -679,15 +679,19 @@ function AppInner() { /> - {selectedWorkspace ? ( + {selectedWorkspace?.workspacePath ? ( 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..d120868f9 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 ?? []) { + // 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 (workspaceIdToMatch === workspaceId) { return { workspacePath: workspace.path, projectPath }; } } @@ -182,8 +189,9 @@ export class Config { for (const [projectPath, projectConfig] of config.projects) { const projectName = this.getProjectName(projectPath); - for (const workspace of projectConfig.workspaces) { - const workspaceId = this.generateWorkspaceId(projectPath, workspace.path); + 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); workspaceMetadata.push({ id: workspaceId, 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.` ); diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 766bd8428..ce2115f5d 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,9 +298,12 @@ export class IpcMain { let workspaceIndex = -1; 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; + const idx = (projectConfig.workspaces ?? []).findIndex((w) => { + // 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) { @@ -363,7 +367,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 +809,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 +928,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.` ); } 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) { 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;