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