Skip to content

Commit d827eb0

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 bb3ab70 commit d827eb0

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
@@ -402,39 +402,96 @@ export class WorkspaceService extends EventEmitter {
402402
}
403403
}
404404

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

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

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

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

489+
try {
490+
// Resolve runtime config (may involve path resolution for SSH)
491+
let finalRuntimeConfig = runtimeConfig;
434492
let runtime;
435493
try {
436494
runtime = createRuntime(finalRuntimeConfig, { projectPath });
437-
// Resolve srcBaseDir path if the config has one
438495
const srcBaseDir = getSrcBaseDir(finalRuntimeConfig);
439496
if (srcBaseDir) {
440497
const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir);
@@ -448,10 +505,16 @@ export class WorkspaceService extends EventEmitter {
448505
}
449506
} catch (error) {
450507
const errorMsg = error instanceof Error ? error.message : String(error);
451-
return { success: false, error: errorMsg };
508+
log.error("Failed to create runtime for workspace", { workspaceId, error: errorMsg });
509+
session.emitMetadata(null); // Remove the "creating" workspace
510+
return;
452511
}
453512

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

@@ -470,7 +533,6 @@ export class WorkspaceService extends EventEmitter {
470533

471534
if (createResult.success) break;
472535

473-
// If collision and not last attempt, retry with suffix
474536
if (
475537
isWorkspaceNameCollision(createResult.error) &&
476538
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
@@ -483,25 +545,20 @@ export class WorkspaceService extends EventEmitter {
483545
}
484546

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

489556
const projectName =
490557
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
491-
492-
// Compute namedWorkspacePath
493558
const namedWorkspacePath = runtime.getWorkspacePath(projectPath, finalBranchName);
559+
const createdAt = new Date().toISOString();
494560

495-
const metadata: FrontendWorkspaceMetadata = {
496-
id: workspaceId,
497-
name: finalBranchName,
498-
projectName,
499-
projectPath,
500-
createdAt: new Date().toISOString(),
501-
namedWorkspacePath,
502-
runtimeConfig: finalRuntimeConfig,
503-
};
504-
561+
// Save to config
505562
await this.config.editConfig((config) => {
506563
let projectConfig = config.projects.get(projectPath);
507564
if (!projectConfig) {
@@ -512,21 +569,27 @@ export class WorkspaceService extends EventEmitter {
512569
path: createResult!.workspacePath!,
513570
id: workspaceId,
514571
name: finalBranchName,
515-
createdAt: metadata.createdAt,
572+
createdAt,
516573
runtimeConfig: finalRuntimeConfig,
517574
});
518575
return config;
519576
});
520577

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

528-
session.emitMetadata(completeMetadata);
590+
log.debug("Workspace creation completed", { workspaceId, finalBranchName });
529591

592+
// Start workspace initialization in background
530593
void runtime
531594
.initWorkspace({
532595
projectPath,
@@ -542,20 +605,15 @@ export class WorkspaceService extends EventEmitter {
542605
initLogger.logComplete(-1);
543606
});
544607

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

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

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

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

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

1004-
return await this.createForFirstMessage(message, options.projectPath, options);
1076+
return this.createForFirstMessage(message, options.projectPath, options);
10051077
}
10061078

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

0 commit comments

Comments
 (0)