Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -679,15 +679,19 @@ function AppInner() {
/>
<MainContent>
<ContentArea>
{selectedWorkspace ? (
{selectedWorkspace?.workspacePath ? (
<ErrorBoundary
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId ?? "unknown"}`}
>
<AIView
key={selectedWorkspace.workspaceId}
workspaceId={selectedWorkspace.workspaceId}
projectName={selectedWorkspace.projectName}
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
branch={
selectedWorkspace.workspacePath?.split("/").pop() ??
selectedWorkspace.workspaceId ??
""
}
workspacePath={selectedWorkspace.workspacePath}
/>
</ErrorBoundary>
Expand Down
52 changes: 27 additions & 25 deletions src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -820,31 +820,33 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
</AddWorkspaceBtn>
</WorkspaceHeader>
{(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 (
<WorkspaceListItem
key={workspace.path}
workspaceId={workspaceId}
workspacePath={workspace.path}
projectPath={projectPath}
projectName={projectName}
isSelected={isSelected}
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
onSelectWorkspace={onSelectWorkspace}
onRemoveWorkspace={handleRemoveWorkspace}
onToggleUnread={_onToggleUnread}
/>
);
}
)}
{(
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 (
<WorkspaceListItem
key={workspace.path}
workspaceId={workspaceId}
workspacePath={workspace.path}
projectPath={projectPath}
projectName={projectName}
isSelected={isSelected}
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
onSelectWorkspace={onSelectWorkspace}
onRemoveWorkspace={handleRemoveWorkspace}
onToggleUnread={_onToggleUnread}
/>
);
})}
</WorkspacesContainer>
)}
</ProjectGroup>
Expand Down
22 changes: 15 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}
}
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
Expand Down
24 changes: 15 additions & 9 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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.`
);
}

Expand Down
16 changes: 8 additions & 8 deletions src/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
},
],
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
},
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
44 changes: 8 additions & 36 deletions tests/ipcMain/renameWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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?");
Expand Down Expand Up @@ -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;
Expand Down