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 ? (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we wanna show this as the default path when creating a new workspace in a project? Like, even when you have existing workspaces / projects?

<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) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make workspaceId optional here? Then we reduce duplication in the IPC.

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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably use the standard useSendMessage options

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