feat: add admin UI control for chat auto-archive days (#24704)

Relates to #24642 

Adds admin UI controls for managing chat auto-archive (days) under
"Lifecycle".
Also adds a "Days" label to the right of the pre-existing unitless
numeric input for consistency.

Exemplary screenshot below. More screens available in Storybook.

<img width="847" height="585" alt="Screenshot 2026-04-24 at 16 48 59"
src="https://github.com/user-attachments/assets/d38de5f8-d379-4b06-b175-ac399f31e578"
/>
This commit is contained in:
Cian Johnston
2026-04-27 09:54:22 +01:00
committed by GitHub
parent 069223ae26
commit 2f26903af9
8 changed files with 556 additions and 14 deletions
+2 -2
View File
@@ -4360,8 +4360,8 @@ func (api *API) getChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request)
})
}
// Upper bound for the auto-archive window. Update the frontend
// constant if a settings UI is added.
// Upper bound for the auto-archive window. Keep in sync with
// the validation schema in site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx.
const autoArchiveDaysMaximum = 3650 // ~10 years
// putChatAutoArchiveDays updates the deployment-wide auto-archive
+18
View File
@@ -3379,6 +3379,24 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/retention-days", req);
};
getChatAutoArchiveDays =
async (): Promise<TypesGen.ChatAutoArchiveDaysResponse> => {
const response =
await this.axios.get<TypesGen.ChatAutoArchiveDaysResponse>(
"/api/experimental/chats/config/auto-archive-days",
);
return response.data;
};
updateChatAutoArchiveDays = async (
req: TypesGen.UpdateChatAutoArchiveDaysRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/auto-archive-days",
req,
);
};
updateChatTemplateAllowlist = async (
req: TypesGen.ChatTemplateAllowlist,
): Promise<void> => {
+16
View File
@@ -1351,6 +1351,22 @@ export const updateChatRetentionDays = (queryClient: QueryClient) => ({
},
});
const chatAutoArchiveDaysKey = ["chat-auto-archive-days"] as const;
export const chatAutoArchiveDays = () => ({
queryKey: chatAutoArchiveDaysKey,
queryFn: () => API.experimental.getChatAutoArchiveDays(),
});
export const updateChatAutoArchiveDays = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatAutoArchiveDays,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatAutoArchiveDaysKey,
});
},
});
const chatTemplateAllowlistKey = ["chat-template-allowlist"] as const;
export const chatTemplateAllowlist = () => ({
@@ -1,8 +1,10 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatAutoArchiveDays,
chatRetentionDays,
chatWorkspaceTTL,
updateChatAutoArchiveDays,
updateChatRetentionDays,
updateChatWorkspaceTTL,
} from "#/api/queries/chats";
@@ -21,12 +23,19 @@ const AgentSettingsLifecyclePage: FC = () => {
...chatRetentionDays(),
enabled: permissions.editDeploymentConfig,
});
const autoArchiveDaysQuery = useQuery({
...chatAutoArchiveDays(),
enabled: permissions.editDeploymentConfig,
});
const saveWorkspaceTTLMutation = useMutation(
updateChatWorkspaceTTL(queryClient),
);
const saveRetentionDaysMutation = useMutation(
updateChatRetentionDays(queryClient),
);
const saveAutoArchiveDaysMutation = useMutation(
updateChatAutoArchiveDays(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
@@ -43,6 +52,12 @@ const AgentSettingsLifecyclePage: FC = () => {
onSaveRetentionDays={saveRetentionDaysMutation.mutate}
isSavingRetentionDays={saveRetentionDaysMutation.isPending}
isSaveRetentionDaysError={saveRetentionDaysMutation.isError}
autoArchiveDaysData={autoArchiveDaysQuery.data}
isAutoArchiveDaysLoading={autoArchiveDaysQuery.isLoading}
isAutoArchiveDaysLoadError={autoArchiveDaysQuery.isError}
onSaveAutoArchiveDays={saveAutoArchiveDaysMutation.mutate}
isSavingAutoArchiveDays={saveAutoArchiveDaysMutation.isPending}
isSaveAutoArchiveDaysError={saveAutoArchiveDaysMutation.isError}
/>
</RequirePermission>
);
@@ -18,6 +18,12 @@ const baseArgs: AgentSettingsLifecyclePageViewProps = {
onSaveRetentionDays: fn(),
isSavingRetentionDays: false,
isSaveRetentionDaysError: false,
autoArchiveDaysData: { auto_archive_days: 0 },
isAutoArchiveDaysLoading: false,
isAutoArchiveDaysLoadError: false,
onSaveAutoArchiveDays: fn(),
isSavingAutoArchiveDays: false,
isSaveAutoArchiveDaysError: false,
};
const meta = {
@@ -233,6 +239,231 @@ export const DefaultAutostopToggleOffFailure: Story = {
},
};
// --- 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 },
@@ -352,5 +583,44 @@ export const RetentionExceedsMax: Story = {
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();
});
},
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { AutoArchiveSettings } from "./components/AutoArchiveSettings";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings";
@@ -28,6 +29,17 @@ export interface AgentSettingsLifecyclePageViewProps {
>;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined;
isAutoArchiveDaysLoading: boolean;
isAutoArchiveDaysLoadError: boolean;
onSaveAutoArchiveDays: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatAutoArchiveDaysRequest,
unknown
>;
isSavingAutoArchiveDays: boolean;
isSaveAutoArchiveDaysError: boolean;
}
export const AgentSettingsLifecyclePageView: FC<
@@ -45,6 +57,12 @@ export const AgentSettingsLifecyclePageView: FC<
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
autoArchiveDaysData,
isAutoArchiveDaysLoading,
isAutoArchiveDaysLoadError,
onSaveAutoArchiveDays,
isSavingAutoArchiveDays,
isSaveAutoArchiveDaysError,
}) => {
return (
<div className="flex flex-col gap-8">
@@ -60,6 +78,14 @@ export const AgentSettingsLifecyclePageView: FC<
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
/>
<AutoArchiveSettings
autoArchiveDaysData={autoArchiveDaysData}
isAutoArchiveDaysLoading={isAutoArchiveDaysLoading}
isAutoArchiveDaysLoadError={isAutoArchiveDaysLoadError}
onSaveAutoArchiveDays={onSaveAutoArchiveDays}
isSavingAutoArchiveDays={isSavingAutoArchiveDays}
isSaveAutoArchiveDaysError={isSaveAutoArchiveDaysError}
/>
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
@@ -0,0 +1,190 @@
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>
);
};
@@ -123,18 +123,25 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
</p>
{isRetentionEnabled && (
<>
<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}
disabled={isSavingRetentionDays || isRetentionDaysLoading}
className="w-full"
/>
<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}