diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 30336d746da3b..75d6528e7bd6c 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -106,6 +106,13 @@ export const DeploymentSidebarView: FC = ({ {!hasPremiumLicense && ( Premium )} + {permissions.editDeploymentConfig && ( + + + Manage Coder Agents + + + )} ); diff --git a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx index 8cb746219571a..a9b57dc4117cc 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx @@ -249,7 +249,7 @@ export const AgentSettingsAPIKeysPageView: FC<
diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index 73217fe797671..8e4d25e406aa0 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -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< } />
{ - 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 ( - - ); -}; - -export default AgentSettingsBehaviorPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx deleted file mode 100644 index b2a0dab68ed2a..0000000000000 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx +++ /dev/null @@ -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; - -export default meta; -type Story = StoryObj; - -// ── 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(); - }, -}; diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx deleted file mode 100644 index 07f8d7e343bd1..0000000000000 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx +++ /dev/null @@ -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; - onResetThreshold: (modelConfigId: string) => Promise; - - // 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 ( -
- - - - - - - {/* ── Admin-only settings ── */} - {canSetSystemPrompt && ( - <> - - - - - - - )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx new file mode 100644 index 0000000000000..1109ee8f9d949 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx @@ -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 ( + + ); +}; + +export default AgentSettingsCompactionPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx new file mode 100644 index 0000000000000..ffb10622c074a --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx new file mode 100644 index 0000000000000..4dd47ee35a91d --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx @@ -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; + onResetThreshold: (modelConfigId: string) => Promise; +} + +export const AgentSettingsCompactionPageView: FC< + AgentSettingsCompactionPageViewProps +> = ({ + modelConfigsData, + modelConfigsError, + isLoadingModelConfigs, + thresholds, + isThresholdsLoading, + thresholdsError, + onSaveThreshold, + onResetThreshold, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsExperimentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsExperimentsPage.tsx new file mode 100644 index 0000000000000..8e5c6294c719c --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsExperimentsPage.tsx @@ -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 ( + + + + ); +}; + +export default AgentSettingsExperimentsPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.stories.tsx new file mode 100644 index 0000000000000..1d9513cde5259 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.tsx new file mode 100644 index 0000000000000..2dd2284ebd100 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsExperimentsPageView.tsx @@ -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 ( +
+ + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsGeneralPage.tsx b/site/src/pages/AgentsPage/AgentSettingsGeneralPage.tsx new file mode 100644 index 0000000000000..c2b606400812a --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsGeneralPage.tsx @@ -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 ( + + ); +}; + +export default AgentSettingsGeneralPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.stories.tsx new file mode 100644 index 0000000000000..f76e38d3c14f6 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx new file mode 100644 index 0000000000000..8cc1fd6373e96 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx @@ -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 ( +
+ + + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx new file mode 100644 index 0000000000000..ed423856c1778 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx @@ -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 ( + + + + ); +}; + +export default AgentSettingsInstructionsPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx new file mode 100644 index 0000000000000..2919b4e193440 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(), + ); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.tsx new file mode 100644 index 0000000000000..59fc42528b596 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.tsx @@ -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 ( +
+ + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx new file mode 100644 index 0000000000000..0dd07eef4542f --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx @@ -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 ( + + + + ); +}; + +export default AgentSettingsLifecyclePage; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx new file mode 100644 index 0000000000000..6106ec65b493e --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const DefaultAutostopDefault: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Workspace Autostop Fallback"); + await canvas.findByText( + /Set a default autostop for agent-created workspaces/i, + ); + + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).not.toBeChecked(); + expect(canvas.queryByLabelText("Autostop Fallback")).toBeNull(); + }, +}; + +export const DefaultAutostopCustomValue: Story = { + args: { + workspaceTTLData: { workspace_ttl_ms: 7_200_000 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("2"); + }, +}; + +export const DefaultAutostopSave: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + await userEvent.click(toggle); + + await waitFor(() => { + expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith( + { workspace_ttl_ms: 3_600_000 }, + expect.anything(), + ); + }); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("1"); + + await userEvent.clear(durationInput); + await userEvent.type(durationInput, "3"); + + const ttlForm = durationInput.closest("form"); + if (!(ttlForm instanceof HTMLFormElement)) { + throw new Error( + "Expected autostop duration input to live inside a form.", + ); + } + const saveButton = within(ttlForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + + await userEvent.clear(durationInput); + await waitFor(() => { + expect( + within(ttlForm).queryByRole("button", { name: "Save" }), + ).toBeNull(); + }); + }, +}; + +export const DefaultAutostopExceedsMax: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + await userEvent.click(toggle); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + const ttlForm = durationInput.closest("form"); + if (!(ttlForm instanceof HTMLFormElement)) { + throw new Error( + "Expected autostop duration input to live inside a form.", + ); + } + + await userEvent.clear(durationInput); + await userEvent.type(durationInput, "721"); + + await waitFor(() => { + expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument(); + }); + + const saveButton = within(ttlForm).getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeDisabled(); + }, +}; + +export const DefaultAutostopToggleOff: Story = { + args: { + workspaceTTLData: { workspace_ttl_ms: 7_200_000 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + await waitFor(() => { + expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith( + { workspace_ttl_ms: 0 }, + expect.anything(), + ); + }); + }, +}; + +export const DefaultAutostopSaveDisabled: Story = { + args: { + workspaceTTLData: { workspace_ttl_ms: 7_200_000 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("2"); + + const ttlForm = durationInput.closest("form"); + if (!(ttlForm instanceof HTMLFormElement)) { + throw new Error( + "Expected autostop duration input to live inside a form.", + ); + } + expect(within(ttlForm).queryByRole("button", { name: "Save" })).toBeNull(); + }, +}; + +export const DefaultAutostopToggleFailure: Story = { + args: { + isSaveWorkspaceTTLError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + await waitFor(() => { + expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith( + { workspace_ttl_ms: 3_600_000 }, + expect.anything(), + ); + }); + expect( + canvas.getByText("Failed to save autostop setting."), + ).toBeInTheDocument(); + }, +}; + +export const DefaultAutostopToggleOffFailure: Story = { + args: { + workspaceTTLData: { workspace_ttl_ms: 7_200_000 }, + isSaveWorkspaceTTLError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable default autostop", + }); + expect(toggle).toBeChecked(); + + const durationInput = await canvas.findByLabelText("Autostop Fallback"); + expect(durationInput).toHaveValue("2"); + + await userEvent.click(toggle); + await waitFor(() => { + expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith( + { workspace_ttl_ms: 0 }, + expect.anything(), + ); + }); + expect( + canvas.getByText("Failed to save autostop setting."), + ).toBeInTheDocument(); + }, +}; + +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(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx new file mode 100644 index 0000000000000..21ea43fb706b9 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx @@ -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 ( +
+ + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx b/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx index 125cf34b5a3c7..4523b6ed4aed1 100644 --- a/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsMCPServersPage.tsx @@ -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 = () => { } serversData={serversQuery.data} isLoadingServers={serversQuery.isLoading} serversError={serversQuery.isError ? serversQuery.error : null} diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx index ce5b287743439..0eb0b759fde58 100644 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx @@ -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={} providerConfigsData={providerConfigsQuery.data} modelConfigsData={modelConfigsQuery.data} modelCatalogData={modelCatalogQuery.data} diff --git a/site/src/pages/AgentsPage/AgentSettingsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPage.tsx index 6016e54f15401..b176a6c6f8e5b 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPage.tsx @@ -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 ( - +
diff --git a/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx b/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx index 8b0192502559b..b186822a0c9ed 100644 --- a/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsProvidersPage.tsx @@ -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={} providerConfigsData={providerConfigsQuery.data} modelConfigsData={modelConfigsQuery.data} modelCatalogData={modelCatalogQuery.data} diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx index 5f7fb915d614d..b9e8b97dab75f 100644 --- a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx @@ -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< } /> {isLoadingConfig ? ( diff --git a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx index 6ca0dff2a94a1..61f7ff8a32ae5 100644 --- a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx @@ -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< } /> {isLoading && ( diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index a5c382a86b2dc..228876e2b004a 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -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 => ({ // 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 ( - undefined)} - onResetThreshold={fn(async () => undefined)} - /> - ); -}; - const AgentsRouteElement = () => ( , children: [ - { index: true, element: }, - { path: "behavior", element: }, + { index: true, element: }, + { path: "general", element: }, + { path: "compaction", element: }, + { + path: "instructions", + element: , + }, + { path: "experiments", element: }, + { path: "lifecycle", element: }, + { path: "admin", element: }, { path: "agents", element: }, { path: "spend", element: }, { @@ -364,10 +307,50 @@ const meta: Meta = { 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(); }); }, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 6d25b8c36278f..bf0de9efa217c 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -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 = ({ // 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 diff --git a/site/src/pages/AgentsPage/components/AdminBadge.tsx b/site/src/pages/AgentsPage/components/AdminBadge.tsx deleted file mode 100644 index d913d893e8974..0000000000000 --- a/site/src/pages/AgentsPage/components/AdminBadge.tsx +++ /dev/null @@ -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 = () => ( - - - - - - Admin only - - - - Only visible to deployment administrators. - - - -); diff --git a/site/src/pages/AgentsPage/components/AdminChatDebugLoggingSettings.tsx b/site/src/pages/AgentsPage/components/AdminChatDebugLoggingSettings.tsx new file mode 100644 index 0000000000000..12d09ff889c06 --- /dev/null +++ b/site/src/pages/AgentsPage/components/AdminChatDebugLoggingSettings.tsx @@ -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 ( +
+
+

+ Let users record chat debug logs +

+
+
+
+ {forcedByDeployment ? ( +

+ Debug logging is already enabled deployment-wide, so this per-user + setting has no effect right now. +

+ ) : ( +

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

+ )} +
+ + onSaveAdminSetting({ allow_users: checked }) + } + aria-label="Allow users to enable chat debug logging" + disabled={forcedByDeployment || isSavingAdminSetting} + /> +
+ {isSaveAdminSettingError && ( +

+ Failed to save the admin debug logging setting. +

+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx index 85fbd9f9187a7..833665bab5b06 100644 --- a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx +++ b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx @@ -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 = ({ const location = useLocation(); const sidebarView = sidebarViewFromPath(location.pathname); + const isSettingsPanel = isSettingsView(sidebarView); + return (
{mobileBack ? ( @@ -74,7 +76,7 @@ export const AgentPageHeader: FC = ({ 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", )} > diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 49e170611d954..0b4dcc07685a8 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -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 = ({ section = "providers", sectionLabel, sectionDescription, - sectionBadge, providerConfigsData, modelConfigsData, modelCatalogData, @@ -302,7 +300,6 @@ export const ChatModelAdminPanel: FC = ({ = ({ = ({ sectionLabel, sectionDescription, - sectionBadge, providerStates, selectedProvider, selectedProviderState, @@ -233,7 +231,6 @@ export const ModelsSection: FC = ({ description={ sectionDescription ?? "Manage models available to Agents." } - badge={sectionBadge} action={addButton || undefined} /> )} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx index 5ef85a4cf2df0..75e0ca2fcb3db 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx @@ -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 = ({ sectionLabel, sectionDescription, - sectionBadge, providerStates, providerConfigsUnavailable, isProviderMutationPending, @@ -134,7 +132,6 @@ export const ProvidersSection: FC = ({ description={ sectionDescription ?? "Configure AI providers to use with Agents." } - badge={sectionBadge} /> )}
diff --git a/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx b/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx deleted file mode 100644 index 9c7d85bfdbbdc..0000000000000 --- a/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
- {canManageAdminSetting && ( -
-
-

- Let users record chat debug logs -

- -
-
-
- {forcedByDeployment ? ( -

- Debug logging is already enabled deployment-wide, so this - per-user setting has no effect right now. -

- ) : ( -

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

- )} -
- - onSaveAdminSetting({ allow_users: checked }) - } - aria-label="Allow users to enable chat debug logging" - disabled={forcedByDeployment || isSavingAdminSetting} - /> -
- {isSaveAdminSettingError && ( -

- Failed to save the admin debug logging setting. -

- )} -
- )} - -
-
-

- Record debug logs for my chats -

-
-
-
- {forcedByDeployment ? ( -

- An administrator has enabled debug logging for every chat in - this deployment, so this toggle is locked on. -

- ) : userToggleAllowed ? ( -

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

- ) : ( -

- An administrator hasn't allowed users to record chat debug logs - yet. -

- )} -
- - onSaveUserSetting({ debug_logging_enabled: checked }) - } - aria-label="Enable personal chat debug logging" - disabled={ - forcedByDeployment || !userToggleAllowed || isSavingUserSetting - } - /> -
- {isSaveUserSettingError && ( -

- Failed to save your chat debug logging preference. -

- )} -
-
- ); -}; diff --git a/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx b/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx index 6e31f52f32315..ed0c275347e45 100644 --- a/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx +++ b/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx @@ -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<

Explore subagent model

-

Optional deployment-wide model override for read-only Explore diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx index 59c9f30a50d1e..21c49fcf666d2 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx @@ -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 = ({ @@ -252,7 +244,6 @@ const ServerList: FC = ({ onAdd, sectionLabel, sectionDescription, - sectionBadge, }) => { return ( <> @@ -262,7 +253,6 @@ const ServerList: FC = ({ sectionDescription ?? "Configure external MCP servers that provide additional tools for Coder Agents." } - badge={sectionBadge} action={

Custom instructions applied when the agent enters planning mode. These diff --git a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx b/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx index d1e06103ab8f2..0487739537f76 100644 --- a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx +++ b/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx @@ -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 = ({

Conversation Retention Period

-
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(); }, }; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 1cd990c8ae50d..4fe24b6644b71 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -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 { + return view.panel === "settings" || view.panel === "settings-admin"; +} + interface AgentsSidebarProps { chats: readonly Chat[]; chatErrorReasons: Record; @@ -816,12 +852,20 @@ export const AgentsSidebar: FC = (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 = (props) => { onOpenRenameDialog: onRenameTitle ? setChatPendingRename : undefined, }; - const subNavTitle = "Settings"; + const subNavTitle = + settingsPanel === "settings-admin" ? "Manage Agents" : "Settings"; return (
{/* ── Panel 1: Chats ── */}
@@ -1054,7 +1099,7 @@ export const AgentsSidebar: FC = (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", )} > @@ -1266,10 +1311,10 @@ export const AgentsSidebar: FC = (props) => {
{/* Back header */}
@@ -1281,14 +1326,28 @@ export const AgentsSidebar: FC = (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" > - - - + {settingsPanel === "settings-admin" ? ( + + + + ) : ( + + + + )}
{onCollapse && ( @@ -1305,79 +1364,115 @@ export const AgentsSidebar: FC = (props) => {
{/* Sub-navigation items */} - {sidebarView.panel === "settings" && ( + {settingsPanel === "settings" ? ( + ) : ( + )}
{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 }) => ( <> - - {label} - {adminOnly && ( - - - - - - - Admin only - - )} - + {label} + {(adminOnly || TrailingIcon) && ( + + {adminOnly && ( + + + + + + + Admin only + + )} + {TrailingIcon && } + + )} ); @@ -1443,6 +1543,7 @@ const SettingsNavItem: FC = ({ active, adminOnly, disabled, + trailingIcon, ...rest }) => { if (rest.to != null) { @@ -1456,7 +1557,12 @@ const SettingsNavItem: FC = ({ aria-current={active ? "page" : undefined} tabIndex={disabled ? -1 : undefined} > - + ); } @@ -1469,7 +1575,12 @@ const SettingsNavItem: FC = ({ className={navItemClassName(active, disabled)} aria-current={active ? "page" : undefined} > - + ); }; diff --git a/site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts b/site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts new file mode 100644 index 0000000000000..9113b9f30d3a4 --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts @@ -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); + }); +}); diff --git a/site/src/pages/AgentsPage/components/SpendDrillInView.tsx b/site/src/pages/AgentsPage/components/SpendDrillInView.tsx index 5767efa16601d..2fca900402afb 100644 --- a/site/src/pages/AgentsPage/components/SpendDrillInView.tsx +++ b/site/src/pages/AgentsPage/components/SpendDrillInView.tsx @@ -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 = ({ } action={ System Instructions -
diff --git a/site/src/pages/AgentsPage/components/UserChatDebugLoggingSettings.tsx b/site/src/pages/AgentsPage/components/UserChatDebugLoggingSettings.tsx new file mode 100644 index 0000000000000..43d059788dc19 --- /dev/null +++ b/site/src/pages/AgentsPage/components/UserChatDebugLoggingSettings.tsx @@ -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 ( +
+

+ Record debug logs for my chats +

+
+
+ {forcedByDeployment ? ( +

+ An administrator has enabled debug logging for every chat in this + deployment, so this toggle is locked on. +

+ ) : ( +

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

+ )} +
+ + onSaveUserSetting({ debug_logging_enabled: checked }) + } + aria-label="Enable personal chat debug logging" + disabled={forcedByDeployment || isSavingUserSetting} + /> +
+ {isSaveUserSettingError && ( +

+ Failed to save your chat debug logging preference. +

+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/VirtualDesktopSettings.tsx b/site/src/pages/AgentsPage/components/VirtualDesktopSettings.tsx index 0e88edcc04b71..999498831f6f5 100644 --- a/site/src/pages/AgentsPage/components/VirtualDesktopSettings.tsx +++ b/site/src/pages/AgentsPage/components/VirtualDesktopSettings.tsx @@ -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 = ({

Virtual Desktop

- Experimental feature diff --git a/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx b/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx index affc379af2527..261b1083fda3b 100644 --- a/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx +++ b/site/src/pages/AgentsPage/components/WorkspaceAutostopSettings.tsx @@ -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 = ({

Workspace Autostop Fallback

-
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( > } /> }> - } /> - } /> + } /> + } /> + } + /> + } + /> + } + /> + } /> + } /> } /> } /> } />