diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx
index f515a2a571..d4c39c3e53 100644
--- a/docs/system-prompt.mdx
+++ b/docs/system-prompt.mdx
@@ -47,46 +47,59 @@ When the user asks you to remember something:
/**
* Build environment context XML block describing the workspace.
* @param workspacePath - Workspace directory path
- * @param runtimeType - Runtime type: "local", "worktree", or "ssh"
+ * @param runtimeType - Runtime type (local, worktree, ssh, docker)
*/
-function buildEnvironmentContext(
- workspacePath: string,
- runtimeType: "local" | "worktree" | "ssh"
-): string {
- if (runtimeType === "local") {
- // Local runtime works directly in project directory - may or may not be git
- return `
-
-You are working in a directory at ${workspacePath}
-
-- Tools run here automatically
-- You are meant to do your work isolated from the user and other agents
-
-`;
- }
-
- if (runtimeType === "ssh") {
- // SSH runtime clones the repository on a remote host
- return `
-
-You are in a clone of a git repository at ${workspacePath}
-
-- This IS a git repository - run git commands directly (no cd needed)
-- Tools run here automatically
-- You are meant to do your work isolated from the user and other agents
-
-`;
+function buildEnvironmentContext(workspacePath: string, runtimeType: RuntimeMode): string {
+ // Common lines shared across git-based runtimes
+ const gitCommonLines = [
+ "- This IS a git repository - run git commands directly (no cd needed)",
+ "- Tools run here automatically",
+ "- You are meant to do your work isolated from the user and other agents",
+ ];
+
+ let description: string;
+ let lines: string[];
+
+ switch (runtimeType) {
+ case RUNTIME_MODE.LOCAL:
+ // Local runtime works directly in project directory - may or may not be git
+ description = `You are working in a directory at ${workspacePath}`;
+ lines = [
+ "- Tools run here automatically",
+ "- You are meant to do your work isolated from the user and other agents",
+ ];
+ break;
+
+ case RUNTIME_MODE.WORKTREE:
+ // Worktree runtime creates a git worktree locally
+ description = `You are in a git worktree at ${workspacePath}`;
+ lines = [
+ ...gitCommonLines,
+ "- Do not modify or visit other worktrees (especially the main project) without explicit user intent",
+ ];
+ break;
+
+ case RUNTIME_MODE.SSH:
+ // SSH runtime clones the repository on a remote host
+ description = `You are in a clone of a git repository at ${workspacePath}`;
+ lines = gitCommonLines;
+ break;
+
+ case RUNTIME_MODE.DOCKER:
+ // Docker runtime runs in an isolated container
+ description = `You are in a clone of a git repository at ${workspacePath} inside a Docker container`;
+ lines = gitCommonLines;
+ break;
+
+ default:
+ assertNever(runtimeType, `Unknown runtime type: ${String(runtimeType)}`);
}
- // Worktree runtime creates a git worktree locally
return `
-You are in a git worktree at ${workspacePath}
+${description}
-- This IS a git repository - run git commands directly (no cd needed)
-- Tools run here automatically
-- Do not modify or visit other worktrees (especially the main project) without explicit user intent
-- You are meant to do your work isolated from the user and other agents
+${lines.join("\n")}
`;
}
diff --git a/jest.config.js b/jest.config.js
index f831bd2763..f7be994803 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -21,7 +21,9 @@ module.exports = {
// Transform ESM modules to CommonJS for Jest
transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"],
// Run tests in parallel (use 50% of available cores, or 4 minimum)
- maxWorkers: "50%",
+ // CI runners often have a low core count; "50%" can result in a single Jest worker,
+ // which can push the integration job over its 10-minute timeout.
+ maxWorkers: process.env.CI ? 4 : "50%",
// Force exit after tests complete to avoid hanging on lingering handles
forceExit: true,
// 10 minute timeout for integration tests, 10s for unit tests
diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx
index fc2f482d1e..4ce3f3e97f 100644
--- a/src/browser/components/ChatInput/CreationControls.tsx
+++ b/src/browser/components/ChatInput/CreationControls.tsx
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect } from "react";
-import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
+import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime";
import { Select } from "../Select";
import { Loader2, Wand2 } from "lucide-react";
import { cn } from "@/common/lib/utils";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
-import { SSHIcon, WorktreeIcon, LocalIcon } from "../icons/RuntimeIcons";
+import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "../icons/RuntimeIcons";
import { DocsLink } from "../DocsLink";
import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
@@ -14,12 +14,12 @@ interface CreationControlsProps {
branchesLoaded: boolean;
trunkBranch: string;
onTrunkBranchChange: (branch: string) => void;
- runtimeMode: RuntimeMode;
+ /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */
+ selectedRuntime: ParsedRuntime;
defaultRuntimeMode: RuntimeMode;
- sshHost: string;
- onRuntimeModeChange: (mode: RuntimeMode) => void;
+ /** Set the currently selected runtime (discriminated union) */
+ onSelectedRuntimeChange: (runtime: ParsedRuntime) => void;
onSetDefaultRuntime: (mode: RuntimeMode) => void;
- onSshHostChange: (host: string) => void;
disabled: boolean;
/** Project name to display as header */
projectName: string;
@@ -80,6 +80,17 @@ const RUNTIME_OPTIONS: Array<{
idleClass:
"bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-ssh)]/40",
},
+ {
+ value: RUNTIME_MODE.DOCKER,
+ label: "Docker",
+ description: "Run in Docker container",
+ docsPath: "/runtime/docker",
+ Icon: DockerIcon,
+ activeClass:
+ "bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60",
+ idleClass:
+ "bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-docker)]/40",
+ },
];
function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
@@ -153,18 +164,20 @@ export function CreationControls(props: CreationControlsProps) {
// Don't check until branches have loaded to avoid prematurely switching runtime
const isNonGitRepo = props.branchesLoaded && props.branches.length === 0;
+ // Extract mode from discriminated union for convenience
+ const runtimeMode = props.selectedRuntime.mode;
+
// Local runtime doesn't need a trunk branch selector (uses project dir as-is)
- const showTrunkBranchSelector =
- props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL;
+ const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL;
- const { runtimeMode, onRuntimeModeChange } = props;
+ const { selectedRuntime, onSelectedRuntimeChange } = props;
// Force local runtime for non-git directories (only after branches loaded)
useEffect(() => {
- if (isNonGitRepo && runtimeMode !== RUNTIME_MODE.LOCAL) {
- onRuntimeModeChange(RUNTIME_MODE.LOCAL);
+ if (isNonGitRepo && selectedRuntime.mode !== RUNTIME_MODE.LOCAL) {
+ onSelectedRuntimeChange({ mode: "local" });
}
- }, [isNonGitRepo, runtimeMode, onRuntimeModeChange]);
+ }, [isNonGitRepo, selectedRuntime.mode, onSelectedRuntimeChange]);
const handleNameChange = useCallback(
(e: React.ChangeEvent) => {
@@ -263,12 +276,39 @@ export function CreationControls(props: CreationControlsProps) {