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
63 changes: 52 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,6 +59,17 @@ function AppInner() {
const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState<string | null>(null);
const workspaceModalProjectRef = useRef<string | null>(null);

// Track selected project for empty workspace state
const [selectedProject, setSelectedProject] = useState<string | null>(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);
Expand Down Expand Up @@ -654,17 +667,45 @@ function AppInner() {
/>
</ErrorBoundary>
) : (
<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]"
style={{
padding: "clamp(40px, 10vh, 100px) 20px",
fontSize: "clamp(14px, 2vw, 16px)",
}}
>
<h2 style={{ fontSize: "clamp(24px, 5vw, 36px)", letterSpacing: "-1px" }}>
Welcome to Cmux
</h2>
<p>Select a workspace from the sidebar or add a new one to get started.</p>
<div className="flex flex-1 flex-col overflow-hidden">
<ProjectSelector
projects={projects}
selectedProject={selectedProject}
onSelect={setSelectedProject}
/>
{selectedProject ? (
<FirstMessageInput
projectPath={selectedProject}
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]"
style={{
padding: "clamp(40px, 10vh, 100px) 20px",
fontSize: "clamp(14px, 2vw, 16px)",
}}
>
<h2 style={{ fontSize: "clamp(24px, 5vw, 36px)", letterSpacing: "-1px" }}>
Welcome to Cmux
</h2>
<p>Select a project to get started or add a new one from the sidebar.</p>
</div>
)}
</div>
)}
</div>
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
132 changes: 132 additions & 0 deletions src/components/FirstMessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(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<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>
);
}
59 changes: 59 additions & 0 deletions src/components/ProjectSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useMemo } from "react";
import type { ProjectConfig } from "@/types/project";

interface ProjectSelectorProps {
projects: Map<string, ProjectConfig>;
selectedProject: string | null;
onSelect: (projectPath: string) => void;
}

/**
* ProjectSelector - Dropdown for selecting a project when no workspace exists
*
* Shows project list in a dropdown. If only one project exists, it's auto-selected
* and the dropdown is not shown.
*/
export function ProjectSelector({ projects, selectedProject, onSelect }: ProjectSelectorProps) {
const projectList = useMemo(() => Array.from(projects.keys()), [projects]);

// Extract project name from path for display
const getProjectName = (projectPath: string): string => {
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? projectPath;
};

if (projectList.length === 0) {
return (
<div className="flex items-center justify-center p-4 text-gray-400">
No projects added. Use Command Palette (⌘⇧P) to add a project.
</div>
);
}

// If only one project, don't show selector (it's auto-selected by parent)
if (projectList.length === 1) {
return null;
}

return (
<div className="flex items-center gap-2 border-b border-gray-700 p-4">
<label htmlFor="project-selector" className="text-sm text-gray-400">
Project:
</label>
<select
id="project-selector"
className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-gray-200 focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={selectedProject ?? ""}
onChange={(e) => onSelect(e.target.value)}
>
<option value="" disabled>
Select a project...
</option>
{projectList.map((projectPath) => (
<option key={projectPath} value={projectPath}>
{getProjectName(projectPath)}
</option>
))}
</select>
</div>
);
}
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
Loading