Skip to content

Commit 84b8df6

Browse files
authored
🤖 Persist runtime preference per project (#428)
## Overview Adds persistent runtime preference storage per project, so users don't have to repeatedly select local vs SSH runtime when creating multiple workspaces for the same project. ## Changes ### 1. Persist runtime preference on workspace creation - Added `getRuntimeKey()` to storage constants (format: `runtime:{projectPath}`) - Save runtime string to localStorage after successful workspace creation - Only saves when runtime is explicitly provided (not default local) ### 2. Load saved preferences in NewWorkspaceModal - Modal now accepts `projectPath` prop - Loads saved preference when modal opens - Pre-fills form with user's last choice ### 3. Created `useNewWorkspaceOptions` hook - Centralized runtime options management with localStorage persistence - Provides `runtimeMode`, `sshHost`, and `getRuntimeString()` helper - Eliminates duplication between modal and command-line paths - Single source of truth for runtime preference logic ### 4. Updated `/new` command to respect saved preferences - When `-r` flag not provided, loads saved runtime for the project - Explicit `-r` flag still overrides saved preference - Consistent behavior across all workspace creation paths ### 5. Deduplicated runtime mode types and constants - Added shared `RuntimeMode` type and `RUNTIME_MODE` constants to `src/types/runtime.ts` - Added `SSH_RUNTIME_PREFIX` constant - Added `parseRuntimeModeAndHost()` and `buildRuntimeString()` utilities - Eliminated magic strings (`"local"`, `"ssh"`, `"ssh "`) across codebase - Updated hook, modal, and command utilities to use shared types ## Behavior - **First time creating a workspace**: Uses default (local) or user's explicit choice - **After successful creation**: Runtime preference is saved for that project - **Next workspace creation (modal)**: Saved preference pre-fills the form - **Next workspace creation (`/new`)**: Saved preference used if `-r` not specified - **Explicit override**: `-r` flag in `/new` command always takes precedence ## Testing - All tests pass (4 pre-existing unrelated failures) - Typecheck passes - Updated Storybook stories to include `projectPath` prop ## Benefits ✅ **Better UX** - Users set their preference once per project, not every workspace ✅ **Consistency** - Same behavior across modal and `/new` command ✅ **Type safety** - Compiler catches typos and misuse ✅ **No magic strings** - Constants prevent string literal bugs ✅ **Easier refactoring** - Adding new runtime modes only requires updating one file ✅ **Reduced duplication** - Centralized logic in reusable hook _Generated with `cmux`_
1 parent 9f2b981 commit 84b8df6

File tree

7 files changed

+190
-23
lines changed

7 files changed

+190
-23
lines changed

src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type { ThinkingLevel } from "./types/thinking";
2525
import type { RuntimeConfig } from "./types/runtime";
2626
import { CUSTOM_EVENTS } from "./constants/events";
2727
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
28-
import { getThinkingLevelKey } from "./constants/storage";
28+
import { getThinkingLevelKey, getRuntimeKey } from "./constants/storage";
2929
import type { BranchListResult } from "./types/ipc";
3030
import { useTelemetry } from "./hooks/useTelemetry";
3131
import { parseRuntimeString } from "./utils/chatCommands";
@@ -268,6 +268,12 @@ function AppInner() {
268268
// Track workspace creation
269269
telemetry.workspaceCreated(newWorkspace.workspaceId);
270270
setSelectedWorkspace(newWorkspace);
271+
272+
// Save runtime preference for this project if provided
273+
if (runtime) {
274+
const runtimeKey = getRuntimeKey(workspaceModalProject);
275+
localStorage.setItem(runtimeKey, runtime);
276+
}
271277
}
272278
};
273279

@@ -669,6 +675,7 @@ function AppInner() {
669675
<NewWorkspaceModal
670676
isOpen={workspaceModalOpen}
671677
projectName={workspaceModalProjectName}
678+
projectPath={workspaceModalProject}
672679
branches={workspaceModalBranches}
673680
defaultTrunkBranch={workspaceModalDefaultTrunk}
674681
loadErrorMessage={workspaceModalLoadError}

src/components/NewWorkspaceModal.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const meta = {
2121
control: "text",
2222
description: "Name of the project",
2323
},
24+
projectPath: {
25+
control: "text",
26+
description: "Path to the project",
27+
},
2428
branches: {
2529
control: "object",
2630
description: "List of available branches",
@@ -47,6 +51,7 @@ export const Default: Story = {
4751
args: {
4852
isOpen: true,
4953
projectName: "my-project",
54+
projectPath: "/path/to/my-project",
5055
branches: ["main", "develop", "feature/new-feature"],
5156
defaultTrunkBranch: "main",
5257
},
@@ -56,6 +61,7 @@ export const LongProjectName: Story = {
5661
args: {
5762
isOpen: true,
5863
projectName: "very-long-project-name-that-demonstrates-wrapping",
64+
projectPath: "/path/to/very-long-project-name-that-demonstrates-wrapping",
5965
branches: ["main", "develop"],
6066
defaultTrunkBranch: "main",
6167
},
@@ -65,6 +71,7 @@ export const NoBranches: Story = {
6571
args: {
6672
isOpen: true,
6773
projectName: "empty-project",
74+
projectPath: "/path/to/empty-project",
6875
branches: [],
6976
},
7077
};
@@ -73,6 +80,7 @@ export const ManyBranches: Story = {
7380
args: {
7481
isOpen: true,
7582
projectName: "active-project",
83+
projectPath: "/path/to/active-project",
7684
branches: [
7785
"main",
7886
"develop",
@@ -90,6 +98,7 @@ export const Closed: Story = {
9098
args: {
9199
isOpen: false,
92100
projectName: "my-project",
101+
projectPath: "/path/to/my-project",
93102
branches: ["main", "develop"],
94103
defaultTrunkBranch: "main",
95104
},

src/components/NewWorkspaceModal.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import React, { useEffect, useId, useState } from "react";
22
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";
33
import { TooltipWrapper, Tooltip } from "./Tooltip";
44
import { formatNewCommand } from "@/utils/chatCommands";
5+
import { useNewWorkspaceOptions } from "@/hooks/useNewWorkspaceOptions";
6+
import { RUNTIME_MODE } from "@/types/runtime";
57

68
interface NewWorkspaceModalProps {
79
isOpen: boolean;
810
projectName: string;
11+
projectPath: string;
912
branches: string[];
1013
defaultTrunkBranch?: string;
1114
loadErrorMessage?: string | null;
@@ -20,6 +23,7 @@ const formFieldClasses =
2023
const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
2124
isOpen,
2225
projectName,
26+
projectPath,
2327
branches,
2428
defaultTrunkBranch,
2529
loadErrorMessage,
@@ -28,13 +32,15 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
2832
}) => {
2933
const [branchName, setBranchName] = useState("");
3034
const [trunkBranch, setTrunkBranch] = useState(defaultTrunkBranch ?? branches[0] ?? "");
31-
const [runtimeMode, setRuntimeMode] = useState<"local" | "ssh">("local");
32-
const [sshHost, setSshHost] = useState("");
3335
const [isLoading, setIsLoading] = useState(false);
3436
const [error, setError] = useState<string | null>(null);
3537
const infoId = useId();
3638
const hasBranches = branches.length > 0;
3739

40+
// Load runtime preferences from localStorage for this project
41+
const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath);
42+
const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions;
43+
3844
useEffect(() => {
3945
setError(loadErrorMessage ?? null);
4046
}, [loadErrorMessage]);
@@ -59,8 +65,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
5965
const handleCancel = () => {
6066
setBranchName("");
6167
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
62-
setRuntimeMode("local");
63-
setSshHost("");
68+
setRuntimeOptions(RUNTIME_MODE.LOCAL, "");
6469
setError(loadErrorMessage ?? null);
6570
onClose();
6671
};
@@ -83,7 +88,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
8388
console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated");
8489

8590
// Validate SSH host if SSH runtime selected
86-
if (runtimeMode === "ssh") {
91+
if (runtimeMode === RUNTIME_MODE.SSH) {
8792
const trimmedHost = sshHost.trim();
8893
if (trimmedHost.length === 0) {
8994
setError("SSH host is required (e.g., hostname or user@host)");
@@ -97,14 +102,13 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
97102
setError(null);
98103

99104
try {
100-
// Build runtime string if SSH selected
101-
const runtime = runtimeMode === "ssh" ? `ssh ${sshHost.trim()}` : undefined;
105+
// Get runtime string from hook helper
106+
const runtime = getRuntimeString();
102107

103108
await onAdd(trimmedBranchName, normalizedTrunkBranch, runtime);
104109
setBranchName("");
105110
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
106-
setRuntimeMode("local");
107-
setSshHost("");
111+
setRuntimeOptions(RUNTIME_MODE.LOCAL, "");
108112
onClose();
109113
} catch (err) {
110114
const message = err instanceof Error ? err.message : "Failed to create workspace";
@@ -203,25 +207,28 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
203207
id="runtimeMode"
204208
value={runtimeMode}
205209
onChange={(event) => {
206-
setRuntimeMode(event.target.value as "local" | "ssh");
210+
const newMode = event.target.value as
211+
| typeof RUNTIME_MODE.LOCAL
212+
| typeof RUNTIME_MODE.SSH;
213+
setRuntimeOptions(newMode, newMode === RUNTIME_MODE.LOCAL ? "" : sshHost);
207214
setError(null);
208215
}}
209216
disabled={isLoading}
210217
>
211-
<option value="local">Local</option>
212-
<option value="ssh">SSH Remote</option>
218+
<option value={RUNTIME_MODE.LOCAL}>Local</option>
219+
<option value={RUNTIME_MODE.SSH}>SSH Remote</option>
213220
</select>
214221
</div>
215222

216-
{runtimeMode === "ssh" && (
223+
{runtimeMode === RUNTIME_MODE.SSH && (
217224
<div className={formFieldClasses}>
218225
<label htmlFor="sshHost">SSH Host:</label>
219226
<input
220227
id="sshHost"
221228
type="text"
222229
value={sshHost}
223230
onChange={(event) => {
224-
setSshHost(event.target.value);
231+
setRuntimeOptions(RUNTIME_MODE.SSH, event.target.value);
225232
setError(null);
226233
}}
227234
placeholder="hostname or user@hostname"
@@ -238,7 +245,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
238245
<ModalInfo id={infoId}>
239246
<p>This will create a git worktree at:</p>
240247
<code className="block break-all">
241-
{runtimeMode === "ssh"
248+
{runtimeMode === RUNTIME_MODE.SSH
242249
? `${sshHost || "<host>"}:~/cmux/${branchName || "<branch-name>"}`
243250
: `~/.cmux/src/${projectName}/${branchName || "<branch-name>"}`}
244251
</code>
@@ -251,7 +258,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
251258
{formatNewCommand(
252259
branchName.trim(),
253260
trunkBranch.trim() || undefined,
254-
runtimeMode === "ssh" && sshHost.trim() ? `ssh ${sshHost.trim()}` : undefined
261+
getRuntimeString()
255262
)}
256263
</div>
257264
</div>

src/constants/storage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ export function getModeKey(workspaceId: string): string {
6363
return `mode:${workspaceId}`;
6464
}
6565

66+
/**
67+
* Get the localStorage key for the default runtime for a project
68+
* Stores the last successfully used runtime config when creating a workspace
69+
* Format: "runtime:{projectPath}"
70+
*/
71+
export function getRuntimeKey(projectPath: string): string {
72+
return `runtime:${projectPath}`;
73+
}
74+
6675
/**
6776
* Get the localStorage key for the 1M context preference (global)
6877
* Format: "use1MContext"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useState, useEffect } from "react";
2+
import { getRuntimeKey } from "@/constants/storage";
3+
import {
4+
type RuntimeMode,
5+
RUNTIME_MODE,
6+
parseRuntimeModeAndHost,
7+
buildRuntimeString,
8+
} from "@/types/runtime";
9+
10+
export interface WorkspaceRuntimeOptions {
11+
runtimeMode: RuntimeMode;
12+
sshHost: string;
13+
/**
14+
* Returns the runtime string for IPC calls (format: "ssh <host>" or undefined for local)
15+
*/
16+
getRuntimeString: () => string | undefined;
17+
}
18+
19+
/**
20+
* Hook to manage workspace creation runtime options with localStorage persistence.
21+
* Loads saved runtime preference for a project and provides consistent state management.
22+
*
23+
* @param projectPath - Path to the project (used as key for localStorage)
24+
* @returns Runtime options state and setter
25+
*/
26+
export function useNewWorkspaceOptions(
27+
projectPath: string | null | undefined
28+
): [WorkspaceRuntimeOptions, (mode: RuntimeMode, host?: string) => void] {
29+
const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(RUNTIME_MODE.LOCAL);
30+
const [sshHost, setSshHost] = useState("");
31+
32+
// Load saved runtime preference when projectPath changes
33+
useEffect(() => {
34+
if (!projectPath) {
35+
// Reset to defaults when no project
36+
setRuntimeMode(RUNTIME_MODE.LOCAL);
37+
setSshHost("");
38+
return;
39+
}
40+
41+
const runtimeKey = getRuntimeKey(projectPath);
42+
const savedRuntime = localStorage.getItem(runtimeKey);
43+
const parsed = parseRuntimeModeAndHost(savedRuntime);
44+
45+
setRuntimeMode(parsed.mode);
46+
setSshHost(parsed.host);
47+
}, [projectPath]);
48+
49+
// Setter for updating both mode and host
50+
const setRuntimeOptions = (mode: RuntimeMode, host?: string) => {
51+
setRuntimeMode(mode);
52+
setSshHost(host ?? "");
53+
};
54+
55+
// Helper to get runtime string for IPC calls
56+
const getRuntimeString = (): string | undefined => {
57+
return buildRuntimeString(runtimeMode, sshHost);
58+
};
59+
60+
return [
61+
{
62+
runtimeMode,
63+
sshHost,
64+
getRuntimeString,
65+
},
66+
setRuntimeOptions,
67+
];
68+
}

src/types/runtime.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
* Runtime configuration types for workspace execution environments
33
*/
44

5+
/** Runtime mode type - used in UI and runtime string parsing */
6+
export type RuntimeMode = "local" | "ssh";
7+
8+
/** Runtime mode constants */
9+
export const RUNTIME_MODE = {
10+
LOCAL: "local" as const,
11+
SSH: "ssh" as const,
12+
} as const;
13+
14+
/** Runtime string prefix for SSH mode (e.g., "ssh hostname") */
15+
export const SSH_RUNTIME_PREFIX = "ssh ";
16+
517
export type RuntimeConfig =
618
| {
719
type: "local";
@@ -19,3 +31,46 @@ export type RuntimeConfig =
1931
/** Optional: SSH port (default: 22) */
2032
port?: number;
2133
};
34+
35+
/**
36+
* Parse runtime string from localStorage or UI input into mode and host
37+
* Format: "ssh <host>" -> { mode: "ssh", host: "<host>" }
38+
* "local" or undefined -> { mode: "local", host: "" }
39+
*
40+
* Use this for UI state management (localStorage, form inputs)
41+
*/
42+
export function parseRuntimeModeAndHost(runtime: string | null | undefined): {
43+
mode: RuntimeMode;
44+
host: string;
45+
} {
46+
if (!runtime) {
47+
return { mode: RUNTIME_MODE.LOCAL, host: "" };
48+
}
49+
50+
const trimmed = runtime.trim();
51+
const lowerTrimmed = trimmed.toLowerCase();
52+
53+
if (lowerTrimmed === RUNTIME_MODE.LOCAL) {
54+
return { mode: RUNTIME_MODE.LOCAL, host: "" };
55+
}
56+
57+
if (lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) {
58+
const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim();
59+
return { mode: RUNTIME_MODE.SSH, host };
60+
}
61+
62+
// Default to local for unrecognized strings
63+
return { mode: RUNTIME_MODE.LOCAL, host: "" };
64+
}
65+
66+
/**
67+
* Build runtime string for storage/IPC from mode and host
68+
* Returns: "ssh <host>" for SSH, undefined for local
69+
*/
70+
export function buildRuntimeString(mode: RuntimeMode, host: string): string | undefined {
71+
if (mode === RUNTIME_MODE.SSH) {
72+
const trimmedHost = host.trim();
73+
return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : undefined;
74+
}
75+
return undefined;
76+
}

0 commit comments

Comments
 (0)