diff --git a/src/browser/components/ChatInput/CreationCenterContent.tsx b/src/browser/components/ChatInput/CreationCenterContent.tsx index 3db11368d2..ceb480201f 100644 --- a/src/browser/components/ChatInput/CreationCenterContent.tsx +++ b/src/browser/components/ChatInput/CreationCenterContent.tsx @@ -3,38 +3,36 @@ import React from "react"; interface CreationCenterContentProps { projectName: string; isSending: boolean; - inputPreview?: string; + /** The confirmed workspace name (null while name generation is in progress) */ + workspaceName?: string | null; } /** * 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 && ( -

- Generating name for “{truncatedPreview}” -

- )} +

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

) : (

{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. + Describe what you want to build and a workspace will be created.

)} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index dd01b02022..00c99dcc71 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,7 +1,11 @@ -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, 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[]; @@ -10,68 +14,144 @@ 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 generation state and actions */ + nameState: WorkspaceNameState; } /** * 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; - return ( -
- {/* Runtime Selector - icon-based with tooltips */} - + const { nameState } = props; + + const handleNameChange = useCallback( + (e: React.ChangeEvent) => { + nameState.setName(e.target.value); + }, + [nameState] + ); + + // Clicking into the input disables auto-generation so user can edit + const handleInputFocus = useCallback(() => { + if (nameState.autoGenerate) { + nameState.setAutoGenerate(false); + } + }, [nameState]); + + // Toggle auto-generation via wand button + const handleWandClick = useCallback(() => { + nameState.setAutoGenerate(!nameState.autoGenerate); + }, [nameState]); - {/* Trunk Branch Selector - hidden for Local runtime */} - {showTrunkBranchSelector && ( -
- - + {/* Magic wand / loading indicator - vertically centered */} +
+ {nameState.isGenerating ? ( + + ) : ( + + + + {nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"} + + + )} +
- )} + {/* Error display - inline */} + {nameState.error && {nameState.error}} +
- {/* SSH Host Input - after From selector */} - {props.runtimeMode === RUNTIME_MODE.SSH && ( - props.onSshHostChange(e.target.value)} - placeholder="user@host" + {/* Second 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="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" + /> + )} +
); } diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index c3d1d3c077..9a53c8c4b5 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) => { )} @@ -1387,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" && ( = (props) => { onSetDefaultRuntime={creationState.setDefaultRuntimeMode} onSshHostChange={creationState.setSshHost} disabled={creationState.isSending || isSending} + nameState={creationState.nameState} /> )} 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..d169d9001b 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, type WorkspaceNameState } 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,10 @@ interface UseCreationWorkspaceReturn { setToast: (toast: Toast | null) => void; isSending: boolean; 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; } /** @@ -73,17 +80,21 @@ 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([]); 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 { @@ -98,6 +109,19 @@ export function useCreationWorkspace({ // 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; + // Load branches on mount useEffect(() => { // This can be created with an empty project path when the user is @@ -118,13 +142,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); + setCreatingWithName(null); 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) { + setIsSending(false); + return false; + } + + // Set the confirmed name for UI display + setCreatingWithName(workspaceName); + // Get runtime config from options const runtimeString = getRuntimeString(); const runtimeConfig: RuntimeConfig | undefined = runtimeString @@ -136,15 +172,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 +208,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 +238,7 @@ export function useCreationWorkspace({ onWorkspaceCreated, getRuntimeString, settings.trunkBranch, + waitForGeneration, ] ); @@ -214,5 +256,9 @@ export function useCreationWorkspace({ setToast, isSending, handleSend, + // Workspace name state (for CreationControls) + nameState: workspaceNameState, + // The confirmed name being used for creation (null until waitForGeneration resolves) + creatingWithName, }; } diff --git a/src/browser/hooks/useWorkspaceName.ts b/src/browser/hooks/useWorkspaceName.ts new file mode 100644 index 0000000000..0187aa317e --- /dev/null +++ b/src/browser/hooks/useWorkspaceName.ts @@ -0,0 +1,268 @@ +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; + /** Model to use if preferred small models aren't available */ + fallbackModel?: string; +} + +/** 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 */ + 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; +} + +export interface UseWorkspaceNameReturn extends WorkspaceNameState { + /** 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, fallbackModel } = 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(""); + // 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?.requestId === oldRequestId) { + generationPromiseRef.current.resolve(""); + generationPromiseRef.current = null; + setIsGenerating(false); + } + }, []); + + 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, requestId }; + + try { + const result = await api.nameGeneration.generate({ + message: forMessage, + fallbackModel, + }); + + // 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 name = result.data.name; + setGeneratedName(name); + lastGeneratedForRef.current = forMessage; + safeResolve(name); + return name; + } 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, fallbackModel] + ); + + // Debounced generation effect + useEffect(() => { + // 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 since message changed + cancelPendingGeneration(); + + // Capture message for the debounced callback (avoid stale closure) + pendingMessageRef.current = message; + + // Debounce the generation + debounceTimerRef.current = setTimeout(() => { + 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, cancelPendingGeneration]); + + // 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); + setError(null); + }, []); + + const waitForGeneration = useCallback(async (): Promise => { + // 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; + } + + // 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; + const msg = pendingMessageRef.current; + pendingMessageRef.current = ""; + if (msg.trim()) { + return generateName(msg); + } + } + + // If generation is in progress, wait for it to complete + if (generationPromiseRef.current) { + return generationPromiseRef.current.promise; + } + + // If we have a name that was generated for the current message, use it + if (generatedName && lastGeneratedForRef.current === message) { + return generatedName; + } + + // Otherwise generate a fresh name for the current message + if (message.trim()) { + return generateName(message); + } + + return ""; + }, [autoGenerate, manualName, generatedName, 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..aa7d0011e2 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,24 @@ 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(), + /** Model to use if preferred small models (Haiku, GPT-Mini) aren't available */ + fallbackModel: z.string().optional(), + }), + 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..3d2e1178a1 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,29 @@ export const router = (authToken?: string) => { }), }, }, + nameGeneration: { + generate: t + .input(schemas.nameGeneration.generate.input) + .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; + if (!model) { + return { + success: false, + error: { + type: "unknown" as const, + raw: "No model available for name generation.", + }, + }; + } + 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 +261,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..5200d5bad9 100644 --- a/src/node/services/workspaceTitleGenerator.test.ts +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -1,37 +1,50 @@ import { describe, it, expect } from "bun:test"; -import { generatePlaceholderName } from "./workspaceTitleGenerator"; +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"; -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"); - }); +type CreateModelResult = Result; - it("should handle special characters", () => { - const result = generatePlaceholderName("Fix bug #123 in user/profile"); - expect(result).toBe("fix-bug-123-in-user-profile"); - }); +// Helper to create a mock AIService that succeeds for specific models +function createMockAIService(availableModels: string[]): AIService { + const service: Partial = { + createModel: (modelString: string): Promise => { + if (availableModels.includes(modelString)) { + // Return a minimal success result - data is not used by getPreferredNameModel + const result: CreateModelResult = { success: true, data: null as never }; + return Promise.resolve(result); + } + const err: CreateModelResult = { + success: false, + error: { type: "api_key_not_found", provider: "test" }, + }; + return Promise.resolve(err); + }, + }; + return service as AIService; +} - 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"); - }); +describe("workspaceTitleGenerator", () => { + const HAIKU_ID = getKnownModel("HAIKU").id; + const GPT_MINI_ID = getKnownModel("GPT_MINI").id; - it("should return default name for empty/whitespace input", () => { - expect(generatePlaceholderName("")).toBe("new-workspace"); - expect(generatePlaceholderName(" ")).toBe("new-workspace"); + it("getPreferredNameModel returns null when no models available", async () => { + const aiService = createMockAIService([]); + expect(await getPreferredNameModel(aiService)).toBeNull(); }); - it("should handle unicode characters", () => { - const result = generatePlaceholderName("Add émojis 🚀 and accénts"); - expect(result).toBe("add-mojis-and-acc-nts"); + 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("should handle only special characters", () => { - const result = generatePlaceholderName("!@#$%^&*()"); - expect(result).toBe("new-workspace"); + 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 47dba7a162..746f1cb585 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -5,6 +5,10 @@ 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 +19,22 @@ const workspaceNameSchema = z.object({ .describe("Git-safe branch/workspace name: lowercase, hyphens only"), }); +/** + * 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 async function getPreferredNameModel(aiService: AIService): Promise { + for (const modelId of PREFERRED_MODELS) { + 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; +} + /** * Generate workspace name using AI. * If AI cannot be used (e.g. missing credentials, unsupported provider, invalid model), @@ -64,12 +84,3 @@ function sanitizeBranchName(name: string, maxLength: number): string { 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/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)}`); } 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,