Skip to content

Commit 6460891

Browse files
authored
🤖 fix: fail-fast workspace title generation with provider errors (#673)
This PR removes the temporary fallback workspace title generation and surfaces provider errors to the user during first-message workspace creation.
1 parent 5f31123 commit 6460891

File tree

8 files changed

+135
-136
lines changed

8 files changed

+135
-136
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,13 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
463463
if (variant === "creation") {
464464
// Creation variant: simple message send + workspace creation
465465
setIsSending(true);
466-
setInput(""); // Clear input immediately (will be restored by parent if creation fails)
467-
await creationState.handleSend(messageText);
466+
const ok = await creationState.handleSend(messageText);
467+
if (ok) {
468+
setInput("");
469+
if (inputRef.current) {
470+
inputRef.current.style.height = "36px";
471+
}
472+
}
468473
setIsSending(false);
469474
return;
470475
}
@@ -891,11 +896,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
891896
data-component="ChatInputSection"
892897
>
893898
<div className="mx-auto w-full max-w-4xl">
894-
{/* Creation error toast */}
895-
{variant === "creation" && creationState?.error && (
896-
<div className="mb-2 rounded border border-red-700 bg-red-900/20 px-3 py-2 text-sm text-red-400">
897-
{creationState.error}
898-
</div>
899+
{/* Creation toast */}
900+
{variant === "creation" && (
901+
<ChatInputToast
902+
toast={creationState.toast}
903+
onDismiss={() => creationState.setToast(null)}
904+
/>
899905
)}
900906

901907
{/* Workspace toast */}

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe("useCreationWorkspace", () => {
245245

246246
expect(sendMessageMock.mock.calls.length).toBe(1);
247247
expect(onWorkspaceCreated.mock.calls.length).toBe(0);
248-
await waitFor(() => expect(getHook().error).toBe("backend exploded"));
248+
await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded"));
249249
await waitFor(() => expect(getHook().isSending).toBe(false));
250250
expect(updatePersistedStateCalls).toEqual([]);
251251
});

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSett
88
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
99
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
1010
import { getModeKey, getProjectScopeId, getThinkingLevelKey } from "@/common/constants/storage";
11-
import { extractErrorMessage } from "./utils";
11+
import type { Toast } from "@/browser/components/ChatInputToast";
12+
import { createErrorToast } from "@/browser/components/ChatInputToasts";
1213

1314
interface UseCreationWorkspaceOptions {
1415
projectPath: string;
@@ -39,10 +40,10 @@ interface UseCreationWorkspaceReturn {
3940
runtimeMode: RuntimeMode;
4041
sshHost: string;
4142
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
42-
error: string | null;
43-
setError: (error: string | null) => void;
43+
toast: Toast | null;
44+
setToast: (toast: Toast | null) => void;
4445
isSending: boolean;
45-
handleSend: (message: string) => Promise<void>;
46+
handleSend: (message: string) => Promise<boolean>;
4647
}
4748

4849
/**
@@ -58,7 +59,7 @@ export function useCreationWorkspace({
5859
}: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn {
5960
const [branches, setBranches] = useState<string[]>([]);
6061
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
61-
const [error, setError] = useState<string | null>(null);
62+
const [toast, setToast] = useState<Toast | null>(null);
6263
const [isSending, setIsSending] = useState(false);
6364

6465
// Centralized draft workspace settings with automatic persistence
@@ -88,11 +89,11 @@ export function useCreationWorkspace({
8889
}, [projectPath]);
8990

9091
const handleSend = useCallback(
91-
async (message: string) => {
92-
if (!message.trim() || isSending) return;
92+
async (message: string): Promise<boolean> => {
93+
if (!message.trim() || isSending) return false;
9394

9495
setIsSending(true);
95-
setError(null);
96+
setToast(null);
9697

9798
try {
9899
// Get runtime config from options
@@ -110,9 +111,9 @@ export function useCreationWorkspace({
110111
});
111112

112113
if (!result.success) {
113-
setError(extractErrorMessage(result.error));
114+
setToast(createErrorToast(result.error));
114115
setIsSending(false);
115-
return;
116+
return false;
116117
}
117118

118119
// Check if this is a workspace creation result (has metadata field)
@@ -121,15 +122,27 @@ export function useCreationWorkspace({
121122
// Settings are already persisted via useDraftWorkspaceSettings
122123
// Notify parent to switch workspace (clears input via parent unmount)
123124
onWorkspaceCreated(result.metadata);
125+
setIsSending(false);
126+
return true;
124127
} else {
125128
// This shouldn't happen for null workspaceId, but handle gracefully
126-
setError("Unexpected response from server");
129+
setToast({
130+
id: Date.now().toString(),
131+
type: "error",
132+
message: "Unexpected response from server",
133+
});
127134
setIsSending(false);
135+
return false;
128136
}
129137
} catch (err) {
130138
const errorMessage = err instanceof Error ? err.message : String(err);
131-
setError(`Failed to create workspace: ${errorMessage}`);
139+
setToast({
140+
id: Date.now().toString(),
141+
type: "error",
142+
message: `Failed to create workspace: ${errorMessage}`,
143+
});
132144
setIsSending(false);
145+
return false;
133146
}
134147
},
135148
[
@@ -149,8 +162,8 @@ export function useCreationWorkspace({
149162
runtimeMode: settings.runtimeMode,
150163
sshHost: settings.sshHost,
151164
setRuntimeOptions,
152-
error,
153-
setError,
165+
toast,
166+
setToast,
154167
isSending,
155168
handleSend,
156169
};

src/common/telemetry/client.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { initTelemetry, trackEvent, isTelemetryInitialized } from "./client";
1212

1313
describe("Telemetry", () => {
1414
describe("in test environment", () => {
15+
beforeAll(() => {
16+
process.env.NODE_ENV = "test";
17+
});
18+
1519
it("should not initialize PostHog", () => {
1620
initTelemetry();
1721
expect(isTelemetryInitialized()).toBe(false);

src/node/services/aiService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export class AIService extends EventEmitter {
251251
* constructor, ensuring automatic parity with Vercel AI SDK - any configuration options
252252
* supported by the provider will work without modification.
253253
*/
254-
private async createModel(
254+
async createModel(
255255
modelString: string,
256256
muxProviderOptions?: MuxProviderOptions
257257
): Promise<Result<LanguageModel, SendMessageError>> {

src/node/services/ipcMain.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type {
2323
ImagePart,
2424
WorkspaceChatMessage,
2525
} from "@/common/types/ipc";
26-
import { Ok, Err } from "@/common/types/result";
26+
import { Ok, Err, type Result } from "@/common/types/result";
2727
import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation";
2828
import type {
2929
WorkspaceMetadata,
@@ -240,11 +240,33 @@ export class IpcMain {
240240
}
241241
): Promise<
242242
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
243-
| { success: false; error: string }
243+
| Result<void, SendMessageError>
244244
> {
245245
try {
246246
// 1. Generate workspace branch name using AI (use same model as message)
247-
const branchName = await generateWorkspaceName(message, options.model, this.config);
247+
let branchName: string;
248+
{
249+
const isErrLike = (v: unknown): v is { type: string } =>
250+
typeof v === "object" && v !== null && "type" in v;
251+
const nameResult = await generateWorkspaceName(message, options.model, this.aiService);
252+
if (!nameResult.success) {
253+
const err = nameResult.error;
254+
if (isErrLike(err)) {
255+
return Err(err);
256+
}
257+
const toSafeString = (v: unknown): string => {
258+
if (v instanceof Error) return v.message;
259+
try {
260+
return JSON.stringify(v);
261+
} catch {
262+
return String(v);
263+
}
264+
};
265+
const msg = toSafeString(err);
266+
return Err({ type: "unknown", raw: `Failed to generate workspace name: ${msg}` });
267+
}
268+
branchName = nameResult.data;
269+
}
248270

249271
log.debug("Generated workspace name", { branchName });
250272

@@ -277,7 +299,7 @@ export class IpcMain {
277299
}
278300
} catch (error) {
279301
const errorMsg = error instanceof Error ? error.message : String(error);
280-
return { success: false, error: errorMsg };
302+
return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` });
281303
}
282304

283305
const session = this.getOrCreateSession(workspaceId);
@@ -294,7 +316,7 @@ export class IpcMain {
294316
});
295317

296318
if (!createResult.success || !createResult.workspacePath) {
297-
return { success: false, error: createResult.error ?? "Failed to create workspace" };
319+
return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" });
298320
}
299321

300322
const projectName =
@@ -327,7 +349,7 @@ export class IpcMain {
327349
const allMetadata = await this.config.getAllWorkspaceMetadata();
328350
const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
329351
if (!completeMetadata) {
330-
return { success: false, error: "Failed to retrieve workspace metadata" };
352+
return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" });
331353
}
332354

333355
session.emitMetadata(completeMetadata);
@@ -358,7 +380,7 @@ export class IpcMain {
358380
} catch (error) {
359381
const errorMessage = error instanceof Error ? error.message : String(error);
360382
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
361-
return { success: false, error: `Failed to create workspace: ${errorMessage}` };
383+
return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` });
362384
}
363385
}
364386

@@ -971,20 +993,35 @@ export class IpcMain {
971993
const messageText = textParts.map((p) => p.text).join(" ");
972994

973995
if (messageText.trim()) {
974-
const branchName = await generateWorkspaceName(
996+
const nameResult = await generateWorkspaceName(
975997
messageText,
976998
"anthropic:claude-sonnet-4-5", // Use reasonable default model
977-
this.config
999+
this.aiService
9781000
);
979-
980-
// Update config with regenerated name
981-
await this.config.updateWorkspaceMetadata(workspaceId, {
982-
name: branchName,
983-
});
984-
985-
// Return updated metadata
986-
metadata.name = branchName;
987-
log.info(`Regenerated workspace name: ${branchName}`);
1001+
if (nameResult.success) {
1002+
const branchName = nameResult.data;
1003+
// Update config with regenerated name
1004+
await this.config.updateWorkspaceMetadata(workspaceId, {
1005+
name: branchName,
1006+
});
1007+
1008+
// Return updated metadata
1009+
metadata.name = branchName;
1010+
log.info(`Regenerated workspace name: ${branchName}`);
1011+
} else {
1012+
log.info(
1013+
`Skipping title regeneration for ${workspaceId}: ${
1014+
(
1015+
nameResult.error as {
1016+
type?: string;
1017+
provider?: string;
1018+
message?: string;
1019+
raw?: string;
1020+
}
1021+
).type ?? "unknown"
1022+
}`
1023+
);
1024+
}
9881025
}
9891026
}
9901027
} catch (error) {

src/node/services/systemMessage.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ describe("buildSystemMessage", () => {
2020
let globalDir: string;
2121
let mockHomedir: Mock<typeof os.homedir>;
2222
let runtime: LocalRuntime;
23+
let originalMuxRoot: string | undefined;
2324

2425
beforeEach(async () => {
26+
// Snapshot any existing MUX_ROOT so we can restore it after the test.
27+
originalMuxRoot = process.env.MUX_ROOT;
28+
2529
// Create temp directory for test
2630
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "systemMessage-test-"));
2731
projectDir = path.join(tempDir, "project");
@@ -35,13 +39,24 @@ describe("buildSystemMessage", () => {
3539
mockHomedir = spyOn(os, "homedir");
3640
mockHomedir.mockReturnValue(tempDir);
3741

42+
// Force mux home to our test .mux directory regardless of host MUX_ROOT.
43+
process.env.MUX_ROOT = globalDir;
44+
3845
// Create a local runtime for tests
3946
runtime = new LocalRuntime(tempDir);
4047
});
4148

4249
afterEach(async () => {
4350
// Clean up temp directory
4451
await fs.rm(tempDir, { recursive: true, force: true });
52+
53+
// Restore environment override
54+
if (originalMuxRoot === undefined) {
55+
delete process.env.MUX_ROOT;
56+
} else {
57+
process.env.MUX_ROOT = originalMuxRoot;
58+
}
59+
4560
// Restore the original homedir
4661
mockHomedir?.mockRestore();
4762
});

0 commit comments

Comments
 (0)