feat(site): move lifecycle settings to AI settings (#26625)

This commit is contained in:
Danielle Maywood
2026-06-24 10:15:28 +01:00
committed by GitHub
parent e569e16682
commit 791b4b6413
23 changed files with 1392 additions and 1655 deletions
@@ -35,6 +35,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/lifecycle">
Lifecycle
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/templates">
Templates
@@ -12,9 +12,10 @@ import {
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsLifecyclePageView } from "./AgentSettingsLifecyclePageView";
import { pageTitle } from "#/utils/page";
import { LifecyclePageView } from "./LifecyclePageView";
const AgentSettingsLifecyclePage: FC = () => {
const LifecyclePage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const workspaceTTLQuery = useQuery({
@@ -48,7 +49,8 @@ const AgentSettingsLifecyclePage: FC = () => {
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsLifecyclePageView
<title>{pageTitle("Lifecycle", "AI Settings")}</title>
<LifecyclePageView
workspaceTTLData={workspaceTTLQuery.data}
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
@@ -78,4 +80,4 @@ const AgentSettingsLifecyclePage: FC = () => {
);
};
export default AgentSettingsLifecyclePage;
export default LifecyclePage;
@@ -0,0 +1,635 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
LifecyclePageView,
type LifecyclePageViewProps,
} from "./LifecyclePageView";
const baseArgs: LifecyclePageViewProps = {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
isWorkspaceTTLLoading: false,
isWorkspaceTTLLoadError: false,
onSaveWorkspaceTTL: fn(),
isSavingWorkspaceTTL: false,
isSaveWorkspaceTTLError: false,
retentionDaysData: { retention_days: 30 },
isRetentionDaysLoading: false,
isRetentionDaysLoadError: false,
onSaveRetentionDays: fn(),
isSavingRetentionDays: false,
isSaveRetentionDaysError: false,
debugRetentionDaysData: { debug_retention_days: 10 },
isDebugRetentionDaysLoading: false,
isDebugRetentionDaysLoadError: false,
onSaveDebugRetentionDays: fn(),
isSavingDebugRetentionDays: false,
isSaveDebugRetentionDaysError: false,
autoArchiveDaysData: { auto_archive_days: 45 },
isAutoArchiveDaysLoading: false,
isAutoArchiveDaysLoadError: false,
onSaveAutoArchiveDays: fn(),
isSavingAutoArchiveDays: false,
isSaveAutoArchiveDaysError: false,
};
const meta = {
title: "pages/AISettingsPage/LifecyclePage/LifecyclePageView",
component: LifecyclePageView,
args: baseArgs,
} satisfies Meta<typeof LifecyclePageView>;
export default meta;
type Story = StoryObj<typeof LifecyclePageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByRole("heading", { name: "Lifecycle" }),
).toBeVisible();
expect(
canvas.getByText(
"Control workspace lifecycle and conversation retention.",
),
).toBeVisible();
expect(canvas.getByText("Workspace autostop fallback")).toBeVisible();
expect(
canvas.getByText("Auto-archive inactive conversations"),
).toBeVisible();
expect(canvas.getByText("Conversation retention period")).toBeVisible();
expect(canvas.getByText("Chat debug data retention")).toBeVisible();
expect(canvas.queryByRole("button", { name: "Save" })).toBeNull();
},
};
export const DirtyAutostopInput: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Autostop fallback");
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected autostop input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "3");
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 10_800_000 },
expect.anything(),
);
});
},
};
export const DirtyAutostopToggle: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
const form = toggle.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected autostop toggle to live inside a form.");
}
await userEvent.click(toggle);
expect(args.onSaveWorkspaceTTL).not.toHaveBeenCalled();
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
},
};
export const DirtyAutoArchiveToggle: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
const form = toggle.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive toggle to live inside a form.");
}
await userEvent.click(toggle);
expect(args.onSaveAutoArchiveDays).not.toHaveBeenCalled();
expect(
await within(form).findByLabelText("Auto-archive period in days"),
).toHaveValue(90);
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith(
{ auto_archive_days: 90 },
expect.anything(),
);
});
},
};
export const DirtyRetentionInput: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Conversation retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "60");
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(args.onSaveRetentionDays).toHaveBeenCalledWith(
{ retention_days: 60 },
expect.anything(),
);
});
},
};
export const DirtyDebugRetentionInput: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "14");
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith(
{ debug_retention_days: 14 },
expect.anything(),
);
});
},
};
export const InvalidRetentionMinDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Conversation retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "0");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Retention period must be at least 1 day."),
).toBeInTheDocument();
});
},
};
export const InvalidRetentionMaxDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Conversation retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "9999");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Must not exceed 3650 days (~10 years)."),
).toBeInTheDocument();
});
},
};
export const InvalidAutostopMaxDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Autostop fallback");
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected autostop input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "721");
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument();
});
},
};
export const AutostopInvalidThenToggleOffSubmitsZero: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Autostop fallback");
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected autostop input to live inside a form.");
}
const toggle = canvas.getByRole("switch", {
name: "Enable default autostop",
});
await userEvent.click(toggle);
await userEvent.clear(input);
await userEvent.type(input, "721");
await waitFor(() => {
expect(input).toBeInvalid();
});
await userEvent.click(toggle);
expect(input).toBeDisabled();
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
},
};
export const AutostopInputDisabledWhenToggleOff: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 0 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Autostop fallback");
expect(input).toBeDisabled();
},
};
export const AutostopSaveError: Story = {
args: {
isSaveWorkspaceTTLError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
export const AutostopLoadError: Story = {
args: {
workspaceTTLData: undefined,
isWorkspaceTTLLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to load autostop setting."),
).toBeInTheDocument();
},
};
export const AutoArchiveSaveError: Story = {
args: {
isSaveAutoArchiveDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save auto-archive setting."),
).toBeInTheDocument();
},
};
export const AutoArchiveLoadError: Story = {
args: {
autoArchiveDaysData: undefined,
isAutoArchiveDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to load auto-archive setting."),
).toBeInTheDocument();
},
};
export const RetentionSaveError: Story = {
args: {
isSaveRetentionDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save conversation retention setting."),
).toBeInTheDocument();
},
};
export const RetentionLoadError: Story = {
args: {
retentionDaysData: undefined,
isRetentionDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to load conversation retention setting."),
).toBeInTheDocument();
},
};
export const DebugRetentionSaveError: Story = {
args: {
isSaveDebugRetentionDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save chat debug retention setting."),
).toBeInTheDocument();
},
};
export const DebugRetentionLoadError: Story = {
args: {
debugRetentionDaysData: undefined,
isDebugRetentionDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to load chat debug retention setting."),
).toBeInTheDocument();
},
};
export const DirtyAutoArchiveToggleOff: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
const form = toggle.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive toggle to live inside a form.");
}
await userEvent.click(toggle);
expect(args.onSaveAutoArchiveDays).not.toHaveBeenCalled();
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith(
{ auto_archive_days: 0 },
expect.anything(),
);
});
},
};
export const DirtyRetentionToggleOff: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable conversation retention",
});
const form = toggle.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected retention toggle to live inside a form.");
}
await userEvent.click(toggle);
expect(args.onSaveRetentionDays).not.toHaveBeenCalled();
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveRetentionDays).toHaveBeenCalledWith(
{ retention_days: 0 },
expect.anything(),
);
});
},
};
export const DirtyDebugRetentionToggleOff: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable chat debug data retention",
});
const form = toggle.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected debug retention toggle to live inside a form.");
}
await userEvent.click(toggle);
expect(args.onSaveDebugRetentionDays).not.toHaveBeenCalled();
const saveButton = within(form).getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith(
{ debug_retention_days: 0 },
expect.anything(),
);
});
},
};
export const InvalidAutoArchiveMinDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "0");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Auto-archive period must be at least 1 day."),
).toBeInTheDocument();
});
},
};
export const InvalidAutoArchiveMaxDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "9999");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Must not exceed 3650 days (~10 years)."),
).toBeInTheDocument();
});
},
};
export const InvalidDebugRetentionMinDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "0");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Debug retention period must be at least 1 day."),
).toBeInTheDocument();
});
},
};
export const InvalidDebugRetentionMaxDisablesSave: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const form = input.closest("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "9999");
await userEvent.tab();
const saveButton = within(form).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
expect(
canvas.getByText("Must not exceed 3650 days (~10 years)."),
).toBeInTheDocument();
});
},
};
export const AutoArchiveInputDisabledWhenToggleOff: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 0 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
expect(input).toBeDisabled();
},
};
export const RetentionInputDisabledWhenToggleOff: Story = {
args: {
retentionDaysData: { retention_days: 0 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Conversation retention period in days",
);
expect(input).toBeDisabled();
},
};
export const DebugRetentionInputDisabledWhenToggleOff: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 0 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
expect(input).toBeDisabled();
},
};
@@ -1,13 +1,17 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { AutoArchiveSettings } from "./components/AutoArchiveSettings";
import { DebugRetentionSettings } from "./components/DebugRetentionSettings";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings";
export interface AgentSettingsLifecyclePageViewProps {
export interface LifecyclePageViewProps {
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
@@ -54,9 +58,7 @@ export interface AgentSettingsLifecyclePageViewProps {
isSaveAutoArchiveDaysError: boolean;
}
export const AgentSettingsLifecyclePageView: FC<
AgentSettingsLifecyclePageViewProps
> = ({
export const LifecyclePageView: FC<LifecyclePageViewProps> = ({
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
@@ -83,43 +85,47 @@ export const AgentSettingsLifecyclePageView: FC<
isSaveAutoArchiveDaysError,
}) => {
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Lifecycle"
description="Control workspace lifecycle and conversation retention."
/>
<WorkspaceAutostopSettings
workspaceTTLData={workspaceTTLData}
isWorkspaceTTLLoading={isWorkspaceTTLLoading}
isWorkspaceTTLLoadError={isWorkspaceTTLLoadError}
onSaveWorkspaceTTL={onSaveWorkspaceTTL}
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
/>
<AutoArchiveSettings
autoArchiveDaysData={autoArchiveDaysData}
isAutoArchiveDaysLoading={isAutoArchiveDaysLoading}
isAutoArchiveDaysLoadError={isAutoArchiveDaysLoadError}
onSaveAutoArchiveDays={onSaveAutoArchiveDays}
isSavingAutoArchiveDays={isSavingAutoArchiveDays}
isSaveAutoArchiveDaysError={isSaveAutoArchiveDaysError}
/>
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
isRetentionDaysLoadError={isRetentionDaysLoadError}
onSaveRetentionDays={onSaveRetentionDays}
isSavingRetentionDays={isSavingRetentionDays}
isSaveRetentionDaysError={isSaveRetentionDaysError}
/>
<DebugRetentionSettings
debugRetentionDaysData={debugRetentionDaysData}
isDebugRetentionDaysLoading={isDebugRetentionDaysLoading}
isDebugRetentionDaysLoadError={isDebugRetentionDaysLoadError}
onSaveDebugRetentionDays={onSaveDebugRetentionDays}
isSavingDebugRetentionDays={isSavingDebugRetentionDays}
isSaveDebugRetentionDaysError={isSaveDebugRetentionDaysError}
/>
<div className="flex max-w-[1100px] flex-col gap-4">
<SettingsHeader>
<SettingsHeaderTitle>Lifecycle</SettingsHeaderTitle>
<SettingsHeaderDescription>
Control workspace lifecycle and conversation retention.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="flex flex-col gap-8">
<WorkspaceAutostopSettings
workspaceTTLData={workspaceTTLData}
isWorkspaceTTLLoading={isWorkspaceTTLLoading}
isWorkspaceTTLLoadError={isWorkspaceTTLLoadError}
onSaveWorkspaceTTL={onSaveWorkspaceTTL}
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
/>
<AutoArchiveSettings
autoArchiveDaysData={autoArchiveDaysData}
isAutoArchiveDaysLoading={isAutoArchiveDaysLoading}
isAutoArchiveDaysLoadError={isAutoArchiveDaysLoadError}
onSaveAutoArchiveDays={onSaveAutoArchiveDays}
isSavingAutoArchiveDays={isSavingAutoArchiveDays}
isSaveAutoArchiveDaysError={isSaveAutoArchiveDaysError}
/>
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
isRetentionDaysLoadError={isRetentionDaysLoadError}
onSaveRetentionDays={onSaveRetentionDays}
isSavingRetentionDays={isSavingRetentionDays}
isSaveRetentionDaysError={isSaveRetentionDaysError}
/>
<DebugRetentionSettings
debugRetentionDaysData={debugRetentionDaysData}
isDebugRetentionDaysLoading={isDebugRetentionDaysLoading}
isDebugRetentionDaysLoadError={isDebugRetentionDaysLoadError}
onSaveDebugRetentionDays={onSaveDebugRetentionDays}
isSavingDebugRetentionDays={isSavingDebugRetentionDays}
isSaveDebugRetentionDaysError={isSaveDebugRetentionDaysError}
/>
</div>
</div>
);
};
@@ -0,0 +1,131 @@
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { DefaultChatAutoArchiveDays } from "#/api/typesGenerated";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { DaysField, LifecycleSettingLayout } from "./LifecycleSettingLayout";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface AutoArchiveSettingsProps {
autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined;
isAutoArchiveDaysLoading: boolean;
isAutoArchiveDaysLoadError: boolean;
onSaveAutoArchiveDays: (
req: TypesGen.UpdateChatAutoArchiveDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingAutoArchiveDays: boolean;
isSaveAutoArchiveDaysError: boolean;
}
// Keep in sync with autoArchiveDaysMaximum in coderd/exp_chats.go.
const DAYS_MIN = 1;
const DAYS_MAX = 3650;
const ENABLE_DEFAULT_DAYS = 90;
const validationSchema = Yup.object({
enabled: Yup.boolean().required(),
auto_archive_days: Yup.number().when("enabled", {
is: true,
then: (schema) =>
schema
.integer("Auto-archive days must be a whole number.")
.min(DAYS_MIN, "Auto-archive period must be at least 1 day.")
.max(DAYS_MAX, "Must not exceed 3650 days (~10 years).")
.required("Auto-archive days is required."),
}),
});
export const AutoArchiveSettings: FC<AutoArchiveSettingsProps> = ({
autoArchiveDaysData,
isAutoArchiveDaysLoading,
isAutoArchiveDaysLoadError,
onSaveAutoArchiveDays,
isSavingAutoArchiveDays,
isSaveAutoArchiveDaysError,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverAutoArchiveDays =
autoArchiveDaysData?.auto_archive_days ?? DefaultChatAutoArchiveDays;
const form = useFormik({
initialValues: {
enabled: serverAutoArchiveDays > 0,
auto_archive_days:
serverAutoArchiveDays > 0 ? serverAutoArchiveDays : ENABLE_DEFAULT_DAYS,
},
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveAutoArchiveDays(
{ auto_archive_days: values.enabled ? values.auto_archive_days : 0 },
{
onSuccess: () => {
showSavedState();
helpers.resetForm({ values });
},
},
);
},
});
const fieldError = form.errors.auto_archive_days;
const hasError =
(Boolean(fieldError) && Boolean(form.touched.auto_archive_days)) ||
isSaveAutoArchiveDaysError ||
isAutoArchiveDaysLoadError;
return (
<LifecycleSettingLayout
title="Auto-archive inactive conversations"
description="Inactive conversations are automatically archived after this period. Pinned conversations are exempt."
checked={form.values.enabled}
onCheckedChange={(checked) => void form.setFieldValue("enabled", checked)}
switchLabel="Enable auto-archive"
disabled={isSavingAutoArchiveDays || isAutoArchiveDaysLoading}
showSave={form.dirty}
isSaving={isSavingAutoArchiveDays}
isSavedVisible={isSavedVisible}
saveDisabled={
isSavingAutoArchiveDays || !form.dirty || Boolean(fieldError)
}
onSubmit={form.handleSubmit}
error={
hasError ? (
<>
{fieldError && form.touched.auto_archive_days && (
<p className="m-0">{fieldError}</p>
)}
{isSaveAutoArchiveDaysError && (
<p className="m-0">Failed to save auto-archive setting.</p>
)}
{isAutoArchiveDaysLoadError && (
<p className="m-0">Failed to load auto-archive setting.</p>
)}
</>
) : undefined
}
>
<DaysField
name="auto_archive_days"
value={form.values.auto_archive_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
label="Auto-archive period in days"
disabled={
!form.values.enabled ||
isSavingAutoArchiveDays ||
isAutoArchiveDaysLoading
}
error={Boolean(fieldError)}
min={DAYS_MIN}
max={DAYS_MAX}
/>
</LifecycleSettingLayout>
);
};
@@ -0,0 +1,141 @@
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { DefaultChatDebugRetentionDays } from "#/api/typesGenerated";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { DaysField, LifecycleSettingLayout } from "./LifecycleSettingLayout";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface DebugRetentionSettingsProps {
debugRetentionDaysData: TypesGen.ChatDebugRetentionDaysResponse | undefined;
isDebugRetentionDaysLoading: boolean;
isDebugRetentionDaysLoadError: boolean;
onSaveDebugRetentionDays: (
req: TypesGen.UpdateChatDebugRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingDebugRetentionDays: boolean;
isSaveDebugRetentionDaysError: boolean;
}
// Keep in sync with chatDebugRetentionDaysMaximum in coderd/exp_chats.go.
const DAYS_MIN = 1;
const DAYS_MAX = 3650;
const validationSchema = Yup.object({
enabled: Yup.boolean().required(),
debug_retention_days: Yup.number().when("enabled", {
is: true,
then: (schema) =>
schema
.integer("Debug retention days must be a whole number.")
.min(DAYS_MIN, "Debug retention period must be at least 1 day.")
.max(DAYS_MAX, "Must not exceed 3650 days (~10 years).")
.required("Debug retention days is required."),
}),
});
export const DebugRetentionSettings: FC<DebugRetentionSettingsProps> = ({
debugRetentionDaysData,
isDebugRetentionDaysLoading,
isDebugRetentionDaysLoadError,
onSaveDebugRetentionDays,
isSavingDebugRetentionDays,
isSaveDebugRetentionDaysError,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverDebugRetentionDays =
debugRetentionDaysData?.debug_retention_days ??
DefaultChatDebugRetentionDays;
const form = useFormik({
initialValues: {
enabled: serverDebugRetentionDays > 0,
debug_retention_days:
serverDebugRetentionDays > 0
? serverDebugRetentionDays
: DefaultChatDebugRetentionDays,
},
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveDebugRetentionDays(
{
debug_retention_days: values.enabled
? values.debug_retention_days
: 0,
},
{
onSuccess: () => {
showSavedState();
helpers.resetForm({ values });
},
},
);
},
});
const fieldError = form.errors.debug_retention_days;
const hasError =
(Boolean(fieldError) && Boolean(form.touched.debug_retention_days)) ||
isSaveDebugRetentionDaysError ||
isDebugRetentionDaysLoadError;
return (
<LifecycleSettingLayout
title="Chat debug data retention"
description="Chat debug runs and debug steps older than this are automatically deleted. This does not control chat message retention."
checked={form.values.enabled}
onCheckedChange={(checked) => void form.setFieldValue("enabled", checked)}
switchLabel="Enable chat debug data retention"
disabled={isSavingDebugRetentionDays || isDebugRetentionDaysLoading}
showSave={form.dirty}
isSaving={isSavingDebugRetentionDays}
isSavedVisible={isSavedVisible}
saveDisabled={
isSavingDebugRetentionDays || !form.dirty || Boolean(fieldError)
}
onSubmit={form.handleSubmit}
error={
hasError ? (
<>
{fieldError && form.touched.debug_retention_days && (
<p className="m-0">{fieldError}</p>
)}
{isSaveDebugRetentionDaysError && (
<p className="m-0">
Failed to save chat debug retention setting.
</p>
)}
{isDebugRetentionDaysLoadError && (
<p className="m-0">
Failed to load chat debug retention setting.
</p>
)}
</>
) : undefined
}
>
<DaysField
name="debug_retention_days"
value={form.values.debug_retention_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
label="Chat debug data retention period in days"
disabled={
!form.values.enabled ||
isSavingDebugRetentionDays ||
isDebugRetentionDaysLoading
}
error={Boolean(fieldError)}
min={DAYS_MIN}
max={DAYS_MAX}
/>
</LifecycleSettingLayout>
);
};
@@ -4,7 +4,7 @@ import { expect, userEvent, within } from "storybook/test";
import { DurationField } from "./DurationField";
const meta: Meta<typeof DurationField> = {
title: "pages/AgentsPage/DurationField",
title: "pages/AISettingsPage/LifecyclePage/DurationField",
component: DurationField,
args: {
label: "Duration",
@@ -100,14 +100,17 @@ export const DurationField: FC<DurationFieldProps> = ({
aria-label={label}
aria-invalid={error}
disabled={disabled}
className="flex-1"
className="h-10 w-24 flex-none gap-2 rounded-md border-border-default px-3 shadow-none"
/>
<Select
value={unit}
onValueChange={(v: string) => handleUnitChange(v as TimeUnit)}
disabled={disabled}
>
<SelectTrigger className="w-[120px]" aria-label="Time unit">
<SelectTrigger
className="h-10 w-[120px] flex-none gap-2 rounded-md border-border-default px-3 shadow-none"
aria-label="Time unit"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -0,0 +1,133 @@
import type { FC, FormEventHandler, ReactNode } from "react";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { TemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { cn } from "#/utils/cn";
interface LifecycleSettingLayoutProps {
title: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
switchLabel: string;
disabled?: boolean;
children?: ReactNode;
error?: ReactNode;
showSave: boolean;
isSaving: boolean;
isSavedVisible: boolean;
saveDisabled: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
}
export const LifecycleSettingLayout: FC<LifecycleSettingLayoutProps> = ({
title,
description,
checked,
onCheckedChange,
switchLabel,
disabled,
children,
error,
showSave,
isSaving,
isSavedVisible,
saveDisabled,
onSubmit,
}) => {
return (
<form className="flex items-start gap-3" onSubmit={onSubmit} noValidate>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={switchLabel}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex max-w-[980px] flex-1 flex-col">
<h3 className="m-0 text-sm font-normal leading-6 text-content-primary">
{title}
</h3>
<p className="mt-1 mb-0 text-sm font-normal leading-6 text-content-secondary">
{description}
</p>
<div className="mt-4 flex flex-wrap items-start gap-3">
{children}
<div className="flex min-h-10 items-center">
{(showSave || isSavedVisible || isSaving) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="lg"
type="submit"
disabled={saveDisabled}
className="h-10 min-w-[88px]"
>
{isSaving && <Spinner loading className="h-4 w-4" />}
Save
</Button>
))}
</div>
</div>
{error && (
<div className="text-xs text-content-destructive">{error}</div>
)}
</div>
</form>
);
};
interface DaysFieldProps {
name: string;
value: number;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
label: string;
disabled?: boolean;
error?: boolean;
min: number;
max: number;
}
export const DaysField: FC<DaysFieldProps> = ({
name,
value,
onChange,
onBlur,
label,
disabled,
error,
min,
max,
}) => {
return (
<label
className={cn(
"grid h-10 w-24 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md border border-border-default border-solid bg-transparent px-3 transition-colors",
error && "border-border-destructive",
disabled && "opacity-50",
)}
>
<span className="sr-only">{label}</span>
<input
type="number"
name={name}
min={min}
max={max}
step={1}
aria-label={label}
value={value}
onChange={onChange}
onBlur={onBlur}
aria-invalid={error}
disabled={disabled}
className="min-w-0 w-full border-none bg-transparent p-0 text-sm font-medium leading-6 text-content-placeholder outline-none disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<span className="shrink-0 text-xs font-normal leading-[18px] text-content-placeholder">
Days
</span>
</label>
);
};
@@ -0,0 +1,133 @@
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { DaysField, LifecycleSettingLayout } from "./LifecycleSettingLayout";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface RetentionPeriodSettingsProps {
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
isRetentionDaysLoading: boolean;
isRetentionDaysLoadError: boolean;
onSaveRetentionDays: (
req: TypesGen.UpdateChatRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
}
// Keep in sync with retentionDaysMaximum in coderd/exp_chats.go.
const DAYS_MIN = 1;
const DAYS_MAX = 3650;
// Matches SQL COALESCE default in GetChatRetentionDays.
const DEFAULT_RETENTION_DAYS = 30;
const validationSchema = Yup.object({
enabled: Yup.boolean().required(),
retention_days: Yup.number().when("enabled", {
is: true,
then: (schema) =>
schema
.integer("Retention days must be a whole number.")
.min(DAYS_MIN, "Retention period must be at least 1 day.")
.max(DAYS_MAX, "Must not exceed 3650 days (~10 years).")
.required("Retention days is required."),
}),
});
export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
retentionDaysData,
isRetentionDaysLoading,
isRetentionDaysLoadError,
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverRetentionDays =
retentionDaysData?.retention_days ?? DEFAULT_RETENTION_DAYS;
const form = useFormik({
initialValues: {
enabled: serverRetentionDays > 0,
retention_days:
serverRetentionDays > 0 ? serverRetentionDays : DEFAULT_RETENTION_DAYS,
},
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveRetentionDays(
{ retention_days: values.enabled ? values.retention_days : 0 },
{
onSuccess: () => {
showSavedState();
helpers.resetForm({ values });
},
},
);
},
});
const fieldError = form.errors.retention_days;
const hasError =
(Boolean(fieldError) && Boolean(form.touched.retention_days)) ||
isSaveRetentionDaysError ||
isRetentionDaysLoadError;
return (
<LifecycleSettingLayout
title="Conversation retention period"
description="Archived conversations and orphaned files older than this are automatically deleted."
checked={form.values.enabled}
onCheckedChange={(checked) => void form.setFieldValue("enabled", checked)}
switchLabel="Enable conversation retention"
disabled={isSavingRetentionDays || isRetentionDaysLoading}
showSave={form.dirty}
isSaving={isSavingRetentionDays}
isSavedVisible={isSavedVisible}
saveDisabled={isSavingRetentionDays || !form.dirty || Boolean(fieldError)}
onSubmit={form.handleSubmit}
error={
hasError ? (
<>
{fieldError && form.touched.retention_days && (
<p className="m-0">{fieldError}</p>
)}
{isSaveRetentionDaysError && (
<p className="m-0">
Failed to save conversation retention setting.
</p>
)}
{isRetentionDaysLoadError && (
<p className="m-0">
Failed to load conversation retention setting.
</p>
)}
</>
) : undefined
}
>
<DaysField
name="retention_days"
value={form.values.retention_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
label="Conversation retention period in days"
disabled={
!form.values.enabled ||
isSavingRetentionDays ||
isRetentionDaysLoading
}
error={Boolean(fieldError)}
min={DAYS_MIN}
max={DAYS_MAX}
/>
</LifecycleSettingLayout>
);
};
@@ -0,0 +1,130 @@
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { DurationField } from "./DurationField/DurationField";
import { LifecycleSettingLayout } from "./LifecycleSettingLayout";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface WorkspaceAutostopSettingsProps {
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
onSaveWorkspaceTTL: (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
options?: MutationCallbacks,
) => void;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
}
const DEFAULT_WORKSPACE_TTL_MS = 3_600_000;
const maxTTLMs = 30 * 24 * 60 * 60_000;
const validationSchema = Yup.object({
enabled: Yup.boolean().required(),
workspace_ttl_ms: Yup.number().when("enabled", {
is: true,
then: (schema) =>
schema
.required()
.moreThan(0, "Duration must be greater than zero.")
.max(maxTTLMs, "Must not exceed 30 days (720 hours)."),
}),
});
export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
const form = useFormik({
initialValues: {
enabled: serverTTLMs > 0,
workspace_ttl_ms:
serverTTLMs > 0 ? serverTTLMs : DEFAULT_WORKSPACE_TTL_MS,
},
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveWorkspaceTTL(
{ workspace_ttl_ms: values.enabled ? values.workspace_ttl_ms : 0 },
{
onSuccess: () => {
showSavedState();
helpers.resetForm({ values });
},
},
);
},
});
const handleToggleAutostop = (checked: boolean) => {
void form.setFieldValue("enabled", checked);
if (checked && form.values.workspace_ttl_ms <= 0) {
void form.setFieldValue("workspace_ttl_ms", DEFAULT_WORKSPACE_TTL_MS);
}
};
const handleTTLChange = (value: number) => {
void form.setFieldValue("workspace_ttl_ms", value);
};
const fieldError = form.errors.workspace_ttl_ms;
const hasError =
Boolean(fieldError) || isSaveWorkspaceTTLError || isWorkspaceTTLLoadError;
return (
<LifecycleSettingLayout
title="Workspace autostop fallback"
description="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 conversations will extend the stop time."
checked={form.values.enabled}
onCheckedChange={handleToggleAutostop}
switchLabel="Enable default autostop"
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
showSave={form.dirty}
isSaving={isSavingWorkspaceTTL}
isSavedVisible={isSavedVisible}
saveDisabled={isSavingWorkspaceTTL || !form.dirty || Boolean(fieldError)}
onSubmit={form.handleSubmit}
error={
hasError ? (
<>
{/* DurationField manages its own text state and never calls
Formik's onBlur, so form.touched is never set for this
field. We display the error directly when present. */}
{fieldError && <p className="m-0">{fieldError}</p>}
{isSaveWorkspaceTTLError && (
<p className="m-0">Failed to save autostop setting.</p>
)}
{isWorkspaceTTLLoadError && (
<p className="m-0">Failed to load autostop setting.</p>
)}
</>
) : undefined
}
>
<DurationField
valueMs={form.values.workspace_ttl_ms}
onChange={handleTTLChange}
label="Autostop fallback"
disabled={
!form.values.enabled || isSavingWorkspaceTTL || isWorkspaceTTLLoading
}
error={Boolean(fieldError)}
className="w-fit"
/>
</LifecycleSettingLayout>
);
};
@@ -1,836 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
AgentSettingsLifecyclePageView,
type AgentSettingsLifecyclePageViewProps,
} from "./AgentSettingsLifecyclePageView";
const baseArgs: AgentSettingsLifecyclePageViewProps = {
workspaceTTLData: { workspace_ttl_ms: 0 },
isWorkspaceTTLLoading: false,
isWorkspaceTTLLoadError: false,
onSaveWorkspaceTTL: fn(),
isSavingWorkspaceTTL: false,
isSaveWorkspaceTTLError: false,
retentionDaysData: { retention_days: 30 },
isRetentionDaysLoading: false,
isRetentionDaysLoadError: false,
onSaveRetentionDays: fn(),
isSavingRetentionDays: false,
isSaveRetentionDaysError: false,
debugRetentionDaysData: { debug_retention_days: 30 },
isDebugRetentionDaysLoading: false,
isDebugRetentionDaysLoadError: false,
onSaveDebugRetentionDays: fn(),
isSavingDebugRetentionDays: false,
isSaveDebugRetentionDaysError: false,
autoArchiveDaysData: { auto_archive_days: 0 },
isAutoArchiveDaysLoading: false,
isAutoArchiveDaysLoadError: false,
onSaveAutoArchiveDays: fn(),
isSavingAutoArchiveDays: false,
isSaveAutoArchiveDaysError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsLifecyclePageView",
component: AgentSettingsLifecyclePageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsLifecyclePageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsLifecyclePageView>;
export const Default: Story = {};
export const DefaultAutostopDefault: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Workspace autostop fallback");
await canvas.findByText(
/Set a default autostop for agent-created workspaces/i,
);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).not.toBeChecked();
expect(canvas.queryByLabelText("Autostop fallback")).toBeNull();
},
};
export const DefaultAutostopCustomValue: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop fallback");
expect(durationInput).toHaveValue("2");
},
};
export const DefaultAutostopSave: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 3_600_000 },
expect.anything(),
);
});
const durationInput = await canvas.findByLabelText("Autostop fallback");
expect(durationInput).toHaveValue("1");
await userEvent.clear(durationInput);
await userEvent.type(durationInput, "3");
const ttlForm = durationInput.closest("form");
if (!(ttlForm instanceof HTMLFormElement)) {
throw new Error(
"Expected autostop duration input to live inside a form.",
);
}
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.clear(durationInput);
await waitFor(() => {
expect(
within(ttlForm).queryByRole("button", { name: "Save" }),
).toBeNull();
});
},
};
export const DefaultAutostopExceedsMax: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
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");
if (!(ttlForm instanceof HTMLFormElement)) {
throw new Error(
"Expected autostop duration input to live inside a form.",
);
}
await userEvent.clear(durationInput);
await userEvent.type(durationInput, "721");
await waitFor(() => {
expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument();
});
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
expect(saveButton).toBeDisabled();
},
};
export const DefaultAutostopToggleOff: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
},
};
export const DefaultAutostopSaveDisabled: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop fallback");
expect(durationInput).toHaveValue("2");
const ttlForm = durationInput.closest("form");
if (!(ttlForm instanceof HTMLFormElement)) {
throw new Error(
"Expected autostop duration input to live inside a form.",
);
}
expect(within(ttlForm).queryByRole("button", { name: "Save" })).toBeNull();
},
};
export const DefaultAutostopToggleFailure: Story = {
args: {
isSaveWorkspaceTTLError: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 3_600_000 },
expect.anything(),
);
});
expect(
canvas.getByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
export const DefaultAutostopToggleOffFailure: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
isSaveWorkspaceTTLError: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop fallback");
expect(durationInput).toHaveValue("2");
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
expect(
canvas.getByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
// --- Auto-Archive Stories ---
export const AutoArchiveDefault: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
expect(toggle).not.toBeChecked();
expect(canvas.queryByLabelText("Auto-archive period in days")).toBeNull();
},
};
export const AutoArchiveEnabled: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
expect(toggle).toBeChecked();
const input = await canvas.findByLabelText("Auto-archive period in days");
expect(input).toHaveValue(90);
},
};
export const AutoArchiveSaveDisabled: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const archiveForm = input.closest("form");
if (!(archiveForm instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
expect(
within(archiveForm).queryByRole("button", { name: "Save" }),
).toBeNull();
},
};
export const AutoArchiveToggleOnSavesDefault: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveAutoArchiveDays).toHaveBeenNthCalledWith(
1,
{ auto_archive_days: 90 },
expect.anything(),
);
});
const archiveForm = toggle.closest("form");
if (!(archiveForm instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive toggle to live inside a form.");
}
const input = await within(archiveForm).findByLabelText(
"Auto-archive period in days",
);
expect(input).toHaveValue(90);
},
};
export const AutoArchiveToggleOffSavesDisabled: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith(
{ auto_archive_days: 0 },
expect.anything(),
);
});
},
};
export const AutoArchiveEditDaysAndSave: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const archiveForm = input.closest("form");
if (!(archiveForm instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "120");
const saveButton = within(archiveForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith(
{ auto_archive_days: 120 },
expect.anything(),
);
});
},
};
export const AutoArchiveExceedsMax: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const archiveForm = input.closest("form");
if (!(archiveForm instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "9999");
const saveButton = within(archiveForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(
canvas.getByText(/must not exceed 3650 days/i),
).toBeInTheDocument();
});
},
};
export const AutoArchiveBelowMin: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText("Auto-archive period in days");
const archiveForm = input.closest("form");
if (!(archiveForm instanceof HTMLFormElement)) {
throw new Error("Expected auto-archive input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "0");
const saveButton = within(archiveForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument();
});
},
};
export const AutoArchiveSaveError: Story = {
args: {
autoArchiveDaysData: { auto_archive_days: 90 },
isSaveAutoArchiveDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save auto-archive setting."),
).toBeInTheDocument();
},
};
export const AutoArchiveLoadError: Story = {
args: {
autoArchiveDaysData: undefined,
isAutoArchiveDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable auto-archive",
});
expect(toggle).not.toBeChecked();
expect(canvas.queryByLabelText("Auto-archive period in days")).toBeNull();
expect(
await canvas.findByText("Failed to load auto-archive setting."),
).toBeInTheDocument();
},
};
// --- Retention Stories ---
export const RetentionToggleOnSavesDefault: Story = {
args: {
retentionDaysData: { retention_days: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable conversation retention",
});
expect(toggle).not.toBeChecked();
const retentionForm = toggle.closest("form");
if (!(retentionForm instanceof HTMLFormElement)) {
throw new Error("Expected retention toggle to live inside a form.");
}
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveRetentionDays).toHaveBeenNthCalledWith(
1,
{ retention_days: 30 },
expect.anything(),
);
});
const retentionInput = await within(retentionForm).findByLabelText(
"Conversation retention period in days",
);
expect(retentionInput).toHaveValue(30);
const saveButton = await within(retentionForm).findByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveRetentionDays).toHaveBeenNthCalledWith(
2,
{ retention_days: 30 },
expect.anything(),
);
});
},
};
export const RetentionToggleOffSavesDisabled: Story = {
args: {
retentionDaysData: { retention_days: 30 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable conversation retention",
});
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveRetentionDays).toHaveBeenCalledWith(
{ retention_days: 0 },
expect.anything(),
);
});
},
};
export const RetentionSaveError: Story = {
args: {
retentionDaysData: { retention_days: 30 },
isSaveRetentionDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save retention setting."),
).toBeInTheDocument();
},
};
export const RetentionLoadError: Story = {
args: {
isRetentionDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to load retention setting."),
).toBeInTheDocument();
},
};
export const RetentionExceedsMax: Story = {
args: {
retentionDaysData: { retention_days: 30 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const retentionInput = await canvas.findByLabelText(
"Conversation retention period in days",
);
const retentionForm = retentionInput.closest("form");
if (!(retentionForm instanceof HTMLFormElement)) {
throw new Error("Expected retention period input to live inside a form.");
}
await userEvent.clear(retentionInput);
await userEvent.type(retentionInput, "9999");
const saveButton = within(retentionForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(retentionInput).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(
canvas.getByText(/must not exceed 3650 days/i),
).toBeInTheDocument();
});
},
};
export const RetentionBelowMin: Story = {
args: {
retentionDaysData: { retention_days: 30 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const retentionInput = await canvas.findByLabelText(
"Conversation retention period in days",
);
const retentionForm = retentionInput.closest("form");
if (!(retentionForm instanceof HTMLFormElement)) {
throw new Error("Expected retention period input to live inside a form.");
}
await userEvent.clear(retentionInput);
await userEvent.type(retentionInput, "0");
const saveButton = within(retentionForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(retentionInput).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument();
});
},
};
export const DebugRetentionLoadedDefault: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Chat debug data retention");
await canvas.findByText(/debug runs and debug steps/i);
await canvas.findByText(/does not control chat message retention/i);
const toggle = await canvas.findByRole("switch", {
name: "Enable chat debug data retention",
});
expect(toggle).toBeChecked();
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
expect(input).toHaveValue(30);
},
};
export const DebugRetentionToggleOffSavesDisabled: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 30 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable chat debug data retention",
});
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith(
{ debug_retention_days: 0 },
expect.anything(),
);
});
},
};
export const DebugRetentionToggleOnSavesDefault: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable chat debug data retention",
});
expect(toggle).not.toBeChecked();
const debugRetentionForm = toggle.closest("form");
if (!(debugRetentionForm instanceof HTMLFormElement)) {
throw new Error("Expected debug retention toggle to live inside a form.");
}
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveDebugRetentionDays).toHaveBeenNthCalledWith(
1,
{ debug_retention_days: 30 },
expect.anything(),
);
});
const input = await within(debugRetentionForm).findByLabelText(
"Chat debug data retention period in days",
);
expect(input).toHaveValue(30);
},
};
export const DebugRetentionEditDaysAndSave: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 30 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const debugRetentionForm = input.closest("form");
if (!(debugRetentionForm instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "14");
const saveButton = within(debugRetentionForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith(
{ debug_retention_days: 14 },
expect.anything(),
);
});
},
};
export const DebugRetentionExceedsMax: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 30 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const debugRetentionForm = input.closest("form");
if (!(debugRetentionForm instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "3651");
const saveButton = within(debugRetentionForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(
canvas.getByText(/must not exceed 3650 days/i),
).toBeInTheDocument();
});
},
};
export const DebugRetentionBelowMin: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 30 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByLabelText(
"Chat debug data retention period in days",
);
const debugRetentionForm = input.closest("form");
if (!(debugRetentionForm instanceof HTMLFormElement)) {
throw new Error("Expected debug retention input to live inside a form.");
}
await userEvent.clear(input);
await userEvent.type(input, "0");
const saveButton = within(debugRetentionForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(input).toBeInvalid();
expect(saveButton).toBeDisabled();
});
await userEvent.tab();
await waitFor(() => {
expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument();
});
},
};
export const DebugRetentionSaveError: Story = {
args: {
debugRetentionDaysData: { debug_retention_days: 30 },
isSaveDebugRetentionDaysError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Failed to save chat debug retention setting."),
).toBeInTheDocument();
},
};
export const DebugRetentionLoadError: Story = {
args: {
debugRetentionDaysData: undefined,
isDebugRetentionDaysLoadError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable chat debug data retention",
});
expect(toggle).toBeChecked();
expect(
await canvas.findByLabelText("Chat debug data retention period in days"),
).toHaveValue(30);
expect(
await canvas.findByText("Failed to load chat debug retention setting."),
).toBeInTheDocument();
},
};
@@ -34,7 +34,6 @@ import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage";
import AgentSettingsExperimentsPage from "./AgentSettingsExperimentsPage";
import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage";
import AgentSettingsInstructionsPage from "./AgentSettingsInstructionsPage";
import AgentSettingsLifecyclePage from "./AgentSettingsLifecyclePage";
import AgentSettingsPage from "./AgentSettingsPage";
import AgentSettingsSpendPage from "./AgentSettingsSpendPage";
import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView";
@@ -211,7 +210,10 @@ const agentsRouting = {
element: <AgentSettingsInstructionsPage />,
},
{ path: "experiments", element: <AgentSettingsExperimentsPage /> },
{ path: "lifecycle", element: <AgentSettingsLifecyclePage /> },
{
path: "lifecycle",
element: <Navigate to="/ai/settings/lifecycle" replace />,
},
{ path: "admin", element: <AgentsRouteElement /> },
{ path: "agents", element: <AgentsRouteElement /> },
{ path: "spend", element: <AgentSettingsSpendPage now={fixedNow} /> },
@@ -1,190 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { DefaultChatAutoArchiveDays } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface AutoArchiveSettingsProps {
autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined;
isAutoArchiveDaysLoading: boolean;
isAutoArchiveDaysLoadError: boolean;
onSaveAutoArchiveDays: (
req: TypesGen.UpdateChatAutoArchiveDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingAutoArchiveDays: boolean;
isSaveAutoArchiveDaysError: boolean;
}
// Keep in sync with autoArchiveDaysMaximum in coderd/exp_chats.go.
const validationSchema = Yup.object({
auto_archive_days: Yup.number()
.integer("Auto-archive days must be a whole number.")
.min(1, "Auto-archive period must be at least 1 day.")
.max(3650, "Must not exceed 3650 days (~10 years).")
.required("Auto-archive days is required."),
});
// Sensible default offered when an admin enables auto-archive for
// the first time. Distinct from the server default (0 = disabled).
const ENABLE_DEFAULT_DAYS = 90;
export const AutoArchiveSettings: FC<AutoArchiveSettingsProps> = ({
autoArchiveDaysData,
isAutoArchiveDaysLoading,
isAutoArchiveDaysLoadError,
onSaveAutoArchiveDays,
isSavingAutoArchiveDays,
isSaveAutoArchiveDaysError,
}) => {
const [archiveToggled, setArchiveToggled] = useState<boolean | null>(null);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverAutoArchiveDays =
autoArchiveDaysData?.auto_archive_days ?? DefaultChatAutoArchiveDays;
const isAutoArchiveEnabled = archiveToggled ?? serverAutoArchiveDays > 0;
const form = useFormik({
initialValues: { auto_archive_days: serverAutoArchiveDays },
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveAutoArchiveDays(
{ auto_archive_days: values.auto_archive_days },
{
onSuccess: () => {
showSavedState();
setArchiveToggled(null);
helpers.resetForm();
},
},
);
},
});
const resetArchiveState = () => {
setArchiveToggled(null);
form.resetForm();
};
const handleToggleAutoArchive = (checked: boolean) => {
if (checked) {
const days =
serverAutoArchiveDays > 0 ? serverAutoArchiveDays : ENABLE_DEFAULT_DAYS;
setArchiveToggled(true);
void form.setFieldValue("auto_archive_days", days);
onSaveAutoArchiveDays(
{ auto_archive_days: days },
{
onSuccess: resetArchiveState,
onError: resetArchiveState,
},
);
} else {
setArchiveToggled(false);
void form.setFieldValue("auto_archive_days", 0);
onSaveAutoArchiveDays(
{ auto_archive_days: 0 },
{
onSuccess: resetArchiveState,
onError: resetArchiveState,
},
);
}
};
return (
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Auto-archive inactive conversations
</h3>
</div>
<Switch
checked={isAutoArchiveEnabled}
onCheckedChange={handleToggleAutoArchive}
aria-label="Enable auto-archive"
disabled={isSavingAutoArchiveDays || isAutoArchiveDaysLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Inactive conversations are automatically archived after this period.
Pinned conversations are exempt.
</p>
{isAutoArchiveEnabled && (
<>
<div className="flex gap-2">
<Input
type="number"
name="auto_archive_days"
min={1}
max={3650}
step={1}
aria-label="Auto-archive period in days"
value={form.values.auto_archive_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
aria-invalid={Boolean(form.errors.auto_archive_days)}
disabled={isSavingAutoArchiveDays || isAutoArchiveDaysLoading}
className="flex-1"
/>
<span className="flex h-10 w-[120px] items-center px-3 text-sm text-content-secondary">
Days
</span>
</div>
{form.errors.auto_archive_days && form.touched.auto_archive_days && (
<p className="m-0 text-xs text-content-destructive">
{form.errors.auto_archive_days}
</p>
)}
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingAutoArchiveDays) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingAutoArchiveDays ||
!form.dirty ||
Boolean(form.errors.auto_archive_days)
}
>
{isSavingAutoArchiveDays && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
</>
)}
{isSaveAutoArchiveDaysError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save auto-archive setting.
</p>
)}
{isAutoArchiveDaysLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load auto-archive setting.
</p>
)}
</form>
);
};
@@ -215,9 +215,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={RefreshCwIcon}
label="Lifecycle"
active={settingsSection === "lifecycle"}
to="/agents/settings/lifecycle"
state={location.state}
active={false}
to="/ai/settings/lifecycle"
trailingIcon={ArrowUpRightIcon}
/>
</nav>
)}
@@ -1,195 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { DefaultChatDebugRetentionDays } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface DebugRetentionSettingsProps {
debugRetentionDaysData: TypesGen.ChatDebugRetentionDaysResponse | undefined;
isDebugRetentionDaysLoading: boolean;
isDebugRetentionDaysLoadError: boolean;
onSaveDebugRetentionDays: (
req: TypesGen.UpdateChatDebugRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingDebugRetentionDays: boolean;
isSaveDebugRetentionDaysError: boolean;
}
// Keep in sync with chatDebugRetentionDaysMaximum in coderd/exp_chats.go.
const validationSchema = Yup.object({
debug_retention_days: Yup.number()
.integer("Debug retention days must be a whole number.")
.min(1, "Debug retention period must be at least 1 day.")
.max(3650, "Must not exceed 3650 days (~10 years).")
.required("Debug retention days is required."),
});
export const DebugRetentionSettings: FC<DebugRetentionSettingsProps> = ({
debugRetentionDaysData,
isDebugRetentionDaysLoading,
isDebugRetentionDaysLoadError,
onSaveDebugRetentionDays,
isSavingDebugRetentionDays,
isSaveDebugRetentionDaysError,
}) => {
const [debugRetentionToggled, setDebugRetentionToggled] = useState<
boolean | null
>(null);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverDebugRetentionDays =
debugRetentionDaysData?.debug_retention_days ??
DefaultChatDebugRetentionDays;
const isDebugRetentionEnabled =
debugRetentionToggled ?? serverDebugRetentionDays > 0;
const form = useFormik({
initialValues: { debug_retention_days: serverDebugRetentionDays },
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveDebugRetentionDays(
{ debug_retention_days: values.debug_retention_days },
{
onSuccess: () => {
showSavedState();
setDebugRetentionToggled(null);
helpers.resetForm();
},
},
);
},
});
const resetDebugRetentionState = () => {
setDebugRetentionToggled(null);
form.resetForm();
};
const handleToggleDebugRetention = (checked: boolean) => {
if (checked) {
const days =
serverDebugRetentionDays > 0
? serverDebugRetentionDays
: DefaultChatDebugRetentionDays;
setDebugRetentionToggled(true);
void form.setFieldValue("debug_retention_days", days);
onSaveDebugRetentionDays(
{ debug_retention_days: days },
{
onSuccess: resetDebugRetentionState,
onError: resetDebugRetentionState,
},
);
} else {
setDebugRetentionToggled(false);
void form.setFieldValue("debug_retention_days", 0);
onSaveDebugRetentionDays(
{ debug_retention_days: 0 },
{
onSuccess: resetDebugRetentionState,
onError: resetDebugRetentionState,
},
);
}
};
return (
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Chat debug data retention
</h3>
</div>
<Switch
checked={isDebugRetentionEnabled}
onCheckedChange={handleToggleDebugRetention}
aria-label="Enable chat debug data retention"
disabled={isSavingDebugRetentionDays || isDebugRetentionDaysLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Chat debug runs and debug steps older than this are automatically
deleted. This does not control chat message retention.
</p>
{isDebugRetentionEnabled && (
<>
<div className="flex gap-2">
<Input
type="number"
name="debug_retention_days"
min={1}
max={3650}
step={1}
aria-label="Chat debug data retention period in days"
value={form.values.debug_retention_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
aria-invalid={Boolean(form.errors.debug_retention_days)}
disabled={
isSavingDebugRetentionDays || isDebugRetentionDaysLoading
}
className="flex-1"
/>
<span className="flex h-10 w-[120px] items-center px-3 text-sm text-content-secondary">
Days
</span>
</div>
{form.errors.debug_retention_days &&
form.touched.debug_retention_days && (
<p className="m-0 text-xs text-content-destructive">
{form.errors.debug_retention_days}
</p>
)}
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingDebugRetentionDays) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingDebugRetentionDays ||
!form.dirty ||
Boolean(form.errors.debug_retention_days)
}
>
{isSavingDebugRetentionDays && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
</>
)}
{isSaveDebugRetentionDaysError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save chat debug retention setting.
</p>
)}
{isDebugRetentionDaysLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load chat debug retention setting.
</p>
)}
</form>
);
};
@@ -6,12 +6,12 @@ import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
} from "#/components/TemporarySavedState/TemporarySavedState";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -1,185 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface RetentionPeriodSettingsProps {
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
isRetentionDaysLoading: boolean;
isRetentionDaysLoadError: boolean;
onSaveRetentionDays: (
req: TypesGen.UpdateChatRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
}
// Keep in sync with retentionDaysMaximum in coderd/exp_chats.go.
const validationSchema = Yup.object({
retention_days: Yup.number()
.integer("Retention days must be a whole number.")
.min(1, "Retention period must be at least 1 day.")
.max(3650, "Must not exceed 3650 days (~10 years).")
.required("Retention days is required."),
});
export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
retentionDaysData,
isRetentionDaysLoading,
isRetentionDaysLoadError,
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
}) => {
const [retentionToggled, setRetentionToggled] = useState<boolean | null>(
null,
);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverRetentionDays = retentionDaysData?.retention_days ?? 30;
const isRetentionEnabled = retentionToggled ?? serverRetentionDays > 0;
const form = useFormik({
initialValues: { retention_days: serverRetentionDays },
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveRetentionDays(
{ retention_days: values.retention_days },
{
onSuccess: () => {
showSavedState();
setRetentionToggled(null);
helpers.resetForm();
},
},
);
},
});
const resetRetentionState = () => {
setRetentionToggled(null);
form.resetForm();
};
const handleToggleRetention = (checked: boolean) => {
if (checked) {
const days = serverRetentionDays > 0 ? serverRetentionDays : 30;
setRetentionToggled(true);
void form.setFieldValue("retention_days", days);
onSaveRetentionDays(
{ retention_days: days },
{
onSuccess: resetRetentionState,
onError: resetRetentionState,
},
);
} else {
setRetentionToggled(false);
void form.setFieldValue("retention_days", 0);
onSaveRetentionDays(
{ retention_days: 0 },
{
onSuccess: resetRetentionState,
onError: resetRetentionState,
},
);
}
};
return (
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Conversation retention period
</h3>
</div>
<Switch
checked={isRetentionEnabled}
onCheckedChange={handleToggleRetention}
aria-label="Enable conversation retention"
disabled={isSavingRetentionDays || isRetentionDaysLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Archived conversations and orphaned files older than this are
automatically deleted.
</p>
{isRetentionEnabled && (
<>
<div className="flex gap-2">
<Input
type="number"
name="retention_days"
min={1}
max={3650}
step={1}
aria-label="Conversation retention period in days"
value={form.values.retention_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
aria-invalid={Boolean(form.errors.retention_days)}
disabled={isSavingRetentionDays || isRetentionDaysLoading}
className="flex-1"
/>
<span className="flex h-10 w-[120px] items-center px-3 text-sm text-content-secondary">
Days
</span>
</div>
{form.errors.retention_days && form.touched.retention_days && (
<p className="m-0 text-xs text-content-destructive">
{form.errors.retention_days}
</p>
)}
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingRetentionDays) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingRetentionDays ||
!form.dirty ||
Boolean(form.errors.retention_days)
}
>
{isSavingRetentionDays && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
</>
)}
{isSaveRetentionDaysError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save retention setting.
</p>
)}
{isRetentionDaysLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load retention setting.
</p>
)}
</form>
);
};
@@ -7,12 +7,12 @@ import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
} from "#/components/TemporarySavedState/TemporarySavedState";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { TextPreviewDialog } from "./TextPreviewDialog";
interface MutationCallbacks {
@@ -15,6 +15,10 @@ import {
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
TemporarySavedState,
useTemporarySavedState,
} from "#/components/TemporarySavedState/TemporarySavedState";
import {
Tooltip,
TooltipContent,
@@ -22,10 +26,6 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { ProviderIcon } from "./ChatModelAdminPanel/ProviderIcon";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface UserCompactionThresholdSettingsProps {
modelConfigs: readonly TypesGen.ChatModelConfig[];
@@ -1,182 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { DurationField } from "./DurationField/DurationField";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface WorkspaceAutostopSettingsProps {
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
onSaveWorkspaceTTL: (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
options?: MutationCallbacks,
) => void;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
}
const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days
export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
}) => {
// ── Toggle state (fires immediate mutations, not a form submit) ──
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
// ── Derived state ──
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0;
// ── Form (for editing the TTL value) ──
const validationSchema = Yup.object({
workspace_ttl_ms: Yup.number()
.required()
.when([], {
is: () => isAutostopEnabled,
then: (schema) =>
schema.moreThan(0, "Duration must be greater than zero."),
})
.max(maxTTLMs, "Must not exceed 30 days (720 hours)."),
});
const form = useFormik({
initialValues: { workspace_ttl_ms: serverTTLMs },
enableReinitialize: true,
validationSchema,
onSubmit: (values, helpers) => {
onSaveWorkspaceTTL(
{ workspace_ttl_ms: values.workspace_ttl_ms },
{
onSuccess: () => {
showSavedState();
setAutostopToggled(null);
helpers.resetForm();
},
onError: () => setAutostopToggled(null),
},
);
},
});
// ── Handlers ──
const resetAutostopState = () => {
setAutostopToggled(null);
form.resetForm();
};
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);
void form.setFieldValue("workspace_ttl_ms", defaultTTL);
onSaveWorkspaceTTL(
{ workspace_ttl_ms: defaultTTL },
{ onSuccess: resetAutostopState, onError: resetAutostopState },
);
} else {
setAutostopToggled(false);
void form.setFieldValue("workspace_ttl_ms", 0);
onSaveWorkspaceTTL(
{ workspace_ttl_ms: 0 },
{ onSuccess: resetAutostopState, onError: resetAutostopState },
);
}
};
const handleTTLChange = (value: number) => {
void form.setFieldValue("workspace_ttl_ms", value);
// Latch the toggle open while the user is editing
// so a background refetch cannot unmount the field.
if (autostopToggled === null) {
setAutostopToggled(true);
}
};
const fieldError = form.errors.workspace_ttl_ms;
return (
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Workspace autostop fallback
</h3>
</div>
<Switch
checked={isAutostopEnabled}
onCheckedChange={handleToggleAutostop}
aria-label="Enable default autostop"
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
/>
</div>
<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 conversations will extend the stop time.
</p>
{isAutostopEnabled && (
<DurationField
valueMs={form.values.workspace_ttl_ms}
onChange={handleTTLChange}
label="Autostop fallback"
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
error={Boolean(fieldError)}
helperText={fieldError}
/>
)}
{isAutostopEnabled && (
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingWorkspaceTTL) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingWorkspaceTTL || !form.dirty || Boolean(fieldError)
}
>
{isSavingWorkspaceTTL && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
)}
{isSaveWorkspaceTTLError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save autostop setting.
</p>
)}
{isWorkspaceTTLLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load autostop setting.
</p>
)}
</form>
);
};
+7 -3
View File
@@ -377,8 +377,8 @@ const AgentSettingsInstructionsPage = lazy(
const AgentSettingsExperimentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsExperimentsPage"),
);
const AgentSettingsLifecyclePage = lazy(
() => import("./pages/AgentsPage/AgentSettingsLifecyclePage"),
const AISettingsLifecyclePage = lazy(
() => import("./pages/AISettingsPage/LifecyclePage/LifecyclePage"),
);
const AgentSettingsAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAgentsPage"),
@@ -755,6 +755,7 @@ export const router = createBrowserRouter(
/>
<Route index element={<AISettingsIndexPage />} />
<Route path="models" element={<AISettingsModelsPage />} />
<Route path="lifecycle" element={<AISettingsLifecyclePage />} />
<Route path="templates" element={<AISettingsTemplatesPage />} />
<Route path="models/add" element={<AISettingsAddModelPage />} />
<Route
@@ -834,7 +835,10 @@ export const router = createBrowserRouter(
path="experiments"
element={<AgentSettingsExperimentsPage />}
/>
<Route path="lifecycle" element={<AgentSettingsLifecyclePage />} />
<Route
path="lifecycle"
element={<Navigate to="/ai/settings/lifecycle" replace />}
/>
<Route
path="user-agents"
element={<AgentSettingsUserAgentsPage />}