diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 105711ec63..e797e571f4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3070,6 +3070,26 @@ class ApiMethods { await this.axios.put("/api/experimental/chats/config/system-prompt", req); }; + getUserChatCustomPrompt = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/user-prompt", + ); + return response.data; + }; + + updateUserChatCustomPrompt = async ( + req: TypesGen.UpdateUserChatCustomPromptRequest, + ): Promise => { + const response = + await this.axios.put( + "/api/experimental/chats/config/user-prompt", + req, + ); + return response.data; + }; + getChatProviderConfigs = async (): Promise => { const response = await this.axios.get( chatProviderConfigsPath, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a68b087b3c..8d7d279f16 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -270,6 +270,22 @@ export const updateChatSystemPrompt = (queryClient: QueryClient) => ({ }, }); +const chatUserCustomPromptKey = ["chat-user-custom-prompt"] as const; + +export const chatUserCustomPrompt = () => ({ + queryKey: chatUserCustomPromptKey, + queryFn: () => API.getUserChatCustomPrompt(), +}); + +export const updateUserChatCustomPrompt = (queryClient: QueryClient) => ({ + mutationFn: API.updateUserChatCustomPrompt, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUserCustomPromptKey, + }); + }, +}); + export const chatModelsKey = ["chat-models"] as const; export const chatModels = () => ({ diff --git a/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx index c8c22b4eac..966b2b1de9 100644 --- a/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx @@ -51,6 +51,12 @@ const meta: Meta = { system_prompt: "", }); spyOn(API, "updateChatSystemPrompt").mockResolvedValue(); + spyOn(API, "getUserChatCustomPrompt").mockResolvedValue({ + custom_prompt: "", + }); + spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({ + custom_prompt: "", + }); }, }; @@ -180,12 +186,20 @@ export const SavesBehaviorPromptAndRestores: Story = { }, play: async () => { const dialog = await screen.findByRole("dialog"); - const textarea = await within(dialog).findByPlaceholderText( - "Optional. Set deployment-wide instructions for all new chats.", + + // Find the System Instructions textarea by its unique placeholder. + const textareas = await within(dialog).findAllByPlaceholderText( + "Additional behavior, style, and tone preferences for all users", ); + const textarea = textareas[0]; await userEvent.type(textarea, "You are a focused coding assistant."); - await userEvent.click(within(dialog).getByRole("button", { name: "Save" })); + + // Click the Save button inside the System Instructions form. + // There are multiple Save buttons (one per form), so grab all and + // pick the last one which belongs to the system prompt section. + const saveButtons = within(dialog).getAllByRole("button", { name: "Save" }); + await userEvent.click(saveButtons[saveButtons.length - 1]); await waitFor(() => { expect(API.updateChatSystemPrompt).toHaveBeenCalledWith({ diff --git a/site/src/pages/AgentsPage/AgentCreateForm.tsx b/site/src/pages/AgentsPage/AgentCreateForm.tsx index 786ebac0a6..9d15df0669 100644 --- a/site/src/pages/AgentsPage/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/AgentCreateForm.tsx @@ -1,4 +1,9 @@ -import { chatSystemPrompt, updateChatSystemPrompt } from "api/queries/chats"; +import { + chatSystemPrompt, + chatUserCustomPrompt, + updateChatSystemPrompt, + updateUserChatCustomPrompt, +} from "api/queries/chats"; import { workspaces } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; @@ -144,6 +149,12 @@ export const AgentCreateForm: FC = ({ isPending: isSavingSystemPrompt, isError: isSaveSystemPromptError, } = useMutation(updateChatSystemPrompt(queryClient)); + const userPromptQuery = useQuery(chatUserCustomPrompt()); + const { + mutate: saveUserPrompt, + isPending: isSavingUserPrompt, + isError: isSaveUserPromptError, + } = useMutation(updateUserChatCustomPrompt(queryClient)); const [initialLastModelConfigID] = useState(() => { if (typeof window === "undefined") { return ""; @@ -206,6 +217,9 @@ export const AgentCreateForm: FC = ({ const serverPrompt = systemPromptQuery.data?.system_prompt ?? ""; const [localEdit, setLocalEdit] = useState(null); const systemPromptDraft = localEdit ?? serverPrompt; + const serverUserPrompt = userPromptQuery.data?.custom_prompt ?? ""; + const [localUserEdit, setLocalUserEdit] = useState(null); + const userPromptDraft = localUserEdit ?? serverUserPrompt; const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( () => { @@ -215,7 +229,6 @@ export const AgentCreateForm: FC = ({ ); const workspaceOptions = workspacesQuery.data?.workspaces ?? []; const autoCreateWorkspaceValue = "__auto_create_workspace__"; - const hasAdminControls = canSetSystemPrompt || canManageChatModelConfigs; const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); const modelSelectorPlaceholder = getModelSelectorPlaceholder( @@ -264,6 +277,22 @@ export const AgentCreateForm: FC = ({ const selectedModelRef = useRef(selectedModel); selectedModelRef.current = selectedModel; const isSystemPromptDirty = localEdit !== null && localEdit !== serverPrompt; + const isUserPromptDirty = + localUserEdit !== null && localUserEdit !== serverUserPrompt; + + const handleSaveUserPrompt = useCallback( + (event: FormEvent) => { + event.preventDefault(); + if (!isUserPromptDirty) { + return; + } + saveUserPrompt( + { custom_prompt: userPromptDraft }, + { onSuccess: () => setLocalUserEdit(null) }, + ); + }, + [isUserPromptDirty, userPromptDraft, saveUserPrompt], + ); const handleWorkspaceChange = (value: string) => { if (value === autoCreateWorkspaceValue) { @@ -432,20 +461,23 @@ export const AgentCreateForm: FC = ({ /> - {hasAdminControls && ( - - )} + ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 6510c400b7..1000ae8562 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -123,6 +123,7 @@ export const AgentsPageView: FC = ({ hasNextPage={hasNextPage} onLoadMore={onLoadMore} onCollapse={onCollapseSidebar} + onOpenSettings={() => setConfigureAgentsDialogOpen(true)} /> @@ -162,16 +163,6 @@ export const AgentsPageView: FC = ({
- {isAgentsAdmin && ( - - )}
void; onCollapse?: () => void; + onOpenSettings?: () => void; } const statusConfig = { @@ -542,6 +544,7 @@ export const AgentsSidebar: FC = (props) => { hasNextPage, onLoadMore, onCollapse, + onOpenSettings, } = props; const { agentId, chatId } = useParams<{ agentId?: string; @@ -814,36 +817,48 @@ export const AgentsSidebar: FC = (props) => {
- - +
+ + + + + + link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> + + + {onOpenSettings && ( - - - link.location !== "navbar", - ) ?? [] - } - onSignOut={signOut} - /> - - + )} +
); diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx index 895ee28e87..f8cb6d8285 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx @@ -79,6 +79,11 @@ const meta: Meta = { onSaveSystemPrompt: fn(), isSystemPromptDirty: false, saveSystemPromptError: false, + userPromptDraft: "", + onUserPromptDraftChange: fn(), + onSaveUserPrompt: fn(), + isUserPromptDirty: false, + saveUserPromptError: false, isDisabled: false, }, }; @@ -86,23 +91,19 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const SystemPromptOnly: Story = { +/** Regular user sees only the Personal Prompt section. */ +export const UserOnly: Story = {}; + +/** Admin sees Personal Prompt + System Prompt in the same Prompts tab. */ +export const AdminPrompts: Story = { args: { canSetSystemPrompt: true, - canManageChatModelConfigs: false, systemPromptDraft: "You are a helpful coding assistant.", }, }; -export const ModelConfigOnly: Story = { - args: { - canSetSystemPrompt: false, - canManageChatModelConfigs: true, - }, - parameters: { queries: chatQueries }, -}; - -export const BothEnabled: Story = { +/** Admin with model config permissions sees Providers/Models tabs. */ +export const AdminFull: Story = { args: { canSetSystemPrompt: true, canManageChatModelConfigs: true, diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx index 9487c94473..9ac1114cca 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx @@ -7,22 +7,54 @@ import { DialogHeader, DialogTitle, } from "components/Dialog/Dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import type { LucideIcon } from "lucide-react"; -import { BoxesIcon, KeyRoundIcon, UserIcon, XIcon } from "lucide-react"; +import { + BoxesIcon, + KeyRoundIcon, + ShieldIcon, + 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 ConfigureAgentsSection = "providers" | "models" | "behavior"; type ConfigureAgentsSectionOption = { id: ConfigureAgentsSection; label: string; icon: LucideIcon; + adminOnly?: boolean; }; +const AdminBadge: FC = () => ( + + + + + + Admin + + + + Only visible to deployment administrators. + + + +); + +const textareaClassName = + "max-h-[240px] w-full resize-none overflow-y-auto 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 [scrollbar-width:thin]"; + interface ConfigureAgentsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -33,6 +65,11 @@ interface ConfigureAgentsDialogProps { onSaveSystemPrompt: (event: FormEvent) => void; isSystemPromptDirty: boolean; saveSystemPromptError: boolean; + userPromptDraft: string; + onUserPromptDraftChange: (value: string) => void; + onSaveUserPrompt: (event: FormEvent) => void; + isUserPromptDirty: boolean; + saveUserPromptError: boolean; isDisabled: boolean; } @@ -46,64 +83,64 @@ export const ConfigureAgentsDialog: FC = ({ onSaveSystemPrompt, isSystemPromptDirty, saveSystemPromptError, + userPromptDraft, + onUserPromptDraftChange, + onSaveUserPrompt, + isUserPromptDirty, + saveUserPromptError, isDisabled, }) => { const configureSectionOptions = useMemo< readonly ConfigureAgentsSectionOption[] >(() => { const options: ConfigureAgentsSectionOption[] = []; + options.push({ + id: "behavior", + label: "Behavior", + icon: UserIcon, + }); if (canManageChatModelConfigs) { options.push({ id: "providers", label: "Providers", icon: KeyRoundIcon, + adminOnly: true, }); options.push({ id: "models", label: "Models", icon: BoxesIcon, - }); - } - if (canSetSystemPrompt) { - options.push({ - id: "system-prompt", - label: "Behavior", - icon: UserIcon, + adminOnly: true, }); } return options; - }, [canManageChatModelConfigs, canSetSystemPrompt]); + }, [canManageChatModelConfigs]); const [userActiveSection, setUserActiveSection] = - useState("providers"); + useState("behavior"); - // 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"); + : (configureSectionOptions[0]?.id ?? "behavior"); - // Reset to the preferred initial section each time the dialog opens. useEffect(() => { if (open) { - setUserActiveSection("providers"); + setUserActiveSection("behavior"); } }, [open]); return ( - {/* Visually hidden for accessibility */} - Configure Agents + Settings - Manage providers, system prompt, and available models. + Manage your personal preferences and agent configuration. - {/* Sidebar */} - {/* Content */} -
- {activeSection === "providers" && canManageChatModelConfigs && ( - - )} - {activeSection === "system-prompt" && canSetSystemPrompt && ( +
+ {activeSection === "behavior" && ( <> - + + {/* ── Personal prompt (always visible) ── */}
void onSaveSystemPrompt(event)} + className="space-y-2" + onSubmit={(event) => void onSaveUserPrompt(event)} > -
-

- System Prompt -

-

- Admin-only instruction applied to all new chats. When empty, - the built-in default prompt is used. -

- - onSystemPromptDraftChange(event.target.value) - } - disabled={isDisabled} - minRows={7} - /> -
- - -
- {saveSystemPromptError && ( -

- Failed to save system prompt. -

- )} +

+ Personal Instructions +

+

+ Applied to all your chats. Only visible to you. +

{" "} + + onUserPromptDraftChange(event.target.value) + } + disabled={isDisabled} + minRows={1} + /> +
+ +
+ {saveUserPromptError && ( +

+ Failed to save personal instructions. +

+ )} + + {/* ── Admin system prompt (admin only) ── */} + {canSetSystemPrompt && ( + <> +
+
void onSaveSystemPrompt(event)} + > +
+

+ System Instructions +

+ +
+

+ Applied to all chats for every user. When empty, the + built-in default is used. +

{" "} + + onSystemPromptDraftChange(event.target.value) + } + disabled={isDisabled} + minRows={1} + /> +
+ + +
+ {saveSystemPromptError && ( +

+ Failed to save system prompt. +

+ )} + + + )} + + )} + {activeSection === "providers" && canManageChatModelConfigs && ( + <> + } + />{" "} + )} {activeSection === "models" && canManageChatModelConfigs && ( - + <> + } + />{" "} + + )}
diff --git a/site/src/pages/AgentsPage/SectionHeader.tsx b/site/src/pages/AgentsPage/SectionHeader.tsx index cbbad67a4c..4810fa8fe6 100644 --- a/site/src/pages/AgentsPage/SectionHeader.tsx +++ b/site/src/pages/AgentsPage/SectionHeader.tsx @@ -3,22 +3,29 @@ import type { FC, ReactNode } from "react"; interface SectionHeaderProps { label: string; description?: string; + badge?: ReactNode; action?: ReactNode; } export const SectionHeader: FC = ({ label, description, + badge, action, }) => ( <>
-

- {label} -

+
+

+ {label} +

+ {badge} +
{description && ( -

{description}

+

+ {description} +

)}
{action}