From 6bcbfbe5b994660968a558d2b887f83ec3b232e6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 01:38:22 -0400 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=A4=96=20Replace=20workspace=20name?= =?UTF-8?q?=20with=20auto-generated=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `name` field with `title` field in WorkspaceMetadata - Use workspace ID for directory names (not name/title) - Add autotitle service with AI-based title generation (claude-haiku-4) - Add WORKSPACE_GENERATE_TITLE IPC channel - Update workspace creation to use ID for directories - Title changes are purely cosmetic (no filesystem moves) - Display falls back to ID when title is undefined - Update UI to show title || id Breaking changes: - WorkspaceMetadata.name → WorkspaceMetadata.title - Directory names use workspace ID, not name - Tests need updating (next step) --- src/components/WorkspaceListItem.tsx | 35 +++--- src/config.ts | 44 ++++---- src/constants/ipc-constants.ts | 1 + src/preload.ts | 2 + src/services/agentSession.ts | 10 +- src/services/aiService.ts | 4 +- src/services/autotitle.ts | 137 +++++++++++++++++++++++ src/services/ipcMain.ts | 157 +++++++++++++++------------ src/types/ipc.ts | 1 + src/types/project.ts | 17 ++- src/types/workspace.ts | 31 +++--- 11 files changed, 302 insertions(+), 137 deletions(-) create mode 100644 src/services/autotitle.ts diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 25632fe729..03d2e51378 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -40,7 +40,7 @@ const WorkspaceListItemInner: React.FC = ({ onToggleUnread, }) => { // Destructure metadata for convenience - const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; + const { id: workspaceId, title, namedWorkspacePath } = metadata; // Subscribe to this specific workspace's sidebar state (streaming status, model, recency) const sidebarState = useWorkspaceSidebarState(workspaceId); const gitStatus = useGitStatus(workspaceId); @@ -48,12 +48,12 @@ const WorkspaceListItemInner: React.FC = ({ // Get rename context const { editingWorkspaceId, requestRename, confirmRename, cancelRename } = useRename(); - // Local state for rename - const [editingName, setEditingName] = useState(""); + // Local state for editing title + const [editingTitle, setEditingTitle] = useState(""); const [renameError, setRenameError] = useState(null); - // Use workspace name from metadata instead of deriving from path - const displayName = workspaceName; + // Display title if available, otherwise fall back to showing the workspace ID + const displayName = title || workspaceId; const isStreaming = sidebarState.canInterrupt; const streamingModel = sidebarState.currentModel; const isEditing = editingWorkspaceId === workspaceId; @@ -66,20 +66,18 @@ const WorkspaceListItemInner: React.FC = ({ const startRenaming = () => { if (requestRename(workspaceId, displayName)) { - setEditingName(displayName); + setEditingTitle(displayName); setRenameError(null); } }; const handleConfirmRename = async () => { - if (!editingName.trim()) { - setRenameError("Name cannot be empty"); - return; - } + // Empty title is OK - will fall back to showing ID + const newTitle = editingTitle.trim(); - const result = await confirmRename(workspaceId, editingName); + const result = await confirmRename(workspaceId, newTitle); if (!result.success) { - setRenameError(result.error ?? "Failed to rename workspace"); + setRenameError(result.error ?? "Failed to edit title"); } else { setRenameError(null); } @@ -87,7 +85,7 @@ const WorkspaceListItemInner: React.FC = ({ const handleCancelRename = () => { cancelRename(); - setEditingName(""); + setEditingTitle(""); setRenameError(null); }; @@ -182,15 +180,14 @@ const WorkspaceListItemInner: React.FC = ({ tooltipPosition="right" /> {isEditing ? ( - setEditingName(e.target.value)} + setEditingTitle(e.target.value)} onKeyDown={handleRenameKeyDown} onBlur={() => void handleConfirmRename()} autoFocus onClick={(e) => e.stopPropagation()} - aria-label={`Rename workspace ${displayName}`} + aria-label={`Edit title for workspace ${displayName}`} data-workspace-id={workspaceId} /> ) : ( @@ -200,7 +197,7 @@ const WorkspaceListItemInner: React.FC = ({ e.stopPropagation(); startRenaming(); }} - title="Double-click to rename" + title="Double-click to edit title" > {displayName} diff --git a/src/config.ts b/src/config.ts index 790f861b19..3e82a7bf9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -135,13 +135,14 @@ export class Config { /** * Compute workspace path from metadata. - * Directory uses workspace name (e.g., ~/.cmux/src/project/workspace-name). + * Directory uses workspace id (e.g., ~/.cmux/src/project/a1b2c3d4e5). + * For legacy workspaces, id may be in old format (e.g., cmux-feature-branch). */ getWorkspacePaths(metadata: WorkspaceMetadata): { - /** Worktree path (uses workspace name as directory) */ + /** Worktree path (uses workspace id as directory) */ namedWorkspacePath: string; } { - const path = this.getWorkspacePath(metadata.projectPath, metadata.name); + const path = this.getWorkspacePath(metadata.projectPath, metadata.id); return { namedWorkspacePath: path, }; @@ -258,10 +259,10 @@ export class Config { try { // NEW FORMAT: If workspace has metadata in config, use it directly - if (workspace.id && workspace.name) { + if (workspace.id) { const metadata: WorkspaceMetadata = { id: workspace.id, - name: workspace.name, + title: workspace.title, // May be undefined (OK - falls back to id in UI) projectName, projectPath, createdAt: workspace.createdAt, @@ -278,21 +279,20 @@ export class Config { if (fs.existsSync(metadataPath)) { const data = fs.readFileSync(metadataPath, "utf-8"); - let metadata = JSON.parse(data) as WorkspaceMetadata; - - // Ensure required fields are present - if (!metadata.name || !metadata.projectPath) { - metadata = { - ...metadata, - name: metadata.name ?? workspaceBasename, - projectPath: metadata.projectPath ?? projectPath, - projectName: metadata.projectName ?? projectName, - }; - } + const legacyMetadata = JSON.parse(data) as WorkspaceMetadata & { name?: string }; + + // Migrate from old format: name → no field (title will be generated) + const metadata: WorkspaceMetadata = { + id: legacyMetadata.id, + title: undefined, // Will be generated after first message + projectName: legacyMetadata.projectName ?? projectName, + projectPath: legacyMetadata.projectPath ?? projectPath, + createdAt: legacyMetadata.createdAt, + }; // Migrate to config for next load workspace.id = metadata.id; - workspace.name = metadata.name; + workspace.title = undefined; // Don't copy legacy name workspace.createdAt = metadata.createdAt; configModified = true; @@ -305,14 +305,14 @@ export class Config { const legacyId = this.generateWorkspaceId(projectPath, workspace.path); const metadata: WorkspaceMetadata = { id: legacyId, - name: workspaceBasename, + title: undefined, // Will be generated after first message projectName, projectPath, }; // Save to config for next load workspace.id = metadata.id; - workspace.name = metadata.name; + workspace.title = undefined; configModified = true; workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); @@ -323,7 +323,7 @@ export class Config { const legacyId = this.generateWorkspaceId(projectPath, workspace.path); const metadata: WorkspaceMetadata = { id: legacyId, - name: workspaceBasename, + title: undefined, // No title for fallback case projectName, projectPath, }; @@ -359,11 +359,11 @@ export class Config { // Check if workspace already exists (by ID) const existingIndex = project.workspaces.findIndex((w) => w.id === metadata.id); - const workspacePath = this.getWorkspacePath(projectPath, metadata.name); + const workspacePath = this.getWorkspacePath(projectPath, metadata.id); const workspaceEntry: Workspace = { path: workspacePath, id: metadata.id, - name: metadata.name, + title: metadata.title, createdAt: metadata.createdAt, }; diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index be42fd9ad0..5f7f183766 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -35,6 +35,7 @@ export const IPC_CHANNELS = { WORKSPACE_GET_INFO: "workspace:getInfo", WORKSPACE_EXECUTE_BASH: "workspace:executeBash", WORKSPACE_OPEN_TERMINAL: "workspace:openTerminal", + WORKSPACE_GENERATE_TITLE: "workspace:generateTitle", // Window channels WINDOW_SET_TITLE: "window:setTitle", diff --git a/src/preload.ts b/src/preload.ts index a42e597e94..f2346dcd3f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -72,6 +72,8 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), openTerminal: (workspacePath) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), + generateTitle: (workspaceId: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId), onChat: (workspaceId, callback) => { const channel = getChatChannel(workspaceId); diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index 394d631cde..ac1f102893 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -158,8 +158,8 @@ export class AgentSession { if (existing.success) { // Metadata already exists, verify workspace path matches const metadata = existing.data; - // Directory name uses workspace name (not stable ID) - const expectedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); + // Directory name uses workspace id (not title) + const expectedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.id); assert( expectedPath === normalizedWorkspacePath, `Existing metadata workspace path mismatch for ${this.workspaceId}: expected ${expectedPath}, got ${normalizedWorkspacePath}` @@ -175,12 +175,10 @@ export class AgentSession { ? projectName.trim() : path.basename(derivedProjectPath) || "unknown"; - // Extract name from workspace path (last component) - const workspaceName = path.basename(normalizedWorkspacePath); - + // No title initially - will be auto-generated after first message const metadata: WorkspaceMetadata = { id: this.workspaceId, - name: workspaceName, + title: undefined, projectName: derivedProjectName, projectPath: derivedProjectPath, }; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 6ec1017f55..cf5ef4dcc9 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -495,8 +495,8 @@ export class AIService extends EventEmitter { return Err({ type: "unknown", raw: `Workspace ${workspaceId} not found in config` }); } - // Get workspace path (directory name uses workspace name) - const workspacePath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); + // Get workspace path (directory name uses workspace id) + const workspacePath = this.config.getWorkspacePath(metadata.projectPath, metadata.id); // Build system message from workspace metadata const systemMessage = await buildSystemMessage( diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts new file mode 100644 index 0000000000..a66b1f1718 --- /dev/null +++ b/src/services/autotitle.ts @@ -0,0 +1,137 @@ +import { generateText } from "ai"; +import type { LanguageModel } from "ai"; +import type { Result } from "@/types/result"; +import { Ok, Err } from "@/types/result"; +import type { HistoryService } from "./historyService"; +import { log } from "./log"; +import { getTokenizerForModel } from "@/utils/main/tokenizer"; + +/** + * AutotitleService - Generates concise titles for workspaces based on conversation history + * + * Key design decisions: + * - Uses first 2-3 messages or ~2000 tokens from history (enough context without being expensive) + * - Uses claude-haiku-4 for speed and cost (~$0.0025 per title) + * - Generates after first assistant response and on compaction + * - Never generates for empty workspaces to avoid "Untitled" placeholders + */ + +const AUTOTITLE_TOKEN_LIMIT = 2000; +const AUTOTITLE_OUTPUT_TOKENS = 150; + +/** + * Prompt strategy: Ask for concise 3-7 word title that captures main topic + * Emphasize ONLY the title (no quotes, no explanation) for clean output + */ +const TITLE_GENERATION_PROMPT = `Generate a concise 3-7 word title that captures the main topic of this conversation. Respond with ONLY the title, no quotes or explanation.`; + +/** + * Generate a workspace title based on conversation history + * @param workspaceId - Workspace identifier + * @param historyService - Service to retrieve chat history + * @param model - Language model to use for generation (should be a fast, cheap model like haiku) + * @returns Result containing generated title or error + */ +export async function generateWorkspaceTitle( + workspaceId: string, + historyService: HistoryService, + model: LanguageModel +): Promise> { + try { + // Get conversation history + const historyResult = await historyService.getHistory(workspaceId); + if (!historyResult.success) { + return Err(`Failed to get history: ${historyResult.error}`); + } + + const messages = historyResult.data; + + // Don't generate title for empty workspaces + if (messages.length === 0) { + return Err("Cannot generate title for empty workspace"); + } + + // Take first few messages up to token limit + // This gives enough context without excessive cost + const modelStr = typeof model === "string" ? model : model.modelId; + const tokenizer = getTokenizerForModel(modelStr); + let tokensUsed = 0; + const selectedMessages = []; + + for (const message of messages) { + // Estimate tokens for this message + const messageText = JSON.stringify(message); + const messageTokens = await tokenizer.count(messageText); + + if (tokensUsed + messageTokens > AUTOTITLE_TOKEN_LIMIT) { + break; + } + + selectedMessages.push(message); + tokensUsed += messageTokens; + } + + // Need at least one message to generate a title + if (selectedMessages.length === 0) { + return Err("No messages available for title generation"); + } + + // Format messages for the model + const conversationContext = selectedMessages + .map((msg) => { + const role = msg.role === "user" ? "User" : "Assistant"; + // Handle both old string format and new content format + let contentText: string; + if (typeof msg.content === "string") { + contentText = msg.content; + } else if (Array.isArray(msg.content)) { + contentText = msg.content + .map((c: { type?: string; text?: string }) => { + if ("text" in c && c.text) return c.text; + return "[non-text content]"; + }) + .join(" "); + } else { + contentText = String(msg.content); + } + return `${role}: ${contentText}`; + }) + .join("\n\n"); + + log.debug(`[Autotitle] Generating title for workspace ${workspaceId}`, { + messageCount: selectedMessages.length, + tokensUsed, + }); + + // Generate title using AI + const result = await generateText({ + model, + prompt: `${conversationContext}\n\n${TITLE_GENERATION_PROMPT}`, + maxTokens: AUTOTITLE_OUTPUT_TOKENS, + temperature: 0.3, // Lower temperature for more focused titles + }); + + const title = result.text.trim(); + + // Validate title length (should be reasonable) + if (title.length === 0) { + return Err("Generated title is empty"); + } + + if (title.length > 200) { + // Truncate excessively long titles + const truncated = title.substring(0, 200).trim(); + log.error(`[Autotitle] Generated title too long (${title.length} chars), truncated to 200`); + return Ok(truncated); + } + + log.debug(`[Autotitle] Generated title for workspace ${workspaceId}: "${title}"`); + + return Ok(title); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`[Autotitle] Failed to generate title for workspace ${workspaceId}:`, error); + return Err(`Title generation failed: ${message}`); + } +} + diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 465d9ad210..609951ddf2 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -191,23 +191,24 @@ export class IpcMain { const normalizedTrunkBranch = trunkBranch.trim(); - // Generate stable workspace ID (stored in config, not used for directory name) + // Generate stable workspace ID (used for both config and directory name) const workspaceId = this.config.generateStableId(); - // Create the git worktree with the workspace name as directory name + // Create the git worktree with the workspace ID as directory name + // This allows title changes without filesystem moves const result = await createWorktree(this.config, projectPath, branchName, { trunkBranch: normalizedTrunkBranch, - workspaceId: branchName, // Use name for directory (workspaceId param is misnamed, it's directoryName) + workspaceId: workspaceId, // Use stable ID for directory }); if (result.success && result.path) { const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - // Initialize workspace metadata with stable ID and name + // Initialize workspace metadata with stable ID (no title initially) const metadata = { id: workspaceId, - name: branchName, // Name is separate from ID + title: undefined, // Title will be auto-generated after first message projectName, projectPath, // Full project path for computing worktree path createdAt: new Date().toISOString(), @@ -228,7 +229,7 @@ export class IpcMain { projectConfig.workspaces.push({ path: result.path!, id: workspaceId, - name: branchName, + title: undefined, // No title yet createdAt: metadata.createdAt, }); return config; @@ -267,21 +268,10 @@ export class IpcMain { ipcMain.handle( IPC_CHANNELS.WORKSPACE_RENAME, - (_event, workspaceId: string, newName: string) => { + (_event, workspaceId: string, newTitle: string) => { try { - // Block rename during active streaming to prevent race conditions - // (bash processes would have stale cwd, system message would be wrong) - if (this.aiService.isStreaming(workspaceId)) { - return Err( - "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." - ); - } - - // Validate workspace name - const validation = validateWorkspaceName(newName); - if (!validation.valid) { - return Err(validation.error ?? "Invalid workspace name"); - } + // Editing title is allowed during streaming (doesn't affect filesystem) + // Title is purely cosmetic - no filesystem operations needed // Get current metadata const metadataResult = this.aiService.getWorkspaceMetadata(workspaceId); @@ -289,69 +279,97 @@ export class IpcMain { return Err(`Failed to get workspace metadata: ${metadataResult.error}`); } const oldMetadata = metadataResult.data; - const oldName = oldMetadata.name; + const oldTitle = oldMetadata.title; - // If renaming to itself, just return success (no-op) - if (newName === oldName) { + // If setting to same title, just return success (no-op) + if (newTitle === oldTitle) { return Ok({ newWorkspaceId: workspaceId }); } - // Check if new name collides with existing workspace name or ID - const allWorkspaces = this.config.getAllWorkspaceMetadata(); - const collision = allWorkspaces.find( - (ws) => (ws.name === newName || ws.id === newName) && ws.id !== workspaceId - ); - if (collision) { - return Err(`Workspace with name "${newName}" already exists`); + // Titles can be duplicate (IDs ensure uniqueness) + // Empty title is OK (falls back to showing ID) + + // Update config with new title (no filesystem changes) + this.config.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + workspace.title = newTitle || undefined; // Empty string becomes undefined + break; + } + } + return config; + }); + + // Get updated metadata from config + const allMetadata = this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!updatedMetadata) { + return Err("Failed to retrieve updated workspace metadata"); } - // Find project path from config - const workspace = this.config.findWorkspace(workspaceId); - if (!workspace) { - return Err("Failed to find workspace in config"); + // Emit metadata event with updated title (same workspace ID) + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else if (this.mainWindow) { + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata: updatedMetadata, + }); } - const { projectPath, workspacePath } = workspace; - // Compute new path (based on name) - const oldPath = workspacePath; - const newPath = this.config.getWorkspacePath(projectPath, newName); + return Ok({ newWorkspaceId: workspaceId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to edit title: ${message}`); + } + } + ); - // Use git worktree move to rename the worktree directory - // This updates git's internal worktree metadata correctly - try { - const result = spawnSync("git", ["worktree", "move", oldPath, newPath], { - cwd: projectPath, - }); - if (result.status !== 0) { - const stderr = result.stderr?.toString() || "Unknown error"; - return Err(`Failed to move worktree: ${stderr}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to move worktree: ${message}`); + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, + async (_event, workspaceId: string) => { + try { + const { generateWorkspaceTitle } = await import("@/services/autotitle"); + const { anthropic } = await import("@ai-sdk/anthropic"); + + // Generate title using Haiku (fast and cheap) + const model = anthropic("claude-haiku-4"); + const result = await generateWorkspaceTitle( + workspaceId, + this.historyService, + model + ); + + if (!result.success) { + return Err(result.error); } - // Update config with new name and path + const title = result.data; + + // Update config with new title this.config.editConfig((config) => { - const projectConfig = config.projects.get(projectPath); - if (projectConfig) { - const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath); - if (workspaceEntry) { - workspaceEntry.name = newName; - workspaceEntry.path = newPath; // Update path to reflect new directory name + for (const [projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find( + (w) => w.id === workspaceId + ); + if (workspace) { + workspace.title = title; + break; } } return config; }); - // Get updated metadata from config (includes updated name and paths) + // Get updated metadata from config const allMetadata = this.config.getAllWorkspaceMetadata(); const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); if (!updatedMetadata) { return Err("Failed to retrieve updated workspace metadata"); } - // Emit metadata event with updated metadata (same workspace ID) + // Emit metadata event with updated title const session = this.sessions.get(workspaceId); if (session) { session.emitMetadata(updatedMetadata); @@ -362,14 +380,15 @@ export class IpcMain { }); } - return Ok({ newWorkspaceId: workspaceId }); + return Ok({ title }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to rename workspace: ${message}`); + return Err(`Failed to generate title: ${message}`); } } ); + ipcMain.handle( IPC_CHANNELS.WORKSPACE_FORK, async (_event, sourceWorkspaceId: string, newName: string) => { @@ -397,10 +416,10 @@ export class IpcMain { const sourceMetadata = sourceMetadataResult.data; const foundProjectPath = sourceMetadata.projectPath; - // Compute source workspace path from metadata (use name for directory lookup) + // Compute source workspace path from metadata (use id for directory lookup) const sourceWorkspacePath = this.config.getWorkspacePath( foundProjectPath, - sourceMetadata.name + sourceMetadata.id ); // Get current branch from source workspace (fork from current branch, not trunk) @@ -418,7 +437,7 @@ export class IpcMain { // Create new git worktree branching from source workspace's branch const result = await createWorktree(this.config, foundProjectPath, newName, { trunkBranch: sourceBranch, - workspaceId: newName, // Use name for directory (workspaceId param is misnamed, it's directoryName) + workspaceId: newWorkspaceId, // Use stable ID for directory }); if (!result.success || !result.path) { @@ -478,10 +497,10 @@ export class IpcMain { return { success: false, error: `Failed to copy chat history: ${message}` }; } - // Initialize workspace metadata with stable ID and name + // Initialize workspace metadata with stable ID (no title initially) const metadata: WorkspaceMetadata = { id: newWorkspaceId, - name: newName, // Name is separate from ID + title: undefined, // Title will be auto-generated after first message projectName, projectPath: foundProjectPath, createdAt: new Date().toISOString(), @@ -740,8 +759,8 @@ export class IpcMain { return Err(`Workspace ${workspaceId} not found in config`); } - // Get workspace path (directory name uses workspace name) - const namedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); + // Get workspace path (directory name uses workspace id) + const namedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.id); // Load project secrets const projectSecrets = this.config.getProjectSecrets(metadata.projectPath); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index e513ba3b7b..c4d05f8cf3 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -228,6 +228,7 @@ export interface IPCApi { } ): Promise>; openTerminal(workspacePath: string): Promise; + generateTitle(workspaceId: string): Promise>; // Event subscriptions (renderer-only) // These methods are designed to send current state immediately upon subscription, diff --git a/src/types/project.ts b/src/types/project.ts index 682aa4ace9..941b9bf52c 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -6,17 +6,19 @@ /** * Workspace configuration in config.json. * - * NEW FORMAT (preferred, used for all new workspaces): + * NEW FORMAT (with autotitle, used for all new workspaces): * { * "path": "~/.cmux/src/project/workspace-id", // Kept for backward compat - * "id": "a1b2c3d4e5", // Stable workspace ID - * "name": "feature-branch", // User-facing name + * "id": "a1b2c3d4e5", // Stable workspace ID (used for directory) + * "title": "Fix parser bug", // Auto-generated display title * "createdAt": "2024-01-01T00:00:00Z" // Creation timestamp * } * * LEGACY FORMAT (old workspaces, still supported): * { - * "path": "~/.cmux/src/project/workspace-id" // Only field present + * "path": "~/.cmux/src/project/workspace-id", // Only field present + * "id": "cmux-old-workspace", // May be old format + * "name": "old-workspace" // Legacy field, ignored * } * * For legacy entries, metadata is read from ~/.cmux/sessions/{workspaceId}/metadata.json @@ -28,11 +30,14 @@ export interface Workspace { /** Stable workspace ID (10 hex chars for new workspaces) - optional for legacy */ id?: string; - /** User-facing workspace name - optional for legacy */ - name?: string; + /** Auto-generated workspace title for display - optional (falls back to id) */ + title?: string; /** ISO 8601 creation timestamp - optional for legacy */ createdAt?: string; + + /** @deprecated Legacy field - replaced by title, ignored on load */ + name?: string; } export interface ProjectConfig { diff --git a/src/types/workspace.ts b/src/types/workspace.ts index 4dca240b7f..593c248023 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -5,11 +5,12 @@ import { z } from "zod"; */ export const WorkspaceMetadataSchema = z.object({ id: z.string().min(1, "Workspace ID is required"), - name: z.string().min(1, "Workspace name is required"), + title: z.string().optional(), // Auto-generated from conversation, optional for backward compatibility projectName: z.string().min(1, "Project name is required"), projectPath: z.string().min(1, "Project path is required"), createdAt: z.string().optional(), // ISO 8601 timestamp (optional for backward compatibility) - // Legacy field - ignored on load, removed on save + // Legacy fields - ignored on load, removed on save + name: z.string().optional(), // Legacy field, replaced by title workspacePath: z.string().optional(), }); @@ -17,29 +18,33 @@ export const WorkspaceMetadataSchema = z.object({ * Unified workspace metadata type used throughout the application. * This is the single source of truth for workspace information. * - * ID vs Name: + * ID vs Title: * - `id`: Stable unique identifier (10 hex chars for new workspaces, legacy format for old) - * Generated once at creation, never changes - * - `name`: User-facing mutable name (e.g., "feature-branch") - * Can be changed via rename operation + * Generated once at creation, never changes. Used for filesystem directory names. + * - `title`: Auto-generated user-facing label (e.g., "Fix parser bug") + * Generated from conversation content after first message. Purely cosmetic. + * Editing title never affects filesystem - it's just for display. * * For legacy workspaces created before stable IDs: - * - id and name are the same (e.g., "cmux-stable-ids") - * For new workspaces: + * - id is the old format (e.g., "cmux-stable-ids") + * - title generates lazily on first use + * For new workspaces (with autotitle): * - id is a random 10 hex char string (e.g., "a1b2c3d4e5") - * - name is the branch/workspace name (e.g., "feature-branch") + * - title is undefined initially, generated after first message + * - Directory uses id: ~/.cmux/src/project/{id} * * Path handling: - * - Worktree paths are computed on-demand via config.getWorkspacePath(projectPath, id) + * - New workspaces: Directory is id-based (e.g., ~/.cmux/src/project/a1b2c3d4e5) + * - Legacy workspaces: Directory may use old name format + * - Worktree paths computed via config.getWorkspacePath(projectPath, id) * - This avoids storing redundant derived data - * - Frontend can show symlink paths, backend uses real paths */ export interface WorkspaceMetadata { /** Stable unique identifier (10 hex chars for new workspaces, legacy format for old) */ id: string; - /** User-facing workspace name (e.g., "feature-branch") */ - name: string; + /** Auto-generated title for display (optional, falls back to id if undefined) */ + title?: string; /** Project name extracted from project path (for display) */ projectName: string; From 0d4ba0b050645a82cb71c56e027d875d49879ebb Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 01:43:34 -0400 Subject: [PATCH 02/17] Fix tests and types for autotitle feature - Update all test files to use `title` instead of `name` - Fix autotitle service to use correct API (generateText with maxTokens) - Fix autotitle service to use CmuxMessage.parts instead of content - Add generateTitle to browser API and App.stories mocks - Update App.tsx to use title || id for window titles - Fix GitStatusStore and other test files Remaining: GitStatusStore.test.ts has Map type errors (non-blocking) --- src/App.stories.tsx | 54 ++++++++++++++++----------- src/App.tsx | 12 +++--- src/browser/api.ts | 1 + src/config.test.ts | 8 ++-- src/services/autotitle.ts | 25 ++++--------- src/services/systemMessage.test.ts | 10 ++--- src/stores/WorkspaceStore.test.ts | 28 +++++++------- src/utils/commands/sources.ts | 36 +++++++++--------- tests/ipcMain/executeBash.test.ts | 2 +- tests/ipcMain/removeWorkspace.test.ts | 10 ++--- 10 files changed, 94 insertions(+), 92 deletions(-) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 0a44d052f4..72e10f68b2 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -32,7 +32,7 @@ function setupMockAPI(options: { success: true, metadata: { id: `${projectPath.split("/").pop() ?? "project"}-${branchName}`, - name: branchName, + title: branchName, projectPath, projectName: projectPath.split("/").pop() ?? "project", namedWorkspacePath: `/mock/workspace/${branchName}`, @@ -60,6 +60,11 @@ function setupMockAPI(options: { success: true, data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, }), + generateTitle: () => + Promise.resolve({ + success: true, + data: { title: "Generated Title" }, + }), }, projects: { list: () => Promise.resolve(Array.from(mockProjects.entries())), @@ -135,16 +140,16 @@ export const SingleProject: Story = { "/home/user/projects/my-app", { workspaces: [ - { path: "/home/user/.cmux/src/my-app/main", id: "my-app-main", name: "main" }, + { path: "/home/user/.cmux/src/my-app/main", id: "my-app-main", title: "main" }, { path: "/home/user/.cmux/src/my-app/feature-auth", id: "my-app-feature-auth", - name: "feature/auth", + title: "feature/auth", }, { path: "/home/user/.cmux/src/my-app/bugfix", id: "my-app-bugfix", - name: "bugfix/memory-leak", + title: "bugfix/memory-leak", }, ], }, @@ -154,21 +159,21 @@ export const SingleProject: Story = { const workspaces: FrontendWorkspaceMetadata[] = [ { id: "my-app-main", - name: "main", + title: "main", projectPath: "/home/user/projects/my-app", projectName: "my-app", namedWorkspacePath: "/home/user/.cmux/src/my-app/main", }, { id: "my-app-feature-auth", - name: "feature/auth", + title: "feature/auth", projectPath: "/home/user/projects/my-app", projectName: "my-app", namedWorkspacePath: "/home/user/.cmux/src/my-app/feature-auth", }, { id: "my-app-bugfix", - name: "bugfix/memory-leak", + title: "bugfix/memory-leak", projectPath: "/home/user/projects/my-app", projectName: "my-app", namedWorkspacePath: "/home/user/.cmux/src/my-app/bugfix", @@ -186,11 +191,11 @@ export const MultipleProjects: Story = { "/home/user/projects/frontend", { workspaces: [ - { path: "/home/user/.cmux/src/frontend/main", id: "frontend-main", name: "main" }, + { path: "/home/user/.cmux/src/frontend/main", id: "frontend-main", title: "main" }, { path: "/home/user/.cmux/src/frontend/redesign", id: "frontend-redesign", - name: "redesign", + title: "redesign", }, ], }, @@ -199,12 +204,12 @@ export const MultipleProjects: Story = { "/home/user/projects/backend", { workspaces: [ - { path: "/home/user/.cmux/src/backend/main", id: "backend-main", name: "main" }, - { path: "/home/user/.cmux/src/backend/api-v2", id: "backend-api-v2", name: "api-v2" }, + { path: "/home/user/.cmux/src/backend/main", id: "backend-main", title: "main" }, + { path: "/home/user/.cmux/src/backend/api-v2", id: "backend-api-v2", title: "api-v2" }, { path: "/home/user/.cmux/src/backend/db-migration", id: "backend-db-migration", - name: "db-migration", + title: "db-migration", }, ], }, @@ -213,7 +218,7 @@ export const MultipleProjects: Story = { "/home/user/projects/mobile", { workspaces: [ - { path: "/home/user/.cmux/src/mobile/main", id: "mobile-main", name: "main" }, + { path: "/home/user/.cmux/src/mobile/main", id: "mobile-main", title: "main" }, ], }, ], @@ -222,42 +227,42 @@ export const MultipleProjects: Story = { const workspaces: FrontendWorkspaceMetadata[] = [ { id: "frontend-main", - name: "main", + title: "main", projectPath: "/home/user/projects/frontend", projectName: "frontend", namedWorkspacePath: "/home/user/.cmux/src/frontend/main", }, { id: "frontend-redesign", - name: "redesign", + title: "redesign", projectPath: "/home/user/projects/frontend", projectName: "frontend", namedWorkspacePath: "/home/user/.cmux/src/frontend/redesign", }, { id: "backend-main", - name: "main", + title: "main", projectPath: "/home/user/projects/backend", projectName: "backend", namedWorkspacePath: "/home/user/.cmux/src/backend/main", }, { id: "backend-api-v2", - name: "api-v2", + title: "api-v2", projectPath: "/home/user/projects/backend", projectName: "backend", namedWorkspacePath: "/home/user/.cmux/src/backend/api-v2", }, { id: "backend-db-migration", - name: "db-migration", + title: "db-migration", projectPath: "/home/user/projects/backend", projectName: "backend", namedWorkspacePath: "/home/user/.cmux/src/backend/db-migration", }, { id: "mobile-main", - name: "main", + title: "main", projectPath: "/home/user/projects/mobile", projectName: "mobile", namedWorkspacePath: "/home/user/.cmux/src/mobile/main", @@ -318,7 +323,7 @@ export const ActiveWorkspaceWithChat: Story = { "/home/user/projects/my-app", { workspaces: [ - { path: "/home/user/.cmux/src/my-app/feature", id: workspaceId, name: "feature/auth" }, + { path: "/home/user/.cmux/src/my-app/feature", id: workspaceId, title: "feature/auth" }, ], }, ], @@ -327,7 +332,7 @@ export const ActiveWorkspaceWithChat: Story = { const workspaces: FrontendWorkspaceMetadata[] = [ { id: workspaceId, - name: "feature/auth", + title: "feature/auth", projectPath: "/home/user/projects/my-app", projectName: "my-app", namedWorkspacePath: "/home/user/.cmux/src/my-app/feature", @@ -352,7 +357,7 @@ export const ActiveWorkspaceWithChat: Story = { success: true, metadata: { id: `${projectPath.split("/").pop() ?? "project"}-${branchName}`, - name: branchName, + title: branchName, projectPath, projectName: projectPath.split("/").pop() ?? "project", namedWorkspacePath: `/mock/workspace/${branchName}`, @@ -597,6 +602,11 @@ export const ActiveWorkspaceWithChat: Story = { success: true, data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, }), + generateTitle: () => + Promise.resolve({ + success: true, + data: { title: "Generated Title" }, + }), }, }, }); diff --git a/src/App.tsx b/src/App.tsx index b74b928a11..607dfcff0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,10 +141,10 @@ function AppInner() { window.history.replaceState(null, "", newHash); } - // Update window title with workspace name - const workspaceName = - workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId; - const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; + // Update window title with workspace title or ID + const workspaceTitle = + workspaceMetadata.get(selectedWorkspace.workspaceId)?.title ?? selectedWorkspace.workspaceId; + const title = `${workspaceTitle} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { // Clear hash when no workspace selected @@ -359,11 +359,11 @@ function AppInner() { if ( !compareMaps(prev, next, (a, b) => { if (a.length !== b.length) return false; - // Check both ID and name to detect renames + // Check both ID and title to detect changes return a.every((metadata, i) => { const bMeta = b[i]; if (!bMeta || !metadata) return false; // Null-safe - return metadata.id === bMeta.id && metadata.name === bMeta.name; + return metadata.id === bMeta.id && metadata.title === bMeta.title; }); }) ) { diff --git a/src/browser/api.ts b/src/browser/api.ts index 4be41e43d1..1acf495f20 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -243,6 +243,7 @@ const webApi: IPCApi = { executeBash: (workspaceId, script, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), + generateTitle: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId), onChat: (workspaceId, callback) => { const channel = getChatChannel(workspaceId); diff --git a/src/config.test.ts b/src/config.test.ts index 23bac50abd..bdb3fe2e13 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -57,7 +57,7 @@ describe("Config", () => { expect(allMetadata).toHaveLength(1); const metadata = allMetadata[0]; expect(metadata.id).toBe("project-feature-branch"); // Legacy ID format - expect(metadata.name).toBe("feature-branch"); + expect(metadata.title).toBe("feature-branch"); expect(metadata.projectName).toBe("project"); expect(metadata.projectPath).toBe(projectPath); @@ -68,7 +68,7 @@ describe("Config", () => { expect(projectConfig!.workspaces).toHaveLength(1); const workspace = projectConfig!.workspaces[0]; expect(workspace.id).toBe("project-feature-branch"); - expect(workspace.name).toBe("feature-branch"); + expect(workspace.title).toBe("feature-branch"); }); it("should use existing metadata file if present (legacy format)", () => { @@ -107,7 +107,7 @@ describe("Config", () => { expect(allMetadata).toHaveLength(1); const metadata = allMetadata[0]; expect(metadata.id).toBe(legacyId); - expect(metadata.name).toBe(workspaceName); + expect(metadata.title).toBe(workspaceName); expect(metadata.createdAt).toBe("2025-01-01T00:00:00.000Z"); // Verify metadata was migrated to config @@ -117,7 +117,7 @@ describe("Config", () => { expect(projectConfig!.workspaces).toHaveLength(1); const workspace = projectConfig!.workspaces[0]; expect(workspace.id).toBe(legacyId); - expect(workspace.name).toBe(workspaceName); + expect(workspace.title).toBe(workspaceName); expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z"); }); }); diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts index a66b1f1718..c68e3a8ebb 100644 --- a/src/services/autotitle.ts +++ b/src/services/autotitle.ts @@ -61,7 +61,7 @@ export async function generateWorkspaceTitle( for (const message of messages) { // Estimate tokens for this message const messageText = JSON.stringify(message); - const messageTokens = await tokenizer.count(messageText); + const messageTokens = tokenizer.countTokens(messageText); if (tokensUsed + messageTokens > AUTOTITLE_TOKEN_LIMIT) { break; @@ -80,21 +80,12 @@ export async function generateWorkspaceTitle( const conversationContext = selectedMessages .map((msg) => { const role = msg.role === "user" ? "User" : "Assistant"; - // Handle both old string format and new content format - let contentText: string; - if (typeof msg.content === "string") { - contentText = msg.content; - } else if (Array.isArray(msg.content)) { - contentText = msg.content - .map((c: { type?: string; text?: string }) => { - if ("text" in c && c.text) return c.text; - return "[non-text content]"; - }) - .join(" "); - } else { - contentText = String(msg.content); - } - return `${role}: ${contentText}`; + // Extract text from parts array + const textParts = msg.parts + .filter((part) => part.type === "text") + .map((part) => ("text" in part ? part.text : "")) + .join(" "); + return `${role}: ${textParts || "[no text content]"}`; }) .join("\n\n"); @@ -107,7 +98,7 @@ export async function generateWorkspaceTitle( const result = await generateText({ model, prompt: `${conversationContext}\n\n${TITLE_GENERATION_PROMPT}`, - maxTokens: AUTOTITLE_OUTPUT_TOKENS, + maxTokens: 150, // Single generation step temperature: 0.3, // Lower temperature for more focused titles }); diff --git a/src/services/systemMessage.test.ts b/src/services/systemMessage.test.ts index 40e50589cc..3f0e731aaf 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -47,7 +47,7 @@ Use diagrams where appropriate. const metadata: WorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: tempDir, }; @@ -78,7 +78,7 @@ Focus on planning and design. const metadata: WorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: tempDir, }; @@ -117,7 +117,7 @@ Workspace plan instructions (should win). const metadata: WorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: tempDir, }; @@ -152,7 +152,7 @@ Just general workspace stuff. const metadata: WorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: tempDir, }; @@ -173,7 +173,7 @@ Special mode instructions. const metadata: WorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: tempDir, }; diff --git a/src/stores/WorkspaceStore.test.ts b/src/stores/WorkspaceStore.test.ts index a10d7064bf..c3b8774033 100644 --- a/src/stores/WorkspaceStore.test.ts +++ b/src/stores/WorkspaceStore.test.ts @@ -64,7 +64,7 @@ describe("WorkspaceStore", () => { // Create workspace metadata const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -91,7 +91,7 @@ describe("WorkspaceStore", () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -113,7 +113,7 @@ describe("WorkspaceStore", () => { it("should add new workspaces", () => { const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", - name: "workspace-1", + title: "workspace-1", projectName: "project-1", projectPath: "/project-1", namedWorkspacePath: "/path/1", @@ -131,7 +131,7 @@ describe("WorkspaceStore", () => { it("should remove deleted workspaces", () => { const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", - name: "workspace-1", + title: "workspace-1", projectName: "project-1", projectPath: "/project-1", namedWorkspacePath: "/path/1", @@ -193,7 +193,7 @@ describe("WorkspaceStore", () => { it("should call onModelUsed when stream starts", async () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -237,7 +237,7 @@ describe("WorkspaceStore", () => { it("getWorkspaceState() returns same reference when state hasn't changed", () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -288,7 +288,7 @@ describe("WorkspaceStore", () => { it("invalidates getWorkspaceState() cache when workspace changes", async () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -326,7 +326,7 @@ describe("WorkspaceStore", () => { it("invalidates getAllStates() cache when workspace changes", async () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -363,7 +363,7 @@ describe("WorkspaceStore", () => { it("invalidates getWorkspaceRecency() cache when workspace changes", async () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -386,7 +386,7 @@ describe("WorkspaceStore", () => { it("maintains cache when no changes occur", () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -414,7 +414,7 @@ describe("WorkspaceStore", () => { it("handles IPC message for removed workspace gracefully", async () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", @@ -460,14 +460,14 @@ describe("WorkspaceStore", () => { it("handles concurrent workspace additions", () => { const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", - name: "workspace-1", + title: "workspace-1", projectName: "project-1", projectPath: "/project-1", namedWorkspacePath: "/path/1", }; const metadata2: FrontendWorkspaceMetadata = { id: "workspace-2", - name: "workspace-2", + title: "workspace-2", projectName: "project-2", projectPath: "/project-2", namedWorkspacePath: "/path/2", @@ -486,7 +486,7 @@ describe("WorkspaceStore", () => { it("handles workspace removal during state access", () => { const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", - name: "test-workspace", + title: "test-workspace", projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: "/test/project/test-workspace", diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 9c5fa97936..4373e7251e 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -94,23 +94,23 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const isStreaming = p.streamingModels?.has(meta.id) ?? false; list.push({ id: `ws:switch:${meta.id}`, - title: `${isCurrent ? "• " : ""}Switch to ${meta.name}`, + title: `${isCurrent ? "• " : ""}Switch to ${meta.title}`, subtitle: `${meta.projectName}${isStreaming ? " • streaming" : ""}`, section: section.workspaces, - keywords: [meta.name, meta.projectName, meta.namedWorkspacePath], + keywords: [meta.title, meta.projectName, meta.titledWorkspacePath], run: () => p.onSelectWorkspace({ projectPath: meta.projectPath, projectName: meta.projectName, - namedWorkspacePath: meta.namedWorkspacePath, + namedWorkspacePath: meta.titledWorkspacePath, workspaceId: meta.id, }), }); } // Remove current workspace (rename action intentionally omitted until we add a proper modal) - if (selected?.namedWorkspacePath) { - const workspaceDisplayName = `${selected.projectName}/${selected.namedWorkspacePath.split("/").pop() ?? selected.namedWorkspacePath}`; + if (selected?.titledWorkspacePath) { + const workspaceDisplayName = `${selected.projectName}/${selected.titledWorkspacePath.split("/").pop() ?? selected.titledWorkspacePath}`; list.push({ id: "ws:open-terminal-current", title: "Open Current Workspace in Terminal", @@ -146,8 +146,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi label: "New name", placeholder: "Enter new workspace name", // Use workspace metadata name (not path) for initial value - initialValue: p.workspaceMetadata.get(selected.workspaceId)?.name ?? "", - getInitialValue: () => p.workspaceMetadata.get(selected.workspaceId)?.name ?? "", + initialValue: p.workspaceMetadata.get(selected.workspaceId)?.title ?? "", + getInitialValue: () => p.workspaceMetadata.get(selected.workspaceId)?.title ?? "", validate: (v) => (!v.trim() ? "Name is required" : null), }, ], @@ -169,17 +169,17 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - name: "workspaceId", + title: "workspaceId", label: "Workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { // Use workspace name instead of extracting from path - const label = `${meta.projectName} / ${meta.name}`; + const label = `${meta.projectName} / ${meta.title}`; return { id: meta.id, label, - keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], + keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], }; }), }, @@ -199,16 +199,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - name: "workspaceId", + title: "workspaceId", label: "Select workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const label = `${meta.projectName} / ${meta.name}`; + const label = `${meta.projectName} / ${meta.title}`; return { id: meta.id, label, - keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], + keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], }; }), }, @@ -221,7 +221,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === values.workspaceId ); - return meta ? meta.name : ""; + return meta ? meta.title : ""; }, validate: (v) => (!v.trim() ? "Name is required" : null), }, @@ -241,16 +241,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - name: "workspaceId", + title: "workspaceId", label: "Select workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const label = `${meta.projectName}/${meta.name}`; + const label = `${meta.projectName}/${meta.title}`; return { id: meta.id, label, - keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], + keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], }; }), }, @@ -259,7 +259,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === vals.workspaceId ); - const workspaceName = meta ? `${meta.projectName}/${meta.name}` : vals.workspaceId; + const workspaceName = meta ? `${meta.projectName}/${meta.title}` : vals.workspaceId; const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); if (ok) { await p.onRemoveWorkspace(vals.workspaceId); diff --git a/tests/ipcMain/executeBash.test.ts b/tests/ipcMain/executeBash.test.ts index bfbc9b3164..8bcc20f932 100644 --- a/tests/ipcMain/executeBash.test.ts +++ b/tests/ipcMain/executeBash.test.ts @@ -39,7 +39,7 @@ describeIntegration("IpcMain executeBash integration tests", () => { expect(pwdResult.success).toBe(true); expect(pwdResult.data.success).toBe(true); // Verify pwd output contains the workspace name (directories are named with workspace names) - expect(pwdResult.data.output).toContain(metadata.name); + expect(pwdResult.data.output).toContain(metadata.title); expect(pwdResult.data.exitCode).toBe(0); // Clean up diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index 408094d37b..76b786dbf4 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -31,7 +31,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.namedWorkspacePath; + const workspacePath = metadata.titledWorkspacePath; // Verify the worktree exists const worktreeExistsBefore = await fs @@ -42,7 +42,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { // Get the symlink path before removing const projectName = tempGitRepo.split("/").pop() || "unknown"; - const symlinkPath = `${env.config.srcDir}/${projectName}/${metadata.name}`; + const symlinkPath = `${env.config.srcDir}/${projectName}/${metadata.title}`; const symlinkExistsBefore = await fs .lstat(symlinkPath) .then(() => true) @@ -120,7 +120,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.namedWorkspacePath; + const workspacePath = metadata.titledWorkspacePath; // Manually delete the worktree directory (simulating external deletion) await fs.rm(workspacePath, { recursive: true, force: true }); @@ -174,7 +174,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.namedWorkspacePath; + const workspacePath = metadata.titledWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); @@ -228,7 +228,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.namedWorkspacePath; + const workspacePath = metadata.titledWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); From d056612320f08a7c57cd2993928499ab83137a39 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 01:57:51 -0400 Subject: [PATCH 03/17] Auto-generate titles after first assistant response - Add maybeGenerateTitle() method to AgentSession - Trigger on stream-end event (fire-and-forget) - Only generate for workspaces without titles - Only after first assistant response (assistantMessages.length === 1) - Emit metadata update so UI refreshes automatically - Non-fatal errors are logged but don't block stream UX: Users now see titles appear automatically after first message without any manual action required. --- src/services/agentSession.ts | 70 +++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index ac1f102893..b3e938369c 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -366,7 +366,11 @@ export class AgentSession { forward("stream-start", (payload) => this.emitChatEvent(payload)); forward("stream-delta", (payload) => this.emitChatEvent(payload)); - forward("stream-end", (payload) => this.emitChatEvent(payload)); + forward("stream-end", (payload) => { + this.emitChatEvent(payload); + // Auto-generate title after first assistant response (fire-and-forget) + void this.maybeGenerateTitle(); + }); forward("tool-call-start", (payload) => this.emitChatEvent(payload)); forward("tool-call-delta", (payload) => this.emitChatEvent(payload)); forward("tool-call-end", (payload) => this.emitChatEvent(payload)); @@ -403,6 +407,70 @@ export class AgentSession { this.aiService.on("error", errorHandler as never); } + /** + * Auto-generate workspace title after first assistant response. + * This is a fire-and-forget operation that happens in the background. + * Errors are logged but don't affect the stream completion. + */ + private async maybeGenerateTitle(): Promise { + try { + // 1. Check if workspace already has a title + const metadataResult = this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success || metadataResult.data.title) { + return; // Already has title, skip + } + + // 2. Check if this is the first assistant response + const historyResult = await this.historyService.getHistory(this.workspaceId); + if (!historyResult.success) { + return; + } + + const assistantMessages = historyResult.data.filter((m) => m.role === "assistant"); + if (assistantMessages.length !== 1) { + return; // Not first message, skip + } + + // 3. Generate title using autotitle service + const { generateWorkspaceTitle } = await import("@/services/autotitle"); + const { anthropic } = await import("@ai-sdk/anthropic"); + const model = anthropic("claude-haiku-4"); + + const result = await generateWorkspaceTitle(this.workspaceId, this.historyService, model); + + if (!result.success) { + // Non-fatal: just log and continue without title + console.error(`[Autotitle] Failed to generate title for ${this.workspaceId}:`, result.error); + return; + } + + const title = result.data; + + // 4. Update config with new title + this.config.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === this.workspaceId); + if (workspace) { + workspace.title = title; + break; + } + } + return config; + }); + + // 5. Emit metadata update so UI refreshes + const updatedMetadataResult = this.aiService.getWorkspaceMetadata(this.workspaceId); + if (updatedMetadataResult.success) { + this.emitMetadata(updatedMetadataResult.data); + } + + console.log(`[Autotitle] Generated title for ${this.workspaceId}: "${title}"`); + } catch (error) { + // Catch any unexpected errors to prevent breaking the stream + console.error(`[Autotitle] Unexpected error for ${this.workspaceId}:`, error); + } + } + private emitChatEvent(message: WorkspaceChatMessage): void { this.assertNotDisposed("emitChatEvent"); this.emitter.emit("chat-event", { From 963186799f893ec936d5d824a056d8f0e862eebe Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:04:00 -0400 Subject: [PATCH 04/17] Use configured provider's model for title generation + migrate legacy name to title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Provider Selection:** - Check providers.jsonc for configured provider - Use first provider's cheapest model: - Anthropic: claude-haiku-4 - OpenAI: gpt-4o-mini - Fallback: anthropic:claude-haiku-4 - Both AgentSession and IPC handler updated **Legacy Migration:** - Legacy `name` field now becomes `title` fallback - Preserves existing workspace names on upgrade - Users see familiar names instead of IDs - New workspaces still get auto-generated titles **Implementation:** - Updated autotitle service to accept model + modelString - Config loading migrates name → title - Both auto-trigger and manual IPC use configured provider --- src/config.ts | 9 +++--- src/services/agentSession.ts | 54 +++++++++++++++++++++++++++++++----- src/services/autotitle.ts | 9 +++--- src/services/ipcMain.ts | 46 ++++++++++++++++++++++++++---- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3e82a7bf9e..06aa43f0b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -262,7 +262,8 @@ export class Config { if (workspace.id) { const metadata: WorkspaceMetadata = { id: workspace.id, - title: workspace.title, // May be undefined (OK - falls back to id in UI) + // Use title if present, otherwise fallback to legacy name field + title: workspace.title || (workspace as { name?: string }).name || undefined, projectName, projectPath, createdAt: workspace.createdAt, @@ -281,10 +282,10 @@ export class Config { const data = fs.readFileSync(metadataPath, "utf-8"); const legacyMetadata = JSON.parse(data) as WorkspaceMetadata & { name?: string }; - // Migrate from old format: name → no field (title will be generated) + // Migrate from old format: use name as fallback title const metadata: WorkspaceMetadata = { id: legacyMetadata.id, - title: undefined, // Will be generated after first message + title: legacyMetadata.name || undefined, // Use legacy name as fallback title projectName: legacyMetadata.projectName ?? projectName, projectPath: legacyMetadata.projectPath ?? projectPath, createdAt: legacyMetadata.createdAt, @@ -292,7 +293,7 @@ export class Config { // Migrate to config for next load workspace.id = metadata.id; - workspace.title = undefined; // Don't copy legacy name + workspace.title = legacyMetadata.name || undefined; // Preserve legacy name as title workspace.createdAt = metadata.createdAt; configModified = true; diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index b3e938369c..d7a97eef7a 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -431,12 +431,52 @@ export class AgentSession { return; // Not first message, skip } - // 3. Generate title using autotitle service - const { generateWorkspaceTitle } = await import("@/services/autotitle"); - const { anthropic } = await import("@ai-sdk/anthropic"); - const model = anthropic("claude-haiku-4"); + // 3. Determine which provider to use + const providersConfig = this.config.loadProvidersConfig(); + let modelString = "anthropic:claude-haiku-4"; // Default + + if (providersConfig) { + // Use first configured provider's cheapest model + const providers = Object.keys(providersConfig); + if (providers.length > 0) { + const provider = providers[0]; + if (provider === "anthropic") { + modelString = "anthropic:claude-haiku-4"; + } else if (provider === "openai") { + modelString = "openai:gpt-4o-mini"; + } + } + } - const result = await generateWorkspaceTitle(this.workspaceId, this.historyService, model); + // 4. Create model instance using AIService (handles provider config loading) + const [providerName, modelId] = modelString.split(":"); + let model; + + if (providerName === "anthropic") { + const { createAnthropic } = await import("@ai-sdk/anthropic"); + const providerConfig = providersConfig?.[providerName] ?? {}; + const anthropic = createAnthropic(providerConfig); + model = anthropic(modelId); + } else if (providerName === "openai") { + const { createOpenAI } = await import("@ai-sdk/openai"); + const providerConfig = providersConfig?.[providerName] ?? {}; + const openai = createOpenAI(providerConfig); + model = openai(modelId); + } else { + // Fallback to anthropic + const { createAnthropic } = await import("@ai-sdk/anthropic"); + const anthropic = createAnthropic({}); + model = anthropic("claude-haiku-4"); + } + + // 5. Generate title + const { generateWorkspaceTitle } = await import("@/services/autotitle"); + const result = await generateWorkspaceTitle( + this.workspaceId, + this.historyService, + model, + modelString + ); if (!result.success) { // Non-fatal: just log and continue without title @@ -446,7 +486,7 @@ export class AgentSession { const title = result.data; - // 4. Update config with new title + // 6. Update config with new title this.config.editConfig((config) => { for (const [_projectPath, projectConfig] of config.projects) { const workspace = projectConfig.workspaces.find((w) => w.id === this.workspaceId); @@ -458,7 +498,7 @@ export class AgentSession { return config; }); - // 5. Emit metadata update so UI refreshes + // 7. Emit metadata update so UI refreshes const updatedMetadataResult = this.aiService.getWorkspaceMetadata(this.workspaceId); if (updatedMetadataResult.success) { this.emitMetadata(updatedMetadataResult.data); diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts index c68e3a8ebb..6d6dd97164 100644 --- a/src/services/autotitle.ts +++ b/src/services/autotitle.ts @@ -29,13 +29,15 @@ const TITLE_GENERATION_PROMPT = `Generate a concise 3-7 word title that captures * Generate a workspace title based on conversation history * @param workspaceId - Workspace identifier * @param historyService - Service to retrieve chat history - * @param model - Language model to use for generation (should be a fast, cheap model like haiku) + * @param model - Language model instance (already configured) + * @param modelString - Model string for token counting (e.g., "anthropic:claude-haiku-4") * @returns Result containing generated title or error */ export async function generateWorkspaceTitle( workspaceId: string, historyService: HistoryService, - model: LanguageModel + model: LanguageModel, + modelString: string ): Promise> { try { // Get conversation history @@ -53,8 +55,7 @@ export async function generateWorkspaceTitle( // Take first few messages up to token limit // This gives enough context without excessive cost - const modelStr = typeof model === "string" ? model : model.modelId; - const tokenizer = getTokenizerForModel(modelStr); + const tokenizer = getTokenizerForModel(modelString); let tokensUsed = 0; const selectedMessages = []; diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 609951ddf2..934bcde8f3 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -331,15 +331,51 @@ export class IpcMain { IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, async (_event, workspaceId: string) => { try { - const { generateWorkspaceTitle } = await import("@/services/autotitle"); - const { anthropic } = await import("@ai-sdk/anthropic"); + // Determine which provider to use + const providersConfig = this.config.loadProvidersConfig(); + let modelString = "anthropic:claude-haiku-4"; // Default + + if (providersConfig) { + // Use first configured provider's cheapest model + const providers = Object.keys(providersConfig); + if (providers.length > 0) { + const provider = providers[0]; + if (provider === "anthropic") { + modelString = "anthropic:claude-haiku-4"; + } else if (provider === "openai") { + modelString = "openai:gpt-4o-mini"; + } + } + } - // Generate title using Haiku (fast and cheap) - const model = anthropic("claude-haiku-4"); + // Create model instance + const [providerName, modelId] = modelString.split(":"); + let model; + + if (providerName === "anthropic") { + const { createAnthropic } = await import("@ai-sdk/anthropic"); + const providerConfig = providersConfig?.[providerName] ?? {}; + const anthropic = createAnthropic(providerConfig); + model = anthropic(modelId); + } else if (providerName === "openai") { + const { createOpenAI } = await import("@ai-sdk/openai"); + const providerConfig = providersConfig?.[providerName] ?? {}; + const openai = createOpenAI(providerConfig); + model = openai(modelId); + } else { + // Fallback to anthropic + const { createAnthropic } = await import("@ai-sdk/anthropic"); + const anthropic = createAnthropic({}); + model = anthropic("claude-haiku-4"); + } + + // Generate title + const { generateWorkspaceTitle } = await import("@/services/autotitle"); const result = await generateWorkspaceTitle( workspaceId, this.historyService, - model + model, + modelString ); if (!result.success) { From 9826586aaec1bb38b27aef18213acda414629c6e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:19:24 -0400 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20linting,=20type=20ch?= =?UTF-8?q?ecking,=20and=20test=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dynamic imports (use static imports at top of file) - Fix unused variable warnings (_prefix convention) - Replace || with ?? for nullish coalescing - Fix Makefile version script invocation (use bash prefix) - Fix type errors: name→title, titledWorkspacePath→namedWorkspacePath - Fix command palette field: title→name for select fields - Remove maxCompletionTokens (not supported by AI SDK v5 generateText) - Update all test files to use new metadata schema All static checks now passing. --- Makefile | 2 +- src/App.tsx | 3 +- src/components/WorkspaceListItem.tsx | 2 +- src/config.ts | 10 +- src/services/agentSession.ts | 12 +- src/services/autotitle.ts | 3 - src/services/ipcMain.ts | 161 ++++++++++++------------ src/stores/GitStatusStore.test.ts | 18 +-- src/telemetry/utils.ts | 7 +- src/utils/commands/sources.test.ts | 4 +- src/utils/commands/sources.ts | 51 +++++--- src/utils/ui/workspaceFiltering.test.ts | 2 +- tests/ipcMain/removeWorkspace.test.ts | 8 +- 13 files changed, 145 insertions(+), 138 deletions(-) diff --git a/Makefile b/Makefile index 3fbb97d423..281f98d0eb 100644 --- a/Makefile +++ b/Makefile @@ -110,7 +110,7 @@ build-static: ## Copy static assets to dist # Always regenerate version file (marked as .PHONY above) version: ## Generate version file - @./scripts/generate-version.sh + @bash ./scripts/generate-version.sh src/version.ts: version diff --git a/src/App.tsx b/src/App.tsx index 607dfcff0c..6b7321c3ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -143,7 +143,8 @@ function AppInner() { // Update window title with workspace title or ID const workspaceTitle = - workspaceMetadata.get(selectedWorkspace.workspaceId)?.title ?? selectedWorkspace.workspaceId; + workspaceMetadata.get(selectedWorkspace.workspaceId)?.title ?? + selectedWorkspace.workspaceId; const title = `${workspaceTitle} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 03d2e51378..3ce2603e0f 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -53,7 +53,7 @@ const WorkspaceListItemInner: React.FC = ({ const [renameError, setRenameError] = useState(null); // Display title if available, otherwise fall back to showing the workspace ID - const displayName = title || workspaceId; + const displayName = title ?? workspaceId; const isStreaming = sidebarState.canInterrupt; const streamingModel = sidebarState.currentModel; const isEditing = editingWorkspaceId === workspaceId; diff --git a/src/config.ts b/src/config.ts index 06aa43f0b0..bd63b1f217 100644 --- a/src/config.ts +++ b/src/config.ts @@ -253,17 +253,13 @@ export class Config { const projectName = this.getProjectName(projectPath); for (const workspace of projectConfig.workspaces) { - // Extract workspace basename from path (could be stable ID or legacy name) - const workspaceBasename = - workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown"; - try { // NEW FORMAT: If workspace has metadata in config, use it directly if (workspace.id) { const metadata: WorkspaceMetadata = { id: workspace.id, // Use title if present, otherwise fallback to legacy name field - title: workspace.title || (workspace as { name?: string }).name || undefined, + title: workspace.title ?? (workspace as { name?: string }).name ?? undefined, projectName, projectPath, createdAt: workspace.createdAt, @@ -285,7 +281,7 @@ export class Config { // Migrate from old format: use name as fallback title const metadata: WorkspaceMetadata = { id: legacyMetadata.id, - title: legacyMetadata.name || undefined, // Use legacy name as fallback title + title: legacyMetadata.name ?? undefined, // Use legacy name as fallback title projectName: legacyMetadata.projectName ?? projectName, projectPath: legacyMetadata.projectPath ?? projectPath, createdAt: legacyMetadata.createdAt, @@ -293,7 +289,7 @@ export class Config { // Migrate to config for next load workspace.id = metadata.id; - workspace.title = legacyMetadata.name || undefined; // Preserve legacy name as title + workspace.title = legacyMetadata.name ?? undefined; // Preserve legacy name as title workspace.createdAt = metadata.createdAt; configModified = true; diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index d7a97eef7a..e9c0abcd44 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -14,6 +14,9 @@ import type { Result } from "@/types/result"; import { Ok, Err } from "@/types/result"; import { enforceThinkingPolicy } from "@/utils/thinking/policy"; import { loadTokenizerForModel } from "@/utils/main/tokenizer"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generateWorkspaceTitle } from "@/services/autotitle"; interface ImagePart { url: string; @@ -453,24 +456,20 @@ export class AgentSession { let model; if (providerName === "anthropic") { - const { createAnthropic } = await import("@ai-sdk/anthropic"); const providerConfig = providersConfig?.[providerName] ?? {}; const anthropic = createAnthropic(providerConfig); model = anthropic(modelId); } else if (providerName === "openai") { - const { createOpenAI } = await import("@ai-sdk/openai"); const providerConfig = providersConfig?.[providerName] ?? {}; const openai = createOpenAI(providerConfig); model = openai(modelId); } else { // Fallback to anthropic - const { createAnthropic } = await import("@ai-sdk/anthropic"); const anthropic = createAnthropic({}); model = anthropic("claude-haiku-4"); } // 5. Generate title - const { generateWorkspaceTitle } = await import("@/services/autotitle"); const result = await generateWorkspaceTitle( this.workspaceId, this.historyService, @@ -480,7 +479,10 @@ export class AgentSession { if (!result.success) { // Non-fatal: just log and continue without title - console.error(`[Autotitle] Failed to generate title for ${this.workspaceId}:`, result.error); + console.error( + `[Autotitle] Failed to generate title for ${this.workspaceId}:`, + result.error + ); return; } diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts index 6d6dd97164..0497419e1b 100644 --- a/src/services/autotitle.ts +++ b/src/services/autotitle.ts @@ -17,7 +17,6 @@ import { getTokenizerForModel } from "@/utils/main/tokenizer"; */ const AUTOTITLE_TOKEN_LIMIT = 2000; -const AUTOTITLE_OUTPUT_TOKENS = 150; /** * Prompt strategy: Ask for concise 3-7 word title that captures main topic @@ -99,7 +98,6 @@ export async function generateWorkspaceTitle( const result = await generateText({ model, prompt: `${conversationContext}\n\n${TITLE_GENERATION_PROMPT}`, - maxTokens: 150, // Single generation step temperature: 0.3, // Lower temperature for more focused titles }); @@ -126,4 +124,3 @@ export async function generateWorkspaceTitle( return Err(`Title generation failed: ${message}`); } } - diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 934bcde8f3..65abee7077 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -28,6 +28,9 @@ import { createBashTool } from "@/services/tools/bash"; import type { BashToolResult } from "@/types/tools"; import { secretsToRecord } from "@/types/secrets"; import { DisposableTempDir } from "@/services/tempDir"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generateWorkspaceTitle } from "@/services/autotitle"; /** * IpcMain - Manages all IPC handlers and service coordination @@ -327,103 +330,93 @@ export class IpcMain { } ); - ipcMain.handle( - IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, - async (_event, workspaceId: string) => { - try { - // Determine which provider to use - const providersConfig = this.config.loadProvidersConfig(); - let modelString = "anthropic:claude-haiku-4"; // Default - - if (providersConfig) { - // Use first configured provider's cheapest model - const providers = Object.keys(providersConfig); - if (providers.length > 0) { - const provider = providers[0]; - if (provider === "anthropic") { - modelString = "anthropic:claude-haiku-4"; - } else if (provider === "openai") { - modelString = "openai:gpt-4o-mini"; - } + ipcMain.handle(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, async (_event, workspaceId: string) => { + try { + // Determine which provider to use + const providersConfig = this.config.loadProvidersConfig(); + let modelString = "anthropic:claude-haiku-4"; // Default + + if (providersConfig) { + // Use first configured provider's cheapest model + const providers = Object.keys(providersConfig); + if (providers.length > 0) { + const provider = providers[0]; + if (provider === "anthropic") { + modelString = "anthropic:claude-haiku-4"; + } else if (provider === "openai") { + modelString = "openai:gpt-4o-mini"; } } + } - // Create model instance - const [providerName, modelId] = modelString.split(":"); - let model; - - if (providerName === "anthropic") { - const { createAnthropic } = await import("@ai-sdk/anthropic"); - const providerConfig = providersConfig?.[providerName] ?? {}; - const anthropic = createAnthropic(providerConfig); - model = anthropic(modelId); - } else if (providerName === "openai") { - const { createOpenAI } = await import("@ai-sdk/openai"); - const providerConfig = providersConfig?.[providerName] ?? {}; - const openai = createOpenAI(providerConfig); - model = openai(modelId); - } else { - // Fallback to anthropic - const { createAnthropic } = await import("@ai-sdk/anthropic"); - const anthropic = createAnthropic({}); - model = anthropic("claude-haiku-4"); - } + // Create model instance + const [providerName, modelId] = modelString.split(":"); + let model; + + if (providerName === "anthropic") { + const providerConfig = providersConfig?.[providerName] ?? {}; + const anthropic = createAnthropic(providerConfig); + model = anthropic(modelId); + } else if (providerName === "openai") { + const providerConfig = providersConfig?.[providerName] ?? {}; + const openai = createOpenAI(providerConfig); + model = openai(modelId); + } else { + // Fallback to anthropic + const anthropic = createAnthropic({}); + model = anthropic("claude-haiku-4"); + } - // Generate title - const { generateWorkspaceTitle } = await import("@/services/autotitle"); - const result = await generateWorkspaceTitle( - workspaceId, - this.historyService, - model, - modelString - ); + // Generate title + const result = await generateWorkspaceTitle( + workspaceId, + this.historyService, + model, + modelString + ); - if (!result.success) { - return Err(result.error); - } + if (!result.success) { + return Err(result.error); + } - const title = result.data; + const title = result.data; - // Update config with new title - this.config.editConfig((config) => { - for (const [projectPath, projectConfig] of config.projects) { - const workspace = projectConfig.workspaces.find( - (w) => w.id === workspaceId - ); - if (workspace) { - workspace.title = title; - break; - } + // Update config with new title + this.config.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + workspace.title = title; + break; } - return config; - }); - - // Get updated metadata from config - const allMetadata = this.config.getAllWorkspaceMetadata(); - const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); - if (!updatedMetadata) { - return Err("Failed to retrieve updated workspace metadata"); } + return config; + }); - // Emit metadata event with updated title - const session = this.sessions.get(workspaceId); - if (session) { - session.emitMetadata(updatedMetadata); - } else if (this.mainWindow) { - this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { - workspaceId, - metadata: updatedMetadata, - }); - } + // Get updated metadata from config + const allMetadata = this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!updatedMetadata) { + return Err("Failed to retrieve updated workspace metadata"); + } - return Ok({ title }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to generate title: ${message}`); + // Emit metadata event with updated title + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else if (this.mainWindow) { + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata: updatedMetadata, + }); } - } - ); + return Ok({ title }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to generate title: ${message}`); + } + }); ipcMain.handle( IPC_CHANNELS.WORKSPACE_FORK, diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index 92fe5c6f74..6c0c9119c0 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -70,7 +70,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -91,7 +91,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -101,7 +101,7 @@ describe("GitStatusStore", () => { "ws2", { id: "ws2", - name: "feature", + title: "feature", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/feature", @@ -123,7 +123,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -147,7 +147,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -172,7 +172,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -201,7 +201,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -228,7 +228,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", @@ -256,7 +256,7 @@ describe("GitStatusStore", () => { "ws1", { id: "ws1", - name: "main", + title: "main", projectName: "test-project", projectPath: "/home/user/test-project", namedWorkspacePath: "/home/user/.cmux/src/test-project/main", diff --git a/src/telemetry/utils.ts b/src/telemetry/utils.ts index 12864eced1..fda6d3d4c7 100644 --- a/src/telemetry/utils.ts +++ b/src/telemetry/utils.ts @@ -9,11 +9,10 @@ import { VERSION } from "../version"; * Get base telemetry properties included with all events */ export function getBaseTelemetryProperties(): BaseTelemetryProperties { - const gitDescribe: string = VERSION.git_describe; return { - version: gitDescribe, - platform: window.api?.platform || "unknown", - electronVersion: window.api?.versions?.electron || "unknown", + version: VERSION.git_describe, + platform: window.api?.platform ?? "unknown", + electronVersion: window.api?.versions?.electron ?? "unknown", }; } diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts index b2e98e13c4..44019b7387 100644 --- a/src/utils/commands/sources.test.ts +++ b/src/utils/commands/sources.test.ts @@ -10,14 +10,14 @@ const mk = (over: Partial[0]> = {}) => { const workspaceMetadata = new Map(); workspaceMetadata.set("w1", { id: "w1", - name: "feat-x", + title: "feat-x", projectName: "a", projectPath: "/repo/a", namedWorkspacePath: "/repo/a/feat-x", }); workspaceMetadata.set("w2", { id: "w2", - name: "feat-y", + title: "feat-y", projectName: "a", projectPath: "/repo/a", namedWorkspacePath: "/repo/a/feat-y", diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 4373e7251e..070764b95f 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -94,23 +94,25 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const isStreaming = p.streamingModels?.has(meta.id) ?? false; list.push({ id: `ws:switch:${meta.id}`, - title: `${isCurrent ? "• " : ""}Switch to ${meta.title}`, + title: `${isCurrent ? "• " : ""}Switch to ${meta.title ?? meta.id}`, subtitle: `${meta.projectName}${isStreaming ? " • streaming" : ""}`, section: section.workspaces, - keywords: [meta.title, meta.projectName, meta.titledWorkspacePath], + keywords: [meta.title ?? meta.id, meta.projectName, meta.namedWorkspacePath], run: () => p.onSelectWorkspace({ projectPath: meta.projectPath, projectName: meta.projectName, - namedWorkspacePath: meta.titledWorkspacePath, + namedWorkspacePath: meta.namedWorkspacePath, workspaceId: meta.id, }), }); } // Remove current workspace (rename action intentionally omitted until we add a proper modal) - if (selected?.titledWorkspacePath) { - const workspaceDisplayName = `${selected.projectName}/${selected.titledWorkspacePath.split("/").pop() ?? selected.titledWorkspacePath}`; + if (selected?.namedWorkspacePath) { + const pathParts = selected.namedWorkspacePath.split("/"); + const workspaceName = pathParts[pathParts.length - 1] ?? selected.namedWorkspacePath; + const workspaceDisplayName = `${selected.projectName}/${workspaceName}`; list.push({ id: "ws:open-terminal-current", title: "Open Current Workspace in Terminal", @@ -169,17 +171,22 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - title: "workspaceId", + name: "workspaceId", label: "Workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { // Use workspace name instead of extracting from path - const label = `${meta.projectName} / ${meta.title}`; + const label = `${meta.projectName} / ${meta.title ?? meta.id}`; return { id: meta.id, label, - keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], + keywords: [ + meta.title ?? meta.id, + meta.projectName, + meta.namedWorkspacePath, + meta.id, + ], }; }), }, @@ -199,16 +206,21 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - title: "workspaceId", + name: "workspaceId", label: "Select workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const label = `${meta.projectName} / ${meta.title}`; + const label = `${meta.projectName} / ${meta.title ?? meta.id}`; return { id: meta.id, label, - keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], + keywords: [ + meta.title ?? meta.id, + meta.projectName, + meta.namedWorkspacePath, + meta.id, + ], }; }), }, @@ -221,7 +233,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === values.workspaceId ); - return meta ? meta.title : ""; + return meta ? (meta.title ?? meta.id) : ""; }, validate: (v) => (!v.trim() ? "Name is required" : null), }, @@ -241,16 +253,21 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - title: "workspaceId", + name: "workspaceId", label: "Select workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const label = `${meta.projectName}/${meta.title}`; + const label = `${meta.projectName}/${meta.title ?? meta.id}`; return { id: meta.id, label, - keywords: [meta.title, meta.projectName, meta.titledWorkspacePath, meta.id], + keywords: [ + meta.title ?? meta.id, + meta.projectName, + meta.namedWorkspacePath, + meta.id, + ], }; }), }, @@ -259,7 +276,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === vals.workspaceId ); - const workspaceName = meta ? `${meta.projectName}/${meta.title}` : vals.workspaceId; + const workspaceName = meta + ? `${meta.projectName}/${meta.title ?? meta.id}` + : vals.workspaceId; const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); if (ok) { await p.onRemoveWorkspace(vals.workspaceId); diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts index f22052bac7..2d06acd276 100644 --- a/src/utils/ui/workspaceFiltering.test.ts +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -8,7 +8,7 @@ describe("partitionWorkspacesByAge", () => { const createWorkspace = (id: string): FrontendWorkspaceMetadata => ({ id, - name: `workspace-${id}`, + title: `workspace-${id}`, projectName: "test-project", projectPath: "/test/project", namedWorkspacePath: `/test/project/.worktrees/${id}`, diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index 76b786dbf4..9193a40cae 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -31,7 +31,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.titledWorkspacePath; + const workspacePath = metadata.namedWorkspacePath; // Verify the worktree exists const worktreeExistsBefore = await fs @@ -120,7 +120,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.titledWorkspacePath; + const workspacePath = metadata.namedWorkspacePath; // Manually delete the worktree directory (simulating external deletion) await fs.rm(workspacePath, { recursive: true, force: true }); @@ -174,7 +174,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.titledWorkspacePath; + const workspacePath = metadata.namedWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); @@ -228,7 +228,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.titledWorkspacePath; + const workspacePath = metadata.namedWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); From 6cc0ba163d94ea1eb5f58906093c183a4fe7956c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:24:46 -0400 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20migration:=20use=20b?= =?UTF-8?q?asename=20as=20fallback=20title=20for=20legacy=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When migrating workspaces without metadata files, extract the workspace basename from the path and use it as the title. This preserves the display name for existing workspaces until auto-title generates a new one. Fixes test: Config > getAllWorkspaceMetadata with migration --- src/config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index bd63b1f217..2547966cf4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -300,16 +300,18 @@ export class Config { // No metadata found anywhere - create basic metadata if (!metadataFound) { const legacyId = this.generateWorkspaceId(projectPath, workspace.path); + // Extract workspace basename from path as fallback title for legacy workspaces + const workspaceBasename = path.basename(workspace.path); const metadata: WorkspaceMetadata = { id: legacyId, - title: undefined, // Will be generated after first message + title: workspaceBasename, // Use basename as fallback title projectName, projectPath, }; // Save to config for next load workspace.id = metadata.id; - workspace.title = undefined; + workspace.title = workspaceBasename; configModified = true; workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); From 1d031cfbbf8fe60d3d91812a05243c1403f6ead2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:31:06 -0400 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20integration=20tests:?= =?UTF-8?q?=20update=20name=E2=86=92title=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix renameWorkspace tests to use 'title' instead of 'name' - Fix executeBash test to check for workspace ID (not title) - Fix removeWorkspace test to use metadata.id for symlink path Workspace directories are named by ID, not title. --- tests/ipcMain/executeBash.test.ts | 4 ++-- tests/ipcMain/removeWorkspace.test.ts | 2 +- tests/ipcMain/renameWorkspace.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ipcMain/executeBash.test.ts b/tests/ipcMain/executeBash.test.ts index 8bcc20f932..5e4695f8b3 100644 --- a/tests/ipcMain/executeBash.test.ts +++ b/tests/ipcMain/executeBash.test.ts @@ -38,8 +38,8 @@ describeIntegration("IpcMain executeBash integration tests", () => { expect(pwdResult.success).toBe(true); expect(pwdResult.data.success).toBe(true); - // Verify pwd output contains the workspace name (directories are named with workspace names) - expect(pwdResult.data.output).toContain(metadata.title); + // Verify pwd output contains the workspace ID (directories are named with workspace IDs) + expect(pwdResult.data.output).toContain(metadata.id); expect(pwdResult.data.exitCode).toBe(0); // Clean up diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index 9193a40cae..20d7221bdb 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -42,7 +42,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { // Get the symlink path before removing const projectName = tempGitRepo.split("/").pop() || "unknown"; - const symlinkPath = `${env.config.srcDir}/${projectName}/${metadata.title}`; + const symlinkPath = `${env.config.srcDir}/${projectName}/${metadata.id}`; const symlinkExistsBefore = await fs .lstat(symlinkPath) .then(() => true) diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 8abe6ba75e..62ebd6a1b4 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -81,7 +81,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { ); expect(newMetadataResult).toBeTruthy(); expect(newMetadataResult.id).toBe(workspaceId); // ID unchanged - expect(newMetadataResult.name).toBe(newName); // Name updated + expect(newMetadataResult.title).toBe(newName); // Title updated expect(newMetadataResult.projectName).toBe(projectName); // Path DOES change (directory is renamed from old name to new name) @@ -96,7 +96,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const workspace = projectConfig.workspaces.find((w) => w.path === newWorkspacePath); if (workspace) { foundWorkspace = true; - expect(workspace.name).toBe(newName); // Name updated in config + expect(workspace.title).toBe(newName); // Title updated in config expect(workspace.id).toBe(workspaceId); // ID unchanged break; } From 8a25975178366f860105b5c89a4a09bd12993665 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:38:56 -0400 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=A4=96=20Update=20renameWorkspace?= =?UTF-8?q?=20tests=20for=20cosmetic=20title=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace rename is now cosmetic (title-only) and doesn't move directories: - Directories use stable IDs, not titles - Duplicate titles are allowed (IDs ensure uniqueness) - Any title format is valid (empty, special chars, long) - Renaming during streaming is allowed (no filesystem changes) Updated all test expectations to match new behavior. --- tests/ipcMain/renameWorkspace.test.ts | 78 ++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 62ebd6a1b4..077d1ec492 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -35,7 +35,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { projectConfig.workspaces.push({ path: workspacePath, id: workspaceId, - name: branchName, + title: branchName, }); env.config.saveConfig(projectsConfig); } @@ -84,20 +84,21 @@ describeIntegration("IpcMain rename workspace integration tests", () => { expect(newMetadataResult.title).toBe(newName); // Title updated expect(newMetadataResult.projectName).toBe(projectName); - // Path DOES change (directory is renamed from old name to new name) + // Path DOES NOT change (directories use stable IDs, not titles) const newWorkspacePath = newMetadataResult.namedWorkspacePath; - expect(newWorkspacePath).not.toBe(oldWorkspacePath); - expect(newWorkspacePath).toContain(newName); // New path includes new name + expect(newWorkspacePath).toBe(oldWorkspacePath); // Path stays the same + expect(newWorkspacePath).toContain(workspaceId); // Path contains workspace ID - // Verify config was updated with new path + // Verify config was updated with new title (path unchanged) const config = env.config.loadConfigOrDefault(); let foundWorkspace = false; for (const [, projectConfig] of config.projects.entries()) { - const workspace = projectConfig.workspaces.find((w) => w.path === newWorkspacePath); + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); if (workspace) { foundWorkspace = true; expect(workspace.title).toBe(newName); // Title updated in config expect(workspace.id).toBe(workspaceId); // ID unchanged + expect(workspace.path).toBe(oldWorkspacePath); // Path unchanged break; } } @@ -113,7 +114,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { workspaceId, metadata: expect.objectContaining({ id: workspaceId, - name: newName, + title: newName, projectName, }), }); @@ -125,7 +126,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { ); test.concurrent( - "should fail to rename if new name conflicts with existing workspace", + "should allow duplicate titles (IDs ensure uniqueness)", async () => { const { env, workspaceId, tempGitRepo, cleanup } = await setupWorkspace("anthropic"); try { @@ -137,23 +138,28 @@ describeIntegration("IpcMain rename workspace integration tests", () => { secondBranchName ); expect(createResult.success).toBe(true); + const secondWorkspaceId = createResult.metadata.id; - // Try to rename first workspace to the second workspace's name + // Rename first workspace to the second workspace's title - should succeed const renameResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, secondBranchName ); - expect(renameResult.success).toBe(false); - expect(renameResult.error).toContain("already exists"); + expect(renameResult.success).toBe(true); - // Verify original workspace still exists and wasn't modified - const metadataResult = await env.mockIpcRenderer.invoke( + // Verify both workspaces exist with the same title but different IDs + const metadata1 = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId ); - expect(metadataResult).toBeTruthy(); - expect(metadataResult.id).toBe(workspaceId); + const metadata2 = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_GET_INFO, + secondWorkspaceId + ); + expect(metadata1.title).toBe(secondBranchName); + expect(metadata2.title).toBe(secondBranchName); + expect(metadata1.id).not.toBe(metadata2.id); // Different IDs } finally { await cleanup(); } @@ -176,7 +182,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { projectConfig.workspaces.push({ path: workspacePath, id: workspaceId, - name: branchName, + title: branchName, }); env.config.saveConfig(projectsConfig); } @@ -233,31 +239,30 @@ describeIntegration("IpcMain rename workspace integration tests", () => { ); test.concurrent( - "should fail to rename with invalid workspace name", + "should allow any title format (titles are cosmetic)", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Test various invalid names - const invalidNames = [ - { name: "", expectedError: "empty" }, - { name: "My-Branch", expectedError: "lowercase" }, - { name: "branch name", expectedError: "lowercase" }, - { name: "branch@123", expectedError: "lowercase" }, - { name: "branch/test", expectedError: "lowercase" }, - { name: "a".repeat(65), expectedError: "64 characters" }, + // Test various title formats - all should be valid + const validTitles = [ + "", // Empty (falls back to ID display) + "My-Branch", // Uppercase + "branch name", // Spaces + "branch@123", // Special chars + "branch/test", // Slashes + "a".repeat(100), // Long titles ]; - for (const { name, expectedError } of invalidNames) { + for (const title of validTitles) { const renameResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, - name + title ); - expect(renameResult.success).toBe(false); - expect(renameResult.error.toLowerCase()).toContain(expectedError.toLowerCase()); + expect(renameResult.success).toBe(true); } - // Verify original workspace still exists and wasn't modified + // Verify workspace still exists const metadataResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId @@ -286,7 +291,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { projectConfig.workspaces.push({ path: workspacePath, id: workspaceId, - name: branchName, + title: branchName, }); env.config.saveConfig(projectsConfig); } @@ -349,7 +354,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { projectConfig.workspaces.push({ path: workspacePath, id: workspaceId, - name: branchName, + title: branchName, }); env.config.saveConfig(projectsConfig); } @@ -450,7 +455,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { projectConfig.workspaces.push({ path: workspacePath, id: workspaceId, - name: branchName, + title: branchName, }); env.config.saveConfig(projectsConfig); } @@ -466,7 +471,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-start", 5000); - // Attempt to rename while streaming - should fail + // Attempt to rename while streaming - should succeed (titles are cosmetic) const newName = "renamed-during-stream"; const renameResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_RENAME, @@ -474,9 +479,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { newName ); - // Verify rename was blocked due to active stream - expect(renameResult.success).toBe(false); - expect(renameResult.error).toContain("stream is active"); + // Verify rename succeeded even during streaming + expect(renameResult.success).toBe(true); // Wait for stream to complete await collector.waitForEvent("stream-end", 10000); From bfb1ccbb75da1938f62cb0ae7e4fb536d5108753 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 02:41:01 -0400 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20TypeScript=20error?= =?UTF-8?q?=20in=20renameWorkspace=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add type guard to narrow Result type before accessing metadata. --- tests/ipcMain/renameWorkspace.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 077d1ec492..138e92105f 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -138,6 +138,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { secondBranchName ); expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); const secondWorkspaceId = createResult.metadata.id; // Rename first workspace to the second workspace's title - should succeed From 71ebff278f66daf6ab15f041c0282dae55f15648 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 12:21:42 -0400 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20test:=20new=20worksp?= =?UTF-8?q?aces=20start=20with=20undefined=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspaces created via WORKSPACE_CREATE have title=undefined initially. Titles are auto-generated after the first assistant response. Updated test expectation to check for undefined instead of branch name. --- tests/ipcMain/renameWorkspace.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 138e92105f..7f34648c74 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -158,8 +158,8 @@ describeIntegration("IpcMain rename workspace integration tests", () => { IPC_CHANNELS.WORKSPACE_GET_INFO, secondWorkspaceId ); - expect(metadata1.title).toBe(secondBranchName); - expect(metadata2.title).toBe(secondBranchName); + expect(metadata1.title).toBe(secondBranchName); // First workspace was renamed + expect(metadata2.title).toBeUndefined(); // Second workspace has no title yet (auto-generated later) expect(metadata1.id).not.toBe(metadata2.id); // Different IDs } finally { await cleanup(); From 214a6239c17789a5d884cc907dcdc628d4384dd4 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:21:11 -0400 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=A4=96=20Address=20review:=20Show?= =?UTF-8?q?=20'New=20Workspace'=20fallback,=20add=20maxTokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes based on ammario's review: - Show 'New Workspace' instead of workspace ID when title is undefined - Add maxTokens: 50 to title generation (3-7 words ≈ 10-20 tokens) - Update comment about empty title behavior Addresses review comments on lines 166, 186, and 101. --- src/components/WorkspaceListItem.tsx | 6 +++--- src/services/autotitle.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 3ce2603e0f..914511bde5 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -52,8 +52,8 @@ const WorkspaceListItemInner: React.FC = ({ const [editingTitle, setEditingTitle] = useState(""); const [renameError, setRenameError] = useState(null); - // Display title if available, otherwise fall back to showing the workspace ID - const displayName = title ?? workspaceId; + // Display title if available, otherwise show "New Workspace" as placeholder + const displayName = title ?? "New Workspace"; const isStreaming = sidebarState.canInterrupt; const streamingModel = sidebarState.currentModel; const isEditing = editingWorkspaceId === workspaceId; @@ -72,7 +72,7 @@ const WorkspaceListItemInner: React.FC = ({ }; const handleConfirmRename = async () => { - // Empty title is OK - will fall back to showing ID + // Empty title is OK - will show "New Workspace" until auto-generated const newTitle = editingTitle.trim(); const result = await confirmRename(workspaceId, newTitle); diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts index 0497419e1b..0c1b2066af 100644 --- a/src/services/autotitle.ts +++ b/src/services/autotitle.ts @@ -98,6 +98,7 @@ export async function generateWorkspaceTitle( const result = await generateText({ model, prompt: `${conversationContext}\n\n${TITLE_GENERATION_PROMPT}`, + maxTokens: 50, // Short titles only (3-7 words ≈ 10-20 tokens + buffer) temperature: 0.3, // Lower temperature for more focused titles }); From f2ab327aaef915fd39ebc69fa5497cc09fd17237 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:22:29 -0400 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20migration:=20Don't?= =?UTF-8?q?=20use=20basename=20as=20title=20for=20workspaces=20without=20m?= =?UTF-8?q?etadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: Migration logic for workspaces without metadata.json should not use the directory basename as title. The basename could be either: - A legacy workspace name (for very old workspaces) - A workspace ID (for new workspaces) Without being able to distinguish, it's better to leave title undefined and let auto-title generate it on first use. Fixes review comment on line 277 of config.ts. --- src/config.test.ts | 4 ++-- src/config.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index bdb3fe2e13..82f2bc8ac4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -57,7 +57,7 @@ describe("Config", () => { expect(allMetadata).toHaveLength(1); const metadata = allMetadata[0]; expect(metadata.id).toBe("project-feature-branch"); // Legacy ID format - expect(metadata.title).toBe("feature-branch"); + expect(metadata.title).toBeUndefined(); // No title - will be auto-generated expect(metadata.projectName).toBe("project"); expect(metadata.projectPath).toBe(projectPath); @@ -68,7 +68,7 @@ describe("Config", () => { expect(projectConfig!.workspaces).toHaveLength(1); const workspace = projectConfig!.workspaces[0]; expect(workspace.id).toBe("project-feature-branch"); - expect(workspace.title).toBe("feature-branch"); + expect(workspace.title).toBeUndefined(); }); it("should use existing metadata file if present (legacy format)", () => { diff --git a/src/config.ts b/src/config.ts index 2547966cf4..1c27e86de2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -300,18 +300,16 @@ export class Config { // No metadata found anywhere - create basic metadata if (!metadataFound) { const legacyId = this.generateWorkspaceId(projectPath, workspace.path); - // Extract workspace basename from path as fallback title for legacy workspaces - const workspaceBasename = path.basename(workspace.path); const metadata: WorkspaceMetadata = { id: legacyId, - title: workspaceBasename, // Use basename as fallback title + title: undefined, // No title - will be auto-generated on first use projectName, projectPath, }; // Save to config for next load workspace.id = metadata.id; - workspace.title = workspaceBasename; + workspace.title = undefined; configModified = true; workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); From db289a6bb6372b57ef67972b78ff7ff8b57fac8b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:25:40 -0400 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=A4=96=20Fix:=20Use=20maxOutputToke?= =?UTF-8?q?ns=20instead=20of=20maxTokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI SDK v5 uses 'maxOutputTokens' not 'maxTokens' for generateText. --- src/services/autotitle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/autotitle.ts b/src/services/autotitle.ts index 0c1b2066af..6f06e3b4d5 100644 --- a/src/services/autotitle.ts +++ b/src/services/autotitle.ts @@ -98,7 +98,7 @@ export async function generateWorkspaceTitle( const result = await generateText({ model, prompt: `${conversationContext}\n\n${TITLE_GENERATION_PROMPT}`, - maxTokens: 50, // Short titles only (3-7 words ≈ 10-20 tokens + buffer) + maxOutputTokens: 50, // Short titles only (3-7 words ≈ 10-20 tokens + buffer) temperature: 0.3, // Lower temperature for more focused titles }); From 8b4bfdce1ac2ad8619b0ab877dd177476b957029 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:36:06 -0400 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=A4=96=20Address=20review:=20Use=20?= =?UTF-8?q?user's=20model,=20remove=20duplication,=20frontend-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural changes based on ammario's review: 1. **Respect user's sendMessageOptions model**: - IPC now accepts model string parameter instead of hardcoding - Frontend passes the model used for the original message - Supports litellm/custom base URLs via provider config 2. **Remove duplication**: - Created shared utility: src/utils/ai/modelFactory.ts - Centralizes provider detection and model creation - Removed duplicated logic from agentSession.ts and ipcMain.ts 3. **Frontend-triggered generation** (SRP): - Removed auto-generation from backend (AgentSession) - WorkspaceStore triggers generation after stream-end - Checks for first assistant message + no title - Uses workspace's current model (from sendMessageOptions) Addresses review comments on: - lines 437-470 (agentSession.ts) - hardcoded models - lines 350-380 (ipcMain.ts) - duplication - line 250 (browser/api.ts) - IPC design - Maintains SRP: backend doesn't auto-trigger, frontend does --- src/browser/api.ts | 3 +- src/preload.ts | 4 +- src/services/agentSession.ts | 108 ----------------------------------- src/services/ipcMain.ts | 64 +++++++-------------- src/stores/WorkspaceStore.ts | 54 ++++++++++++++++++ src/types/ipc.ts | 5 +- src/utils/ai/modelFactory.ts | 43 ++++++++++++++ 7 files changed, 126 insertions(+), 155 deletions(-) create mode 100644 src/utils/ai/modelFactory.ts diff --git a/src/browser/api.ts b/src/browser/api.ts index 1acf495f20..2039de9a9a 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -243,7 +243,8 @@ const webApi: IPCApi = { executeBash: (workspaceId, script, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), - generateTitle: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId), + generateTitle: (workspaceId, modelString) => + invokeIPC(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId, modelString), onChat: (workspaceId, callback) => { const channel = getChatChannel(workspaceId); diff --git a/src/preload.ts b/src/preload.ts index f2346dcd3f..a038a2d83d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -72,8 +72,8 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), openTerminal: (workspacePath) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), - generateTitle: (workspaceId: string) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId), + generateTitle: (workspaceId: string, modelString: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, workspaceId, modelString), onChat: (workspaceId, callback) => { const channel = getChatChannel(workspaceId); diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index e9c0abcd44..baefec1b8a 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -14,9 +14,6 @@ import type { Result } from "@/types/result"; import { Ok, Err } from "@/types/result"; import { enforceThinkingPolicy } from "@/utils/thinking/policy"; import { loadTokenizerForModel } from "@/utils/main/tokenizer"; -import { createAnthropic } from "@ai-sdk/anthropic"; -import { createOpenAI } from "@ai-sdk/openai"; -import { generateWorkspaceTitle } from "@/services/autotitle"; interface ImagePart { url: string; @@ -371,8 +368,6 @@ export class AgentSession { forward("stream-delta", (payload) => this.emitChatEvent(payload)); forward("stream-end", (payload) => { this.emitChatEvent(payload); - // Auto-generate title after first assistant response (fire-and-forget) - void this.maybeGenerateTitle(); }); forward("tool-call-start", (payload) => this.emitChatEvent(payload)); forward("tool-call-delta", (payload) => this.emitChatEvent(payload)); @@ -410,109 +405,6 @@ export class AgentSession { this.aiService.on("error", errorHandler as never); } - /** - * Auto-generate workspace title after first assistant response. - * This is a fire-and-forget operation that happens in the background. - * Errors are logged but don't affect the stream completion. - */ - private async maybeGenerateTitle(): Promise { - try { - // 1. Check if workspace already has a title - const metadataResult = this.aiService.getWorkspaceMetadata(this.workspaceId); - if (!metadataResult.success || metadataResult.data.title) { - return; // Already has title, skip - } - - // 2. Check if this is the first assistant response - const historyResult = await this.historyService.getHistory(this.workspaceId); - if (!historyResult.success) { - return; - } - - const assistantMessages = historyResult.data.filter((m) => m.role === "assistant"); - if (assistantMessages.length !== 1) { - return; // Not first message, skip - } - - // 3. Determine which provider to use - const providersConfig = this.config.loadProvidersConfig(); - let modelString = "anthropic:claude-haiku-4"; // Default - - if (providersConfig) { - // Use first configured provider's cheapest model - const providers = Object.keys(providersConfig); - if (providers.length > 0) { - const provider = providers[0]; - if (provider === "anthropic") { - modelString = "anthropic:claude-haiku-4"; - } else if (provider === "openai") { - modelString = "openai:gpt-4o-mini"; - } - } - } - - // 4. Create model instance using AIService (handles provider config loading) - const [providerName, modelId] = modelString.split(":"); - let model; - - if (providerName === "anthropic") { - const providerConfig = providersConfig?.[providerName] ?? {}; - const anthropic = createAnthropic(providerConfig); - model = anthropic(modelId); - } else if (providerName === "openai") { - const providerConfig = providersConfig?.[providerName] ?? {}; - const openai = createOpenAI(providerConfig); - model = openai(modelId); - } else { - // Fallback to anthropic - const anthropic = createAnthropic({}); - model = anthropic("claude-haiku-4"); - } - - // 5. Generate title - const result = await generateWorkspaceTitle( - this.workspaceId, - this.historyService, - model, - modelString - ); - - if (!result.success) { - // Non-fatal: just log and continue without title - console.error( - `[Autotitle] Failed to generate title for ${this.workspaceId}:`, - result.error - ); - return; - } - - const title = result.data; - - // 6. Update config with new title - this.config.editConfig((config) => { - for (const [_projectPath, projectConfig] of config.projects) { - const workspace = projectConfig.workspaces.find((w) => w.id === this.workspaceId); - if (workspace) { - workspace.title = title; - break; - } - } - return config; - }); - - // 7. Emit metadata update so UI refreshes - const updatedMetadataResult = this.aiService.getWorkspaceMetadata(this.workspaceId); - if (updatedMetadataResult.success) { - this.emitMetadata(updatedMetadataResult.data); - } - - console.log(`[Autotitle] Generated title for ${this.workspaceId}: "${title}"`); - } catch (error) { - // Catch any unexpected errors to prevent breaking the stream - console.error(`[Autotitle] Unexpected error for ${this.workspaceId}:`, error); - } - } - private emitChatEvent(message: WorkspaceChatMessage): void { this.assertNotDisposed("emitChatEvent"); this.emitter.emit("chat-event", { diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 65abee7077..ff62743415 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -28,8 +28,6 @@ import { createBashTool } from "@/services/tools/bash"; import type { BashToolResult } from "@/types/tools"; import { secretsToRecord } from "@/types/secrets"; import { DisposableTempDir } from "@/services/tempDir"; -import { createAnthropic } from "@ai-sdk/anthropic"; -import { createOpenAI } from "@ai-sdk/openai"; import { generateWorkspaceTitle } from "@/services/autotitle"; /** @@ -330,50 +328,30 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, async (_event, workspaceId: string) => { - try { - // Determine which provider to use - const providersConfig = this.config.loadProvidersConfig(); - let modelString = "anthropic:claude-haiku-4"; // Default - - if (providersConfig) { - // Use first configured provider's cheapest model - const providers = Object.keys(providersConfig); - if (providers.length > 0) { - const provider = providers[0]; - if (provider === "anthropic") { - modelString = "anthropic:claude-haiku-4"; - } else if (provider === "openai") { - modelString = "openai:gpt-4o-mini"; - } + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, + async (_event, workspaceId: string, modelString: string) => { + try { + if (!modelString || !modelString.includes(":")) { + return Err( + 'Invalid model string format. Expected "provider:model-id" (e.g., "anthropic:claude-3-5-sonnet-20241022")' + ); } - } - // Create model instance - const [providerName, modelId] = modelString.split(":"); - let model; - - if (providerName === "anthropic") { - const providerConfig = providersConfig?.[providerName] ?? {}; - const anthropic = createAnthropic(providerConfig); - model = anthropic(modelId); - } else if (providerName === "openai") { - const providerConfig = providersConfig?.[providerName] ?? {}; - const openai = createOpenAI(providerConfig); - model = openai(modelId); - } else { - // Fallback to anthropic - const anthropic = createAnthropic({}); - model = anthropic("claude-haiku-4"); - } + // Load provider configs for API keys, base URLs, etc. + const providersConfig = this.config.loadProvidersConfig(); - // Generate title - const result = await generateWorkspaceTitle( - workspaceId, - this.historyService, - model, - modelString - ); + // Create model instance using utility + const { createModelFromString } = await import("@/utils/ai/modelFactory"); + const model = createModelFromString(modelString, providersConfig); + + // Generate title + const result = await generateWorkspaceTitle( + workspaceId, + this.historyService, + model, + modelString + ); if (!result.success) { return Err(result.error); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index b91782b328..1188dc8265 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -760,6 +760,57 @@ export class WorkspaceStore { // Private methods + /** + * Auto-generate title after first assistant response. + * Uses the workspace's current model to respect user's sendMessageOptions. + */ + private maybeGenerateTitle( + workspaceId: string, + aggregator: StreamingMessageAggregator + ): void { + // Get workspace metadata + const metadata = this.workspaceMetadata.get(workspaceId); + if (!metadata) { + return; // No metadata available + } + + // Skip if workspace already has a title + if (metadata.title) { + return; + } + + // Check if this is the first assistant response + const messages = aggregator.getMessages(); + const assistantMessages = messages.filter((m) => m.role === "assistant"); + if (assistantMessages.length !== 1) { + return; // Not the first assistant message + } + + // Get the current model from workspace state (the model used for this stream) + const state = this.states.get(workspaceId); + const currentModel = state?.currentModel; + if (!currentModel || !currentModel.includes(":")) { + console.warn( + `[AutoTitle] Cannot generate title for ${workspaceId}: no valid model configured` + ); + return; + } + + // Fire and forget - generate title in background + void window.api.workspace + .generateTitle(workspaceId, currentModel) + .then((result) => { + if (result.success) { + console.log(`[AutoTitle] Generated title for ${workspaceId}: "${result.data.title}"`); + } else { + console.error(`[AutoTitle] Failed to generate title for ${workspaceId}:`, result.error); + } + }) + .catch((error) => { + console.error(`[AutoTitle] Unexpected error for ${workspaceId}:`, error); + }); + } + private getOrCreateAggregator( workspaceId: string, createdAt?: string @@ -904,6 +955,9 @@ export class WorkspaceStore { // MUST happen after aggregator.handleStreamEnd() stores the metadata this.finalizeUsageStats(workspaceId, data.metadata); + // Auto-generate title after first assistant response (if workspace has no title) + this.maybeGenerateTitle(workspaceId, aggregator); + return; } diff --git a/src/types/ipc.ts b/src/types/ipc.ts index c4d05f8cf3..1286b5d4ed 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -228,7 +228,10 @@ export interface IPCApi { } ): Promise>; openTerminal(workspacePath: string): Promise; - generateTitle(workspaceId: string): Promise>; + generateTitle( + workspaceId: string, + modelString: string + ): Promise>; // Event subscriptions (renderer-only) // These methods are designed to send current state immediately upon subscription, diff --git a/src/utils/ai/modelFactory.ts b/src/utils/ai/modelFactory.ts new file mode 100644 index 0000000000..aba6f67878 --- /dev/null +++ b/src/utils/ai/modelFactory.ts @@ -0,0 +1,43 @@ +/** + * Utility for creating AI SDK model instances from model strings. + * Centralizes provider detection and model creation logic. + */ + +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createOpenAI } from "@ai-sdk/openai"; +import type { LanguageModel } from "ai"; +import type { ProvidersConfig } from "@/types/providers"; + +/** + * Creates a language model instance from a model string like "anthropic:claude-3-5-sonnet-20241022" + * @param modelString Format: "provider:model-id" + * @param providersConfig Provider configuration (API keys, base URLs, etc.) + * @returns Language model instance ready for use with AI SDK + */ +export function createModelFromString( + modelString: string, + providersConfig?: ProvidersConfig +): LanguageModel { + const [providerName, modelId] = modelString.split(":"); + + if (!modelId) { + throw new Error(`Invalid model string format: "${modelString}". Expected "provider:model-id"`); + } + + switch (providerName) { + case "anthropic": { + const providerConfig = providersConfig?.[providerName] ?? {}; + const anthropic = createAnthropic(providerConfig); + return anthropic(modelId); + } + case "openai": { + const providerConfig = providersConfig?.[providerName] ?? {}; + const openai = createOpenAI(providerConfig); + return openai(modelId); + } + default: + throw new Error( + `Unsupported provider: "${providerName}". Supported providers: anthropic, openai` + ); + } +} From a1e13ec8455aa47a92cfc9c277cd959f1dbf869d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:38:27 -0400 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20type=20errors=20and?= =?UTF-8?q?=20update=20mock=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed WorkspaceStore: use getAllMessages() and getWorkspaceSidebarState() - Fixed ProvidersConfig type (inline definition) - Updated mock APIs in stories to match new generateTitle signature - Added title existence check in IPC handler - Fixed null vs undefined type mismatch --- src/App.stories.tsx | 4 ++-- src/services/ipcMain.ts | 8 +++++++- src/stores/WorkspaceStore.ts | 25 +++++++++---------------- src/utils/ai/modelFactory.ts | 4 +++- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 72e10f68b2..d08a1fbd0e 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -60,7 +60,7 @@ function setupMockAPI(options: { success: true, data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, }), - generateTitle: () => + generateTitle: (_workspaceId: string, _modelString: string) => Promise.resolve({ success: true, data: { title: "Generated Title" }, @@ -602,7 +602,7 @@ export const ActiveWorkspaceWithChat: Story = { success: true, data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, }), - generateTitle: () => + generateTitle: (_workspaceId: string, _modelString: string) => Promise.resolve({ success: true, data: { title: "Generated Title" }, diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index ff62743415..64eb6bddb5 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -332,6 +332,12 @@ export class IpcMain { IPC_CHANNELS.WORKSPACE_GENERATE_TITLE, async (_event, workspaceId: string, modelString: string) => { try { + // Check if workspace already has a title + const metadataResult = this.aiService.getWorkspaceMetadata(workspaceId); + if (metadataResult.success && metadataResult.data.title) { + return Err("Workspace already has a title"); + } + if (!modelString || !modelString.includes(":")) { return Err( 'Invalid model string format. Expected "provider:model-id" (e.g., "anthropic:claude-3-5-sonnet-20241022")' @@ -343,7 +349,7 @@ export class IpcMain { // Create model instance using utility const { createModelFromString } = await import("@/utils/ai/modelFactory"); - const model = createModelFromString(modelString, providersConfig); + const model = createModelFromString(modelString, providersConfig ?? undefined); // Generate title const result = await generateWorkspaceTitle( diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 1188dc8265..cf3d415fc8 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -763,32 +763,22 @@ export class WorkspaceStore { /** * Auto-generate title after first assistant response. * Uses the workspace's current model to respect user's sendMessageOptions. + * + * Note: Metadata check is done via getWorkspaceMetadata() which queries the config. */ private maybeGenerateTitle( workspaceId: string, aggregator: StreamingMessageAggregator ): void { - // Get workspace metadata - const metadata = this.workspaceMetadata.get(workspaceId); - if (!metadata) { - return; // No metadata available - } - - // Skip if workspace already has a title - if (metadata.title) { - return; - } - // Check if this is the first assistant response - const messages = aggregator.getMessages(); - const assistantMessages = messages.filter((m) => m.role === "assistant"); + const messages = aggregator.getAllMessages(); + const assistantMessages = messages.filter((m: CmuxMessage) => m.role === "assistant"); if (assistantMessages.length !== 1) { return; // Not the first assistant message } // Get the current model from workspace state (the model used for this stream) - const state = this.states.get(workspaceId); - const currentModel = state?.currentModel; + const currentModel = this.getWorkspaceSidebarState(workspaceId).currentModel; if (!currentModel || !currentModel.includes(":")) { console.warn( `[AutoTitle] Cannot generate title for ${workspaceId}: no valid model configured` @@ -796,6 +786,7 @@ export class WorkspaceStore { return; } + // Check workspace metadata via IPC (it will check if title already exists) // Fire and forget - generate title in background void window.api.workspace .generateTitle(workspaceId, currentModel) @@ -803,7 +794,8 @@ export class WorkspaceStore { if (result.success) { console.log(`[AutoTitle] Generated title for ${workspaceId}: "${result.data.title}"`); } else { - console.error(`[AutoTitle] Failed to generate title for ${workspaceId}:`, result.error); + // Title already exists or other error - this is fine, just log it + console.log(`[AutoTitle] Skipped title generation for ${workspaceId}:`, result.error); } }) .catch((error) => { @@ -956,6 +948,7 @@ export class WorkspaceStore { this.finalizeUsageStats(workspaceId, data.metadata); // Auto-generate title after first assistant response (if workspace has no title) + // Note: We don't have metadata here, so title check happens in the method this.maybeGenerateTitle(workspaceId, aggregator); return; diff --git a/src/utils/ai/modelFactory.ts b/src/utils/ai/modelFactory.ts index aba6f67878..ca7fed25d6 100644 --- a/src/utils/ai/modelFactory.ts +++ b/src/utils/ai/modelFactory.ts @@ -6,7 +6,9 @@ import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; import type { LanguageModel } from "ai"; -import type { ProvidersConfig } from "@/types/providers"; + +// ProvidersConfig is the type from providers.jsonc (Record) +type ProvidersConfig = Record>; /** * Creates a language model instance from a model string like "anthropic:claude-3-5-sonnet-20241022" From 426f9c8cf6e1a2f7b60f315ce0528b066d7c97b6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:40:42 -0400 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20lint:=20remove=20dyn?= =?UTF-8?q?amic=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use static import at top of file instead of dynamic import. --- src/services/ipcMain.ts | 4 ++-- src/stores/WorkspaceStore.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 64eb6bddb5..cfb6099559 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -29,6 +29,7 @@ import type { BashToolResult } from "@/types/tools"; import { secretsToRecord } from "@/types/secrets"; import { DisposableTempDir } from "@/services/tempDir"; import { generateWorkspaceTitle } from "@/services/autotitle"; +import { createModelFromString } from "@/utils/ai/modelFactory"; /** * IpcMain - Manages all IPC handlers and service coordination @@ -338,7 +339,7 @@ export class IpcMain { return Err("Workspace already has a title"); } - if (!modelString || !modelString.includes(":")) { + if (!modelString?.includes(":")) { return Err( 'Invalid model string format. Expected "provider:model-id" (e.g., "anthropic:claude-3-5-sonnet-20241022")' ); @@ -348,7 +349,6 @@ export class IpcMain { const providersConfig = this.config.loadProvidersConfig(); // Create model instance using utility - const { createModelFromString } = await import("@/utils/ai/modelFactory"); const model = createModelFromString(modelString, providersConfig ?? undefined); // Generate title diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index cf3d415fc8..d3ea6bcf32 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -779,7 +779,7 @@ export class WorkspaceStore { // Get the current model from workspace state (the model used for this stream) const currentModel = this.getWorkspaceSidebarState(workspaceId).currentModel; - if (!currentModel || !currentModel.includes(":")) { + if (!currentModel?.includes(":")) { console.warn( `[AutoTitle] Cannot generate title for ${workspaceId}: no valid model configured` ); From 278a178f17692ad35121d98613f20203d9a67a19 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 22 Oct 2025 13:43:09 -0400 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/ipcMain.ts | 69 ++++++++++++++++++------------------ src/stores/WorkspaceStore.ts | 7 ++-- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index cfb6099559..b00485c8d4 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -359,48 +359,49 @@ export class IpcMain { modelString ); - if (!result.success) { - return Err(result.error); - } + if (!result.success) { + return Err(result.error); + } - const title = result.data; + const title = result.data; - // Update config with new title - this.config.editConfig((config) => { - for (const [_projectPath, projectConfig] of config.projects) { - const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); - if (workspace) { - workspace.title = title; - break; + // Update config with new title + this.config.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + workspace.title = title; + break; + } } + return config; + }); + + // Get updated metadata from config + const allMetadata = this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!updatedMetadata) { + return Err("Failed to retrieve updated workspace metadata"); } - return config; - }); - // Get updated metadata from config - const allMetadata = this.config.getAllWorkspaceMetadata(); - const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); - if (!updatedMetadata) { - return Err("Failed to retrieve updated workspace metadata"); - } + // Emit metadata event with updated title + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else if (this.mainWindow) { + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata: updatedMetadata, + }); + } - // Emit metadata event with updated title - const session = this.sessions.get(workspaceId); - if (session) { - session.emitMetadata(updatedMetadata); - } else if (this.mainWindow) { - this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { - workspaceId, - metadata: updatedMetadata, - }); + return Ok({ title }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to generate title: ${message}`); } - - return Ok({ title }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to generate title: ${message}`); } - }); + ); ipcMain.handle( IPC_CHANNELS.WORKSPACE_FORK, diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index d3ea6bcf32..acb7f97798 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -763,13 +763,10 @@ export class WorkspaceStore { /** * Auto-generate title after first assistant response. * Uses the workspace's current model to respect user's sendMessageOptions. - * + * * Note: Metadata check is done via getWorkspaceMetadata() which queries the config. */ - private maybeGenerateTitle( - workspaceId: string, - aggregator: StreamingMessageAggregator - ): void { + private maybeGenerateTitle(workspaceId: string, aggregator: StreamingMessageAggregator): void { // Check if this is the first assistant response const messages = aggregator.getAllMessages(); const assistantMessages = messages.filter((m: CmuxMessage) => m.role === "assistant");