mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user