Skip to content
Open
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
67 changes: 36 additions & 31 deletions bun.lock

Large diffs are not rendered by default.

126 changes: 81 additions & 45 deletions src/browser/features/Settings/Sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import {
isFontFamilyAvailableInBrowser,
isGenericFontFamily,
} from "@/browser/terminal/terminalFontFamily";
import {
CODER_ARCHIVE_BEHAVIORS,
DEFAULT_CODER_ARCHIVE_BEHAVIOR,
type CoderWorkspaceArchiveBehavior,
} from "@/common/config/coderArchiveBehavior";

// Guard against corrupted/old persisted settings (e.g. from a downgraded build).
const ALLOWED_EDITOR_TYPES: ReadonlySet<EditorType> = new Set([
Expand Down Expand Up @@ -130,6 +135,18 @@ const LAUNCH_BEHAVIOR_OPTIONS = [
{ value: "new-chat", label: "New chat on recent project" },
{ value: "last-workspace", label: "Last visited workspace" },
] as const;
const ARCHIVE_BEHAVIOR_OPTIONS = [
{ value: "keep", label: "Keep running" },
{ value: "stop", label: "Stop workspace" },
{ value: "delete", label: "Delete workspace" },
] as const;

function isCoderWorkspaceArchiveBehavior(value: unknown): value is CoderWorkspaceArchiveBehavior {
return (
typeof value === "string" &&
CODER_ARCHIVE_BEHAVIORS.includes(value as CoderWorkspaceArchiveBehavior)
);
}

// Browser mode: window.api is not set (only exists in Electron via preload)
const isBrowserMode = typeof window !== "undefined" && !window.api;
Expand Down Expand Up @@ -171,33 +188,41 @@ export function GeneralSection() {
// (which would clear the config) when the initial fetch failed.
const [cloneDirLoadedOk, setCloneDirLoadedOk] = useState(false);

// Backend config: default to ON so archiving is safest even before async load completes.
const [stopCoderWorkspaceOnArchive, setStopCoderWorkspaceOnArchive] = useState(true);
// Backend config: default to stop so archiving is safest even before async load completes.
const [archiveBehavior, setArchiveBehavior] = useState<CoderWorkspaceArchiveBehavior>(
DEFAULT_CODER_ARCHIVE_BEHAVIOR
);
const [llmDebugLogs, setLlmDebugLogs] = useState(false);
const stopCoderWorkspaceOnArchiveLoadNonceRef = useRef(0);
const archiveBehaviorLoadNonceRef = useRef(0);

const llmDebugLogsLoadNonceRef = useRef(0);

// updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid
// toggles can't race and persist a stale value via out-of-order writes.
const stopCoderWorkspaceOnArchiveUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
// selections can't race and persist a stale value via out-of-order writes.
const archiveBehaviorUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const stopCoderWorkspaceOnArchivePendingUpdateRef = useRef<boolean | undefined>(undefined);
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
undefined
);

useEffect(() => {
if (!api) {
return;
}

const stopCoderWorkspaceOnArchiveNonce = ++stopCoderWorkspaceOnArchiveLoadNonceRef.current;
const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current;
const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current;

void api.config
.getConfig()
.then((cfg) => {
// If the user toggled the setting while this request was in flight, keep the UI selection.
if (stopCoderWorkspaceOnArchiveNonce === stopCoderWorkspaceOnArchiveLoadNonceRef.current) {
setStopCoderWorkspaceOnArchive(cfg.stopCoderWorkspaceOnArchive);
// If the user changed the setting while this request was in flight, keep the UI selection.
if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) {
setArchiveBehavior(
isCoderWorkspaceArchiveBehavior(cfg.coderWorkspaceArchiveBehavior)
? cfg.coderWorkspaceArchiveBehavior
: DEFAULT_CODER_ARCHIVE_BEHAVIOR
);
}

// Use an independent nonce so debug-log toggles do not discard archive-setting updates.
Expand All @@ -206,46 +231,45 @@ export function GeneralSection() {
}
})
.catch(() => {
// Best-effort only. Keep the default (ON) if config fails to load.
// Best-effort only. Keep the default (stop) if config fails to load.
});
}, [api]);

const handleStopCoderWorkspaceOnArchiveChange = useCallback(
(checked: boolean) => {
const handleArchiveBehaviorChange = useCallback(
(behavior: CoderWorkspaceArchiveBehavior) => {
// Invalidate any in-flight initial load so it doesn't overwrite the user's selection.
stopCoderWorkspaceOnArchiveLoadNonceRef.current++;
setStopCoderWorkspaceOnArchive(checked);
archiveBehaviorLoadNonceRef.current++;
setArchiveBehavior(behavior);

if (!api?.config?.updateCoderPrefs) {
return;
}

stopCoderWorkspaceOnArchivePendingUpdateRef.current = checked;

stopCoderWorkspaceOnArchiveUpdateChainRef.current =
stopCoderWorkspaceOnArchiveUpdateChainRef.current
.then(async () => {
// Drain the pending ref so a toggle that happens while updateCoderPrefs is in-flight
// doesn't get stranded without a subsequent write scheduled.
for (;;) {
const pending = stopCoderWorkspaceOnArchivePendingUpdateRef.current;
if (pending === undefined) {
return;
}
archiveBehaviorPendingUpdateRef.current = behavior;

// Clear before awaiting so rapid toggles coalesce into a new pending value.
stopCoderWorkspaceOnArchivePendingUpdateRef.current = undefined;
archiveBehaviorUpdateChainRef.current = archiveBehaviorUpdateChainRef.current
.then(async () => {
// Drain the pending ref so a change that happens while updateCoderPrefs is in-flight
// doesn't get stranded without a subsequent write scheduled.
for (;;) {
const pending = archiveBehaviorPendingUpdateRef.current;
if (pending === undefined) {
return;
}

try {
await api.config.updateCoderPrefs({ stopCoderWorkspaceOnArchive: pending });
} catch {
// Best-effort only. Swallow errors so the queue doesn't get stuck.
}
// Clear before awaiting so rapid changes coalesce into a new pending value.
archiveBehaviorPendingUpdateRef.current = undefined;

try {
await api.config.updateCoderPrefs({ coderWorkspaceArchiveBehavior: pending });
} catch {
// Best-effort only. Swallow errors so the queue doesn't get stuck.
}
})
.catch(() => {
// Best-effort only.
});
}
})
.catch(() => {
// Best-effort only.
});
},
[api]
);
Expand Down Expand Up @@ -518,17 +542,29 @@ export function GeneralSection() {

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Stop Coder workspace when archiving</div>
<div className="text-foreground text-sm">Coder workspace on archive</div>
<div className="text-muted text-xs">
When enabled, archiving a Mux workspace will stop its dedicated Coder workspace first.
Action to take on dedicated Coder workspaces when archiving a chat. Delete is permanent.
</div>
</div>
<Switch
checked={stopCoderWorkspaceOnArchive}
onCheckedChange={handleStopCoderWorkspaceOnArchiveChange}
<Select
value={archiveBehavior}
onValueChange={(value) =>
handleArchiveBehaviorChange(value as CoderWorkspaceArchiveBehavior)
}
disabled={!api?.config?.updateCoderPrefs}
aria-label="Toggle stopping the dedicated Coder workspace when archiving a Mux workspace"
/>
>
<SelectTrigger className="border-border-medium bg-background-secondary hover:bg-hover h-9 w-auto cursor-pointer rounded-md border px-3 text-sm transition-colors">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARCHIVE_BEHAVIOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{isBrowserMode && sshHostLoaded && (
Expand Down
15 changes: 9 additions & 6 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import type {
CoderTemplate,
CoderWorkspace,
} from "@/common/orpc/schemas/coder";
import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior";
import type { z } from "zod";
import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors";
import { isWorkspaceArchived } from "@/common/utils/archive";
Expand Down Expand Up @@ -124,7 +125,7 @@ export interface MockORPCClientOptions {
/** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */
subagentAiDefaults?: SubagentAiDefaults;
/** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */
stopCoderWorkspaceOnArchive?: boolean;
coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior;
/** Initial runtime enablement for config.getConfig */
runtimeEnablement?: Record<string, boolean>;
/** Initial default runtime for config.getConfig (global) */
Expand Down Expand Up @@ -341,7 +342,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
taskSettings: initialTaskSettings,
subagentAiDefaults: initialSubagentAiDefaults,
agentAiDefaults: initialAgentAiDefaults,
stopCoderWorkspaceOnArchive: initialStopCoderWorkspaceOnArchive = true,
coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop",
runtimeEnablement: initialRuntimeEnablement,
defaultRuntime: initialDefaultRuntime,
onePasswordAccountName: initialOnePasswordAccountName = null,
Expand Down Expand Up @@ -543,7 +544,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl

let muxGatewayEnabled: boolean | undefined = undefined;
let muxGatewayModels: string[] | undefined = undefined;
let stopCoderWorkspaceOnArchive = initialStopCoderWorkspaceOnArchive;
let coderWorkspaceArchiveBehavior = initialCoderWorkspaceArchiveBehavior;
let runtimeEnablement: Record<string, boolean> = initialRuntimeEnablement ?? {
local: true,
worktree: true,
Expand Down Expand Up @@ -737,7 +738,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
muxGatewayModels,
routePriority,
routeOverrides,
stopCoderWorkspaceOnArchive,
coderWorkspaceArchiveBehavior,
runtimeEnablement,
defaultRuntime,
agentAiDefaults,
Expand Down Expand Up @@ -814,8 +815,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
notifyConfigChanged();
return Promise.resolve(undefined);
},
updateCoderPrefs: (input: { stopCoderWorkspaceOnArchive: boolean }) => {
stopCoderWorkspaceOnArchive = input.stopCoderWorkspaceOnArchive;
updateCoderPrefs: (input: {
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
}) => {
coderWorkspaceArchiveBehavior = input.coderWorkspaceArchiveBehavior;
notifyConfigChanged();
return Promise.resolve(undefined);
},
Expand Down
5 changes: 5 additions & 0 deletions src/common/config/coderArchiveBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const CODER_ARCHIVE_BEHAVIORS = ["keep", "stop", "delete"] as const;

export type CoderWorkspaceArchiveBehavior = (typeof CODER_ARCHIVE_BEHAVIORS)[number];

export const DEFAULT_CODER_ARCHIVE_BEHAVIOR: CoderWorkspaceArchiveBehavior = "stop";
2 changes: 2 additions & 0 deletions src/common/config/schemas/appConfigOnDisk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AgentIdSchema, RuntimeEnablementIdSchema } from "../../schemas/ids";
import { ProjectConfigSchema } from "../../schemas/project";
import { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablement";
import { ThinkingLevelSchema } from "../../types/thinking";
import { CODER_ARCHIVE_BEHAVIORS } from "../coderArchiveBehavior";
import { TaskSettingsSchema } from "./taskSettings";

export { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablement";
Expand Down Expand Up @@ -58,6 +59,7 @@ export const AppConfigOnDiskSchema = z
useSSH2Transport: z.boolean().optional(),
muxGovernorUrl: z.string().optional(),
muxGovernorToken: z.string().optional(),
coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS).optional(),
stopCoderWorkspaceOnArchive: z.boolean().optional(),
terminalDefaultShell: z.string().optional(),
updateChannel: UpdateChannelSchema.optional(),
Expand Down
5 changes: 3 additions & 2 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { eventIterator } from "@orpc/server";
import { UIModeSchema } from "../../types/mode";
import { z } from "zod";
import { CODER_ARCHIVE_BEHAVIORS } from "@/common/config/coderArchiveBehavior";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import { ChatStatsSchema, SessionUsageFileSchema } from "./chatStats";
import {
Expand Down Expand Up @@ -1696,7 +1697,7 @@ export const config = {
routeOverrides: z.record(z.string(), z.string()).optional(),
defaultModel: z.string().optional(),
hiddenModels: z.array(z.string()).optional(),
stopCoderWorkspaceOnArchive: z.boolean(),
coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS),
runtimeEnablement: z.record(z.string(), z.boolean()),
defaultRuntime: z.string().nullable(),
agentAiDefaults: AgentAiDefaultsSchema,
Expand Down Expand Up @@ -1752,7 +1753,7 @@ export const config = {
updateCoderPrefs: {
input: z
.object({
stopCoderWorkspaceOnArchive: z.boolean(),
coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS),
})
.strict(),
output: z.void(),
Expand Down
10 changes: 8 additions & 2 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Kept lightweight for preload script usage.
*/

import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior";
import type { FeatureFlagOverride, UpdateChannel } from "@/common/config/schemas/appConfigOnDisk";
import type { z } from "zod";
import type {
Expand Down Expand Up @@ -112,8 +113,13 @@ export interface ProjectsConfig {
muxGovernorToken?: string;

/**
* When true (default), archiving a Mux workspace will stop its dedicated mux-created Coder
* workspace first, and unarchiving will attempt to start it again.
* What to do with a dedicated mux-created Coder workspace when its chat is archived.
* Defaults to `"stop"` to preserve existing behavior.
*/
coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior;

/**
* Legacy boolean shim for downgrade compatibility.
*
* Stored as `false` only (undefined behaves as true) to keep config.json minimal.
*/
Expand Down
Loading
Loading