From 08577006c67dbddaf4aad36d452d1986d6d56a67 Mon Sep 17 00:00:00 2001 From: Matt Vollmer Date: Tue, 24 Mar 2026 09:28:10 -0400 Subject: [PATCH] fix(site): improve Workspace Autostop Fallback UX on agents settings page (#23465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/a482ef45-402a-4d86-af59-b1526b2ce3e2 ## Summary Redesigns the **Default Autostop** section on the `/agents` settings page to clarify that it is a fallback for chat-linked workspaces whose templates do not define their own autostop policy. Template-level settings always take priority — this is a backstop, not an override. ## Changes ### UX - Renamed to **Workspace Autostop Fallback** with clearer description - Replaced always-visible duration field (confusing `0` in an hours box) with a **toggle-to-enable** pattern matching the Virtual Desktop section - Toggle ON auto-saves with a 1-hour default; toggle OFF auto-saves with 0 - Save button is always visible when the toggle is on but disabled until the user changes the duration value - Per-section disabled flags — toggling autostop no longer freezes the Virtual Desktop switch or prompt textareas during the save round-trip ### Reliability - `onError` rollback on toggle auto-saves so the UI snaps back to server truth on failure - Stateful mocks in Storybook stories to prevent race conditions from instant mock resolution ### Accessibility - Added `aria-label="Autostop duration"` to the DurationField input - Updated `DurationField` component to merge external `inputProps` with internal ones (preserves `step: 1`) ### Stories - Updated all existing autostop stories for the new toggle-based flow - Added `DefaultAutostopToggleOff` — tests disabling from an enabled state - Added `DefaultAutostopSaveDisabled` — verifies Save button is visible but disabled when no duration change --- PR generated with Coder Agents --- .../AgentSettingsPageView.stories.tsx | 252 ++++++++++++++++-- .../AgentsPage/AgentSettingsPageView.tsx | 138 +++++++--- 2 files changed, 324 insertions(+), 66 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx index 7261ac0fc4..80e008144a 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx @@ -210,20 +210,20 @@ export const DefaultAutostopDefault: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText("Default Autostop"); - // When disabled (0s), shows template-default copy. - await canvas.findByText(/stopped as configured by their templates/i); + await canvas.findByText("Workspace Autostop Fallback"); + // Description is always visible. + await canvas.findByText( + /set a default autostop for agent-created workspaces/i, + ); - // DurationField renders a text input labeled "Default autostop". - const durationInput = await canvas.findByLabelText("Default autostop"); + // Toggle should be OFF when TTL is 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).not.toBeChecked(); - // Default is "0s" → 0 hours (disabled). - expect(durationInput).toHaveValue("0"); - - // Save button should be disabled (no local change). - const ttlForm = durationInput.closest("form")!; - const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); - expect(saveButton).toBeDisabled(); + // Duration field should not be visible when disabled. + expect(canvas.queryByLabelText("Autostop Fallback")).toBeNull(); }, }; @@ -237,29 +237,54 @@ export const DefaultAutostopCustomValue: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const durationInput = await canvas.findByLabelText("Default autostop"); + // Toggle should be ON when TTL > 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); - // Shows 2 hours from the mock. + // Duration field should be visible with 2 hours. + const durationInput = await canvas.findByLabelText("Autostop Fallback"); expect(durationInput).toHaveValue("2"); - - // When non-zero, shows the duration in the description. - await canvas.findByText(/stopped after 2 hours of inactivity/i); }, }; export const DefaultAutostopSave: Story = { + beforeEach: () => { + let currentTTL = 0; + spyOn(API.experimental, "getChatWorkspaceTTL").mockImplementation( + async () => ({ workspace_ttl_ms: currentTTL }), + ); + spyOn(API.experimental, "updateChatWorkspaceTTL").mockImplementation( + async (req) => { + currentTTL = req.workspace_ttl_ms; + }, + ); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const durationInput = await canvas.findByLabelText("Default autostop"); - const ttlForm = durationInput.closest("form")!; - const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); + // Toggle ON — should auto-save with 1-hour default. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + await userEvent.click(toggle); - // Change to 3 hours. + await waitFor(() => { + expect(API.experimental.updateChatWorkspaceTTL).toHaveBeenCalledWith({ + workspace_ttl_ms: 3_600_000, + }); + }); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("1"); + + // Change to 3 hours — Save button should appear. await userEvent.clear(durationInput); await userEvent.type(durationInput, "3"); - // Save button should now be enabled. + const ttlForm = durationInput.closest("form")!; + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); await waitFor(() => { expect(saveButton).toBeEnabled(); }); @@ -270,16 +295,39 @@ export const DefaultAutostopSave: Story = { workspace_ttl_ms: 10_800_000, }); }); + + // Verify the isTTLZero guard: clearing to 0 should disable Save + // because the toggle is still ON. + await userEvent.clear(durationInput); + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); }, }; export const DefaultAutostopExceedsMax: Story = { + beforeEach: () => { + let currentTTL = 0; + spyOn(API.experimental, "getChatWorkspaceTTL").mockImplementation( + async () => ({ workspace_ttl_ms: currentTTL }), + ); + spyOn(API.experimental, "updateChatWorkspaceTTL").mockImplementation( + async (req) => { + currentTTL = req.workspace_ttl_ms; + }, + ); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const durationInput = await canvas.findByLabelText("Default autostop"); + // Toggle ON to reveal the duration field. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + await userEvent.click(toggle); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); const ttlForm = durationInput.closest("form")!; - const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); // Enter 721 hours (exceeds 30-day / 720h limit). await userEvent.clear(durationInput); @@ -291,10 +339,164 @@ export const DefaultAutostopExceedsMax: Story = { }); // Save button should be disabled despite the field being dirty. + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); }, }; +export const DefaultAutostopToggleOff: Story = { + beforeEach: () => { + let currentTTL = 7_200_000; + spyOn(API.experimental, "getChatWorkspaceTTL").mockImplementation( + async () => ({ workspace_ttl_ms: currentTTL }), + ); + spyOn(API.experimental, "updateChatWorkspaceTTL").mockImplementation( + async (req) => { + currentTTL = req.workspace_ttl_ms; + }, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Toggle should start ON since TTL > 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + // Click toggle OFF. + await userEvent.click(toggle); + + await waitFor(() => { + expect(API.experimental.updateChatWorkspaceTTL).toHaveBeenCalledWith({ + workspace_ttl_ms: 0, + }); + }); + + // Duration field should no longer be visible. + await waitFor(() => { + expect(canvas.queryByLabelText("Autostop Fallback")).toBeNull(); + }); + }, +}; + +export const DefaultAutostopSaveDisabled: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatWorkspaceTTL").mockResolvedValue({ + workspace_ttl_ms: 7_200_000, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Toggle should be ON since TTL > 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + // Duration field should show 2 hours. + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("2"); + + // Save button should exist but be disabled (no changes made). + const ttlForm = durationInput.closest("form")!; + const saveButton = within(ttlForm).getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); + }, +}; + +export const DefaultAutostopToggleFailure: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatWorkspaceTTL").mockResolvedValue({ + workspace_ttl_ms: 0, + }); + spyOn(API.experimental, "updateChatWorkspaceTTL").mockRejectedValue( + new Error("Server error"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Toggle starts OFF since TTL is 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).not.toBeChecked(); + + // Click toggle ON. + await userEvent.click(toggle); + + // Verify the mutation was called with the 1-hour default. + await waitFor(() => { + expect(API.experimental.updateChatWorkspaceTTL).toHaveBeenCalledWith({ + workspace_ttl_ms: 3_600_000, + }); + }); + + // The onError handler resets state, reverting the toggle. + await waitFor(() => { + expect(toggle).not.toBeChecked(); + }); + + // Error message should be visible. + expect( + canvas.getByText("Failed to save autostop setting."), + ).toBeInTheDocument(); + + // DurationField should not be visible since toggle reverted to OFF. + expect(canvas.queryByLabelText("Autostop Fallback")).toBeNull(); + }, +}; + +export const DefaultAutostopToggleOffFailure: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatWorkspaceTTL").mockResolvedValue({ + workspace_ttl_ms: 7_200_000, + }); + spyOn(API.experimental, "updateChatWorkspaceTTL").mockRejectedValue( + new Error("Server error"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Toggle starts ON since TTL > 0. + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + // Duration should show 2 hours initially. + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("2"); + + // Click toggle OFF. + await userEvent.click(toggle); + + // Verify the mutation was called with 0 to disable. + await waitFor(() => { + expect(API.experimental.updateChatWorkspaceTTL).toHaveBeenCalledWith({ + workspace_ttl_ms: 0, + }); + }); + + // The onError handler resets state, reverting the toggle to ON. + await waitFor(() => { + expect(toggle).toBeChecked(); + }); + + // Error message should be visible. + expect( + canvas.getByText("Failed to save autostop setting."), + ).toBeInTheDocument(); + + // DurationField should still be visible with 2 hours. + expect(canvas.getByLabelText("Autostop Fallback")).toHaveValue("2"); + }, +}; + export const DefaultAutostopNotVisibleToNonAdmin: Story = { args: { canSetSystemPrompt: false, @@ -306,7 +508,7 @@ export const DefaultAutostopNotVisibleToNonAdmin: Story = { await canvas.findByText("Personal Instructions"); // Admin-only sections should not be present. - const ttlHeading = canvas.queryByText("Default Autostop"); + const ttlHeading = canvas.queryByText("Workspace Autostop Fallback"); expect(ttlHeading).toBeNull(); const desktopHeading = canvas.queryByText("Virtual Desktop"); diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx index aedb089698..fe493b6467 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx @@ -52,7 +52,6 @@ import { useSearchParams } from "react-router"; import TextareaAutosize from "react-textarea-autosize"; import { formatTokenCount } from "utils/analytics"; import { formatCostMicros } from "utils/currency"; -import { humanDuration } from "utils/time"; import { DateRange, type DateRangeValue, @@ -552,15 +551,16 @@ export const AgentSettingsPageView: FC = ({ const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false; const serverTTLMs = workspaceTTLQuery.data?.workspace_ttl_ms ?? 0; const [localTTLMs, setLocalTTLMs] = useState(null); + const [autostopToggled, setAutostopToggled] = useState(null); const ttlMs = localTTLMs ?? serverTTLMs; + const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0; const isTTLDirty = localTTLMs !== null && localTTLMs !== serverTTLMs; const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days const isTTLOverMax = ttlMs > maxTTLMs; - const isDisabled = - isSavingSystemPrompt || - isSavingUserPrompt || - isSavingDesktopEnabled || - isSavingWorkspaceTTL; + const isTTLZero = isAutostopEnabled && ttlMs === 0; + const isPromptSaving = isSavingSystemPrompt || isSavingUserPrompt; + const isDesktopSaving = isSavingDesktopEnabled; + const isTTLSaving = isSavingWorkspaceTTL; const isTTLLoading = workspaceTTLQuery.isLoading; const handleSaveSystemPrompt = (event: FormEvent) => { @@ -581,12 +581,41 @@ export const AgentSettingsPageView: FC = ({ ); }; + const resetAutostopState = () => { + setLocalTTLMs(null); + setAutostopToggled(null); + }; + + const handleToggleAutostop = (checked: boolean) => { + if (checked) { + // Defensive: restore server value if query cache is + // stale; otherwise default to 1 hour. + const defaultTTL = serverTTLMs > 0 ? serverTTLMs : 3_600_000; + setAutostopToggled(true); + setLocalTTLMs(defaultTTL); + saveWorkspaceTTL( + { workspace_ttl_ms: defaultTTL }, + { onSuccess: resetAutostopState, onError: resetAutostopState }, + ); + } else { + setAutostopToggled(false); + setLocalTTLMs(0); + saveWorkspaceTTL( + { workspace_ttl_ms: 0 }, + { onSuccess: resetAutostopState, onError: resetAutostopState }, + ); + } + }; + const handleSaveChatWorkspaceTTL = (event: FormEvent) => { event.preventDefault(); - if (!isTTLDirty) return; + if (!isTTLDirty || isTTLSaving) return; saveWorkspaceTTL( { workspace_ttl_ms: localTTLMs ?? 0 }, - { onSuccess: () => setLocalTTLMs(null) }, + { + onSuccess: resetAutostopState, + onError: () => setAutostopToggled(null), + }, ); }; return ( @@ -614,7 +643,7 @@ export const AgentSettingsPageView: FC = ({ placeholder="Additional behavior, style, and tone preferences" value={userPromptDraft} onChange={(event) => setLocalUserEdit(event.target.value)} - disabled={isDisabled} + disabled={isPromptSaving} minRows={1} />
@@ -623,14 +652,14 @@ export const AgentSettingsPageView: FC = ({ variant="outline" type="button" onClick={() => setLocalUserEdit("")} - disabled={isDisabled || !userPromptDraft} + disabled={isPromptSaving || !userPromptDraft} > Clear @@ -672,7 +701,7 @@ export const AgentSettingsPageView: FC = ({ placeholder="Additional behavior, style, and tone preferences for all users" value={systemPromptDraft} onChange={(event) => setLocalEdit(event.target.value)} - disabled={isDisabled} + disabled={isPromptSaving} minRows={1} />
@@ -681,14 +710,14 @@ export const AgentSettingsPageView: FC = ({ variant="outline" type="button" onClick={() => setLocalEdit("")} - disabled={isDisabled || !systemPromptDraft} + disabled={isPromptSaving || !systemPromptDraft} > Clear @@ -733,7 +762,7 @@ export const AgentSettingsPageView: FC = ({ saveDesktopEnabled({ enable_desktop: checked }) } aria-label="Enable" - disabled={isDisabled} + disabled={isDesktopSaving} />
{isSaveDesktopEnabledError && ( @@ -749,36 +778,63 @@ export const AgentSettingsPageView: FC = ({ >

- Default Autostop + Workspace Autostop Fallback

-

- {ttlMs === 0 - ? "Workspaces linked to chats will be stopped as configured by their templates. Active chats continuously extend the deadline." - : `Workspaces linked to chats will be stopped after ${humanDuration(ttlMs)} of inactivity. Active chats continuously extend the deadline.`} -

- setLocalTTLMs(v)} - disabled={isDisabled || isTTLLoading} - error={isTTLOverMax} - helperText={ - isTTLOverMax - ? "Must not exceed 30 days (720 hours)." - : undefined - } - /> -
- +
+

+ Set a default autostop for agent-created workspaces that + don't have one defined in their template. Template-defined + autostop rules always take precedence. Active chats will + extend the stop time. +

+ {" "}
+ {isAutostopEnabled && ( + { + setLocalTTLMs(v); + // Latch the toggle open while the user is editing + // so a background refetch cannot unmount the field. + if (autostopToggled === null) { + setAutostopToggled(true); + } + }} + label="Autostop Fallback" + disabled={isTTLSaving || isTTLLoading} + error={isTTLOverMax || isTTLZero} + helperText={ + isTTLZero + ? "Duration must be greater than zero." + : isTTLOverMax + ? "Must not exceed 30 days (720 hours)." + : undefined + } + /> + )} + {isAutostopEnabled && ( +
+ +
+ )} {isSaveWorkspaceTTLError && (

Failed to save autostop setting.