diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 3ad1874664..c4f6c30dd0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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. diff --git a/rfc/260223_workspace-default-runtime.md b/rfc/260223_workspace-default-runtime.md new file mode 100644 index 0000000000..89a0b288f8 --- /dev/null +++ b/rfc/260223_workspace-default-runtime.md @@ -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 \ No newline at end of file diff --git a/rfc/README.md b/rfc/README.md new file mode 100644 index 0000000000..4f1e8814db --- /dev/null +++ b/rfc/README.md @@ -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 `_.md` in this folder. + +The file should be structured as follows: + +```markdown +--- +author: @<github-username> +date: <YYYY-MM-DD> +--- + +# <Title> + +... body ... +``` \ No newline at end of file diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 6f95b6d6a6..e0f3d84918 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -25,6 +25,7 @@ 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"; @@ -32,6 +33,7 @@ import { Skeleton } from "../ui/skeleton"; import { DocsLink } from "../DocsLink"; import { RUNTIME_CHOICE_UI, + RUNTIME_OPTION_FIELDS, type RuntimeChoice, type RuntimeIconProps, } from "@/browser/utils/runtimeUi"; @@ -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; @@ -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"> @@ -998,12 +974,14 @@ 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} /> )} @@ -1011,7 +989,7 @@ export function CreationControls(props: CreationControlsProps) { {selectedRuntime.mode === "docker" && ( <RuntimeConfigInput - label="image" + label={RUNTIME_OPTION_FIELDS.docker.label} value={selectedRuntime.image} onChange={(value) => onSelectedRuntimeChange({ @@ -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> @@ -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" /> diff --git a/src/browser/components/RuntimeConfigInput.tsx b/src/browser/components/RuntimeConfigInput.tsx new file mode 100644 index 0000000000..101c30eb31 --- /dev/null +++ b/src/browser/components/RuntimeConfigInput.tsx @@ -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> + ); +} diff --git a/src/browser/components/Settings/sections/RuntimesSection.tsx b/src/browser/components/Settings/sections/RuntimesSection.tsx index c464273c5d..a43fd048e4 100644 --- a/src/browser/components/Settings/sections/RuntimesSection.tsx +++ b/src/browser/components/Settings/sections/RuntimesSection.tsx @@ -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, @@ -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 { @@ -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>( @@ -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. @@ -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]} @@ -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"> diff --git a/src/browser/contexts/SettingsContext.tsx b/src/browser/contexts/SettingsContext.tsx index c00e5241b9..e74b4adb32 100644 --- a/src/browser/contexts/SettingsContext.tsx +++ b/src/browser/contexts/SettingsContext.tsx @@ -13,6 +13,8 @@ import { useRouter } from "@/browser/contexts/RouterContext"; interface OpenSettingsOptions { /** When opening the Providers settings, expand the given provider. */ expandProvider?: string; + /** When opening the Runtimes settings, pre-select this project scope. */ + runtimesProjectPath?: string; } interface SettingsContextValue { @@ -28,6 +30,10 @@ interface SettingsContextValue { /** One-shot hint for ProvidersSection to expand a provider. */ providersExpandedProvider: string | null; setProvidersExpandedProvider: (provider: string | null) => void; + + /** One-shot hint for RuntimesSection to pre-select a project scope. */ + runtimesProjectPath: string | null; + setRuntimesProjectPath: (path: string | null) => void; } const SettingsContext = createContext<SettingsContextValue | null>(null); @@ -43,6 +49,7 @@ const DEFAULT_SECTION = "general"; export function SettingsProvider(props: { children: ReactNode }) { const router = useRouter(); const [providersExpandedProvider, setProvidersExpandedProvider] = useState<string | null>(null); + const [runtimesProjectPath, setRuntimesProjectPath] = useState<string | null>(null); const closeCallbacksRef = useRef(new Set<() => void>()); @@ -57,6 +64,11 @@ export function SettingsProvider(props: { children: ReactNode }) { } else { setProvidersExpandedProvider(null); } + if (nextSection === "runtimes") { + setRuntimesProjectPath(options?.runtimesProjectPath ?? null); + } else { + setRuntimesProjectPath(null); + } router.navigateToSettings(nextSection); }, [router] @@ -74,6 +86,8 @@ export function SettingsProvider(props: { children: ReactNode }) { const wasOpenRef = useRef(isOpen); useEffect(() => { if (wasOpenRef.current && !isOpen) { + setProvidersExpandedProvider(null); + setRuntimesProjectPath(null); for (const callback of closeCallbacksRef.current) { callback(); } @@ -83,6 +97,7 @@ export function SettingsProvider(props: { children: ReactNode }) { const close = useCallback(() => { setProvidersExpandedProvider(null); + setRuntimesProjectPath(null); router.navigateFromSettings(); }, [router]); @@ -91,6 +106,10 @@ export function SettingsProvider(props: { children: ReactNode }) { if (section !== "providers") { setProvidersExpandedProvider(null); } + if (section !== "runtimes") { + // Runtime scope hints are one-shot and should not persist across section changes. + setRuntimesProjectPath(null); + } router.navigateToSettings(section); }, [router] @@ -106,6 +125,8 @@ export function SettingsProvider(props: { children: ReactNode }) { registerOnClose, providersExpandedProvider, setProvidersExpandedProvider, + runtimesProjectPath, + setRuntimesProjectPath, }), [ isOpen, @@ -115,6 +136,7 @@ export function SettingsProvider(props: { children: ReactNode }) { setActiveSection, registerOnClose, providersExpandedProvider, + runtimesProjectPath, ] ); diff --git a/src/browser/utils/runtimeUi.ts b/src/browser/utils/runtimeUi.ts index a8ab1dd511..0192b343cb 100644 --- a/src/browser/utils/runtimeUi.ts +++ b/src/browser/utils/runtimeUi.ts @@ -1,5 +1,5 @@ import type { ComponentType } from "react"; -import type { RuntimeMode } from "@/common/types/runtime"; +import type { RuntimeEnablementId, RuntimeMode } from "@/common/types/runtime"; import { SSHIcon, WorktreeIcon, @@ -14,9 +14,54 @@ export interface RuntimeIconProps { className?: string; } +export interface RuntimeOptionFieldSpec { + readonly field: string; + readonly label: string; + readonly placeholder: string; + readonly summary: string; +} + +export const RUNTIME_OPTION_FIELDS = { + ssh: { + field: "host", + label: "Host", + placeholder: "user@host", + summary: "Host (user@host)", + }, + docker: { + field: "image", + label: "Image", + placeholder: "node:20", + summary: "Image name (e.g. node:20)", + }, + devcontainer: { + field: "configPath", + label: "Config", + placeholder: ".devcontainer/devcontainer.json", + summary: "Config path (devcontainer.json)", + }, +} as const satisfies Partial<Record<RuntimeEnablementId, RuntimeOptionFieldSpec>>; + +export function getRuntimeOptionField( + runtimeId: RuntimeEnablementId +): RuntimeOptionFieldSpec | null { + switch (runtimeId) { + case "ssh": + return RUNTIME_OPTION_FIELDS.ssh; + case "docker": + return RUNTIME_OPTION_FIELDS.docker; + case "devcontainer": + return RUNTIME_OPTION_FIELDS.devcontainer; + default: + return null; + } +} + export interface RuntimeUiSpec { label: string; description: string; + /** What user-provided options this runtime requires at creation time. */ + options?: string; docsPath: string; Icon: ComponentType<RuntimeIconProps>; button: { @@ -86,6 +131,7 @@ export const RUNTIME_UI = { ssh: { label: "SSH", description: "Remote clone on SSH host", + options: RUNTIME_OPTION_FIELDS.ssh.summary, docsPath: "/runtime/ssh", Icon: SSHIcon, button: { @@ -109,6 +155,7 @@ export const RUNTIME_UI = { docker: { label: "Docker", description: "Isolated container per workspace", + options: RUNTIME_OPTION_FIELDS.docker.summary, docsPath: "/runtime/docker", Icon: DockerIcon, button: { @@ -132,6 +179,7 @@ export const RUNTIME_UI = { devcontainer: { label: "Dev container", description: "Uses project's devcontainer.json configuration", + options: RUNTIME_OPTION_FIELDS.devcontainer.summary, docsPath: "/runtime/devcontainer", Icon: DevcontainerIcon, button: { @@ -158,6 +206,7 @@ const CODER_RUNTIME_UI: RuntimeUiSpec = { ...RUNTIME_UI.ssh, label: "Coder", description: "Coder workspace via the Coder CLI", + options: "Coder workspace template", docsPath: "/runtime/coder", Icon: CoderIcon, };