diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 799620dde..0b1a262d3 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -48,6 +48,19 @@ function setupMockAPI(options: { onChat: () => () => undefined, onMetadata: () => () => undefined, sendMessage: () => Promise.resolve({ success: true, data: undefined }), + sendFirstMessage: () => + Promise.resolve({ + success: true, + workspaceId: Math.random().toString(36).substring(2, 12), + metadata: { + id: Math.random().toString(36).substring(2, 12), + name: "mock-workspace", + projectPath: "/mock/project", + projectName: "project", + namedWorkspacePath: "/mock/workspace/mock-workspace", + createdAt: new Date().toISOString(), + }, + }), resumeStream: () => Promise.resolve({ success: true, data: undefined }), interruptStream: () => Promise.resolve({ success: true, data: undefined }), truncateHistory: () => Promise.resolve({ success: true, data: undefined }), @@ -629,6 +642,19 @@ export const ActiveWorkspaceWithChat: Story = { }, onMetadata: () => () => undefined, sendMessage: () => Promise.resolve({ success: true, data: undefined }), + sendFirstMessage: () => + Promise.resolve({ + success: true, + workspaceId: Math.random().toString(36).substring(2, 12), + metadata: { + id: Math.random().toString(36).substring(2, 12), + name: "mock-workspace", + projectPath: "/mock/project", + projectName: "project", + namedWorkspacePath: "/mock/workspace/mock-workspace", + createdAt: new Date().toISOString(), + }, + }), resumeStream: () => Promise.resolve({ success: true, data: undefined }), interruptStream: () => Promise.resolve({ success: true, data: undefined }), truncateHistory: () => Promise.resolve({ success: true, data: undefined }), diff --git a/src/App.tsx b/src/App.tsx index d7b1ea208..cd0d760f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; +import { FirstMessageInput } from "./components/FirstMessageInput"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; @@ -114,9 +115,10 @@ function AppInner() { window.history.replaceState(null, "", newHash); } - // Update window title with workspace name + // Update window title with workspace name (prefer displayName if available) + const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); const workspaceName = - workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId; + metadata?.displayName ?? metadata?.name ?? selectedWorkspace.workspaceId; const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { @@ -653,6 +655,25 @@ function AppInner() { } /> + ) : projects.size === 1 ? ( + { + // Add to workspace metadata map + setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); + + // Switch to new workspace + handleWorkspaceSwitch({ + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }); + + // Track telemetry + telemetry.workspaceCreated(metadata.id); + }} + /> ) : (
invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), + sendFirstMessage: (projectPath, message, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_FIRST_MESSAGE, projectPath, message, options), resumeStream: (workspaceId, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), interruptStream: (workspaceId, options) => diff --git a/src/components/FirstMessageInput.tsx b/src/components/FirstMessageInput.tsx new file mode 100644 index 000000000..ab4c95245 --- /dev/null +++ b/src/components/FirstMessageInput.tsx @@ -0,0 +1,133 @@ +import React, { useState, useRef, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { RuntimeConfig } from "@/types/runtime"; +import { parseRuntimeString } from "@/utils/chatCommands"; +import { getRuntimeKey } from "@/constants/storage"; +import { useModelLRU } from "@/hooks/useModelLRU"; + +interface FirstMessageInputProps { + projectPath: string; + onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; +} + +/** + * FirstMessageInput - Simplified input for sending first message without a workspace + * + * When user sends a message, it: + * 1. Creates a workspace with AI-generated title/branch + * 2. Sends the message to the new workspace + * 3. Switches to the new workspace (via callback) + */ +export function FirstMessageInput({ projectPath, onWorkspaceCreated }: FirstMessageInputProps) { + const [input, setInput] = useState(""); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + // Get most recent model from LRU (no workspace-specific model yet) + const { recentModels } = useModelLRU(); + const model = recentModels[0]; // Most recently used model + + const handleSend = useCallback(async () => { + if (!input.trim() || isSending) return; + + setIsSending(true); + setError(null); + + try { + // Read runtime preference from localStorage + const runtimeKey = getRuntimeKey(projectPath); + const runtimeString = localStorage.getItem(runtimeKey); + const runtimeConfig: RuntimeConfig | undefined = runtimeString + ? parseRuntimeString(runtimeString, "") + : undefined; + + const result = await window.api.workspace.sendFirstMessage(projectPath, input, { + model, // Use most recent model from LRU + runtimeConfig, + }); + + if (!result.success) { + setError(result.error); + setIsSending(false); + return; + } + + // Clear input + setInput(""); + + // Notify parent to switch workspace + onWorkspaceCreated(result.metadata); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + setError(`Failed to create workspace: ${errorMessage}`); + setIsSending(false); + } + }, [input, isSending, projectPath, model, onWorkspaceCreated]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Send on Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void handleSend(); + } + }, + [handleSend] + ); + + return ( +
+ {/* Spacer to push input to bottom */} +
+ + {/* Input area */} +
+ {error && ( +
+ {error} +
+ )} + +
+