mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: move instructions to AI settings (#26624)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/instructions">
|
||||
Instructions
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/lifecycle">
|
||||
Lifecycle
|
||||
|
||||
+16
-9
@@ -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 (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<AgentSettingsInstructionsPageView
|
||||
<title>{pageTitle("Instructions", "AI Settings")}</title>
|
||||
|
||||
<InstructionsPageView
|
||||
systemPromptData={systemPromptQuery.data}
|
||||
planModeInstructionsData={planModeInstructionsQuery.data}
|
||||
onSaveSystemPrompt={saveSystemPromptMutation.mutate}
|
||||
isSavingSystemPrompt={saveSystemPromptMutation.isPending}
|
||||
isSaveSystemPromptError={saveSystemPromptMutation.isError}
|
||||
onSavePlanModeInstructions={savePlanModeInstructionsMutation.mutate}
|
||||
isSavingPlanModeInstructions={
|
||||
onSaveSystemPrompt={saveSystemPromptMutation.mutateAsync}
|
||||
onSavePlanModeInstructions={
|
||||
savePlanModeInstructionsMutation.mutateAsync
|
||||
}
|
||||
onResetSystemPromptSave={saveSystemPromptMutation.reset}
|
||||
onResetPlanModeInstructionsSave={savePlanModeInstructionsMutation.reset}
|
||||
isSaving={
|
||||
saveSystemPromptMutation.isPending ||
|
||||
savePlanModeInstructionsMutation.isPending
|
||||
}
|
||||
isSaveSystemPromptError={saveSystemPromptMutation.isError}
|
||||
isSavePlanModeInstructionsError={
|
||||
savePlanModeInstructionsMutation.isError
|
||||
}
|
||||
@@ -49,4 +56,4 @@ const AgentSettingsInstructionsPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsInstructionsPage;
|
||||
export default InstructionsPage;
|
||||
@@ -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<typeof InstructionsPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InstructionsPageView>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSystemPromptValue("New")}
|
||||
className="sr-only"
|
||||
>
|
||||
Simulate system prompt refetch
|
||||
</button>
|
||||
<InstructionsPageView
|
||||
systemPromptData={{
|
||||
system_prompt: systemPromptValue,
|
||||
include_default_system_prompt: false,
|
||||
default_system_prompt: mockDefaultSystemPrompt,
|
||||
}}
|
||||
planModeInstructionsData={{
|
||||
plan_mode_instructions: "Baseline plan mode guidance.",
|
||||
}}
|
||||
onSaveSystemPrompt={fn()}
|
||||
onSavePlanModeInstructions={fn()}
|
||||
onResetSystemPromptSave={fn()}
|
||||
onResetPlanModeInstructionsSave={fn()}
|
||||
isSaving={false}
|
||||
isSaveSystemPromptError={false}
|
||||
isSavePlanModeInstructionsError={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PartialSaveFailureCancelResyncsToServer: Story = {
|
||||
render: () => <RefetchPromptWrapper />,
|
||||
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();
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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> | void;
|
||||
onSavePlanModeInstructions: (
|
||||
req: TypesGen.UpdateChatPlanModeInstructionsRequest,
|
||||
) => Promise<void> | void;
|
||||
onResetSystemPromptSave: () => void;
|
||||
onResetPlanModeInstructionsSave: () => void;
|
||||
isSaving: boolean;
|
||||
isSaveSystemPromptError: boolean;
|
||||
isSavePlanModeInstructionsError: boolean;
|
||||
}
|
||||
|
||||
export const InstructionsPageView: FC<InstructionsPageViewProps> = ({
|
||||
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 (
|
||||
<InstructionsForm
|
||||
systemPromptData={systemPromptData}
|
||||
planModeInstructionsData={planModeInstructionsData}
|
||||
{...formProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface InstructionsFormProps {
|
||||
systemPromptData: TypesGen.ChatSystemPromptResponse;
|
||||
planModeInstructionsData: TypesGen.ChatPlanModeInstructionsResponse;
|
||||
onSaveSystemPrompt: (
|
||||
req: TypesGen.UpdateChatSystemPromptRequest,
|
||||
) => Promise<void> | void;
|
||||
onSavePlanModeInstructions: (
|
||||
req: TypesGen.UpdateChatPlanModeInstructionsRequest,
|
||||
) => Promise<void> | void;
|
||||
onResetSystemPromptSave: () => void;
|
||||
onResetPlanModeInstructionsSave: () => void;
|
||||
isSaving: boolean;
|
||||
isSaveSystemPromptError: boolean;
|
||||
isSavePlanModeInstructionsError: boolean;
|
||||
}
|
||||
|
||||
const InstructionsForm: FC<InstructionsFormProps> = ({
|
||||
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 (
|
||||
<div className="flex max-w-4xl flex-col gap-8">
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>Instructions</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Control the system prompts and plan mode instructions used across the
|
||||
deployment.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
<form
|
||||
className="flex flex-col rounded-lg border border-solid border-border p-6"
|
||||
onSubmit={form.handleSubmit}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-sans text-sm font-normal leading-6 text-content-primary">
|
||||
<Switch
|
||||
checked={form.values.include_default_system_prompt}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setFieldValue("include_default_system_prompt", checked)
|
||||
}
|
||||
aria-label="Include Coder Agents default system prompt"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span>Include Coder Agents default system prompt.</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() => setShowDefaultPromptPreview(true)}
|
||||
className="min-w-0 px-0 font-sans text-sm font-normal leading-6 text-content-link hover:text-content-link"
|
||||
>
|
||||
View prompt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="mt-4 mb-2 font-sans text-sm font-bold leading-6 text-content-primary"
|
||||
htmlFor="system_prompt"
|
||||
>
|
||||
Additional system instructions
|
||||
</label>
|
||||
<TextareaAutosize
|
||||
className="w-full resize-none overflow-y-auto rounded-lg border border-solid border-border bg-surface-primary px-4 py-3 font-sans text-sm font-normal leading-6 text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30 [scrollbar-width:thin]"
|
||||
id="system_prompt"
|
||||
placeholder="Instructions appended to every agent session"
|
||||
name="system_prompt"
|
||||
value={form.values.system_prompt}
|
||||
onChange={form.handleChange}
|
||||
disabled={isDisabled}
|
||||
minRows={1}
|
||||
maxRows={TEXTAREA_MAX_ROWS}
|
||||
/>
|
||||
{systemInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning" className="mt-2">
|
||||
<AlertDescription>
|
||||
This text contains {systemInvisibleCharCount} invisible Unicode{" "}
|
||||
{systemInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<label
|
||||
className="mt-8 mb-2 font-sans text-sm font-bold leading-6 text-content-primary"
|
||||
htmlFor="plan_mode_instructions"
|
||||
>
|
||||
Additional plan mode instructions
|
||||
</label>
|
||||
<TextareaAutosize
|
||||
className="w-full resize-none overflow-y-auto rounded-lg border border-solid border-border bg-surface-primary px-4 py-3 font-sans text-sm font-normal leading-6 text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30 [scrollbar-width:thin]"
|
||||
id="plan_mode_instructions"
|
||||
placeholder="Instructions applied when the agent enters plan mode"
|
||||
name="plan_mode_instructions"
|
||||
value={form.values.plan_mode_instructions}
|
||||
onChange={form.handleChange}
|
||||
disabled={isDisabled}
|
||||
minRows={4}
|
||||
maxRows={TEXTAREA_MAX_ROWS}
|
||||
/>
|
||||
{planModeInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning" className="mt-2">
|
||||
<AlertDescription>
|
||||
This text contains {planModeInvisibleCharCount} invisible Unicode{" "}
|
||||
{planModeInvisibleCharCount !== 1 ? "characters" : "character"}{" "}
|
||||
that could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isSaveSystemPromptError && (
|
||||
<p className="m-0 mt-4 text-xs text-content-destructive">
|
||||
Failed to save system prompt.
|
||||
</p>
|
||||
)}
|
||||
{isSavePlanModeInstructionsError && (
|
||||
<p className="m-0 mt-4 text-xs text-content-destructive">
|
||||
Failed to save plan mode instructions.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Save failures leave mutation errors outside Formik state, so
|
||||
// both stores must reset before the clean form disables actions.
|
||||
onResetSystemPromptSave();
|
||||
onResetPlanModeInstructionsSave();
|
||||
form.resetForm({ values: initialValues });
|
||||
}}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
(!form.dirty &&
|
||||
!isSaveSystemPromptError &&
|
||||
!isSavePlanModeInstructionsError)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isDisabled || !form.dirty}>
|
||||
{isSaving && <Spinner loading className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{showDefaultPromptPreview && (
|
||||
<TextPreviewDialog
|
||||
content={defaultSystemPrompt}
|
||||
fileName="Default System Prompt"
|
||||
onClose={() => setShowDefaultPromptPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof AgentSettingsInstructionsPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSettingsInstructionsPageView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const AdminWithDefaultToggleOn: Story = {
|
||||
args: {
|
||||
systemPromptData: {
|
||||
system_prompt: "Always use TypeScript for code examples.",
|
||||
include_default_system_prompt: true,
|
||||
default_system_prompt: mockDefaultSystemPrompt,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Include Coder Agents default system prompt",
|
||||
});
|
||||
expect(toggle).toBeChecked();
|
||||
const promptInput = await canvas.findByDisplayValue(
|
||||
"Always use TypeScript for code examples.",
|
||||
);
|
||||
expect(promptInput).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText(/built-in Coder Agents prompt is prepended/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Preview" }));
|
||||
expect(await body.findByText("Default System Prompt")).toBeInTheDocument();
|
||||
expect(body.getByText(mockDefaultSystemPrompt)).toBeInTheDocument();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() => {
|
||||
expect(body.queryByText("Default System Prompt")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(toggle);
|
||||
const promptForm = promptInput.closest("form");
|
||||
if (!(promptForm instanceof HTMLFormElement)) {
|
||||
throw new Error("Expected system prompt textarea to live inside a form.");
|
||||
}
|
||||
const saveButton = within(promptForm).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AdminWithDefaultToggleOff: Story = {
|
||||
args: {
|
||||
systemPromptData: {
|
||||
system_prompt: "You are a custom assistant.",
|
||||
include_default_system_prompt: false,
|
||||
default_system_prompt: mockDefaultSystemPrompt,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Include Coder Agents default system prompt",
|
||||
});
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(
|
||||
await canvas.findByDisplayValue("You are a custom assistant."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText(/only the additional instructions below are used/i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const InvisibleUnicodeWarningSystemPrompt: Story = {
|
||||
args: {
|
||||
systemPromptData: {
|
||||
system_prompt:
|
||||
"Normal prompt text\u200b\u200b\u200b\u200bhidden instruction",
|
||||
include_default_system_prompt: true,
|
||||
default_system_prompt: mockDefaultSystemPrompt,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText("System instructions");
|
||||
const alert = await canvas.findByText(/invisible Unicode/);
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(alert.textContent).toContain("4");
|
||||
},
|
||||
};
|
||||
|
||||
// The deleted combined story covered both prompt editors on one page. After
|
||||
// the split, this story covers the system prompt half and the General page
|
||||
// stories cover the personal instructions half.
|
||||
export const NoWarningForCleanPrompt: Story = {
|
||||
args: {
|
||||
systemPromptData: {
|
||||
system_prompt: "You are a helpful coding assistant.",
|
||||
include_default_system_prompt: true,
|
||||
default_system_prompt: mockDefaultSystemPrompt,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText("System instructions");
|
||||
await canvas.findByDisplayValue("You are a helpful coding assistant.");
|
||||
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesPlanModeInstructions: Story = {
|
||||
args: {
|
||||
planModeInstructionsData: { plan_mode_instructions: "" },
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const textarea = await canvas.findByPlaceholderText(
|
||||
"Additional instructions for planning mode",
|
||||
);
|
||||
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, "Always produce a concise plan first.");
|
||||
|
||||
const planModeForm = textarea.closest("form");
|
||||
if (!(planModeForm instanceof HTMLFormElement)) {
|
||||
throw new Error(
|
||||
"Expected plan mode instructions textarea to live inside a form.",
|
||||
);
|
||||
}
|
||||
const saveButton = within(planModeForm).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSavePlanModeInstructions).toHaveBeenCalledWith(
|
||||
{ plan_mode_instructions: "Always produce a concise plan first." },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SectionHeader
|
||||
label="Instructions"
|
||||
description="Control the system prompts and plan mode instructions used across the deployment."
|
||||
/>
|
||||
<SystemInstructionsSettings
|
||||
systemPromptData={systemPromptData}
|
||||
onSaveSystemPrompt={onSaveSystemPrompt}
|
||||
isSavingSystemPrompt={isSavingSystemPrompt}
|
||||
isSaveSystemPromptError={isSaveSystemPromptError}
|
||||
isAnyPromptSaving={isAnyPromptSaving}
|
||||
/>
|
||||
<PlanModeInstructionsSettings
|
||||
planModeInstructionsData={planModeInstructionsData}
|
||||
onSavePlanModeInstructions={onSavePlanModeInstructions}
|
||||
isSavePlanModeInstructionsError={isSavePlanModeInstructionsError}
|
||||
isAnyPromptSaving={isAnyPromptSaving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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: <AgentSettingsCompactionPage /> },
|
||||
{
|
||||
path: "instructions",
|
||||
element: <AgentSettingsInstructionsPage />,
|
||||
element: <Navigate to="/ai/settings/instructions" replace />,
|
||||
},
|
||||
{ path: "experiments", element: <AgentSettingsExperimentsPage /> },
|
||||
{
|
||||
|
||||
@@ -201,9 +201,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
|
||||
<SettingsNavItem
|
||||
icon={ReceiptTextIcon}
|
||||
label="Instructions"
|
||||
active={settingsSection === "instructions"}
|
||||
to="/agents/settings/instructions"
|
||||
state={location.state}
|
||||
active={false}
|
||||
to="/ai/settings/instructions"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={FlaskConicalIcon}
|
||||
|
||||
@@ -46,13 +46,6 @@ describe("sidebarViewFromPath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -6,13 +6,9 @@ type SidebarView =
|
||||
|
||||
const ADMIN_SETTINGS_SECTIONS = new Set([
|
||||
"agents",
|
||||
"templates",
|
||||
"models",
|
||||
"mcp-servers",
|
||||
"spend",
|
||||
"instructions",
|
||||
"experiments",
|
||||
"lifecycle",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
<form className="space-y-2" onSubmit={form.handleSubmit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Plan mode instructions
|
||||
</h3>
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
Custom instructions applied when the agent enters planning mode. These
|
||||
supplement the built-in planning behavior.
|
||||
</p>
|
||||
<TextareaAutosize
|
||||
className={cn(
|
||||
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30",
|
||||
isPlanModeInstructionsOverflowing &&
|
||||
"overflow-y-auto [scrollbar-width:thin]",
|
||||
)}
|
||||
placeholder="Additional instructions for planning mode"
|
||||
name="plan_mode_instructions"
|
||||
value={form.values.plan_mode_instructions}
|
||||
onChange={form.handleChange}
|
||||
onHeightChange={(height) =>
|
||||
setIsPlanModeInstructionsOverflowing(height >= 240)
|
||||
}
|
||||
disabled={isPlanModeInstructionsDisabled}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
/>
|
||||
{planModeInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {planModeInvisibleCharCount} invisible Unicode{" "}
|
||||
{planModeInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => form.setFieldValue("plan_mode_instructions", "")}
|
||||
disabled={
|
||||
isPlanModeInstructionsDisabled ||
|
||||
!form.values.plan_mode_instructions
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={
|
||||
isPlanModeInstructionsDisabled ||
|
||||
!(form.dirty && hasLoadedPlanModeInstructions)
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{isSavePlanModeInstructionsError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save plan mode instructions.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold text-content-primary">
|
||||
System instructions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
|
||||
<span>Include Coder Agents default system prompt</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() => setShowDefaultPromptPreview(true)}
|
||||
disabled={!hasLoadedSystemPrompt}
|
||||
className="min-w-0 px-0 text-content-link hover:text-content-link"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.values.include_default_system_prompt}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setFieldValue("include_default_system_prompt", checked)
|
||||
}
|
||||
aria-label="Include Coder Agents default system prompt"
|
||||
disabled={isSystemPromptDisabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{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."}
|
||||
</p>
|
||||
<TextareaAutosize
|
||||
className={cn(
|
||||
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-sm leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30",
|
||||
isSystemPromptOverflowing &&
|
||||
"overflow-y-auto [scrollbar-width:thin]",
|
||||
)}
|
||||
placeholder="Additional instructions for all users"
|
||||
name="system_prompt"
|
||||
value={form.values.system_prompt}
|
||||
onChange={form.handleChange}
|
||||
onHeightChange={(height) =>
|
||||
setIsSystemPromptOverflowing(height >= 240)
|
||||
}
|
||||
disabled={isSystemPromptDisabled}
|
||||
minRows={1}
|
||||
/>
|
||||
{systemInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {systemInvisibleCharCount} invisible Unicode{" "}
|
||||
{systemInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mt-2 flex min-h-6 justify-end gap-2">
|
||||
{(form.dirty || isSavedVisible || isSavingSystemPrompt) &&
|
||||
(isSavedVisible ? (
|
||||
<TemporarySavedState />
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => form.setFieldValue("system_prompt", "")}
|
||||
disabled={
|
||||
isSystemPromptDisabled || !form.values.system_prompt
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
disabled={
|
||||
isSystemPromptDisabled ||
|
||||
!(form.dirty && hasLoadedSystemPrompt)
|
||||
}
|
||||
>
|
||||
{isSavingSystemPrompt && (
|
||||
<Spinner loading className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{isSaveSystemPromptError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save system prompt.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{showDefaultPromptPreview && (
|
||||
<TextPreviewDialog
|
||||
content={defaultSystemPrompt}
|
||||
fileName="Default System Prompt"
|
||||
onClose={() => setShowDefaultPromptPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+9
-4
@@ -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(
|
||||
/>
|
||||
<Route index element={<AISettingsIndexPage />} />
|
||||
<Route path="models" element={<AISettingsModelsPage />} />
|
||||
<Route
|
||||
path="instructions"
|
||||
element={<AISettingsInstructionsPage />}
|
||||
/>
|
||||
<Route path="lifecycle" element={<AISettingsLifecyclePage />} />
|
||||
<Route path="templates" element={<AISettingsTemplatesPage />} />
|
||||
<Route path="models/add" element={<AISettingsAddModelPage />} />
|
||||
@@ -829,7 +834,7 @@ export const router = createBrowserRouter(
|
||||
/>
|
||||
<Route
|
||||
path="instructions"
|
||||
element={<AgentSettingsInstructionsPage />}
|
||||
element={<Navigate to="/ai/settings/instructions" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="experiments"
|
||||
|
||||
Reference in New Issue
Block a user