Skip to content
26 changes: 26 additions & 0 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 }),
Expand Down
25 changes: 23 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -653,6 +655,25 @@ function AppInner() {
}
/>
</ErrorBoundary>
) : projects.size === 1 ? (
<FirstMessageInput
projectPath={Array.from(projects.keys())[0]}
onWorkspaceCreated={(metadata) => {
// 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);
}}
/>
) : (
<div
className="[&_p]:text-muted mx-auto w-full max-w-3xl text-center [&_h2]:mb-4 [&_h2]:font-bold [&_h2]:tracking-tight [&_h2]:text-white [&_p]:leading-[1.6]"
Expand Down
2 changes: 2 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
133 changes: 133 additions & 0 deletions src/components/FirstMessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
// Send on Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void handleSend();
}
},
[handleSend]
);

return (
<div className="flex h-full flex-col">
{/* Spacer to push input to bottom */}
<div className="flex-1" />

{/* Input area */}
<div className="border-t border-gray-700 p-4">
{error && (
<div className="mb-3 rounded border border-red-700 bg-red-900/20 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}

<div className="flex flex-col gap-2">
<textarea
ref={inputRef}
className={cn(
"w-full resize-none rounded border bg-gray-800 px-3 py-2 text-white",
"border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
"placeholder-gray-500",
"min-h-[80px] max-h-[300px]"
)}
placeholder="Type your first message to create a workspace..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isSending}
autoFocus
/>

<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{window.api.platform === "darwin" ? "⌘" : "Ctrl"}+Enter to send
</span>

<button
type="button"
onClick={() => void handleSend()}
disabled={!input.trim() || isSending}
className={cn(
"rounded px-4 py-2 text-sm font-medium",
!input.trim() || isSending
? "cursor-not-allowed bg-gray-700 text-gray-500"
: "bg-blue-600 text-white hover:bg-blue-700"
)}
>
{isSending ? "Creating..." : "Send"}
</button>
</div>
</div>
</div>
</div>
);
}
11 changes: 8 additions & 3 deletions src/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
onToggleUnread,
}) => {
// Destructure metadata for convenience
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
const {
id: workspaceId,
name: workspaceName,
displayName: displayTitle,
namedWorkspacePath,
} = metadata;
const gitStatus = useGitStatus(workspaceId);

// Get rename context
Expand All @@ -48,8 +53,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
const [editingName, setEditingName] = useState<string>("");
const [renameError, setRenameError] = useState<string | null>(null);

// Use workspace name from metadata instead of deriving from path
const displayName = workspaceName;
// Prefer displayName (human-readable title) over name (branch name) for AI-generated workspaces
const displayName = displayTitle ?? workspaceName;
const isEditing = editingWorkspaceId === workspaceId;

const startRenaming = () => {
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export class Config {
const metadata: WorkspaceMetadata = {
id: workspace.id,
name: workspace.name,
displayName: workspace.displayName, // Optional display title
projectName,
projectPath,
// GUARANTEE: All workspaces must have createdAt (assign now if missing)
Expand Down
1 change: 1 addition & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const IPC_CHANNELS = {
WORKSPACE_RENAME: "workspace:rename",
WORKSPACE_FORK: "workspace:fork",
WORKSPACE_SEND_MESSAGE: "workspace:sendMessage",
WORKSPACE_SEND_FIRST_MESSAGE: "workspace:sendFirstMessage",
WORKSPACE_RESUME_STREAM: "workspace:resumeStream",
WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream",
WORKSPACE_TRUNCATE_HISTORY: "workspace:truncateHistory",
Expand Down
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ const api: IPCApi = {
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName),
sendMessage: (workspaceId, message, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
sendFirstMessage: (projectPath, message, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_FIRST_MESSAGE, projectPath, message, options),
resumeStream: (workspaceId, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) =>
Expand Down
Loading