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/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index e3c2b32e17..a1298057ad 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -44,7 +44,9 @@ 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 isDisabled = isCreating || isDeleting; const gitStatus = useGitStatus(workspaceId); // Get rename context @@ -100,19 +102,24 @@ const WorkspaceListItemInner: React.FC = ({
+ onClick={() => { + if (isDisabled) return; onSelectWorkspace({ projectPath, projectName, namedWorkspacePath, workspaceId, - }) - } + }); + }} onKeyDown={(e) => { + if (isDisabled) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectWorkspace({ @@ -124,9 +131,16 @@ const WorkspaceListItemInner: React.FC = ({ } }} role="button" - tabIndex={0} + tabIndex={isDisabled ? -1 : 0} aria-current={isSelected ? "true" : undefined} - aria-label={`Select workspace ${displayName}`} + aria-label={ + isCreating + ? `Creating workspace ${displayName}` + : isDeleting + ? `Deleting workspace ${displayName}` + : `Select workspace ${displayName}` + } + aria-disabled={isDisabled} data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > @@ -147,14 +161,18 @@ const WorkspaceListItemInner: React.FC = ({ /> ) : ( { + if (isDisabled) return; e.stopPropagation(); startRenaming(); }} - title="Double-click to rename" + title={isDisabled ? undefined : "Double-click to rename"} > - {canInterrupt ? ( + {canInterrupt || isCreating ? ( {displayName} @@ -165,41 +183,47 @@ const WorkspaceListItemInner: React.FC = ({ )}
- + {!isCreating && ( + <> + - - - - Remove workspace - - + + + + Remove workspace + + + + )}
-
- {isDeleting ? ( -
- 🗑️ - Deleting... -
- ) : ( - - )} -
+ {!isCreating && ( +
+ {isDeleting ? ( +
+ 🗑️ + Deleting... +
+ ) : ( + + )} +
+ )} {renameError && isEditing && ( diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index a016d64b3d..940cd4b149 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; @@ -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.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..75fad086f9 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,56 @@ 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 (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; + }); + } + + return result; +} + /** * Format a day count for display. * Returns a human-readable string like "1 day", "7 days", etc. 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..d83f4e0c24 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -46,14 +46,29 @@ 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); +} + +/** + * 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 { + const truncated = message.slice(0, 40).trim(); + return sanitizeBranchName(truncated, 30) || "new-workspace"; }