diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index ad315ecc7c..34500a799a 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -186,8 +186,7 @@ deployment. Use this to encode organizational conventions: - Required review processes before merging. - Any guardrails specific to your environment. -Configure the system prompt from **Agents** > **Settings** > -**Manage Agents** > **Instructions** +Configure the system prompt from **AI Settings** > **Instructions** or via the API at `PUT /api/experimental/chats/config/system-prompt`. See [Platform Controls](./platform-controls/index.md) for details. diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index 5911d66a83..94ad362d7c 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -52,9 +52,7 @@ Administrators can set a system prompt that applies to all agent sessions. This is useful for establishing organizational conventions: coding standards, commit message formats, preferred libraries, or repository-specific context. -This setting is available under **Agents** > **Settings** > -**Manage Agents** > **Instructions** and is only accessible to -administrators. Developers do not see or interact with it. +This setting is available under **AI Settings** > **Instructions** and is only accessible to administrators. Developers do not see or interact with it. ### Plan mode instructions @@ -63,8 +61,7 @@ enters plan mode. These instructions supplement the built-in planning behavior and are useful for organization-specific planning requirements such as required plan sections, approval checkpoints, or review workflows. -This setting is available under **Agents** > **Settings** > -**Manage Agents** > **Instructions**. Developers do not edit it directly. +This setting is available under **AI Settings** > **Instructions**. Developers do not edit it directly. The same value is exposed over the experimental chat configuration API: diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 6e83faaa1d..9ebccff0bf 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -35,6 +35,11 @@ const AISettingsSidebarView: FC = ({ {permissions.editDeploymentConfig && ( Models )} + {permissions.editDeploymentConfig && ( + + Instructions + + )} {permissions.editDeploymentConfig && ( Lifecycle diff --git a/site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPage.tsx similarity index 68% rename from site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx rename to site/src/pages/AISettingsPage/InstructionsPage/InstructionsPage.tsx index ed423856c1..4f546783d6 100644 --- a/site/src/pages/AgentsPage/AgentSettingsInstructionsPage.tsx +++ b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPage.tsx @@ -8,9 +8,10 @@ import { } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; -import { AgentSettingsInstructionsPageView } from "./AgentSettingsInstructionsPageView"; +import { pageTitle } from "#/utils/page"; +import { InstructionsPageView } from "./InstructionsPageView"; -const AgentSettingsInstructionsPage: FC = () => { +const InstructionsPage: FC = () => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); @@ -31,16 +32,22 @@ const AgentSettingsInstructionsPage: FC = () => { return ( - {pageTitle("Instructions", "AI Settings")} + + { ); }; -export default AgentSettingsInstructionsPage; +export default InstructionsPage; diff --git a/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.stories.tsx b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.stories.tsx new file mode 100644 index 0000000000..993c27671b --- /dev/null +++ b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.stories.tsx @@ -0,0 +1,485 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type FC, useState } from "react"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { + InstructionsPageView, + type InstructionsPageViewProps, +} from "./InstructionsPageView"; + +const mockDefaultSystemPrompt = "You are Coder, an AI coding assistant."; +const saveOrder: string[] = []; + +const baseArgs: InstructionsPageViewProps = { + 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(async () => undefined), + onSavePlanModeInstructions: fn(async () => undefined), + onResetSystemPromptSave: fn(), + onResetPlanModeInstructionsSave: fn(), + isSaving: false, + isSaveSystemPromptError: false, + isSavePlanModeInstructionsError: false, +}; + +const meta = { + title: "pages/AISettingsPage/InstructionsPage/InstructionsPageView", + component: InstructionsPageView, + 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("Additional system instructions"), + ).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("button", { name: "View prompt" })); + 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 saveButton = canvas.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("Additional system instructions"), + ).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("Additional system instructions"); + const alert = await canvas.findByText(/invisible Unicode/); + expect(alert).toBeInTheDocument(); + expect(alert.textContent).toContain("4"); + }, +}; + +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("Additional system instructions"); + await canvas.findByDisplayValue("You are a helpful coding assistant."); + expect(canvas.queryByText(/invisible Unicode/)).toBeNull(); + }, +}; + +export const SavesSystemPrompt: Story = { + args: { + systemPromptData: { + system_prompt: "", + include_default_system_prompt: false, + default_system_prompt: mockDefaultSystemPrompt, + }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const textarea = await canvas.findByLabelText( + "Additional system instructions", + ); + + await userEvent.type(textarea, "Always explain tradeoffs first."); + await userEvent.click( + canvas.getByRole("switch", { + name: "Include Coder Agents default system prompt", + }), + ); + + const saveButton = canvas.getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(args.onSaveSystemPrompt).toHaveBeenCalledWith({ + system_prompt: "Always explain tradeoffs first.", + include_default_system_prompt: true, + }); + }); + expect(args.onSavePlanModeInstructions).not.toHaveBeenCalled(); + }, +}; + +export const SavesPlanModeInstructions: Story = { + args: { + planModeInstructionsData: { plan_mode_instructions: "" }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const textarea = await canvas.findByLabelText( + "Additional plan mode instructions", + ); + + await userEvent.clear(textarea); + await userEvent.type(textarea, "Always produce a concise plan first."); + + const saveButton = canvas.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(args.onSaveSystemPrompt).not.toHaveBeenCalled(); + }, +}; + +export const SystemPromptSaveErrorThenCancel: Story = { + args: { + systemPromptData: { + system_prompt: "Baseline system prompt.", + include_default_system_prompt: false, + default_system_prompt: mockDefaultSystemPrompt, + }, + onResetSystemPromptSave: fn(), + onResetPlanModeInstructionsSave: fn(), + isSaveSystemPromptError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect( + canvas.getByText("Failed to save system prompt."), + ).toBeInTheDocument(); + + const textarea = await canvas.findByLabelText( + "Additional system instructions", + ); + await userEvent.type(textarea, " edited"); + + const cancelButton = canvas.getByRole("button", { + name: "Cancel", + }); + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + }); + await userEvent.click(cancelButton); + + expect(args.onResetSystemPromptSave).toHaveBeenCalledTimes(1); + expect(args.onResetPlanModeInstructionsSave).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect( + canvas.getByDisplayValue("Baseline system prompt."), + ).toBeInTheDocument(); + }); + }, +}; + +export const CleanSystemPromptErrorCancelDismisses: Story = { + args: { + onResetSystemPromptSave: fn(), + onResetPlanModeInstructionsSave: fn(), + isSaveSystemPromptError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect( + canvas.getByText("Failed to save system prompt."), + ).toBeInTheDocument(); + + const cancelButton = canvas.getByRole("button", { + name: "Cancel", + }); + const saveButton = canvas.getByRole("button", { + name: "Save", + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + }); + expect(saveButton).toBeDisabled(); + + await userEvent.click(cancelButton); + + expect(args.onResetSystemPromptSave).toHaveBeenCalledTimes(1); + expect(args.onResetPlanModeInstructionsSave).toHaveBeenCalledTimes(1); + }, +}; + +export const CleanPlanModeInstructionsErrorCancelDismisses: Story = { + args: { + onResetSystemPromptSave: fn(), + onResetPlanModeInstructionsSave: fn(), + isSavePlanModeInstructionsError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect( + canvas.getByText("Failed to save plan mode instructions."), + ).toBeInTheDocument(); + + const cancelButton = canvas.getByRole("button", { + name: "Cancel", + }); + const saveButton = canvas.getByRole("button", { + name: "Save", + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + }); + expect(saveButton).toBeDisabled(); + + await userEvent.click(cancelButton); + + expect(args.onResetSystemPromptSave).toHaveBeenCalledTimes(1); + expect(args.onResetPlanModeInstructionsSave).toHaveBeenCalledTimes(1); + }, +}; + +export const CleanBothErrorsCancelDismisses: Story = { + args: { + onResetSystemPromptSave: fn(), + onResetPlanModeInstructionsSave: fn(), + isSaveSystemPromptError: true, + isSavePlanModeInstructionsError: true, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect( + canvas.getByText("Failed to save system prompt."), + ).toBeInTheDocument(); + expect( + canvas.getByText("Failed to save plan mode instructions."), + ).toBeInTheDocument(); + + const cancelButton = canvas.getByRole("button", { + name: "Cancel", + }); + const saveButton = canvas.getByRole("button", { + name: "Save", + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + }); + expect(saveButton).toBeDisabled(); + + await userEvent.click(cancelButton); + + expect(args.onResetSystemPromptSave).toHaveBeenCalledTimes(1); + expect(args.onResetPlanModeInstructionsSave).toHaveBeenCalledTimes(1); + }, +}; + +export const PlanModeInstructionsSaveError: Story = { + args: { + isSavePlanModeInstructionsError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + canvas.getByText("Failed to save plan mode instructions."), + ).toBeInTheDocument(); + }, +}; + +export const SavesBothSections: Story = { + args: { + systemPromptData: { + system_prompt: "", + include_default_system_prompt: false, + default_system_prompt: mockDefaultSystemPrompt, + }, + planModeInstructionsData: { plan_mode_instructions: "" }, + onSaveSystemPrompt: fn(async () => { + saveOrder.push("system"); + }), + onSavePlanModeInstructions: fn(async () => { + saveOrder.push("plan"); + }), + }, + play: async ({ canvasElement, args }) => { + saveOrder.length = 0; + const canvas = within(canvasElement); + + const systemPromptTextarea = await canvas.findByLabelText( + "Additional system instructions", + ); + const planModeTextarea = await canvas.findByLabelText( + "Additional plan mode instructions", + ); + + await userEvent.type(systemPromptTextarea, "New system instruction."); + await userEvent.type(planModeTextarea, "New plan mode guidance."); + + const saveButton = canvas.getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(args.onSaveSystemPrompt).toHaveBeenCalledWith({ + system_prompt: "New system instruction.", + include_default_system_prompt: false, + }); + }); + await waitFor(() => { + expect(args.onSavePlanModeInstructions).toHaveBeenCalledWith({ + plan_mode_instructions: "New plan mode guidance.", + }); + }); + + expect(saveOrder).toEqual(["system", "plan"]); + }, +}; + +const RefetchPromptWrapper: FC = () => { + const [systemPromptValue, setSystemPromptValue] = useState("Old"); + + return ( + <> + + + + ); +}; + +export const PartialSaveFailureCancelResyncsToServer: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByDisplayValue("Old"); + await canvas.findByDisplayValue("Baseline plan mode guidance."); + expect( + canvas.getByText("Failed to save plan mode instructions."), + ).toBeInTheDocument(); + + const planTextarea = await canvas.findByLabelText( + "Additional plan mode instructions", + ); + await userEvent.type(planTextarea, " edited"); + + await userEvent.click( + canvas.getByRole("button", { name: "Simulate system prompt refetch" }), + ); + + const cancelButton = canvas.getByRole("button", { + name: "Cancel", + }); + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + }); + await userEvent.click(cancelButton); + + await waitFor(() => { + expect(canvas.getByDisplayValue("New")).toBeInTheDocument(); + expect(canvas.queryByDisplayValue("Old")).toBeNull(); + }); + }, +}; diff --git a/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.tsx b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.tsx new file mode 100644 index 0000000000..fcafd3c79b --- /dev/null +++ b/site/src/pages/AISettingsPage/InstructionsPage/InstructionsPageView.tsx @@ -0,0 +1,281 @@ +import { useFormik } from "formik"; +import type { FC } from "react"; +import { useState } from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import { toast } from "sonner"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { Switch } from "#/components/Switch/Switch"; +import { TextPreviewDialog } from "#/pages/AgentsPage/components/TextPreviewDialog"; +import { countInvisibleCharacters } from "#/utils/invisibleUnicode"; + +const TEXTAREA_MAX_ROWS = 9; + +export interface InstructionsPageViewProps { + systemPromptData: TypesGen.ChatSystemPromptResponse | undefined; + planModeInstructionsData: + | TypesGen.ChatPlanModeInstructionsResponse + | undefined; + onSaveSystemPrompt: ( + req: TypesGen.UpdateChatSystemPromptRequest, + ) => Promise | void; + onSavePlanModeInstructions: ( + req: TypesGen.UpdateChatPlanModeInstructionsRequest, + ) => Promise | void; + onResetSystemPromptSave: () => void; + onResetPlanModeInstructionsSave: () => void; + isSaving: boolean; + isSaveSystemPromptError: boolean; + isSavePlanModeInstructionsError: boolean; +} + +export const InstructionsPageView: FC = ({ + systemPromptData, + planModeInstructionsData, + ...formProps +}) => { + const hasLoadedInstructions = + systemPromptData !== undefined && planModeInstructionsData !== undefined; + + // Without this gate, Formik would initialize from empty query fallbacks and + // keep those values after query data loads. + if (!hasLoadedInstructions) { + return null; + } + + return ( + + ); +}; + +interface InstructionsFormProps { + systemPromptData: TypesGen.ChatSystemPromptResponse; + planModeInstructionsData: TypesGen.ChatPlanModeInstructionsResponse; + onSaveSystemPrompt: ( + req: TypesGen.UpdateChatSystemPromptRequest, + ) => Promise | void; + onSavePlanModeInstructions: ( + req: TypesGen.UpdateChatPlanModeInstructionsRequest, + ) => Promise | void; + onResetSystemPromptSave: () => void; + onResetPlanModeInstructionsSave: () => void; + isSaving: boolean; + isSaveSystemPromptError: boolean; + isSavePlanModeInstructionsError: boolean; +} + +const InstructionsForm: FC = ({ + systemPromptData, + planModeInstructionsData, + onSaveSystemPrompt, + onSavePlanModeInstructions, + onResetSystemPromptSave, + onResetPlanModeInstructionsSave, + isSaving, + isSaveSystemPromptError, + isSavePlanModeInstructionsError, +}) => { + const [showDefaultPromptPreview, setShowDefaultPromptPreview] = + useState(false); + const defaultSystemPrompt = systemPromptData.default_system_prompt ?? ""; + const initialValues = { + system_prompt: systemPromptData.system_prompt ?? "", + include_default_system_prompt: + systemPromptData.include_default_system_prompt ?? false, + plan_mode_instructions: + planModeInstructionsData.plan_mode_instructions ?? "", + }; + + const form = useFormik({ + initialValues, + onSubmit: async (values, { resetForm, setValues }) => { + onResetSystemPromptSave(); + onResetPlanModeInstructionsSave(); + + try { + if ( + values.system_prompt !== initialValues.system_prompt || + values.include_default_system_prompt !== + initialValues.include_default_system_prompt + ) { + await onSaveSystemPrompt({ + system_prompt: values.system_prompt, + include_default_system_prompt: values.include_default_system_prompt, + }); + } + + if ( + values.plan_mode_instructions !== initialValues.plan_mode_instructions + ) { + await onSavePlanModeInstructions({ + plan_mode_instructions: values.plan_mode_instructions, + }); + } + } catch (error) { + await setValues(values, false); + throw error; + } + + toast.success("Instructions saved successfully."); + resetForm({ values }); + }, + }); + + const systemInvisibleCharCount = countInvisibleCharacters( + form.values.system_prompt, + ); + const planModeInvisibleCharCount = countInvisibleCharacters( + form.values.plan_mode_instructions, + ); + const isDisabled = isSaving || form.isSubmitting; + + return ( +
+ + Instructions + + Control the system prompts and plan mode instructions used across the + deployment. + + + +
+
+ + form.setFieldValue("include_default_system_prompt", checked) + } + aria-label="Include Coder Agents default system prompt" + disabled={isDisabled} + /> +
+ Include Coder Agents default system prompt. + +
+
+ + + + {systemInvisibleCharCount > 0 && ( + + + This text contains {systemInvisibleCharCount} invisible Unicode{" "} + {systemInvisibleCharCount !== 1 ? "characters" : "character"} that + could hide content. These will be stripped on save. + + + )} + + + + {planModeInvisibleCharCount > 0 && ( + + + This text contains {planModeInvisibleCharCount} invisible Unicode{" "} + {planModeInvisibleCharCount !== 1 ? "characters" : "character"}{" "} + that could hide content. These will be stripped on save. + + + )} + + {isSaveSystemPromptError && ( +

+ Failed to save system prompt. +

+ )} + {isSavePlanModeInstructionsError && ( +

+ Failed to save plan mode instructions. +

+ )} + +
+ + +
+ + + {showDefaultPromptPreview && ( + setShowDefaultPromptPreview(false)} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx deleted file mode 100644 index ea8088311a..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.stories.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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 deleted file mode 100644 index 59fc42528b..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsInstructionsPageView.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 2e844f4f75..236f0ad795 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -33,7 +33,6 @@ import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView"; import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage"; import AgentSettingsExperimentsPage from "./AgentSettingsExperimentsPage"; import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage"; -import AgentSettingsInstructionsPage from "./AgentSettingsInstructionsPage"; import AgentSettingsPage from "./AgentSettingsPage"; import AgentSettingsSpendPage from "./AgentSettingsSpendPage"; import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView"; @@ -207,7 +206,7 @@ const agentsRouting = { { path: "compaction", element: }, { path: "instructions", - element: , + element: , }, { path: "experiments", element: }, { diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index f1234990aa..757656c63a 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -201,9 +201,9 @@ export const SettingsPanel: FC = ({ { }); }); - it("returns the lifecycle admin settings section", () => { - expect(sidebarViewFromPath("/agents/settings/lifecycle")).toEqual({ - panel: "settings-admin", - section: "lifecycle", - }); - }); - it("normalizes the admin index route to an undefined section", () => { expect(sidebarViewFromPath("/agents/settings/admin")).toEqual({ panel: "settings-admin", @@ -60,11 +53,23 @@ describe("sidebarViewFromPath", () => { }); }); - it("returns the instructions admin settings section", () => { + it("falls through moved admin sections to the user settings panel", () => { expect(sidebarViewFromPath("/agents/settings/instructions")).toEqual({ - panel: "settings-admin", + panel: "settings", section: "instructions", }); + expect(sidebarViewFromPath("/agents/settings/lifecycle")).toEqual({ + panel: "settings", + section: "lifecycle", + }); + expect(sidebarViewFromPath("/agents/settings/models")).toEqual({ + panel: "settings", + section: "models", + }); + expect(sidebarViewFromPath("/agents/settings/templates")).toEqual({ + panel: "settings", + section: "templates", + }); }); it("falls through unknown settings slugs to the user settings panel", () => { diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts index a69420bba4..852f6ea753 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts @@ -6,13 +6,9 @@ type SidebarView = const ADMIN_SETTINGS_SECTIONS = new Set([ "agents", - "templates", - "models", "mcp-servers", "spend", - "instructions", "experiments", - "lifecycle", ]); /** diff --git a/site/src/pages/AgentsPage/components/PlanModeInstructionsSettings.tsx b/site/src/pages/AgentsPage/components/PlanModeInstructionsSettings.tsx deleted file mode 100644 index b76f2b6149..0000000000 --- a/site/src/pages/AgentsPage/components/PlanModeInstructionsSettings.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useFormik } from "formik"; -import type { FC } from "react"; -import { useState } from "react"; -import TextareaAutosize from "react-textarea-autosize"; -import type * as TypesGen from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; -import { cn } from "#/utils/cn"; -import { countInvisibleCharacters } from "#/utils/invisibleUnicode"; - -interface MutationCallbacks { - onSuccess?: () => void; - onError?: () => void; -} - -interface PlanModeInstructionsSettingsProps { - planModeInstructionsData: - | TypesGen.ChatPlanModeInstructionsResponse - | undefined; - onSavePlanModeInstructions: ( - req: TypesGen.UpdateChatPlanModeInstructionsRequest, - options?: MutationCallbacks, - ) => void; - isSavePlanModeInstructionsError: boolean; - isAnyPromptSaving: boolean; -} - -export const PlanModeInstructionsSettings: FC< - PlanModeInstructionsSettingsProps -> = ({ - planModeInstructionsData, - onSavePlanModeInstructions, - isSavePlanModeInstructionsError, - isAnyPromptSaving, -}) => { - const [ - isPlanModeInstructionsOverflowing, - setIsPlanModeInstructionsOverflowing, - ] = useState(false); - - const hasLoadedPlanModeInstructions = planModeInstructionsData !== undefined; - - const form = useFormik({ - enableReinitialize: true, - initialValues: { - plan_mode_instructions: - planModeInstructionsData?.plan_mode_instructions ?? "", - }, - onSubmit: (values, { resetForm }) => { - onSavePlanModeInstructions(values, { - onSuccess: () => { - resetForm(); - }, - }); - }, - }); - - const planModeInvisibleCharCount = countInvisibleCharacters( - form.values.plan_mode_instructions, - ); - const isPlanModeInstructionsDisabled = - isAnyPromptSaving || !hasLoadedPlanModeInstructions; - - return ( -
-
-

- Plan mode instructions -

-
-

- Custom instructions applied when the agent enters planning mode. These - supplement the built-in planning behavior. -

- - setIsPlanModeInstructionsOverflowing(height >= 240) - } - disabled={isPlanModeInstructionsDisabled} - minRows={4} - maxRows={12} - /> - {planModeInvisibleCharCount > 0 && ( - - - This text contains {planModeInvisibleCharCount} invisible Unicode{" "} - {planModeInvisibleCharCount !== 1 ? "characters" : "character"} that - could hide content. These will be stripped on save. - - - )} -
- - -
- {isSavePlanModeInstructionsError && ( -

- Failed to save plan mode instructions. -

- )} - - ); -}; diff --git a/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx b/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx deleted file mode 100644 index de1a6bb0eb..0000000000 --- a/site/src/pages/AgentsPage/components/SystemInstructionsSettings.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useFormik } from "formik"; -import type { FC } from "react"; -import { useState } from "react"; -import TextareaAutosize from "react-textarea-autosize"; -import type * as TypesGen from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { Switch } from "#/components/Switch/Switch"; -import { - TemporarySavedState, - useTemporarySavedState, -} from "#/components/TemporarySavedState/TemporarySavedState"; -import { cn } from "#/utils/cn"; -import { countInvisibleCharacters } from "#/utils/invisibleUnicode"; -import { TextPreviewDialog } from "./TextPreviewDialog"; - -interface MutationCallbacks { - onSuccess?: () => void; - onError?: () => void; -} - -interface SystemInstructionsSettingsProps { - systemPromptData: TypesGen.ChatSystemPromptResponse | undefined; - onSaveSystemPrompt: ( - req: TypesGen.UpdateChatSystemPromptRequest, - options?: MutationCallbacks, - ) => void; - isSavingSystemPrompt: boolean; - isSaveSystemPromptError: boolean; - isAnyPromptSaving: boolean; -} - -export const SystemInstructionsSettings: FC< - SystemInstructionsSettingsProps -> = ({ - systemPromptData, - onSaveSystemPrompt, - isSavingSystemPrompt, - isSaveSystemPromptError, - isAnyPromptSaving, -}) => { - const [showDefaultPromptPreview, setShowDefaultPromptPreview] = - useState(false); - const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] = - useState(false); - const { isSavedVisible, showSavedState } = useTemporarySavedState(); - - const hasLoadedSystemPrompt = systemPromptData !== undefined; - const defaultSystemPrompt = systemPromptData?.default_system_prompt ?? ""; - - const form = useFormik({ - enableReinitialize: true, - initialValues: { - system_prompt: systemPromptData?.system_prompt ?? "", - include_default_system_prompt: - systemPromptData?.include_default_system_prompt ?? false, - }, - onSubmit: (values, { resetForm }) => { - onSaveSystemPrompt(values, { - onSuccess: () => { - showSavedState(); - resetForm(); - }, - }); - }, - }); - - const systemInvisibleCharCount = countInvisibleCharacters( - form.values.system_prompt, - ); - const isSystemPromptDisabled = isAnyPromptSaving || !hasLoadedSystemPrompt; - - return ( - <> -
-
-

- System instructions -

-
-
-
- Include Coder Agents default system prompt - -
- - form.setFieldValue("include_default_system_prompt", checked) - } - aria-label="Include Coder Agents default system prompt" - disabled={isSystemPromptDisabled} - /> -
-

- {form.values.include_default_system_prompt - ? "The built-in Coder Agents prompt is prepended. Additional instructions below are appended." - : "Only the additional instructions below are used. When empty, no deployment-wide system prompt is sent."} -

- - setIsSystemPromptOverflowing(height >= 240) - } - disabled={isSystemPromptDisabled} - minRows={1} - /> - {systemInvisibleCharCount > 0 && ( - - - This text contains {systemInvisibleCharCount} invisible Unicode{" "} - {systemInvisibleCharCount !== 1 ? "characters" : "character"} that - could hide content. These will be stripped on save. - - - )} -
- {(form.dirty || isSavedVisible || isSavingSystemPrompt) && - (isSavedVisible ? ( - - ) : ( - <> - - - - ))} -
- {isSaveSystemPromptError && ( -

- Failed to save system prompt. -

- )} - - - {showDefaultPromptPreview && ( - setShowDefaultPromptPreview(false)} - /> - )} - - ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index b9dbfef44c..0a16a84894 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -371,9 +371,7 @@ const AgentSettingsGeneralPage = lazy( const AgentSettingsCompactionPage = lazy( () => import("./pages/AgentsPage/AgentSettingsCompactionPage"), ); -const AgentSettingsInstructionsPage = lazy( - () => import("./pages/AgentsPage/AgentSettingsInstructionsPage"), -); + const AgentSettingsExperimentsPage = lazy( () => import("./pages/AgentsPage/AgentSettingsExperimentsPage"), ); @@ -448,6 +446,9 @@ const AISettingsGatewayKeysPage = lazy( const AISettingsModelsPage = lazy( () => import("./pages/AISettingsPage/ModelsPage/ModelsPage"), ); +const AISettingsInstructionsPage = lazy( + () => import("./pages/AISettingsPage/InstructionsPage/InstructionsPage"), +); const AISettingsTemplatesPage = lazy( () => import("./pages/AISettingsPage/TemplatesPage/TemplatesPage"), ); @@ -755,6 +756,10 @@ export const router = createBrowserRouter( /> } /> } /> + } + /> } /> } /> } /> @@ -829,7 +834,7 @@ export const router = createBrowserRouter( /> } + element={} />