Compare commits

...

9 Commits

Author SHA1 Message Date
Atif Ali 305da20064 fix: refetch workspace data on settings page navigation
Addresses review feedback from jaaydenh:
- Button was getting stuck disabled when returning to parameters page during transition
- Added refetchOnMount: "always" to ensure fresh workspace data on every navigation
  to settings pages

This prevents stale workspace.latest_build.status from incorrectly keeping the
isInTransition check true when the build has already completed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 04:59:12 +00:00
Atif Ali 5750dbbbf2 Merge branch 'main' into fix/workspace-parameter-update-restart 2026-01-27 22:27:20 +05:00
Atif Ali e2bc48f61e test: add test for submit button disabled during workspace transitions
Adds test coverage to verify:
- Submit button is disabled when workspace is in transitional state (starting/stopping/etc)
- Button remains disabled even after form changes are made

This ensures the new isInTransition check properly prevents parameter updates during workspace builds.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 13:15:39 +00:00
Atif Ali b5cb947c89 refactor: disable submit button during workspace transitions instead of waiting
Addresses review feedback to improve UX when updating workspace parameters:
- Add "deleting" status to isInTransition check
- Update alert message to show specific transition and status instead of generic message
- Applied to both classic and experimental parameter pages

This provides clearer feedback to users about why they can't proceed with parameter updates.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 13:07:33 +00:00
Atif Ali da442c1a5e refactor: disable submit button during workspace transitions instead of waiting
Instead of waiting for transitional workspace states (starting, stopping,
pending, canceling) in the mutation handler, we now:

- Check workspace state before rendering the form
- Disable the "Submit and restart" button when workspace is in a
  transitional state
- Display an informative message: "There is currently a workspace build
  in progress. Please wait for it to complete before proceeding."

This provides better user experience by making the state clear upfront
rather than having the mutation hang while waiting for the workspace to
reach a stable state.

Addresses review feedback from johnstcn.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 09:43:32 +00:00
Atif Ali 242a034b7b Merge branch 'main' into fix/workspace-parameter-update-restart 2026-01-17 14:42:23 +05:00
Atif Ali 41ea7a8fa0 feat: handle transitional states and stop failures in parameter updates
Add robust handling for edge cases when updating workspace parameters:

- Wait for workspaces in transitional states (starting, stopping,
  pending, canceling) to reach a stable state before proceeding with
  parameter updates
- Check for canceled transitions after waiting and bail out with clear
  error messages
- Explicitly check for failed stop operations and provide user-friendly
  error messages instead of allowing errors to propagate silently

This prevents race conditions when users click "Submit and restart"
while workspace is in a transitional state and provides better feedback
when stop operations fail.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 07:33:11 +00:00
Atif Ali 6a9921d495 test: add test for stopped workspace parameter update
Add test case to verify that when a workspace is already stopped,
parameter updates skip the stop step and directly start with new
parameters.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 07:25:19 +00:00
Atif Ali 77303f91df fix: workspace parameter updates now restart running workspaces
When updating workspace parameters in the settings page, the code was only
triggering a "start" transition. This caused issues when the workspace was
already running, as it wouldn't properly apply the new parameters.

The fix checks if the workspace is currently running before updating parameters:
- If running: stops the workspace, waits for it to complete, then starts with new parameters
- If stopped: just starts with new parameters (existing behavior)

This ensures parameter changes are properly applied by performing a full restart cycle.

Fixes the bug where parameter updates would trigger a start instead of stop+start.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 20:02:42 +00:00
6 changed files with 241 additions and 8 deletions
@@ -33,6 +33,7 @@ interface WorkspaceParameterFormProps {
templateVersionRichParameters: TemplateVersionParameter[];
autofillParams: AutofillBuildParameter[];
isSubmitting: boolean;
isInTransition: boolean;
canChangeVersions: boolean;
templatePermissions: { canUpdateTemplate: boolean } | undefined;
error: unknown;
@@ -50,6 +51,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
canChangeVersions,
templatePermissions,
isSubmitting,
isInTransition,
}) => {
const form = useFormik<WorkspaceParametersFormValues>({
onSubmit,
@@ -169,7 +171,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
<Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
disabled={isSubmitting || disabled || isInTransition || !form.dirty}
>
<Spinner loading={isSubmitting} />
Submit and restart
@@ -34,6 +34,14 @@ test("Submit the workspace settings page successfully", async () => {
// Immutable value
MockWorkspaceBuildParameter4,
]);
// Mock the API calls for stopping and restarting the workspace
const stopWorkspaceSpy = vi
.spyOn(API, "stopWorkspace")
.mockResolvedValue({ ...MockWorkspaceBuild, transition: "stop" });
const waitForBuildSpy = vi.spyOn(API, "waitForBuild").mockResolvedValue({
...MockWorkspaceBuild.job,
status: "succeeded",
});
// Mock the API calls that submit data
const postWorkspaceBuildSpy = vi
.spyOn(API, "postWorkspaceBuild")
@@ -66,6 +74,10 @@ test("Submit the workspace settings page successfully", async () => {
);
// Assert that the API calls were made with the correct data
await waitFor(() => {
// Since workspace is running, it should stop first
expect(stopWorkspaceSpy).toHaveBeenCalledWith(MockWorkspace.id);
expect(waitForBuildSpy).toHaveBeenCalled();
// Then start with new parameters
expect(postWorkspaceBuildSpy).toHaveBeenCalledWith(MockWorkspace.id, {
reason: "dashboard",
transition: "start",
@@ -77,6 +89,83 @@ test("Submit the workspace settings page successfully", async () => {
});
});
test("Submit the workspace settings page when workspace is stopped", async () => {
// Create a stopped workspace
const stoppedWorkspace = {
...MockWorkspace,
latest_build: {
...MockWorkspaceBuild,
status: "stopped" as const,
transition: "stop" as const,
},
};
// Mock the API calls that loads data
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce(
stoppedWorkspace,
);
vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
// Immutable parameters
MockTemplateVersionParameter4,
]);
vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
// Immutable value
MockWorkspaceBuildParameter4,
]);
// Mock the API calls - stopWorkspace should NOT be called for stopped workspace
const stopWorkspaceSpy = vi.spyOn(API, "stopWorkspace");
const waitForBuildSpy = vi.spyOn(API, "waitForBuild");
// Mock the API calls that submit data
const postWorkspaceBuildSpy = vi
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValue(MockWorkspaceBuild);
// Setup event and rendering
const user = userEvent.setup();
renderWithWorkspaceSettingsLayout(<WorkspaceParametersPage />, {
route: "/@test-user/test-workspace/settings",
path: "/:username/:workspace/settings",
// Need this because after submit the user is redirected
extraRoutes: [{ path: "/:username/:workspace", element: <div /> }],
});
await waitForLoaderToBeRemoved();
// Fill the form and submit
const form = screen.getByTestId("form");
const parameter1 = within(form).getByLabelText(
MockWorkspaceBuildParameter1.name,
{ exact: false },
);
await user.clear(parameter1);
await user.type(parameter1, "new-value");
const parameter2 = within(form).getByLabelText(
MockWorkspaceBuildParameter2.name,
{ exact: false },
);
await user.clear(parameter2);
await user.type(parameter2, "3");
await user.click(
within(form).getByRole("button", { name: "Submit and restart" }),
);
// Assert that the API calls were made with the correct data
await waitFor(() => {
// Since workspace is stopped, it should NOT stop first
expect(stopWorkspaceSpy).not.toHaveBeenCalled();
expect(waitForBuildSpy).not.toHaveBeenCalled();
// Should just start with new parameters
expect(postWorkspaceBuildSpy).toHaveBeenCalledWith(stoppedWorkspace.id, {
reason: "dashboard",
transition: "start",
rich_parameter_values: [
{ name: MockTemplateVersionParameter1.name, value: "new-value" },
{ name: MockTemplateVersionParameter2.name, value: "3" },
],
});
});
});
test("Submit button is only enabled when changes are made", async () => {
// Mock the API calls that loads data
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce(
@@ -131,3 +220,53 @@ test("Submit button is only enabled when changes are made", async () => {
// There are now no changes, the button should be disabled.
expect(submitButton.disabled).toBeTruthy();
});
test("Submit button is disabled and shows warning when workspace is in transition", async () => {
// Create a workspace in starting state
const startingWorkspace = {
...MockWorkspace,
latest_build: {
...MockWorkspaceBuild,
status: "starting" as const,
transition: "start" as const,
},
};
// Mock the API calls that loads data
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce(
startingWorkspace,
);
vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
]);
vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
]);
// Setup event and rendering
const user = userEvent.setup();
renderWithWorkspaceSettingsLayout(<WorkspaceParametersPage />, {
route: "/@test-user/test-workspace/settings",
path: "/:username/:workspace/settings",
});
await waitForLoaderToBeRemoved();
const submitButton: HTMLButtonElement = screen.getByRole("button", {
name: "Submit and restart",
});
const form = screen.getByTestId("form");
const parameter1 = within(form).getByLabelText(
MockWorkspaceBuildParameter1.name,
{ exact: false },
);
// Make changes to the form
await user.clear(parameter1);
await user.type(parameter1, "new-value");
// Even with changes, button should still be disabled due to transition
expect(submitButton.disabled).toBeTruthy();
});
@@ -8,6 +8,7 @@ import type {
Workspace,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
@@ -39,12 +40,33 @@ const WorkspaceParametersPage: FC = () => {
);
const navigate = useNavigate();
const updateParameters = useMutation({
mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
API.postWorkspaceBuild(workspace.id, {
mutationFn: async (buildParameters: WorkspaceBuildParameter[]) => {
const currentBuild = workspace.latest_build;
// If workspace is running, stop it first then start with new parameters
if (currentBuild.status === "running") {
const stopBuild = await API.stopWorkspace(workspace.id);
const awaitedStopBuild = await API.waitForBuild(stopBuild);
// If the stop is canceled or failed, bail out
if (awaitedStopBuild?.status === "canceled") {
throw new Error(
"Workspace stop was canceled, not proceeding with parameter update.",
);
}
if (awaitedStopBuild?.status === "failed") {
throw new Error(
"Workspace failed to stop, not proceeding with parameter update.",
);
}
}
// Now start the workspace with new parameters
return API.postWorkspaceBuild(workspace.id, {
transition: "start",
rich_parameter_values: buildParameters,
reason: "dashboard",
}),
});
},
onSuccess: () => {
navigate(`/${workspace.owner_name}/${workspace.name}`);
},
@@ -78,6 +100,14 @@ const WorkspaceParametersPage: FC = () => {
| { canUpdateTemplate: boolean }
| undefined;
// Check if workspace is in a transitional state
const isInTransition =
workspace.latest_build.status === "starting" ||
workspace.latest_build.status === "stopping" ||
workspace.latest_build.status === "pending" ||
workspace.latest_build.status === "canceling" ||
workspace.latest_build.status === "deleting";
return (
<>
<title>{pageTitle(workspace.name, "Parameters")}</title>
@@ -88,6 +118,7 @@ const WorkspaceParametersPage: FC = () => {
buildParameters={buildParameters}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
isInTransition={isInTransition}
submitError={updateParameters.error}
isSubmitting={updateParameters.isPending}
onSubmit={(values) => {
@@ -123,6 +154,7 @@ type WorkspaceParametersPageViewProps = {
templatePermissions: { canUpdateTemplate: boolean } | undefined;
templateVersionParameters?: TemplateVersionParameter[];
buildParameters?: WorkspaceBuildParameter[];
isInTransition: boolean;
submitError: unknown;
isSubmitting: boolean;
onSubmit: (formValues: WorkspaceParametersFormValues) => void;
@@ -137,6 +169,7 @@ export const WorkspaceParametersPageView: FC<
templatePermissions,
templateVersionParameters,
buildParameters,
isInTransition,
submitError,
onSubmit,
isSubmitting,
@@ -154,12 +187,22 @@ export const WorkspaceParametersPageView: FC<
<ErrorAlert error={submitError} css={{ marginBottom: 48 }} />
) : null}
{isInTransition && (
<Alert severity="info">
There is currently a{" "}
<strong>{workspace.latest_build.transition}</strong> workspace build{" "}
<strong>{workspace.latest_build.status}</strong>. Please wait for the
workspace build to complete before proceeding.
</Alert>
)}
{templateVersionParameters && buildParameters ? (
templateVersionParameters.length > 0 ? (
<WorkspaceParametersForm
workspace={workspace}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
isInTransition={isInTransition}
autofillParams={buildParameters.map((p) => ({
...p,
source: "active_build",
@@ -6,6 +6,7 @@ import type {
DynamicParametersResponse,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
@@ -143,13 +144,34 @@ const WorkspaceParametersPageExperimental: FC = () => {
]);
const updateParameters = useMutation({
mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
API.postWorkspaceBuild(workspace.id, {
mutationFn: async (buildParameters: WorkspaceBuildParameter[]) => {
const currentBuild = workspace.latest_build;
// If workspace is running, stop it first then start with new parameters
if (currentBuild.status === "running") {
const stopBuild = await API.stopWorkspace(workspace.id);
const awaitedStopBuild = await API.waitForBuild(stopBuild);
// If the stop is canceled or failed, bail out
if (awaitedStopBuild?.status === "canceled") {
throw new Error(
"Workspace stop was canceled, not proceeding with parameter update.",
);
}
if (awaitedStopBuild?.status === "failed") {
throw new Error(
"Workspace failed to stop, not proceeding with parameter update.",
);
}
}
// Now start the workspace with new parameters
return API.postWorkspaceBuild(workspace.id, {
transition: "start",
template_version_id: templateVersionId,
rich_parameter_values: buildParameters,
reason: "dashboard",
}),
});
},
onSuccess: () => {
navigate(`/@${workspace.owner_name}/${workspace.name}`);
},
@@ -195,6 +217,14 @@ const WorkspaceParametersPageExperimental: FC = () => {
const error = wsError || updateParameters.error;
// Check if workspace is in a transitional state
const isInTransition =
workspace.latest_build.status === "starting" ||
workspace.latest_build.status === "stopping" ||
workspace.latest_build.status === "pending" ||
workspace.latest_build.status === "canceling" ||
workspace.latest_build.status === "deleting";
if (
latestBuildParametersLoading ||
!latestResponse ||
@@ -237,12 +267,22 @@ const WorkspaceParametersPageExperimental: FC = () => {
{Boolean(error) && <ErrorAlert error={error} />}
{isInTransition && (
<Alert severity="info">
There is currently a{" "}
<strong>{workspace.latest_build.transition}</strong> workspace build{" "}
<strong>{workspace.latest_build.status}</strong>. Please wait for the
workspace build to complete before proceeding.
</Alert>
)}
{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
templateVersionId={templateVersionId}
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
isInTransition={isInTransition}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}
isSubmitting={updateParameters.isPending}
@@ -26,6 +26,7 @@ type WorkspaceParametersPageViewExperimentalProps = {
diagnostics: PreviewParameter["diagnostics"];
canChangeVersions: boolean;
isSubmitting: boolean;
isInTransition: boolean;
onCancel: () => void;
onSubmit: (values: {
rich_parameter_values: WorkspaceBuildParameter[];
@@ -43,6 +44,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
diagnostics,
canChangeVersions,
isSubmitting,
isInTransition,
onSubmit,
sendMessage,
onCancel,
@@ -248,6 +250,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
disabled={
isSubmitting ||
disabled ||
isInTransition ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||
@@ -36,7 +36,13 @@ export const WorkspaceSettingsLayout: FC = () => {
error,
isLoading,
isError,
} = useQuery(workspaceByOwnerAndName(username, workspaceName));
} = useQuery({
...workspaceByOwnerAndName(username, workspaceName),
// Always refetch workspace data when navigating to settings pages to ensure
// we have the latest build status. This prevents the isInTransition check
// from using stale data and incorrectly disabling the submit button.
refetchOnMount: "always",
});
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),