Skip to content

Commit 38d07ca

Browse files
committed
fix: move all git operations to background for instant UX
The workspace now appears immediately with 'creating' status: 1. Generate placeholder name and ID (instant) 2. Emit preliminary metadata with status='creating' (instant) 3. Return to frontend immediately All blocking operations run in completeWorkspaceCreation(): - listLocalBranches (git operation) - detectDefaultTrunkBranch (git operation) - runtime.createWorkspace (creates worktree) - Config save On completion, final metadata is emitted (without 'creating' status). On failure, null metadata is emitted to remove the workspace. Also added collision retry for AI-generated name renames.
1 parent 4abbba5 commit 38d07ca

File tree

1 file changed

+134
-62
lines changed

1 file changed

+134
-62
lines changed

src/node/services/workspaceService.ts

Lines changed: 134 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -403,39 +403,96 @@ export class WorkspaceService extends EventEmitter {
403403
}
404404
}
405405

406-
async createForFirstMessage(
406+
createForFirstMessage(
407407
message: string,
408408
projectPath: string,
409409
options: SendMessageOptions & {
410410
imageParts?: Array<{ url: string; mediaType: string }>;
411411
runtimeConfig?: RuntimeConfig;
412412
trunkBranch?: string;
413413
} = { model: "claude-3-5-sonnet-20241022" }
414-
): Promise<
414+
):
415415
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
416-
| { success: false; error: string }
417-
> {
418-
try {
419-
// Use placeholder name for immediate workspace creation (non-blocking)
420-
const placeholderName = generatePlaceholderName(message);
421-
log.debug("Using placeholder name for immediate creation", { placeholderName });
416+
| { success: false; error: string } {
417+
// Generate placeholder name and ID immediately (non-blocking)
418+
const placeholderName = generatePlaceholderName(message);
419+
const workspaceId = this.config.generateStableId();
420+
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
422421

423-
const branches = await listLocalBranches(projectPath);
424-
const recommendedTrunk =
425-
options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
422+
// Use provided runtime config or default to worktree
423+
const runtimeConfig: RuntimeConfig = options.runtimeConfig ?? {
424+
type: "worktree",
425+
srcBaseDir: this.config.srcDir,
426+
};
426427

427-
// Default to worktree runtime for backward compatibility
428-
let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
429-
type: "worktree",
430-
srcBaseDir: this.config.srcDir,
431-
};
428+
// Compute preliminary workspace path (may be refined after srcBaseDir resolution)
429+
const srcBaseDir = getSrcBaseDir(runtimeConfig) ?? this.config.srcDir;
430+
const preliminaryWorkspacePath = path.join(srcBaseDir, projectName, placeholderName);
431+
432+
// Create preliminary metadata with "creating" status for immediate UI response
433+
const preliminaryMetadata: FrontendWorkspaceMetadata = {
434+
id: workspaceId,
435+
name: placeholderName,
436+
projectName,
437+
projectPath,
438+
createdAt: new Date().toISOString(),
439+
namedWorkspacePath: preliminaryWorkspacePath,
440+
runtimeConfig,
441+
status: "creating",
442+
};
443+
444+
// Create session and emit metadata immediately so frontend can switch
445+
const session = this.getOrCreateSession(workspaceId);
446+
session.emitMetadata(preliminaryMetadata);
447+
448+
log.debug("Emitted preliminary workspace metadata", { workspaceId, placeholderName });
449+
450+
// Kick off background workspace creation (git operations, config save, etc.)
451+
void this.completeWorkspaceCreation(
452+
workspaceId,
453+
message,
454+
projectPath,
455+
placeholderName,
456+
runtimeConfig,
457+
options
458+
);
459+
460+
// Return immediately with preliminary metadata
461+
return {
462+
success: true,
463+
workspaceId,
464+
metadata: preliminaryMetadata,
465+
};
466+
}
432467

433-
const workspaceId = this.config.generateStableId();
468+
/**
469+
* Completes workspace creation in the background after preliminary metadata is emitted.
470+
* Handles git operations, config persistence, and kicks off message sending.
471+
*/
472+
private async completeWorkspaceCreation(
473+
workspaceId: string,
474+
message: string,
475+
projectPath: string,
476+
placeholderName: string,
477+
runtimeConfig: RuntimeConfig,
478+
options: SendMessageOptions & {
479+
imageParts?: Array<{ url: string; mediaType: string }>;
480+
runtimeConfig?: RuntimeConfig;
481+
trunkBranch?: string;
482+
}
483+
): Promise<void> {
484+
const session = this.sessions.get(workspaceId);
485+
if (!session) {
486+
log.error("Session not found for workspace creation", { workspaceId });
487+
return;
488+
}
434489

490+
try {
491+
// Resolve runtime config (may involve path resolution for SSH)
492+
let finalRuntimeConfig = runtimeConfig;
435493
let runtime;
436494
try {
437495
runtime = createRuntime(finalRuntimeConfig, { projectPath });
438-
// Resolve srcBaseDir path if the config has one
439496
const srcBaseDir = getSrcBaseDir(finalRuntimeConfig);
440497
if (srcBaseDir) {
441498
const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir);
@@ -449,10 +506,16 @@ export class WorkspaceService extends EventEmitter {
449506
}
450507
} catch (error) {
451508
const errorMsg = error instanceof Error ? error.message : String(error);
452-
return { success: false, error: errorMsg };
509+
log.error("Failed to create runtime for workspace", { workspaceId, error: errorMsg });
510+
session.emitMetadata(null); // Remove the "creating" workspace
511+
return;
453512
}
454513

455-
const session = this.getOrCreateSession(workspaceId);
514+
// Detect trunk branch (git operation)
515+
const branches = await listLocalBranches(projectPath);
516+
const recommendedTrunk =
517+
options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
518+
456519
this.initStateManager.startInit(workspaceId, projectPath);
457520
const initLogger = this.createInitLogger(workspaceId);
458521

@@ -471,7 +534,6 @@ export class WorkspaceService extends EventEmitter {
471534

472535
if (createResult.success) break;
473536

474-
// If collision and not last attempt, retry with suffix
475537
if (
476538
isWorkspaceNameCollision(createResult.error) &&
477539
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
@@ -484,25 +546,20 @@ export class WorkspaceService extends EventEmitter {
484546
}
485547

486548
if (!createResult!.success || !createResult!.workspacePath) {
487-
return { success: false, error: createResult!.error ?? "Failed to create workspace" };
549+
log.error("Failed to create workspace", {
550+
workspaceId,
551+
error: createResult!.error,
552+
});
553+
session.emitMetadata(null); // Remove the "creating" workspace
554+
return;
488555
}
489556

490557
const projectName =
491558
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
492-
493-
// Compute namedWorkspacePath
494559
const namedWorkspacePath = runtime.getWorkspacePath(projectPath, finalBranchName);
560+
const createdAt = new Date().toISOString();
495561

496-
const metadata: FrontendWorkspaceMetadata = {
497-
id: workspaceId,
498-
name: finalBranchName,
499-
projectName,
500-
projectPath,
501-
createdAt: new Date().toISOString(),
502-
namedWorkspacePath,
503-
runtimeConfig: finalRuntimeConfig,
504-
};
505-
562+
// Save to config
506563
await this.config.editConfig((config) => {
507564
let projectConfig = config.projects.get(projectPath);
508565
if (!projectConfig) {
@@ -513,21 +570,27 @@ export class WorkspaceService extends EventEmitter {
513570
path: createResult!.workspacePath!,
514571
id: workspaceId,
515572
name: finalBranchName,
516-
createdAt: metadata.createdAt,
573+
createdAt,
517574
runtimeConfig: finalRuntimeConfig,
518575
});
519576
return config;
520577
});
521578

522-
const allMetadata = await this.config.getAllWorkspaceMetadata();
523-
const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
524-
525-
if (!completeMetadata) {
526-
return { success: false, error: "Failed to retrieve workspace metadata" };
527-
}
579+
// Emit final metadata (without "creating" status)
580+
const finalMetadata: FrontendWorkspaceMetadata = {
581+
id: workspaceId,
582+
name: finalBranchName,
583+
projectName,
584+
projectPath,
585+
createdAt,
586+
namedWorkspacePath,
587+
runtimeConfig: finalRuntimeConfig,
588+
};
589+
session.emitMetadata(finalMetadata);
528590

529-
session.emitMetadata(completeMetadata);
591+
log.debug("Workspace creation completed", { workspaceId, finalBranchName });
530592

593+
// Start workspace initialization in background
531594
void runtime
532595
.initWorkspace({
533596
projectPath,
@@ -543,20 +606,15 @@ export class WorkspaceService extends EventEmitter {
543606
initLogger.logComplete(-1);
544607
});
545608

609+
// Send the first message
546610
void session.sendMessage(message, options);
547611

548612
// Generate AI name asynchronously and rename if successful
549613
void this.generateAndApplyAIName(workspaceId, message, finalBranchName, options.model);
550-
551-
return {
552-
success: true,
553-
workspaceId,
554-
metadata: completeMetadata,
555-
};
556614
} catch (error) {
557615
const errorMessage = error instanceof Error ? error.message : String(error);
558-
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
559-
return { success: false, error: `Failed to create workspace: ${errorMessage}` };
616+
log.error("Unexpected error in workspace creation", { workspaceId, error: errorMessage });
617+
session.emitMetadata(null); // Remove the "creating" workspace
560618
}
561619
}
562620

@@ -613,24 +671,38 @@ export class WorkspaceService extends EventEmitter {
613671
// Wait for the stream to complete before renaming (rename is blocked during streaming)
614672
await this.waitForStreamComplete(workspaceId);
615673

616-
// Attempt to rename the workspace
617-
const renameResult = await this.rename(workspaceId, aiGeneratedName);
674+
// Attempt to rename with collision retry (same logic as workspace creation)
675+
let finalName = aiGeneratedName;
676+
for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) {
677+
const renameResult = await this.rename(workspaceId, finalName);
678+
679+
if (renameResult.success) {
680+
log.info("Successfully renamed workspace to AI-generated name", {
681+
workspaceId,
682+
oldName: currentName,
683+
newName: finalName,
684+
});
685+
return;
686+
}
618687

619-
if (!renameResult.success) {
620-
// Rename failed (e.g., collision) - keep the placeholder name
688+
// If collision and not last attempt, retry with suffix
689+
if (
690+
renameResult.error?.includes("already exists") &&
691+
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
692+
) {
693+
log.debug(`Workspace name collision for "${finalName}", retrying with suffix`);
694+
finalName = appendCollisionSuffix(aiGeneratedName);
695+
continue;
696+
}
697+
698+
// Non-collision error or out of retries - keep placeholder name
621699
log.info("Failed to rename workspace to AI-generated name", {
622700
workspaceId,
623-
aiGeneratedName,
701+
aiGeneratedName: finalName,
624702
error: renameResult.error,
625703
});
626704
return;
627705
}
628-
629-
log.info("Successfully renamed workspace to AI-generated name", {
630-
workspaceId,
631-
oldName: currentName,
632-
newName: aiGeneratedName,
633-
});
634706
} catch (error) {
635707
const errorMessage = error instanceof Error ? error.message : String(error);
636708
log.error("Unexpected error in async AI name generation", {
@@ -1002,7 +1074,7 @@ export class WorkspaceService extends EventEmitter {
10021074
messagePreview: message.substring(0, 50),
10031075
});
10041076

1005-
return await this.createForFirstMessage(message, options.projectPath, options);
1077+
return this.createForFirstMessage(message, options.projectPath, options);
10061078
}
10071079

10081080
log.debug("sendMessage handler: Received", {

0 commit comments

Comments
 (0)