From 6459c364362157d618fcca08a80cf13d0a12f2d8 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 29 Jul 2026 03:01:15 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20prompt=20to=20r?= =?UTF-8?q?estart=20when=20enabling=20workspace=20autostop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule page gated the restart-to-apply dialog on the workspace's pre-submit autostop state, so enabling autostop on a running workspace never prompted and the new deadline only applied on the next start. Key the prompt off the submitted form value instead via a small pure helper, shouldConfirmAutostopRestart, so enabling or changing autostop while running prompts, while disabling (which clears the running build's deadline server-side) does not. This also restores the original intent of #16085, which meant to skip the prompt when removing autostop but keyed off the old state. --- .../WorkspaceSchedulePage.test.tsx | 59 +++++++++++++++++++ .../WorkspaceSchedulePage.tsx | 9 ++- .../WorkspaceSchedulePage/restart.ts | 25 ++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 3d604b4f501..bb6a607dbba 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -2,6 +2,7 @@ import { formValuesToAutostartRequest, formValuesToTTLRequest, } from "./formToRequest"; +import { shouldConfirmAutostopRestart } from "./restart"; import { scheduleToAutostart } from "./schedule"; import { ttlMsToAutostop } from "./ttl"; import type { WorkspaceScheduleFormValues } from "./WorkspaceScheduleForm"; @@ -235,4 +236,62 @@ describe("WorkspaceSchedulePage", () => { expect(ttlMsToAutostop(ttlMs)).toEqual(autostop); }); }); + + describe("shouldConfirmAutostopRestart", () => { + it.each([ + // Enabling autostop on a running workspace should prompt, since the + // deadline is only applied on the next start without a restart. + [ + "enable while running", + { + autostopChanged: true, + autostopEnabled: true, + workspaceStatus: "running", + }, + true, + ], + // Changing the value while autostop stays enabled should prompt too. + [ + "modify while running", + { + autostopChanged: true, + autostopEnabled: true, + workspaceStatus: "running", + }, + true, + ], + // Disabling autostop clears the deadline server-side, so no prompt. + [ + "disable while running", + { + autostopChanged: true, + autostopEnabled: false, + workspaceStatus: "running", + }, + false, + ], + // A stopped workspace has no running build to apply a deadline to. + [ + "enable while stopped", + { + autostopChanged: true, + autostopEnabled: true, + workspaceStatus: "stopped", + }, + false, + ], + // No autostop change means nothing to apply. + [ + "unchanged while running", + { + autostopChanged: false, + autostopEnabled: true, + workspaceStatus: "running", + }, + false, + ], + ] as const)("shouldConfirmAutostopRestart(%s) returns %p", (_name, options, expected) => { + expect(shouldConfirmAutostopRestart(options)).toBe(expected); + }); + }); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 5ef83950494..909ca8d205f 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -18,6 +18,7 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { shouldConfirmAutostopRestart } from "#/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart"; import { scheduleChanged, scheduleToAutostart, @@ -141,9 +142,11 @@ const WorkspaceSchedulePage: FC = () => { await submitScheduleMutation.mutateAsync(data); if ( - data.autostopChanged && - getAutostop(workspace).autostopEnabled && - workspace.latest_build.status === "running" + shouldConfirmAutostopRestart({ + autostopChanged: data.autostopChanged, + autostopEnabled: values.autostopEnabled, + workspaceStatus: workspace.latest_build.status, + }) ) { setIsConfirmingApply(true); } diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts new file mode 100644 index 00000000000..11cfe40d3fd --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts @@ -0,0 +1,25 @@ +import type { WorkspaceStatus } from "#/api/typesGenerated"; + +interface ShouldConfirmAutostopRestartOptions { + autostopChanged: boolean; + autostopEnabled: boolean; + workspaceStatus: WorkspaceStatus; +} + +/** + * shouldConfirmAutostopRestart reports whether the user should be prompted to + * restart their workspace so a newly saved autostop value applies immediately. + * + * The prompt is only meaningful when autostop is enabled after the change and + * the workspace is running, because the deadline is derived at build start. + * Enabling autostop (or changing its value while enabled) does not touch a + * running build's deadline, so without a restart the new value only applies on + * the next start. Disabling autostop clears the running build's deadline + * server-side, so no restart is required in that case. + */ +export const shouldConfirmAutostopRestart = ({ + autostopChanged, + autostopEnabled, + workspaceStatus, +}: ShouldConfirmAutostopRestartOptions): boolean => + autostopChanged && autostopEnabled && workspaceStatus === "running"; From b233ecb11e602156c36d7a2968aa3573b53b8485 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 29 Jul 2026 03:18:19 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20inline=20autost?= =?UTF-8?q?op=20restart=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceSchedulePage.test.tsx | 59 ------------------- .../WorkspaceSchedulePage.tsx | 9 +-- .../WorkspaceSchedulePage/restart.ts | 25 -------- 3 files changed, 3 insertions(+), 90 deletions(-) delete mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index bb6a607dbba..3d604b4f501 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -2,7 +2,6 @@ import { formValuesToAutostartRequest, formValuesToTTLRequest, } from "./formToRequest"; -import { shouldConfirmAutostopRestart } from "./restart"; import { scheduleToAutostart } from "./schedule"; import { ttlMsToAutostop } from "./ttl"; import type { WorkspaceScheduleFormValues } from "./WorkspaceScheduleForm"; @@ -236,62 +235,4 @@ describe("WorkspaceSchedulePage", () => { expect(ttlMsToAutostop(ttlMs)).toEqual(autostop); }); }); - - describe("shouldConfirmAutostopRestart", () => { - it.each([ - // Enabling autostop on a running workspace should prompt, since the - // deadline is only applied on the next start without a restart. - [ - "enable while running", - { - autostopChanged: true, - autostopEnabled: true, - workspaceStatus: "running", - }, - true, - ], - // Changing the value while autostop stays enabled should prompt too. - [ - "modify while running", - { - autostopChanged: true, - autostopEnabled: true, - workspaceStatus: "running", - }, - true, - ], - // Disabling autostop clears the deadline server-side, so no prompt. - [ - "disable while running", - { - autostopChanged: true, - autostopEnabled: false, - workspaceStatus: "running", - }, - false, - ], - // A stopped workspace has no running build to apply a deadline to. - [ - "enable while stopped", - { - autostopChanged: true, - autostopEnabled: true, - workspaceStatus: "stopped", - }, - false, - ], - // No autostop change means nothing to apply. - [ - "unchanged while running", - { - autostopChanged: false, - autostopEnabled: true, - workspaceStatus: "running", - }, - false, - ], - ] as const)("shouldConfirmAutostopRestart(%s) returns %p", (_name, options, expected) => { - expect(shouldConfirmAutostopRestart(options)).toBe(expected); - }); - }); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 909ca8d205f..2d74cfc1d81 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -18,7 +18,6 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { shouldConfirmAutostopRestart } from "#/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart"; import { scheduleChanged, scheduleToAutostart, @@ -142,11 +141,9 @@ const WorkspaceSchedulePage: FC = () => { await submitScheduleMutation.mutateAsync(data); if ( - shouldConfirmAutostopRestart({ - autostopChanged: data.autostopChanged, - autostopEnabled: values.autostopEnabled, - workspaceStatus: workspace.latest_build.status, - }) + data.autostopChanged && + values.autostopEnabled && + workspace.latest_build.status === "running" ) { setIsConfirmingApply(true); } diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts deleted file mode 100644 index 11cfe40d3fd..00000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/restart.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { WorkspaceStatus } from "#/api/typesGenerated"; - -interface ShouldConfirmAutostopRestartOptions { - autostopChanged: boolean; - autostopEnabled: boolean; - workspaceStatus: WorkspaceStatus; -} - -/** - * shouldConfirmAutostopRestart reports whether the user should be prompted to - * restart their workspace so a newly saved autostop value applies immediately. - * - * The prompt is only meaningful when autostop is enabled after the change and - * the workspace is running, because the deadline is derived at build start. - * Enabling autostop (or changing its value while enabled) does not touch a - * running build's deadline, so without a restart the new value only applies on - * the next start. Disabling autostop clears the running build's deadline - * server-side, so no restart is required in that case. - */ -export const shouldConfirmAutostopRestart = ({ - autostopChanged, - autostopEnabled, - workspaceStatus, -}: ShouldConfirmAutostopRestartOptions): boolean => - autostopChanged && autostopEnabled && workspaceStatus === "running"; From 32d13babd9900e85772080c44c289657b0613f82 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 29 Jul 2026 03:23:11 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20test(site):=20cover=20autost?= =?UTF-8?q?op=20restart=20prompt=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Storybook play-function coverage for the autostop restart prompt and document the trigger conditions inline: enabling or changing the value while running prompts; disabling or a stopped workspace does not. Rework the prior story that asserted the pre-fix behavior of prompting when disabling autostop. --- .../WorkspaceSchedulePage.stories.tsx | 65 ++++++++++++++++--- .../WorkspaceSchedulePage.tsx | 10 +++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx index d6a2fbcf82d..e106283e10b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -73,7 +73,30 @@ export const EnablingAutostopUsesTemplateDefault: Story = { }, }; -export const ChangingAutostopShowsRestartDialog: Story = { +export const EnablingAutostopShowsRestartDialog: Story = { + parameters: { + reactRouter: workspaceRouterParameters(autostopDisabledWorkspace), + queries: workspaceQueries(autostopDisabledWorkspace), + }, + beforeEach: () => { + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + autostopDisabledWorkspace, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + await user.click(await canvas.findByLabelText("Enable Autostop")); + await user.click(await canvas.findByRole("button", { name: /save/i })); + await body.findByText( + `Schedule for workspace "${MockWorkspace.name}" updated successfully.`, + ); + await body.findByText("Restart workspace?"); + }, +}; + +export const ChangingAutostopValueShowsRestartDialog: Story = { parameters: { reactRouter: workspaceRouterParameters(MockWorkspace), queries: workspaceQueries(MockWorkspace), @@ -85,7 +108,11 @@ export const ChangingAutostopShowsRestartDialog: Story = { const canvas = within(canvasElement); const body = within(document.body); const user = userEvent.setup(); - await user.click(await canvas.findByLabelText("Enable Autostop")); + const ttlInput = await canvas.findByLabelText( + "Time until shutdown (hours)", + ); + await user.clear(ttlInput); + await user.type(ttlInput, "4"); await user.click(await canvas.findByRole("button", { name: /save/i })); await body.findByText( `Schedule for workspace "${MockWorkspace.name}" updated successfully.`, @@ -94,19 +121,41 @@ export const ChangingAutostopShowsRestartDialog: Story = { }, }; -const stoppedWorkspace: Workspace = { - ...MockWorkspace, +export const DisablingAutostopSkipsRestartDialog: Story = { + parameters: { + reactRouter: workspaceRouterParameters(MockWorkspace), + queries: workspaceQueries(MockWorkspace), + }, + beforeEach: () => { + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(MockWorkspace); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + // MockWorkspace has autostop enabled, so clicking the toggle disables it. + await user.click(await canvas.findByLabelText("Enable Autostop")); + await user.click(await canvas.findByRole("button", { name: /save/i })); + await body.findByText( + `Schedule for workspace "${MockWorkspace.name}" updated successfully.`, + ); + expect(body.queryByText("Restart workspace?")).not.toBeInTheDocument(); + }, +}; + +const stoppedAutostopDisabledWorkspace: Workspace = { + ...autostopDisabledWorkspace, latest_build: { ...MockWorkspaceBuild, status: "stopped" }, }; -export const ChangingAutostopWhileStoppedSkipsDialog: Story = { +export const EnablingAutostopWhileStoppedSkipsDialog: Story = { parameters: { - reactRouter: workspaceRouterParameters(stoppedWorkspace), - queries: workspaceQueries(stoppedWorkspace), + reactRouter: workspaceRouterParameters(stoppedAutostopDisabledWorkspace), + queries: workspaceQueries(stoppedAutostopDisabledWorkspace), }, beforeEach: () => { spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( - stoppedWorkspace, + stoppedAutostopDisabledWorkspace, ); }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 2d74cfc1d81..4e4c1fd3fe3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -140,6 +140,16 @@ const WorkspaceSchedulePage: FC = () => { await submitScheduleMutation.mutateAsync(data); + // A running build's autostop deadline is calculated when the + // build starts, so updating the TTL does not retroactively + // change it. Prompt the user to restart so the new value takes + // effect immediately, but only when all of the following hold: + // - autostop actually changed (toggled or new TTL value), + // - autostop is enabled after the change; disabling clears the + // running build's deadline server-side, so no restart is + // needed, and + // - the workspace is running; a stopped workspace picks up the + // new value on its next start. if ( data.autostopChanged && values.autostopEnabled && From 21d7c6235e512683ea8d32d68f389c4ee345707e Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 29 Jul 2026 04:13:15 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20keep=20user=20o?= =?UTF-8?q?n=20schedule=20page=20when=20applying=20autostop=20later?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dismissing the restart confirmation with "Apply later" navigated away to the workspace, same as confirming. Close the dialog instead so the user stays on the schedule page; the saved value still applies on the next start. --- .../WorkspaceSchedulePage.stories.tsx | 30 ++++++++++++++++++- .../WorkspaceSchedulePage.tsx | 4 ++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx index e106283e10b..1af29059047 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, within } from "storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterOutlet, reactRouterParameters, @@ -96,6 +96,34 @@ export const EnablingAutostopShowsRestartDialog: Story = { }, }; +export const ApplyLaterKeepsUserOnSchedulePage: Story = { + parameters: { + reactRouter: workspaceRouterParameters(autostopDisabledWorkspace), + queries: workspaceQueries(autostopDisabledWorkspace), + }, + beforeEach: () => { + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + autostopDisabledWorkspace, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + const restartSpy = spyOn(API, "restartWorkspace"); + await user.click(await canvas.findByLabelText("Enable Autostop")); + await user.click(await canvas.findByRole("button", { name: /save/i })); + await body.findByText("Restart workspace?"); + await user.click(await body.findByRole("button", { name: /apply later/i })); + // The dialog closes without restarting or leaving the schedule page. + await waitFor(() => + expect(body.queryByText("Restart workspace?")).not.toBeInTheDocument(), + ); + expect(restartSpy).not.toHaveBeenCalled(); + await canvas.findByLabelText("Enable Autostop"); + }, +}; + export const ChangingAutostopValueShowsRestartDialog: Story = { parameters: { reactRouter: workspaceRouterParameters(MockWorkspace), diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 4e4c1fd3fe3..ed12a6b9e13 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -173,7 +173,9 @@ const WorkspaceSchedulePage: FC = () => { navigate(`/@${username}/${workspaceName}`); }} onClose={() => { - navigate(`/@${username}/${workspaceName}`); + // Keep the user on the schedule page; the saved value still + // applies on the next workspace start. + setIsConfirmingApply(false); }} />