Skip to content

Commit 3618849

Browse files
committed
🤖 feat: show pending workspace states in sidebar
- Add status?: 'creating' field to WorkspaceMetadata for pending workspaces - Backend emits pending metadata immediately before slow AI title generation - generatePlaceholderName() creates git-safe placeholder from user's message - Frontend shows shimmer on workspace name during creation - Disable selection/remove actions while workspace is being created - Clear pending state on error by emitting null metadata This provides immediate visual feedback when creating workspaces instead of the UI appearing frozen during title generation (2-5s).
1 parent 5874155 commit 3618849

File tree

5 files changed

+135
-67
lines changed

5 files changed

+135
-67
lines changed

src/browser/components/WorkspaceListItem.tsx

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4444
onToggleUnread: _onToggleUnread,
4545
}) => {
4646
// Destructure metadata for convenience
47-
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
47+
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
48+
const isCreating = status === "creating";
4849
const gitStatus = useGitStatus(workspaceId);
4950

5051
// Get rename context
@@ -100,19 +101,24 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
100101
<React.Fragment>
101102
<div
102103
className={cn(
103-
"py-1.5 pl-4 pr-2 cursor-pointer border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100 flex gap-2",
104-
isSelected && "bg-hover border-l-blue-400",
105-
isDeleting && "opacity-50 pointer-events-none"
104+
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
105+
isCreating || isDeleting
106+
? "cursor-default opacity-70"
107+
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
108+
isSelected && !isCreating && "bg-hover border-l-blue-400",
109+
isDeleting && "pointer-events-none"
106110
)}
107-
onClick={() =>
111+
onClick={() => {
112+
if (isCreating) return; // Disable click while creating
108113
onSelectWorkspace({
109114
projectPath,
110115
projectName,
111116
namedWorkspacePath,
112117
workspaceId,
113-
})
114-
}
118+
});
119+
}}
115120
onKeyDown={(e) => {
121+
if (isCreating) return; // Disable keyboard while creating
116122
if (e.key === "Enter" || e.key === " ") {
117123
e.preventDefault();
118124
onSelectWorkspace({
@@ -124,9 +130,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
124130
}
125131
}}
126132
role="button"
127-
tabIndex={0}
133+
tabIndex={isCreating ? -1 : 0}
128134
aria-current={isSelected ? "true" : undefined}
129-
aria-label={`Select workspace ${displayName}`}
135+
aria-label={
136+
isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}`
137+
}
138+
aria-disabled={isCreating}
130139
data-workspace-path={namedWorkspacePath}
131140
data-workspace-id={workspaceId}
132141
>
@@ -147,14 +156,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
147156
/>
148157
) : (
149158
<span
150-
className="text-foreground -mx-1 min-w-0 flex-1 cursor-pointer truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200 hover:bg-white/5"
159+
className={cn(
160+
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
161+
!isCreating && "cursor-pointer hover:bg-white/5"
162+
)}
151163
onDoubleClick={(e) => {
164+
if (isCreating) return; // Disable rename while creating
152165
e.stopPropagation();
153166
startRenaming();
154167
}}
155-
title="Double-click to rename"
168+
title={isCreating ? "Creating workspace..." : "Double-click to rename"}
156169
>
157-
{canInterrupt ? (
170+
{canInterrupt || isCreating ? (
158171
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
159172
{displayName}
160173
</Shimmer>
@@ -165,41 +178,47 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
165178
)}
166179

167180
<div className="ml-auto flex items-center gap-1">
168-
<GitStatusIndicator
169-
gitStatus={gitStatus}
170-
workspaceId={workspaceId}
171-
tooltipPosition="right"
172-
isWorking={canInterrupt}
173-
/>
181+
{!isCreating && (
182+
<>
183+
<GitStatusIndicator
184+
gitStatus={gitStatus}
185+
workspaceId={workspaceId}
186+
tooltipPosition="right"
187+
isWorking={canInterrupt}
188+
/>
174189

175-
<TooltipWrapper inline>
176-
<button
177-
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
178-
onClick={(e) => {
179-
e.stopPropagation();
180-
void onRemoveWorkspace(workspaceId, e.currentTarget);
181-
}}
182-
aria-label={`Remove workspace ${displayName}`}
183-
data-workspace-id={workspaceId}
184-
>
185-
×
186-
</button>
187-
<Tooltip className="tooltip" align="right">
188-
Remove workspace
189-
</Tooltip>
190-
</TooltipWrapper>
190+
<TooltipWrapper inline>
191+
<button
192+
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
193+
onClick={(e) => {
194+
e.stopPropagation();
195+
void onRemoveWorkspace(workspaceId, e.currentTarget);
196+
}}
197+
aria-label={`Remove workspace ${displayName}`}
198+
data-workspace-id={workspaceId}
199+
>
200+
×
201+
</button>
202+
<Tooltip className="tooltip" align="right">
203+
Remove workspace
204+
</Tooltip>
205+
</TooltipWrapper>
206+
</>
207+
)}
191208
</div>
192209
</div>
193-
<div className="min-w-0">
194-
{isDeleting ? (
195-
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
196-
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
197-
<span className="min-w-0 truncate">Deleting...</span>
198-
</div>
199-
) : (
200-
<WorkspaceStatusIndicator workspaceId={workspaceId} />
201-
)}
202-
</div>
210+
{!isCreating && (
211+
<div className="min-w-0">
212+
{isDeleting ? (
213+
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
214+
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
215+
<span className="min-w-0 truncate">Deleting...</span>
216+
</div>
217+
) : (
218+
<WorkspaceStatusIndicator workspaceId={workspaceId} />
219+
)}
220+
</div>
221+
)}
203222
</div>
204223
</div>
205224
{renameError && isEditing && (

src/common/types/workspace.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export interface WorkspaceMetadata {
5454

5555
/** Runtime configuration for this workspace (always set, defaults to local on load) */
5656
runtimeConfig: RuntimeConfig;
57+
58+
/**
59+
* Workspace creation status. When 'creating', the workspace is being set up
60+
* (title generation, git operations). Undefined or absent means ready.
61+
* Pending workspaces are ephemeral (not persisted to config).
62+
*/
63+
status?: "creating";
5764
}
5865

5966
/**

src/node/services/agentSession.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { AIService } from "@/node/services/aiService";
88
import type { HistoryService } from "@/node/services/historyService";
99
import type { PartialService } from "@/node/services/partialService";
1010
import type { InitStateManager } from "@/node/services/initStateManager";
11-
import type { WorkspaceMetadata } from "@/common/types/workspace";
11+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
1212
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1313
import type {
1414
WorkspaceChatMessage,
@@ -33,7 +33,7 @@ export interface AgentSessionChatEvent {
3333

3434
export interface AgentSessionMetadataEvent {
3535
workspaceId: string;
36-
metadata: WorkspaceMetadata | null;
36+
metadata: FrontendWorkspaceMetadata | null;
3737
}
3838

3939
interface AgentSessionOptions {
@@ -139,7 +139,7 @@ export class AgentSession {
139139
await this.emitHistoricalEvents(listener);
140140
}
141141

142-
emitMetadata(metadata: WorkspaceMetadata | null): void {
142+
emitMetadata(metadata: FrontendWorkspaceMetadata | null): void {
143143
this.assertNotDisposed("emitMetadata");
144144
this.emitter.emit("metadata-event", {
145145
workspaceId: this.workspaceId,
@@ -244,11 +244,12 @@ export class AgentSession {
244244
: PlatformPaths.basename(normalizedWorkspacePath) || "unknown";
245245
}
246246

247-
const metadata: WorkspaceMetadata = {
247+
const metadata: FrontendWorkspaceMetadata = {
248248
id: this.workspaceId,
249249
name: workspaceName,
250250
projectName: derivedProjectName,
251251
projectPath: derivedProjectPath,
252+
namedWorkspacePath: normalizedWorkspacePath,
252253
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
253254
};
254255

src/node/services/ipcMain.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import type {
2626
import { Ok, Err, type Result } from "@/common/types/result";
2727
import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation";
2828
import type {
29-
WorkspaceMetadata,
3029
FrontendWorkspaceMetadata,
3130
WorkspaceActivitySnapshot,
3231
} from "@/common/types/workspace";
@@ -107,7 +106,7 @@ async function createWorkspaceWithCollisionRetry(
107106
throw new Error("Unexpected: workspace creation loop completed without return");
108107
}
109108

110-
import { generateWorkspaceName } from "./workspaceTitleGenerator";
109+
import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator";
111110
/**
112111
* IpcMain - Manages all IPC handlers and service coordination
113112
*
@@ -305,15 +304,44 @@ export class IpcMain {
305304
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
306305
| Result<void, SendMessageError>
307306
> {
307+
// Generate IDs and placeholder upfront for immediate UI feedback
308+
const workspaceId = this.config.generateStableId();
309+
const placeholderName = generatePlaceholderName(message);
310+
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
311+
const createdAt = new Date().toISOString();
312+
313+
// Prepare runtime config early for pending metadata
314+
// Default to worktree runtime for new workspaces
315+
let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
316+
type: "worktree",
317+
srcBaseDir: this.config.srcDir,
318+
};
319+
320+
// Create session and emit pending metadata IMMEDIATELY
321+
// This allows the sidebar to show the workspace while we do slow operations
322+
const session = this.getOrCreateSession(workspaceId);
323+
session.emitMetadata({
324+
id: workspaceId,
325+
name: placeholderName,
326+
projectName,
327+
projectPath,
328+
namedWorkspacePath: "", // Not yet created
329+
createdAt,
330+
runtimeConfig: finalRuntimeConfig,
331+
status: "creating",
332+
});
333+
308334
try {
309-
// 1. Generate workspace branch name using AI (use same model as message)
335+
// 1. Generate workspace branch name using AI (SLOW - but user sees pending state)
310336
let branchName: string;
311337
{
312338
const isErrLike = (v: unknown): v is { type: string } =>
313339
typeof v === "object" && v !== null && "type" in v;
314340
const nameResult = await generateWorkspaceName(message, options.model, this.aiService);
315341
if (!nameResult.success) {
316342
const err = nameResult.error;
343+
// Clear pending state on error
344+
session.emitMetadata(null);
317345
if (isErrLike(err)) {
318346
return Err(err);
319347
}
@@ -338,15 +366,7 @@ export class IpcMain {
338366
const recommendedTrunk =
339367
options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
340368

341-
// 3. Create workspace
342-
// Default to worktree runtime for new workspaces
343-
let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
344-
type: "worktree",
345-
srcBaseDir: this.config.srcDir,
346-
};
347-
348-
const workspaceId = this.config.generateStableId();
349-
369+
// 3. Resolve runtime paths
350370
let runtime;
351371
try {
352372
// Handle different runtime types
@@ -376,12 +396,12 @@ export class IpcMain {
376396
}
377397
} catch (error) {
378398
const errorMsg = error instanceof Error ? error.message : String(error);
399+
// Clear pending state on error
400+
session.emitMetadata(null);
379401
return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` });
380402
}
381403

382-
const session = this.getOrCreateSession(workspaceId);
383404
this.initStateManager.startInit(workspaceId, projectPath);
384-
385405
const initLogger = this.createInitLogger(workspaceId);
386406

387407
// Create workspace with automatic collision retry
@@ -393,21 +413,20 @@ export class IpcMain {
393413
);
394414

395415
if (!createResult.success || !createResult.workspacePath) {
416+
// Clear pending state on error
417+
session.emitMetadata(null);
396418
return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" });
397419
}
398420

399421
// Use the final branch name (may have suffix if collision occurred)
400422
branchName = finalBranchName;
401423

402-
const projectName =
403-
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
404-
405424
const metadata = {
406425
id: workspaceId,
407426
name: branchName,
408427
projectName,
409428
projectPath,
410-
createdAt: new Date().toISOString(),
429+
createdAt,
411430
};
412431

413432
await this.config.editConfig((config) => {
@@ -429,9 +448,12 @@ export class IpcMain {
429448
const allMetadata = await this.config.getAllWorkspaceMetadata();
430449
const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
431450
if (!completeMetadata) {
451+
// Clear pending state on error
452+
session.emitMetadata(null);
432453
return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" });
433454
}
434455

456+
// Emit final metadata (no status = ready)
435457
session.emitMetadata(completeMetadata);
436458

437459
void runtime
@@ -460,6 +482,8 @@ export class IpcMain {
460482
} catch (error) {
461483
const errorMessage = error instanceof Error ? error.message : String(error);
462484
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
485+
// Clear pending state on error
486+
session.emitMetadata(null);
463487
return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` });
464488
}
465489
}
@@ -1063,11 +1087,12 @@ export class IpcMain {
10631087
}
10641088

10651089
// Initialize workspace metadata
1066-
const metadata: WorkspaceMetadata = {
1090+
const metadata: FrontendWorkspaceMetadata = {
10671091
id: newWorkspaceId,
10681092
name: newName,
10691093
projectName,
10701094
projectPath: foundProjectPath,
1095+
namedWorkspacePath: runtime.getWorkspacePath(foundProjectPath, newName),
10711096
createdAt: new Date().toISOString(),
10721097
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
10731098
};

src/node/services/workspaceTitleGenerator.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,19 @@ function validateBranchName(name: string): string {
5757
.replace(/-+/g, "-")
5858
.substring(0, 50);
5959
}
60+
61+
/**
62+
* Generate a placeholder name from the user's message for immediate display
63+
* while the AI generates the real title. This is git-safe and human-readable.
64+
*/
65+
export function generatePlaceholderName(message: string): string {
66+
// Take first ~40 chars, sanitize for git branch name
67+
const truncated = message.slice(0, 40).trim();
68+
const sanitized = truncated
69+
.toLowerCase()
70+
.replace(/[^a-z0-9]+/g, "-")
71+
.replace(/^-+|-+$/g, "")
72+
.replace(/-+/g, "-")
73+
.substring(0, 30);
74+
return sanitized || "new-workspace";
75+
}

0 commit comments

Comments
 (0)