diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 0911aaf83..9c2b80d25 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"; @@ -193,46 +193,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] ); diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index bb8e17db4..0c96392f4 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -41,7 +41,8 @@ const WorkspaceListItemInner: React.FC = ({ 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 @@ -103,18 +104,23 @@ 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({ @@ -126,9 +132,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} > @@ -156,14 +165,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} @@ -174,33 +187,39 @@ const WorkspaceListItemInner: React.FC = ({ )}
- - - - - - Remove workspace - - + {!isCreating && ( + <> + + + + + + Remove workspace + + + + )}
-
- -
+ {!isCreating && ( +
+ +
+ )} {renameError && isEditing && ( diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 81efe2247..a4f05425e 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "@jest/globals"; -import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering"; +import { + partitionWorkspacesByAge, + formatOldWorkspaceThreshold, + 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", () => { @@ -126,3 +131,145 @@ describe("formatOldWorkspaceThreshold", () => { expect(result).toBe("1 day"); }); }); + +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 55dec9f2d..06388e2e5 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -1,10 +1,62 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { ProjectConfig } from "@/common/types/project"; /** * Time threshold for considering a workspace "old" (24 hours in milliseconds) */ const OLD_WORKSPACE_THRESHOLD_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 the old workspace threshold for display. * Returns a human-readable string like "1 day", "2 hours", etc. diff --git a/src/common/types/workspace.ts b/src/common/types/workspace.ts index 465cd38d7..d4b023b36 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 07012203e..660800134 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, @@ -34,7 +34,7 @@ export interface AgentSessionChatEvent { export interface AgentSessionMetadataEvent { workspaceId: string; - metadata: WorkspaceMetadata | null; + metadata: FrontendWorkspaceMetadata | null; } interface AgentSessionOptions { @@ -136,7 +136,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, @@ -240,11 +240,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 0700c49f2..aca8816b2 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"; @@ -44,7 +43,7 @@ import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; -import { generateWorkspaceName } from "./workspaceTitleGenerator"; +import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator"; /** * IpcMain - Manages all IPC handlers and service coordination * @@ -242,8 +241,34 @@ 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 + const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { + type: "local", + 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 } => @@ -251,6 +276,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); } @@ -275,14 +302,7 @@ export class IpcMain { const recommendedTrunk = options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; - // 3. Create workspace - const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "local", - srcBaseDir: this.config.srcDir, - }; - - const workspaceId = this.config.generateStableId(); - + // 3. Resolve runtime paths let runtime; let resolvedSrcBaseDir: string; try { @@ -299,14 +319,15 @@ 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); + // 4. Create workspace with final name const createResult = await runtime.createWorkspace({ projectPath, branchName, @@ -316,18 +337,17 @@ 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" }); } - 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) => { @@ -349,9 +369,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 @@ -380,6 +403,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}` }); } } @@ -933,11 +958,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 9137892f1..dc12624be 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"; +}