Skip to content

Commit cde3826

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 372f0d9 commit cde3826

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
@@ -43,7 +43,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4343
onToggleUnread,
4444
}) => {
4545
// Destructure metadata for convenience
46-
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
46+
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
47+
const isCreating = status === "creating";
4748
const gitStatus = useGitStatus(workspaceId);
4849

4950
// Get rename context
@@ -105,19 +106,24 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
105106
<React.Fragment>
106107
<div
107108
className={cn(
108-
"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",
109-
isSelected && "bg-hover border-l-blue-400",
110-
isDeleting && "opacity-50 pointer-events-none"
109+
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
110+
isCreating || isDeleting
111+
? "cursor-default opacity-70"
112+
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
113+
isSelected && !isCreating && "bg-hover border-l-blue-400",
114+
isDeleting && "pointer-events-none"
111115
)}
112-
onClick={() =>
116+
onClick={() => {
117+
if (isCreating) return; // Disable click while creating
113118
onSelectWorkspace({
114119
projectPath,
115120
projectName,
116121
namedWorkspacePath,
117122
workspaceId,
118-
})
119-
}
123+
});
124+
}}
120125
onKeyDown={(e) => {
126+
if (isCreating) return; // Disable keyboard while creating
121127
if (e.key === "Enter" || e.key === " ") {
122128
e.preventDefault();
123129
onSelectWorkspace({
@@ -129,9 +135,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
129135
}
130136
}}
131137
role="button"
132-
tabIndex={0}
138+
tabIndex={isCreating ? -1 : 0}
133139
aria-current={isSelected ? "true" : undefined}
134-
aria-label={`Select workspace ${displayName}`}
140+
aria-label={
141+
isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}`
142+
}
143+
aria-disabled={isCreating}
135144
data-workspace-path={namedWorkspacePath}
136145
data-workspace-id={workspaceId}
137146
>
@@ -159,14 +168,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
159168
/>
160169
) : (
161170
<span
162-
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"
171+
className={cn(
172+
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
173+
!isCreating && "cursor-pointer hover:bg-white/5"
174+
)}
163175
onDoubleClick={(e) => {
176+
if (isCreating) return; // Disable rename while creating
164177
e.stopPropagation();
165178
startRenaming();
166179
}}
167-
title="Double-click to rename"
180+
title={isCreating ? "Creating workspace..." : "Double-click to rename"}
168181
>
169-
{canInterrupt ? (
182+
{canInterrupt || isCreating ? (
170183
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
171184
{displayName}
172185
</Shimmer>
@@ -177,40 +190,46 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
177190
)}
178191

179192
<div className="ml-auto flex items-center gap-1">
180-
<GitStatusIndicator
181-
gitStatus={gitStatus}
182-
workspaceId={workspaceId}
183-
tooltipPosition="right"
184-
/>
185-
186-
<TooltipWrapper inline>
187-
<button
188-
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"
189-
onClick={(e) => {
190-
e.stopPropagation();
191-
void onRemoveWorkspace(workspaceId, e.currentTarget);
192-
}}
193-
aria-label={`Remove workspace ${displayName}`}
194-
data-workspace-id={workspaceId}
195-
>
196-
×
197-
</button>
198-
<Tooltip className="tooltip" align="right">
199-
Remove workspace
200-
</Tooltip>
201-
</TooltipWrapper>
193+
{!isCreating && (
194+
<>
195+
<GitStatusIndicator
196+
gitStatus={gitStatus}
197+
workspaceId={workspaceId}
198+
tooltipPosition="right"
199+
/>
200+
201+
<TooltipWrapper inline>
202+
<button
203+
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"
204+
onClick={(e) => {
205+
e.stopPropagation();
206+
void onRemoveWorkspace(workspaceId, e.currentTarget);
207+
}}
208+
aria-label={`Remove workspace ${displayName}`}
209+
data-workspace-id={workspaceId}
210+
>
211+
×
212+
</button>
213+
<Tooltip className="tooltip" align="right">
214+
Remove workspace
215+
</Tooltip>
216+
</TooltipWrapper>
217+
</>
218+
)}
202219
</div>
203220
</div>
204-
<div className="min-w-0">
205-
{isDeleting ? (
206-
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
207-
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
208-
<span className="min-w-0 truncate">Deleting...</span>
209-
</div>
210-
) : (
211-
<WorkspaceStatusIndicator workspaceId={workspaceId} />
212-
)}
213-
</div>
221+
{!isCreating && (
222+
<div className="min-w-0">
223+
{isDeleting ? (
224+
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
225+
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
226+
<span className="min-w-0 truncate">Deleting...</span>
227+
</div>
228+
) : (
229+
<WorkspaceStatusIndicator workspaceId={workspaceId} />
230+
)}
231+
</div>
232+
)}
214233
</div>
215234
</div>
216235
{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 {
@@ -135,7 +135,7 @@ export class AgentSession {
135135
await this.emitHistoricalEvents(listener);
136136
}
137137

138-
emitMetadata(metadata: WorkspaceMetadata | null): void {
138+
emitMetadata(metadata: FrontendWorkspaceMetadata | null): void {
139139
this.assertNotDisposed("emitMetadata");
140140
this.emitter.emit("metadata-event", {
141141
workspaceId: this.workspaceId,
@@ -240,11 +240,12 @@ export class AgentSession {
240240
: PlatformPaths.basename(normalizedWorkspacePath) || "unknown";
241241
}
242242

243-
const metadata: WorkspaceMetadata = {
243+
const metadata: FrontendWorkspaceMetadata = {
244244
id: this.workspaceId,
245245
name: workspaceName,
246246
projectName: derivedProjectName,
247247
projectPath: derivedProjectPath,
248+
namedWorkspacePath: normalizedWorkspacePath,
248249
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
249250
};
250251

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";
@@ -106,7 +105,7 @@ async function createWorkspaceWithCollisionRetry(
106105
throw new Error("Unexpected: workspace creation loop completed without return");
107106
}
108107

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

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

381-
const session = this.getOrCreateSession(workspaceId);
382403
this.initStateManager.startInit(workspaceId, projectPath);
383-
384404
const initLogger = this.createInitLogger(workspaceId);
385405

386406
// Create workspace with automatic collision retry
@@ -392,21 +412,20 @@ export class IpcMain {
392412
);
393413

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

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

401-
const projectName =
402-
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
403-
404423
const metadata = {
405424
id: workspaceId,
406425
name: branchName,
407426
projectName,
408427
projectPath,
409-
createdAt: new Date().toISOString(),
428+
createdAt,
410429
};
411430

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

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

436458
void runtime
@@ -459,6 +481,8 @@ export class IpcMain {
459481
} catch (error) {
460482
const errorMessage = error instanceof Error ? error.message : String(error);
461483
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
484+
// Clear pending state on error
485+
session.emitMetadata(null);
462486
return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` });
463487
}
464488
}
@@ -1023,11 +1047,12 @@ export class IpcMain {
10231047
}
10241048

10251049
// Initialize workspace metadata
1026-
const metadata: WorkspaceMetadata = {
1050+
const metadata: FrontendWorkspaceMetadata = {
10271051
id: newWorkspaceId,
10281052
name: newName,
10291053
projectName,
10301054
projectPath: foundProjectPath,
1055+
namedWorkspacePath: runtime.getWorkspacePath(foundProjectPath, newName),
10311056
createdAt: new Date().toISOString(),
10321057
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
10331058
};

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)