Skip to content

Commit fa2df24

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 d086f87 commit fa2df24

File tree

5 files changed

+127
-58
lines changed

5 files changed

+127
-58
lines changed

src/browser/components/WorkspaceListItem.tsx

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4141
onToggleUnread,
4242
}) => {
4343
// Destructure metadata for convenience
44-
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
44+
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
45+
const isCreating = status === "creating";
4546
const gitStatus = useGitStatus(workspaceId);
4647

4748
// Get rename context
@@ -103,18 +104,23 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
103104
<React.Fragment>
104105
<div
105106
className={cn(
106-
"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",
107-
isSelected && "bg-hover border-l-blue-400"
107+
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
108+
isCreating
109+
? "cursor-default opacity-70"
110+
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
111+
isSelected && !isCreating && "bg-hover border-l-blue-400"
108112
)}
109-
onClick={() =>
113+
onClick={() => {
114+
if (isCreating) return; // Disable click while creating
110115
onSelectWorkspace({
111116
projectPath,
112117
projectName,
113118
namedWorkspacePath,
114119
workspaceId,
115-
})
116-
}
120+
});
121+
}}
117122
onKeyDown={(e) => {
123+
if (isCreating) return; // Disable keyboard while creating
118124
if (e.key === "Enter" || e.key === " ") {
119125
e.preventDefault();
120126
onSelectWorkspace({
@@ -126,9 +132,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
126132
}
127133
}}
128134
role="button"
129-
tabIndex={0}
135+
tabIndex={isCreating ? -1 : 0}
130136
aria-current={isSelected ? "true" : undefined}
131-
aria-label={`Select workspace ${displayName}`}
137+
aria-label={
138+
isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}`
139+
}
140+
aria-disabled={isCreating}
132141
data-workspace-path={namedWorkspacePath}
133142
data-workspace-id={workspaceId}
134143
>
@@ -156,14 +165,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
156165
/>
157166
) : (
158167
<span
159-
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"
168+
className={cn(
169+
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
170+
!isCreating && "cursor-pointer hover:bg-white/5"
171+
)}
160172
onDoubleClick={(e) => {
173+
if (isCreating) return; // Disable rename while creating
161174
e.stopPropagation();
162175
startRenaming();
163176
}}
164-
title="Double-click to rename"
177+
title={isCreating ? "Creating workspace..." : "Double-click to rename"}
165178
>
166-
{canInterrupt ? (
179+
{canInterrupt || isCreating ? (
167180
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
168181
{displayName}
169182
</Shimmer>
@@ -174,33 +187,39 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
174187
)}
175188

176189
<div className="ml-auto flex items-center gap-1">
177-
<GitStatusIndicator
178-
gitStatus={gitStatus}
179-
workspaceId={workspaceId}
180-
tooltipPosition="right"
181-
/>
182-
183-
<TooltipWrapper inline>
184-
<button
185-
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"
186-
onClick={(e) => {
187-
e.stopPropagation();
188-
void onRemoveWorkspace(workspaceId, e.currentTarget);
189-
}}
190-
aria-label={`Remove workspace ${displayName}`}
191-
data-workspace-id={workspaceId}
192-
>
193-
×
194-
</button>
195-
<Tooltip className="tooltip" align="right">
196-
Remove workspace
197-
</Tooltip>
198-
</TooltipWrapper>
190+
{!isCreating && (
191+
<>
192+
<GitStatusIndicator
193+
gitStatus={gitStatus}
194+
workspaceId={workspaceId}
195+
tooltipPosition="right"
196+
/>
197+
198+
<TooltipWrapper inline>
199+
<button
200+
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"
201+
onClick={(e) => {
202+
e.stopPropagation();
203+
void onRemoveWorkspace(workspaceId, e.currentTarget);
204+
}}
205+
aria-label={`Remove workspace ${displayName}`}
206+
data-workspace-id={workspaceId}
207+
>
208+
×
209+
</button>
210+
<Tooltip className="tooltip" align="right">
211+
Remove workspace
212+
</Tooltip>
213+
</TooltipWrapper>
214+
</>
215+
)}
199216
</div>
200217
</div>
201-
<div className="min-w-0">
202-
<WorkspaceStatusIndicator workspaceId={workspaceId} />
203-
</div>
218+
{!isCreating && (
219+
<div className="min-w-0">
220+
<WorkspaceStatusIndicator workspaceId={workspaceId} />
221+
</div>
222+
)}
204223
</div>
205224
</div>
206225
{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,
@@ -34,7 +34,7 @@ export interface AgentSessionChatEvent {
3434

3535
export interface AgentSessionMetadataEvent {
3636
workspaceId: string;
37-
metadata: WorkspaceMetadata | null;
37+
metadata: FrontendWorkspaceMetadata | null;
3838
}
3939

4040
interface AgentSessionOptions {
@@ -136,7 +136,7 @@ export class AgentSession {
136136
await this.emitHistoricalEvents(listener);
137137
}
138138

139-
emitMetadata(metadata: WorkspaceMetadata | null): void {
139+
emitMetadata(metadata: FrontendWorkspaceMetadata | null): void {
140140
this.assertNotDisposed("emitMetadata");
141141
this.emitter.emit("metadata-event", {
142142
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 & 18 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";
@@ -44,7 +43,7 @@ import { PTYService } from "@/node/services/ptyService";
4443
import type { TerminalWindowManager } from "@/desktop/terminalWindowManager";
4544
import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal";
4645
import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService";
47-
import { generateWorkspaceName } from "./workspaceTitleGenerator";
46+
import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator";
4847
/**
4948
* IpcMain - Manages all IPC handlers and service coordination
5049
*
@@ -242,15 +241,43 @@ export class IpcMain {
242241
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
243242
| Result<void, SendMessageError>
244243
> {
244+
// Generate IDs and placeholder upfront for immediate UI feedback
245+
const workspaceId = this.config.generateStableId();
246+
const placeholderName = generatePlaceholderName(message);
247+
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
248+
const createdAt = new Date().toISOString();
249+
250+
// Prepare runtime config early for pending metadata
251+
const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
252+
type: "local",
253+
srcBaseDir: this.config.srcDir,
254+
};
255+
256+
// Create session and emit pending metadata IMMEDIATELY
257+
// This allows the sidebar to show the workspace while we do slow operations
258+
const session = this.getOrCreateSession(workspaceId);
259+
session.emitMetadata({
260+
id: workspaceId,
261+
name: placeholderName,
262+
projectName,
263+
projectPath,
264+
namedWorkspacePath: "", // Not yet created
265+
createdAt,
266+
runtimeConfig: finalRuntimeConfig,
267+
status: "creating",
268+
});
269+
245270
try {
246-
// 1. Generate workspace branch name using AI (use same model as message)
271+
// 1. Generate workspace branch name using AI (SLOW - but user sees pending state)
247272
let branchName: string;
248273
{
249274
const isErrLike = (v: unknown): v is { type: string } =>
250275
typeof v === "object" && v !== null && "type" in v;
251276
const nameResult = await generateWorkspaceName(message, options.model, this.aiService);
252277
if (!nameResult.success) {
253278
const err = nameResult.error;
279+
// Clear pending state on error
280+
session.emitMetadata(null);
254281
if (isErrLike(err)) {
255282
return Err(err);
256283
}
@@ -275,14 +302,7 @@ export class IpcMain {
275302
const recommendedTrunk =
276303
options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
277304

278-
// 3. Create workspace
279-
const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
280-
type: "local",
281-
srcBaseDir: this.config.srcDir,
282-
};
283-
284-
const workspaceId = this.config.generateStableId();
285-
305+
// 3. Resolve runtime paths
286306
let runtime;
287307
let resolvedSrcBaseDir: string;
288308
try {
@@ -299,14 +319,15 @@ export class IpcMain {
299319
}
300320
} catch (error) {
301321
const errorMsg = error instanceof Error ? error.message : String(error);
322+
// Clear pending state on error
323+
session.emitMetadata(null);
302324
return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` });
303325
}
304326

305-
const session = this.getOrCreateSession(workspaceId);
306327
this.initStateManager.startInit(workspaceId, projectPath);
307-
308328
const initLogger = this.createInitLogger(workspaceId);
309329

330+
// 4. Create workspace with final name
310331
const createResult = await runtime.createWorkspace({
311332
projectPath,
312333
branchName,
@@ -316,18 +337,17 @@ export class IpcMain {
316337
});
317338

318339
if (!createResult.success || !createResult.workspacePath) {
340+
// Clear pending state on error
341+
session.emitMetadata(null);
319342
return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" });
320343
}
321344

322-
const projectName =
323-
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
324-
325345
const metadata = {
326346
id: workspaceId,
327347
name: branchName,
328348
projectName,
329349
projectPath,
330-
createdAt: new Date().toISOString(),
350+
createdAt,
331351
};
332352

333353
await this.config.editConfig((config) => {
@@ -349,9 +369,12 @@ export class IpcMain {
349369
const allMetadata = await this.config.getAllWorkspaceMetadata();
350370
const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
351371
if (!completeMetadata) {
372+
// Clear pending state on error
373+
session.emitMetadata(null);
352374
return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" });
353375
}
354376

377+
// Emit final metadata (no status = ready)
355378
session.emitMetadata(completeMetadata);
356379

357380
void runtime
@@ -380,6 +403,8 @@ export class IpcMain {
380403
} catch (error) {
381404
const errorMessage = error instanceof Error ? error.message : String(error);
382405
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
406+
// Clear pending state on error
407+
session.emitMetadata(null);
383408
return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` });
384409
}
385410
}
@@ -933,11 +958,12 @@ export class IpcMain {
933958
}
934959

935960
// Initialize workspace metadata
936-
const metadata: WorkspaceMetadata = {
961+
const metadata: FrontendWorkspaceMetadata = {
937962
id: newWorkspaceId,
938963
name: newName,
939964
projectName,
940965
projectPath: foundProjectPath,
966+
namedWorkspacePath: runtime.getWorkspacePath(foundProjectPath, newName),
941967
createdAt: new Date().toISOString(),
942968
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
943969
};

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)