From 48532f04a032aa0d843bb16c3eb73836fbd4caf9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 12:41:37 -0600 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=A4=96=20feat:=20auto-generate=20wo?= =?UTF-8?q?rkspace=20names=20before=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add frontend name generation with debounced AI calls - New useWorkspaceName hook with auto-generate checkbox control - Frontend creates workspace with generated name, then sends message - Remove legacy backend async name generation flow - Simplify sendMessage to require workspaceId (no more null for creation) - Remove createForFirstMessage, generateAndApplyAIName, placeholder names - Backend uses preferred small models (Haiku, Codex-mini) for generation _Generated with mux_ --- .../ChatInput/CreationCenterContent.tsx | 16 +- .../components/ChatInput/CreationControls.tsx | 143 ++++-- src/browser/components/ChatInput/index.tsx | 12 +- .../ChatInput/useCreationWorkspace.test.tsx | 179 ++++--- .../ChatInput/useCreationWorkspace.ts | 111 +++-- src/browser/hooks/useWorkspaceName.ts | 238 ++++++++++ src/cli/cli.test.ts | 2 + src/cli/server.test.ts | 2 + src/cli/server.ts | 2 + src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 29 +- src/common/types/global.d.ts | 1 + src/desktop/main.ts | 2 + src/node/orpc/context.ts | 4 + src/node/orpc/router.ts | 44 +- src/node/services/serviceContainer.ts | 4 +- src/node/services/workspaceService.ts | 438 +----------------- .../services/workspaceTitleGenerator.test.ts | 74 +-- src/node/services/workspaceTitleGenerator.ts | 36 +- tests/integration/setup.ts | 2 + 20 files changed, 702 insertions(+), 638 deletions(-) create mode 100644 src/browser/hooks/useWorkspaceName.ts diff --git a/src/browser/components/ChatInput/CreationCenterContent.tsx b/src/browser/components/ChatInput/CreationCenterContent.tsx index 3db11368d2..fc02912dd1 100644 --- a/src/browser/components/ChatInput/CreationCenterContent.tsx +++ b/src/browser/components/ChatInput/CreationCenterContent.tsx @@ -3,29 +3,23 @@ import React from "react"; interface CreationCenterContentProps { projectName: string; isSending: boolean; - inputPreview?: string; + workspaceName?: string; } /** * Center content displayed during workspace creation - * Shows either a loading state with the user's prompt or welcome message + * Shows either a loading state with the workspace name or welcome message */ export function CreationCenterContent(props: CreationCenterContentProps) { - // Truncate long prompts for preview display - const truncatedPreview = - props.inputPreview && props.inputPreview.length > 150 - ? props.inputPreview.slice(0, 150) + "..." - : props.inputPreview; - return (
{props.isSending ? (

Creating workspace

- {truncatedPreview && ( + {props.workspaceName && (

- Generating name for “{truncatedPreview}” + Creating {props.workspaceName}

)}
@@ -34,7 +28,7 @@ export function CreationCenterContent(props: CreationCenterContentProps) {

{props.projectName}

Describe what you want to build. A new workspace will be created with an automatically - generated branch name. Configure runtime and model options below. + generated name. Configure runtime and model options below.

)} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index dd01b02022..64f4e48927 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,7 +1,9 @@ -import React from "react"; +import React, { useCallback } from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; import { Select } from "../Select"; import { RuntimeIconSelector } from "../RuntimeIconSelector"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/common/lib/utils"; interface CreationControlsProps { branches: string[]; @@ -17,61 +19,126 @@ interface CreationControlsProps { /** Called when user changes SSH host */ onSshHostChange: (host: string) => void; disabled: boolean; + /** Workspace name state */ + workspaceName: string; + /** Whether name is being generated */ + isGeneratingName: boolean; + /** Whether auto-generation is enabled */ + autoGenerateName: boolean; + /** Name generation error */ + nameError: string | null; + /** Called when auto-generate checkbox changes */ + onAutoGenerateChange: (enabled: boolean) => void; + /** Called when user types in the name field */ + onNameChange: (name: string) => void; } /** * Additional controls shown only during workspace creation * - Trunk branch selector (which branch to fork from) - hidden for Local runtime * - Runtime mode (Local, Worktree, SSH) + * - Workspace name (auto-generated with manual override) */ export function CreationControls(props: CreationControlsProps) { // Local runtime doesn't need a trunk branch selector (uses project dir as-is) const showTrunkBranchSelector = props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL; + const { onNameChange } = props; + const handleNameChange = useCallback( + (e: React.ChangeEvent) => { + onNameChange(e.target.value); + }, + [onNameChange] + ); + return ( -
- {/* Runtime Selector - icon-based with tooltips */} - +
+ {/* First row: Runtime, Branch, SSH */} +
+ {/* Runtime Selector - icon-based with tooltips */} + + + {/* Trunk Branch Selector - hidden for Local runtime */} + {showTrunkBranchSelector && ( +
+ + props.onSshHostChange(e.target.value)} + placeholder="user@host" disabled={props.disabled} - className="max-w-[120px]" + className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50" /> + )} +
+ + {/* Second row: Workspace name with auto-generate checkbox */} +
+ +
+ + {/* Loading indicator when generating */} + {props.isGeneratingName && ( +
+ +
+ )}
- )} + {/* Auto-generate checkbox */} + +
- {/* SSH Host Input - after From selector */} - {props.runtimeMode === RUNTIME_MODE.SSH && ( - props.onSshHostChange(e.target.value)} - placeholder="user@host" - disabled={props.disabled} - className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50" - /> - )} + {/* Error display */} + {props.nameError &&
{props.nameError}
}
); } diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index c3d1d3c077..05ba6ee015 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -244,12 +244,14 @@ export const ChatInput: React.FC = (props) => { ? { projectPath: props.projectPath, onWorkspaceCreated: props.onWorkspaceCreated, + message: input, } : { // Dummy values for workspace variant (never used) projectPath: "", // eslint-disable-next-line @typescript-eslint/no-empty-function onWorkspaceCreated: () => {}, + message: "", } ); @@ -1190,7 +1192,9 @@ export const ChatInput: React.FC = (props) => { )} @@ -1400,6 +1404,12 @@ export const ChatInput: React.FC = (props) => { onSetDefaultRuntime={creationState.setDefaultRuntimeMode} onSshHostChange={creationState.setSshHost} disabled={creationState.isSending || isSending} + workspaceName={creationState.workspaceName} + isGeneratingName={creationState.isGeneratingName} + autoGenerateName={creationState.autoGenerateName} + nameError={creationState.nameError} + onAutoGenerateChange={creationState.setAutoGenerateName} + onNameChange={creationState.setWorkspaceName} /> )}
diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 6fca864302..c1d0eb4e7a 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -8,7 +8,7 @@ import { getProjectScopeId, getThinkingLevelKey, } from "@/common/constants/storage"; -import type { SendMessageError } from "@/common/types/errors"; +import type { SendMessageError as _SendMessageError } from "@/common/types/errors"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { RuntimeMode } from "@/common/types/runtime"; import type { @@ -82,8 +82,13 @@ type BranchListResult = Awaited[0]; type WorkspaceSendMessageArgs = Parameters[0]; type WorkspaceSendMessageResult = Awaited>; +type WorkspaceCreateArgs = Parameters[0]; +type WorkspaceCreateResult = Awaited>; +type NameGenerationArgs = Parameters[0]; +type NameGenerationResult = Awaited>; type MockOrpcProjectsClient = Pick; -type MockOrpcWorkspaceClient = Pick; +type MockOrpcWorkspaceClient = Pick; +type MockOrpcNameGenerationClient = Pick; type WindowWithApi = Window & typeof globalThis; type WindowApi = WindowWithApi["api"]; @@ -102,15 +107,25 @@ const noopUnsubscribe = () => () => undefined; interface MockOrpcClient { projects: MockOrpcProjectsClient; workspace: MockOrpcWorkspaceClient; + nameGeneration: MockOrpcNameGenerationClient; } interface SetupWindowOptions { listBranches?: ReturnType Promise>>; sendMessage?: ReturnType< typeof mock<(args: WorkspaceSendMessageArgs) => Promise> >; + create?: ReturnType Promise>>; + nameGeneration?: ReturnType< + typeof mock<(args: NameGenerationArgs) => Promise> + >; } -const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => { +const setupWindow = ({ + listBranches, + sendMessage, + create, + nameGeneration, +}: SetupWindowOptions = {}) => { const listBranchesMock = listBranches ?? mock<(args: ListBranchesArgs) => Promise>(({ projectPath }) => { @@ -125,29 +140,33 @@ const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => const sendMessageMock = sendMessage ?? - mock<(args: WorkspaceSendMessageArgs) => Promise>((args) => { - if (!args.workspaceId && !args.options?.projectPath) { - return Promise.resolve({ - success: false, - error: { type: "unknown", raw: "Missing project path" } satisfies SendMessageError, - }); - } - - if (!args.workspaceId) { - return Promise.resolve({ - success: true, - data: { - workspaceId: TEST_WORKSPACE_ID, - metadata: TEST_METADATA, - }, - } satisfies WorkspaceSendMessageResult); - } - - const existingWorkspaceResult: WorkspaceSendMessageResult = { + mock<(args: WorkspaceSendMessageArgs) => Promise>(() => { + const result: WorkspaceSendMessageResult = { success: true, data: {}, }; - return Promise.resolve(existingWorkspaceResult); + return Promise.resolve(result); + }); + + const createMock = + create ?? + mock<(args: WorkspaceCreateArgs) => Promise>(() => { + return Promise.resolve({ + success: true, + metadata: TEST_METADATA, + } as WorkspaceCreateResult); + }); + + const nameGenerationMock = + nameGeneration ?? + mock<(args: NameGenerationArgs) => Promise>(() => { + return Promise.resolve({ + success: true, + data: { + name: "test-workspace", + modelUsed: "anthropic:claude-haiku-4-5", + }, + } as NameGenerationResult); }); currentORPCClient = { @@ -156,6 +175,10 @@ const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => }, workspace: { sendMessage: (input: WorkspaceSendMessageArgs) => sendMessageMock(input), + create: (input: WorkspaceCreateArgs) => createMock(input), + }, + nameGeneration: { + generate: (input: NameGenerationArgs) => nameGenerationMock(input), }, }; @@ -184,9 +207,12 @@ const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => update: rejectNotImplemented("projects.secrets.update"), }, }, + nameGeneration: { + generate: (args: NameGenerationArgs) => nameGenerationMock(args), + }, workspace: { list: rejectNotImplemented("workspace.list"), - create: rejectNotImplemented("workspace.create"), + create: (args: WorkspaceCreateArgs) => createMock(args), remove: rejectNotImplemented("workspace.remove"), rename: rejectNotImplemented("workspace.rename"), fork: rejectNotImplemented("workspace.fork"), @@ -252,7 +278,8 @@ const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => return { projectsApi: { listBranches: listBranchesMock }, - workspaceApi: { sendMessage: sendMessageMock }, + workspaceApi: { sendMessage: sendMessageMock, create: createMock }, + nameGenerationApi: { generate: nameGenerationMock }, }; }; const TEST_METADATA: FrontendWorkspaceMetadata = { @@ -340,7 +367,7 @@ describe("useCreationWorkspace", () => { expect(getHook().branches).toEqual([]); }); - test("handleSend sends message and syncs preferences on success", async () => { + test("handleSend creates workspace and sends message on success", async () => { const listBranchesMock = mock( (): Promise => Promise.resolve({ @@ -352,15 +379,28 @@ describe("useCreationWorkspace", () => { (_args: WorkspaceSendMessageArgs): Promise => Promise.resolve({ success: true as const, - data: { - workspaceId: TEST_WORKSPACE_ID, - metadata: TEST_METADATA, - }, + data: {}, }) ); - const { workspaceApi } = setupWindow({ + const createMock = mock( + (_args: WorkspaceCreateArgs): Promise => + Promise.resolve({ + success: true, + metadata: TEST_METADATA, + } as WorkspaceCreateResult) + ); + const nameGenerationMock = mock( + (_args: NameGenerationArgs): Promise => + Promise.resolve({ + success: true, + data: { name: "generated-name", modelUsed: "anthropic:claude-haiku-4-5" }, + } as NameGenerationResult) + ); + const { workspaceApi, nameGenerationApi } = setupWindow({ listBranches: listBranchesMock, sendMessage: sendMessageMock, + create: createMock, + nameGeneration: nameGenerationMock, }); persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan"; @@ -379,40 +419,43 @@ describe("useCreationWorkspace", () => { const getHook = renderUseCreationWorkspace({ projectPath: TEST_PROJECT_PATH, onWorkspaceCreated, + message: "launch workspace", }); await waitFor(() => expect(getHook().branches).toEqual(["main"])); + // Wait for name generation to trigger (happens on debounce) + await waitFor(() => expect(nameGenerationApi.generate.mock.calls.length).toBe(1)); + await act(async () => { await getHook().handleSend("launch workspace"); }); - expect(workspaceApi.sendMessage.mock.calls.length).toBe(1); - // ORPC uses a single argument object - const firstCall = workspaceApi.sendMessage.mock.calls[0]; - if (!firstCall) { - throw new Error("Expected workspace.sendMessage to be called at least once"); + // workspace.create should be called with the generated name + expect(workspaceApi.create.mock.calls.length).toBe(1); + const createCall = workspaceApi.create.mock.calls[0]; + if (!createCall) { + throw new Error("Expected workspace.create to be called at least once"); } - const [request] = firstCall; - if (!request) { - throw new Error("sendMessage mock was invoked without arguments"); - } - const { workspaceId, message, options } = request; - expect(workspaceId).toBeNull(); - expect(message).toBe("launch workspace"); - expect(options?.projectPath).toBe(TEST_PROJECT_PATH); - expect(options?.trunkBranch).toBe("dev"); - expect(options?.model).toBe("gpt-4"); - // Mode was set to "plan" in persistedPreferences, so that's what we expect - expect(options?.mode).toBe("plan"); - // thinkingLevel was set to "high" in persistedPreferences - expect(options?.thinkingLevel).toBe("high"); - expect(options?.runtimeConfig).toEqual({ + const [createRequest] = createCall; + expect(createRequest?.branchName).toBe("generated-name"); + expect(createRequest?.trunkBranch).toBe("dev"); + expect(createRequest?.runtimeConfig).toEqual({ type: "ssh", host: "example.com", srcBaseDir: "~/mux", }); + // workspace.sendMessage should be called with the created workspace ID + expect(workspaceApi.sendMessage.mock.calls.length).toBe(1); + const sendCall = workspaceApi.sendMessage.mock.calls[0]; + if (!sendCall) { + throw new Error("Expected workspace.sendMessage to be called at least once"); + } + const [sendRequest] = sendCall; + expect(sendRequest?.workspaceId).toBe(TEST_WORKSPACE_ID); + expect(sendRequest?.message).toBe("launch workspace"); + await waitFor(() => expect(onWorkspaceCreated.mock.calls.length).toBe(1)); expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA); @@ -430,27 +473,41 @@ describe("useCreationWorkspace", () => { }); test("handleSend surfaces backend errors and resets state", async () => { - const sendMessageMock = mock( - (_args: WorkspaceSendMessageArgs): Promise => + const createMock = mock( + (_args: WorkspaceCreateArgs): Promise => Promise.resolve({ - success: false as const, - error: { type: "unknown", raw: "backend exploded" } satisfies SendMessageError, - }) + success: false, + error: "backend exploded", + } as WorkspaceCreateResult) + ); + const nameGenerationMock = mock( + (_args: NameGenerationArgs): Promise => + Promise.resolve({ + success: true, + data: { name: "test-name", modelUsed: "anthropic:claude-haiku-4-5" }, + } as NameGenerationResult) ); - setupWindow({ sendMessage: sendMessageMock }); + const { workspaceApi, nameGenerationApi } = setupWindow({ + create: createMock, + nameGeneration: nameGenerationMock, + }); draftSettingsState = createDraftSettingsHarness({ trunkBranch: "dev" }); const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata); const getHook = renderUseCreationWorkspace({ projectPath: TEST_PROJECT_PATH, onWorkspaceCreated, + message: "make workspace", }); + // Wait for name generation to trigger + await waitFor(() => expect(nameGenerationApi.generate.mock.calls.length).toBe(1)); + await act(async () => { await getHook().handleSend("make workspace"); }); - expect(sendMessageMock.mock.calls.length).toBe(1); + expect(workspaceApi.create.mock.calls.length).toBe(1); expect(onWorkspaceCreated.mock.calls.length).toBe(0); await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded")); await waitFor(() => expect(getHook().isSending).toBe(false)); @@ -545,6 +602,7 @@ function createDraftSettingsHarness( interface HookOptions { projectPath: string; onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; + message?: string; } function renderUseCreationWorkspace(options: HookOptions) { @@ -553,7 +611,10 @@ function renderUseCreationWorkspace(options: HookOptions) { } = { current: null }; function Harness(props: HookOptions) { - resultRef.current = useCreationWorkspace(props); + resultRef.current = useCreationWorkspace({ + ...props, + message: props.message ?? "", + }); return null; } diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 513dbff0f9..d714a2a50c 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -19,10 +19,13 @@ import type { Toast } from "@/browser/components/ChatInputToast"; import { createErrorToast } from "@/browser/components/ChatInputToasts"; import { useAPI } from "@/browser/contexts/API"; import type { ImagePart } from "@/common/orpc/types"; +import { useWorkspaceName } from "@/browser/hooks/useWorkspaceName"; interface UseCreationWorkspaceOptions { projectPath: string; onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; + /** Current message input for name generation */ + message: string; } function syncCreationPreferences(projectPath: string, workspaceId: string): void { @@ -66,6 +69,18 @@ interface UseCreationWorkspaceReturn { setToast: (toast: Toast | null) => void; isSending: boolean; handleSend: (message: string, imageParts?: ImagePart[]) => Promise; + /** Workspace name state */ + workspaceName: string; + /** Whether name is being generated */ + isGeneratingName: boolean; + /** Whether auto-generation is enabled */ + autoGenerateName: boolean; + /** Name generation error */ + nameError: string | null; + /** Set auto-generation enabled */ + setAutoGenerateName: (enabled: boolean) => void; + /** Set workspace name (for manual entry) */ + setWorkspaceName: (name: string) => void; } /** @@ -73,11 +88,13 @@ interface UseCreationWorkspaceReturn { * Handles: * - Branch selection * - Runtime configuration (local vs SSH) + * - Workspace name generation * - Message sending with workspace creation */ export function useCreationWorkspace({ projectPath, onWorkspaceCreated, + message, }: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { const { api } = useAPI(); const [branches, setBranches] = useState([]); @@ -95,6 +112,15 @@ export function useCreationWorkspace({ getRuntimeString, } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + // Workspace name generation with debounce + const workspaceNameState = useWorkspaceName({ + message, + debounceMs: 500, + }); + + // Destructure name state functions for use in callbacks + const { waitForGeneration } = workspaceNameState; + // Project scope ID for reading send options at send time const projectScopeId = getProjectScopeId(projectPath); @@ -118,13 +144,25 @@ export function useCreationWorkspace({ }, [projectPath, api]); const handleSend = useCallback( - async (message: string, imageParts?: ImagePart[]): Promise => { - if (!message.trim() || isSending || !api) return false; + async (messageText: string, imageParts?: ImagePart[]): Promise => { + if (!messageText.trim() || isSending || !api) return false; setIsSending(true); setToast(null); try { + // Wait for name generation to complete (blocks if still in progress) + const workspaceName = await waitForGeneration(); + if (!workspaceName) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Failed to generate workspace name. Please enter a name manually.", + }); + setIsSending(false); + return false; + } + // Get runtime config from options const runtimeString = getRuntimeString(); const runtimeConfig: RuntimeConfig | undefined = runtimeString @@ -136,15 +174,32 @@ export function useCreationWorkspace({ // in usePersistedState can delay state updates after model selection) const sendMessageOptions = getSendOptionsFromStorage(projectScopeId); - // Send message with runtime config and creation-specific params + // Create the workspace with the generated/manual name first + const createResult = await api.workspace.create({ + projectPath, + branchName: workspaceName, + trunkBranch: settings.trunkBranch, + runtimeConfig, + }); + + if (!createResult.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: createResult.error, + }); + setIsSending(false); + return false; + } + + const { metadata } = createResult; + + // Now send the message to the newly created workspace const result = await api.workspace.sendMessage({ - workspaceId: null, - message, + workspaceId: metadata.id, + message: messageText, options: { ...sendMessageOptions, - runtimeConfig, - projectPath, // Pass projectPath when workspaceId is null - trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings imageParts: imageParts && imageParts.length > 0 ? imageParts : undefined, }, }); @@ -155,29 +210,17 @@ export function useCreationWorkspace({ return false; } - // Check if this is a workspace creation result (has metadata in data) - const { metadata } = result.data; - if (metadata) { - syncCreationPreferences(projectPath, metadata.id); - if (projectPath) { - const pendingInputKey = getInputKey(getPendingScopeId(projectPath)); - updatePersistedState(pendingInputKey, ""); - } - // Settings are already persisted via useDraftWorkspaceSettings - // Notify parent to switch workspace (clears input via parent unmount) - onWorkspaceCreated(metadata); - setIsSending(false); - return true; - } else { - // This shouldn't happen for null workspaceId, but handle gracefully - setToast({ - id: Date.now().toString(), - type: "error", - message: "Unexpected response from server", - }); - setIsSending(false); - return false; + // Sync preferences and complete + syncCreationPreferences(projectPath, metadata.id); + if (projectPath) { + const pendingInputKey = getInputKey(getPendingScopeId(projectPath)); + updatePersistedState(pendingInputKey, ""); } + // Settings are already persisted via useDraftWorkspaceSettings + // Notify parent to switch workspace (clears input via parent unmount) + onWorkspaceCreated(metadata); + setIsSending(false); + return true; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setToast({ @@ -197,6 +240,7 @@ export function useCreationWorkspace({ onWorkspaceCreated, getRuntimeString, settings.trunkBranch, + waitForGeneration, ] ); @@ -214,5 +258,12 @@ export function useCreationWorkspace({ setToast, isSending, handleSend, + // Workspace name state + workspaceName: workspaceNameState.name, + isGeneratingName: workspaceNameState.isGenerating, + autoGenerateName: workspaceNameState.autoGenerate, + nameError: workspaceNameState.error, + setAutoGenerateName: workspaceNameState.setAutoGenerate, + setWorkspaceName: workspaceNameState.setName, }; } diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts new file mode 100644 index 0000000000..ad9d1f8e98 --- /dev/null +++ b/src/browser/hooks/useWorkspaceName.ts @@ -0,0 +1,238 @@ +import { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { useAPI } from "@/browser/contexts/API"; + +export interface UseWorkspaceNameOptions { + /** The user's message to generate a name for */ + message: string; + /** Debounce delay in milliseconds (default: 500) */ + debounceMs?: number; +} + +export interface UseWorkspaceNameReturn { + /** The generated or manually entered name */ + name: string; + /** Whether name generation is in progress */ + isGenerating: boolean; + /** Whether auto-generation is enabled */ + autoGenerate: boolean; + /** Error message if generation failed */ + error: string | null; + /** Set whether auto-generation is enabled */ + setAutoGenerate: (enabled: boolean) => void; + /** Set manual name (for when auto-generate is off) */ + setName: (name: string) => void; + /** Wait for any pending generation to complete */ + waitForGeneration: () => Promise; +} + +/** + * Hook for managing workspace name generation with debouncing. + * + * Automatically generates names as the user types their message, + * but allows manual override. If the user clears the manual name, + * auto-generation resumes. + */ +export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspaceNameReturn { + const { message, debounceMs = 500 } = options; + const { api } = useAPI(); + + const [generatedName, setGeneratedName] = useState(""); + const [manualName, setManualName] = useState(""); + const [autoGenerate, setAutoGenerate] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + + // Track the message that was used for the last successful generation + const lastGeneratedForRef = useRef(""); + // Promise that resolves when current generation completes + const generationPromiseRef = useRef<{ + promise: Promise; + resolve: (name: string) => void; + } | null>(null); + // Debounce timer + const debounceTimerRef = useRef | null>(null); + // Generation request counter for cancellation + const requestIdRef = useRef(0); + + const name = autoGenerate ? generatedName : manualName; + + const generateName = useCallback( + async (forMessage: string): Promise => { + if (!api || !forMessage.trim()) { + return ""; + } + + const requestId = ++requestIdRef.current; + setIsGenerating(true); + setError(null); + + // Create a promise that external callers can wait on + let resolvePromise: ((name: string) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + // TypeScript doesn't understand the Promise executor runs synchronously + const safeResolve = resolvePromise!; + generationPromiseRef.current = { promise, resolve: safeResolve }; + + try { + const result = await api.nameGeneration.generate({ + message: forMessage, + }); + + // Check if this request is still current + if (requestId !== requestIdRef.current) { + return ""; + } + + if (result.success) { + const generatedName = result.data.name; + setGeneratedName(generatedName); + lastGeneratedForRef.current = forMessage; + safeResolve(generatedName); + return generatedName; + } else { + const errorMsg = + result.error.type === "unknown" && "raw" in result.error + ? result.error.raw + : `Generation failed: ${result.error.type}`; + setError(errorMsg); + safeResolve(""); + return ""; + } + } catch (err) { + if (requestId !== requestIdRef.current) { + return ""; + } + const errorMsg = err instanceof Error ? err.message : String(err); + setError(errorMsg); + safeResolve(""); + return ""; + } finally { + if (requestId === requestIdRef.current) { + setIsGenerating(false); + generationPromiseRef.current = null; + } + } + }, + [api] + ); + + // Debounced generation effect + useEffect(() => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + // Don't generate if: + // - Auto-generation is disabled + // - Message is empty + // - Already generated for this message + if (!autoGenerate || !message.trim() || lastGeneratedForRef.current === message) { + return; + } + + // Cancel any in-flight request + requestIdRef.current++; + + // Debounce the generation + debounceTimerRef.current = setTimeout(() => { + void generateName(message); + }, debounceMs); + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, [message, autoGenerate, debounceMs, generateName]); + + // When auto-generate is toggled on, trigger generation + const handleSetAutoGenerate = useCallback( + (enabled: boolean) => { + setAutoGenerate(enabled); + if (enabled) { + // Reset so debounced generation will trigger + lastGeneratedForRef.current = ""; + setError(null); + } + }, + [] + ); + + const setName = useCallback((name: string) => { + setManualName(name); + setError(null); + }, []); + + const waitForGeneration = useCallback(async (): Promise => { + // If auto-generate is off, return the manual name + if (!autoGenerate) { + return manualName; + } + + // If we already have a generated name and nothing is pending, return it + if (generatedName && !isGenerating && !debounceTimerRef.current) { + return generatedName; + } + + // Helper to wait for pending generation with optional timeout + const waitForPending = async (timeoutMs?: number): Promise => { + // If there's a debounced generation pending, trigger it now + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + return generateName(message); + } + + // If generation is in progress, wait for it (with optional timeout) + if (generationPromiseRef.current) { + if (timeoutMs !== undefined) { + const timeout = new Promise((resolve) => + setTimeout(() => resolve(""), timeoutMs) + ); + return Promise.race([generationPromiseRef.current.promise, timeout]); + } + return generationPromiseRef.current.promise; + } + + // Generate if we don't have a name yet + if (!generatedName && message.trim()) { + return generateName(message); + } + + return ""; + }; + + // If we have no name, we must wait fully for generation + if (!generatedName) { + return waitForPending(); + } + + // We have a name but generation might be pending - wait up to 2s for potential update + const pending = isGenerating || debounceTimerRef.current; + if (pending) { + const result = await waitForPending(2000); + // Use result if we got one, otherwise fall back to existing name + return result || generatedName; + } + + return generatedName; + }, [autoGenerate, manualName, generatedName, isGenerating, message, generateName]); + + return useMemo( + () => ({ + name, + isGenerating, + autoGenerate, + error, + setAutoGenerate: handleSetAutoGenerate, + setName, + waitForGeneration, + }), + [name, isGenerating, autoGenerate, error, handleSetAutoGenerate, setName, waitForGeneration] + ); +} diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 1cffe05d1c..273238a111 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -57,6 +57,8 @@ async function createTestServer(authToken?: string): Promise { // Build context const context: ORPCContext = { + config: services.config, + aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, providerService: services.providerService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 045a6d8741..d0f2db27f9 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -60,6 +60,8 @@ async function createTestServer(): Promise { // Build context const context: ORPCContext = { + config: services.config, + aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, providerService: services.providerService, diff --git a/src/cli/server.ts b/src/cli/server.ts index 04d824f01c..d4fef1f879 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -68,6 +68,8 @@ const mockWindow: BrowserWindow = { // Build oRPC context from services const context: ORPCContext = { + config: serviceContainer.config, + aiService: serviceContainer.aiService, projectService: serviceContainer.projectService, workspaceService: serviceContainer.workspaceService, providerService: serviceContainer.providerService, diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index ef56937083..8dea1b3582 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -93,6 +93,7 @@ export { debug, general, menu, + nameGeneration, projects, ProviderConfigInfoSchema, providers, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index ea4f48899f..65f47a3582 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -173,22 +173,13 @@ export const workspace = { }, sendMessage: { input: z.object({ - workspaceId: z.string().nullable(), + workspaceId: z.string(), message: z.string(), options: SendMessageOptionsSchema.extend({ imageParts: z.array(ImagePartSchema).optional(), - runtimeConfig: RuntimeConfigSchema.optional(), - projectPath: z.string().optional(), - trunkBranch: z.string().optional(), }).optional(), }), - output: ResultSchema( - z.object({ - workspaceId: z.string().optional(), - metadata: FrontendWorkspaceMetadataSchema.optional(), - }), - SendMessageErrorSchema - ), + output: ResultSchema(z.object({}), SendMessageErrorSchema), }, resumeStream: { input: z.object({ @@ -282,6 +273,22 @@ export const workspace = { export type WorkspaceSendMessageOutput = z.infer; +// Name generation for new workspaces (decoupled from workspace creation) +export const nameGeneration = { + generate: { + input: z.object({ + message: z.string(), + }), + output: ResultSchema( + z.object({ + name: z.string(), + modelUsed: z.string(), + }), + SendMessageErrorSchema + ), + }, +}; + // Window export const window = { setTitle: { diff --git a/src/common/types/global.d.ts b/src/common/types/global.d.ts index b2199b1a0c..1dcffb3289 100644 --- a/src/common/types/global.d.ts +++ b/src/common/types/global.d.ts @@ -20,6 +20,7 @@ declare global { // Optional ORPC-backed API surfaces populated in tests/storybook mocks tokenizer?: unknown; providers?: unknown; + nameGeneration?: unknown; workspace?: unknown; projects?: unknown; window?: unknown; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 557fe047a7..0294dde7e1 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -322,6 +322,8 @@ async function loadServices(): Promise { // Build the oRPC context with all services const orpcContext = { + config: services.config, + aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, providerService: services.providerService, diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 07114cfeb2..c48402a79f 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -1,4 +1,6 @@ import type { IncomingHttpHeaders } from "http"; +import type { Config } from "@/node/config"; +import type { AIService } from "@/node/services/aiService"; import type { ProjectService } from "@/node/services/projectService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { ProviderService } from "@/node/services/providerService"; @@ -12,6 +14,8 @@ import type { VoiceService } from "@/node/services/voiceService"; import type { TelemetryService } from "@/node/services/telemetryService"; export interface ORPCContext { + config: Config; + aiService: AIService; projectService: ProjectService; workspaceService: WorkspaceService; providerService: ProviderService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 4abe77cde6..7b9a43c885 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,6 +1,10 @@ import { os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; +import { + getPreferredNameModel, + generateWorkspaceName, +} from "@/node/services/workspaceTitleGenerator"; import type { UpdateStatus, WorkspaceActivitySnapshot, @@ -165,6 +169,28 @@ export const router = (authToken?: string) => { }), }, }, + nameGeneration: { + generate: t + .input(schemas.nameGeneration.generate.input) + .output(schemas.nameGeneration.generate.output) + .handler(async ({ context, input }) => { + const model = getPreferredNameModel(context.config); + if (!model) { + return { + success: false, + error: { + type: "unknown" as const, + raw: "No model available for name generation. Configure an API key for Anthropic or OpenAI.", + }, + }; + } + const result = await generateWorkspaceName(input.message, model, context.aiService); + if (!result.success) { + return result; + } + return { success: true, data: { name: result.data, modelUsed: model } }; + }), + }, workspace: { list: t .input(schemas.workspace.list.input) @@ -234,25 +260,9 @@ export const router = (authToken?: string) => { ); if (!result.success) { - const error = - typeof result.error === "string" - ? { type: "unknown" as const, raw: result.error } - : result.error; - return { success: false, error }; - } - - // Check if this is a workspace creation result - if ("workspaceId" in result) { - return { - success: true, - data: { - workspaceId: result.workspaceId, - metadata: result.metadata, - }, - }; + return { success: false, error: result.error }; } - // Regular message send (no workspace creation) return { success: true, data: {} }; }), resumeStream: t diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 4bad951338..245670ebe9 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -26,10 +26,10 @@ import { TelemetryService } from "@/node/services/telemetryService"; * Services are accessed via the ORPC context object. */ export class ServiceContainer { - private readonly config: Config; + public readonly config: Config; private readonly historyService: HistoryService; private readonly partialService: PartialService; - private readonly aiService: AIService; + public readonly aiService: AIService; public readonly projectService: ProjectService; public readonly workspaceService: WorkspaceService; public readonly providerService: ProviderService; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 66b1032e14..cb5bd7dbf0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -12,18 +12,14 @@ import type { PartialService } from "@/node/services/partialService"; import type { AIService } from "@/node/services/aiService"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; -import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory"; -import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; -import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; import type { SendMessageOptions, DeleteMessage, ImagePart, WorkspaceChatMessage, - StreamErrorMessage, } from "@/common/orpc/types"; import type { SendMessageError } from "@/common/types/errors"; import type { @@ -407,374 +403,6 @@ export class WorkspaceService extends EventEmitter { } } - createForFirstMessage( - message: string, - projectPath: string, - options: SendMessageOptions & { - imageParts?: Array<{ url: string; mediaType: string }>; - runtimeConfig?: RuntimeConfig; - trunkBranch?: string; - } = { model: "claude-3-5-sonnet-20241022" } - ): - | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string } { - // Generate placeholder name and ID immediately (non-blocking) - const placeholderName = generatePlaceholderName(message); - const workspaceId = this.config.generateStableId(); - const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - - // Use provided runtime config or default to worktree - const runtimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "worktree", - srcBaseDir: this.config.srcDir, - }; - - // Compute preliminary workspace path (may be refined after srcBaseDir resolution) - const srcBaseDir = getSrcBaseDir(runtimeConfig) ?? this.config.srcDir; - const preliminaryWorkspacePath = path.join(srcBaseDir, projectName, placeholderName); - - // Create preliminary metadata with "creating" status for immediate UI response - const preliminaryMetadata: FrontendWorkspaceMetadata = { - id: workspaceId, - name: placeholderName, - projectName, - projectPath, - createdAt: new Date().toISOString(), - namedWorkspacePath: preliminaryWorkspacePath, - runtimeConfig, - status: "creating", - }; - - // Create session and emit metadata immediately so frontend can switch - const session = this.getOrCreateSession(workspaceId); - session.emitMetadata(preliminaryMetadata); - - log.debug("Emitted preliminary workspace metadata", { workspaceId, placeholderName }); - - // Kick off background workspace creation (git operations, config save, etc.) - void this.completeWorkspaceCreation( - workspaceId, - message, - projectPath, - placeholderName, - runtimeConfig, - options - ); - - // Return immediately with preliminary metadata - return { - success: true, - workspaceId, - metadata: preliminaryMetadata, - }; - } - - /** - * Completes workspace creation in the background after preliminary metadata is emitted. - * Handles git operations, config persistence, and kicks off message sending. - */ - private async completeWorkspaceCreation( - workspaceId: string, - message: string, - projectPath: string, - placeholderName: string, - runtimeConfig: RuntimeConfig, - options: SendMessageOptions & { - imageParts?: Array<{ url: string; mediaType: string }>; - runtimeConfig?: RuntimeConfig; - trunkBranch?: string; - } - ): Promise { - const session = this.sessions.get(workspaceId); - if (!session) { - log.error("Session not found for workspace creation", { workspaceId }); - return; - } - - try { - // Resolve runtime config (may involve path resolution for SSH) - let finalRuntimeConfig = runtimeConfig; - let runtime; - try { - runtime = createRuntime(finalRuntimeConfig, { projectPath }); - const srcBaseDir = getSrcBaseDir(finalRuntimeConfig); - if (srcBaseDir) { - const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir); - if (resolvedSrcBaseDir !== srcBaseDir && hasSrcBaseDir(finalRuntimeConfig)) { - finalRuntimeConfig = { - ...finalRuntimeConfig, - srcBaseDir: resolvedSrcBaseDir, - }; - runtime = createRuntime(finalRuntimeConfig, { projectPath }); - } - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error("Failed to create runtime for workspace", { workspaceId, error: errorMsg }); - session.emitMetadata(null); // Remove the "creating" workspace - return; - } - - // Detect trunk branch (git operation) - const branches = await listLocalBranches(projectPath); - const recommendedTrunk = - options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; - - this.initStateManager.startInit(workspaceId, projectPath); - const initLogger = this.createInitLogger(workspaceId); - - // Create workspace with automatic collision retry - let finalBranchName = placeholderName; - let createResult: { success: boolean; workspacePath?: string; error?: string }; - - for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) { - createResult = await runtime.createWorkspace({ - projectPath, - branchName: finalBranchName, - trunkBranch: recommendedTrunk, - directoryName: finalBranchName, - initLogger, - }); - - if (createResult.success) break; - - if ( - isWorkspaceNameCollision(createResult.error) && - attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES - ) { - log.debug(`Workspace name collision for "${finalBranchName}", retrying with suffix`); - finalBranchName = appendCollisionSuffix(placeholderName); - continue; - } - break; - } - - if (!createResult!.success || !createResult!.workspacePath) { - log.error("Failed to create workspace", { - workspaceId, - error: createResult!.error, - }); - session.emitMetadata(null); // Remove the "creating" workspace - return; - } - - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - const namedWorkspacePath = runtime.getWorkspacePath(projectPath, finalBranchName); - const createdAt = new Date().toISOString(); - - // Save to config - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(projectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(projectPath, projectConfig); - } - projectConfig.workspaces.push({ - path: createResult!.workspacePath!, - id: workspaceId, - name: finalBranchName, - createdAt, - runtimeConfig: finalRuntimeConfig, - }); - return config; - }); - - // Emit final metadata (without "creating" status) - const finalMetadata: FrontendWorkspaceMetadata = { - id: workspaceId, - name: finalBranchName, - projectName, - projectPath, - createdAt, - namedWorkspacePath, - runtimeConfig: finalRuntimeConfig, - }; - session.emitMetadata(finalMetadata); - - log.debug("Workspace creation completed", { workspaceId, finalBranchName }); - - // Start workspace initialization in background - void runtime - .initWorkspace({ - projectPath, - branchName: finalBranchName, - trunkBranch: recommendedTrunk, - workspacePath: createResult!.workspacePath, - initLogger, - }) - .catch((error: unknown) => { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error(`initWorkspace failed for ${workspaceId}:`, error); - initLogger.logStderr(`Initialization failed: ${errorMsg}`); - initLogger.logComplete(-1); - }); - - // Send the first message, surfacing errors to the chat UI - void session.sendMessage(message, options).then((result) => { - if (!result.success) { - log.error("sendMessage failed during workspace creation", { - workspaceId, - errorType: result.error.type, - error: result.error, - }); - const { message: errorMessage, errorType } = formatSendMessageError(result.error); - const streamError: StreamErrorMessage = { - type: "stream-error", - messageId: `error-${Date.now()}`, - error: errorMessage, - errorType, - }; - session.emitChatEvent(streamError); - } - }); - - // Generate AI name asynchronously and rename if successful - void this.generateAndApplyAIName(workspaceId, message, finalBranchName, options.model); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in workspace creation", { workspaceId, error: errorMessage }); - session.emitMetadata(null); // Remove the "creating" workspace - } - } - - /** - * Asynchronously generates an AI workspace name and renames the workspace if successful. - * This runs in the background after workspace creation to avoid blocking the UX. - * - * The method: - * 1. Generates the AI name (can run while stream is active) - * 2. Waits for any active stream to complete (rename is blocked during streaming) - * 3. Attempts to rename the workspace - */ - private async generateAndApplyAIName( - workspaceId: string, - message: string, - currentName: string, - model: string - ): Promise { - try { - log.debug("Starting async AI name generation", { workspaceId, currentName }); - - const branchNameResult = await generateWorkspaceName(message, model, this.aiService); - - if (!branchNameResult.success) { - // AI name generation failed - keep the placeholder name - const err = branchNameResult.error; - const errorMessage = - "message" in err - ? err.message - : err.type === "api_key_not_found" - ? `API key not found for ${err.provider}` - : err.type === "provider_not_supported" - ? `Provider not supported: ${err.provider}` - : "raw" in err - ? err.raw - : "Unknown error"; - log.info("AI name generation failed, keeping placeholder name", { - workspaceId, - currentName, - error: errorMessage, - }); - return; - } - - const aiGeneratedName = branchNameResult.data; - log.debug("AI generated workspace name", { workspaceId, aiGeneratedName, currentName }); - - // Only rename if the AI name is different from current name - if (aiGeneratedName === currentName) { - log.debug("AI name matches placeholder, no rename needed", { workspaceId }); - return; - } - - // Wait for the stream to complete before renaming (rename is blocked during streaming) - await this.waitForStreamComplete(workspaceId); - - // Mark workspace as renaming to block new streams during the rename operation - this.renamingWorkspaces.add(workspaceId); - try { - // Attempt to rename with collision retry (same logic as workspace creation) - let finalName = aiGeneratedName; - for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) { - const renameResult = await this.rename(workspaceId, finalName); - - if (renameResult.success) { - log.info("Successfully renamed workspace to AI-generated name", { - workspaceId, - oldName: currentName, - newName: finalName, - }); - return; - } - - // If collision and not last attempt, retry with suffix - if ( - renameResult.error?.includes("already exists") && - attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES - ) { - log.debug(`Workspace name collision for "${finalName}", retrying with suffix`); - finalName = appendCollisionSuffix(aiGeneratedName); - continue; - } - - // Non-collision error or out of retries - keep placeholder name - log.info("Failed to rename workspace to AI-generated name", { - workspaceId, - aiGeneratedName: finalName, - error: renameResult.error, - }); - return; - } - } finally { - // Always clear renaming flag, even on error - this.renamingWorkspaces.delete(workspaceId); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in async AI name generation", { - workspaceId, - error: errorMessage, - }); - } - } - - /** - * Waits for an active stream on the workspace to complete. - * Returns immediately if no stream is active. - */ - private waitForStreamComplete(workspaceId: string): Promise { - // If not currently streaming, resolve immediately - if (!this.aiService.isStreaming(workspaceId)) { - return Promise.resolve(); - } - - log.debug("Waiting for stream to complete before rename", { workspaceId }); - - return new Promise((resolve) => { - // Create handler that checks for this workspace's stream end - const handler = (event: StreamEndEvent | StreamAbortEvent) => { - if (event.workspaceId === workspaceId) { - this.aiService.off("stream-end", handler); - this.aiService.off("stream-abort", handler); - log.debug("Stream completed, proceeding with rename", { workspaceId }); - resolve(); - } - }; - - // Listen for both normal completion and abort - this.aiService.on("stream-end", handler); - this.aiService.on("stream-abort", handler); - - // Safety check: if stream already ended between the isStreaming check and subscribing - if (!this.aiService.isStreaming(workspaceId)) { - this.aiService.off("stream-end", handler); - this.aiService.off("stream-abort", handler); - resolve(); - } - }); - } - async remove(workspaceId: string, force = false): Promise> { // Try to remove from runtime (filesystem) try { @@ -845,47 +473,7 @@ export class WorkspaceService extends EventEmitter { async getInfo(workspaceId: string): Promise { const allMetadata = await this.config.getAllWorkspaceMetadata(); - const metadata = allMetadata.find((m) => m.id === workspaceId); - - if (metadata && !metadata.name) { - log.info(`Workspace ${workspaceId} missing title or branch name, regenerating...`); - try { - const historyResult = await this.historyService.getHistory(workspaceId); - if (!historyResult.success) { - log.error(`Failed to load history for workspace ${workspaceId}:`, historyResult.error); - return metadata; - } - - const firstUserMessage = historyResult.data.find((m: MuxMessage) => m.role === "user"); - - if (firstUserMessage) { - const textParts = firstUserMessage.parts.filter((p) => p.type === "text"); - const messageText = textParts.map((p) => p.text).join(" "); - - if (messageText.trim()) { - const branchNameResult = await generateWorkspaceName( - messageText, - defaultModel, - this.aiService - ); - - if (branchNameResult.success) { - const branchName = branchNameResult.data; - await this.config.updateWorkspaceMetadata(workspaceId, { - name: branchName, - }); - - metadata.name = branchName; - log.info(`Regenerated workspace name: ${branchName}`); - } - } - } - } catch (error) { - log.error(`Failed to regenerate workspace names for ${workspaceId}:`, error); - } - } - - return metadata! ?? null; + return allMetadata.find((m) => m.id === workspaceId) ?? null; } async rename(workspaceId: string, newName: string): Promise> { @@ -1082,34 +670,14 @@ export class WorkspaceService extends EventEmitter { } async sendMessage( - workspaceId: string | null, + workspaceId: string, message: string, options: | (SendMessageOptions & { imageParts?: ImagePart[]; - runtimeConfig?: RuntimeConfig; - projectPath?: string; - trunkBranch?: string; }) | undefined = { model: defaultModel } - ): Promise< - | Result - | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string } - > { - if (workspaceId === null) { - if (!options?.projectPath) { - return Err("projectPath is required when workspaceId is null"); - } - - log.debug("sendMessage handler: Creating workspace for first message", { - projectPath: options.projectPath, - messagePreview: message.substring(0, 50), - }); - - return this.createForFirstMessage(message, options.projectPath, options); - } - + ): Promise> { log.debug("sendMessage handler: Received", { workspaceId, messagePreview: message.substring(0, 50), diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts index 90c3295d02..e2cf7f1406 100644 --- a/src/node/services/workspaceTitleGenerator.test.ts +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -1,37 +1,59 @@ import { describe, it, expect } from "bun:test"; -import { generatePlaceholderName } from "./workspaceTitleGenerator"; +import { getPreferredNameModel } from "./workspaceTitleGenerator"; +import type { Config } from "@/node/config"; -describe("generatePlaceholderName", () => { - it("should generate a git-safe name from message", () => { - const result = generatePlaceholderName("Add user authentication feature"); - expect(result).toBe("add-user-authentication-featur"); - }); +describe("workspaceTitleGenerator", () => { + it("getPreferredNameModel returns null when no providers configured", () => { + // Save and clear env vars + const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; + const savedAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; - it("should handle special characters", () => { - const result = generatePlaceholderName("Fix bug #123 in user/profile"); - expect(result).toBe("fix-bug-123-in-user-profile"); - }); + try { + const mockConfig = { + loadProvidersConfig: () => null, + } as unknown as Config; - it("should truncate long messages", () => { - const result = generatePlaceholderName( - "This is a very long message that should be truncated to fit within the maximum length" - ); - expect(result.length).toBeLessThanOrEqual(30); - expect(result).toBe("this-is-a-very-long-message-th"); + expect(getPreferredNameModel(mockConfig)).toBeNull(); + } finally { + // Restore env vars + if (savedAnthropicKey) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; + if (savedAnthropicToken) process.env.ANTHROPIC_AUTH_TOKEN = savedAnthropicToken; + } }); - it("should return default name for empty/whitespace input", () => { - expect(generatePlaceholderName("")).toBe("new-workspace"); - expect(generatePlaceholderName(" ")).toBe("new-workspace"); - }); + it("getPreferredNameModel prefers anthropic when configured", () => { + const mockConfig = { + loadProvidersConfig: () => ({ + anthropic: { apiKey: "test-key" }, + }), + } as unknown as Config; - it("should handle unicode characters", () => { - const result = generatePlaceholderName("Add émojis 🚀 and accénts"); - expect(result).toBe("add-mojis-and-acc-nts"); + const model = getPreferredNameModel(mockConfig); + expect(model).toContain("anthropic"); }); - it("should handle only special characters", () => { - const result = generatePlaceholderName("!@#$%^&*()"); - expect(result).toBe("new-workspace"); + it("getPreferredNameModel falls back to openai when anthropic not configured", () => { + // Save and clear env vars + const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; + const savedAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + + try { + const mockConfig = { + loadProvidersConfig: () => ({ + openai: { apiKey: "test-key" }, + }), + } as unknown as Config; + + const model = getPreferredNameModel(mockConfig); + expect(model).toContain("openai"); + } finally { + // Restore env vars + if (savedAnthropicKey) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; + if (savedAnthropicToken) process.env.ANTHROPIC_AUTH_TOKEN = savedAnthropicToken; + } }); }); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index 47dba7a162..0f6ca10063 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -1,10 +1,18 @@ import { generateObject } from "ai"; import { z } from "zod"; import type { AIService } from "./aiService"; +import type { Config } from "@/node/config"; import { log } from "./log"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; +import { getKnownModel } from "@/common/constants/knownModels"; + +/** Models to try in order of preference for name generation (small, fast models) */ +const PREFERRED_MODELS = [ + getKnownModel("HAIKU").id, + getKnownModel("GPT_MINI").id, +] as const; const workspaceNameSchema = z.object({ name: z @@ -15,6 +23,25 @@ const workspaceNameSchema = z.object({ .describe("Git-safe branch/workspace name: lowercase, hyphens only"), }); +/** + * Get the preferred model for name generation based on configured providers. + */ +export function getPreferredNameModel(config: Config): string | null { + const providersConfig = config.loadProvidersConfig(); + for (const modelId of PREFERRED_MODELS) { + const provider = modelId.split(":")[0]; + const providerConfig = providersConfig?.[provider]; + const hasKey = providerConfig + ? !!(providerConfig as { apiKey?: string }).apiKey + : provider === "anthropic" && + !!(process.env.ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_AUTH_TOKEN); + if (hasKey) { + return modelId; + } + } + return null; +} + /** * Generate workspace name using AI. * If AI cannot be used (e.g. missing credentials, unsupported provider, invalid model), @@ -65,11 +92,4 @@ function validateBranchName(name: string): string { return sanitizeBranchName(name, 50); } -/** - * Generate a placeholder name from the user's message for immediate display - * while the AI generates the real title. This is git-safe and human-readable. - */ -export function generatePlaceholderName(message: string): string { - const truncated = message.slice(0, 40).trim(); - return sanitizeBranchName(truncated, 30) || "new-workspace"; -} + diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 0f2a1bc370..ca98732518 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -69,6 +69,8 @@ export async function createTestEnvironment(): Promise { services.windowService.setMainWindow(mockWindow); const orpcContext: ORPCContext = { + config: services.config, + aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, providerService: services.providerService, From 04ef1de8bae2fd2a696a68de55200b3ac8cba9de Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 12:43:34 -0600 Subject: [PATCH 02/15] fmt --- src/browser/hooks/useWorkspaceName.ts | 19 ++++++++----------- src/node/services/workspaceTitleGenerator.ts | 7 +------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index ad9d1f8e98..ade0f3a354 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -151,17 +151,14 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace }, [message, autoGenerate, debounceMs, generateName]); // When auto-generate is toggled on, trigger generation - const handleSetAutoGenerate = useCallback( - (enabled: boolean) => { - setAutoGenerate(enabled); - if (enabled) { - // Reset so debounced generation will trigger - lastGeneratedForRef.current = ""; - setError(null); - } - }, - [] - ); + const handleSetAutoGenerate = useCallback((enabled: boolean) => { + setAutoGenerate(enabled); + if (enabled) { + // Reset so debounced generation will trigger + lastGeneratedForRef.current = ""; + setError(null); + } + }, []); const setName = useCallback((name: string) => { setManualName(name); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index 0f6ca10063..f4174c8d3f 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -9,10 +9,7 @@ import type { SendMessageError } from "@/common/types/errors"; import { getKnownModel } from "@/common/constants/knownModels"; /** Models to try in order of preference for name generation (small, fast models) */ -const PREFERRED_MODELS = [ - getKnownModel("HAIKU").id, - getKnownModel("GPT_MINI").id, -] as const; +const PREFERRED_MODELS = [getKnownModel("HAIKU").id, getKnownModel("GPT_MINI").id] as const; const workspaceNameSchema = z.object({ name: z @@ -91,5 +88,3 @@ function sanitizeBranchName(name: string, maxLength: number): string { function validateBranchName(name: string): string { return sanitizeBranchName(name, 50); } - - From f1ee5ac484738a7425392b1d4df9546a7f908418 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 12:48:08 -0600 Subject: [PATCH 03/15] fix: update integration test helper for simplified sendMessage --- tests/integration/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 64c45ba14b..ec3ccc7db6 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -290,7 +290,7 @@ export async function sendMessageAndWait( }, }); - if (!result.success && !("workspaceId" in result)) { + if (!result.success) { throw new Error(`Failed to send message: ${JSON.stringify(result, null, 2)}`); } From 651b94fe6ccb3b1d63a0eb27cbe43f5ddf8eead6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:12:33 -0600 Subject: [PATCH 04/15] refactor: move name row above runtime controls, normalize heights --- .../components/ChatInput/CreationControls.tsx | 89 +++++++++---------- src/browser/components/ChatInput/index.tsx | 2 +- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 64f4e48927..af1a5d3b57 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -54,7 +54,47 @@ export function CreationControls(props: CreationControlsProps) { return (
- {/* First row: Runtime, Branch, SSH */} + {/* First row: Workspace name with auto-generate checkbox */} +
+ +
+ + {/* Loading indicator when generating */} + {props.isGeneratingName && ( +
+ +
+ )} +
+ {/* Auto-generate checkbox */} + + {/* Error display - inline */} + {props.nameError && {props.nameError}} +
+ + {/* Second row: Runtime, Branch, SSH */}
{/* Runtime Selector - icon-based with tooltips */}
)} @@ -94,51 +134,10 @@ export function CreationControls(props: CreationControlsProps) { onChange={(e) => props.onSshHostChange(e.target.value)} placeholder="user@host" disabled={props.disabled} - className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50" + className="bg-separator text-foreground border-border-medium focus:border-accent h-6 w-32 rounded border px-1 text-xs focus:outline-none disabled:opacity-50" /> )}
- - {/* Second row: Workspace name with auto-generate checkbox */} -
- -
- - {/* Loading indicator when generating */} - {props.isGeneratingName && ( -
- -
- )} -
- {/* Auto-generate checkbox */} - -
- - {/* Error display */} - {props.nameError &&
{props.nameError}
}
); } diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 05ba6ee015..b7a420a23e 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1391,7 +1391,7 @@ export const ChatInput: React.FC = (props) => { - {/* Creation controls - second row for creation variant */} + {/* Creation controls - below model controls for creation variant */} {variant === "creation" && ( Date: Thu, 4 Dec 2025 13:14:42 -0600 Subject: [PATCH 05/15] fix: show error state on name field instead of toast when empty --- src/browser/components/ChatInput/useCreationWorkspace.ts | 6 +----- src/browser/hooks/useWorkspaceName.ts | 6 +++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index d714a2a50c..08a2e5a57d 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -152,13 +152,9 @@ export function useCreationWorkspace({ try { // Wait for name generation to complete (blocks if still in progress) + // Returns empty string if generation failed or manual name is empty (error already set in hook) const workspaceName = await waitForGeneration(); if (!workspaceName) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Failed to generate workspace name. Please enter a name manually.", - }); setIsSending(false); return false; } diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index ade0f3a354..d032f216df 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -166,8 +166,12 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace }, []); const waitForGeneration = useCallback(async (): Promise => { - // If auto-generate is off, return the manual name + // If auto-generate is off, return the manual name (or set error if empty) if (!autoGenerate) { + if (!manualName.trim()) { + setError("Please enter a workspace name"); + return ""; + } return manualName; } From b439627ae078b7e4743522f27b7e924f7ef3c82d Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:17:52 -0600 Subject: [PATCH 06/15] feat: replace auto-name checkbox with magic wand icon in input field - Clicking into name field disables auto-generation, allowing edit - Wand icon toggles auto-generation on/off - Colored wand = auto enabled, grayscale = disabled - Loading spinner replaces wand during generation --- .../components/ChatInput/CreationControls.tsx | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index af1a5d3b57..a6aa0dd4ec 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -2,8 +2,9 @@ import React, { useCallback } from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; import { Select } from "../Select"; import { RuntimeIconSelector } from "../RuntimeIconSelector"; -import { Loader2 } from "lucide-react"; +import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; +import { Tooltip, TooltipWrapper } from "../Tooltip"; interface CreationControlsProps { branches: string[]; @@ -44,7 +45,8 @@ export function CreationControls(props: CreationControlsProps) { const showTrunkBranchSelector = props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL; - const { onNameChange } = props; + const { onNameChange, onAutoGenerateChange } = props; + const handleNameChange = useCallback( (e: React.ChangeEvent) => { onNameChange(e.target.value); @@ -52,9 +54,21 @@ export function CreationControls(props: CreationControlsProps) { [onNameChange] ); + // Clicking into the input disables auto-generation so user can edit + const handleInputFocus = useCallback(() => { + if (props.autoGenerateName) { + onAutoGenerateChange(false); + } + }, [props.autoGenerateName, onAutoGenerateChange]); + + // Toggle auto-generation via wand button + const handleWandClick = useCallback(() => { + onAutoGenerateChange(!props.autoGenerateName); + }, [props.autoGenerateName, onAutoGenerateChange]); + return (
- {/* First row: Workspace name with auto-generate checkbox */} + {/* First row: Workspace name with magic wand toggle */}
- {/* Auto-generate checkbox */} - {/* Error display - inline */} {props.nameError && {props.nameError}}
From d7be2dc9338eac05ac59ad2b4aaa4e1e82c4803c Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:21:10 -0600 Subject: [PATCH 07/15] fix: preserve generated name when switching to manual edit, fix wand alignment - When clicking into field, generated name becomes editable instead of clearing - Use inset-y-0 + flex items-center for proper vertical centering of wand icon --- .../components/ChatInput/CreationControls.tsx | 6 ++--- src/browser/hooks/useWorkspaceName.ts | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index a6aa0dd4ec..4e3fb642e7 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -87,8 +87,8 @@ export function CreationControls(props: CreationControlsProps) { props.nameError && "border-red-500" )} /> - {/* Magic wand / loading indicator */} -
+ {/* Magic wand / loading indicator - vertically centered */} +
{props.isGeneratingName ? ( ) : ( @@ -97,7 +97,7 @@ export function CreationControls(props: CreationControlsProps) { type="button" onClick={handleWandClick} disabled={props.disabled} - className="flex items-center justify-center disabled:opacity-50" + className="flex h-full items-center disabled:opacity-50" aria-label={props.autoGenerateName ? "Disable auto-naming" : "Enable auto-naming"} > { - setAutoGenerate(enabled); - if (enabled) { - // Reset so debounced generation will trigger - lastGeneratedForRef.current = ""; - setError(null); - } - }, []); + // When auto-generate is toggled, handle name preservation + const handleSetAutoGenerate = useCallback( + (enabled: boolean) => { + if (enabled) { + // Switching to auto: reset so debounced generation will trigger + lastGeneratedForRef.current = ""; + setError(null); + } else { + // Switching to manual: copy generated name as starting point for editing + if (generatedName) { + setManualName(generatedName); + } + } + setAutoGenerate(enabled); + }, + [generatedName] + ); const setName = useCallback((name: string) => { setManualName(name); From 02440b5aca8d0a3302307114a83089f68e3c42e4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:25:56 -0600 Subject: [PATCH 08/15] fix: always wait for name generation to complete on full message Remove Promise.race timeout - with voice input, messages can go from empty to complete instantly, so we must ensure the generated name reflects the total content, not a partial intermediate state. --- src/browser/hooks/useWorkspaceName.ts | 63 ++++++++++----------------- 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index e2848d3657..158ef6f65d 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -183,54 +183,35 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace return manualName; } - // If we already have a generated name and nothing is pending, return it - if (generatedName && !isGenerating && !debounceTimerRef.current) { - return generatedName; - } - - // Helper to wait for pending generation with optional timeout - const waitForPending = async (timeoutMs?: number): Promise => { - // If there's a debounced generation pending, trigger it now - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - return generateName(message); - } - - // If generation is in progress, wait for it (with optional timeout) - if (generationPromiseRef.current) { - if (timeoutMs !== undefined) { - const timeout = new Promise((resolve) => - setTimeout(() => resolve(""), timeoutMs) - ); - return Promise.race([generationPromiseRef.current.promise, timeout]); - } - return generationPromiseRef.current.promise; - } + // Always wait for any pending generation to complete on the full message. + // This is important because with voice input, the message can go from empty + // to complete very quickly - we must ensure the generated name reflects the + // total content, not a partial intermediate state. - // Generate if we don't have a name yet - if (!generatedName && message.trim()) { - return generateName(message); - } + // If there's a debounced generation pending, trigger it immediately + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + return generateName(message); + } - return ""; - }; + // If generation is in progress, wait for it to complete + if (generationPromiseRef.current) { + return generationPromiseRef.current.promise; + } - // If we have no name, we must wait fully for generation - if (!generatedName) { - return waitForPending(); + // If we have a name that was generated for the current message, use it + if (generatedName && lastGeneratedForRef.current === message) { + return generatedName; } - // We have a name but generation might be pending - wait up to 2s for potential update - const pending = isGenerating || debounceTimerRef.current; - if (pending) { - const result = await waitForPending(2000); - // Use result if we got one, otherwise fall back to existing name - return result || generatedName; + // Otherwise generate a fresh name for the current message + if (message.trim()) { + return generateName(message); } - return generatedName; - }, [autoGenerate, manualName, generatedName, isGenerating, message, generateName]); + return ""; + }, [autoGenerate, manualName, generatedName, message, generateName]); return useMemo( () => ({ From f6dc76360f1dc846ee04fd28c91d3b0492df5ecd Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:30:29 -0600 Subject: [PATCH 09/15] refactor: simplify CreationControls props by passing nameState object Replace 6 individual name-related props with single WorkspaceNameState object. Reduces indirection and keeps component interface cleaner. --- .../components/ChatInput/CreationControls.tsx | 50 +++++++------------ src/browser/components/ChatInput/index.tsx | 9 +--- .../ChatInput/useCreationWorkspace.ts | 25 ++-------- src/browser/hooks/useWorkspaceName.ts | 6 ++- 4 files changed, 31 insertions(+), 59 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 4e3fb642e7..00c99dcc71 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -5,6 +5,7 @@ import { RuntimeIconSelector } from "../RuntimeIconSelector"; import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipWrapper } from "../Tooltip"; +import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; interface CreationControlsProps { branches: string[]; @@ -13,25 +14,12 @@ interface CreationControlsProps { runtimeMode: RuntimeMode; defaultRuntimeMode: RuntimeMode; sshHost: string; - /** Called when user clicks a runtime icon to select it (does not persist) */ onRuntimeModeChange: (mode: RuntimeMode) => void; - /** Called when user checks "Default for project" checkbox (persists) */ onSetDefaultRuntime: (mode: RuntimeMode) => void; - /** Called when user changes SSH host */ onSshHostChange: (host: string) => void; disabled: boolean; - /** Workspace name state */ - workspaceName: string; - /** Whether name is being generated */ - isGeneratingName: boolean; - /** Whether auto-generation is enabled */ - autoGenerateName: boolean; - /** Name generation error */ - nameError: string | null; - /** Called when auto-generate checkbox changes */ - onAutoGenerateChange: (enabled: boolean) => void; - /** Called when user types in the name field */ - onNameChange: (name: string) => void; + /** Workspace name generation state and actions */ + nameState: WorkspaceNameState; } /** @@ -45,26 +33,26 @@ export function CreationControls(props: CreationControlsProps) { const showTrunkBranchSelector = props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL; - const { onNameChange, onAutoGenerateChange } = props; + const { nameState } = props; const handleNameChange = useCallback( (e: React.ChangeEvent) => { - onNameChange(e.target.value); + nameState.setName(e.target.value); }, - [onNameChange] + [nameState] ); // Clicking into the input disables auto-generation so user can edit const handleInputFocus = useCallback(() => { - if (props.autoGenerateName) { - onAutoGenerateChange(false); + if (nameState.autoGenerate) { + nameState.setAutoGenerate(false); } - }, [props.autoGenerateName, onAutoGenerateChange]); + }, [nameState]); // Toggle auto-generation via wand button const handleWandClick = useCallback(() => { - onAutoGenerateChange(!props.autoGenerateName); - }, [props.autoGenerateName, onAutoGenerateChange]); + nameState.setAutoGenerate(!nameState.autoGenerate); + }, [nameState]); return (
@@ -77,19 +65,19 @@ export function CreationControls(props: CreationControlsProps) { {/* Magic wand / loading indicator - vertically centered */}
- {props.isGeneratingName ? ( + {nameState.isGenerating ? ( ) : ( @@ -98,26 +86,26 @@ export function CreationControls(props: CreationControlsProps) { onClick={handleWandClick} disabled={props.disabled} className="flex h-full items-center disabled:opacity-50" - aria-label={props.autoGenerateName ? "Disable auto-naming" : "Enable auto-naming"} + aria-label={nameState.autoGenerate ? "Disable auto-naming" : "Enable auto-naming"} > - {props.autoGenerateName ? "Auto-naming enabled" : "Click to enable auto-naming"} + {nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"} )}
{/* Error display - inline */} - {props.nameError && {props.nameError}} + {nameState.error && {nameState.error}}
{/* Second row: Runtime, Branch, SSH */} diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b7a420a23e..db3dc44c57 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1193,7 +1193,7 @@ export const ChatInput: React.FC = (props) => { projectName={props.projectName} isSending={creationState.isSending || isSending} workspaceName={ - creationState.isSending || isSending ? creationState.workspaceName : undefined + creationState.isSending || isSending ? creationState.nameState.name : undefined } /> )} @@ -1404,12 +1404,7 @@ export const ChatInput: React.FC = (props) => { onSetDefaultRuntime={creationState.setDefaultRuntimeMode} onSshHostChange={creationState.setSshHost} disabled={creationState.isSending || isSending} - workspaceName={creationState.workspaceName} - isGeneratingName={creationState.isGeneratingName} - autoGenerateName={creationState.autoGenerateName} - nameError={creationState.nameError} - onAutoGenerateChange={creationState.setAutoGenerateName} - onNameChange={creationState.setWorkspaceName} + nameState={creationState.nameState} /> )}
diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 08a2e5a57d..ccdc7d2d8c 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -19,7 +19,7 @@ import type { Toast } from "@/browser/components/ChatInputToast"; import { createErrorToast } from "@/browser/components/ChatInputToasts"; import { useAPI } from "@/browser/contexts/API"; import type { ImagePart } from "@/common/orpc/types"; -import { useWorkspaceName } from "@/browser/hooks/useWorkspaceName"; +import { useWorkspaceName, type WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; interface UseCreationWorkspaceOptions { projectPath: string; @@ -69,18 +69,8 @@ interface UseCreationWorkspaceReturn { setToast: (toast: Toast | null) => void; isSending: boolean; handleSend: (message: string, imageParts?: ImagePart[]) => Promise; - /** Workspace name state */ - workspaceName: string; - /** Whether name is being generated */ - isGeneratingName: boolean; - /** Whether auto-generation is enabled */ - autoGenerateName: boolean; - /** Name generation error */ - nameError: string | null; - /** Set auto-generation enabled */ - setAutoGenerateName: (enabled: boolean) => void; - /** Set workspace name (for manual entry) */ - setWorkspaceName: (name: string) => void; + /** Workspace name generation state and actions (for CreationControls) */ + nameState: WorkspaceNameState; } /** @@ -254,12 +244,7 @@ export function useCreationWorkspace({ setToast, isSending, handleSend, - // Workspace name state - workspaceName: workspaceNameState.name, - isGeneratingName: workspaceNameState.isGenerating, - autoGenerateName: workspaceNameState.autoGenerate, - nameError: workspaceNameState.error, - setAutoGenerateName: workspaceNameState.setAutoGenerate, - setWorkspaceName: workspaceNameState.setName, + // Workspace name state (for CreationControls) + nameState: workspaceNameState, }; } diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index 158ef6f65d..f7ea4e169f 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -8,7 +8,8 @@ export interface UseWorkspaceNameOptions { debounceMs?: number; } -export interface UseWorkspaceNameReturn { +/** State and actions for workspace name generation, suitable for passing to components */ +export interface WorkspaceNameState { /** The generated or manually entered name */ name: string; /** Whether name generation is in progress */ @@ -21,6 +22,9 @@ export interface UseWorkspaceNameReturn { setAutoGenerate: (enabled: boolean) => void; /** Set manual name (for when auto-generate is off) */ setName: (name: string) => void; +} + +export interface UseWorkspaceNameReturn extends WorkspaceNameState { /** Wait for any pending generation to complete */ waitForGeneration: () => Promise; } From 24f3e2a91710a20202e3226ad08ecce34e5e3d9f Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:35:28 -0600 Subject: [PATCH 10/15] fix: eliminate race conditions in useWorkspaceName hook - Add cancelPendingGeneration() that properly resolves waiting promises before invalidating requests (prevents hanging waiters) - Track requestId in generationPromiseRef to match cancellation correctly - Use pendingMessageRef to capture message at schedule time, avoiding stale closures when debounced callback fires - Clear pendingMessageRef in all cleanup paths - waitForGeneration now uses captured message from pendingMessageRef instead of potentially stale closure value --- src/browser/hooks/useWorkspaceName.ts | 85 +++++++++++++++++++-------- 1 file changed, 59 insertions(+), 26 deletions(-) diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index f7ea4e169f..241a2f09cb 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -48,18 +48,39 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace // Track the message that was used for the last successful generation const lastGeneratedForRef = useRef(""); - // Promise that resolves when current generation completes - const generationPromiseRef = useRef<{ - promise: Promise; - resolve: (name: string) => void; - } | null>(null); // Debounce timer const debounceTimerRef = useRef | null>(null); + // Message pending in debounce timer (captured at schedule time) + const pendingMessageRef = useRef(""); // Generation request counter for cancellation const requestIdRef = useRef(0); + // Current in-flight generation promise and its resolver + const generationPromiseRef = useRef<{ + promise: Promise; + resolve: (name: string) => void; + requestId: number; + } | null>(null); const name = autoGenerate ? generatedName : manualName; + // Cancel any pending generation and resolve waiters with empty string + const cancelPendingGeneration = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + pendingMessageRef.current = ""; + } + // Increment request ID to invalidate any in-flight request + const oldRequestId = requestIdRef.current; + requestIdRef.current++; + // Resolve any waiters so they don't hang forever + if (generationPromiseRef.current && generationPromiseRef.current.requestId === oldRequestId) { + generationPromiseRef.current.resolve(""); + generationPromiseRef.current = null; + setIsGenerating(false); + } + }, []); + const generateName = useCallback( async (forMessage: string): Promise => { if (!api || !forMessage.trim()) { @@ -77,24 +98,25 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace }); // TypeScript doesn't understand the Promise executor runs synchronously const safeResolve = resolvePromise!; - generationPromiseRef.current = { promise, resolve: safeResolve }; + generationPromiseRef.current = { promise, resolve: safeResolve, requestId }; try { const result = await api.nameGeneration.generate({ message: forMessage, }); - // Check if this request is still current + // Check if this request is still current (wasn't cancelled) if (requestId !== requestIdRef.current) { + // Don't resolve here - cancellation already resolved the promise return ""; } if (result.success) { - const generatedName = result.data.name; - setGeneratedName(generatedName); + const name = result.data.name; + setGeneratedName(name); lastGeneratedForRef.current = forMessage; - safeResolve(generatedName); - return generatedName; + safeResolve(name); + return name; } else { const errorMsg = result.error.type === "unknown" && "raw" in result.error @@ -124,35 +146,42 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace // Debounced generation effect useEffect(() => { - // Clear any pending debounce timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - // Don't generate if: // - Auto-generation is disabled // - Message is empty // - Already generated for this message if (!autoGenerate || !message.trim() || lastGeneratedForRef.current === message) { + // Clear any pending timer since conditions changed + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + pendingMessageRef.current = ""; + } return; } - // Cancel any in-flight request - requestIdRef.current++; + // Cancel any in-flight request since message changed + cancelPendingGeneration(); + + // Capture message for the debounced callback (avoid stale closure) + pendingMessageRef.current = message; // Debounce the generation debounceTimerRef.current = setTimeout(() => { - void generateName(message); + const msg = pendingMessageRef.current; + debounceTimerRef.current = null; + pendingMessageRef.current = ""; + void generateName(msg); }, debounceMs); return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; + pendingMessageRef.current = ""; } }; - }, [message, autoGenerate, debounceMs, generateName]); + }, [message, autoGenerate, debounceMs, generateName, cancelPendingGeneration]); // When auto-generate is toggled, handle name preservation const handleSetAutoGenerate = useCallback( @@ -187,16 +216,20 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace return manualName; } - // Always wait for any pending generation to complete on the full message. - // This is important because with voice input, the message can go from empty - // to complete very quickly - we must ensure the generated name reflects the - // total content, not a partial intermediate state. + // Always wait for generation to complete on the full message. + // With voice input, the message can go from empty to complete very quickly, + // so we must ensure the generated name reflects the total content. // If there's a debounced generation pending, trigger it immediately + // Use the captured message from pendingMessageRef to avoid stale closures if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; - return generateName(message); + const msg = pendingMessageRef.current; + pendingMessageRef.current = ""; + if (msg.trim()) { + return generateName(msg); + } } // If generation is in progress, wait for it to complete From f95082a197555b813cc93d5c7f1ee180b02fdd62 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:40:52 -0600 Subject: [PATCH 11/15] refactor: delegate provider checking to AIService in getPreferredNameModel - Changed getPreferredNameModel to async, calls aiService.createModel() to test if models are available instead of duplicating API key logic - Removes dependency on Config, avoiding duplication of provider-specific env var checking (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, etc.) - Updated tests to use mock AIService instead of mock Config - Single source of truth for provider availability in AIService --- src/node/orpc/router.ts | 2 +- .../services/workspaceTitleGenerator.test.ts | 75 +++++++------------ src/node/services/workspaceTitleGenerator.ts | 18 ++--- 3 files changed, 35 insertions(+), 60 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 7b9a43c885..6d89101281 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -174,7 +174,7 @@ export const router = (authToken?: string) => { .input(schemas.nameGeneration.generate.input) .output(schemas.nameGeneration.generate.output) .handler(async ({ context, input }) => { - const model = getPreferredNameModel(context.config); + const model = await getPreferredNameModel(context.aiService); if (!model) { return { success: false, diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts index e2cf7f1406..3148205649 100644 --- a/src/node/services/workspaceTitleGenerator.test.ts +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -1,59 +1,38 @@ import { describe, it, expect } from "bun:test"; import { getPreferredNameModel } from "./workspaceTitleGenerator"; -import type { Config } from "@/node/config"; +import type { AIService } from "./aiService"; +import { getKnownModel } from "@/common/constants/knownModels"; + +// Helper to create a mock AIService that succeeds for specific models +function createMockAIService(availableModels: string[]): AIService { + return { + createModel: async (modelString: string) => { + if (availableModels.includes(modelString)) { + return { success: true, data: {} as never }; + } + return { success: false, error: { type: "api_key_not_found", provider: "test" } }; + }, + } as unknown as AIService; +} describe("workspaceTitleGenerator", () => { - it("getPreferredNameModel returns null when no providers configured", () => { - // Save and clear env vars - const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; - const savedAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; - delete process.env.ANTHROPIC_API_KEY; - delete process.env.ANTHROPIC_AUTH_TOKEN; + const HAIKU_ID = getKnownModel("HAIKU").id; + const GPT_MINI_ID = getKnownModel("GPT_MINI").id; - try { - const mockConfig = { - loadProvidersConfig: () => null, - } as unknown as Config; - - expect(getPreferredNameModel(mockConfig)).toBeNull(); - } finally { - // Restore env vars - if (savedAnthropicKey) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; - if (savedAnthropicToken) process.env.ANTHROPIC_AUTH_TOKEN = savedAnthropicToken; - } + it("getPreferredNameModel returns null when no models available", async () => { + const aiService = createMockAIService([]); + expect(await getPreferredNameModel(aiService)).toBeNull(); }); - it("getPreferredNameModel prefers anthropic when configured", () => { - const mockConfig = { - loadProvidersConfig: () => ({ - anthropic: { apiKey: "test-key" }, - }), - } as unknown as Config; - - const model = getPreferredNameModel(mockConfig); - expect(model).toContain("anthropic"); + it("getPreferredNameModel prefers Haiku when available", async () => { + const aiService = createMockAIService([HAIKU_ID, GPT_MINI_ID]); + const model = await getPreferredNameModel(aiService); + expect(model).toBe(HAIKU_ID); }); - it("getPreferredNameModel falls back to openai when anthropic not configured", () => { - // Save and clear env vars - const savedAnthropicKey = process.env.ANTHROPIC_API_KEY; - const savedAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; - delete process.env.ANTHROPIC_API_KEY; - delete process.env.ANTHROPIC_AUTH_TOKEN; - - try { - const mockConfig = { - loadProvidersConfig: () => ({ - openai: { apiKey: "test-key" }, - }), - } as unknown as Config; - - const model = getPreferredNameModel(mockConfig); - expect(model).toContain("openai"); - } finally { - // Restore env vars - if (savedAnthropicKey) process.env.ANTHROPIC_API_KEY = savedAnthropicKey; - if (savedAnthropicToken) process.env.ANTHROPIC_AUTH_TOKEN = savedAnthropicToken; - } + it("getPreferredNameModel falls back to GPT Mini when Haiku unavailable", async () => { + const aiService = createMockAIService([GPT_MINI_ID]); + const model = await getPreferredNameModel(aiService); + expect(model).toBe(GPT_MINI_ID); }); }); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index f4174c8d3f..746f1cb585 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -1,7 +1,6 @@ import { generateObject } from "ai"; import { z } from "zod"; import type { AIService } from "./aiService"; -import type { Config } from "@/node/config"; import { log } from "./log"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -21,20 +20,17 @@ const workspaceNameSchema = z.object({ }); /** - * Get the preferred model for name generation based on configured providers. + * Get the preferred model for name generation by testing which models the AIService + * can actually create. This delegates credential checking to AIService, avoiding + * duplication of provider-specific API key logic. */ -export function getPreferredNameModel(config: Config): string | null { - const providersConfig = config.loadProvidersConfig(); +export async function getPreferredNameModel(aiService: AIService): Promise { for (const modelId of PREFERRED_MODELS) { - const provider = modelId.split(":")[0]; - const providerConfig = providersConfig?.[provider]; - const hasKey = providerConfig - ? !!(providerConfig as { apiKey?: string }).apiKey - : provider === "anthropic" && - !!(process.env.ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_AUTH_TOKEN); - if (hasKey) { + const result = await aiService.createModel(modelId); + if (result.success) { return modelId; } + // If it's an API key error, try the next model; other errors are also skipped } return null; } From 4d85cd5a4cbd131e8d646521be4d49e486398dfc Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:42:40 -0600 Subject: [PATCH 12/15] fix: use fallback model for name generation when preferred models unavailable If Haiku/GPT-Mini aren't available (no Anthropic/OpenAI key), fall back to the user's configured message model. This avoids confusing errors when users have only configured alternative providers. --- .../components/ChatInput/useCreationWorkspace.ts | 10 +++++++--- src/browser/hooks/useWorkspaceName.ts | 7 +++++-- src/common/orpc/schemas/api.ts | 2 ++ src/node/orpc/router.ts | 6 ++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index ccdc7d2d8c..b458d3ab61 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -102,18 +102,22 @@ export function useCreationWorkspace({ getRuntimeString, } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + // Project scope ID for reading send options at send time + const projectScopeId = getProjectScopeId(projectPath); + + // Read the user's preferred model for fallback (same source as ChatInput's preferredModel) + const fallbackModel = readPersistedState(getModelKey(projectScopeId), null); + // Workspace name generation with debounce const workspaceNameState = useWorkspaceName({ message, debounceMs: 500, + fallbackModel: fallbackModel ?? undefined, }); // Destructure name state functions for use in callbacks const { waitForGeneration } = workspaceNameState; - // Project scope ID for reading send options at send time - const projectScopeId = getProjectScopeId(projectPath); - // Load branches on mount useEffect(() => { // This can be created with an empty project path when the user is diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index 241a2f09cb..785f483874 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -6,6 +6,8 @@ export interface UseWorkspaceNameOptions { message: string; /** Debounce delay in milliseconds (default: 500) */ debounceMs?: number; + /** Model to use if preferred small models aren't available */ + fallbackModel?: string; } /** State and actions for workspace name generation, suitable for passing to components */ @@ -37,7 +39,7 @@ export interface UseWorkspaceNameReturn extends WorkspaceNameState { * auto-generation resumes. */ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspaceNameReturn { - const { message, debounceMs = 500 } = options; + const { message, debounceMs = 500, fallbackModel } = options; const { api } = useAPI(); const [generatedName, setGeneratedName] = useState(""); @@ -103,6 +105,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace try { const result = await api.nameGeneration.generate({ message: forMessage, + fallbackModel, }); // Check if this request is still current (wasn't cancelled) @@ -141,7 +144,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace } } }, - [api] + [api, fallbackModel] ); // Debounced generation effect diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 65f47a3582..aa7d0011e2 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -278,6 +278,8 @@ export const nameGeneration = { generate: { input: z.object({ message: z.string(), + /** Model to use if preferred small models (Haiku, GPT-Mini) aren't available */ + fallbackModel: z.string().optional(), }), output: ResultSchema( z.object({ diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 6d89101281..07250c8bca 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -174,13 +174,15 @@ export const router = (authToken?: string) => { .input(schemas.nameGeneration.generate.input) .output(schemas.nameGeneration.generate.output) .handler(async ({ context, input }) => { - const model = await getPreferredNameModel(context.aiService); + // Prefer small/fast models, fall back to user's configured model + const model = + (await getPreferredNameModel(context.aiService)) ?? input.fallbackModel; if (!model) { return { success: false, error: { type: "unknown" as const, - raw: "No model available for name generation. Configure an API key for Anthropic or OpenAI.", + raw: "No model available for name generation.", }, }; } From e684c8a6762965c50b40967000cd1ec4be4a9786 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:44:10 -0600 Subject: [PATCH 13/15] fix: lint and formatting issues --- src/browser/hooks/useWorkspaceName.ts | 2 +- .../services/workspaceTitleGenerator.test.ts | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts index 785f483874..0187aa317e 100644 --- a/src/browser/hooks/useWorkspaceName.ts +++ b/src/browser/hooks/useWorkspaceName.ts @@ -76,7 +76,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace const oldRequestId = requestIdRef.current; requestIdRef.current++; // Resolve any waiters so they don't hang forever - if (generationPromiseRef.current && generationPromiseRef.current.requestId === oldRequestId) { + if (generationPromiseRef.current?.requestId === oldRequestId) { generationPromiseRef.current.resolve(""); generationPromiseRef.current = null; setIsGenerating(false); diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts index 3148205649..5200d5bad9 100644 --- a/src/node/services/workspaceTitleGenerator.test.ts +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -2,17 +2,29 @@ import { describe, it, expect } from "bun:test"; import { getPreferredNameModel } from "./workspaceTitleGenerator"; import type { AIService } from "./aiService"; import { getKnownModel } from "@/common/constants/knownModels"; +import type { LanguageModel } from "ai"; +import type { Result } from "@/common/types/result"; +import type { SendMessageError } from "@/common/types/errors"; + +type CreateModelResult = Result; // Helper to create a mock AIService that succeeds for specific models function createMockAIService(availableModels: string[]): AIService { - return { - createModel: async (modelString: string) => { + const service: Partial = { + createModel: (modelString: string): Promise => { if (availableModels.includes(modelString)) { - return { success: true, data: {} as never }; + // Return a minimal success result - data is not used by getPreferredNameModel + const result: CreateModelResult = { success: true, data: null as never }; + return Promise.resolve(result); } - return { success: false, error: { type: "api_key_not_found", provider: "test" } }; + const err: CreateModelResult = { + success: false, + error: { type: "api_key_not_found", provider: "test" }, + }; + return Promise.resolve(err); }, - } as unknown as AIService; + }; + return service as AIService; } describe("workspaceTitleGenerator", () => { From 6bcaea89830a6cbc18c8e9643c731df8d42ce422 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:45:43 -0600 Subject: [PATCH 14/15] style: prettier formatting --- src/node/orpc/router.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 07250c8bca..3d2e1178a1 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -175,8 +175,7 @@ export const router = (authToken?: string) => { .output(schemas.nameGeneration.generate.output) .handler(async ({ context, input }) => { // Prefer small/fast models, fall back to user's configured model - const model = - (await getPreferredNameModel(context.aiService)) ?? input.fallbackModel; + const model = (await getPreferredNameModel(context.aiService)) ?? input.fallbackModel; if (!model) { return { success: false, From 63708eb2b49de9751b1c243da82326ed796ce89d Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:49:52 -0600 Subject: [PATCH 15/15] fix: show correct workspace name during creation, update empty state text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track confirmed name separately (creatingWithName) from input field name - Show 'Generating name…' briefly while waitForGeneration completes - Then show the actual name that will be used for the workspace - Simplified welcome text in empty state --- .../ChatInput/CreationCenterContent.tsx | 20 +++++++++++-------- src/browser/components/ChatInput/index.tsx | 2 +- .../ChatInput/useCreationWorkspace.ts | 10 ++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/browser/components/ChatInput/CreationCenterContent.tsx b/src/browser/components/ChatInput/CreationCenterContent.tsx index fc02912dd1..ceb480201f 100644 --- a/src/browser/components/ChatInput/CreationCenterContent.tsx +++ b/src/browser/components/ChatInput/CreationCenterContent.tsx @@ -3,7 +3,8 @@ import React from "react"; interface CreationCenterContentProps { projectName: string; isSending: boolean; - workspaceName?: string; + /** The confirmed workspace name (null while name generation is in progress) */ + workspaceName?: string | null; } /** @@ -17,18 +18,21 @@ export function CreationCenterContent(props: CreationCenterContentProps) {

Creating workspace

- {props.workspaceName && ( -

- Creating {props.workspaceName} -

- )} +

+ {props.workspaceName ? ( + <> + {props.workspaceName} + + ) : ( + "Generating name…" + )} +

) : (

{props.projectName}

- Describe what you want to build. A new workspace will be created with an automatically - generated name. Configure runtime and model options below. + Describe what you want to build and a workspace will be created.

)} diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index db3dc44c57..9a53c8c4b5 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1193,7 +1193,7 @@ export const ChatInput: React.FC = (props) => { projectName={props.projectName} isSending={creationState.isSending || isSending} workspaceName={ - creationState.isSending || isSending ? creationState.nameState.name : undefined + creationState.isSending || isSending ? creationState.creatingWithName : undefined } /> )} diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index b458d3ab61..d169d9001b 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -71,6 +71,8 @@ interface UseCreationWorkspaceReturn { handleSend: (message: string, imageParts?: ImagePart[]) => Promise; /** Workspace name generation state and actions (for CreationControls) */ nameState: WorkspaceNameState; + /** The confirmed name being used for creation (null until name generation resolves) */ + creatingWithName: string | null; } /** @@ -91,6 +93,8 @@ export function useCreationWorkspace({ const [recommendedTrunk, setRecommendedTrunk] = useState(null); const [toast, setToast] = useState(null); const [isSending, setIsSending] = useState(false); + // The confirmed name being used for workspace creation (set after waitForGeneration resolves) + const [creatingWithName, setCreatingWithName] = useState(null); // Centralized draft workspace settings with automatic persistence const { @@ -143,6 +147,7 @@ export function useCreationWorkspace({ setIsSending(true); setToast(null); + setCreatingWithName(null); try { // Wait for name generation to complete (blocks if still in progress) @@ -153,6 +158,9 @@ export function useCreationWorkspace({ return false; } + // Set the confirmed name for UI display + setCreatingWithName(workspaceName); + // Get runtime config from options const runtimeString = getRuntimeString(); const runtimeConfig: RuntimeConfig | undefined = runtimeString @@ -250,5 +258,7 @@ export function useCreationWorkspace({ handleSend, // Workspace name state (for CreationControls) nameState: workspaceNameState, + // The confirmed name being used for creation (null until waitForGeneration resolves) + creatingWithName, }; }