From 42c2497a847067e36318babcaf52878b70e321c7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 2 Nov 2025 05:45:20 -0500 Subject: [PATCH 01/10] feat: AI-generated workspace creation on first message - Add workspaceTitleGenerator service using Vercel AI SDK - Generate human-readable titles and git-safe branch names - Add sendFirstMessage IPC handler that creates workspace automatically - Add ProjectSelector and FirstMessageInput components for empty state - Update App.tsx to show project selector when no workspace exists - Gracefully fall back to timestamp names if AI generation fails Users can now type a message and hit send to create a workspace with an AI-generated title and branch name based on their message. Generated with `cmux` --- src/App.stories.tsx | 26 ++++ src/App.tsx | 63 +++++++-- src/browser/api.ts | 2 + src/components/FirstMessageInput.tsx | 132 +++++++++++++++++++ src/components/ProjectSelector.tsx | 59 +++++++++ src/constants/ipc-constants.ts | 1 + src/preload.ts | 2 + src/services/ipcMain.ts | 164 ++++++++++++++++++++++++ src/services/workspaceTitleGenerator.ts | 122 ++++++++++++++++++ src/types/ipc.ts | 11 ++ 10 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 src/components/FirstMessageInput.tsx create mode 100644 src/components/ProjectSelector.tsx create mode 100644 src/services/workspaceTitleGenerator.ts 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..85ce21bf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; +import { ProjectSelector } from "./components/ProjectSelector"; +import { FirstMessageInput } from "./components/FirstMessageInput"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; @@ -57,6 +59,17 @@ function AppInner() { const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState(null); const workspaceModalProjectRef = useRef(null); + // Track selected project for empty workspace state + const [selectedProject, setSelectedProject] = useState(null); + + // Auto-select single project when no workspace exists + useEffect(() => { + if (!selectedWorkspace && projects.size === 1) { + const [singleProject] = Array.from(projects.keys()); + setSelectedProject(singleProject); + } + }, [selectedWorkspace, projects]); + // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); @@ -654,17 +667,45 @@ function AppInner() { /> ) : ( -
-

- Welcome to Cmux -

-

Select a workspace from the sidebar or add a new one to get started.

+
+ + {selectedProject ? ( + { + // 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); + }} + /> + ) : ( +
+

+ Welcome to Cmux +

+

Select a project to get started or add a new one from the sidebar.

+
+ )}
)}
diff --git a/src/browser/api.ts b/src/browser/api.ts index 6a77917ab..89cc8baa2 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -214,6 +214,8 @@ const webApi: IPCApi = { invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName), sendMessage: (workspaceId, message, options) => 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..17b4bb8b4 --- /dev/null +++ b/src/components/FirstMessageInput.tsx @@ -0,0 +1,132 @@ +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 { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; +import { parseRuntimeString } from "@/utils/chatCommands"; +import { getRuntimeKey } from "@/constants/storage"; + +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 send message options (uses placeholder since no workspace exists yet) + const sendMessageOptions = useSendMessageOptions("__no_workspace__"); + + 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, { + ...sendMessageOptions, + 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, sendMessageOptions, 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} +
+ )} + +
+