Skip to content

Commit 379f584

Browse files
committed
fix: include pending workspaces in sidebar workspace list
Extract buildSortedWorkspacesByProject utility to handle both persisted and pending (status: creating) workspaces. Pending workspaces are now displayed in the sidebar immediately when workspace creation starts, rather than waiting for the config to be saved. This fixes concurrent workspace creation where multiple workspaces were being created simultaneously but only appeared after completion.
1 parent 2881d5e commit 379f584

File tree

4 files changed

+230
-46
lines changed

4 files changed

+230
-46
lines changed

src/browser/App.tsx

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import "./styles/globals.css";
33
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
44
import { useProjectContext } from "./contexts/ProjectContext";
55
import type { WorkspaceSelection } from "./components/ProjectSidebar";
6-
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
76
import { LeftSidebar } from "./components/LeftSidebar";
87
import { ProjectCreateModal } from "./components/ProjectCreateModal";
98
import { AIView } from "./components/AIView";
109
import { ErrorBoundary } from "./components/ErrorBoundary";
1110
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
1211
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
12+
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
1313
import { useResumeManager } from "./hooks/useResumeManager";
1414
import { useUnreadTracking } from "./hooks/useUnreadTracking";
1515
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
@@ -198,46 +198,24 @@ function AppInner() {
198198
// NEW: Get workspace recency from store
199199
const workspaceRecency = useWorkspaceRecency();
200200

201-
// Sort workspaces by recency (most recent first)
202-
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
201+
// Build sorted workspaces map including pending workspaces
203202
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
204203
const sortedWorkspacesByProject = useStableReference(
205-
() => {
206-
const result = new Map<string, FrontendWorkspaceMetadata[]>();
207-
for (const [projectPath, config] of projects) {
208-
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
209-
const metadataList = config.workspaces
210-
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
211-
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);
212-
213-
// Sort by recency
214-
metadataList.sort((a, b) => {
215-
const aTimestamp = workspaceRecency[a.id] ?? 0;
216-
const bTimestamp = workspaceRecency[b.id] ?? 0;
217-
return bTimestamp - aTimestamp;
204+
() => buildSortedWorkspacesByProject(projects, workspaceMetadata, workspaceRecency),
205+
(prev, next) =>
206+
compareMaps(prev, next, (a, b) => {
207+
if (a.length !== b.length) return false;
208+
// Check ID, name, and status to detect changes
209+
return a.every((meta, i) => {
210+
const other = b[i];
211+
return (
212+
other &&
213+
meta.id === other.id &&
214+
meta.name === other.name &&
215+
meta.status === other.status
216+
);
218217
});
219-
220-
result.set(projectPath, metadataList);
221-
}
222-
return result;
223-
},
224-
(prev, next) => {
225-
// Compare Maps: check if size, workspace order, and metadata content are the same
226-
if (
227-
!compareMaps(prev, next, (a, b) => {
228-
if (a.length !== b.length) return false;
229-
// Check both ID and name to detect renames
230-
return a.every((metadata, i) => {
231-
const bMeta = b[i];
232-
if (!bMeta || !metadata) return false; // Null-safe
233-
return metadata.id === bMeta.id && metadata.name === bMeta.name;
234-
});
235-
})
236-
) {
237-
return false;
238-
}
239-
return true;
240-
},
218+
}),
241219
[projects, workspaceMetadata, workspaceRecency]
242220
);
243221

@@ -597,12 +575,19 @@ function AppInner() {
597575
new Map(prev).set(metadata.id, metadata)
598576
);
599577

600-
// Switch to new workspace
601-
setSelectedWorkspace({
602-
workspaceId: metadata.id,
603-
projectPath: metadata.projectPath,
604-
projectName: metadata.projectName,
605-
namedWorkspacePath: metadata.namedWorkspacePath,
578+
// Only switch to new workspace if user hasn't selected another one
579+
// during the creation process (selectedWorkspace was null when creation started)
580+
setSelectedWorkspace((current) => {
581+
if (current !== null) {
582+
// User has already selected another workspace - don't override
583+
return current;
584+
}
585+
return {
586+
workspaceId: metadata.id,
587+
projectPath: metadata.projectPath,
588+
projectName: metadata.projectName,
589+
namedWorkspacePath: metadata.namedWorkspacePath,
590+
};
606591
});
607592

608593
// Track telemetry

src/browser/contexts/WorkspaceContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface WorkspaceContext {
6262

6363
// Selection
6464
selectedWorkspace: WorkspaceSelection | null;
65-
setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void;
65+
setSelectedWorkspace: React.Dispatch<React.SetStateAction<WorkspaceSelection | null>>;
6666

6767
// Workspace creation flow
6868
pendingNewWorkspaceProject: string | null;

src/browser/utils/ui/workspaceFiltering.test.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect } from "@jest/globals";
2-
import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering";
2+
import {
3+
partitionWorkspacesByAge,
4+
formatOldWorkspaceThreshold,
5+
buildSortedWorkspacesByProject,
6+
} from "./workspaceFiltering";
37
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
8+
import type { ProjectConfig } from "@/common/types/project";
49
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
510

611
describe("partitionWorkspacesByAge", () => {
@@ -126,3 +131,145 @@ describe("formatOldWorkspaceThreshold", () => {
126131
expect(result).toBe("1 day");
127132
});
128133
});
134+
135+
describe("buildSortedWorkspacesByProject", () => {
136+
const createWorkspace = (
137+
id: string,
138+
projectPath: string,
139+
status?: "creating"
140+
): FrontendWorkspaceMetadata => ({
141+
id,
142+
name: `workspace-${id}`,
143+
projectName: projectPath.split("/").pop() ?? "unknown",
144+
projectPath,
145+
namedWorkspacePath: `${projectPath}/workspace-${id}`,
146+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
147+
status,
148+
});
149+
150+
it("should include workspaces from persisted config", () => {
151+
const projects = new Map<string, ProjectConfig>([
152+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
153+
]);
154+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
155+
["ws1", createWorkspace("ws1", "/project/a")],
156+
]);
157+
158+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
159+
160+
expect(result.get("/project/a")).toHaveLength(1);
161+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
162+
});
163+
164+
it("should include pending workspaces not yet in config", () => {
165+
const projects = new Map<string, ProjectConfig>([
166+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
167+
]);
168+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
169+
["ws1", createWorkspace("ws1", "/project/a")],
170+
["pending1", createWorkspace("pending1", "/project/a", "creating")],
171+
]);
172+
173+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
174+
175+
expect(result.get("/project/a")).toHaveLength(2);
176+
expect(result.get("/project/a")?.map((w) => w.id)).toContain("ws1");
177+
expect(result.get("/project/a")?.map((w) => w.id)).toContain("pending1");
178+
});
179+
180+
it("should handle multiple concurrent pending workspaces", () => {
181+
const projects = new Map<string, ProjectConfig>([["/project/a", { workspaces: [] }]]);
182+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
183+
["pending1", createWorkspace("pending1", "/project/a", "creating")],
184+
["pending2", createWorkspace("pending2", "/project/a", "creating")],
185+
["pending3", createWorkspace("pending3", "/project/a", "creating")],
186+
]);
187+
188+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
189+
190+
expect(result.get("/project/a")).toHaveLength(3);
191+
});
192+
193+
it("should add pending workspaces for projects not yet in config", () => {
194+
const projects = new Map<string, ProjectConfig>();
195+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
196+
["pending1", createWorkspace("pending1", "/new/project", "creating")],
197+
]);
198+
199+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
200+
201+
expect(result.get("/new/project")).toHaveLength(1);
202+
expect(result.get("/new/project")?.[0].id).toBe("pending1");
203+
});
204+
205+
it("should sort workspaces by recency (most recent first)", () => {
206+
const now = Date.now();
207+
const projects = new Map<string, ProjectConfig>([
208+
[
209+
"/project/a",
210+
{
211+
workspaces: [
212+
{ path: "/a/ws1", id: "ws1" },
213+
{ path: "/a/ws2", id: "ws2" },
214+
{ path: "/a/ws3", id: "ws3" },
215+
],
216+
},
217+
],
218+
]);
219+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
220+
["ws1", createWorkspace("ws1", "/project/a")],
221+
["ws2", createWorkspace("ws2", "/project/a")],
222+
["ws3", createWorkspace("ws3", "/project/a")],
223+
]);
224+
const recency = {
225+
ws1: now - 3000, // oldest
226+
ws2: now - 1000, // newest
227+
ws3: now - 2000, // middle
228+
};
229+
230+
const result = buildSortedWorkspacesByProject(projects, metadata, recency);
231+
232+
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]);
233+
});
234+
235+
it("should not duplicate workspaces that exist in both config and have creating status", () => {
236+
// Edge case: workspace was saved to config but still has status: "creating"
237+
// (this shouldn't happen in practice but tests defensive coding)
238+
const projects = new Map<string, ProjectConfig>([
239+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
240+
]);
241+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
242+
["ws1", createWorkspace("ws1", "/project/a", "creating")],
243+
]);
244+
245+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
246+
247+
expect(result.get("/project/a")).toHaveLength(1);
248+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
249+
});
250+
251+
it("should skip workspaces with no id in config", () => {
252+
const projects = new Map<string, ProjectConfig>([
253+
["/project/a", { workspaces: [{ path: "/a/legacy" }, { path: "/a/ws1", id: "ws1" }] }],
254+
]);
255+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
256+
["ws1", createWorkspace("ws1", "/project/a")],
257+
]);
258+
259+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
260+
261+
expect(result.get("/project/a")).toHaveLength(1);
262+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
263+
});
264+
265+
it("should skip config workspaces with no matching metadata", () => {
266+
const projects = new Map<string, ProjectConfig>([
267+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
268+
]);
269+
const metadata = new Map<string, FrontendWorkspaceMetadata>(); // empty
270+
271+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
272+
273+
expect(result.get("/project/a")).toHaveLength(0);
274+
});
275+
});

src/browser/utils/ui/workspaceFiltering.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,62 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
2+
import type { ProjectConfig } from "@/common/types/project";
23

34
/**
45
* Time threshold for considering a workspace "old" (24 hours in milliseconds)
56
*/
67
const OLD_WORKSPACE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
78

9+
/**
10+
* Build a map of project paths to sorted workspace metadata lists.
11+
* Includes both persisted workspaces (from config) and pending workspaces
12+
* (status: "creating") that haven't been saved yet.
13+
*
14+
* Workspaces are sorted by recency (most recent first).
15+
*/
16+
export function buildSortedWorkspacesByProject(
17+
projects: Map<string, ProjectConfig>,
18+
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>,
19+
workspaceRecency: Record<string, number>
20+
): Map<string, FrontendWorkspaceMetadata[]> {
21+
const result = new Map<string, FrontendWorkspaceMetadata[]>();
22+
const includedIds = new Set<string>();
23+
24+
// First pass: include workspaces from persisted config
25+
for (const [projectPath, config] of projects) {
26+
const metadataList: FrontendWorkspaceMetadata[] = [];
27+
for (const ws of config.workspaces) {
28+
if (!ws.id) continue;
29+
const meta = workspaceMetadata.get(ws.id);
30+
if (meta) {
31+
metadataList.push(meta);
32+
includedIds.add(ws.id);
33+
}
34+
}
35+
result.set(projectPath, metadataList);
36+
}
37+
38+
// Second pass: add pending workspaces (status: "creating") not yet in config
39+
for (const [id, metadata] of workspaceMetadata) {
40+
if (metadata.status === "creating" && !includedIds.has(id)) {
41+
const projectWorkspaces = result.get(metadata.projectPath) ?? [];
42+
projectWorkspaces.push(metadata);
43+
result.set(metadata.projectPath, projectWorkspaces);
44+
}
45+
}
46+
47+
// Sort each project's workspaces by recency
48+
for (const [projectPath, metadataList] of result) {
49+
metadataList.sort((a, b) => {
50+
const aTimestamp = workspaceRecency[a.id] ?? 0;
51+
const bTimestamp = workspaceRecency[b.id] ?? 0;
52+
return bTimestamp - aTimestamp;
53+
});
54+
result.set(projectPath, metadataList);
55+
}
56+
57+
return result;
58+
}
59+
860
/**
961
* Format the old workspace threshold for display.
1062
* Returns a human-readable string like "1 day", "2 hours", etc.

0 commit comments

Comments
 (0)