Skip to content

Commit 2680451

Browse files
committed
🤖 feat: add DockerRuntime support
- Add DockerRuntime implementation (docker exec-based runtime)\n- Add runtime parsing/config support for docker\n- Keep container naming compatible with existing workspaces\n\n_Generated with mux_
1 parent 422ec9c commit 2680451

27 files changed

+1480
-234
lines changed

docs/system-prompt.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ When the user asks you to remember something:
4747
/**
4848
* Build environment context XML block describing the workspace.
4949
* @param workspacePath - Workspace directory path
50-
* @param runtimeType - Runtime type: "local", "worktree", or "ssh"
50+
* @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker"
5151
*/
5252
function buildEnvironmentContext(
5353
workspacePath: string,
54-
runtimeType: "local" | "worktree" | "ssh"
54+
runtimeType: "local" | "worktree" | "ssh" | "docker"
5555
): string {
5656
if (runtimeType === "local") {
5757
// Local runtime works directly in project directory - may or may not be git
@@ -78,6 +78,19 @@ You are in a clone of a git repository at ${workspacePath}
7878
`;
7979
}
8080

81+
if (runtimeType === "docker") {
82+
// Docker runtime runs in an isolated container
83+
return `
84+
<environment>
85+
You are in a clone of a git repository at ${workspacePath} inside a Docker container
86+
87+
- This IS a git repository - run git commands directly (no cd needed)
88+
- Tools run here automatically inside the container
89+
- You are meant to do your work isolated from the user and other agents
90+
</environment>
91+
`;
92+
}
93+
8194
// Worktree runtime creates a git worktree locally
8295
return `
8396
<environment>

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ function createDraftSettingsHarness(
521521
initial?: Partial<{
522522
runtimeMode: RuntimeMode;
523523
sshHost: string;
524+
dockerImage: string;
524525
trunkBranch: string;
525526
runtimeString?: string | undefined;
526527
defaultRuntimeMode?: RuntimeMode;
@@ -530,12 +531,14 @@ function createDraftSettingsHarness(
530531
runtimeMode: initial?.runtimeMode ?? "local",
531532
defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree",
532533
sshHost: initial?.sshHost ?? "",
534+
dockerImage: initial?.dockerImage ?? "",
533535
trunkBranch: initial?.trunkBranch ?? "main",
534536
runtimeString: initial?.runtimeString,
535537
} satisfies {
536538
runtimeMode: RuntimeMode;
537539
defaultRuntimeMode: RuntimeMode;
538540
sshHost: string;
541+
dockerImage: string;
539542
trunkBranch: string;
540543
runtimeString: string | undefined;
541544
};
@@ -563,18 +566,24 @@ function createDraftSettingsHarness(
563566
state.sshHost = host;
564567
});
565568

569+
const setDockerImage = mock((image: string) => {
570+
state.dockerImage = image;
571+
});
572+
566573
return {
567574
state,
568575
setRuntimeMode,
569576
setDefaultRuntimeMode,
570577
setSshHost,
578+
setDockerImage,
571579
setTrunkBranch,
572580
getRuntimeString,
573581
snapshot(): {
574582
settings: DraftWorkspaceSettings;
575583
setRuntimeMode: typeof setRuntimeMode;
576584
setDefaultRuntimeMode: typeof setDefaultRuntimeMode;
577585
setSshHost: typeof setSshHost;
586+
setDockerImage: typeof setDockerImage;
578587
setTrunkBranch: typeof setTrunkBranch;
579588
getRuntimeString: typeof getRuntimeString;
580589
} {
@@ -585,13 +594,15 @@ function createDraftSettingsHarness(
585594
runtimeMode: state.runtimeMode,
586595
defaultRuntimeMode: state.defaultRuntimeMode,
587596
sshHost: state.sshHost,
597+
dockerImage: state.dockerImage ?? "",
588598
trunkBranch: state.trunkBranch,
589599
};
590600
return {
591601
settings,
592602
setRuntimeMode,
593603
setDefaultRuntimeMode,
594604
setSshHost,
605+
setDockerImage,
595606
setTrunkBranch,
596607
getRuntimeString,
597608
};

src/browser/components/RuntimeIconSelector.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import { cn } from "@/common/lib/utils";
33
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
4-
import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons";
4+
import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "./icons/RuntimeIcons";
55
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
66

77
interface RuntimeIconSelectorProps {
@@ -36,6 +36,11 @@ const RUNTIME_STYLES = {
3636
active:
3737
"bg-[var(--color-runtime-local)]/30 text-foreground border-[var(--color-runtime-local)]/60",
3838
},
39+
docker: {
40+
idle: "bg-transparent text-muted border-[var(--color-runtime-docker)]/30 hover:border-[var(--color-runtime-docker)]/50",
41+
active:
42+
"bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60",
43+
},
3944
} as const;
4045

4146
const RUNTIME_INFO: Record<RuntimeMode, { label: string; description: string }> = {
@@ -51,6 +56,10 @@ const RUNTIME_INFO: Record<RuntimeMode, { label: string; description: string }>
5156
label: "SSH",
5257
description: "Remote clone on SSH host",
5358
},
59+
docker: {
60+
label: "Docker",
61+
description: "Isolated container per workspace",
62+
},
5463
};
5564

5665
interface RuntimeIconButtonProps {
@@ -74,7 +83,9 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
7483
? SSHIcon
7584
: props.mode === RUNTIME_MODE.WORKTREE
7685
? WorktreeIcon
77-
: LocalIcon;
86+
: props.mode === RUNTIME_MODE.DOCKER
87+
? DockerIcon
88+
: LocalIcon;
7889

7990
return (
8091
<Tooltip>
@@ -127,7 +138,12 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
127138
* Each tooltip has a "Default for project" checkbox to persist the preference.
128139
*/
129140
export function RuntimeIconSelector(props: RuntimeIconSelectorProps) {
130-
const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH];
141+
const modes: RuntimeMode[] = [
142+
RUNTIME_MODE.LOCAL,
143+
RUNTIME_MODE.WORKTREE,
144+
RUNTIME_MODE.SSH,
145+
RUNTIME_MODE.DOCKER,
146+
];
131147
const disabledModes = props.disabledModes ?? [];
132148

133149
return (

src/browser/components/icons/RuntimeIcons.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,28 @@ export function LocalIcon({ size = 10, className }: IconProps) {
7373
</svg>
7474
);
7575
}
76+
77+
/** Container/box icon for Docker runtime */
78+
export function DockerIcon({ size = 10, className }: IconProps) {
79+
return (
80+
<svg
81+
width={size}
82+
height={size}
83+
viewBox="0 0 16 16"
84+
fill="none"
85+
stroke="currentColor"
86+
strokeWidth="1.5"
87+
strokeLinecap="round"
88+
strokeLinejoin="round"
89+
aria-label="Docker Runtime"
90+
className={className}
91+
>
92+
{/* Container box with stacked layers */}
93+
<rect x="2" y="6" width="12" height="8" rx="1" />
94+
<line x1="2" y1="10" x2="14" y2="10" />
95+
<line x1="5" y1="3" x2="5" y2="6" />
96+
<line x1="8" y1="2" x2="8" y2="6" />
97+
<line x1="11" y1="3" x2="11" y2="6" />
98+
</svg>
99+
);
100+
}

src/browser/hooks/useDraftWorkspaceSettings.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { useMode } from "@/browser/contexts/ModeContext";
55
import { getDefaultModel } from "./useModelsFromSettings";
66
import {
77
type RuntimeMode,
8+
type ParsedRuntime,
89
parseRuntimeModeAndHost,
910
buildRuntimeString,
11+
RUNTIME_MODE,
1012
} from "@/common/types/runtime";
1113
import {
1214
getModelKey,
1315
getRuntimeKey,
1416
getTrunkBranchKey,
1517
getLastSshHostKey,
18+
getLastDockerImageKey,
1619
getProjectScopeId,
1720
} from "@/common/constants/storage";
1821
import type { UIMode } from "@/common/types/mode";
@@ -33,7 +36,10 @@ export interface DraftWorkspaceSettings {
3336
runtimeMode: RuntimeMode;
3437
/** Persisted default runtime for this project (used to initialize selection) */
3538
defaultRuntimeMode: RuntimeMode;
39+
/** SSH host (persisted separately from mode) */
3640
sshHost: string;
41+
/** Docker image (persisted separately from mode) */
42+
dockerImage: string;
3743
trunkBranch: string;
3844
}
3945

@@ -57,6 +63,7 @@ export function useDraftWorkspaceSettings(
5763
/** Set the default runtime mode for this project (persists via checkbox) */
5864
setDefaultRuntimeMode: (mode: RuntimeMode) => void;
5965
setSshHost: (host: string) => void;
66+
setDockerImage: (image: string) => void;
6067
setTrunkBranch: (branch: string) => void;
6168
getRuntimeString: () => string | undefined;
6269
} {
@@ -78,8 +85,9 @@ export function useDraftWorkspaceSettings(
7885
{ listener: true }
7986
);
8087

81-
// Parse default runtime string into mode (worktree when undefined)
82-
const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString);
88+
// Parse default runtime string into mode (worktree when undefined or invalid)
89+
const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString);
90+
const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE;
8391

8492
// Currently selected runtime mode for this session (initialized from default)
8593
// This allows user to select a different runtime without changing the default
@@ -105,6 +113,13 @@ export function useDraftWorkspaceSettings(
105113
{ listener: true }
106114
);
107115

116+
// Project-scoped Docker image preference (persisted separately from runtime mode)
117+
const [lastDockerImage, setLastDockerImage] = usePersistedState<string>(
118+
getLastDockerImageKey(projectPath),
119+
"",
120+
{ listener: true }
121+
);
122+
108123
// Initialize trunk branch from backend recommendation or first branch
109124
useEffect(() => {
110125
if (!trunkBranch && branches.length > 0) {
@@ -113,14 +128,31 @@ export function useDraftWorkspaceSettings(
113128
}
114129
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);
115130

131+
// Build ParsedRuntime from mode + stored host/image
132+
const buildParsedRuntime = (mode: RuntimeMode): ParsedRuntime | null => {
133+
switch (mode) {
134+
case RUNTIME_MODE.LOCAL:
135+
return { mode: "local" };
136+
case RUNTIME_MODE.WORKTREE:
137+
return { mode: "worktree" };
138+
case RUNTIME_MODE.SSH:
139+
return lastSshHost ? { mode: "ssh", host: lastSshHost } : null;
140+
case RUNTIME_MODE.DOCKER:
141+
return lastDockerImage ? { mode: "docker", image: lastDockerImage } : null;
142+
default:
143+
return null;
144+
}
145+
};
146+
116147
// Setter for selected runtime mode (changes current selection, does not persist)
117148
const setRuntimeMode = (newMode: RuntimeMode) => {
118149
setSelectedRuntimeMode(newMode);
119150
};
120151

121152
// Setter for default runtime mode (persists via checkbox in tooltip)
122153
const setDefaultRuntimeMode = (newMode: RuntimeMode) => {
123-
const newRuntimeString = buildRuntimeString(newMode, lastSshHost);
154+
const parsed = buildParsedRuntime(newMode);
155+
const newRuntimeString = parsed ? buildRuntimeString(parsed) : undefined;
124156
setDefaultRuntimeString(newRuntimeString);
125157
// Also update selection to match new default
126158
setSelectedRuntimeMode(newMode);
@@ -131,9 +163,15 @@ export function useDraftWorkspaceSettings(
131163
setLastSshHost(newHost);
132164
};
133165

166+
// Setter for Docker image (persisted separately so it's remembered across mode switches)
167+
const setDockerImage = (newImage: string) => {
168+
setLastDockerImage(newImage);
169+
};
170+
134171
// Helper to get runtime string for IPC calls (uses selected mode, not default)
135172
const getRuntimeString = (): string | undefined => {
136-
return buildRuntimeString(selectedRuntimeMode, lastSshHost);
173+
const parsed = buildParsedRuntime(selectedRuntimeMode);
174+
return parsed ? buildRuntimeString(parsed) : undefined;
137175
};
138176

139177
return {
@@ -144,11 +182,13 @@ export function useDraftWorkspaceSettings(
144182
runtimeMode: selectedRuntimeMode,
145183
defaultRuntimeMode,
146184
sshHost: lastSshHost,
185+
dockerImage: lastDockerImage,
147186
trunkBranch,
148187
},
149188
setRuntimeMode,
150189
setDefaultRuntimeMode,
151190
setSshHost,
191+
setDockerImage,
152192
setTrunkBranch,
153193
getRuntimeString,
154194
};

src/browser/styles/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
--color-runtime-worktree: #a855f7;
7777
--color-runtime-worktree-text: #c084fc; /* purple-400 */
7878
--color-runtime-local: hsl(0 0% 60%); /* matches --color-muted-foreground */
79+
--color-runtime-docker: #2496ed; /* Docker blue */
80+
--color-runtime-docker-text: #54b3f4;
7981

8082
/* Background & Layout */
8183
--color-background: hsl(0 0% 12%);
@@ -323,6 +325,8 @@
323325
--color-runtime-worktree: #a855f7;
324326
--color-runtime-worktree-text: #c084fc;
325327
--color-runtime-local: hsl(210 14% 48%); /* matches --color-muted-foreground */
328+
--color-runtime-docker: #2496ed; /* Docker blue */
329+
--color-runtime-docker-text: #0d7cc4;
326330

327331
--color-background: hsl(210 33% 98%);
328332
--color-background-secondary: hsl(210 36% 95%);
@@ -555,6 +559,8 @@
555559
--color-runtime-worktree: #6c71c4; /* violet */
556560
--color-runtime-worktree-text: #6c71c4;
557561
--color-runtime-local: #839496; /* base0 */
562+
--color-runtime-docker: #2aa198; /* cyan */
563+
--color-runtime-docker-text: #2aa198;
558564

559565
/* Background & Layout - Solarized base colors */
560566
--color-background: #fdf6e3; /* base3 */
@@ -770,6 +776,8 @@
770776
--color-runtime-worktree: #6c71c4; /* violet */
771777
--color-runtime-worktree-text: #6c71c4;
772778
--color-runtime-local: #839496; /* base0 */
779+
--color-runtime-docker: #2aa198; /* cyan */
780+
--color-runtime-docker-text: #2aa198;
773781

774782
/* Background & Layout - Solarized dark base colors
775783
Palette reference:

src/browser/utils/chatCommands.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,37 @@ describe("parseRuntimeString", () => {
9898
});
9999
});
100100

101-
test("throws error for unknown runtime type", () => {
101+
test("parses docker runtime with image", () => {
102+
const result = parseRuntimeString("docker ubuntu:22.04", workspaceName);
103+
expect(result).toEqual({
104+
type: "docker",
105+
image: "ubuntu:22.04",
106+
});
107+
});
108+
109+
test("parses docker with registry image", () => {
110+
const result = parseRuntimeString("docker ghcr.io/myorg/dev:latest", workspaceName);
111+
expect(result).toEqual({
112+
type: "docker",
113+
image: "ghcr.io/myorg/dev:latest",
114+
});
115+
});
116+
117+
test("throws error for docker without image", () => {
102118
expect(() => parseRuntimeString("docker", workspaceName)).toThrow(
103-
"Unknown runtime type: 'docker'. Use 'ssh <host>', 'worktree', or 'local'"
119+
"Docker runtime requires image"
104120
);
121+
expect(() => parseRuntimeString("docker ", workspaceName)).toThrow(
122+
"Docker runtime requires image"
123+
);
124+
});
125+
126+
test("throws error for unknown runtime type", () => {
105127
expect(() => parseRuntimeString("remote", workspaceName)).toThrow(
106-
"Unknown runtime type: 'remote'. Use 'ssh <host>', 'worktree', or 'local'"
128+
"Unknown runtime type: 'remote'. Use 'ssh <host>', 'docker <image>', 'worktree', or 'local'"
129+
);
130+
expect(() => parseRuntimeString("kubernetes", workspaceName)).toThrow(
131+
"Unknown runtime type: 'kubernetes'. Use 'ssh <host>', 'docker <image>', 'worktree', or 'local'"
107132
);
108133
});
109134
});

0 commit comments

Comments
 (0)