Skip to content

Commit e0d598f

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 fa2df24 commit e0d598f

File tree

3 files changed

+216
-39
lines changed

3 files changed

+216
-39
lines changed

src/browser/App.tsx

Lines changed: 16 additions & 38 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";
@@ -193,46 +193,24 @@ function AppInner() {
193193
// NEW: Get workspace recency from store
194194
const workspaceRecency = useWorkspaceRecency();
195195

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

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)