Skip to content

Commit 8ca7ae4

Browse files
ammar-agentammario
andauthored
🤖 fix: improve workspace default runtime UX (#2553)
## Summary Improves the workspace default runtime UX per RFC `rfc/260223_workspace-default-runtime.md`, addressing gaps left by #2387. ## Background Commit 88a2be8 (#2387) moved the per-project default runtime setting from a simple tooltip checkbox into a dedicated Settings → Runtimes page. This left two UX gaps: 1. The "configure" link was muted and opened Settings at global scope even when on a project page 2. No visual feedback when the user's current runtime selection differs from the saved default 3. The Settings → Runtimes page didn't show configurable runtime options (SSH host, Docker image, etc.) ## Implementation **Bug fix — project scope passthrough:** - Extended `SettingsContext` with a `runtimesProjectPath` one-shot hint (same pattern as the existing `providersExpandedProvider`) - "set defaults" button now passes the current project path, so Settings → Runtimes opens pre-scoped to the correct project **Rename + prominence:** - Changed "configure" → "set defaults" to clarify purpose - Styled with `text-accent` as the base state **Modified state indicator (no layout shift):** - When the selected runtime differs from the project default, the "set defaults" button gains a `bg-warning/15 text-warning` pill style - Uses consistent `rounded-sm px-1` padding in both states to avoid any layout shift **Configurable runtime options in Settings:** - Added `RUNTIME_OPTION_FIELDS` constant mapping each runtime to its configurable field (SSH→host, Docker→image, Devcontainer→configPath) - For project scope: shows actual text input fields under each runtime row, reading/writing the same `lastRuntimeConfig:{projectPath}` localStorage keys the creation flow uses - For global scope: shows read-only option descriptions from `RuntimeUiSpec.options` - Inputs respect the disabled state (dimmed when project overrides are off) **Single-ownership (RuntimeUiSpec):** - Added `options?: string` field to `RuntimeUiSpec` for centralized documentation of what each runtime requires ## Risks - Low: purely UI changes, no backend or config format changes - The Settings page writes to the same localStorage keys as the creation flow, so edits propagate immediately via `usePersistedState({ listener: true })` --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh` • Cost: `$8.39`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh costs=8.39 --> --------- Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
1 parent 3008182 commit 8ca7ae4

8 files changed

Lines changed: 276 additions & 44 deletions

File tree

docs/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ description: Agent instructions for AI assistants working on the Mux codebase
2424
## Documentation Rules
2525

2626
- 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.
27+
- Exception: the `rfc` folder contains human-written RFCs for implementation planning.
2728
- For planning artifacts, use the `propose_plan` tool or inline comments instead of ad-hoc docs.
2829
- Do not add new root-level docs without explicit request; during feature work rely on code + tests + inline comments.
2930
- External API docs already live inside `/tmp/ai-sdk-docs/**.mdx`; never browse `https://sdk.vercel.ai/docs/ai-sdk-core` directly.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
author: "@ammario"
3+
date: 2026-02-23
4+
---
5+
6+
# Workspace Default Runtime
7+
8+
Commit 88a2be88e2 moved the workspace default runtime config from a tooltip checkbox to a
9+
dedicated sections page. It left the following outstanding UX gaps:
10+
11+
- Unclear path for user to set runtime config to default
12+
- Unclear persistence behavior for runtime options
13+
14+
15+
Two fixes are required:
16+
17+
- Make the `configure` runtime more prominent, and call it `set defaults` instead.
18+
- Create distinct visual style when the current runtime options are not the default so the user
19+
can more quickly see how they would persist their changes. Only the button itself should change,
20+
and the re-style must not create a layout shift.
21+
- Include runtime options in the new runtime settings page to clarify how the defaults work there.
22+
- These defaults should be configurable just as they are in the new workspace page.
23+
- The options should have labels to match parity with the new workspace page.
24+
25+
There's also a bug where clicking the configure button on a project page takes you to the
26+
settings page with a global scope instead of the project scope. We should fix this as well.
27+
28+
29+
## Code Structure
30+
31+
During this change, it is imperative that we have single-ownership of:
32+
33+
- What options are available per runtime
34+
- The setting / getting of defaults
35+
- The list of runtime types
36+
- Display code for the runtime options

rfc/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# RFC
2+
3+
This folder contains human-written but AI-assisted RFCs for implementing major work items in Mux.
4+
5+
It is an adjunct to AI planning but shifts content ownership to humans for more precise steering.
6+
7+
RFCs should be in files named `<YYYYMMDD>_<title>.md` in this folder.
8+
9+
The file should be structured as follows:
10+
11+
```markdown
12+
---
13+
author: @<github-username>
14+
date: <YYYY-MM-DD>
15+
---
16+
17+
# <Title>
18+
19+
... body ...
20+
```

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import { PlatformPaths } from "@/common/utils/paths";
2525
import { useProjectContext } from "@/browser/contexts/ProjectContext";
2626
import { useSettings } from "@/browser/contexts/SettingsContext";
2727
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
28+
import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput";
2829
import { cn } from "@/common/lib/utils";
2930
import { formatNameGenerationError } from "@/common/utils/errors/formatNameGenerationError";
3031
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
3132
import { Skeleton } from "../ui/skeleton";
3233
import { DocsLink } from "../DocsLink";
3334
import {
3435
RUNTIME_CHOICE_UI,
36+
RUNTIME_OPTION_FIELDS,
3537
type RuntimeChoice,
3638
type RuntimeIconProps,
3739
} from "@/browser/utils/runtimeUi";
@@ -55,36 +57,6 @@ import {
5557
const INLINE_CONTROL_CLASSES =
5658
"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";
5759

58-
/** Shared runtime config text input - used for SSH host, Docker image, etc. */
59-
function RuntimeConfigInput(props: {
60-
label: string;
61-
value: string;
62-
onChange: (value: string) => void;
63-
placeholder: string;
64-
disabled?: boolean;
65-
hasError?: boolean;
66-
id?: string;
67-
ariaLabel?: string;
68-
}) {
69-
return (
70-
<div className="flex items-center gap-2">
71-
<label htmlFor={props.id} className="text-muted-foreground text-xs">
72-
{props.label}
73-
</label>
74-
<input
75-
id={props.id}
76-
aria-label={props.ariaLabel}
77-
type="text"
78-
value={props.value}
79-
onChange={(e) => props.onChange(e.target.value)}
80-
placeholder={props.placeholder}
81-
disabled={props.disabled}
82-
className={cn(INLINE_CONTROL_CLASSES, props.hasError && "border-red-500")}
83-
/>
84-
</div>
85-
);
86-
}
87-
8860
/** Credential sharing checkbox - used by Docker and Devcontainer runtimes */
8961
function CredentialSharingCheckbox(props: {
9062
checked: boolean;
@@ -871,16 +843,20 @@ export function CreationControls(props: CreationControlsProps) {
871843

872844
{/* Runtime type - button group */}
873845
<div className="flex flex-col gap-1.5" data-component="RuntimeTypeGroup">
874-
{/* User request: keep the configure shortcut but render it in muted gray. */}
875-
<div className="flex items-center gap-1">
846+
<div className="flex items-center gap-2">
876847
<label className="text-muted-foreground text-xs font-medium">Workspace Type</label>
877-
<span className="text-muted-foreground text-xs">-</span>
848+
{/* Keep this subtle so it reads like a secondary action, while still signaling
849+
unsaved differences when the current runtime differs from the project default. */}
878850
<button
879851
type="button"
880-
onClick={() => settings.open("runtimes")}
881-
className="text-muted-foreground hover:text-foreground cursor-pointer text-xs font-medium hover:underline"
852+
onClick={() => settings.open("runtimes", { runtimesProjectPath: props.projectPath })}
853+
className={cn(
854+
"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",
855+
runtimeChoice !== props.defaultRuntimeMode &&
856+
"border-warning/40 bg-warning/10 text-warning hover:bg-warning/20"
857+
)}
882858
>
883-
configure
859+
set defaults
884860
</button>
885861
</div>
886862
<div className="flex flex-col gap-2">
@@ -998,20 +974,22 @@ export function CreationControls(props: CreationControlsProps) {
998974
// Also hide when Coder is still checking but has saved config (will enable after check)
999975
!(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && (
1000976
<RuntimeConfigInput
1001-
label="host"
977+
id="ssh-host"
978+
label={RUNTIME_OPTION_FIELDS.ssh.label}
1002979
value={selectedRuntime.host}
1003980
onChange={(value) => onSelectedRuntimeChange({ mode: "ssh", host: value })}
1004-
placeholder="user@host"
981+
placeholder={RUNTIME_OPTION_FIELDS.ssh.placeholder}
1005982
disabled={props.disabled}
1006983
hasError={props.runtimeFieldError === "ssh"}
984+
inputClassName={INLINE_CONTROL_CLASSES}
1007985
/>
1008986
)}
1009987

1010988
{/* Runtime-specific config inputs */}
1011989

1012990
{selectedRuntime.mode === "docker" && (
1013991
<RuntimeConfigInput
1014-
label="image"
992+
label={RUNTIME_OPTION_FIELDS.docker.label}
1015993
value={selectedRuntime.image}
1016994
onChange={(value) =>
1017995
onSelectedRuntimeChange({
@@ -1020,11 +998,12 @@ export function CreationControls(props: CreationControlsProps) {
1020998
shareCredentials: selectedRuntime.shareCredentials,
1021999
})
10221000
}
1023-
placeholder="node:20"
1001+
placeholder={RUNTIME_OPTION_FIELDS.docker.placeholder}
10241002
disabled={props.disabled}
10251003
hasError={props.runtimeFieldError === "docker"}
10261004
id="docker-image"
10271005
ariaLabel="Docker image"
1006+
inputClassName={INLINE_CONTROL_CLASSES}
10281007
/>
10291008
)}
10301009
</div>
@@ -1038,7 +1017,9 @@ export function CreationControls(props: CreationControlsProps) {
10381017
{selectedRuntime.mode === "devcontainer" && devcontainerSelection.uiMode !== "hidden" && (
10391018
<div className="border-border-medium flex w-fit flex-col gap-1.5 rounded-md border p-2">
10401019
<div className="flex flex-col gap-1">
1041-
<label className="text-muted-foreground text-xs">Config</label>
1020+
<label className="text-muted-foreground text-xs">
1021+
{RUNTIME_OPTION_FIELDS.devcontainer.label}
1022+
</label>
10421023
{devcontainerSelection.uiMode === "loading" ? (
10431024
// Skeleton placeholder while loading - matches dropdown dimensions
10441025
<Skeleton className="h-6 w-[280px] rounded-md" />
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useId } from "react";
2+
3+
import { cn } from "@/common/lib/utils";
4+
5+
/**
6+
* Shared runtime option input used by creation and settings screens.
7+
* Keeps labels/inputs visually and behaviorally aligned across both flows.
8+
*/
9+
export function RuntimeConfigInput(props: {
10+
label: string;
11+
value: string;
12+
onChange: (value: string) => void;
13+
placeholder: string;
14+
disabled?: boolean;
15+
hasError?: boolean;
16+
id?: string;
17+
ariaLabel?: string;
18+
className?: string;
19+
labelClassName?: string;
20+
inputClassName?: string;
21+
}) {
22+
const autoId = useId();
23+
const inputId = props.id ?? autoId;
24+
25+
return (
26+
<div className={cn("flex items-center gap-2", props.className)}>
27+
<label
28+
htmlFor={inputId}
29+
className={cn("text-muted-foreground text-xs", props.labelClassName)}
30+
>
31+
{props.label}
32+
</label>
33+
<input
34+
id={inputId}
35+
aria-label={props.ariaLabel ?? props.label}
36+
type="text"
37+
value={props.value}
38+
onChange={(e) => props.onChange(e.target.value)}
39+
placeholder={props.placeholder}
40+
disabled={props.disabled}
41+
className={cn(
42+
"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",
43+
props.inputClassName,
44+
props.hasError && "border-red-500"
45+
)}
46+
/>
47+
</div>
48+
);
49+
}

src/browser/components/Settings/sections/RuntimesSection.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
22
import { AlertTriangle, Loader2 } from "lucide-react";
33

44
import { resolveCoderAvailability } from "@/browser/components/ChatInput/CoderControls";
5+
import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput";
56
import {
67
Select,
78
SelectContent,
@@ -13,9 +14,16 @@ import { Switch } from "@/browser/components/ui/switch";
1314
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip";
1415
import { useAPI } from "@/browser/contexts/API";
1516
import { useProjectContext } from "@/browser/contexts/ProjectContext";
17+
import { useSettings } from "@/browser/contexts/SettingsContext";
18+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1619
import { useRuntimeEnablement } from "@/browser/hooks/useRuntimeEnablement";
17-
import { RUNTIME_CHOICE_UI, type RuntimeUiSpec } from "@/browser/utils/runtimeUi";
20+
import {
21+
RUNTIME_CHOICE_UI,
22+
getRuntimeOptionField,
23+
type RuntimeUiSpec,
24+
} from "@/browser/utils/runtimeUi";
1825
import { cn } from "@/common/lib/utils";
26+
import { getLastRuntimeConfigKey } from "@/common/constants/storage";
1927
import type { CoderInfo } from "@/common/orpc/schemas/coder";
2028
import { normalizeRuntimeEnablement, RUNTIME_MODE } from "@/common/types/runtime";
2129
import type {
@@ -66,10 +74,16 @@ export function RuntimesSection() {
6674
const { projects, refreshProjects } = useProjectContext();
6775
const { enablement, setRuntimeEnabled, defaultRuntime, setDefaultRuntime } =
6876
useRuntimeEnablement();
77+
const { runtimesProjectPath, setRuntimesProjectPath } = useSettings();
6978

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

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

99+
// When re-opened with a new project hint (e.g., clicking "set defaults" again for
100+
// a different project), sync the scope and clear the one-shot hint.
101+
// Only clear the hint once the project is actually found in the project list;
102+
// projects load asynchronously, so we must keep the hint alive until then.
103+
useEffect(() => {
104+
if (!runtimesProjectPath) return;
105+
if (!projects.has(runtimesProjectPath)) return;
106+
setSelectedScope(runtimesProjectPath);
107+
setRuntimesProjectPath(null);
108+
}, [runtimesProjectPath, projects, setRuntimesProjectPath]);
109+
85110
const selectedProjectPath = selectedScope === ALL_SCOPE_VALUE ? null : selectedScope;
86111
const isProjectScope = Boolean(selectedProjectPath);
87112
const isProjectOverrideActive = isProjectScope && projectOverrideEnabled;
88113

114+
// Per-project runtime option defaults (SSH host, Docker image, etc.).
115+
// Same localStorage keys the creation flow reads, so edits here are reflected immediately.
116+
const runtimeConfigKey = selectedProjectPath
117+
? getLastRuntimeConfigKey(selectedProjectPath)
118+
: "__no_project_defaults__";
119+
type RuntimeOptionConfigs = Partial<Record<string, Record<string, unknown>>>;
120+
const [runtimeOptionConfigs, setRuntimeOptionConfigs] = usePersistedState<RuntimeOptionConfigs>(
121+
runtimeConfigKey,
122+
{},
123+
{ listener: true }
124+
);
125+
126+
const readOptionField = (runtimeMode: string, field: string): string => {
127+
const modeConfig = runtimeOptionConfigs[runtimeMode];
128+
if (!modeConfig || typeof modeConfig !== "object") return "";
129+
const val = modeConfig[field];
130+
return typeof val === "string" ? val : "";
131+
};
132+
133+
const setOptionField = (runtimeMode: string, field: string, value: string) => {
134+
setRuntimeOptionConfigs((prev) => {
135+
const existing = prev[runtimeMode];
136+
const existingObj = existing && typeof existing === "object" ? existing : {};
137+
return { ...prev, [runtimeMode]: { ...existingObj, [field]: value } };
138+
});
139+
};
140+
89141
const syncProjects = () =>
90142
refreshProjects().catch(() => {
91143
// Best-effort only.
@@ -501,6 +553,8 @@ export function RuntimesSection() {
501553
const rowDisabled = isProjectScope && !projectOverrideEnabled;
502554
const isLastEnabled = effectiveEnablement[runtime.id] && enabledRuntimeCount <= 1;
503555
const switchDisabled = rowDisabled || isLastEnabled;
556+
const optionSpec = getRuntimeOptionField(runtime.id);
557+
const optionRuntimeMode = runtime.id === "coder" ? "ssh" : runtime.id;
504558
const switchControl = (
505559
<Switch
506560
checked={effectiveEnablement[runtime.id]}
@@ -550,6 +604,26 @@ export function RuntimesSection() {
550604
) : null}
551605
</div>
552606
<div className="text-muted text-xs">{runtime.description}</div>
607+
{/* Configurable option inputs — project scope uses the same labeled
608+
input component and localStorage defaults as the creation flow. */}
609+
{!optionSpec || !selectedProjectPath ? (
610+
runtime.options && !selectedProjectPath ? (
611+
<div className="text-muted/70 text-[11px]">Options: {runtime.options}</div>
612+
) : null
613+
) : (
614+
<RuntimeConfigInput
615+
label={optionSpec.label}
616+
value={readOptionField(optionRuntimeMode, optionSpec.field)}
617+
onChange={(value) =>
618+
setOptionField(optionRuntimeMode, optionSpec.field, value)
619+
}
620+
placeholder={optionSpec.placeholder}
621+
disabled={rowDisabled}
622+
className="mt-1.5"
623+
inputClassName="w-full max-w-[260px]"
624+
ariaLabel={`${optionSpec.label} for ${runtime.label}`}
625+
/>
626+
)}
553627
</div>
554628
</div>
555629
<div className="flex items-center gap-3">

0 commit comments

Comments
 (0)