Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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