Skip to content

Commit 74ed9a0

Browse files
committed
🤖 refactor: use discriminated union for runtime settings
Refactor DraftWorkspaceSettings to use ParsedRuntime discriminated union instead of separate runtimeMode/sshHost/dockerImage fields. This makes invalid states unrepresentable: - Cannot have SSH mode without a host - Cannot have Docker mode without an image - Runtime-specific args are nested within their runtime type Changes: - DraftWorkspaceSettings.selectedRuntime: ParsedRuntime | null - DraftWorkspaceSettings.defaultRuntime: ParsedRuntime | null - Remove sshHost/dockerImage as separate top-level fields - Update CreationControls to derive mode from selectedRuntime - Add Docker image input field to CreationControls - Update test harness to use new discriminated union API _Generated with mux_
1 parent d2b754e commit 74ed9a0

File tree

5 files changed

+254
-189
lines changed

5 files changed

+254
-189
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 151 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, { useCallback, useEffect } from "react";
2-
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
1+
import React, { useCallback, useEffect, useState } from "react";
2+
import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime";
33
import { Select } from "../Select";
44
import { RuntimeIconSelector } from "../RuntimeIconSelector";
55
import { Loader2, Wand2 } from "lucide-react";
@@ -13,40 +13,150 @@ interface CreationControlsProps {
1313
branchesLoaded: boolean;
1414
trunkBranch: string;
1515
onTrunkBranchChange: (branch: string) => void;
16-
runtimeMode: RuntimeMode;
17-
defaultRuntimeMode: RuntimeMode;
18-
sshHost: string;
19-
onRuntimeModeChange: (mode: RuntimeMode) => void;
20-
onSetDefaultRuntime: (mode: RuntimeMode) => void;
21-
onSshHostChange: (host: string) => void;
16+
/**
17+
* Currently selected runtime (discriminated union).
18+
* null when mode requires additional input (e.g., SSH without host).
19+
*/
20+
selectedRuntime: ParsedRuntime | null;
21+
/** Persisted default runtime for this project */
22+
defaultRuntime: ParsedRuntime | null;
23+
/** Set the currently selected runtime */
24+
onRuntimeChange: (runtime: ParsedRuntime | null) => void;
25+
/** Set the default runtime for this project (persists via checkbox) */
26+
onSetDefaultRuntime: (runtime: ParsedRuntime | null) => void;
2227
disabled: boolean;
2328
/** Workspace name/title generation state and actions */
2429
nameState: WorkspaceNameState;
2530
}
2631

32+
/**
33+
* Helper to get the mode from a ParsedRuntime (for UI display)
34+
*/
35+
function getRuntimeMode(runtime: ParsedRuntime | null): RuntimeMode {
36+
return runtime?.mode ?? RUNTIME_MODE.WORKTREE;
37+
}
38+
39+
/**
40+
* Helper to get SSH host from runtime (empty string if not SSH)
41+
*/
42+
function getSshHost(runtime: ParsedRuntime | null): string {
43+
return runtime?.mode === "ssh" ? runtime.host : "";
44+
}
45+
46+
/**
47+
* Helper to get Docker image from runtime (empty string if not Docker)
48+
*/
49+
function getDockerImage(runtime: ParsedRuntime | null): string {
50+
return runtime?.mode === "docker" ? runtime.image : "";
51+
}
52+
2753
/**
2854
* Additional controls shown only during workspace creation
2955
* - Trunk branch selector (which branch to fork from) - hidden for Local runtime
30-
* - Runtime mode (Local, Worktree, SSH) - only Local available for non-git directories
56+
* - Runtime mode (Local, Worktree, SSH, Docker) - only Local available for non-git directories
3157
* - Workspace name (auto-generated with manual override)
3258
*/
3359
export function CreationControls(props: CreationControlsProps) {
60+
const { selectedRuntime, onRuntimeChange, nameState } = props;
61+
62+
// Derive the current mode for UI display
63+
const runtimeMode = getRuntimeMode(selectedRuntime);
64+
65+
// Local state for SSH host and Docker image input (before user commits)
66+
// This allows typing without immediately updating the runtime
67+
const [sshHostInput, setSshHostInput] = useState(() => getSshHost(selectedRuntime));
68+
const [dockerImageInput, setDockerImageInput] = useState(() => getDockerImage(selectedRuntime));
69+
70+
// Sync local input state when runtime changes externally
71+
useEffect(() => {
72+
setSshHostInput(getSshHost(selectedRuntime));
73+
setDockerImageInput(getDockerImage(selectedRuntime));
74+
}, [selectedRuntime]);
75+
3476
// Non-git directories (empty branches after loading completes) can only use local runtime
3577
// Don't check until branches have loaded to avoid prematurely switching runtime
3678
const isNonGitRepo = props.branchesLoaded && props.branches.length === 0;
3779

3880
// Local runtime doesn't need a trunk branch selector (uses project dir as-is)
39-
const showTrunkBranchSelector =
40-
props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL;
41-
42-
const { runtimeMode, onRuntimeModeChange, nameState } = props;
81+
const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL;
4382

4483
// Force local runtime for non-git directories (only after branches loaded)
4584
useEffect(() => {
4685
if (isNonGitRepo && runtimeMode !== RUNTIME_MODE.LOCAL) {
47-
onRuntimeModeChange(RUNTIME_MODE.LOCAL);
86+
onRuntimeChange({ mode: "local" });
4887
}
49-
}, [isNonGitRepo, runtimeMode, onRuntimeModeChange]);
88+
}, [isNonGitRepo, runtimeMode, onRuntimeChange]);
89+
90+
// Handle mode change from RuntimeIconSelector
91+
const handleModeChange = useCallback(
92+
(mode: RuntimeMode) => {
93+
switch (mode) {
94+
case RUNTIME_MODE.LOCAL:
95+
onRuntimeChange({ mode: "local" });
96+
break;
97+
case RUNTIME_MODE.WORKTREE:
98+
onRuntimeChange({ mode: "worktree" });
99+
break;
100+
case RUNTIME_MODE.SSH:
101+
// Use saved host if available, otherwise null (user needs to enter)
102+
onRuntimeChange(sshHostInput ? { mode: "ssh", host: sshHostInput } : null);
103+
break;
104+
case RUNTIME_MODE.DOCKER:
105+
// Use saved image if available, otherwise null (user needs to enter)
106+
onRuntimeChange(dockerImageInput ? { mode: "docker", image: dockerImageInput } : null);
107+
break;
108+
}
109+
},
110+
[onRuntimeChange, sshHostInput, dockerImageInput]
111+
);
112+
113+
// Handle setting default from RuntimeIconSelector
114+
const { onSetDefaultRuntime } = props;
115+
const handleSetDefault = useCallback(
116+
(mode: RuntimeMode) => {
117+
switch (mode) {
118+
case RUNTIME_MODE.LOCAL:
119+
onSetDefaultRuntime({ mode: "local" });
120+
break;
121+
case RUNTIME_MODE.WORKTREE:
122+
onSetDefaultRuntime({ mode: "worktree" });
123+
break;
124+
case RUNTIME_MODE.SSH:
125+
// Only set default if we have a valid host
126+
if (sshHostInput) {
127+
onSetDefaultRuntime({ mode: "ssh", host: sshHostInput });
128+
}
129+
break;
130+
case RUNTIME_MODE.DOCKER:
131+
// Only set default if we have a valid image
132+
if (dockerImageInput) {
133+
onSetDefaultRuntime({ mode: "docker", image: dockerImageInput });
134+
}
135+
break;
136+
}
137+
},
138+
[onSetDefaultRuntime, sshHostInput, dockerImageInput]
139+
);
140+
141+
// Handle SSH host input change
142+
const handleSshHostChange = useCallback(
143+
(host: string) => {
144+
setSshHostInput(host);
145+
// Update runtime with new host (or null if empty)
146+
onRuntimeChange(host.trim() ? { mode: "ssh", host: host.trim() } : null);
147+
},
148+
[onRuntimeChange]
149+
);
150+
151+
// Handle Docker image input change
152+
const handleDockerImageChange = useCallback(
153+
(image: string) => {
154+
setDockerImageInput(image);
155+
// Update runtime with new image (or null if empty)
156+
onRuntimeChange(image.trim() ? { mode: "docker", image: image.trim() } : null);
157+
},
158+
[onRuntimeChange]
159+
);
50160

51161
const handleNameChange = useCallback(
52162
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -125,16 +235,20 @@ export function CreationControls(props: CreationControlsProps) {
125235
{nameState.error && <span className="text-xs text-red-500">{nameState.error}</span>}
126236
</div>
127237

128-
{/* Second row: Runtime, Branch, SSH */}
238+
{/* Second row: Runtime, Branch, SSH/Docker input */}
129239
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
130240
{/* Runtime Selector - icon-based with tooltips */}
131241
<RuntimeIconSelector
132-
value={props.runtimeMode}
133-
onChange={props.onRuntimeModeChange}
134-
defaultMode={props.defaultRuntimeMode}
135-
onSetDefault={props.onSetDefaultRuntime}
242+
value={runtimeMode}
243+
onChange={handleModeChange}
244+
defaultMode={getRuntimeMode(props.defaultRuntime)}
245+
onSetDefault={handleSetDefault}
136246
disabled={props.disabled}
137-
disabledModes={isNonGitRepo ? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH] : undefined}
247+
disabledModes={
248+
isNonGitRepo
249+
? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH, RUNTIME_MODE.DOCKER]
250+
: undefined
251+
}
138252
/>
139253

140254
{/* Trunk Branch Selector - hidden for Local runtime */}
@@ -158,17 +272,29 @@ export function CreationControls(props: CreationControlsProps) {
158272
</div>
159273
)}
160274

161-
{/* SSH Host Input - after From selector */}
162-
{props.runtimeMode === RUNTIME_MODE.SSH && (
275+
{/* SSH Host Input - shown when SSH mode selected */}
276+
{runtimeMode === RUNTIME_MODE.SSH && (
163277
<input
164278
type="text"
165-
value={props.sshHost}
166-
onChange={(e) => props.onSshHostChange(e.target.value)}
279+
value={sshHostInput}
280+
onChange={(e) => handleSshHostChange(e.target.value)}
167281
placeholder="user@host"
168282
disabled={props.disabled}
169283
className="bg-separator text-foreground border-border-medium focus:border-accent h-6 w-32 rounded border px-1 text-xs focus:outline-none disabled:opacity-50"
170284
/>
171285
)}
286+
287+
{/* Docker Image Input - shown when Docker mode selected */}
288+
{runtimeMode === RUNTIME_MODE.DOCKER && (
289+
<input
290+
type="text"
291+
value={dockerImageInput}
292+
onChange={(e) => handleDockerImageChange(e.target.value)}
293+
placeholder="ubuntu:22.04"
294+
disabled={props.disabled}
295+
className="bg-separator text-foreground border-border-medium focus:border-accent h-6 w-32 rounded border px-1 text-xs focus:outline-none disabled:opacity-50"
296+
/>
297+
)}
172298
</div>
173299
</div>
174300
);

src/browser/components/ChatInput/index.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,12 +1608,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
16081608
branchesLoaded={creationState.branchesLoaded}
16091609
trunkBranch={creationState.trunkBranch}
16101610
onTrunkBranchChange={creationState.setTrunkBranch}
1611-
runtimeMode={creationState.runtimeMode}
1612-
defaultRuntimeMode={creationState.defaultRuntimeMode}
1613-
sshHost={creationState.sshHost}
1614-
onRuntimeModeChange={creationState.setRuntimeMode}
1615-
onSetDefaultRuntime={creationState.setDefaultRuntimeMode}
1616-
onSshHostChange={creationState.setSshHost}
1611+
selectedRuntime={creationState.selectedRuntime}
1612+
defaultRuntime={creationState.defaultRuntime}
1613+
onRuntimeChange={creationState.setSelectedRuntime}
1614+
onSetDefaultRuntime={creationState.setDefaultRuntime}
16171615
disabled={creationState.isSending || isSending}
16181616
nameState={creationState.nameState}
16191617
/>

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 31 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
} from "@/common/constants/storage";
1111
import type { SendMessageError as _SendMessageError } from "@/common/types/errors";
1212
import type { WorkspaceChatMessage } from "@/common/orpc/types";
13-
import type { RuntimeMode } from "@/common/types/runtime";
13+
import type { ParsedRuntime } from "@/common/types/runtime";
14+
import { buildRuntimeString } from "@/common/types/runtime";
1415
import type {
1516
FrontendWorkspaceMetadata,
1617
WorkspaceActivitySnapshot,
@@ -409,9 +410,7 @@ describe("useCreationWorkspace", () => {
409410
persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4";
410411

411412
draftSettingsState = createDraftSettingsHarness({
412-
runtimeMode: "ssh",
413-
sshHost: "example.com",
414-
runtimeString: "ssh example.com",
413+
selectedRuntime: { mode: "ssh", host: "example.com" },
415414
trunkBranch: "dev",
416415
});
417416
const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata);
@@ -519,90 +518,65 @@ type DraftSettingsHarness = ReturnType<typeof createDraftSettingsHarness>;
519518

520519
function createDraftSettingsHarness(
521520
initial?: Partial<{
522-
runtimeMode: RuntimeMode;
523-
sshHost: string;
524-
dockerImage: string;
521+
selectedRuntime: ParsedRuntime | null;
522+
defaultRuntime: ParsedRuntime | null;
525523
trunkBranch: string;
526-
runtimeString?: string | undefined;
527-
defaultRuntimeMode?: RuntimeMode;
528524
}>
529525
) {
530-
const state = {
531-
runtimeMode: initial?.runtimeMode ?? "local",
532-
defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree",
533-
sshHost: initial?.sshHost ?? "",
534-
dockerImage: initial?.dockerImage ?? "",
535-
trunkBranch: initial?.trunkBranch ?? "main",
536-
runtimeString: initial?.runtimeString,
537-
} satisfies {
538-
runtimeMode: RuntimeMode;
539-
defaultRuntimeMode: RuntimeMode;
540-
sshHost: string;
541-
dockerImage: string;
526+
// Use discriminated union - makes invalid states unrepresentable
527+
// Explicitly type state to allow null assignment in setters
528+
const state: {
529+
selectedRuntime: ParsedRuntime | null;
530+
defaultRuntime: ParsedRuntime | null;
542531
trunkBranch: string;
543-
runtimeString: string | undefined;
532+
} = {
533+
selectedRuntime: initial?.selectedRuntime ?? { mode: "local" },
534+
defaultRuntime: initial?.defaultRuntime ?? { mode: "worktree" },
535+
trunkBranch: initial?.trunkBranch ?? "main",
544536
};
545537

546538
const setTrunkBranch = mock((branch: string) => {
547539
state.trunkBranch = branch;
548540
});
549541

550-
const getRuntimeString = mock(() => state.runtimeString);
551-
552-
const setRuntimeMode = mock((mode: RuntimeMode) => {
553-
state.runtimeMode = mode;
554-
const trimmedHost = state.sshHost.trim();
555-
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
542+
const getRuntimeString = mock(() => {
543+
return state.selectedRuntime ? buildRuntimeString(state.selectedRuntime) : undefined;
556544
});
557545

558-
const setDefaultRuntimeMode = mock((mode: RuntimeMode) => {
559-
state.defaultRuntimeMode = mode;
560-
state.runtimeMode = mode;
561-
const trimmedHost = state.sshHost.trim();
562-
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
563-
});
546+
const setSelectedRuntime = mock((runtime: ParsedRuntime | null) => {
547+
state.selectedRuntime = runtime;
548+
}) as (runtime: ParsedRuntime | null) => void;
564549

565-
const setSshHost = mock((host: string) => {
566-
state.sshHost = host;
567-
});
568-
569-
const setDockerImage = mock((image: string) => {
570-
state.dockerImage = image;
571-
});
550+
const setDefaultRuntime = mock((runtime: ParsedRuntime | null) => {
551+
state.defaultRuntime = runtime;
552+
state.selectedRuntime = runtime;
553+
}) as (runtime: ParsedRuntime | null) => void;
572554

573555
return {
574556
state,
575-
setRuntimeMode,
576-
setDefaultRuntimeMode,
577-
setSshHost,
578-
setDockerImage,
557+
setSelectedRuntime,
558+
setDefaultRuntime,
579559
setTrunkBranch,
580560
getRuntimeString,
581561
snapshot(): {
582562
settings: DraftWorkspaceSettings;
583-
setRuntimeMode: typeof setRuntimeMode;
584-
setDefaultRuntimeMode: typeof setDefaultRuntimeMode;
585-
setSshHost: typeof setSshHost;
586-
setDockerImage: typeof setDockerImage;
563+
setSelectedRuntime: typeof setSelectedRuntime;
564+
setDefaultRuntime: typeof setDefaultRuntime;
587565
setTrunkBranch: typeof setTrunkBranch;
588566
getRuntimeString: typeof getRuntimeString;
589567
} {
590568
const settings: DraftWorkspaceSettings = {
591569
model: "gpt-4",
592570
thinkingLevel: "medium",
593571
mode: "exec",
594-
runtimeMode: state.runtimeMode,
595-
defaultRuntimeMode: state.defaultRuntimeMode,
596-
sshHost: state.sshHost,
597-
dockerImage: state.dockerImage ?? "",
572+
selectedRuntime: state.selectedRuntime,
573+
defaultRuntime: state.defaultRuntime,
598574
trunkBranch: state.trunkBranch,
599575
};
600576
return {
601577
settings,
602-
setRuntimeMode,
603-
setDefaultRuntimeMode,
604-
setSshHost,
605-
setDockerImage,
578+
setSelectedRuntime,
579+
setDefaultRuntime,
606580
setTrunkBranch,
607581
getRuntimeString,
608582
};

0 commit comments

Comments
 (0)