Skip to content

Commit e3a4ecd

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 112ba9c commit e3a4ecd

File tree

4 files changed

+226
-45
lines changed

4 files changed

+226
-45
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

@@ -600,12 +578,19 @@ function AppInner() {
600578
new Map(prev).set(metadata.id, metadata)
601579
);
602580

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

611596
// 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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import {
33
partitionWorkspacesByAge,
44
formatDaysThreshold,
55
AGE_THRESHOLDS_DAYS,
6+
buildSortedWorkspacesByProject,
67
} from "./workspaceFiltering";
78
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
9+
import type { ProjectConfig } from "@/common/types/project";
810
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
911

1012
describe("partitionWorkspacesByAge", () => {
@@ -173,3 +175,145 @@ describe("formatDaysThreshold", () => {
173175
expect(formatDaysThreshold(30)).toBe("30 days");
174176
});
175177
});
178+
179+
describe("buildSortedWorkspacesByProject", () => {
180+
const createWorkspace = (
181+
id: string,
182+
projectPath: string,
183+
status?: "creating"
184+
): FrontendWorkspaceMetadata => ({
185+
id,
186+
name: `workspace-${id}`,
187+
projectName: projectPath.split("/").pop() ?? "unknown",
188+
projectPath,
189+
namedWorkspacePath: `${projectPath}/workspace-${id}`,
190+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
191+
status,
192+
});
193+
194+
it("should include workspaces from persisted config", () => {
195+
const projects = new Map<string, ProjectConfig>([
196+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
197+
]);
198+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
199+
["ws1", createWorkspace("ws1", "/project/a")],
200+
]);
201+
202+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
203+
204+
expect(result.get("/project/a")).toHaveLength(1);
205+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
206+
});
207+
208+
it("should include pending workspaces not yet in config", () => {
209+
const projects = new Map<string, ProjectConfig>([
210+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
211+
]);
212+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
213+
["ws1", createWorkspace("ws1", "/project/a")],
214+
["pending1", createWorkspace("pending1", "/project/a", "creating")],
215+
]);
216+
217+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
218+
219+
expect(result.get("/project/a")).toHaveLength(2);
220+
expect(result.get("/project/a")?.map((w) => w.id)).toContain("ws1");
221+
expect(result.get("/project/a")?.map((w) => w.id)).toContain("pending1");
222+
});
223+
224+
it("should handle multiple concurrent pending workspaces", () => {
225+
const projects = new Map<string, ProjectConfig>([["/project/a", { workspaces: [] }]]);
226+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
227+
["pending1", createWorkspace("pending1", "/project/a", "creating")],
228+
["pending2", createWorkspace("pending2", "/project/a", "creating")],
229+
["pending3", createWorkspace("pending3", "/project/a", "creating")],
230+
]);
231+
232+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
233+
234+
expect(result.get("/project/a")).toHaveLength(3);
235+
});
236+
237+
it("should add pending workspaces for projects not yet in config", () => {
238+
const projects = new Map<string, ProjectConfig>();
239+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
240+
["pending1", createWorkspace("pending1", "/new/project", "creating")],
241+
]);
242+
243+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
244+
245+
expect(result.get("/new/project")).toHaveLength(1);
246+
expect(result.get("/new/project")?.[0].id).toBe("pending1");
247+
});
248+
249+
it("should sort workspaces by recency (most recent first)", () => {
250+
const now = Date.now();
251+
const projects = new Map<string, ProjectConfig>([
252+
[
253+
"/project/a",
254+
{
255+
workspaces: [
256+
{ path: "/a/ws1", id: "ws1" },
257+
{ path: "/a/ws2", id: "ws2" },
258+
{ path: "/a/ws3", id: "ws3" },
259+
],
260+
},
261+
],
262+
]);
263+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
264+
["ws1", createWorkspace("ws1", "/project/a")],
265+
["ws2", createWorkspace("ws2", "/project/a")],
266+
["ws3", createWorkspace("ws3", "/project/a")],
267+
]);
268+
const recency = {
269+
ws1: now - 3000, // oldest
270+
ws2: now - 1000, // newest
271+
ws3: now - 2000, // middle
272+
};
273+
274+
const result = buildSortedWorkspacesByProject(projects, metadata, recency);
275+
276+
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]);
277+
});
278+
279+
it("should not duplicate workspaces that exist in both config and have creating status", () => {
280+
// Edge case: workspace was saved to config but still has status: "creating"
281+
// (this shouldn't happen in practice but tests defensive coding)
282+
const projects = new Map<string, ProjectConfig>([
283+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
284+
]);
285+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
286+
["ws1", createWorkspace("ws1", "/project/a", "creating")],
287+
]);
288+
289+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
290+
291+
expect(result.get("/project/a")).toHaveLength(1);
292+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
293+
});
294+
295+
it("should skip workspaces with no id in config", () => {
296+
const projects = new Map<string, ProjectConfig>([
297+
["/project/a", { workspaces: [{ path: "/a/legacy" }, { path: "/a/ws1", id: "ws1" }] }],
298+
]);
299+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
300+
["ws1", createWorkspace("ws1", "/project/a")],
301+
]);
302+
303+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
304+
305+
expect(result.get("/project/a")).toHaveLength(1);
306+
expect(result.get("/project/a")?.[0].id).toBe("ws1");
307+
});
308+
309+
it("should skip config workspaces with no matching metadata", () => {
310+
const projects = new Map<string, ProjectConfig>([
311+
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
312+
]);
313+
const metadata = new Map<string, FrontendWorkspaceMetadata>(); // empty
314+
315+
const result = buildSortedWorkspacesByProject(projects, metadata, {});
316+
317+
expect(result.get("/project/a")).toHaveLength(0);
318+
});
319+
});

src/browser/utils/ui/workspaceFiltering.ts

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

34
/**
45
* Age thresholds for workspace filtering, in ascending order.
@@ -9,6 +10,57 @@ export type AgeThresholdDays = (typeof AGE_THRESHOLDS_DAYS)[number];
910

1011
const DAY_MS = 24 * 60 * 60 * 1000;
1112

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

0 commit comments

Comments
 (0)