From 3618849b82538fc2d825a743968ba1ddd019dc38 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 24 Nov 2025 19:58:52 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20show=20pending=20wor?= =?UTF-8?q?kspace=20states=20in=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add status?: 'creating' field to WorkspaceMetadata for pending workspaces - Backend emits pending metadata immediately before slow AI title generation - generatePlaceholderName() creates git-safe placeholder from user's message - Frontend shows shimmer on workspace name during creation - Disable selection/remove actions while workspace is being created - Clear pending state on error by emitting null metadata This provides immediate visual feedback when creating workspaces instead of the UI appearing frozen during title generation (2-5s). --- src/browser/components/WorkspaceListItem.tsx | 107 +++++++++++-------- src/common/types/workspace.ts | 7 ++ src/node/services/agentSession.ts | 9 +- src/node/services/ipcMain.ts | 63 +++++++---- src/node/services/workspaceTitleGenerator.ts | 16 +++ 5 files changed, 135 insertions(+), 67 deletions(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index e3c2b32e17..23c3a2444b 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -44,7 +44,8 @@ const WorkspaceListItemInner: React.FC = ({ onToggleUnread: _onToggleUnread, }) => { // Destructure metadata for convenience - const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; + const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata; + const isCreating = status === "creating"; const gitStatus = useGitStatus(workspaceId); // Get rename context @@ -100,19 +101,24 @@ const WorkspaceListItemInner: React.FC = ({
+ onClick={() => { + if (isCreating) return; // Disable click while creating onSelectWorkspace({ projectPath, projectName, namedWorkspacePath, workspaceId, - }) - } + }); + }} onKeyDown={(e) => { + if (isCreating) return; // Disable keyboard while creating if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectWorkspace({ @@ -124,9 +130,12 @@ const WorkspaceListItemInner: React.FC = ({ } }} role="button" - tabIndex={0} + tabIndex={isCreating ? -1 : 0} aria-current={isSelected ? "true" : undefined} - aria-label={`Select workspace ${displayName}`} + aria-label={ + isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}` + } + aria-disabled={isCreating} data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > @@ -147,14 +156,18 @@ const WorkspaceListItemInner: React.FC = ({ /> ) : ( { + if (isCreating) return; // Disable rename while creating e.stopPropagation(); startRenaming(); }} - title="Double-click to rename" + title={isCreating ? "Creating workspace..." : "Double-click to rename"} > - {canInterrupt ? ( + {canInterrupt || isCreating ? ( {displayName} @@ -165,41 +178,47 @@ const WorkspaceListItemInner: React.FC = ({ )}
- + {!isCreating && ( + <> + - - - - Remove workspace - - + + + + Remove workspace + + + + )}
-
- {isDeleting ? ( -
- 🗑️ - Deleting... -
- ) : ( - - )} -
+ {!isCreating && ( +
+ {isDeleting ? ( +
+ 🗑️ + Deleting... +
+ ) : ( + + )} +
+ )} {renameError && isEditing && ( diff --git a/src/common/types/workspace.ts b/src/common/types/workspace.ts index ca32f15051..278c24e5ad 100644 --- a/src/common/types/workspace.ts +++ b/src/common/types/workspace.ts @@ -54,6 +54,13 @@ export interface WorkspaceMetadata { /** Runtime configuration for this workspace (always set, defaults to local on load) */ runtimeConfig: RuntimeConfig; + + /** + * Workspace creation status. When 'creating', the workspace is being set up + * (title generation, git operations). Undefined or absent means ready. + * Pending workspaces are ephemeral (not persisted to config). + */ + status?: "creating"; } /** diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8a50897751..f412799ff7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -8,7 +8,7 @@ import type { AIService } from "@/node/services/aiService"; import type { HistoryService } from "@/node/services/historyService"; import type { PartialService } from "@/node/services/partialService"; import type { InitStateManager } from "@/node/services/initStateManager"; -import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import type { WorkspaceChatMessage, @@ -33,7 +33,7 @@ export interface AgentSessionChatEvent { export interface AgentSessionMetadataEvent { workspaceId: string; - metadata: WorkspaceMetadata | null; + metadata: FrontendWorkspaceMetadata | null; } interface AgentSessionOptions { @@ -139,7 +139,7 @@ export class AgentSession { await this.emitHistoricalEvents(listener); } - emitMetadata(metadata: WorkspaceMetadata | null): void { + emitMetadata(metadata: FrontendWorkspaceMetadata | null): void { this.assertNotDisposed("emitMetadata"); this.emitter.emit("metadata-event", { workspaceId: this.workspaceId, @@ -244,11 +244,12 @@ export class AgentSession { : PlatformPaths.basename(normalizedWorkspacePath) || "unknown"; } - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: this.workspaceId, name: workspaceName, projectName: derivedProjectName, projectPath: derivedProjectPath, + namedWorkspacePath: normalizedWorkspacePath, runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 955c0eb278..6129544767 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -26,7 +26,6 @@ import type { import { Ok, Err, type Result } from "@/common/types/result"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import type { - WorkspaceMetadata, FrontendWorkspaceMetadata, WorkspaceActivitySnapshot, } from "@/common/types/workspace"; @@ -107,7 +106,7 @@ async function createWorkspaceWithCollisionRetry( throw new Error("Unexpected: workspace creation loop completed without return"); } -import { generateWorkspaceName } from "./workspaceTitleGenerator"; +import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator"; /** * IpcMain - Manages all IPC handlers and service coordination * @@ -305,8 +304,35 @@ export class IpcMain { | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } | Result > { + // Generate IDs and placeholder upfront for immediate UI feedback + const workspaceId = this.config.generateStableId(); + const placeholderName = generatePlaceholderName(message); + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + const createdAt = new Date().toISOString(); + + // Prepare runtime config early for pending metadata + // Default to worktree runtime for new workspaces + let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { + type: "worktree", + srcBaseDir: this.config.srcDir, + }; + + // Create session and emit pending metadata IMMEDIATELY + // This allows the sidebar to show the workspace while we do slow operations + const session = this.getOrCreateSession(workspaceId); + session.emitMetadata({ + id: workspaceId, + name: placeholderName, + projectName, + projectPath, + namedWorkspacePath: "", // Not yet created + createdAt, + runtimeConfig: finalRuntimeConfig, + status: "creating", + }); + try { - // 1. Generate workspace branch name using AI (use same model as message) + // 1. Generate workspace branch name using AI (SLOW - but user sees pending state) let branchName: string; { const isErrLike = (v: unknown): v is { type: string } => @@ -314,6 +340,8 @@ export class IpcMain { const nameResult = await generateWorkspaceName(message, options.model, this.aiService); if (!nameResult.success) { const err = nameResult.error; + // Clear pending state on error + session.emitMetadata(null); if (isErrLike(err)) { return Err(err); } @@ -338,15 +366,7 @@ export class IpcMain { const recommendedTrunk = options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; - // 3. Create workspace - // Default to worktree runtime for new workspaces - let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "worktree", - srcBaseDir: this.config.srcDir, - }; - - const workspaceId = this.config.generateStableId(); - + // 3. Resolve runtime paths let runtime; try { // Handle different runtime types @@ -376,12 +396,12 @@ export class IpcMain { } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); + // Clear pending state on error + session.emitMetadata(null); return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` }); } - const session = this.getOrCreateSession(workspaceId); this.initStateManager.startInit(workspaceId, projectPath); - const initLogger = this.createInitLogger(workspaceId); // Create workspace with automatic collision retry @@ -393,21 +413,20 @@ export class IpcMain { ); if (!createResult.success || !createResult.workspacePath) { + // Clear pending state on error + session.emitMetadata(null); return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" }); } // Use the final branch name (may have suffix if collision occurred) branchName = finalBranchName; - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - const metadata = { id: workspaceId, name: branchName, projectName, projectPath, - createdAt: new Date().toISOString(), + createdAt, }; await this.config.editConfig((config) => { @@ -429,9 +448,12 @@ export class IpcMain { const allMetadata = await this.config.getAllWorkspaceMetadata(); const completeMetadata = allMetadata.find((m) => m.id === workspaceId); if (!completeMetadata) { + // Clear pending state on error + session.emitMetadata(null); return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" }); } + // Emit final metadata (no status = ready) session.emitMetadata(completeMetadata); void runtime @@ -460,6 +482,8 @@ export class IpcMain { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.error("Unexpected error in createWorkspaceForFirstMessage:", error); + // Clear pending state on error + session.emitMetadata(null); return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` }); } } @@ -1063,11 +1087,12 @@ export class IpcMain { } // Initialize workspace metadata - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: newWorkspaceId, name: newName, projectName, projectPath: foundProjectPath, + namedWorkspacePath: runtime.getWorkspacePath(foundProjectPath, newName), createdAt: new Date().toISOString(), runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index 9137892f1b..dc12624bee 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -57,3 +57,19 @@ function validateBranchName(name: string): string { .replace(/-+/g, "-") .substring(0, 50); } + +/** + * Generate a placeholder name from the user's message for immediate display + * while the AI generates the real title. This is git-safe and human-readable. + */ +export function generatePlaceholderName(message: string): string { + // Take first ~40 chars, sanitize for git branch name + const truncated = message.slice(0, 40).trim(); + const sanitized = truncated + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-") + .substring(0, 30); + return sanitized || "new-workspace"; +} From e3023e42eb7353cc01a1dc0adf549e98a7216217 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 24 Nov 2025 20:18:18 -0600 Subject: [PATCH 2/3] 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. --- src/browser/App.tsx | 73 ++++----- src/browser/contexts/WorkspaceContext.tsx | 2 +- .../utils/ui/workspaceFiltering.test.ts | 144 ++++++++++++++++++ src/browser/utils/ui/workspaceFiltering.ts | 52 +++++++ 4 files changed, 226 insertions(+), 45 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 2b6842cdaa..9975b05f98 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -3,13 +3,13 @@ import "./styles/globals.css"; import { useWorkspaceContext } from "./contexts/WorkspaceContext"; import { useProjectContext } from "./contexts/ProjectContext"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; +import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; @@ -198,46 +198,24 @@ function AppInner() { // NEW: Get workspace recency from store const workspaceRecency = useWorkspaceRecency(); - // Sort workspaces by recency (most recent first) - // Returns Map for direct component use + // Build sorted workspaces map including pending workspaces // Use stable reference to prevent sidebar re-renders when sort order hasn't changed const sortedWorkspacesByProject = useStableReference( - () => { - const result = new Map(); - for (const [projectPath, config] of projects) { - // Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID - const metadataList = config.workspaces - .map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined)) - .filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null); - - // Sort by recency - metadataList.sort((a, b) => { - const aTimestamp = workspaceRecency[a.id] ?? 0; - const bTimestamp = workspaceRecency[b.id] ?? 0; - return bTimestamp - aTimestamp; + () => buildSortedWorkspacesByProject(projects, workspaceMetadata, workspaceRecency), + (prev, next) => + compareMaps(prev, next, (a, b) => { + if (a.length !== b.length) return false; + // Check ID, name, and status to detect changes + return a.every((meta, i) => { + const other = b[i]; + return ( + other && + meta.id === other.id && + meta.name === other.name && + meta.status === other.status + ); }); - - result.set(projectPath, metadataList); - } - return result; - }, - (prev, next) => { - // Compare Maps: check if size, workspace order, and metadata content are the same - if ( - !compareMaps(prev, next, (a, b) => { - if (a.length !== b.length) return false; - // Check both ID and name to detect renames - return a.every((metadata, i) => { - const bMeta = b[i]; - if (!bMeta || !metadata) return false; // Null-safe - return metadata.id === bMeta.id && metadata.name === bMeta.name; - }); - }) - ) { - return false; - } - return true; - }, + }), [projects, workspaceMetadata, workspaceRecency] ); @@ -605,12 +583,19 @@ function AppInner() { new Map(prev).set(metadata.id, metadata) ); - // Switch to new workspace - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - namedWorkspacePath: metadata.namedWorkspacePath, + // Only switch to new workspace if user hasn't selected another one + // during the creation process (selectedWorkspace was null when creation started) + setSelectedWorkspace((current) => { + if (current !== null) { + // User has already selected another workspace - don't override + return current; + } + return { + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }; }); // Track telemetry diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index a016d64b3d..6dcbb5e53a 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -66,7 +66,7 @@ export interface WorkspaceContext { // Selection selectedWorkspace: WorkspaceSelection | null; - setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void; + setSelectedWorkspace: React.Dispatch>; // Workspace creation flow pendingNewWorkspaceProject: string | null; diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 4981b278c8..dee631426f 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -3,8 +3,10 @@ import { partitionWorkspacesByAge, formatDaysThreshold, AGE_THRESHOLDS_DAYS, + buildSortedWorkspacesByProject, } from "./workspaceFiltering"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { ProjectConfig } from "@/common/types/project"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; describe("partitionWorkspacesByAge", () => { @@ -173,3 +175,145 @@ describe("formatDaysThreshold", () => { expect(formatDaysThreshold(30)).toBe("30 days"); }); }); + +describe("buildSortedWorkspacesByProject", () => { + const createWorkspace = ( + id: string, + projectPath: string, + status?: "creating" + ): FrontendWorkspaceMetadata => ({ + id, + name: `workspace-${id}`, + projectName: projectPath.split("/").pop() ?? "unknown", + projectPath, + namedWorkspacePath: `${projectPath}/workspace-${id}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + status, + }); + + it("should include workspaces from persisted config", () => { + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }], + ]); + const metadata = new Map([ + ["ws1", createWorkspace("ws1", "/project/a")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(1); + expect(result.get("/project/a")?.[0].id).toBe("ws1"); + }); + + it("should include pending workspaces not yet in config", () => { + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }], + ]); + const metadata = new Map([ + ["ws1", createWorkspace("ws1", "/project/a")], + ["pending1", createWorkspace("pending1", "/project/a", "creating")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(2); + expect(result.get("/project/a")?.map((w) => w.id)).toContain("ws1"); + expect(result.get("/project/a")?.map((w) => w.id)).toContain("pending1"); + }); + + it("should handle multiple concurrent pending workspaces", () => { + const projects = new Map([["/project/a", { workspaces: [] }]]); + const metadata = new Map([ + ["pending1", createWorkspace("pending1", "/project/a", "creating")], + ["pending2", createWorkspace("pending2", "/project/a", "creating")], + ["pending3", createWorkspace("pending3", "/project/a", "creating")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(3); + }); + + it("should add pending workspaces for projects not yet in config", () => { + const projects = new Map(); + const metadata = new Map([ + ["pending1", createWorkspace("pending1", "/new/project", "creating")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/new/project")).toHaveLength(1); + expect(result.get("/new/project")?.[0].id).toBe("pending1"); + }); + + it("should sort workspaces by recency (most recent first)", () => { + const now = Date.now(); + const projects = new Map([ + [ + "/project/a", + { + workspaces: [ + { path: "/a/ws1", id: "ws1" }, + { path: "/a/ws2", id: "ws2" }, + { path: "/a/ws3", id: "ws3" }, + ], + }, + ], + ]); + const metadata = new Map([ + ["ws1", createWorkspace("ws1", "/project/a")], + ["ws2", createWorkspace("ws2", "/project/a")], + ["ws3", createWorkspace("ws3", "/project/a")], + ]); + const recency = { + ws1: now - 3000, // oldest + ws2: now - 1000, // newest + ws3: now - 2000, // middle + }; + + const result = buildSortedWorkspacesByProject(projects, metadata, recency); + + expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]); + }); + + it("should not duplicate workspaces that exist in both config and have creating status", () => { + // Edge case: workspace was saved to config but still has status: "creating" + // (this shouldn't happen in practice but tests defensive coding) + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }], + ]); + const metadata = new Map([ + ["ws1", createWorkspace("ws1", "/project/a", "creating")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(1); + expect(result.get("/project/a")?.[0].id).toBe("ws1"); + }); + + it("should skip workspaces with no id in config", () => { + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/legacy" }, { path: "/a/ws1", id: "ws1" }] }], + ]); + const metadata = new Map([ + ["ws1", createWorkspace("ws1", "/project/a")], + ]); + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(1); + expect(result.get("/project/a")?.[0].id).toBe("ws1"); + }); + + it("should skip config workspaces with no matching metadata", () => { + const projects = new Map([ + ["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }], + ]); + const metadata = new Map(); // empty + + const result = buildSortedWorkspacesByProject(projects, metadata, {}); + + expect(result.get("/project/a")).toHaveLength(0); + }); +}); diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index a64dcb7932..4d238c2b25 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -1,4 +1,5 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { ProjectConfig } from "@/common/types/project"; /** * Age thresholds for workspace filtering, in ascending order. @@ -9,6 +10,57 @@ export type AgeThresholdDays = (typeof AGE_THRESHOLDS_DAYS)[number]; const DAY_MS = 24 * 60 * 60 * 1000; +/** + * Build a map of project paths to sorted workspace metadata lists. + * Includes both persisted workspaces (from config) and pending workspaces + * (status: "creating") that haven't been saved yet. + * + * Workspaces are sorted by recency (most recent first). + */ +export function buildSortedWorkspacesByProject( + projects: Map, + workspaceMetadata: Map, + workspaceRecency: Record +): Map { + const result = new Map(); + const includedIds = new Set(); + + // First pass: include workspaces from persisted config + for (const [projectPath, config] of projects) { + const metadataList: FrontendWorkspaceMetadata[] = []; + for (const ws of config.workspaces) { + if (!ws.id) continue; + const meta = workspaceMetadata.get(ws.id); + if (meta) { + metadataList.push(meta); + includedIds.add(ws.id); + } + } + result.set(projectPath, metadataList); + } + + // Second pass: add pending workspaces (status: "creating") not yet in config + for (const [id, metadata] of workspaceMetadata) { + if (metadata.status === "creating" && !includedIds.has(id)) { + const projectWorkspaces = result.get(metadata.projectPath) ?? []; + projectWorkspaces.push(metadata); + result.set(metadata.projectPath, projectWorkspaces); + } + } + + // Sort each project's workspaces by recency + for (const [projectPath, metadataList] of result) { + metadataList.sort((a, b) => { + const aTimestamp = workspaceRecency[a.id] ?? 0; + const bTimestamp = workspaceRecency[b.id] ?? 0; + return bTimestamp - aTimestamp; + }); + result.set(projectPath, metadataList); + } + + return result; +} + /** * Format a day count for display. * Returns a human-readable string like "1 day", "7 days", etc. From 626a86636da8ba1cd0268439889601f4629910e3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 22:59:13 -0600 Subject: [PATCH 3/3] refactor: cleanup and simplify pending workspace code - Extract sanitizeBranchName() to deduplicate validation logic - Remove redundant Map.set() after in-place sort - Add isDisabled flag to consolidate isCreating || isDeleting checks - Improve aria-label for deleting state --- src/browser/components/WorkspaceListItem.tsx | 25 ++++++++++------- src/browser/contexts/WorkspaceContext.tsx | 10 +++++-- src/browser/utils/ui/workspaceFiltering.ts | 5 ++-- src/node/services/workspaceTitleGenerator.ts | 29 ++++++++++---------- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index 23c3a2444b..a1298057ad 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -46,6 +46,7 @@ const WorkspaceListItemInner: React.FC = ({ // Destructure metadata for convenience const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata; const isCreating = status === "creating"; + const isDisabled = isCreating || isDeleting; const gitStatus = useGitStatus(workspaceId); // Get rename context @@ -102,14 +103,14 @@ const WorkspaceListItemInner: React.FC = ({
{ - if (isCreating) return; // Disable click while creating + if (isDisabled) return; onSelectWorkspace({ projectPath, projectName, @@ -118,7 +119,7 @@ const WorkspaceListItemInner: React.FC = ({ }); }} onKeyDown={(e) => { - if (isCreating) return; // Disable keyboard while creating + if (isDisabled) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectWorkspace({ @@ -130,12 +131,16 @@ const WorkspaceListItemInner: React.FC = ({ } }} role="button" - tabIndex={isCreating ? -1 : 0} + tabIndex={isDisabled ? -1 : 0} aria-current={isSelected ? "true" : undefined} aria-label={ - isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}` + isCreating + ? `Creating workspace ${displayName}` + : isDeleting + ? `Deleting workspace ${displayName}` + : `Select workspace ${displayName}` } - aria-disabled={isCreating} + aria-disabled={isDisabled} data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > @@ -158,14 +163,14 @@ const WorkspaceListItemInner: React.FC = ({ { - if (isCreating) return; // Disable rename while creating + if (isDisabled) return; e.stopPropagation(); startRenaming(); }} - title={isCreating ? "Creating workspace..." : "Double-click to rename"} + title={isDisabled ? undefined : "Double-click to rename"} > {canInterrupt || isCreating ? ( diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 6dcbb5e53a..940cd4b149 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -214,6 +214,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { setWorkspaceMetadata((prev) => { const updated = new Map(prev); const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null; + const existingMeta = prev.get(event.workspaceId); + const wasCreating = existingMeta?.status === "creating"; + const isNowReady = event.metadata !== null && event.metadata.status !== "creating"; if (event.metadata === null) { // Workspace deleted - remove from map @@ -223,9 +226,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { updated.set(event.workspaceId, event.metadata); } - // If this is a new workspace (e.g., from fork), reload projects - // to ensure the sidebar shows the updated workspace list - if (isNewWorkspace) { + // Reload projects when: + // 1. New workspace appears (e.g., from fork) + // 2. Workspace transitions from "creating" to ready (now saved to config) + if (isNewWorkspace || (wasCreating && isNowReady)) { void refreshProjects(); } diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 4d238c2b25..75fad086f9 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -48,14 +48,13 @@ export function buildSortedWorkspacesByProject( } } - // Sort each project's workspaces by recency - for (const [projectPath, metadataList] of result) { + // Sort each project's workspaces by recency (sort mutates in place) + for (const metadataList of result.values()) { metadataList.sort((a, b) => { const aTimestamp = workspaceRecency[a.id] ?? 0; const bTimestamp = workspaceRecency[b.id] ?? 0; return bTimestamp - aTimestamp; }); - result.set(projectPath, metadataList); } return result; diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index dc12624bee..d83f4e0c24 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -46,16 +46,22 @@ export async function generateWorkspaceName( } /** - * Validate and sanitize branch name to be git-safe + * Sanitize a string to be git-safe: lowercase, hyphens only, no leading/trailing hyphens. */ -function validateBranchName(name: string): string { - // Ensure git-safe - const cleaned = name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); - // Remove leading/trailing hyphens and collapse multiple hyphens - return cleaned +function sanitizeBranchName(name: string, maxLength: number): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .replace(/-+/g, "-") - .substring(0, 50); + .substring(0, maxLength); +} + +/** + * Validate and sanitize branch name to be git-safe + */ +function validateBranchName(name: string): string { + return sanitizeBranchName(name, 50); } /** @@ -63,13 +69,6 @@ function validateBranchName(name: string): string { * while the AI generates the real title. This is git-safe and human-readable. */ export function generatePlaceholderName(message: string): string { - // Take first ~40 chars, sanitize for git branch name const truncated = message.slice(0, 40).trim(); - const sanitized = truncated - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .replace(/-+/g, "-") - .substring(0, 30); - return sanitized || "new-workspace"; + return sanitizeBranchName(truncated, 30) || "new-workspace"; }