refactor(site/src): split Agent Settings Behavior into focused destinations (#24574)

Reorganizes Agents Settings navigation. Previously a flat sidebar with
admin items gated by a role check; now a two-level drill-down with user
settings at the top and admin destinations nested under a "Manage
Agents" sub-panel.

**Top Settings panel** (all users, sidebar title "Settings"):

| Destination | Route |
| --- | --- |
| General | `/agents/settings/general` |
| Compaction | `/agents/settings/compaction` |
| Secrets (API keys) | `/agents/settings/api-keys` |
| Manage Agents › (admin only) | drills into the admin sub-panel |

**Manage Agents sub-panel** (admin only, sidebar title "Manage Agents"):

| Destination | Route |
| --- | --- |
| Agents | `/agents/settings/agents` |
| Providers | `/agents/settings/providers` |
| Models | `/agents/settings/models` |
| MCP Servers | `/agents/settings/mcp-servers` |
| Templates | `/agents/settings/templates` |
| Spend | `/agents/settings/spend` |
| Instructions | `/agents/settings/instructions` |
| Experiments | `/agents/settings/experiments` |
| Lifecycle | `/agents/settings/lifecycle` |
| Insights | `/agents/settings/insights` |

On mobile, tapping "Manage Agents" lands on `/agents/settings/admin`, an
admin sub-panel index URL that shows the admin nav in the sidebar (so
admins can still reach every admin destination without desktop-width
viewports).

Key changes:

- **Split the monolithic Behavior page into five focused destinations**
(General, Compaction, Instructions, Experiments, Lifecycle) so non-admin
users no longer trigger deployment-scoped queries like
`chatSystemPrompt`, `chatDesktopEnabled`, or `chatWorkspaceTTL`.
Admin-only pages gate both route (via `RequirePermission`) and query
`enabled` flags.
- **Split chat debug logging into audience-specific components** so no
admin-gated controls remain in user-facing pages.
`AdminChatDebugLoggingSettings` (admin "Let users record chat debug
logs") now lives in the Experiments tab; `UserChatDebugLoggingSettings`
("Record debug logs for my chats") stays in General and only renders
when the admin has allowed user-level toggling.
- **Nested admin sub-panel** in the sidebar. `SidebarView` gains a
`"settings-admin"` panel; `sidebarViewFromPath` routes admin sections
into it. The slide animation and back button behavior extend cleanly. A
small `isSettingsView` helper was extracted alongside to avoid
duplicating the panel-membership check.
- **Renamed `/agents/settings/system-instructions` to
`/agents/settings/instructions`**. Sidebar label is "Instructions". Page
files renamed to `AgentSettingsInstructionsPage(View)` to match the
route slug (the other split pages all do).
- **Renamed "API Keys" to "Secrets (API keys)"** in the sidebar and page
header.
- **Added MCP Servers** entry to the sidebar (route already existed).
- **Added "Manage Coder Agents"** link at the bottom of the Deployment
settings sidebar (gated by `editDeploymentConfig`, matches the existing
`Groups ↗` external-link style).
- **Updated icons** across the sidebar: General uses `UserIcon`,
Compaction `ShrinkIcon`, Secrets `KeyIcon`, Manage Agents
`Settings2Icon`, Providers `PlugIcon`, MCP Servers `ServerIcon`, Spend
`CoinsIcon`, Instructions `ReceiptTextIcon`, Lifecycle `RefreshCwIcon`,
Insights `SparklesIcon`.
- **Storybook interaction coverage** restored and extended for the split
views: user-prompt save flow, invisible-Unicode warning detection,
system-prompt default toggle, workspace-TTL validation, virtual-desktop
toggle, compaction threshold save/reset/validation, retention
toggle/save-error/load-error parity, plan-mode instructions save, and a
mobile story verifying the admin sub-panel remains reachable after the
"Manage Agents" tap.
- **Unit tests** added for `sidebarViewFromPath` and `isSettingsView`
(17 cases covering chats, analytics, user sections, admin sections, the
new `/admin` index, non-admin fallthrough, and defaults).

> Mux opened this PR on behalf of Mike.
This commit is contained in:
Michael Suchacz
2026-04-22 17:45:29 +02:00
committed by GitHub
parent e00e85765b
commit 60186b2489
49 changed files with 2085 additions and 1301 deletions
@@ -106,6 +106,13 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
{!hasPremiumLicense && (
<SidebarNavItem href="/deployment/premium">Premium</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/agents/settings/agents">
<Stack direction="row" alignItems="center" spacing={0.5}>
Manage Coder Agents <ArrowUpRightIcon size={16} />
</Stack>
</SidebarNavItem>
)}
</div>
</BaseSidebar>
);
@@ -249,7 +249,7 @@ export const AgentSettingsAPIKeysPageView: FC<
<div>
<section className="flex flex-col gap-8">
<SectionHeader
label="Personal API Keys"
label="Secrets (API keys)"
description="Add a personal API key for each provider. Your personal key takes precedence over the shared deployment key when both are available."
/>
<div>
@@ -1,6 +1,5 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { AdminBadge } from "./components/AdminBadge";
import { ExploreModelOverrideSettings } from "./components/ExploreModelOverrideSettings";
import { SectionHeader } from "./components/SectionHeader";
@@ -40,7 +39,6 @@ export const AgentSettingsAgentsPageView: FC<
<SectionHeader
label="Agents"
description="Configure defaults for delegated agents and other agent-specific capabilities."
badge={<AdminBadge />}
/>
<div className="flex flex-col gap-3">
<SectionHeader
@@ -1,153 +0,0 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDebugLogging,
chatDesktopEnabled,
chatModelConfigs,
chatPlanModeInstructions,
chatRetentionDays,
chatSystemPrompt,
chatUserCustomPrompt,
chatWorkspaceTTL,
deleteUserCompactionThreshold,
updateChatDebugLogging,
updateChatDesktopEnabled,
updateChatPlanModeInstructions,
updateChatRetentionDays,
updateChatSystemPrompt,
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
updateUserChatDebugLogging,
updateUserCompactionThreshold,
userChatDebugLogging,
userCompactionThresholds,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
const AgentSettingsBehaviorPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const systemPromptQuery = useQuery({
...chatSystemPrompt(),
enabled: permissions.editDeploymentConfig,
});
const saveSystemPromptMutation = useMutation(
updateChatSystemPrompt(queryClient),
);
const planModeInstructionsQuery = useQuery({
...chatPlanModeInstructions(),
enabled: permissions.editDeploymentConfig,
});
const savePlanModeInstructionsMutation = useMutation(
updateChatPlanModeInstructions(queryClient),
);
const userPromptQuery = useQuery(chatUserCustomPrompt());
const saveUserPromptMutation = useMutation(
updateUserChatCustomPrompt(queryClient),
);
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const saveDesktopEnabledMutation = useMutation(
updateChatDesktopEnabled(queryClient),
);
const debugLoggingQuery = useQuery({
...chatDebugLogging(),
enabled: permissions.editDeploymentConfig,
});
const saveDebugLoggingMutation = useMutation(
updateChatDebugLogging(queryClient),
);
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
const saveUserDebugLoggingMutation = useMutation(
updateUserChatDebugLogging(queryClient),
);
const workspaceTTLQuery = useQuery(chatWorkspaceTTL());
const saveWorkspaceTTLMutation = useMutation(
updateChatWorkspaceTTL(queryClient),
);
const retentionDaysQuery = useQuery(chatRetentionDays());
const saveRetentionDaysMutation = useMutation(
updateChatRetentionDays(queryClient),
);
const modelConfigsQuery = useQuery(chatModelConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
const saveThresholdMutation = useMutation(
updateUserCompactionThreshold(queryClient),
);
const resetThresholdMutation = useMutation(
deleteUserCompactionThreshold(queryClient),
);
const handleSaveThreshold = (
modelConfigId: string,
thresholdPercent: number,
) =>
saveThresholdMutation.mutateAsync({
modelConfigId,
req: { threshold_percent: thresholdPercent },
});
const handleResetThreshold = (modelConfigId: string) =>
resetThresholdMutation.mutateAsync(modelConfigId);
return (
<AgentSettingsBehaviorPageView
canSetSystemPrompt={permissions.editDeploymentConfig}
systemPromptData={systemPromptQuery.data}
planModeInstructionsData={planModeInstructionsQuery.data}
userPromptData={userPromptQuery.data}
desktopEnabledData={desktopEnabledQuery.data}
debugLoggingData={debugLoggingQuery.data}
userDebugLoggingData={userDebugLoggingQuery.data}
workspaceTTLData={workspaceTTLQuery.data}
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
modelConfigsData={modelConfigsQuery.data}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
thresholds={thresholdsQuery.data?.thresholds}
isThresholdsLoading={thresholdsQuery.isLoading}
thresholdsError={thresholdsQuery.error}
onSaveThreshold={handleSaveThreshold}
onResetThreshold={handleResetThreshold}
onSaveSystemPrompt={saveSystemPromptMutation.mutate}
isSavingSystemPrompt={saveSystemPromptMutation.isPending}
isSaveSystemPromptError={saveSystemPromptMutation.isError}
onSavePlanModeInstructions={savePlanModeInstructionsMutation.mutate}
isSavingPlanModeInstructions={savePlanModeInstructionsMutation.isPending}
isSavePlanModeInstructionsError={savePlanModeInstructionsMutation.isError}
onSaveUserPrompt={saveUserPromptMutation.mutate}
isSavingUserPrompt={saveUserPromptMutation.isPending}
isSaveUserPromptError={saveUserPromptMutation.isError}
onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate}
isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending}
isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError}
onSaveDebugLogging={saveDebugLoggingMutation.mutate}
isSavingDebugLogging={saveDebugLoggingMutation.isPending}
isSaveDebugLoggingError={saveDebugLoggingMutation.isError}
onSaveUserDebugLogging={saveUserDebugLoggingMutation.mutate}
isSavingUserDebugLogging={saveUserDebugLoggingMutation.isPending}
isSaveUserDebugLoggingError={saveUserDebugLoggingMutation.isError}
onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate}
isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending}
isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError}
retentionDaysData={retentionDaysQuery.data}
isRetentionDaysLoading={retentionDaysQuery.isLoading}
isRetentionDaysLoadError={retentionDaysQuery.isError}
onSaveRetentionDays={saveRetentionDaysMutation.mutate}
isSavingRetentionDays={saveRetentionDaysMutation.isPending}
isSaveRetentionDaysError={saveRetentionDaysMutation.isError}
/>
);
};
export default AgentSettingsBehaviorPage;
@@ -1,490 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
const mockDefaultSystemPrompt = "You are Coder, an AI coding assistant...";
// Baseline props shared across stories. Only primitives and simple
// objects here to avoid the composeStory deep-merge hang (see vault
// entry storybook-composestory-hang).
const baseProps = {
canSetSystemPrompt: true as boolean,
systemPromptData: {
system_prompt: "",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
} as TypesGen.ChatSystemPromptResponse,
planModeInstructionsData: {
plan_mode_instructions: "",
} as TypesGen.ChatPlanModeInstructionsResponse,
userPromptData: { custom_prompt: "" } as TypesGen.UserChatCustomPrompt,
desktopEnabledData: {
enable_desktop: false,
} as TypesGen.ChatDesktopEnabledResponse,
debugLoggingData: {
allow_users: false,
forced_by_deployment: false,
} as TypesGen.ChatDebugLoggingAdminSettings,
userDebugLoggingData: {
debug_logging_enabled: false,
user_toggle_allowed: false,
forced_by_deployment: false,
} as TypesGen.UserChatDebugLoggingSettings,
workspaceTTLData: {
workspace_ttl_ms: 0,
} as TypesGen.ChatWorkspaceTTLResponse,
isWorkspaceTTLLoading: false,
isWorkspaceTTLLoadError: false,
retentionDaysData: {
retention_days: 30,
} as TypesGen.ChatRetentionDaysResponse,
isRetentionDaysLoading: false,
isRetentionDaysLoadError: false,
modelConfigsData: [] as TypesGen.ChatModelConfig[],
modelConfigsError: undefined as unknown,
isLoadingModelConfigs: false,
thresholds: [] as readonly TypesGen.UserChatCompactionThreshold[],
isThresholdsLoading: false,
thresholdsError: undefined as unknown,
isSavingSystemPrompt: false,
isSaveSystemPromptError: false,
isSavingPlanModeInstructions: false,
isSavePlanModeInstructionsError: false,
isSavingUserPrompt: false,
isSaveUserPromptError: false,
isSavingDesktopEnabled: false,
isSaveDesktopEnabledError: false,
isSavingDebugLogging: false,
isSaveDebugLoggingError: false,
isSavingUserDebugLogging: false,
isSaveUserDebugLoggingError: false,
isSavingWorkspaceTTL: false,
isSaveWorkspaceTTLError: false,
isSavingRetentionDays: false,
isSaveRetentionDaysError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsBehaviorPageView",
component: AgentSettingsBehaviorPageView,
args: {
...baseProps,
onSaveSystemPrompt: fn(),
onSavePlanModeInstructions: fn(),
onSaveUserPrompt: fn(),
onSaveDesktopEnabled: fn(),
onSaveDebugLogging: fn(),
onSaveUserDebugLogging: fn(),
onSaveWorkspaceTTL: fn(),
onSaveRetentionDays: fn(),
onSaveThreshold: fn(),
onResetThreshold: fn(),
},
} satisfies Meta<typeof AgentSettingsBehaviorPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsBehaviorPageView>;
// ── Desktop ────────────────────────────────────────────────────
export const DesktopSetting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Virtual Desktop");
await canvas.findByText(
/Allow agents to use a virtual, graphical desktop/i,
);
await canvas.findByRole("switch", { name: "Enable" });
},
};
export const TogglesDesktop: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", { name: "Enable" });
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveDesktopEnabled).toHaveBeenCalledWith({
enable_desktop: true,
});
});
},
};
// ── System prompt ──────────────────────────────────────────────
export const AdminWithDefaultToggleOn: Story = {
args: {
systemPromptData: {
system_prompt: "Always use TypeScript for code examples.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).toBeChecked();
expect(
await canvas.findByDisplayValue(
"Always use TypeScript for code examples.",
),
).toBeInTheDocument();
expect(
canvas.getByText(/built-in Coder Agents prompt is prepended/i),
).toBeInTheDocument();
// Preview dialog opens and closes.
await userEvent.click(canvas.getByRole("button", { name: "Preview" }));
expect(await body.findByText("Default System Prompt")).toBeInTheDocument();
expect(body.getByText(mockDefaultSystemPrompt)).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(body.queryByText("Default System Prompt")).not.toBeInTheDocument();
});
// Toggle off include_default and save.
await userEvent.click(toggle);
const promptForm = canvas
.getByDisplayValue("Always use TypeScript for code examples.")
.closest("form")!;
const saveButton = within(promptForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
},
};
export const AdminWithDefaultToggleOff: Story = {
args: {
systemPromptData: {
system_prompt: "You are a custom assistant.",
include_default_system_prompt: false,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).not.toBeChecked();
expect(
await canvas.findByDisplayValue("You are a custom assistant."),
).toBeInTheDocument();
expect(
canvas.getByText(/only the additional instructions below are used/i),
).toBeInTheDocument();
},
};
// ── Autostop ───────────────────────────────────────────────────
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);
// Toggle ON — fires immediate save with 1h default.
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");
// Change to 3 hours.
await userEvent.clear(durationInput);
await userEvent.type(durationInput, "3");
const ttlForm = durationInput.closest("form")!;
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
// Clearing back to the original value hides Save (pristine form).
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")!;
// 721 hours exceeds the 30-day / 720h limit.
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")!;
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(),
);
});
// Error message should be visible.
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(),
);
});
// Error message should be visible.
expect(
canvas.getByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
export const DefaultAutostopNotVisibleToNonAdmin: Story = {
args: {
canSetSystemPrompt: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Personal Instructions should be visible.
await canvas.findByText("Personal Instructions");
// Admin-only sections should not be present.
expect(canvas.queryByText("Workspace Autostop Fallback")).toBeNull();
expect(canvas.queryByText("Virtual Desktop")).toBeNull();
expect(canvas.queryByText("System Instructions")).toBeNull();
},
};
// ── Invisible Unicode warnings ─────────────────────────────────
export const InvisibleUnicodeWarningSystemPrompt: Story = {
args: {
systemPromptData: {
system_prompt:
"Normal prompt text\u200b\u200b\u200b\u200bhidden instruction",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("System Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("4");
},
};
export const InvisibleUnicodeWarningUserPrompt: Story = {
args: {
userPromptData: {
custom_prompt: "My custom prompt\u200b\u200c\u200dhidden",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Personal Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("2");
},
};
export const InvisibleUnicodeWarningOnType: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textarea = await canvas.findByPlaceholderText(
"Additional behavior, style, and tone preferences",
);
// No warning initially.
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
// Type a string containing a ZWS character.
await userEvent.type(textarea, "hello\u200bworld");
await waitFor(() => {
expect(canvas.getByText(/invisible Unicode/)).toBeInTheDocument();
});
},
};
export const NoWarningForCleanPrompt: Story = {
args: {
systemPromptData: {
system_prompt: "You are a helpful coding assistant.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
userPromptData: {
custom_prompt: "Be concise and use TypeScript.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Personal Instructions");
await canvas.findByText("System Instructions");
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
},
};
@@ -1,239 +0,0 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import { DebugLoggingSettings } from "./components/DebugLoggingSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
import { PlanModeInstructionsSettings } from "./components/PlanModeInstructionsSettings";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
import { SystemInstructionsSettings } from "./components/SystemInstructionsSettings";
import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings";
import { VirtualDesktopSettings } from "./components/VirtualDesktopSettings";
import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface AgentSettingsBehaviorPageViewProps {
canSetSystemPrompt: boolean;
// Raw query data
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
planModeInstructionsData:
| TypesGen.ChatPlanModeInstructionsResponse
| undefined;
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined;
userDebugLoggingData: TypesGen.UserChatDebugLoggingSettings | undefined;
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
isRetentionDaysLoading: boolean;
isRetentionDaysLoadError: boolean;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
// Thresholds (passed through to child component)
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
isThresholdsLoading: boolean;
thresholdsError: unknown;
onSaveThreshold: (
modelConfigId: string,
thresholdPercent: number,
) => Promise<unknown>;
onResetThreshold: (modelConfigId: string) => Promise<unknown>;
// Mutation handlers
onSaveSystemPrompt: (
req: TypesGen.UpdateChatSystemPromptRequest,
options?: MutationCallbacks,
) => void;
isSavingSystemPrompt: boolean;
isSaveSystemPromptError: boolean;
onSavePlanModeInstructions: (
req: TypesGen.UpdateChatPlanModeInstructionsRequest,
options?: MutationCallbacks,
) => void;
isSavingPlanModeInstructions: boolean;
isSavePlanModeInstructionsError: boolean;
onSaveUserPrompt: (
req: TypesGen.UserChatCustomPrompt,
options?: MutationCallbacks,
) => void;
isSavingUserPrompt: boolean;
isSaveUserPromptError: boolean;
onSaveDesktopEnabled: (
req: TypesGen.UpdateChatDesktopEnabledRequest,
options?: MutationCallbacks,
) => void;
isSavingDesktopEnabled: boolean;
isSaveDesktopEnabledError: boolean;
onSaveDebugLogging: (
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
options?: MutationCallbacks,
) => void;
isSavingDebugLogging: boolean;
isSaveDebugLoggingError: boolean;
onSaveUserDebugLogging: (
req: TypesGen.UpdateUserChatDebugLoggingRequest,
options?: MutationCallbacks,
) => void;
isSavingUserDebugLogging: boolean;
isSaveUserDebugLoggingError: boolean;
onSaveWorkspaceTTL: (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
options?: MutationCallbacks,
) => void;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
onSaveRetentionDays: (
req: TypesGen.UpdateChatRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
}
export const AgentSettingsBehaviorPageView: FC<
AgentSettingsBehaviorPageViewProps
> = ({
canSetSystemPrompt,
systemPromptData,
planModeInstructionsData,
userPromptData,
desktopEnabledData,
debugLoggingData,
userDebugLoggingData,
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
retentionDaysData,
isRetentionDaysLoading,
isRetentionDaysLoadError,
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
thresholds,
isThresholdsLoading,
thresholdsError,
onSaveThreshold,
onResetThreshold,
onSaveSystemPrompt,
isSavingSystemPrompt,
isSaveSystemPromptError,
onSavePlanModeInstructions,
isSavingPlanModeInstructions,
isSavePlanModeInstructionsError,
onSaveUserPrompt,
isSavingUserPrompt,
isSaveUserPromptError,
onSaveDesktopEnabled,
isSavingDesktopEnabled,
isSaveDesktopEnabledError,
onSaveDebugLogging,
isSavingDebugLogging,
isSaveDebugLoggingError,
onSaveUserDebugLogging,
isSavingUserDebugLogging,
isSaveUserDebugLoggingError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
}) => {
const isAnyPromptSaving =
isSavingSystemPrompt || isSavingUserPrompt || isSavingPlanModeInstructions;
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic."
/>
<PersonalInstructionsSettings
userPromptData={userPromptData}
onSaveUserPrompt={onSaveUserPrompt}
isSavingUserPrompt={isSavingUserPrompt}
isSaveUserPromptError={isSaveUserPromptError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<ChatFullWidthSettings />
<DebugLoggingSettings
canManageAdminSetting={canSetSystemPrompt}
adminSettings={debugLoggingData}
userSettings={userDebugLoggingData}
onSaveAdminSetting={onSaveDebugLogging}
isSavingAdminSetting={isSavingDebugLogging}
isSaveAdminSettingError={isSaveDebugLoggingError}
onSaveUserSetting={onSaveUserDebugLogging}
isSavingUserSetting={isSavingUserDebugLogging}
isSaveUserSettingError={isSaveUserDebugLoggingError}
/>
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
thresholds={thresholds}
isThresholdsLoading={isThresholdsLoading}
thresholdsError={thresholdsError}
onSaveThreshold={onSaveThreshold}
onResetThreshold={onResetThreshold}
/>
{/* ── Admin-only settings ── */}
{canSetSystemPrompt && (
<>
<SystemInstructionsSettings
systemPromptData={systemPromptData}
onSaveSystemPrompt={onSaveSystemPrompt}
isSavingSystemPrompt={isSavingSystemPrompt}
isSaveSystemPromptError={isSaveSystemPromptError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<PlanModeInstructionsSettings
planModeInstructionsData={planModeInstructionsData}
onSavePlanModeInstructions={onSavePlanModeInstructions}
isSavePlanModeInstructionsError={isSavePlanModeInstructionsError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<VirtualDesktopSettings
desktopEnabledData={desktopEnabledData}
onSaveDesktopEnabled={onSaveDesktopEnabled}
isSavingDesktopEnabled={isSavingDesktopEnabled}
isSaveDesktopEnabledError={isSaveDesktopEnabledError}
/>
<WorkspaceAutostopSettings
workspaceTTLData={workspaceTTLData}
isWorkspaceTTLLoading={isWorkspaceTTLLoading}
isWorkspaceTTLLoadError={isWorkspaceTTLLoadError}
onSaveWorkspaceTTL={onSaveWorkspaceTTL}
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
/>
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
isRetentionDaysLoadError={isRetentionDaysLoadError}
onSaveRetentionDays={onSaveRetentionDays}
isSavingRetentionDays={isSavingRetentionDays}
isSaveRetentionDaysError={isSaveRetentionDaysError}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,48 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatModelConfigs,
deleteUserCompactionThreshold,
updateUserCompactionThreshold,
userCompactionThresholds,
} from "#/api/queries/chats";
import { AgentSettingsCompactionPageView } from "./AgentSettingsCompactionPageView";
const AgentSettingsCompactionPage: FC = () => {
const queryClient = useQueryClient();
const modelConfigsQuery = useQuery(chatModelConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
const saveThresholdMutation = useMutation(
updateUserCompactionThreshold(queryClient),
);
const resetThresholdMutation = useMutation(
deleteUserCompactionThreshold(queryClient),
);
const handleSaveThreshold = (
modelConfigId: string,
thresholdPercent: number,
) =>
saveThresholdMutation.mutateAsync({
modelConfigId,
req: { threshold_percent: thresholdPercent },
});
const handleResetThreshold = (modelConfigId: string) =>
resetThresholdMutation.mutateAsync(modelConfigId);
return (
<AgentSettingsCompactionPageView
modelConfigsData={modelConfigsQuery.data}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
thresholds={thresholdsQuery.data?.thresholds}
isThresholdsLoading={thresholdsQuery.isLoading}
thresholdsError={thresholdsQuery.error}
onSaveThreshold={handleSaveThreshold}
onResetThreshold={handleResetThreshold}
/>
);
};
export default AgentSettingsCompactionPage;
@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import {
AgentSettingsCompactionPageView,
type AgentSettingsCompactionPageViewProps,
} from "./AgentSettingsCompactionPageView";
const baseArgs: AgentSettingsCompactionPageViewProps = {
modelConfigsData: [
{
id: "model-config-1",
provider: "openai",
model: "gpt-4.1-mini",
display_name: "GPT 4.1 Mini",
enabled: true,
is_default: false,
context_limit: 1_000_000,
compression_threshold: 70,
created_at: "2026-03-12T12:00:00.000Z",
updated_at: "2026-03-12T12:00:00.000Z",
},
] as TypesGen.ChatModelConfig[],
modelConfigsError: undefined,
isLoadingModelConfigs: false,
thresholds: [
{
model_config_id: "model-config-1",
threshold_percent: 60,
},
],
isThresholdsLoading: false,
thresholdsError: undefined,
onSaveThreshold: fn(async () => undefined),
onResetThreshold: fn(async () => undefined),
};
const meta = {
title: "pages/AgentsPage/AgentSettingsCompactionPageView",
component: AgentSettingsCompactionPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsCompactionPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsCompactionPageView>;
export const Default: Story = {};
export const SavesThreshold: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const thresholdInput = await canvas.findByLabelText(
"GPT 4.1 Mini compaction threshold",
);
await userEvent.clear(thresholdInput);
await userEvent.type(thresholdInput, "80");
const saveButton = await canvas.findByRole("button", {
name: "Save 1 change",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveThreshold).toHaveBeenCalledWith("model-config-1", 80);
});
},
};
export const ResetsThreshold: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const resetButton = await canvas.findByLabelText(
"Reset GPT 4.1 Mini to default",
);
await waitFor(() => {
expect(resetButton).toBeEnabled();
});
await userEvent.click(resetButton);
await waitFor(() => {
expect(args.onResetThreshold).toHaveBeenCalledWith("model-config-1");
});
},
};
export const InvalidThresholdIsRejected: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thresholdInput = await canvas.findByLabelText(
"GPT 4.1 Mini compaction threshold",
);
await userEvent.clear(thresholdInput);
await userEvent.type(thresholdInput, "150");
await waitFor(() => {
expect(thresholdInput).toHaveAttribute("aria-invalid", "true");
expect(
canvas.queryByRole("button", { name: /save \d+ changes?/i }),
).toBeNull();
});
},
};
@@ -0,0 +1,50 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { SectionHeader } from "./components/SectionHeader";
import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings";
export interface AgentSettingsCompactionPageViewProps {
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
isThresholdsLoading: boolean;
thresholdsError: unknown;
onSaveThreshold: (
modelConfigId: string,
thresholdPercent: number,
) => Promise<unknown>;
onResetThreshold: (modelConfigId: string) => Promise<unknown>;
}
export const AgentSettingsCompactionPageView: FC<
AgentSettingsCompactionPageViewProps
> = ({
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
thresholds,
isThresholdsLoading,
thresholdsError,
onSaveThreshold,
onResetThreshold,
}) => {
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Compaction"
description="Customize when conversations with models are automatically compacted."
/>
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
thresholds={thresholds}
isThresholdsLoading={isThresholdsLoading}
thresholdsError={thresholdsError}
onSaveThreshold={onSaveThreshold}
onResetThreshold={onResetThreshold}
/>
</div>
);
};
@@ -0,0 +1,47 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDebugLogging,
chatDesktopEnabled,
updateChatDebugLogging,
updateChatDesktopEnabled,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsExperimentsPageView } from "./AgentSettingsExperimentsPageView";
const AgentSettingsExperimentsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const desktopEnabledQuery = useQuery({
...chatDesktopEnabled(),
enabled: permissions.editDeploymentConfig,
});
const debugLoggingQuery = useQuery({
...chatDebugLogging(),
enabled: permissions.editDeploymentConfig,
});
const saveDesktopEnabledMutation = useMutation(
updateChatDesktopEnabled(queryClient),
);
const saveDebugLoggingMutation = useMutation(
updateChatDebugLogging(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsExperimentsPageView
desktopEnabledData={desktopEnabledQuery.data}
onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate}
isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending}
isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError}
debugLoggingData={debugLoggingQuery.data}
onSaveDebugLogging={saveDebugLoggingMutation.mutate}
isSavingDebugLogging={saveDebugLoggingMutation.isPending}
isSaveDebugLoggingError={saveDebugLoggingMutation.isError}
/>
</RequirePermission>
);
};
export default AgentSettingsExperimentsPage;
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
AgentSettingsExperimentsPageView,
type AgentSettingsExperimentsPageViewProps,
} from "./AgentSettingsExperimentsPageView";
const baseArgs: AgentSettingsExperimentsPageViewProps = {
desktopEnabledData: { enable_desktop: false },
onSaveDesktopEnabled: fn(),
isSavingDesktopEnabled: false,
isSaveDesktopEnabledError: false,
debugLoggingData: {
allow_users: false,
forced_by_deployment: false,
},
onSaveDebugLogging: fn(),
isSavingDebugLogging: false,
isSaveDebugLoggingError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsExperimentsPageView",
component: AgentSettingsExperimentsPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsExperimentsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsExperimentsPageView>;
export const AllowUsersOff: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Allow users to enable chat debug logging",
});
expect(
await canvas.findByText("Let users record chat debug logs"),
).toBeInTheDocument();
expect(toggle).not.toBeChecked();
},
};
export const AllowUsersOn: Story = {
args: {
debugLoggingData: {
allow_users: true,
forced_by_deployment: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Allow users to enable chat debug logging",
});
expect(toggle).toBeChecked();
},
};
export const ForcedByDeployment: Story = {
args: {
debugLoggingData: {
allow_users: true,
forced_by_deployment: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Allow users to enable chat debug logging",
});
expect(toggle).toBeDisabled();
expect(
await canvas.findByText(
/Debug logging is already enabled deployment-wide/i,
),
).toBeInTheDocument();
},
};
export const DesktopSetting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Virtual Desktop");
await canvas.findByText(
/Allow agents to use a virtual, graphical desktop within workspaces./i,
);
await canvas.findByRole("switch", { name: "Enable" });
},
};
export const TogglesDesktop: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", { name: "Enable" });
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveDesktopEnabled).toHaveBeenCalledWith({
enable_desktop: true,
});
});
},
};
@@ -0,0 +1,61 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { AdminChatDebugLoggingSettings } from "./components/AdminChatDebugLoggingSettings";
import { SectionHeader } from "./components/SectionHeader";
import { VirtualDesktopSettings } from "./components/VirtualDesktopSettings";
export interface AgentSettingsExperimentsPageViewProps {
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
onSaveDesktopEnabled: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatDesktopEnabledRequest,
unknown
>;
isSavingDesktopEnabled: boolean;
isSaveDesktopEnabledError: boolean;
debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined;
onSaveDebugLogging: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
unknown
>;
isSavingDebugLogging: boolean;
isSaveDebugLoggingError: boolean;
}
export const AgentSettingsExperimentsPageView: FC<
AgentSettingsExperimentsPageViewProps
> = ({
desktopEnabledData,
onSaveDesktopEnabled,
isSavingDesktopEnabled,
isSaveDesktopEnabledError,
debugLoggingData,
onSaveDebugLogging,
isSavingDebugLogging,
isSaveDebugLoggingError,
}) => {
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Experiments"
description="Opt in to experimental features."
/>
<VirtualDesktopSettings
desktopEnabledData={desktopEnabledData}
onSaveDesktopEnabled={onSaveDesktopEnabled}
isSavingDesktopEnabled={isSavingDesktopEnabled}
isSaveDesktopEnabledError={isSaveDesktopEnabledError}
/>
<AdminChatDebugLoggingSettings
adminSettings={debugLoggingData}
onSaveAdminSetting={onSaveDebugLogging}
isSavingAdminSetting={isSavingDebugLogging}
isSaveAdminSettingError={isSaveDebugLoggingError}
/>
</div>
);
};
@@ -0,0 +1,36 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatUserCustomPrompt,
updateUserChatCustomPrompt,
updateUserChatDebugLogging,
userChatDebugLogging,
} from "#/api/queries/chats";
import { AgentSettingsGeneralPageView } from "./AgentSettingsGeneralPageView";
const AgentSettingsGeneralPage: FC = () => {
const queryClient = useQueryClient();
const userPromptQuery = useQuery(chatUserCustomPrompt());
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
const saveUserPromptMutation = useMutation(
updateUserChatCustomPrompt(queryClient),
);
const saveUserDebugLoggingMutation = useMutation(
updateUserChatDebugLogging(queryClient),
);
return (
<AgentSettingsGeneralPageView
userPromptData={userPromptQuery.data}
onSaveUserPrompt={saveUserPromptMutation.mutate}
isSavingUserPrompt={saveUserPromptMutation.isPending}
isSaveUserPromptError={saveUserPromptMutation.isError}
userDebugLoggingData={userDebugLoggingQuery.data}
onSaveUserDebugLogging={saveUserDebugLoggingMutation.mutate}
isSavingUserDebugLogging={saveUserDebugLoggingMutation.isPending}
isSaveUserDebugLoggingError={saveUserDebugLoggingMutation.isError}
/>
);
};
export default AgentSettingsGeneralPage;
@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
AgentSettingsGeneralPageView,
type AgentSettingsGeneralPageViewProps,
} from "./AgentSettingsGeneralPageView";
const baseArgs: AgentSettingsGeneralPageViewProps = {
userPromptData: {
custom_prompt: "Prefer concise answers with clear next steps.",
},
onSaveUserPrompt: fn(),
isSavingUserPrompt: false,
isSaveUserPromptError: false,
userDebugLoggingData: {
debug_logging_enabled: false,
user_toggle_allowed: false,
forced_by_deployment: false,
},
onSaveUserDebugLogging: fn(),
isSavingUserDebugLogging: false,
isSaveUserDebugLoggingError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsGeneralPageView",
component: AgentSettingsGeneralPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsGeneralPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsGeneralPageView>;
export const Default: Story = {};
export const InvisibleUnicodeWarningUserPrompt: Story = {
args: {
userPromptData: {
custom_prompt: "My custom prompt\u200b\u200c\u200dhidden",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Personal Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("2");
},
};
export const InvisibleUnicodeWarningOnType: Story = {
args: {
userPromptData: {
custom_prompt: "",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textarea = await canvas.findByPlaceholderText(
"Additional behavior, style, and tone preferences",
);
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
await userEvent.type(textarea, "hello\u200bworld");
await waitFor(() => {
expect(canvas.getByText(/invisible Unicode/)).toBeInTheDocument();
});
},
};
export const SavesUserPrompt: Story = {
args: {
userPromptData: {
custom_prompt: "",
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const textarea = await canvas.findByPlaceholderText(
"Additional behavior, style, and tone preferences",
);
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
await userEvent.type(
textarea,
"Prefer concise answers with clear next steps.",
);
const promptForm = textarea.closest("form");
if (!(promptForm instanceof HTMLFormElement)) {
throw new Error(
"Expected personal instructions textarea to live inside a form.",
);
}
const saveButton = within(promptForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveUserPrompt).toHaveBeenCalledWith(
{ custom_prompt: "Prefer concise answers with clear next steps." },
expect.anything(),
);
});
},
};
export const RendersChatLayoutSection: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Chat Layout")).toBeInTheDocument();
expect(
await canvas.findByRole("switch", { name: "Full-width chat" }),
).toBeInTheDocument();
},
};
export const ShowsChatDebugLoggingToggle: Story = {
args: {
userDebugLoggingData: {
debug_logging_enabled: false,
user_toggle_allowed: true,
forced_by_deployment: false,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable personal chat debug logging",
});
expect(
await canvas.findByText("Record debug logs for my chats"),
).toBeInTheDocument();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveUserDebugLogging).toHaveBeenCalledWith({
debug_logging_enabled: true,
});
});
},
};
export const HidesChatDebugLoggingToggle: Story = {
args: {
userDebugLoggingData: {
debug_logging_enabled: false,
user_toggle_allowed: false,
forced_by_deployment: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText("Record debug logs for my chats")).toBeNull();
expect(
canvas.queryByRole("switch", {
name: "Enable personal chat debug logging",
}),
).toBeNull();
},
};
@@ -0,0 +1,64 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
import { SectionHeader } from "./components/SectionHeader";
import { UserChatDebugLoggingSettings } from "./components/UserChatDebugLoggingSettings";
export interface AgentSettingsGeneralPageViewProps {
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
onSaveUserPrompt: UseMutateFunction<
TypesGen.UserChatCustomPrompt,
Error,
TypesGen.UserChatCustomPrompt,
unknown
>;
isSavingUserPrompt: boolean;
isSaveUserPromptError: boolean;
userDebugLoggingData: TypesGen.UserChatDebugLoggingSettings | undefined;
onSaveUserDebugLogging: UseMutateFunction<
void,
Error,
TypesGen.UpdateUserChatDebugLoggingRequest,
unknown
>;
isSavingUserDebugLogging: boolean;
isSaveUserDebugLoggingError: boolean;
}
export const AgentSettingsGeneralPageView: FC<
AgentSettingsGeneralPageViewProps
> = ({
userPromptData,
onSaveUserPrompt,
isSavingUserPrompt,
isSaveUserPromptError,
userDebugLoggingData,
onSaveUserDebugLogging,
isSavingUserDebugLogging,
isSaveUserDebugLoggingError,
}) => {
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="General"
description="Personal preferences for your chat experience."
/>
<PersonalInstructionsSettings
userPromptData={userPromptData}
onSaveUserPrompt={onSaveUserPrompt}
isSavingUserPrompt={isSavingUserPrompt}
isSaveUserPromptError={isSaveUserPromptError}
isAnyPromptSaving={isSavingUserPrompt}
/>
<ChatFullWidthSettings />
<UserChatDebugLoggingSettings
userSettings={userDebugLoggingData}
onSaveUserSetting={onSaveUserDebugLogging}
isSavingUserSetting={isSavingUserDebugLogging}
isSaveUserSettingError={isSaveUserDebugLoggingError}
/>
</div>
);
};
@@ -0,0 +1,52 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatPlanModeInstructions,
chatSystemPrompt,
updateChatPlanModeInstructions,
updateChatSystemPrompt,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsInstructionsPageView } from "./AgentSettingsInstructionsPageView";
const AgentSettingsInstructionsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const systemPromptQuery = useQuery({
...chatSystemPrompt(),
enabled: permissions.editDeploymentConfig,
});
const planModeInstructionsQuery = useQuery({
...chatPlanModeInstructions(),
enabled: permissions.editDeploymentConfig,
});
const saveSystemPromptMutation = useMutation(
updateChatSystemPrompt(queryClient),
);
const savePlanModeInstructionsMutation = useMutation(
updateChatPlanModeInstructions(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsInstructionsPageView
systemPromptData={systemPromptQuery.data}
planModeInstructionsData={planModeInstructionsQuery.data}
onSaveSystemPrompt={saveSystemPromptMutation.mutate}
isSavingSystemPrompt={saveSystemPromptMutation.isPending}
isSaveSystemPromptError={saveSystemPromptMutation.isError}
onSavePlanModeInstructions={savePlanModeInstructionsMutation.mutate}
isSavingPlanModeInstructions={
savePlanModeInstructionsMutation.isPending
}
isSavePlanModeInstructionsError={
savePlanModeInstructionsMutation.isError
}
/>
</RequirePermission>
);
};
export default AgentSettingsInstructionsPage;
@@ -0,0 +1,181 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
AgentSettingsInstructionsPageView,
type AgentSettingsInstructionsPageViewProps,
} from "./AgentSettingsInstructionsPageView";
const mockDefaultSystemPrompt = "You are Coder, an AI coding assistant.";
const baseArgs: AgentSettingsInstructionsPageViewProps = {
systemPromptData: {
system_prompt: "Always explain tradeoffs before proposing a change.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
planModeInstructionsData: {
plan_mode_instructions:
"Use a numbered checklist for implementation plans.",
},
onSaveSystemPrompt: fn(),
isSavingSystemPrompt: false,
isSaveSystemPromptError: false,
onSavePlanModeInstructions: fn(),
isSavingPlanModeInstructions: false,
isSavePlanModeInstructionsError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsInstructionsPageView",
component: AgentSettingsInstructionsPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsInstructionsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsInstructionsPageView>;
export const Default: Story = {};
export const AdminWithDefaultToggleOn: Story = {
args: {
systemPromptData: {
system_prompt: "Always use TypeScript for code examples.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).toBeChecked();
const promptInput = await canvas.findByDisplayValue(
"Always use TypeScript for code examples.",
);
expect(promptInput).toBeInTheDocument();
expect(
canvas.getByText(/built-in Coder Agents prompt is prepended/i),
).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Preview" }));
expect(await body.findByText("Default System Prompt")).toBeInTheDocument();
expect(body.getByText(mockDefaultSystemPrompt)).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(body.queryByText("Default System Prompt")).not.toBeInTheDocument();
});
await userEvent.click(toggle);
const promptForm = promptInput.closest("form");
if (!(promptForm instanceof HTMLFormElement)) {
throw new Error("Expected system prompt textarea to live inside a form.");
}
const saveButton = within(promptForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
},
};
export const AdminWithDefaultToggleOff: Story = {
args: {
systemPromptData: {
system_prompt: "You are a custom assistant.",
include_default_system_prompt: false,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).not.toBeChecked();
expect(
await canvas.findByDisplayValue("You are a custom assistant."),
).toBeInTheDocument();
expect(
canvas.getByText(/only the additional instructions below are used/i),
).toBeInTheDocument();
},
};
export const InvisibleUnicodeWarningSystemPrompt: Story = {
args: {
systemPromptData: {
system_prompt:
"Normal prompt text\u200b\u200b\u200b\u200bhidden instruction",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("System Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("4");
},
};
// The deleted combined story covered both prompt editors on one page. After
// the split, this story covers the system prompt half and the General page
// stories cover the personal instructions half.
export const NoWarningForCleanPrompt: Story = {
args: {
systemPromptData: {
system_prompt: "You are a helpful coding assistant.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("System Instructions");
await canvas.findByDisplayValue("You are a helpful coding assistant.");
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
},
};
export const SavesPlanModeInstructions: Story = {
args: {
planModeInstructionsData: { plan_mode_instructions: "" },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const textarea = await canvas.findByPlaceholderText(
"Additional instructions for planning mode",
);
await userEvent.clear(textarea);
await userEvent.type(textarea, "Always produce a concise plan first.");
const planModeForm = textarea.closest("form");
if (!(planModeForm instanceof HTMLFormElement)) {
throw new Error(
"Expected plan mode instructions textarea to live inside a form.",
);
}
const saveButton = within(planModeForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSavePlanModeInstructions).toHaveBeenCalledWith(
{ plan_mode_instructions: "Always produce a concise plan first." },
expect.anything(),
);
});
},
};
@@ -0,0 +1,67 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { PlanModeInstructionsSettings } from "./components/PlanModeInstructionsSettings";
import { SectionHeader } from "./components/SectionHeader";
import { SystemInstructionsSettings } from "./components/SystemInstructionsSettings";
export interface AgentSettingsInstructionsPageViewProps {
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
planModeInstructionsData:
| TypesGen.ChatPlanModeInstructionsResponse
| undefined;
onSaveSystemPrompt: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatSystemPromptRequest,
unknown
>;
isSavingSystemPrompt: boolean;
isSaveSystemPromptError: boolean;
onSavePlanModeInstructions: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatPlanModeInstructionsRequest,
unknown
>;
isSavingPlanModeInstructions: boolean;
isSavePlanModeInstructionsError: boolean;
}
export const AgentSettingsInstructionsPageView: FC<
AgentSettingsInstructionsPageViewProps
> = ({
systemPromptData,
planModeInstructionsData,
onSaveSystemPrompt,
isSavingSystemPrompt,
isSaveSystemPromptError,
onSavePlanModeInstructions,
isSavingPlanModeInstructions,
isSavePlanModeInstructionsError,
}) => {
const isAnyPromptSaving =
isSavingSystemPrompt || isSavingPlanModeInstructions;
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Instructions"
description="Control the system prompts and plan mode instructions used across the deployment."
/>
<SystemInstructionsSettings
systemPromptData={systemPromptData}
onSaveSystemPrompt={onSaveSystemPrompt}
isSavingSystemPrompt={isSavingSystemPrompt}
isSaveSystemPromptError={isSaveSystemPromptError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<PlanModeInstructionsSettings
planModeInstructionsData={planModeInstructionsData}
onSavePlanModeInstructions={onSavePlanModeInstructions}
isSavePlanModeInstructionsError={isSavePlanModeInstructionsError}
isAnyPromptSaving={isAnyPromptSaving}
/>
</div>
);
};
@@ -0,0 +1,51 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatRetentionDays,
chatWorkspaceTTL,
updateChatRetentionDays,
updateChatWorkspaceTTL,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsLifecyclePageView } from "./AgentSettingsLifecyclePageView";
const AgentSettingsLifecyclePage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const workspaceTTLQuery = useQuery({
...chatWorkspaceTTL(),
enabled: permissions.editDeploymentConfig,
});
const retentionDaysQuery = useQuery({
...chatRetentionDays(),
enabled: permissions.editDeploymentConfig,
});
const saveWorkspaceTTLMutation = useMutation(
updateChatWorkspaceTTL(queryClient),
);
const saveRetentionDaysMutation = useMutation(
updateChatRetentionDays(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsLifecyclePageView
workspaceTTLData={workspaceTTLQuery.data}
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate}
isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending}
isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError}
retentionDaysData={retentionDaysQuery.data}
isRetentionDaysLoading={retentionDaysQuery.isLoading}
isRetentionDaysLoadError={retentionDaysQuery.isError}
onSaveRetentionDays={saveRetentionDaysMutation.mutate}
isSavingRetentionDays={saveRetentionDaysMutation.isPending}
isSaveRetentionDaysError={saveRetentionDaysMutation.isError}
/>
</RequirePermission>
);
};
export default AgentSettingsLifecyclePage;
@@ -0,0 +1,356 @@
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,
};
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();
},
};
export const RetentionToggleOnSavesDefault: Story = {
args: {
retentionDaysData: { retention_days: 0 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: /retention/i,
});
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: /retention/i,
});
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();
});
},
};
@@ -0,0 +1,73 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings";
export interface AgentSettingsLifecyclePageViewProps {
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
onSaveWorkspaceTTL: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatWorkspaceTTLRequest,
unknown
>;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
isRetentionDaysLoading: boolean;
isRetentionDaysLoadError: boolean;
onSaveRetentionDays: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatRetentionDaysRequest,
unknown
>;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
}
export const AgentSettingsLifecyclePageView: FC<
AgentSettingsLifecyclePageViewProps
> = ({
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
retentionDaysData,
isRetentionDaysLoading,
isRetentionDaysLoadError,
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
}) => {
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}
/>
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
isRetentionDaysLoadError={isRetentionDaysLoadError}
onSaveRetentionDays={onSaveRetentionDays}
isSavingRetentionDays={isSavingRetentionDays}
isSaveRetentionDaysError={isSaveRetentionDaysError}
/>
</div>
);
};
@@ -8,7 +8,6 @@ import {
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
const AgentSettingsMCPServersPage: FC = () => {
@@ -26,7 +25,6 @@ const AgentSettingsMCPServersPage: FC = () => {
<MCPServerAdminPanel
sectionLabel="MCP Servers"
sectionDescription="Configure external MCP servers that provide additional tools for Coder Agents."
sectionBadge={<AdminBadge />}
serversData={serversQuery.data}
isLoadingServers={serversQuery.isLoading}
serversError={serversQuery.isError ? serversQuery.error : null}
@@ -13,7 +13,6 @@ import {
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
const AgentSettingsModelsPage: FC = () => {
@@ -46,7 +45,6 @@ const AgentSettingsModelsPage: FC = () => {
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
providerConfigsData={providerConfigsQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelCatalogData={modelCatalogQuery.data}
@@ -2,19 +2,22 @@ import type { FC } from "react";
import { Outlet, useLocation } from "react-router";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { AgentPageHeader } from "./components/AgentPageHeader";
import { sidebarViewFromPath } from "./components/Sidebar/AgentsSidebar";
const AgentSettingsPage: FC = () => {
const location = useLocation();
const match = location.pathname.match(/\/agents\/settings\/(.+)/);
const section = match?.[1];
const sidebarView = sidebarViewFromPath(location.pathname);
const mobileBack = section
? sidebarView.panel === "settings-admin"
? { to: "/agents/settings/admin", label: "Manage Agents" }
: { to: "/agents/settings", label: "Settings" }
: undefined;
return (
<ScrollArea className="min-h-0 flex-1" viewportClassName="[&>div]:!block">
<AgentPageHeader
mobileBack={
section ? { to: "/agents/settings", label: "Settings" } : undefined
}
/>
<AgentPageHeader mobileBack={mobileBack} />
<div className="p-4 pt-8">
<div className="mx-auto w-full max-w-3xl">
<Outlet />
@@ -13,7 +13,6 @@ import {
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
const AgentSettingsProvidersPage: FC = () => {
@@ -46,7 +45,6 @@ const AgentSettingsProvidersPage: FC = () => {
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their conversations."
sectionBadge={<AdminBadge />}
providerConfigsData={providerConfigsQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelCatalogData={modelCatalogQuery.data}
@@ -34,7 +34,6 @@ import {
formatCostMicros,
microsToDollars,
} from "#/utils/currency";
import { AdminBadge } from "./components/AdminBadge";
import {
DefaultLimitController,
type DefaultLimitFormValues,
@@ -307,7 +306,6 @@ export const AgentSettingsSpendPageView: FC<
<SectionHeader
label="Spend management"
description="Configure spend limits and monitor usage across your deployment."
badge={<AdminBadge />}
/>
{isLoadingConfig ? (
@@ -6,7 +6,6 @@ import {
type Option,
} from "#/components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "#/components/Spinner/Spinner";
import { AdminBadge } from "./components/AdminBadge";
import { SectionHeader } from "./components/SectionHeader";
interface MutationCallbacks {
@@ -84,7 +83,6 @@ export const AgentSettingsTemplatesPageView: FC<
<SectionHeader
label="Templates"
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
badge={<AdminBadge />}
/>
{isLoading && (
@@ -16,7 +16,6 @@ import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { Chat } from "#/api/typesGenerated";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
MockNoPermissions,
MockPermissions,
@@ -29,7 +28,11 @@ import {
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
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 { AgentsPageView } from "./AgentsPageView";
@@ -151,74 +154,6 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
// across timezones.
const fixedNow = dayjs("2026-03-12T12:00:00");
// Renders the real PageView components with mock data so the
// visual snapshots match the actual UI.
const BehaviorRouteElement = () => {
const { permissions } = useAuthenticated();
return (
<AgentSettingsBehaviorPageView
canSetSystemPrompt={permissions.editDeploymentConfig}
systemPromptData={{
system_prompt: "",
include_default_system_prompt: true,
default_system_prompt: "You are Coder, an AI coding assistant...",
}}
planModeInstructionsData={{
plan_mode_instructions: "",
}}
userPromptData={{ custom_prompt: "" }}
desktopEnabledData={{ enable_desktop: false }}
debugLoggingData={{
allow_users: false,
forced_by_deployment: false,
}}
userDebugLoggingData={{
debug_logging_enabled: false,
user_toggle_allowed: false,
forced_by_deployment: false,
}}
workspaceTTLData={{ workspace_ttl_ms: 0 }}
isWorkspaceTTLLoading={false}
isWorkspaceTTLLoadError={false}
modelConfigsData={[]}
modelConfigsError={undefined}
isLoadingModelConfigs={false}
thresholds={[]}
isThresholdsLoading={false}
thresholdsError={undefined}
onSaveSystemPrompt={fn()}
isSavingSystemPrompt={false}
isSaveSystemPromptError={false}
onSavePlanModeInstructions={fn()}
isSavingPlanModeInstructions={false}
isSavePlanModeInstructionsError={false}
onSaveUserPrompt={fn()}
isSavingUserPrompt={false}
isSaveUserPromptError={false}
onSaveDesktopEnabled={fn()}
isSavingDesktopEnabled={false}
isSaveDesktopEnabledError={false}
onSaveDebugLogging={fn()}
isSavingDebugLogging={false}
isSaveDebugLoggingError={false}
onSaveUserDebugLogging={fn()}
isSavingUserDebugLogging={false}
isSaveUserDebugLoggingError={false}
onSaveWorkspaceTTL={fn()}
isSavingWorkspaceTTL={false}
isSaveWorkspaceTTLError={false}
retentionDaysData={{ retention_days: 30 }}
isRetentionDaysLoading={false}
isRetentionDaysLoadError={false}
onSaveRetentionDays={fn()}
isSavingRetentionDays={false}
isSaveRetentionDaysError={false}
onSaveThreshold={fn(async () => undefined)}
onResetThreshold={fn(async () => undefined)}
/>
);
};
const AgentsRouteElement = () => (
<AgentSettingsAgentsPageView
exploreModelOverrideData={{ has_malformed_override: false }}
@@ -239,8 +174,16 @@ const agentsRouting = {
path: "settings",
element: <AgentSettingsPage />,
children: [
{ index: true, element: <Navigate to="behavior" replace /> },
{ path: "behavior", element: <BehaviorRouteElement /> },
{ index: true, element: <AgentSettingsGeneralPage /> },
{ path: "general", element: <AgentSettingsGeneralPage /> },
{ path: "compaction", element: <AgentSettingsCompactionPage /> },
{
path: "instructions",
element: <AgentSettingsInstructionsPage />,
},
{ path: "experiments", element: <AgentSettingsExperimentsPage /> },
{ path: "lifecycle", element: <AgentSettingsLifecyclePage /> },
{ path: "admin", element: <AgentsRouteElement /> },
{ path: "agents", element: <AgentsRouteElement /> },
{ path: "spend", element: <AgentSettingsSpendPage now={fixedNow} /> },
{
@@ -364,10 +307,50 @@ const meta: Meta<typeof AgentsPageView> = {
spyOn(API.experimental, "getChatDesktopEnabled").mockResolvedValue({
enable_desktop: false,
});
spyOn(API.experimental, "updateChatDesktopEnabled").mockResolvedValue();
spyOn(API.experimental, "getChatDebugLogging").mockResolvedValue({
allow_users: false,
forced_by_deployment: false,
});
spyOn(API.experimental, "updateChatDebugLogging").mockResolvedValue();
spyOn(API.experimental, "getUserChatDebugLogging").mockResolvedValue({
debug_logging_enabled: false,
forced_by_deployment: false,
user_toggle_allowed: false,
});
spyOn(API.experimental, "updateUserChatDebugLogging").mockResolvedValue();
spyOn(API.experimental, "getChatPlanModeInstructions").mockResolvedValue({
plan_mode_instructions: "",
});
spyOn(
API.experimental,
"updateChatPlanModeInstructions",
).mockResolvedValue();
spyOn(
API.experimental,
"getUserChatCompactionThresholds",
).mockResolvedValue({
thresholds: [],
});
spyOn(
API.experimental,
"updateUserChatCompactionThreshold",
).mockResolvedValue({
model_config_id: defaultModelConfigID,
threshold_percent: 70,
});
spyOn(
API.experimental,
"deleteUserChatCompactionThreshold",
).mockResolvedValue();
spyOn(API.experimental, "getChatWorkspaceTTL").mockResolvedValue({
workspace_ttl_ms: 0,
});
spyOn(API.experimental, "updateChatWorkspaceTTL").mockResolvedValue();
spyOn(API.experimental, "getChatRetentionDays").mockResolvedValue({
retention_days: 30,
});
spyOn(API.experimental, "updateChatRetentionDays").mockResolvedValue();
spyOn(API.experimental, "getChatUsageLimitConfig").mockResolvedValue({
spend_limit_micros: null,
period: "month",
@@ -684,9 +667,7 @@ export const OpensSettingsForAdmins: Story = {
await waitFor(() => {
expect(
screen.getByText(
"Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.",
),
screen.getByText("Personal preferences for your chat experience."),
).toBeInTheDocument();
});
},
@@ -704,11 +685,33 @@ export const OpensSettingsForNonAdmins: Story = {
await waitFor(() => {
expect(
screen.getByText(
"Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.",
),
screen.getByText("Personal preferences for your chat experience."),
).toBeInTheDocument();
});
expect(
screen.queryByRole("link", { name: "Manage Agents" }),
).not.toBeInTheDocument();
},
};
export const OpensAdminSubPanelOnMobile: Story = {
args: {
isAgentsAdmin: true,
},
parameters: {
viewport: { defaultViewport: "mobile1" },
},
play: async ({ canvasElement }) => {
await openSettingsView(canvasElement);
await userEvent.click(screen.getByRole("link", { name: "Manage Agents" }));
await expect(
await screen.findByRole("link", { name: "Providers" }),
).toBeInTheDocument();
await expect(
await screen.findByRole("link", { name: "Spend" }),
).toBeInTheDocument();
},
};
@@ -722,14 +725,13 @@ export const SettingsViewResets: Story = {
await waitFor(() => {
expect(
screen.getByText(
"Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.",
),
screen.getByText("Personal preferences for your chat experience."),
).toBeInTheDocument();
});
// Navigate to Spend section
await userEvent.click(screen.getByText("Spend"));
// Navigate to the admin panel, then open the Spend section.
await userEvent.click(screen.getByRole("link", { name: "Manage Agents" }));
await userEvent.click(await screen.findByRole("link", { name: "Spend" }));
await waitFor(() => {
expect(
screen.getByText(
@@ -738,17 +740,21 @@ export const SettingsViewResets: Story = {
).toBeInTheDocument();
});
// Go back to conversations
const backButton = screen.getByLabelText("Back to Agents");
await userEvent.click(backButton);
// Step back to the top-level settings panel, then back to conversations.
const backToSettingsButton = await screen.findByRole("link", {
name: "Back to Settings",
});
await userEvent.click(backToSettingsButton);
const backToAgentsButton = await screen.findByRole("link", {
name: "Back to Agents",
});
await userEvent.click(backToAgentsButton);
// Re-open settings, should reset to Behavior
// Re-open settings, should reset to General
await openSettingsView(canvasElement);
await waitFor(() => {
expect(
screen.getByText(
"Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.",
),
screen.getByText("Personal preferences for your chat experience."),
).toBeInTheDocument();
});
},
+4 -4
View File
@@ -6,6 +6,7 @@ import { pageTitle } from "#/utils/page";
import type { ModelSelectorOption } from "./components/ChatElements";
import {
AgentsSidebar,
isSettingsView,
sidebarViewFromPath,
} from "./components/Sidebar/AgentsSidebar";
import type { ChatDetailError } from "./utils/usageLimitMessage";
@@ -117,10 +118,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
// Mobile can't fit the sidebar nav and content side by side,
// so we show one or the other depending on the route depth.
const isSettingsIndex =
sidebarView.panel === "settings" && !sidebarView.section;
const isSettingsDetail =
sidebarView.panel === "settings" && Boolean(sidebarView.section);
const isSettingsPanel = isSettingsView(sidebarView);
const isSettingsIndex = isSettingsPanel && !sidebarView.section;
const isSettingsDetail = isSettingsPanel && Boolean(sidebarView.section);
const isAnalytics = sidebarView.panel === "analytics";
// The sidebar expects plain string error messages, but the outlet
@@ -1,25 +0,0 @@
import { ShieldIcon } from "lucide-react";
import type { FC } from "react";
import { Badge } from "#/components/Badge/Badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
export const AdminBadge: FC = () => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" size="sm" className="cursor-default">
<ShieldIcon className="h-3 w-3" />
Admin only
</Badge>
</TooltipTrigger>
<TooltipContent side="right">
Only visible to deployment administrators.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -0,0 +1,67 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { Switch } from "#/components/Switch/Switch";
interface AdminChatDebugLoggingSettingsProps {
adminSettings: TypesGen.ChatDebugLoggingAdminSettings | undefined;
onSaveAdminSetting: UseMutateFunction<
void,
Error,
TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
unknown
>;
isSavingAdminSetting: boolean;
isSaveAdminSettingError: boolean;
}
export const AdminChatDebugLoggingSettings: FC<
AdminChatDebugLoggingSettingsProps
> = ({
adminSettings,
onSaveAdminSetting,
isSavingAdminSetting,
isSaveAdminSettingError,
}) => {
const forcedByDeployment = adminSettings?.forced_by_deployment ?? false;
const adminAllowsUsers = adminSettings?.allow_users ?? false;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Let users record chat debug logs
</h3>
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
Debug logging is already enabled deployment-wide, so this per-user
setting has no effect right now.
</p>
) : (
<p className="m-0">
Lets users turn on debug logging for their own chats from their
General settings. When on, Coder saves each chat turn along with
the raw API requests and responses sent to the model provider.
</p>
)}
</div>
<Switch
checked={adminAllowsUsers}
onCheckedChange={(checked) =>
onSaveAdminSetting({ allow_users: checked })
}
aria-label="Allow users to enable chat debug logging"
disabled={forcedByDeployment || isSavingAdminSetting}
/>
</div>
{isSaveAdminSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save the admin debug logging setting.
</p>
)}
</div>
);
};
@@ -12,7 +12,7 @@ import { CoderIcon } from "#/components/Icons/CoderIcon";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { cn } from "#/utils/cn";
import type { AgentsOutletContext } from "../AgentsPageView";
import { sidebarViewFromPath } from "./Sidebar/AgentsSidebar";
import { isSettingsView, sidebarViewFromPath } from "./Sidebar/AgentsSidebar";
interface AgentPageHeaderProps {
children?: ReactNode;
@@ -32,6 +32,8 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
const isSettingsPanel = isSettingsView(sidebarView);
return (
<div className="flex shrink-0 items-center gap-2 px-4 pt-3 pb-0.5 md:py-0.5">
{mobileBack ? (
@@ -74,7 +76,7 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
aria-label="Settings"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
sidebarView.panel === "settings" && "text-content-primary",
isSettingsPanel && "text-content-primary",
)}
>
<Link to="/agents/settings" state={{ from: location.pathname }}>
@@ -1,4 +1,4 @@
import { type FC, type ReactNode, useState } from "react";
import { type FC, useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
@@ -191,7 +191,6 @@ interface ChatModelAdminPanelProps {
section?: ChatModelAdminSection;
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
// Data from queries.
providerConfigsData: TypesGen.ChatProviderConfig[] | undefined;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
@@ -232,7 +231,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
section = "providers",
sectionLabel,
sectionDescription,
sectionBadge,
providerConfigsData,
modelConfigsData,
modelCatalogData,
@@ -302,7 +300,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
<ProvidersSection
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
sectionBadge={sectionBadge}
providerStates={providerStates}
providerConfigsUnavailable={providerConfigsUnavailable}
isProviderMutationPending={isProviderMutationPending}
@@ -315,7 +312,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
<ModelsSection
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
sectionBadge={sectionBadge}
providerStates={providerStates}
selectedProvider={selectedProvider}
selectedProviderState={selectedProviderState}
@@ -5,7 +5,7 @@ import {
PlusIcon,
TriangleAlertIcon,
} from "lucide-react";
import type { FC, ReactNode } from "react";
import type { FC } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
@@ -36,7 +36,6 @@ type ModelView =
interface ModelsSectionProps {
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
@@ -59,7 +58,6 @@ interface ModelsSectionProps {
export const ModelsSection: FC<ModelsSectionProps> = ({
sectionLabel,
sectionDescription,
sectionBadge,
providerStates,
selectedProvider,
selectedProviderState,
@@ -233,7 +231,6 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
description={
sectionDescription ?? "Manage models available to Agents."
}
badge={sectionBadge}
action={addButton || undefined}
/>
)}
@@ -1,5 +1,5 @@
import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { FC } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
@@ -14,7 +14,6 @@ type ProviderView = { mode: "list" } | { mode: "detail"; provider: string };
interface ProvidersSectionProps {
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
providerStates: readonly ProviderState[];
providerConfigsUnavailable: boolean;
isProviderMutationPending: boolean;
@@ -32,7 +31,6 @@ interface ProvidersSectionProps {
export const ProvidersSection: FC<ProvidersSectionProps> = ({
sectionLabel,
sectionDescription,
sectionBadge,
providerStates,
providerConfigsUnavailable,
isProviderMutationPending,
@@ -134,7 +132,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
description={
sectionDescription ?? "Configure AI providers to use with Agents."
}
badge={sectionBadge}
/>
)}
<div>
@@ -1,136 +0,0 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Switch } from "#/components/Switch/Switch";
import { AdminBadge } from "./AdminBadge";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface DebugLoggingSettingsProps {
canManageAdminSetting: boolean;
adminSettings: TypesGen.ChatDebugLoggingAdminSettings | undefined;
userSettings: TypesGen.UserChatDebugLoggingSettings | undefined;
onSaveAdminSetting: (
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
options?: MutationCallbacks,
) => void;
isSavingAdminSetting: boolean;
isSaveAdminSettingError: boolean;
onSaveUserSetting: (
req: TypesGen.UpdateUserChatDebugLoggingRequest,
options?: MutationCallbacks,
) => void;
isSavingUserSetting: boolean;
isSaveUserSettingError: boolean;
}
export const DebugLoggingSettings: FC<DebugLoggingSettingsProps> = ({
canManageAdminSetting,
adminSettings,
userSettings,
onSaveAdminSetting,
isSavingAdminSetting,
isSaveAdminSettingError,
onSaveUserSetting,
isSavingUserSetting,
isSaveUserSettingError,
}) => {
const forcedByDeployment =
userSettings?.forced_by_deployment ??
adminSettings?.forced_by_deployment ??
false;
const adminAllowsUsers = adminSettings?.allow_users ?? false;
const userDebugLoggingEnabled = userSettings?.debug_logging_enabled ?? false;
const userToggleAllowed = userSettings?.user_toggle_allowed ?? false;
return (
<div className="space-y-4">
{canManageAdminSetting && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Let users record chat debug logs
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
Debug logging is already enabled deployment-wide, so this
per-user setting has no effect right now.
</p>
) : (
<p className="m-0">
Lets users turn on debug logging for their own chats from
their Behavior settings. When on, Coder saves each chat turn
along with the raw API requests and responses sent to the
model provider.
</p>
)}
</div>
<Switch
checked={adminAllowsUsers}
onCheckedChange={(checked) =>
onSaveAdminSetting({ allow_users: checked })
}
aria-label="Allow users to enable chat debug logging"
disabled={forcedByDeployment || isSavingAdminSetting}
/>
</div>
{isSaveAdminSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save the admin debug logging setting.
</p>
)}
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Record debug logs for my chats
</h3>
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
An administrator has enabled debug logging for every chat in
this deployment, so this toggle is locked on.
</p>
) : userToggleAllowed ? (
<p className="m-0">
Save a detailed trace of your chats: each turn plus the raw API
requests and responses sent to the model provider. Useful for
troubleshooting unexpected model behavior.
</p>
) : (
<p className="m-0">
An administrator hasn't allowed users to record chat debug logs
yet.
</p>
)}
</div>
<Switch
checked={forcedByDeployment || userDebugLoggingEnabled}
onCheckedChange={(checked) =>
onSaveUserSetting({ debug_logging_enabled: checked })
}
aria-label="Enable personal chat debug logging"
disabled={
forcedByDeployment || !userToggleAllowed || isSavingUserSetting
}
/>
</div>
{isSaveUserSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save your chat debug logging preference.
</p>
)}
</div>
</div>
);
};
@@ -3,7 +3,6 @@ import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { AdminBadge } from "./AdminBadge";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
@@ -96,7 +95,6 @@ export const ExploreModelOverrideSettings: FC<
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Explore subagent model
</h3>
<AdminBadge />
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Optional deployment-wide model override for read-only Explore
@@ -9,14 +9,7 @@ import {
ServerIcon,
XIcon,
} from "lucide-react";
import {
type FC,
lazy,
type ReactNode,
Suspense,
useId,
useState,
} from "react";
import { type FC, lazy, Suspense, useId, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
@@ -243,7 +236,6 @@ interface ServerListProps {
onAdd: () => void;
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
}
const ServerList: FC<ServerListProps> = ({
@@ -252,7 +244,6 @@ const ServerList: FC<ServerListProps> = ({
onAdd,
sectionLabel,
sectionDescription,
sectionBadge,
}) => {
return (
<>
@@ -262,7 +253,6 @@ const ServerList: FC<ServerListProps> = ({
sectionDescription ??
"Configure external MCP servers that provide additional tools for Coder Agents."
}
badge={sectionBadge}
action={
<Button size="sm" onClick={onAdd}>
<PlusIcon className="h-4 w-4" />
@@ -1100,7 +1090,6 @@ interface MCPServerAdminPanelProps {
className?: string;
sectionLabel?: string;
sectionDescription?: string;
sectionBadge?: ReactNode;
// Data from query.
serversData: TypesGen.MCPServerConfig[] | undefined;
isLoadingServers: boolean;
@@ -1126,7 +1115,6 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
className,
sectionLabel,
sectionDescription,
sectionBadge,
serversData,
isLoadingServers,
serversError,
@@ -1235,7 +1223,6 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
}
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
sectionBadge={sectionBadge}
/>
) : isCreating || (!isLoadingServers && editingServer) ? (
<ServerForm
@@ -7,7 +7,6 @@ import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { AdminBadge } from "./AdminBadge";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -68,7 +67,6 @@ export const PlanModeInstructionsSettings: FC<
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Plan mode instructions
</h3>
<AdminBadge />
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Custom instructions applied when the agent enters planning mode. These
@@ -7,7 +7,6 @@ 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 { AdminBadge } from "./AdminBadge";
import {
TemporarySavedState,
useTemporarySavedState,
@@ -110,7 +109,6 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
<h3 className="m-0 text-sm font-semibold text-content-primary">
Conversation Retention Period
</h3>
<AdminBadge />
</div>
<Switch
checked={isRetentionEnabled}
@@ -772,7 +772,7 @@ export const RenameChatGenerateLateResponseDoesNotClobberOtherChat: Story = {
});
expect(inputB).toHaveValue("Chat B");
await userEvent.clear(inputB);
await userEvent.type(inputB, "User edit for B");
await userEvent.paste("User edit for B");
await new Promise((resolve) => setTimeout(resolve, 250));
expect(inputB).toHaveValue("User edit for B");
@@ -833,7 +833,7 @@ export const RenameChatGenerateLateResponseDoesNotClobberSameChatReopen: Story =
});
expect(input).toHaveValue("Chat same");
await userEvent.clear(input);
await userEvent.type(input, "User edit");
await userEvent.paste("User edit");
await new Promise((resolve) => setTimeout(resolve, 250));
expect(input).toHaveValue("User edit");
@@ -1510,7 +1510,9 @@ export const SettingsAPIKeysAdmin: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("API Keys")).toBeInTheDocument();
await expect(
canvas.getByRole("link", { name: "Secrets (API keys)" }),
).toBeInTheDocument();
},
};
@@ -1541,6 +1543,8 @@ export const SettingsAPIKeysNonAdmin: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("API Keys")).toBeInTheDocument();
await expect(
canvas.getByRole("link", { name: "Secrets (API keys)" }),
).toBeInTheDocument();
},
};
@@ -26,26 +26,33 @@ import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CoinsIcon,
EllipsisIcon,
FilterIcon,
FlaskConicalIcon,
GitMergeIcon,
GitPullRequestArrowIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
KeyRoundIcon,
KeyIcon,
LayoutTemplateIcon,
Loader2Icon,
PanelLeftCloseIcon,
PauseIcon,
PinIcon,
PinOffIcon,
PlugIcon,
ReceiptTextIcon,
RefreshCwIcon,
ServerIcon,
Settings2Icon,
SettingsIcon,
ShieldIcon,
ShrinkIcon,
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UserIcon,
WalletIcon,
} from "lucide-react";
import {
createContext,
@@ -107,8 +114,22 @@ import { RenameChatDialog } from "./RenameChatDialog";
type SidebarView =
| { panel: "chats" }
| { panel: "settings"; section: string | undefined }
| { panel: "settings-admin"; section: string | undefined }
| { panel: "analytics" };
const ADMIN_SETTINGS_SECTIONS = new Set([
"agents",
"templates",
"providers",
"models",
"mcp-servers",
"spend",
"insights",
"instructions",
"experiments",
"lifecycle",
]);
/**
* Derive the current sidebar view from the URL pathname.
*/
@@ -118,11 +139,26 @@ export function sidebarViewFromPath(pathname: string): SidebarView {
}
const settingsMatch = pathname.match(/^\/agents\/settings(?:\/([^/]+))?/);
if (settingsMatch) {
return { panel: "settings", section: settingsMatch[1] };
const section = settingsMatch[1];
if (section === "admin") {
return { panel: "settings-admin", section: undefined };
}
return {
panel: ADMIN_SETTINGS_SECTIONS.has(section ?? "")
? "settings-admin"
: "settings",
section,
};
}
return { panel: "chats" };
}
export function isSettingsView(
view: SidebarView,
): view is Extract<SidebarView, { panel: "settings" | "settings-admin" }> {
return view.panel === "settings" || view.panel === "settings-admin";
}
interface AgentsSidebarProps {
chats: readonly Chat[];
chatErrorReasons: Record<string, string>;
@@ -816,12 +852,20 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const { appearance, buildInfo } = useDashboard();
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
const isSettingsPanel = isSettingsView(sidebarView);
const isFallbackToUserPanel =
sidebarView.panel === "settings-admin" && !isAdmin;
const settingsPanel =
sidebarView.panel === "settings-admin" && isAdmin
? "settings-admin"
: "settings";
const settingsSection =
isSettingsPanel && !isFallbackToUserPanel ? sidebarView.section : undefined;
const providerConfigsQuery = useQuery({
...userChatProviderConfigs(),
enabled: sidebarView.panel === "settings" && !isAdmin,
enabled: isSettingsPanel && !isAdmin,
});
const isApiKeysSection =
sidebarView.panel === "settings" && sidebarView.section === "api-keys";
const isApiKeysSection = isSettingsPanel && settingsSection === "api-keys";
const showApiKeysItem =
isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length);
const normalizedSearch = "";
@@ -1025,17 +1069,18 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
onOpenRenameDialog: onRenameTitle ? setChatPendingRename : undefined,
};
const subNavTitle = "Settings";
const subNavTitle =
settingsPanel === "settings-admin" ? "Manage Agents" : "Settings";
return (
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
{/* ── Panel 1: Chats ── */}
<div
className={cn(
"absolute inset-0 flex flex-col md:transition-transform md:duration-200 md:ease-in-out",
sidebarView.panel === "settings" && "-translate-x-full",
isSettingsPanel && "-translate-x-full",
)}
aria-hidden={sidebarView.panel === "settings"}
inert={sidebarView.panel === "settings" ? true : undefined}
aria-hidden={isSettingsPanel}
inert={isSettingsPanel ? true : undefined}
>
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 md:block">
<div className="mb-2.5 flex items-center justify-between">
@@ -1054,7 +1099,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
aria-label="Settings"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
sidebarView.panel === "settings" && "text-content-primary",
isSettingsPanel && "text-content-primary",
)}
>
<Link to="/agents/settings" state={{ from: location.pathname }}>
@@ -1266,10 +1311,10 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
<div
className={cn(
"absolute inset-0 flex flex-col md:transition-transform md:duration-200 md:ease-in-out",
sidebarView.panel !== "settings" && "translate-x-full",
!isSettingsPanel && "translate-x-full",
)}
aria-hidden={sidebarView.panel !== "settings"}
inert={sidebarView.panel !== "settings" ? true : undefined}
aria-hidden={!isSettingsPanel}
inert={!isSettingsPanel ? true : undefined}
>
{/* Back header */}
<div className="border-b border-border-default px-2 pb-2 pt-3 md:py-2">
@@ -1281,14 +1326,28 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
asChild
variant="subtle"
size="icon"
aria-label="Back to Agents"
aria-label={
settingsPanel === "settings-admin"
? "Back to Settings"
: "Back to Agents"
}
className="relative z-10 h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<Link
to={(location.state as { from?: string })?.from || "/agents"}
>
<ChevronLeftIcon />
</Link>
{settingsPanel === "settings-admin" ? (
<Link
to="/agents/settings/general"
state={location.state}
aria-label="Back to Settings"
>
<ChevronLeftIcon />
</Link>
) : (
<Link
to={(location.state as { from?: string })?.from || "/agents"}
>
<ChevronLeftIcon />
</Link>
)}
</Button>
<div className="flex-1" />
{onCollapse && (
@@ -1305,79 +1364,115 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</div>
</div>
{/* Sub-navigation items */}
{sidebarView.panel === "settings" && (
{settingsPanel === "settings" ? (
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={UserIcon}
label="Behavior"
active={
!sidebarView.section || sidebarView.section === "behavior"
}
to="/agents/settings/behavior"
label="General"
active={!settingsSection || settingsSection === "general"}
to="/agents/settings/general"
state={location.state}
/>
<SettingsNavItem
icon={ShrinkIcon}
label="Compaction"
active={settingsSection === "compaction"}
to="/agents/settings/compaction"
state={location.state}
/>
{showApiKeysItem && (
<SettingsNavItem
icon={KeyRoundIcon}
label="API Keys"
active={sidebarView.section === "api-keys"}
icon={KeyIcon}
label="Secrets (API keys)"
active={settingsSection === "api-keys"}
to="/agents/settings/api-keys"
state={location.state}
/>
)}
{isAdmin && (
<>
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={sidebarView.section === "agents"}
to="/agents/settings/agents"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={LayoutTemplateIcon}
label="Templates"
active={sidebarView.section === "templates"}
to="/agents/settings/templates"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={KeyRoundIcon}
label="Providers"
active={sidebarView.section === "providers"}
to="/agents/settings/providers"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={BoxesIcon}
label="Models"
active={sidebarView.section === "models"}
to="/agents/settings/models"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={ServerIcon}
label="MCP Servers"
active={sidebarView.section === "mcp-servers"}
to="/agents/settings/mcp-servers"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={WalletIcon}
label="Spend"
active={sidebarView.section === "spend"}
to="/agents/settings/spend"
state={location.state}
adminOnly
/>
</>
<SettingsNavItem
icon={Settings2Icon}
label="Manage Agents"
active={false}
to="/agents/settings/admin"
state={location.state}
trailingIcon={ChevronRightIcon}
/>
)}
</nav>
) : (
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={!settingsSection || settingsSection === "agents"}
to="/agents/settings/agents"
state={location.state}
/>
<SettingsNavItem
icon={PlugIcon}
label="Providers"
active={settingsSection === "providers"}
to="/agents/settings/providers"
state={location.state}
/>
<SettingsNavItem
icon={BoxesIcon}
label="Models"
active={settingsSection === "models"}
to="/agents/settings/models"
state={location.state}
/>
<SettingsNavItem
icon={ServerIcon}
label="MCP Servers"
active={settingsSection === "mcp-servers"}
to="/agents/settings/mcp-servers"
state={location.state}
/>
<SettingsNavItem
icon={LayoutTemplateIcon}
label="Templates"
active={settingsSection === "templates"}
to="/agents/settings/templates"
state={location.state}
/>
<SettingsNavItem
icon={CoinsIcon}
label="Spend"
active={settingsSection === "spend"}
to="/agents/settings/spend"
state={location.state}
/>
<SettingsNavItem
icon={ReceiptTextIcon}
label="Instructions"
active={settingsSection === "instructions"}
to="/agents/settings/instructions"
state={location.state}
/>
<SettingsNavItem
icon={FlaskConicalIcon}
label="Experiments"
active={settingsSection === "experiments"}
to="/agents/settings/experiments"
state={location.state}
/>
<SettingsNavItem
icon={RefreshCwIcon}
label="Lifecycle"
active={settingsSection === "lifecycle"}
to="/agents/settings/lifecycle"
state={location.state}
/>
<SettingsNavItem
icon={SparklesIcon}
label="Insights"
active={settingsSection === "insights"}
to="/agents/settings/insights"
state={location.state}
/>
</nav>
)}
</div>
{onRenameTitle && (
@@ -1400,6 +1495,7 @@ type SettingsNavItemProps = {
active: boolean;
adminOnly?: boolean;
disabled?: boolean;
trailingIcon?: FC<{ className?: string }>;
} & (
| { to: string; replace?: boolean; state?: unknown; onClick?: () => void }
| { to?: never; replace?: never; state?: never; onClick: () => void }
@@ -1418,22 +1514,26 @@ const NavItemContent: FC<{
icon: FC<{ className?: string }>;
label: string;
adminOnly?: boolean;
}> = ({ icon: Icon, label, adminOnly }) => (
trailingIcon?: FC<{ className?: string }>;
}> = ({ icon: Icon, label, adminOnly, trailingIcon: TrailingIcon }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex flex-1 items-center gap-2">
{label}
{adminOnly && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
<ShieldIcon className="h-3 w-3 shrink-0 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="right">Admin only</TooltipContent>
</Tooltip>
)}
</span>
<span className="min-w-0 flex-1">{label}</span>
{(adminOnly || TrailingIcon) && (
<span className="ml-auto flex items-center gap-2">
{adminOnly && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<ShieldIcon className="h-3 w-3 shrink-0 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="right">Admin only</TooltipContent>
</Tooltip>
)}
{TrailingIcon && <TrailingIcon className="h-4 w-4 shrink-0" />}
</span>
)}
</>
);
@@ -1443,6 +1543,7 @@ const SettingsNavItem: FC<SettingsNavItemProps> = ({
active,
adminOnly,
disabled,
trailingIcon,
...rest
}) => {
if (rest.to != null) {
@@ -1456,7 +1557,12 @@ const SettingsNavItem: FC<SettingsNavItemProps> = ({
aria-current={active ? "page" : undefined}
tabIndex={disabled ? -1 : undefined}
>
<NavItemContent icon={icon} label={label} adminOnly={adminOnly} />
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailingIcon={trailingIcon}
/>
</Link>
);
}
@@ -1469,7 +1575,12 @@ const SettingsNavItem: FC<SettingsNavItemProps> = ({
className={navItemClassName(active, disabled)}
aria-current={active ? "page" : undefined}
>
<NavItemContent icon={icon} label={label} adminOnly={adminOnly} />
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailingIcon={trailingIcon}
/>
</button>
);
};
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { isSettingsView, sidebarViewFromPath } from "./AgentsSidebar";
describe("sidebarViewFromPath", () => {
it("returns chats for the agents index", () => {
expect(sidebarViewFromPath("/agents")).toEqual({ panel: "chats" });
});
it("returns analytics for the analytics route", () => {
expect(sidebarViewFromPath("/agents/analytics")).toEqual({
panel: "analytics",
});
});
it("returns chats for non-settings agent routes", () => {
expect(sidebarViewFromPath("/agents/some-uuid")).toEqual({
panel: "chats",
});
});
it("returns the settings index for /agents/settings", () => {
expect(sidebarViewFromPath("/agents/settings")).toEqual({
panel: "settings",
section: undefined,
});
});
it("returns the general settings section", () => {
expect(sidebarViewFromPath("/agents/settings/general")).toEqual({
panel: "settings",
section: "general",
});
});
it("returns the compaction settings section", () => {
expect(sidebarViewFromPath("/agents/settings/compaction")).toEqual({
panel: "settings",
section: "compaction",
});
});
it("returns the api keys settings section", () => {
expect(sidebarViewFromPath("/agents/settings/api-keys")).toEqual({
panel: "settings",
section: "api-keys",
});
});
it("returns the lifecycle admin settings section", () => {
expect(sidebarViewFromPath("/agents/settings/lifecycle")).toEqual({
panel: "settings-admin",
section: "lifecycle",
});
});
it("returns the providers admin settings section", () => {
expect(sidebarViewFromPath("/agents/settings/providers")).toEqual({
panel: "settings-admin",
section: "providers",
});
});
it("normalizes the admin index route to an undefined section", () => {
expect(sidebarViewFromPath("/agents/settings/admin")).toEqual({
panel: "settings-admin",
section: undefined,
});
});
it("returns the instructions admin settings section", () => {
expect(sidebarViewFromPath("/agents/settings/instructions")).toEqual({
panel: "settings-admin",
section: "instructions",
});
});
it("falls through unknown settings slugs to the user settings panel", () => {
expect(sidebarViewFromPath("/agents/settings/unknown-slug")).toEqual({
panel: "settings",
section: "unknown-slug",
});
});
it("falls back to chats for unrelated routes", () => {
expect(sidebarViewFromPath("/workspaces")).toEqual({
panel: "chats",
});
});
});
describe("isSettingsView", () => {
it("returns true for the user settings panel", () => {
expect(isSettingsView({ panel: "settings", section: undefined })).toBe(
true,
);
});
it("returns true for the admin settings panel", () => {
expect(
isSettingsView({ panel: "settings-admin", section: "providers" }),
).toBe(true);
});
it("returns false for chats", () => {
expect(isSettingsView({ panel: "chats" })).toBe(false);
});
it("returns false for analytics", () => {
expect(isSettingsView({ panel: "analytics" })).toBe(false);
});
});
@@ -9,7 +9,6 @@ import {
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import { Spinner } from "#/components/Spinner/Spinner";
import { AdminBadge } from "./AdminBadge";
import { BackButton } from "./BackButton";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SectionHeader } from "./SectionHeader";
@@ -51,7 +50,6 @@ export const SpendDrillInView: FC<SpendDrillInViewProps> = ({
<SectionHeader
label="Spend management"
description="Review spend details for a specific user."
badge={<AdminBadge />}
action={
<DateRangePicker
value={displayDateRange}
@@ -9,7 +9,6 @@ import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { AdminBadge } from "./AdminBadge";
import {
TemporarySavedState,
useTemporarySavedState,
@@ -79,7 +78,6 @@ export const SystemInstructionsSettings: FC<
<h3 className="m-0 text-sm font-semibold text-content-primary">
System Instructions
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
@@ -0,0 +1,69 @@
import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { Switch } from "#/components/Switch/Switch";
interface UserChatDebugLoggingSettingsProps {
userSettings: TypesGen.UserChatDebugLoggingSettings | undefined;
onSaveUserSetting: UseMutateFunction<
void,
Error,
TypesGen.UpdateUserChatDebugLoggingRequest,
unknown
>;
isSavingUserSetting: boolean;
isSaveUserSettingError: boolean;
}
export const UserChatDebugLoggingSettings: FC<
UserChatDebugLoggingSettingsProps
> = ({
userSettings,
onSaveUserSetting,
isSavingUserSetting,
isSaveUserSettingError,
}) => {
if (!userSettings?.user_toggle_allowed) {
return null;
}
const forcedByDeployment = userSettings.forced_by_deployment;
const userDebugLoggingEnabled = userSettings.debug_logging_enabled;
return (
<div className="space-y-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Record debug logs for my chats
</h3>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
An administrator has enabled debug logging for every chat in this
deployment, so this toggle is locked on.
</p>
) : (
<p className="m-0">
Save a detailed trace of your chats: each turn plus the raw API
requests and responses sent to the model provider. Useful for
troubleshooting unexpected model behavior.
</p>
)}
</div>
<Switch
checked={forcedByDeployment || userDebugLoggingEnabled}
onCheckedChange={(checked) =>
onSaveUserSetting({ debug_logging_enabled: checked })
}
aria-label="Enable personal chat debug logging"
disabled={forcedByDeployment || isSavingUserSetting}
/>
</div>
{isSaveUserSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save your chat debug logging preference.
</p>
)}
</div>
);
};
@@ -4,7 +4,6 @@ import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Link } from "#/components/Link/Link";
import { Switch } from "#/components/Switch/Switch";
import { AdminBadge } from "./AdminBadge";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -36,7 +35,6 @@ export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
<h3 className="m-0 text-sm font-semibold text-content-primary">
Virtual Desktop
</h3>
<AdminBadge />
<Badge size="sm" variant="warning" className="cursor-default">
<TriangleAlertIcon className="h-3 w-3" />
Experimental feature
@@ -6,7 +6,6 @@ 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 { AdminBadge } from "./AdminBadge";
import { DurationField } from "./DurationField/DurationField";
import {
TemporarySavedState,
@@ -124,7 +123,6 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
<h3 className="m-0 text-sm font-semibold text-content-primary">
Workspace Autostop Fallback
</h3>
<AdminBadge />
</div>
<Switch
checked={isAutostopEnabled}
+30 -4
View File
@@ -357,8 +357,20 @@ const AgentCreatePage = lazy(
const AgentSettingsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsPage"),
);
const AgentSettingsBehaviorPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsBehaviorPage"),
const AgentSettingsGeneralPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsGeneralPage"),
);
const AgentSettingsCompactionPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsCompactionPage"),
);
const AgentSettingsInstructionsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsInstructionsPage"),
);
const AgentSettingsExperimentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsExperimentsPage"),
);
const AgentSettingsLifecyclePage = lazy(
() => import("./pages/AgentsPage/AgentSettingsLifecyclePage"),
);
const AgentSettingsAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAgentsPage"),
@@ -710,8 +722,22 @@ export const router = createBrowserRouter(
>
<Route index element={<AgentCreatePage />} />
<Route path="settings" element={<AgentSettingsPage />}>
<Route index element={<AgentSettingsBehaviorPage />} />
<Route path="behavior" element={<AgentSettingsBehaviorPage />} />
<Route index element={<AgentSettingsGeneralPage />} />
<Route path="general" element={<AgentSettingsGeneralPage />} />
<Route
path="compaction"
element={<AgentSettingsCompactionPage />}
/>
<Route
path="instructions"
element={<AgentSettingsInstructionsPage />}
/>
<Route
path="experiments"
element={<AgentSettingsExperimentsPage />}
/>
<Route path="lifecycle" element={<AgentSettingsLifecyclePage />} />
<Route path="admin" element={<AgentSettingsAgentsPage />} />
<Route path="agents" element={<AgentSettingsAgentsPage />} />
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
<Route path="providers" element={<AgentSettingsProvidersPage />} />