diff --git a/site/src/pages/AgentsPage/components/TemporarySavedState.tsx b/site/src/components/TemporarySavedState/TemporarySavedState.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/TemporarySavedState.tsx rename to site/src/components/TemporarySavedState/TemporarySavedState.tsx diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 872eeaac34..89fa6fa3ea 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -35,6 +35,11 @@ const AISettingsSidebarView: FC = ({ {permissions.editDeploymentConfig && ( Models )} + {permissions.editDeploymentConfig && ( + + Lifecycle + + )} {permissions.editDeploymentConfig && ( Templates diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePage.tsx similarity index 92% rename from site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx rename to site/src/pages/AISettingsPage/LifecyclePage/LifecyclePage.tsx index 26a1f60389..f4b48c9f61 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx +++ b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePage.tsx @@ -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 ( - {pageTitle("Lifecycle", "AI Settings")} + { ); }; -export default AgentSettingsLifecyclePage; +export default LifecyclePage; diff --git a/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.stories.tsx b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.stories.tsx new file mode 100644 index 0000000000..89912c0ab4 --- /dev/null +++ b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.tsx similarity index 54% rename from site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx rename to site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.tsx index 699b4e658b..63c44d6781 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx +++ b/site/src/pages/AISettingsPage/LifecyclePage/LifecyclePageView.tsx @@ -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 = ({ workspaceTTLData, isWorkspaceTTLLoading, isWorkspaceTTLLoadError, @@ -83,43 +85,47 @@ export const AgentSettingsLifecyclePageView: FC< isSaveAutoArchiveDaysError, }) => { return ( -
- - - - - +
+ + Lifecycle + + Control workspace lifecycle and conversation retention. + + +
+ + + + +
); }; diff --git a/site/src/pages/AISettingsPage/LifecyclePage/components/AutoArchiveSettings.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/AutoArchiveSettings.tsx new file mode 100644 index 0000000000..b51886fd25 --- /dev/null +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/AutoArchiveSettings.tsx @@ -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 = ({ + 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 ( + 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 && ( +

{fieldError}

+ )} + {isSaveAutoArchiveDaysError && ( +

Failed to save auto-archive setting.

+ )} + {isAutoArchiveDaysLoadError && ( +

Failed to load auto-archive setting.

+ )} + + ) : undefined + } + > + +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/LifecyclePage/components/DebugRetentionSettings.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/DebugRetentionSettings.tsx new file mode 100644 index 0000000000..83289bb542 --- /dev/null +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/DebugRetentionSettings.tsx @@ -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 = ({ + 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 ( + 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 && ( +

{fieldError}

+ )} + {isSaveDebugRetentionDaysError && ( +

+ Failed to save chat debug retention setting. +

+ )} + {isDebugRetentionDaysLoadError && ( +

+ Failed to load chat debug retention setting. +

+ )} + + ) : undefined + } + > + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.stories.tsx similarity index 97% rename from site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx rename to site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.stories.tsx index 040f2cdb7e..813e1222c6 100644 --- a/site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.stories.tsx @@ -4,7 +4,7 @@ import { expect, userEvent, within } from "storybook/test"; import { DurationField } from "./DurationField"; const meta: Meta = { - title: "pages/AgentsPage/DurationField", + title: "pages/AISettingsPage/LifecyclePage/DurationField", component: DurationField, args: { label: "Duration", diff --git a/site/src/pages/AgentsPage/components/DurationField/DurationField.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.tsx similarity index 92% rename from site/src/pages/AgentsPage/components/DurationField/DurationField.tsx rename to site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.tsx index 78f34e2eef..b6c09b0713 100644 --- a/site/src/pages/AgentsPage/components/DurationField/DurationField.tsx +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/DurationField/DurationField.tsx @@ -100,14 +100,17 @@ export const DurationField: FC = ({ 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" /> + + Days + + + ); +}; diff --git a/site/src/pages/AISettingsPage/LifecyclePage/components/RetentionPeriodSettings.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/RetentionPeriodSettings.tsx new file mode 100644 index 0000000000..f4a14d910b --- /dev/null +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/RetentionPeriodSettings.tsx @@ -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 = ({ + 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 ( + 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 && ( +

{fieldError}

+ )} + {isSaveRetentionDaysError && ( +

+ Failed to save conversation retention setting. +

+ )} + {isRetentionDaysLoadError && ( +

+ Failed to load conversation retention setting. +

+ )} + + ) : undefined + } + > + +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/LifecyclePage/components/WorkspaceAutostopSettings.tsx b/site/src/pages/AISettingsPage/LifecyclePage/components/WorkspaceAutostopSettings.tsx new file mode 100644 index 0000000000..98f768829c --- /dev/null +++ b/site/src/pages/AISettingsPage/LifecyclePage/components/WorkspaceAutostopSettings.tsx @@ -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 = ({ + 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 ( + + {/* 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 &&

{fieldError}

} + {isSaveWorkspaceTTLError && ( +

Failed to save autostop setting.

+ )} + {isWorkspaceTTLLoadError && ( +

Failed to load autostop setting.

+ )} + + ) : undefined + } + > + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx deleted file mode 100644 index 638ac04cd4..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx +++ /dev/null @@ -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; - -export default meta; -type Story = StoryObj; - -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(); - }, -}; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index f085144bd7..2e844f4f75 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -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: , }, { path: "experiments", element: }, - { path: "lifecycle", element: }, + { + path: "lifecycle", + element: , + }, { path: "admin", element: }, { path: "agents", element: }, { path: "spend", element: }, diff --git a/site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx b/site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx deleted file mode 100644 index 9718df9577..0000000000 --- a/site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx +++ /dev/null @@ -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 = ({ - autoArchiveDaysData, - isAutoArchiveDaysLoading, - isAutoArchiveDaysLoadError, - onSaveAutoArchiveDays, - isSavingAutoArchiveDays, - isSaveAutoArchiveDaysError, -}) => { - const [archiveToggled, setArchiveToggled] = useState(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 ( -
-
-
-

- Auto-archive inactive conversations -

-
- -
-

- Inactive conversations are automatically archived after this period. - Pinned conversations are exempt. -

- {isAutoArchiveEnabled && ( - <> -
- - - Days - -
- {form.errors.auto_archive_days && form.touched.auto_archive_days && ( -

- {form.errors.auto_archive_days} -

- )} -
- {(form.dirty || isSavedVisible || isSavingAutoArchiveDays) && - (isSavedVisible ? ( - - ) : ( - - ))} -
- - )} - {isSaveAutoArchiveDaysError && ( -

- Failed to save auto-archive setting. -

- )} - {isAutoArchiveDaysLoadError && ( -

- Failed to load auto-archive setting. -

- )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index bf395defd8..f1234990aa 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -215,9 +215,9 @@ export const SettingsPanel: FC = ({ )} diff --git a/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx b/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx deleted file mode 100644 index 5bcc04e784..0000000000 --- a/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
-
-
-

- Chat debug data retention -

-
- -
-

- Chat debug runs and debug steps older than this are automatically - deleted. This does not control chat message retention. -

- {isDebugRetentionEnabled && ( - <> -
- - - Days - -
- {form.errors.debug_retention_days && - form.touched.debug_retention_days && ( -

- {form.errors.debug_retention_days} -

- )} -
- {(form.dirty || isSavedVisible || isSavingDebugRetentionDays) && - (isSavedVisible ? ( - - ) : ( - - ))} -
- - )} - {isSaveDebugRetentionDaysError && ( -

- Failed to save chat debug retention setting. -

- )} - {isDebugRetentionDaysLoadError && ( -

- Failed to load chat debug retention setting. -

- )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/PersonalInstructionsSettings.tsx b/site/src/pages/AgentsPage/components/PersonalInstructionsSettings.tsx index 4b8d2f746d..0cd331f65c 100644 --- a/site/src/pages/AgentsPage/components/PersonalInstructionsSettings.tsx +++ b/site/src/pages/AgentsPage/components/PersonalInstructionsSettings.tsx @@ -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; diff --git a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx b/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx deleted file mode 100644 index 48facdaa89..0000000000 --- a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx +++ /dev/null @@ -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 = ({ - retentionDaysData, - isRetentionDaysLoading, - isRetentionDaysLoadError, - onSaveRetentionDays, - isSavingRetentionDays, - isSaveRetentionDaysError, -}) => { - const [retentionToggled, setRetentionToggled] = useState( - 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 ( -
-
-
-

- Conversation retention period -

-
- -
-

- Archived conversations and orphaned files older than this are - automatically deleted. -

- {isRetentionEnabled && ( - <> -
- - - Days - -
- {form.errors.retention_days && form.touched.retention_days && ( -

- {form.errors.retention_days} -

- )} -
- {(form.dirty || isSavedVisible || isSavingRetentionDays) && - (isSavedVisible ? ( - - ) : ( - - ))} -
- - )} - {isSaveRetentionDaysError && ( -

- Failed to save retention setting. -

- )} - {isRetentionDaysLoadError && ( -

- Failed to load retention setting. -

- )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx b/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx index e204e87127..de1a6bb0eb 100644 --- a/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx +++ b/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx @@ -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 { diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx index aebfce5a9d..70deedf17b 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -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[]; diff --git a/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx b/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx deleted file mode 100644 index 3920a05d2f..0000000000 --- a/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx +++ /dev/null @@ -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 = ({ - workspaceTTLData, - isWorkspaceTTLLoading, - isWorkspaceTTLLoadError, - onSaveWorkspaceTTL, - isSavingWorkspaceTTL, - isSaveWorkspaceTTLError, -}) => { - // ── Toggle state (fires immediate mutations, not a form submit) ── - const [autostopToggled, setAutostopToggled] = useState(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 ( -
-
-
-

- Workspace autostop fallback -

-
- -
-

- 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. -

- {isAutostopEnabled && ( - - )} - {isAutostopEnabled && ( -
- {(form.dirty || isSavedVisible || isSavingWorkspaceTTL) && - (isSavedVisible ? ( - - ) : ( - - ))} -
- )} - {isSaveWorkspaceTTLError && ( -

- Failed to save autostop setting. -

- )} - {isWorkspaceTTLLoadError && ( -

- Failed to load autostop setting. -

- )} - - ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 636aec3084..b9dbfef44c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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( /> } /> } /> + } /> } /> } /> } /> - } /> + } + /> }