Files
coder/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx
T
Cian Johnston c933ddcffd fix(agents): persist system prompt server-side instead of localStorage (#22857)
## Problem

The Admin → Agents → System Prompt textarea saved only to the browser's
`localStorage`. The value was never sent to the backend, never stored in
the database, and never injected into chats. Entering text, clicking
Save, and refreshing the page showed no changes — the prompt was
effectively a no-op.

## Root Cause

Three disconnected layers:
1. **Frontend** wrote to `localStorage`, never called an API.
2. **`handleCreateChat`** never read `savedSystemPrompt`.
3. **Backend** hardcoded `chatd.DefaultSystemPrompt` on every chat
creation — no field in `CreateChatRequest` accepted a custom prompt.

## Changes

### Database
- Added `GetChatSystemPrompt` / `UpsertChatSystemPrompt` queries on the
existing `site_configs` table (no migration needed).

### API
- `GET /api/experimental/chats/system-prompt` — returns the configured
prompt (any authenticated user).
- `PUT /api/experimental/chats/system-prompt` — sets the prompt
(admin-only, `rbac: deployment_config update`).
- Input validation: max 32 KiB prompt length.

### Backend
- `resolvedChatSystemPrompt(ctx)` checks for a custom prompt in the DB,
falls back to `chatd.DefaultSystemPrompt` when empty/unset.
- Logs a warning on DB errors instead of silently swallowing them.
- Replaced the hardcoded `defaultChatSystemPrompt()` call in chat
creation.

### Frontend
- Replaced `localStorage` read/write with React Query
`useQuery`/`useMutation` backed by the new endpoints.
- Fixed `useEffect` draft sync to avoid clobbering in-progress user
edits on refetch.
- Added `try/catch` error handling on save (draft stays dirty for
retry).
- Save button disabled during mutation (`isSavingSystemPrompt`).
- Query key follows kebab-case convention (`chat-system-prompt`).

### UX
- Added hint: "When empty, the built-in default prompt is used."

### Tests
- `TestChatSystemPrompt`: GET returns empty when unset, admin can set,
non-admin gets 403.
- dbauthz `TestMethodTestSuite` coverage for both new querier methods.
2026-03-10 11:46:53 +00:00

205 lines
6.5 KiB
TypeScript

import { Button } from "components/Button/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import type { LucideIcon } from "lucide-react";
import { BoxesIcon, KeyRoundIcon, UserIcon, XIcon } from "lucide-react";
import { type FC, type FormEvent, useEffect, useMemo, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { cn } from "utils/cn";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
import { SectionHeader } from "./SectionHeader";
type ConfigureAgentsSection = "providers" | "system-prompt" | "models";
type ConfigureAgentsSectionOption = {
id: ConfigureAgentsSection;
label: string;
icon: LucideIcon;
};
interface ConfigureAgentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
canManageChatModelConfigs: boolean;
canSetSystemPrompt: boolean;
systemPromptDraft: string;
onSystemPromptDraftChange: (value: string) => void;
onSaveSystemPrompt: (event: FormEvent) => void;
isSystemPromptDirty: boolean;
saveSystemPromptError: boolean;
isDisabled: boolean;
}
export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
open,
onOpenChange,
canManageChatModelConfigs,
canSetSystemPrompt,
systemPromptDraft,
onSystemPromptDraftChange,
onSaveSystemPrompt,
isSystemPromptDirty,
saveSystemPromptError,
isDisabled,
}) => {
const configureSectionOptions = useMemo<
readonly ConfigureAgentsSectionOption[]
>(() => {
const options: ConfigureAgentsSectionOption[] = [];
if (canManageChatModelConfigs) {
options.push({
id: "providers",
label: "Providers",
icon: KeyRoundIcon,
});
options.push({
id: "models",
label: "Models",
icon: BoxesIcon,
});
}
if (canSetSystemPrompt) {
options.push({
id: "system-prompt",
label: "Behavior",
icon: UserIcon,
});
}
return options;
}, [canManageChatModelConfigs, canSetSystemPrompt]);
const [userActiveSection, setUserActiveSection] =
useState<ConfigureAgentsSection>("providers");
// Derive the effective section — validated against current options
// every render so we never show an unavailable tab.
const activeSection = configureSectionOptions.some(
(s) => s.id === userActiveSection,
)
? userActiveSection
: (configureSectionOptions[0]?.id ?? "providers");
// Reset to the preferred initial section each time the dialog opens.
useEffect(() => {
if (open) {
setUserActiveSection("providers");
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[220px_minmax(0,1fr)]">
{/* Visually hidden for accessibility */}
<DialogHeader className="sr-only">
<DialogTitle>Configure Agents</DialogTitle>
<DialogDescription>
Manage providers, system prompt, and available models.
</DialogDescription>
</DialogHeader>
{/* Sidebar */}
<nav className="flex flex-row gap-0.5 overflow-x-auto border-b border-border bg-surface-secondary/40 p-2 md:flex-col md:gap-0.5 md:overflow-x-visible md:border-b-0 md:border-r md:p-4">
<DialogClose asChild>
<Button
variant="subtle"
size="icon-lg"
className="mb-3 shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/50"
>
<XIcon className="text-content-secondary" />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
{configureSectionOptions.map((section) => {
const isActive = section.id === activeSection;
const SectionIcon = section.icon;
return (
<Button
key={section.id}
variant="subtle"
className={cn(
"h-auto justify-start gap-3 rounded-lg border-none px-3 py-1.5 text-left shadow-none",
isActive
? "bg-surface-tertiary/60 text-content-primary hover:bg-surface-tertiary/60"
: "bg-transparent text-content-secondary hover:bg-surface-tertiary/30 hover:text-content-primary",
)}
onClick={() => setUserActiveSection(section.id)}
>
<SectionIcon className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{section.label}</span>
</Button>
);
})}
</nav>
{/* Content */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
{activeSection === "providers" && canManageChatModelConfigs && (
<ChatModelAdminPanel section="providers" sectionLabel="Providers" />
)}
{activeSection === "system-prompt" && canSetSystemPrompt && (
<>
<SectionHeader label="Behavior" />
<form
className="space-y-4"
onSubmit={(event) => void onSaveSystemPrompt(event)}
>
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
System Prompt
</h3>
<p className="m-0 text-xs text-content-secondary">
Admin-only instruction applied to all new chats. When empty,
the built-in default prompt is used.
</p>
<TextareaAutosize
className="min-h-[220px] w-full resize-y 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"
placeholder="Optional. Set deployment-wide instructions for all new chats."
value={systemPromptDraft}
onChange={(event) =>
onSystemPromptDraftChange(event.target.value)
}
disabled={isDisabled}
minRows={7}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => onSystemPromptDraftChange("")}
disabled={isDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isSystemPromptDirty}
>
Save
</Button>
</div>
{saveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</div>
</form>
</>
)}
{activeSection === "models" && canManageChatModelConfigs && (
<ChatModelAdminPanel section="models" sectionLabel="Models" />
)}
</div>
</DialogContent>
</Dialog>
);
};