Skip to content

Commit 4eef087

Browse files
authored
🤖 perf: async workspace name generation for faster UX (#877)
Previously, workspace creation blocked for up to 10s while waiting for the AI to generate a workspace name. This caused a poor UX where users had to wait before seeing any result. ## Changes - Use `generatePlaceholderName()` to create workspace immediately with a temporary name derived from the user's message (e.g., 'add-user-auth') - Generate AI name asynchronously in the background via `generateAndApplyAIName()` - If AI generation succeeds and differs from placeholder, rename workspace using existing `rename()` method - If AI generation or rename fails, gracefully keep the placeholder name ## UX Improvement | Before | After | |--------|-------| | User waits 5-10s for AI name generation | Workspace appears instantly | | Loading spinner while blocked | AI name applied seamlessly when ready | | Network/API errors block creation | Errors handled gracefully, placeholder name retained | The existing `generatePlaceholderName()` function was already implemented but unused - this change puts it to work. _Generated with `mux`_
1 parent e729c9a commit 4eef087

File tree

13 files changed

+639
-79
lines changed

13 files changed

+639
-79
lines changed

src/browser/App.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,9 @@ function AppInner() {
576576
currentMetadata?.name ??
577577
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
578578
selectedWorkspace.workspaceId;
579+
// Use live metadata path (updates on rename) with fallback to initial path
580+
const workspacePath =
581+
currentMetadata?.namedWorkspacePath ?? selectedWorkspace.namedWorkspacePath ?? "";
579582
return (
580583
<ErrorBoundary
581584
workspaceInfo={`${selectedWorkspace.projectName}/${workspaceName}`}
@@ -586,9 +589,10 @@ function AppInner() {
586589
projectPath={selectedWorkspace.projectPath}
587590
projectName={selectedWorkspace.projectName}
588591
branch={workspaceName}
589-
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
592+
namedWorkspacePath={workspacePath}
590593
runtimeConfig={currentMetadata?.runtimeConfig}
591594
incompatibleRuntime={currentMetadata?.incompatibleRuntime}
595+
status={currentMetadata?.status}
592596
/>
593597
</ErrorBoundary>
594598
);
@@ -609,7 +613,13 @@ function AppInner() {
609613
onProviderConfig={handleProviderConfig}
610614
onReady={handleCreationChatReady}
611615
onWorkspaceCreated={(metadata) => {
612-
// Add to workspace metadata map
616+
// IMPORTANT: Add workspace to store FIRST (synchronous) to ensure
617+
// the store knows about it before React processes the state updates.
618+
// This prevents race conditions where the UI tries to access the
619+
// workspace before the store has created its aggregator.
620+
workspaceStore.addWorkspace(metadata);
621+
622+
// Add to workspace metadata map (triggers React state update)
613623
setWorkspaceMetadata((prev) =>
614624
new Map(prev).set(metadata.id, metadata)
615625
);

src/browser/components/AIView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ interface AIViewProps {
5555
className?: string;
5656
/** If set, workspace is incompatible (from newer mux version) and this error should be displayed */
5757
incompatibleRuntime?: string;
58+
/** If 'creating', workspace is still being set up (git operations in progress) */
59+
status?: "creating";
5860
}
5961

6062
const AIViewInner: React.FC<AIViewProps> = ({
@@ -65,6 +67,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
6567
namedWorkspacePath,
6668
runtimeConfig,
6769
className,
70+
status,
6871
}) => {
6972
const { api } = useAPI();
7073
const chatAreaRef = useRef<HTMLDivElement>(null);
@@ -637,6 +640,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
637640
onStartResize={isReviewTabActive ? startResize : undefined} // Pass resize handler when Review active
638641
isResizing={isResizing} // Pass resizing state
639642
onReviewNote={handleReviewNote} // Pass review note handler to append to chat
643+
isCreating={status === "creating"} // Workspace still being set up
640644
/>
641645
</div>
642646
);

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer
99
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
1010
import {
1111
getInputKey,
12+
getModelKey,
1213
getModeKey,
1314
getPendingScopeId,
1415
getProjectScopeId,
@@ -27,6 +28,13 @@ interface UseCreationWorkspaceOptions {
2728
function syncCreationPreferences(projectPath: string, workspaceId: string): void {
2829
const projectScopeId = getProjectScopeId(projectPath);
2930

31+
// Sync model from project scope to workspace scope
32+
// This ensures the model used for creation is persisted for future resumes
33+
const projectModel = readPersistedState<string | null>(getModelKey(projectScopeId), null);
34+
if (projectModel) {
35+
updatePersistedState(getModelKey(workspaceId), projectModel);
36+
}
37+
3038
const projectMode = readPersistedState<UIMode | null>(getModeKey(projectScopeId), null);
3139
if (projectMode) {
3240
updatePersistedState(getModeKey(workspaceId), projectMode);

src/browser/components/RightSidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ interface RightSidebarProps {
8484
isResizing?: boolean;
8585
/** Callback when user adds a review note from Code Review tab */
8686
onReviewNote?: (note: string) => void;
87+
/** Workspace is still being created (git operations in progress) */
88+
isCreating?: boolean;
8789
}
8890

8991
const RightSidebarComponent: React.FC<RightSidebarProps> = ({
@@ -95,6 +97,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
9597
onStartResize,
9698
isResizing = false,
9799
onReviewNote,
100+
isCreating = false,
98101
}) => {
99102
// Global tab preference (not per-workspace)
100103
const [selectedTab, setSelectedTab] = usePersistedState<TabType>("right-sidebar-tab", "costs");
@@ -298,6 +301,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
298301
workspacePath={workspacePath}
299302
onReviewNote={onReviewNote}
300303
focusTrigger={focusTrigger}
304+
isCreating={isCreating}
301305
/>
302306
</div>
303307
)}

src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ interface ReviewPanelProps {
4545
onReviewNote?: (note: string) => void;
4646
/** Trigger to focus panel (increment to trigger) */
4747
focusTrigger?: number;
48+
/** Workspace is still being created (git operations in progress) */
49+
isCreating?: boolean;
4850
}
4951

5052
interface ReviewSearchState {
@@ -120,6 +122,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
120122
workspacePath,
121123
onReviewNote,
122124
focusTrigger,
125+
isCreating = false,
123126
}) => {
124127
const { api } = useAPI();
125128
const panelRef = useRef<HTMLDivElement>(null);
@@ -191,7 +194,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
191194

192195
// Load file tree - when workspace, diffBase, or refreshTrigger changes
193196
useEffect(() => {
194-
if (!api) return;
197+
// Skip data loading while workspace is being created
198+
if (!api || isCreating) return;
195199
let cancelled = false;
196200

197201
const loadFileTree = async () => {
@@ -239,11 +243,13 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
239243
filters.diffBase,
240244
filters.includeUncommitted,
241245
refreshTrigger,
246+
isCreating,
242247
]);
243248

244249
// Load diff hunks - when workspace, diffBase, selected path, or refreshTrigger changes
245250
useEffect(() => {
246-
if (!api) return;
251+
// Skip data loading while workspace is being created
252+
if (!api || isCreating) return;
247253
let cancelled = false;
248254

249255
const loadDiff = async () => {
@@ -333,6 +339,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
333339
filters.includeUncommitted,
334340
selectedFilePath,
335341
refreshTrigger,
342+
isCreating,
336343
]);
337344

338345
// Persist diffBase when it changes
@@ -618,6 +625,17 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
618625
return () => window.removeEventListener("keydown", handleKeyDown);
619626
}, []);
620627

628+
// Show loading state while workspace is being created
629+
if (isCreating) {
630+
return (
631+
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
632+
<div className="mb-4 text-2xl"></div>
633+
<p className="text-secondary text-sm">Setting up workspace...</p>
634+
<p className="text-secondary mt-1 text-xs">Review will be available once ready</p>
635+
</div>
636+
);
637+
}
638+
621639
return (
622640
<div
623641
ref={panelRef}

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,28 @@ export class StreamingMessageAggregator {
564564
// Clean up stream-scoped state (active stream tracking, TODOs)
565565
this.cleanupStreamState(data.messageId);
566566
this.invalidateCache();
567+
} else {
568+
// Pre-stream error (e.g., API key not configured before streaming starts)
569+
// Create a synthetic error message since there's no active stream to attach to
570+
// Get the highest historySequence from existing messages so this appears at the end
571+
const maxSequence = Math.max(
572+
0,
573+
...Array.from(this.messages.values()).map((m) => m.metadata?.historySequence ?? 0)
574+
);
575+
const errorMessage: MuxMessage = {
576+
id: data.messageId,
577+
role: "assistant",
578+
parts: [],
579+
metadata: {
580+
partial: true,
581+
error: data.error,
582+
errorType: data.errorType,
583+
timestamp: Date.now(),
584+
historySequence: maxSequence + 1,
585+
},
586+
};
587+
this.messages.set(data.messageId, errorMessage);
588+
this.invalidateCache();
567589
}
568590
}
569591

src/node/runtime/WorktreeRuntime.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,25 @@ export class WorktreeRuntime extends LocalBaseRuntime {
177177
const newPath = this.getWorkspacePath(projectPath, newName);
178178

179179
try {
180-
// Use git worktree move to rename the worktree directory
181-
// This updates git's internal worktree metadata correctly
182-
using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
183-
await proc.result;
180+
// Move the worktree directory (updates git's internal worktree metadata)
181+
using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
182+
await moveProc.result;
183+
184+
// Rename the git branch to match the new workspace name
185+
// In mux, branch name and workspace name are always kept in sync.
186+
// Run from the new worktree path since that's where the branch is checked out.
187+
// Best-effort: ignore errors (e.g., branch might have a different name in test scenarios).
188+
try {
189+
using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`);
190+
await branchProc.result;
191+
} catch {
192+
// Branch rename failed - this is fine, the directory was still moved
193+
// This can happen if the branch name doesn't match the old directory name
194+
}
184195

185196
return { success: true, oldPath, newPath };
186197
} catch (error) {
187-
return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` };
198+
return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` };
188199
}
189200
}
190201

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { formatSendMessageError, createUnknownSendMessageError } from "./sendMessageError";
3+
4+
describe("formatSendMessageError", () => {
5+
test("formats api_key_not_found with authentication errorType", () => {
6+
const result = formatSendMessageError({
7+
type: "api_key_not_found",
8+
provider: "anthropic",
9+
});
10+
11+
expect(result.errorType).toBe("authentication");
12+
expect(result.message).toContain("anthropic");
13+
expect(result.message).toContain("API key");
14+
});
15+
16+
test("formats provider_not_supported", () => {
17+
const result = formatSendMessageError({
18+
type: "provider_not_supported",
19+
provider: "unsupported-provider",
20+
});
21+
22+
expect(result.errorType).toBe("unknown");
23+
expect(result.message).toContain("unsupported-provider");
24+
expect(result.message).toContain("not supported");
25+
});
26+
27+
test("formats invalid_model_string with model_not_found errorType", () => {
28+
const result = formatSendMessageError({
29+
type: "invalid_model_string",
30+
message: "Invalid model format: foo",
31+
});
32+
33+
expect(result.errorType).toBe("model_not_found");
34+
expect(result.message).toBe("Invalid model format: foo");
35+
});
36+
37+
test("formats incompatible_workspace", () => {
38+
const result = formatSendMessageError({
39+
type: "incompatible_workspace",
40+
message: "Workspace is incompatible",
41+
});
42+
43+
expect(result.errorType).toBe("unknown");
44+
expect(result.message).toBe("Workspace is incompatible");
45+
});
46+
47+
test("formats unknown errors", () => {
48+
const result = formatSendMessageError({
49+
type: "unknown",
50+
raw: "Something went wrong",
51+
});
52+
53+
expect(result.errorType).toBe("unknown");
54+
expect(result.message).toBe("Something went wrong");
55+
});
56+
});
57+
58+
describe("createUnknownSendMessageError", () => {
59+
test("creates unknown error with trimmed message", () => {
60+
const result = createUnknownSendMessageError(" test error ");
61+
62+
expect(result).toEqual({ type: "unknown", raw: "test error" });
63+
});
64+
65+
test("throws on empty message", () => {
66+
expect(() => createUnknownSendMessageError("")).toThrow();
67+
expect(() => createUnknownSendMessageError(" ")).toThrow();
68+
});
69+
});

src/node/services/utils/sendMessageError.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "@/common/utils/assert";
2-
import type { SendMessageError } from "@/common/types/errors";
2+
import type { SendMessageError, StreamErrorType } from "@/common/types/errors";
33

44
/**
55
* Helper to wrap arbitrary errors into SendMessageError structures.
@@ -15,3 +15,39 @@ export const createUnknownSendMessageError = (raw: string): SendMessageError =>
1515
raw: trimmed,
1616
};
1717
};
18+
19+
/**
20+
* Formats a SendMessageError into a user-visible message and StreamErrorType
21+
* for display in the chat UI as a stream-error event.
22+
*/
23+
export const formatSendMessageError = (
24+
error: SendMessageError
25+
): { message: string; errorType: StreamErrorType } => {
26+
switch (error.type) {
27+
case "api_key_not_found":
28+
return {
29+
message: `API key not configured for ${error.provider}. Please add your API key in settings.`,
30+
errorType: "authentication",
31+
};
32+
case "provider_not_supported":
33+
return {
34+
message: `Provider "${error.provider}" is not supported.`,
35+
errorType: "unknown",
36+
};
37+
case "invalid_model_string":
38+
return {
39+
message: error.message,
40+
errorType: "model_not_found",
41+
};
42+
case "incompatible_workspace":
43+
return {
44+
message: error.message,
45+
errorType: "unknown",
46+
};
47+
case "unknown":
48+
return {
49+
message: error.raw,
50+
errorType: "unknown",
51+
};
52+
}
53+
};

0 commit comments

Comments
 (0)