diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 447b8e687..0746bffe4 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -576,6 +576,9 @@ function AppInner() { currentMetadata?.name ?? selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId; + // Use live metadata path (updates on rename) with fallback to initial path + const workspacePath = + currentMetadata?.namedWorkspacePath ?? selectedWorkspace.namedWorkspacePath ?? ""; return ( ); @@ -609,7 +613,13 @@ function AppInner() { onProviderConfig={handleProviderConfig} onReady={handleCreationChatReady} onWorkspaceCreated={(metadata) => { - // Add to workspace metadata map + // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure + // the store knows about it before React processes the state updates. + // This prevents race conditions where the UI tries to access the + // workspace before the store has created its aggregator. + workspaceStore.addWorkspace(metadata); + + // Add to workspace metadata map (triggers React state update) setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata) ); diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 6223a2748..ce6c23c12 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -55,6 +55,8 @@ interface AIViewProps { className?: string; /** If set, workspace is incompatible (from newer mux version) and this error should be displayed */ incompatibleRuntime?: string; + /** If 'creating', workspace is still being set up (git operations in progress) */ + status?: "creating"; } const AIViewInner: React.FC = ({ @@ -65,6 +67,7 @@ const AIViewInner: React.FC = ({ namedWorkspacePath, runtimeConfig, className, + status, }) => { const { api } = useAPI(); const chatAreaRef = useRef(null); @@ -637,6 +640,7 @@ const AIViewInner: React.FC = ({ onStartResize={isReviewTabActive ? startResize : undefined} // Pass resize handler when Review active isResizing={isResizing} // Pass resizing state onReviewNote={handleReviewNote} // Pass review note handler to append to chat + isCreating={status === "creating"} // Workspace still being set up /> ); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index fcbf4a96b..ecd41ea9e 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -9,6 +9,7 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getInputKey, + getModelKey, getModeKey, getPendingScopeId, getProjectScopeId, @@ -27,6 +28,13 @@ interface UseCreationWorkspaceOptions { function syncCreationPreferences(projectPath: string, workspaceId: string): void { const projectScopeId = getProjectScopeId(projectPath); + // Sync model from project scope to workspace scope + // This ensures the model used for creation is persisted for future resumes + const projectModel = readPersistedState(getModelKey(projectScopeId), null); + if (projectModel) { + updatePersistedState(getModelKey(workspaceId), projectModel); + } + const projectMode = readPersistedState(getModeKey(projectScopeId), null); if (projectMode) { updatePersistedState(getModeKey(workspaceId), projectMode); diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 6ee1cbb90..59070fe14 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -84,6 +84,8 @@ interface RightSidebarProps { isResizing?: boolean; /** Callback when user adds a review note from Code Review tab */ onReviewNote?: (note: string) => void; + /** Workspace is still being created (git operations in progress) */ + isCreating?: boolean; } const RightSidebarComponent: React.FC = ({ @@ -95,6 +97,7 @@ const RightSidebarComponent: React.FC = ({ onStartResize, isResizing = false, onReviewNote, + isCreating = false, }) => { // Global tab preference (not per-workspace) const [selectedTab, setSelectedTab] = usePersistedState("right-sidebar-tab", "costs"); @@ -298,6 +301,7 @@ const RightSidebarComponent: React.FC = ({ workspacePath={workspacePath} onReviewNote={onReviewNote} focusTrigger={focusTrigger} + isCreating={isCreating} /> )} diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 0aa6bb798..c8a0bb2c3 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -45,6 +45,8 @@ interface ReviewPanelProps { onReviewNote?: (note: string) => void; /** Trigger to focus panel (increment to trigger) */ focusTrigger?: number; + /** Workspace is still being created (git operations in progress) */ + isCreating?: boolean; } interface ReviewSearchState { @@ -120,6 +122,7 @@ export const ReviewPanel: React.FC = ({ workspacePath, onReviewNote, focusTrigger, + isCreating = false, }) => { const { api } = useAPI(); const panelRef = useRef(null); @@ -191,7 +194,8 @@ export const ReviewPanel: React.FC = ({ // Load file tree - when workspace, diffBase, or refreshTrigger changes useEffect(() => { - if (!api) return; + // Skip data loading while workspace is being created + if (!api || isCreating) return; let cancelled = false; const loadFileTree = async () => { @@ -239,11 +243,13 @@ export const ReviewPanel: React.FC = ({ filters.diffBase, filters.includeUncommitted, refreshTrigger, + isCreating, ]); // Load diff hunks - when workspace, diffBase, selected path, or refreshTrigger changes useEffect(() => { - if (!api) return; + // Skip data loading while workspace is being created + if (!api || isCreating) return; let cancelled = false; const loadDiff = async () => { @@ -333,6 +339,7 @@ export const ReviewPanel: React.FC = ({ filters.includeUncommitted, selectedFilePath, refreshTrigger, + isCreating, ]); // Persist diffBase when it changes @@ -618,6 +625,17 @@ export const ReviewPanel: React.FC = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // Show loading state while workspace is being created + if (isCreating) { + return ( +
+
+

Setting up workspace...

+

Review will be available once ready

+
+ ); + } + return (
m.metadata?.historySequence ?? 0) + ); + const errorMessage: MuxMessage = { + id: data.messageId, + role: "assistant", + parts: [], + metadata: { + partial: true, + error: data.error, + errorType: data.errorType, + timestamp: Date.now(), + historySequence: maxSequence + 1, + }, + }; + this.messages.set(data.messageId, errorMessage); + this.invalidateCache(); } } diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 35d5495d7..999aeb6bf 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -177,14 +177,25 @@ export class WorktreeRuntime extends LocalBaseRuntime { const newPath = this.getWorkspacePath(projectPath, newName); try { - // Use git worktree move to rename the worktree directory - // This updates git's internal worktree metadata correctly - using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); - await proc.result; + // Move the worktree directory (updates git's internal worktree metadata) + using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); + await moveProc.result; + + // Rename the git branch to match the new workspace name + // In mux, branch name and workspace name are always kept in sync. + // Run from the new worktree path since that's where the branch is checked out. + // Best-effort: ignore errors (e.g., branch might have a different name in test scenarios). + try { + using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`); + await branchProc.result; + } catch { + // Branch rename failed - this is fine, the directory was still moved + // This can happen if the branch name doesn't match the old directory name + } return { success: true, oldPath, newPath }; } catch (error) { - return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` }; + return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` }; } } diff --git a/src/node/services/utils/sendMessageError.test.ts b/src/node/services/utils/sendMessageError.test.ts new file mode 100644 index 000000000..e34492fc9 --- /dev/null +++ b/src/node/services/utils/sendMessageError.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { formatSendMessageError, createUnknownSendMessageError } from "./sendMessageError"; + +describe("formatSendMessageError", () => { + test("formats api_key_not_found with authentication errorType", () => { + const result = formatSendMessageError({ + type: "api_key_not_found", + provider: "anthropic", + }); + + expect(result.errorType).toBe("authentication"); + expect(result.message).toContain("anthropic"); + expect(result.message).toContain("API key"); + }); + + test("formats provider_not_supported", () => { + const result = formatSendMessageError({ + type: "provider_not_supported", + provider: "unsupported-provider", + }); + + expect(result.errorType).toBe("unknown"); + expect(result.message).toContain("unsupported-provider"); + expect(result.message).toContain("not supported"); + }); + + test("formats invalid_model_string with model_not_found errorType", () => { + const result = formatSendMessageError({ + type: "invalid_model_string", + message: "Invalid model format: foo", + }); + + expect(result.errorType).toBe("model_not_found"); + expect(result.message).toBe("Invalid model format: foo"); + }); + + test("formats incompatible_workspace", () => { + const result = formatSendMessageError({ + type: "incompatible_workspace", + message: "Workspace is incompatible", + }); + + expect(result.errorType).toBe("unknown"); + expect(result.message).toBe("Workspace is incompatible"); + }); + + test("formats unknown errors", () => { + const result = formatSendMessageError({ + type: "unknown", + raw: "Something went wrong", + }); + + expect(result.errorType).toBe("unknown"); + expect(result.message).toBe("Something went wrong"); + }); +}); + +describe("createUnknownSendMessageError", () => { + test("creates unknown error with trimmed message", () => { + const result = createUnknownSendMessageError(" test error "); + + expect(result).toEqual({ type: "unknown", raw: "test error" }); + }); + + test("throws on empty message", () => { + expect(() => createUnknownSendMessageError("")).toThrow(); + expect(() => createUnknownSendMessageError(" ")).toThrow(); + }); +}); diff --git a/src/node/services/utils/sendMessageError.ts b/src/node/services/utils/sendMessageError.ts index 4e2e9812a..0ef4f0bd8 100644 --- a/src/node/services/utils/sendMessageError.ts +++ b/src/node/services/utils/sendMessageError.ts @@ -1,5 +1,5 @@ import assert from "@/common/utils/assert"; -import type { SendMessageError } from "@/common/types/errors"; +import type { SendMessageError, StreamErrorType } from "@/common/types/errors"; /** * Helper to wrap arbitrary errors into SendMessageError structures. @@ -15,3 +15,39 @@ export const createUnknownSendMessageError = (raw: string): SendMessageError => raw: trimmed, }; }; + +/** + * Formats a SendMessageError into a user-visible message and StreamErrorType + * for display in the chat UI as a stream-error event. + */ +export const formatSendMessageError = ( + error: SendMessageError +): { message: string; errorType: StreamErrorType } => { + switch (error.type) { + case "api_key_not_found": + return { + message: `API key not configured for ${error.provider}. Please add your API key in settings.`, + errorType: "authentication", + }; + case "provider_not_supported": + return { + message: `Provider "${error.provider}" is not supported.`, + errorType: "unknown", + }; + case "invalid_model_string": + return { + message: error.message, + errorType: "model_not_found", + }; + case "incompatible_workspace": + return { + message: error.message, + errorType: "unknown", + }; + case "unknown": + return { + message: error.raw, + errorType: "unknown", + }; + } +}; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts new file mode 100644 index 000000000..1cd485c57 --- /dev/null +++ b/src/node/services/workspaceService.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { WorkspaceService } from "./workspaceService"; +import type { Config } from "@/node/config"; +import type { HistoryService } from "./historyService"; +import type { PartialService } from "./partialService"; +import type { AIService } from "./aiService"; +import type { InitStateManager } from "./initStateManager"; +import type { ExtensionMetadataService } from "./ExtensionMetadataService"; + +// Helper to access private renamingWorkspaces set +function addToRenamingWorkspaces(service: WorkspaceService, workspaceId: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (service as any).renamingWorkspaces.add(workspaceId); +} + +describe("WorkspaceService rename lock", () => { + let workspaceService: WorkspaceService; + let mockAIService: AIService; + + beforeEach(() => { + // Create minimal mocks for the services + mockAIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock(() => Promise.resolve({ success: false, error: "not found" })), + // eslint-disable-next-line @typescript-eslint/no-empty-function + on: mock(() => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + off: mock(() => {}), + } as unknown as AIService; + + const mockHistoryService: Partial = { + getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })), + appendToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })), + }; + + const mockConfig: Partial = { + srcDir: "/tmp/test", + getSessionDir: mock(() => "/tmp/test/sessions"), + generateStableId: mock(() => "test-id"), + findWorkspace: mock(() => null), + }; + + const mockPartialService: Partial = { + commitToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })), + }; + + const mockInitStateManager: Partial = {}; + const mockExtensionMetadataService: Partial = {}; + + workspaceService = new WorkspaceService( + mockConfig as Config, + mockHistoryService as HistoryService, + mockPartialService as PartialService, + mockAIService, + mockInitStateManager as InitStateManager, + mockExtensionMetadataService as ExtensionMetadataService + ); + }); + + test("sendMessage returns error when workspace is being renamed", async () => { + const workspaceId = "test-workspace"; + + addToRenamingWorkspaces(workspaceService, workspaceId); + + const result = await workspaceService.sendMessage(workspaceId, "test message", { + model: "test-model", + }); + + expect(result.success).toBe(false); + if (!result.success) { + const error = result.error; + // Error is SendMessageError which has a discriminated union + expect(typeof error === "object" && error.type === "unknown").toBe(true); + if (typeof error === "object" && error.type === "unknown") { + expect(error.raw).toContain("being renamed"); + } + } + }); + + test("resumeStream returns error when workspace is being renamed", async () => { + const workspaceId = "test-workspace"; + + addToRenamingWorkspaces(workspaceService, workspaceId); + + const result = await workspaceService.resumeStream(workspaceId, { + model: "test-model", + }); + + expect(result.success).toBe(false); + if (!result.success) { + const error = result.error; + // Error is SendMessageError which has a discriminated union + expect(typeof error === "object" && error.type === "unknown").toBe(true); + if (typeof error === "object" && error.type === "unknown") { + expect(error.raw).toContain("being renamed"); + } + } + }); + + test("rename returns error when workspace is streaming", async () => { + const workspaceId = "test-workspace"; + + // Mock isStreaming to return true + (mockAIService.isStreaming as ReturnType).mockReturnValue(true); + + const result = await workspaceService.rename(workspaceId, "new-name"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("stream is active"); + } + }); +}); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3c5633e64..66b1032e1 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -14,14 +14,16 @@ import type { InitStateManager } from "@/node/services/initStateManager"; import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory"; -import { generateWorkspaceName } from "./workspaceTitleGenerator"; +import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; +import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; import type { SendMessageOptions, DeleteMessage, ImagePart, WorkspaceChatMessage, + StreamErrorMessage, } from "@/common/orpc/types"; import type { SendMessageError } from "@/common/types/errors"; import type { @@ -80,6 +82,8 @@ export class WorkspaceService extends EventEmitter { string, { chat: () => void; metadata: () => void } >(); + // Tracks workspaces currently being renamed to prevent streaming during rename + private readonly renamingWorkspaces = new Set(); constructor( private readonly config: Config, @@ -403,7 +407,7 @@ export class WorkspaceService extends EventEmitter { } } - async createForFirstMessage( + createForFirstMessage( message: string, projectPath: string, options: SendMessageOptions & { @@ -411,45 +415,88 @@ export class WorkspaceService extends EventEmitter { runtimeConfig?: RuntimeConfig; trunkBranch?: string; } = { model: "claude-3-5-sonnet-20241022" } - ): Promise< + ): | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string } - > { - try { - const branchNameResult = await generateWorkspaceName(message, options.model, this.aiService); - if (!branchNameResult.success) { - const err = branchNameResult.error; - const errorMessage = - "message" in err - ? err.message - : err.type === "api_key_not_found" - ? `API key not found for ${err.provider}` - : err.type === "provider_not_supported" - ? `Provider not supported: ${err.provider}` - : "raw" in err - ? err.raw - : "Unknown error"; - return { success: false, error: errorMessage }; - } - const branchName = branchNameResult.data; - log.debug("Generated workspace name", { branchName }); + | { success: false; error: string } { + // Generate placeholder name and ID immediately (non-blocking) + const placeholderName = generatePlaceholderName(message); + const workspaceId = this.config.generateStableId(); + const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - const branches = await listLocalBranches(projectPath); - const recommendedTrunk = - options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; + // Use provided runtime config or default to worktree + const runtimeConfig: RuntimeConfig = options.runtimeConfig ?? { + type: "worktree", + srcBaseDir: this.config.srcDir, + }; - // Default to worktree runtime for backward compatibility - let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "worktree", - srcBaseDir: this.config.srcDir, - }; + // Compute preliminary workspace path (may be refined after srcBaseDir resolution) + const srcBaseDir = getSrcBaseDir(runtimeConfig) ?? this.config.srcDir; + const preliminaryWorkspacePath = path.join(srcBaseDir, projectName, placeholderName); + + // Create preliminary metadata with "creating" status for immediate UI response + const preliminaryMetadata: FrontendWorkspaceMetadata = { + id: workspaceId, + name: placeholderName, + projectName, + projectPath, + createdAt: new Date().toISOString(), + namedWorkspacePath: preliminaryWorkspacePath, + runtimeConfig, + status: "creating", + }; + + // Create session and emit metadata immediately so frontend can switch + const session = this.getOrCreateSession(workspaceId); + session.emitMetadata(preliminaryMetadata); + + log.debug("Emitted preliminary workspace metadata", { workspaceId, placeholderName }); - const workspaceId = this.config.generateStableId(); + // Kick off background workspace creation (git operations, config save, etc.) + void this.completeWorkspaceCreation( + workspaceId, + message, + projectPath, + placeholderName, + runtimeConfig, + options + ); + + // Return immediately with preliminary metadata + return { + success: true, + workspaceId, + metadata: preliminaryMetadata, + }; + } + + /** + * Completes workspace creation in the background after preliminary metadata is emitted. + * Handles git operations, config persistence, and kicks off message sending. + */ + private async completeWorkspaceCreation( + workspaceId: string, + message: string, + projectPath: string, + placeholderName: string, + runtimeConfig: RuntimeConfig, + options: SendMessageOptions & { + imageParts?: Array<{ url: string; mediaType: string }>; + runtimeConfig?: RuntimeConfig; + trunkBranch?: string; + } + ): Promise { + const session = this.sessions.get(workspaceId); + if (!session) { + log.error("Session not found for workspace creation", { workspaceId }); + return; + } + try { + // Resolve runtime config (may involve path resolution for SSH) + let finalRuntimeConfig = runtimeConfig; let runtime; try { runtime = createRuntime(finalRuntimeConfig, { projectPath }); - // Resolve srcBaseDir path if the config has one const srcBaseDir = getSrcBaseDir(finalRuntimeConfig); if (srcBaseDir) { const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir); @@ -463,15 +510,21 @@ export class WorkspaceService extends EventEmitter { } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMsg }; + log.error("Failed to create runtime for workspace", { workspaceId, error: errorMsg }); + session.emitMetadata(null); // Remove the "creating" workspace + return; } - const session = this.getOrCreateSession(workspaceId); + // Detect trunk branch (git operation) + const branches = await listLocalBranches(projectPath); + const recommendedTrunk = + options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; + this.initStateManager.startInit(workspaceId, projectPath); const initLogger = this.createInitLogger(workspaceId); // Create workspace with automatic collision retry - let finalBranchName = branchName; + let finalBranchName = placeholderName; let createResult: { success: boolean; workspacePath?: string; error?: string }; for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) { @@ -485,38 +538,32 @@ export class WorkspaceService extends EventEmitter { if (createResult.success) break; - // If collision and not last attempt, retry with suffix if ( isWorkspaceNameCollision(createResult.error) && attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES ) { log.debug(`Workspace name collision for "${finalBranchName}", retrying with suffix`); - finalBranchName = appendCollisionSuffix(branchName); + finalBranchName = appendCollisionSuffix(placeholderName); continue; } break; } if (!createResult!.success || !createResult!.workspacePath) { - return { success: false, error: createResult!.error ?? "Failed to create workspace" }; + log.error("Failed to create workspace", { + workspaceId, + error: createResult!.error, + }); + session.emitMetadata(null); // Remove the "creating" workspace + return; } const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - - // Compute namedWorkspacePath const namedWorkspacePath = runtime.getWorkspacePath(projectPath, finalBranchName); + const createdAt = new Date().toISOString(); - const metadata: FrontendWorkspaceMetadata = { - id: workspaceId, - name: finalBranchName, - projectName, - projectPath, - createdAt: new Date().toISOString(), - namedWorkspacePath, - runtimeConfig: finalRuntimeConfig, - }; - + // Save to config await this.config.editConfig((config) => { let projectConfig = config.projects.get(projectPath); if (!projectConfig) { @@ -527,21 +574,27 @@ export class WorkspaceService extends EventEmitter { path: createResult!.workspacePath!, id: workspaceId, name: finalBranchName, - createdAt: metadata.createdAt, + createdAt, runtimeConfig: finalRuntimeConfig, }); return config; }); - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const completeMetadata = allMetadata.find((m) => m.id === workspaceId); - - if (!completeMetadata) { - return { success: false, error: "Failed to retrieve workspace metadata" }; - } + // Emit final metadata (without "creating" status) + const finalMetadata: FrontendWorkspaceMetadata = { + id: workspaceId, + name: finalBranchName, + projectName, + projectPath, + createdAt, + namedWorkspacePath, + runtimeConfig: finalRuntimeConfig, + }; + session.emitMetadata(finalMetadata); - session.emitMetadata(completeMetadata); + log.debug("Workspace creation completed", { workspaceId, finalBranchName }); + // Start workspace initialization in background void runtime .initWorkspace({ projectPath, @@ -557,20 +610,171 @@ export class WorkspaceService extends EventEmitter { initLogger.logComplete(-1); }); - void session.sendMessage(message, options); + // Send the first message, surfacing errors to the chat UI + void session.sendMessage(message, options).then((result) => { + if (!result.success) { + log.error("sendMessage failed during workspace creation", { + workspaceId, + errorType: result.error.type, + error: result.error, + }); + const { message: errorMessage, errorType } = formatSendMessageError(result.error); + const streamError: StreamErrorMessage = { + type: "stream-error", + messageId: `error-${Date.now()}`, + error: errorMessage, + errorType, + }; + session.emitChatEvent(streamError); + } + }); - return { - success: true, - workspaceId, - metadata: completeMetadata, - }; + // Generate AI name asynchronously and rename if successful + void this.generateAndApplyAIName(workspaceId, message, finalBranchName, options.model); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in workspace creation", { workspaceId, error: errorMessage }); + session.emitMetadata(null); // Remove the "creating" workspace + } + } + + /** + * Asynchronously generates an AI workspace name and renames the workspace if successful. + * This runs in the background after workspace creation to avoid blocking the UX. + * + * The method: + * 1. Generates the AI name (can run while stream is active) + * 2. Waits for any active stream to complete (rename is blocked during streaming) + * 3. Attempts to rename the workspace + */ + private async generateAndApplyAIName( + workspaceId: string, + message: string, + currentName: string, + model: string + ): Promise { + try { + log.debug("Starting async AI name generation", { workspaceId, currentName }); + + const branchNameResult = await generateWorkspaceName(message, model, this.aiService); + + if (!branchNameResult.success) { + // AI name generation failed - keep the placeholder name + const err = branchNameResult.error; + const errorMessage = + "message" in err + ? err.message + : err.type === "api_key_not_found" + ? `API key not found for ${err.provider}` + : err.type === "provider_not_supported" + ? `Provider not supported: ${err.provider}` + : "raw" in err + ? err.raw + : "Unknown error"; + log.info("AI name generation failed, keeping placeholder name", { + workspaceId, + currentName, + error: errorMessage, + }); + return; + } + + const aiGeneratedName = branchNameResult.data; + log.debug("AI generated workspace name", { workspaceId, aiGeneratedName, currentName }); + + // Only rename if the AI name is different from current name + if (aiGeneratedName === currentName) { + log.debug("AI name matches placeholder, no rename needed", { workspaceId }); + return; + } + + // Wait for the stream to complete before renaming (rename is blocked during streaming) + await this.waitForStreamComplete(workspaceId); + + // Mark workspace as renaming to block new streams during the rename operation + this.renamingWorkspaces.add(workspaceId); + try { + // Attempt to rename with collision retry (same logic as workspace creation) + let finalName = aiGeneratedName; + for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) { + const renameResult = await this.rename(workspaceId, finalName); + + if (renameResult.success) { + log.info("Successfully renamed workspace to AI-generated name", { + workspaceId, + oldName: currentName, + newName: finalName, + }); + return; + } + + // If collision and not last attempt, retry with suffix + if ( + renameResult.error?.includes("already exists") && + attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES + ) { + log.debug(`Workspace name collision for "${finalName}", retrying with suffix`); + finalName = appendCollisionSuffix(aiGeneratedName); + continue; + } + + // Non-collision error or out of retries - keep placeholder name + log.info("Failed to rename workspace to AI-generated name", { + workspaceId, + aiGeneratedName: finalName, + error: renameResult.error, + }); + return; + } + } finally { + // Always clear renaming flag, even on error + this.renamingWorkspaces.delete(workspaceId); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in createWorkspaceForFirstMessage:", error); - return { success: false, error: `Failed to create workspace: ${errorMessage}` }; + log.error("Unexpected error in async AI name generation", { + workspaceId, + error: errorMessage, + }); } } + /** + * Waits for an active stream on the workspace to complete. + * Returns immediately if no stream is active. + */ + private waitForStreamComplete(workspaceId: string): Promise { + // If not currently streaming, resolve immediately + if (!this.aiService.isStreaming(workspaceId)) { + return Promise.resolve(); + } + + log.debug("Waiting for stream to complete before rename", { workspaceId }); + + return new Promise((resolve) => { + // Create handler that checks for this workspace's stream end + const handler = (event: StreamEndEvent | StreamAbortEvent) => { + if (event.workspaceId === workspaceId) { + this.aiService.off("stream-end", handler); + this.aiService.off("stream-abort", handler); + log.debug("Stream completed, proceeding with rename", { workspaceId }); + resolve(); + } + }; + + // Listen for both normal completion and abort + this.aiService.on("stream-end", handler); + this.aiService.on("stream-abort", handler); + + // Safety check: if stream already ended between the isStreaming check and subscribing + if (!this.aiService.isStreaming(workspaceId)) { + this.aiService.off("stream-end", handler); + this.aiService.off("stream-abort", handler); + resolve(); + } + }); + } + async remove(workspaceId: string, force = false): Promise> { // Try to remove from runtime (filesystem) try { @@ -697,6 +901,9 @@ export class WorkspaceService extends EventEmitter { return Err(validation.error ?? "Invalid workspace name"); } + // Mark workspace as renaming to block new streams during the rename operation + this.renamingWorkspaces.add(workspaceId); + const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { return Err(`Failed to get workspace metadata: ${metadataResult.error}`); @@ -764,6 +971,9 @@ export class WorkspaceService extends EventEmitter { } catch (error) { const message = error instanceof Error ? error.message : String(error); return Err(`Failed to rename workspace: ${message}`); + } finally { + // Always clear renaming flag, even on error + this.renamingWorkspaces.delete(workspaceId); } } @@ -897,7 +1107,7 @@ export class WorkspaceService extends EventEmitter { messagePreview: message.substring(0, 50), }); - return await this.createForFirstMessage(message, options.projectPath, options); + return this.createForFirstMessage(message, options.projectPath, options); } log.debug("sendMessage handler: Received", { @@ -908,6 +1118,15 @@ export class WorkspaceService extends EventEmitter { }); try { + // Block streaming while workspace is being renamed to prevent path conflicts + if (this.renamingWorkspaces.has(workspaceId)) { + log.debug("sendMessage blocked: workspace is being renamed", { workspaceId }); + return Err({ + type: "unknown", + raw: "Workspace is being renamed. Please wait and try again.", + }); + } + const session = this.getOrCreateSession(workspaceId); void this.updateRecencyTimestamp(workspaceId); @@ -950,6 +1169,15 @@ export class WorkspaceService extends EventEmitter { options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" } ): Promise> { try { + // Block streaming while workspace is being renamed to prevent path conflicts + if (this.renamingWorkspaces.has(workspaceId)) { + log.debug("resumeStream blocked: workspace is being renamed", { workspaceId }); + return Err({ + type: "unknown", + raw: "Workspace is being renamed. Please wait and try again.", + }); + } + const session = this.getOrCreateSession(workspaceId); const result = await session.resumeStream(options); if (!result.success) { diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts new file mode 100644 index 000000000..90c3295d0 --- /dev/null +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "bun:test"; +import { generatePlaceholderName } from "./workspaceTitleGenerator"; + +describe("generatePlaceholderName", () => { + it("should generate a git-safe name from message", () => { + const result = generatePlaceholderName("Add user authentication feature"); + expect(result).toBe("add-user-authentication-featur"); + }); + + it("should handle special characters", () => { + const result = generatePlaceholderName("Fix bug #123 in user/profile"); + expect(result).toBe("fix-bug-123-in-user-profile"); + }); + + it("should truncate long messages", () => { + const result = generatePlaceholderName( + "This is a very long message that should be truncated to fit within the maximum length" + ); + expect(result.length).toBeLessThanOrEqual(30); + expect(result).toBe("this-is-a-very-long-message-th"); + }); + + it("should return default name for empty/whitespace input", () => { + expect(generatePlaceholderName("")).toBe("new-workspace"); + expect(generatePlaceholderName(" ")).toBe("new-workspace"); + }); + + it("should handle unicode characters", () => { + const result = generatePlaceholderName("Add émojis 🚀 and accénts"); + expect(result).toBe("add-mojis-and-acc-nts"); + }); + + it("should handle only special characters", () => { + const result = generatePlaceholderName("!@#$%^&*()"); + expect(result).toBe("new-workspace"); + }); +}); diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 10cf943aa..e1fe6c522 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -948,7 +948,7 @@ describeIntegration("Runtime integration tests", () => { if (!result.success) { // Error message differs between local (git worktree) and SSH (mv command) if (type === "local") { - expect(result.error).toContain("Failed to move worktree"); + expect(result.error).toContain("Failed to rename workspace"); } else { expect(result.error).toContain("Failed to rename directory"); }