Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ description: Agent instructions for AI assistants working on the Mux codebase
## Documentation Rules

- No free-floating Markdown. User docs live in `docs/` (read `docs/README.md`, add pages to `docs.json` navigation, use standard Markdown + mermaid). Developer notes belong inline as comments.
- Exception: the `rfc` folder contains human-written RFCs for implementation planning.
- For planning artifacts, use the `propose_plan` tool or inline comments instead of ad-hoc docs.
- Do not add new root-level docs without explicit request; during feature work rely on code + tests + inline comments.
- External API docs already live inside `/tmp/ai-sdk-docs/**.mdx`; never browse `https://sdk.vercel.ai/docs/ai-sdk-core` directly.
Expand Down
36 changes: 36 additions & 0 deletions rfc/260223_workspace-default-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
author: "@ammario"
date: 2026-02-23
---

# Workspace Default Runtime

Commit 88a2be88e2 moved the workspace default runtime config from a tooltip checkbox to a
dedicated sections page. It left the following outstanding UX gaps:

- Unclear path for user to set runtime config to default
- Unclear persistence behavior for runtime options


Two fixes are required:

- Make the `configure` runtime more prominent, and call it `set defaults` instead.
- Create distinct visual style when the current runtime options are not the default so the user
can more quickly see how they would persist their changes. Only the button itself should change,
and the re-style must not create a layout shift.
- Include runtime options in the new runtime settings page to clarify how the defaults work there.
- These defaults should be configurable just as they are in the new workspace page.
- The options should have labels to match parity with the new workspace page.

There's also a bug where clicking the configure button on a project page takes you to the
settings page with a global scope instead of the project scope. We should fix this as well.


## Code Structure

During this change, it is imperative that we have single-ownership of:

- What options are available per runtime
- The setting / getting of defaults
- The list of runtime types
- Display code for the runtime options
20 changes: 20 additions & 0 deletions rfc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# RFC

This folder contains human-written but AI-assisted RFCs for implementing major work items in Mux.

It is an adjunct to AI planning but shifts content ownership to humans for more precise steering.

RFCs should be in files named `<YYYYMMDD>_<title>.md` in this folder.

The file should be structured as follows:

```markdown
---
author: @<github-username>
date: <YYYY-MM-DD>
---

# <Title>

... body ...
```
63 changes: 22 additions & 41 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import { PlatformPaths } from "@/common/utils/paths";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput";
import { cn } from "@/common/lib/utils";
import { formatNameGenerationError } from "@/common/utils/errors/formatNameGenerationError";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
import { Skeleton } from "../ui/skeleton";
import { DocsLink } from "../DocsLink";
import {
RUNTIME_CHOICE_UI,
RUNTIME_OPTION_FIELDS,
type RuntimeChoice,
type RuntimeIconProps,
} from "@/browser/utils/runtimeUi";
Expand All @@ -55,36 +57,6 @@ import {
const INLINE_CONTROL_CLASSES =
"h-7 w-[140px] rounded border border-border-medium bg-separator px-2 text-xs text-foreground focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50";

/** Shared runtime config text input - used for SSH host, Docker image, etc. */
function RuntimeConfigInput(props: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
disabled?: boolean;
hasError?: boolean;
id?: string;
ariaLabel?: string;
}) {
return (
<div className="flex items-center gap-2">
<label htmlFor={props.id} className="text-muted-foreground text-xs">
{props.label}
</label>
<input
id={props.id}
aria-label={props.ariaLabel}
type="text"
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
placeholder={props.placeholder}
disabled={props.disabled}
className={cn(INLINE_CONTROL_CLASSES, props.hasError && "border-red-500")}
/>
</div>
);
}

/** Credential sharing checkbox - used by Docker and Devcontainer runtimes */
function CredentialSharingCheckbox(props: {
checked: boolean;
Expand Down Expand Up @@ -871,16 +843,20 @@ export function CreationControls(props: CreationControlsProps) {

{/* Runtime type - button group */}
<div className="flex flex-col gap-1.5" data-component="RuntimeTypeGroup">
{/* User request: keep the configure shortcut but render it in muted gray. */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<label className="text-muted-foreground text-xs font-medium">Workspace Type</label>
<span className="text-muted-foreground text-xs">-</span>
{/* Keep this subtle so it reads like a secondary action, while still signaling
unsaved differences when the current runtime differs from the project default. */}
<button
type="button"
onClick={() => settings.open("runtimes")}
className="text-muted-foreground hover:text-foreground cursor-pointer text-xs font-medium hover:underline"
onClick={() => settings.open("runtimes", { runtimesProjectPath: props.projectPath })}
className={cn(
"border-border-medium bg-background-secondary text-muted-foreground hover:bg-hover hover:text-foreground inline-flex h-6 cursor-pointer items-center rounded border px-2 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent",
runtimeChoice !== props.defaultRuntimeMode &&
"border-warning/40 bg-warning/10 text-warning hover:bg-warning/20"
)}
>
configure
set defaults
</button>
</div>
<div className="flex flex-col gap-2">
Expand Down Expand Up @@ -998,20 +974,22 @@ export function CreationControls(props: CreationControlsProps) {
// Also hide when Coder is still checking but has saved config (will enable after check)
!(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && (
<RuntimeConfigInput
label="host"
id="ssh-host"
label={RUNTIME_OPTION_FIELDS.ssh.label}
value={selectedRuntime.host}
onChange={(value) => onSelectedRuntimeChange({ mode: "ssh", host: value })}
placeholder="user@host"
placeholder={RUNTIME_OPTION_FIELDS.ssh.placeholder}
disabled={props.disabled}
hasError={props.runtimeFieldError === "ssh"}
inputClassName={INLINE_CONTROL_CLASSES}
/>
)}

{/* Runtime-specific config inputs */}

{selectedRuntime.mode === "docker" && (
<RuntimeConfigInput
label="image"
label={RUNTIME_OPTION_FIELDS.docker.label}
value={selectedRuntime.image}
onChange={(value) =>
onSelectedRuntimeChange({
Expand All @@ -1020,11 +998,12 @@ export function CreationControls(props: CreationControlsProps) {
shareCredentials: selectedRuntime.shareCredentials,
})
}
placeholder="node:20"
placeholder={RUNTIME_OPTION_FIELDS.docker.placeholder}
disabled={props.disabled}
hasError={props.runtimeFieldError === "docker"}
id="docker-image"
ariaLabel="Docker image"
inputClassName={INLINE_CONTROL_CLASSES}
/>
)}
</div>
Expand All @@ -1038,7 +1017,9 @@ export function CreationControls(props: CreationControlsProps) {
{selectedRuntime.mode === "devcontainer" && devcontainerSelection.uiMode !== "hidden" && (
<div className="border-border-medium flex w-fit flex-col gap-1.5 rounded-md border p-2">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">Config</label>
<label className="text-muted-foreground text-xs">
{RUNTIME_OPTION_FIELDS.devcontainer.label}
</label>
{devcontainerSelection.uiMode === "loading" ? (
// Skeleton placeholder while loading - matches dropdown dimensions
<Skeleton className="h-6 w-[280px] rounded-md" />
Expand Down
49 changes: 49 additions & 0 deletions src/browser/components/RuntimeConfigInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useId } from "react";

import { cn } from "@/common/lib/utils";

/**
* Shared runtime option input used by creation and settings screens.
* Keeps labels/inputs visually and behaviorally aligned across both flows.
*/
export function RuntimeConfigInput(props: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
disabled?: boolean;
hasError?: boolean;
id?: string;
ariaLabel?: string;
className?: string;
labelClassName?: string;
inputClassName?: string;
}) {
const autoId = useId();
const inputId = props.id ?? autoId;

return (
<div className={cn("flex items-center gap-2", props.className)}>
<label
htmlFor={inputId}
className={cn("text-muted-foreground text-xs", props.labelClassName)}
>
{props.label}
</label>
<input
id={inputId}
aria-label={props.ariaLabel ?? props.label}
type="text"
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
placeholder={props.placeholder}
disabled={props.disabled}
className={cn(
"border-border-medium bg-background-secondary text-foreground placeholder:text-muted focus:border-accent h-7 rounded border px-2 text-xs focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
props.inputClassName,
props.hasError && "border-red-500"
)}
/>
</div>
);
}
78 changes: 76 additions & 2 deletions src/browser/components/Settings/sections/RuntimesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";

import { resolveCoderAvailability } from "@/browser/components/ChatInput/CoderControls";
import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput";
import {
Select,
SelectContent,
Expand All @@ -13,9 +14,16 @@ import { Switch } from "@/browser/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip";
import { useAPI } from "@/browser/contexts/API";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { useRuntimeEnablement } from "@/browser/hooks/useRuntimeEnablement";
import { RUNTIME_CHOICE_UI, type RuntimeUiSpec } from "@/browser/utils/runtimeUi";
import {
RUNTIME_CHOICE_UI,
getRuntimeOptionField,
type RuntimeUiSpec,
} from "@/browser/utils/runtimeUi";
import { cn } from "@/common/lib/utils";
import { getLastRuntimeConfigKey } from "@/common/constants/storage";
import type { CoderInfo } from "@/common/orpc/schemas/coder";
import { normalizeRuntimeEnablement, RUNTIME_MODE } from "@/common/types/runtime";
import type {
Expand Down Expand Up @@ -66,10 +74,16 @@ export function RuntimesSection() {
const { projects, refreshProjects } = useProjectContext();
const { enablement, setRuntimeEnabled, defaultRuntime, setDefaultRuntime } =
useRuntimeEnablement();
const { runtimesProjectPath, setRuntimesProjectPath } = useSettings();

const projectList = Array.from(projects.keys());

const [selectedScope, setSelectedScope] = useState(ALL_SCOPE_VALUE);
// Consume one-shot project scope hint from "set defaults" button in creation controls.
const initialScope =
runtimesProjectPath && projects.has(runtimesProjectPath)
? runtimesProjectPath
: ALL_SCOPE_VALUE;
const [selectedScope, setSelectedScope] = useState(initialScope);
const [projectOverrideEnabled, setProjectOverrideEnabled] = useState(false);
const [projectEnablement, setProjectEnablement] = useState<RuntimeEnablement>(enablement);
const [projectDefaultRuntime, setProjectDefaultRuntime] = useState<RuntimeEnablementId | null>(
Expand All @@ -82,10 +96,48 @@ export function RuntimesSection() {
// Cache pending per-project overrides locally while config updates propagate.
const overrideCacheRef = useRef<Map<string, RuntimeOverrideCacheEntry>>(new Map());

// When re-opened with a new project hint (e.g., clicking "set defaults" again for
// a different project), sync the scope and clear the one-shot hint.
// Only clear the hint once the project is actually found in the project list;
// projects load asynchronously, so we must keep the hint alive until then.
useEffect(() => {
if (!runtimesProjectPath) return;
if (!projects.has(runtimesProjectPath)) return;
setSelectedScope(runtimesProjectPath);
setRuntimesProjectPath(null);
}, [runtimesProjectPath, projects, setRuntimesProjectPath]);

const selectedProjectPath = selectedScope === ALL_SCOPE_VALUE ? null : selectedScope;
const isProjectScope = Boolean(selectedProjectPath);
const isProjectOverrideActive = isProjectScope && projectOverrideEnabled;

// Per-project runtime option defaults (SSH host, Docker image, etc.).
// Same localStorage keys the creation flow reads, so edits here are reflected immediately.
const runtimeConfigKey = selectedProjectPath
? getLastRuntimeConfigKey(selectedProjectPath)
: "__no_project_defaults__";
type RuntimeOptionConfigs = Partial<Record<string, Record<string, unknown>>>;
const [runtimeOptionConfigs, setRuntimeOptionConfigs] = usePersistedState<RuntimeOptionConfigs>(
runtimeConfigKey,
{},
{ listener: true }
);

const readOptionField = (runtimeMode: string, field: string): string => {
const modeConfig = runtimeOptionConfigs[runtimeMode];
if (!modeConfig || typeof modeConfig !== "object") return "";
const val = modeConfig[field];
return typeof val === "string" ? val : "";
};

const setOptionField = (runtimeMode: string, field: string, value: string) => {
setRuntimeOptionConfigs((prev) => {
const existing = prev[runtimeMode];
const existingObj = existing && typeof existing === "object" ? existing : {};
return { ...prev, [runtimeMode]: { ...existingObj, [field]: value } };
});
};

const syncProjects = () =>
refreshProjects().catch(() => {
// Best-effort only.
Expand Down Expand Up @@ -501,6 +553,8 @@ export function RuntimesSection() {
const rowDisabled = isProjectScope && !projectOverrideEnabled;
const isLastEnabled = effectiveEnablement[runtime.id] && enabledRuntimeCount <= 1;
const switchDisabled = rowDisabled || isLastEnabled;
const optionSpec = getRuntimeOptionField(runtime.id);
const optionRuntimeMode = runtime.id === "coder" ? "ssh" : runtime.id;
const switchControl = (
<Switch
checked={effectiveEnablement[runtime.id]}
Expand Down Expand Up @@ -550,6 +604,26 @@ export function RuntimesSection() {
) : null}
</div>
<div className="text-muted text-xs">{runtime.description}</div>
{/* Configurable option inputs — project scope uses the same labeled
input component and localStorage defaults as the creation flow. */}
{!optionSpec || !selectedProjectPath ? (
runtime.options && !selectedProjectPath ? (
<div className="text-muted/70 text-[11px]">Options: {runtime.options}</div>
) : null
) : (
<RuntimeConfigInput
label={optionSpec.label}
value={readOptionField(optionRuntimeMode, optionSpec.field)}
onChange={(value) =>
setOptionField(optionRuntimeMode, optionSpec.field, value)
}
placeholder={optionSpec.placeholder}
disabled={rowDisabled}
className="mt-1.5"
inputClassName="w-full max-w-[260px]"
ariaLabel={`${optionSpec.label} for ${runtime.label}`}
/>
)}
</div>
</div>
<div className="flex items-center gap-3">
Expand Down
Loading
Loading