mirror of
https://github.com/coder/coder.git
synced 2026-06-07 15:08:20 +00:00
fix(site): improve Workspace Autostop Fallback UX on agents settings page (#23465)
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
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<AgentSettingsPageViewProps> = ({
|
||||
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
|
||||
const serverTTLMs = workspaceTTLQuery.data?.workspace_ttl_ms ?? 0;
|
||||
const [localTTLMs, setLocalTTLMs] = useState<number | null>(null);
|
||||
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(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<AgentSettingsPageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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<AgentSettingsPageViewProps> = ({
|
||||
placeholder="Additional behavior, style, and tone preferences"
|
||||
value={userPromptDraft}
|
||||
onChange={(event) => setLocalUserEdit(event.target.value)}
|
||||
disabled={isDisabled}
|
||||
disabled={isPromptSaving}
|
||||
minRows={1}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -623,14 +652,14 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => setLocalUserEdit("")}
|
||||
disabled={isDisabled || !userPromptDraft}
|
||||
disabled={isPromptSaving || !userPromptDraft}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isDisabled || !isUserPromptDirty}
|
||||
disabled={isPromptSaving || !isUserPromptDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -672,7 +701,7 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
placeholder="Additional behavior, style, and tone preferences for all users"
|
||||
value={systemPromptDraft}
|
||||
onChange={(event) => setLocalEdit(event.target.value)}
|
||||
disabled={isDisabled}
|
||||
disabled={isPromptSaving}
|
||||
minRows={1}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -681,14 +710,14 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => setLocalEdit("")}
|
||||
disabled={isDisabled || !systemPromptDraft}
|
||||
disabled={isPromptSaving || !systemPromptDraft}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isDisabled || !isSystemPromptDirty}
|
||||
disabled={isPromptSaving || !isSystemPromptDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -733,7 +762,7 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
saveDesktopEnabled({ enable_desktop: checked })
|
||||
}
|
||||
aria-label="Enable"
|
||||
disabled={isDisabled}
|
||||
disabled={isDesktopSaving}
|
||||
/>
|
||||
</div>
|
||||
{isSaveDesktopEnabledError && (
|
||||
@@ -749,36 +778,63 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Default Autostop
|
||||
Workspace Autostop Fallback
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{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.`}
|
||||
</p>
|
||||
<DurationField
|
||||
label="Default autostop"
|
||||
valueMs={ttlMs}
|
||||
onChange={(v) => setLocalTTLMs(v)}
|
||||
disabled={isDisabled || isTTLLoading}
|
||||
error={isTTLOverMax}
|
||||
helperText={
|
||||
isTTLOverMax
|
||||
? "Must not exceed 30 days (720 hours)."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isDisabled || !isTTLDirty || isTTLOverMax}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
|
||||
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.
|
||||
</p>
|
||||
<Switch
|
||||
checked={isAutostopEnabled}
|
||||
onCheckedChange={handleToggleAutostop}
|
||||
aria-label="Enable default autostop"
|
||||
disabled={isTTLSaving || isTTLLoading}
|
||||
/>{" "}
|
||||
</div>
|
||||
{isAutostopEnabled && (
|
||||
<DurationField
|
||||
valueMs={ttlMs}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={
|
||||
isTTLSaving ||
|
||||
!isTTLDirty ||
|
||||
isTTLOverMax ||
|
||||
isTTLZero
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSaveWorkspaceTTLError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save autostop setting.
|
||||
|
||||
Reference in New Issue
Block a user