From b65f74c2a3b9345f505fbe57c071513b0cc39df6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 7 Jul 2026 21:55:58 +0000 Subject: [PATCH] Built-in agents P2: roster badges, detail banner, configure modal, gate Implements all UI surfaces for built-in agents (PAP-12978) against the P3 ux-spec (PAP-12977): - InlineBanner: token-backed info/warning banner primitive (brandBanner tokens) - BuiltInAgentBadge / BuiltInLifecycleChip: provenance + lifecycle chips - Roster (Agents page + org tree + sidebar): Built-in badge, Needs-setup / Pending-approval chip, inline Set up button, and a Built-in filter tab - AgentDetail: provenance banner, Reset to defaults, pause-confirm dialog with dependency warning, Terminate hidden for built-ins - ConfigureBuiltInAgentModal: reuses AgentConfigForm adapter/model pickers (no second model picker), optional budget, provisions via the API - BuiltInAgentGate: loading / setup / pending-approval / paused / ready states - Use-while-paused toast helper (single View-agent link, per D9) - EmptyState gains an optional title; AgentActionButtons gains pauseConfirm + hideTerminate; forward-compatible with the server's board-approval flow - Component tests for gate states + modal submit path; Storybook story + /design-guide entries for the new components Co-Authored-By: Paperclip --- ui/src/api/builtInAgents.ts | 66 +++++ ui/src/components/AgentActionButtons.tsx | 64 ++++- ui/src/components/AgentConfigForm.tsx | 4 +- ui/src/components/BuiltInAgentBadges.tsx | 66 +++++ ui/src/components/BuiltInAgentGate.test.tsx | 163 +++++++++++ ui/src/components/BuiltInAgentGate.tsx | 125 +++++++++ .../ConfigureBuiltInAgentModal.test.tsx | 219 +++++++++++++++ .../components/ConfigureBuiltInAgentModal.tsx | 217 +++++++++++++++ ui/src/components/EmptyState.tsx | 18 +- ui/src/components/InlineBanner.tsx | 69 +++++ ui/src/components/SidebarAgents.tsx | 44 ++- ui/src/lib/built-in-agent-toast.ts | 28 ++ ui/src/lib/queryKeys.ts | 3 + ui/src/lib/status-colors.ts | 18 ++ ui/src/pages/AgentDetail.tsx | 67 ++++- ui/src/pages/Agents.tsx | 146 ++++++++-- ui/src/pages/DesignGuide.tsx | 69 +++++ .../stories/built-in-agents.stories.tsx | 260 ++++++++++++++++++ 18 files changed, 1603 insertions(+), 43 deletions(-) create mode 100644 ui/src/api/builtInAgents.ts create mode 100644 ui/src/components/BuiltInAgentBadges.tsx create mode 100644 ui/src/components/BuiltInAgentGate.test.tsx create mode 100644 ui/src/components/BuiltInAgentGate.tsx create mode 100644 ui/src/components/ConfigureBuiltInAgentModal.test.tsx create mode 100644 ui/src/components/ConfigureBuiltInAgentModal.tsx create mode 100644 ui/src/components/InlineBanner.tsx create mode 100644 ui/src/lib/built-in-agent-toast.ts create mode 100644 ui/storybook/stories/built-in-agents.stories.tsx diff --git a/ui/src/api/builtInAgents.ts b/ui/src/api/builtInAgents.ts new file mode 100644 index 00000000000..b958b864258 --- /dev/null +++ b/ui/src/api/builtInAgents.ts @@ -0,0 +1,66 @@ +import type { Agent, Approval } from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Lifecycle of a built-in agent, derived server-side from row existence, + * adapter-config completeness, board-approval state, and `pausedAt`. + * + * `not_provisioned → pending_approval → needs_setup → ready ⇄ paused` + * + * `pending_approval` only occurs when the company requires board approval for + * new agents; otherwise provisioning goes straight to `needs_setup`/`ready`. + */ +export type BuiltInAgentStatus = + | "not_provisioned" + | "pending_approval" + | "needs_setup" + | "ready" + | "paused"; + +export interface BuiltInAgentDefinition { + key: string; + displayName: string; + featureKeys: string[]; + shortPurpose: string; + defaultInstructions: string; + defaultRole: string; + allowedAdapterTypes?: string[]; + defaultBudgetMonthlyCents?: number; +} + +export interface BuiltInAgentState { + definition: BuiltInAgentDefinition; + status: BuiltInAgentStatus; + agentId: string | null; + agent: Agent | null; + pauseReason: string | null; + /** Present when provisioning queued a board hire approval (HTTP 202). */ + approval?: Approval | null; +} + +export interface BuiltInAgentProvisionInput { + adapterType?: string; + adapterConfig?: Record; +} + +/** + * Error `code` thrown as HTTP 412 by `requireBuiltInAgent` on the server when a + * feature needs a built-in agent that is missing or not fully configured. The + * configure-on-first-use modal is triggered from this signal. + */ +export const BUILT_IN_AGENT_NOT_CONFIGURED_CODE = "built_in_agent_not_configured"; + +/** + * Warning `code` returned alongside a paused built-in agent so callers can + * surface the use-while-paused toast without treating the agent as ready. + */ +export const BUILT_IN_AGENT_PAUSED_CODE = "built_in_agent_paused"; + +export const builtInAgentsApi = { + list: (companyId: string) => + api.get(`/companies/${companyId}/built-in-agents`), + provision: (companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) => + api.post(`/companies/${companyId}/built-in-agents/${key}/provision`, input), + reset: (companyId: string, key: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/reset`, {}), +}; diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 56435f97ad4..dc13464b061 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { useNavigate } from "@/lib/router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -18,6 +18,16 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { AgentStatusBadge } from "./StatusBadge"; import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; @@ -155,6 +165,8 @@ export function AgentActionButtons({ workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, + pauseConfirm, + hideTerminate = false, children, className, }: { @@ -168,6 +180,13 @@ export function AgentActionButtons({ workActionsDisabled?: boolean; workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; + /** + * When set, pausing prompts a confirmation dialog first (e.g. for built-in + * agents that power a feature). Omit for the immediate-pause default. + */ + pauseConfirm?: { title: string; description: ReactNode }; + /** Hide the Terminate action (e.g. built-in agents are undeletable). */ + hideTerminate?: boolean; /** * Optional inline error reporter. When provided it is used instead of a toast * for action failures (preserves the detail page's inline error banner). When @@ -183,6 +202,7 @@ export function AgentActionButtons({ const { openNewIssue } = useDialogActions(); const { pushToast } = useToastActions(); const [moreOpen, setMoreOpen] = useState(false); + const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false); const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); @@ -321,12 +341,30 @@ export function AgentActionButtons({ ) : ( agentAction.mutate("pause")} + onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))} onResume={() => agentAction.mutate("resume")} disabled={pauseResumeDisabled} size={size} /> )} + {pauseConfirm && ( + + + + {pauseConfirm.title} + +
{pauseConfirm.description}
+
+
+ + Cancel + agentAction.mutate("pause")}> + Pause anyway + + +
+
+ )} {showStatus && ( @@ -372,16 +410,18 @@ export function AgentActionButtons({ Reset Sessions - + {!hideTerminate && ( + + )} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 518b2df2a59..5b00190ff71 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1579,7 +1579,7 @@ export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmen /* ---- Internal sub-components ---- */ -function AdapterTypeDropdown({ +export function AdapterTypeDropdown({ value, onChange, disabledTypes, @@ -1652,7 +1652,7 @@ function ExperimentalBadge() { ); } -function ModelDropdown({ +export function ModelDropdown({ models, value, onChange, diff --git a/ui/src/components/BuiltInAgentBadges.tsx b/ui/src/components/BuiltInAgentBadges.tsx new file mode 100644 index 00000000000..d8eae64efcf --- /dev/null +++ b/ui/src/components/BuiltInAgentBadges.tsx @@ -0,0 +1,66 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { brandChipBadge } from "@/lib/status-colors"; +import type { BuiltInAgentStatus } from "@/api/builtInAgents"; + +/** + * Provenance label ("Built-in"). Constant for the life of a built-in agent — + * this is NOT a lifecycle/status chip, so it never routes through + * `StatusBadge`/`AgentStatusBadge` (ux-spec D2). + */ +export function BuiltInAgentBadge({ + className, + compact = false, +}: { + className?: string; + compact?: boolean; +}) { + return ( + + Built-in + + ); +} + +/** + * Derived lifecycle chip. Rendered for the amber attention states + * (`needs_setup`, `pending_approval`). Kept separate from the real agent status + * (`idle/active/…`) per ux-spec D1. + */ +export function BuiltInLifecycleChip({ + status, + compact = false, + className, +}: { + status: BuiltInAgentStatus; + compact?: boolean; + className?: string; +}) { + if (status !== "needs_setup" && status !== "pending_approval") return null; + const isPendingApproval = status === "pending_approval"; + return ( + + {isPendingApproval ? (compact ? "Approval" : "Pending approval") : compact ? "Setup" : "Needs setup"} + + ); +} diff --git a/ui/src/components/BuiltInAgentGate.test.tsx b/ui/src/components/BuiltInAgentGate.test.tsx new file mode 100644 index 00000000000..8617dd20cfe --- /dev/null +++ b/ui/src/components/BuiltInAgentGate.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BuiltInAgentGate } from "./BuiltInAgentGate"; +import type { BuiltInAgentState, BuiltInAgentStatus } from "@/api/builtInAgents"; + +const listMock = vi.hoisted(() => vi.fn()); +const resumeMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/builtInAgents", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + builtInAgentsApi: { list: listMock, provision: vi.fn(), reset: vi.fn() }, + }; +}); + +vi.mock("@/api/agents", () => ({ + agentsApi: { resume: resumeMock }, +})); + +// The configure modal pulls in the full AgentConfigForm; stub it so the gate +// test stays focused on state selection. +vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({ + ConfigureBuiltInAgentModal: ({ open }: { open: boolean }) => + open ?
: null, +})); + +vi.mock("react-router-dom", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, +})); + +function makeState(status: BuiltInAgentStatus, overrides: Partial = {}): BuiltInAgentState { + const provisioned = status !== "not_provisioned"; + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares briefs.", + defaultInstructions: "…", + defaultRole: "general", + allowedAdapterTypes: ["codex_local"], + defaultBudgetMonthlyCents: 0, + }, + status, + agentId: provisioned ? "agent-1" : null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: provisioned + ? ({ id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any) + : null, + pauseReason: null, + ...overrides, + }; +} + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("BuiltInAgentGate (PAP-12978)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + +
brief content
+
+
, + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + listMock.mockReset(); + resumeMock.mockReset(); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + }); + + it("renders the setup empty-state for needs_setup and hides the feature", async () => { + listMock.mockResolvedValue([makeState("needs_setup")]); + await renderGate(); + expect(container.textContent).toContain("Set up the Briefs Agent"); + expect(container.textContent).toContain("Configure its model to enable the feature"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("renders the setup empty-state for not_provisioned", async () => { + listMock.mockResolvedValue([makeState("not_provisioned")]); + await renderGate(); + expect(container.textContent).toContain("Set up the Briefs Agent"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("renders a pending-approval state", async () => { + listMock.mockResolvedValue([makeState("pending_approval")]); + await renderGate(); + expect(container.textContent).toContain("pending approval"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("shows the paused banner and keeps children readable (stale)", async () => { + listMock.mockResolvedValue([makeState("paused")]); + await renderGate(); + expect(container.textContent).toContain("Briefs is paused."); + expect(container.textContent).toContain("Resume agent"); + // Paused ≠ hidden — children still render. + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + }); + + it("resumes the agent from the paused banner", async () => { + listMock.mockResolvedValue([makeState("paused")]); + resumeMock.mockResolvedValue({}); + await renderGate(); + const resumeButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Resume agent"), + ); + expect(resumeButton).toBeTruthy(); + flushSync(() => { + resumeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(resumeMock).toHaveBeenCalledWith("agent-1", "c1"); + }); + + it("renders the feature when ready", async () => { + listMock.mockResolvedValue([makeState("ready")]); + await renderGate(); + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Set up the Briefs Agent"); + }); + + it("fails open to the feature when the key is unknown", async () => { + listMock.mockResolvedValue([makeState("ready", { + definition: { ...makeState("ready").definition, key: "learning" }, + })]); + await renderGate(); + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/BuiltInAgentGate.tsx b/ui/src/components/BuiltInAgentGate.tsx new file mode 100644 index 00000000000..4d7cb8e4a0a --- /dev/null +++ b/ui/src/components/BuiltInAgentGate.tsx @@ -0,0 +1,125 @@ +import { useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bot, Clock3 } from "lucide-react"; + +import { EmptyState } from "@/components/EmptyState"; +import { InlineBanner } from "@/components/InlineBanner"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { Button } from "@/components/ui/button"; +import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; +import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents"; +import { agentsApi } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; +import { agentUrl } from "@/lib/utils"; +import { relativeTime } from "@/lib/utils"; + +export interface BuiltInAgentGateProps { + /** Registry key of the built-in agent that powers this feature (e.g. "briefs"). */ + agentKey: string; + companyId: string | null | undefined; + /** Human label for the gated feature. Defaults to the agent's display name. */ + featureLabel?: string; + children: ReactNode; +} + +/** + * Wraps a feature surface that depends on a built-in agent and renders the + * right lifecycle state (ux-spec §4): + * + * - loading → skeleton + * - not_provisioned / needs_setup → setup empty-state + configure modal CTA + * - paused → amber banner + Resume over the (stale) children + * - ready → children + */ +export function BuiltInAgentGate({ agentKey, companyId, featureLabel, children }: BuiltInAgentGateProps) { + const queryClient = useQueryClient(); + const [configureOpen, setConfigureOpen] = useState(false); + + const { data: states, isLoading } = useQuery({ + queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"), + queryFn: () => builtInAgentsApi.list(companyId!), + enabled: Boolean(companyId), + }); + + const state: BuiltInAgentState | undefined = states?.find((entry) => entry.definition.key === agentKey); + + const resume = useMutation({ + mutationFn: (agentId: string) => agentsApi.resume(agentId, companyId ?? undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__") }); + if (companyId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); + }, + }); + + // Unknown key or still resolving the company — fail open to the feature. + if (!companyId) return <>{children}; + if (isLoading && !states) return ; + if (!state) return <>{children}; + + const label = featureLabel ?? state.definition.displayName; + + if (state.status === "not_provisioned" || state.status === "needs_setup") { + return ( + <> + setConfigureOpen(true)} + hideActionIcon + /> + + + ); + } + + if (state.status === "pending_approval") { + return ( + + ); + } + + if (state.status === "paused" && state.agent) { + const pausedAt = state.agent.pausedAt ? relativeTime(state.agent.pausedAt) : null; + return ( +
+ + + + + } + > + Its built-in agent was paused{pausedAt ? ` ${pausedAt}` : ""}, so new{" "} + {label.toLowerCase()} isn't being generated. + + {/* Paused ≠ hidden: keep existing content readable, marked stale. */} +
{children}
+
+ ); + } + + return <>{children}; +} diff --git a/ui/src/components/ConfigureBuiltInAgentModal.test.tsx b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx new file mode 100644 index 00000000000..54623c1e121 --- /dev/null +++ b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConfigureBuiltInAgentModal } from "./ConfigureBuiltInAgentModal"; +import type { BuiltInAgentState } from "@/api/builtInAgents"; + +const provisionMock = vi.hoisted(() => vi.fn()); +const updateMock = vi.hoisted(() => vi.fn()); +const adapterModelsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/builtInAgents", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, builtInAgentsApi: { list: vi.fn(), provision: provisionMock, reset: vi.fn() } }; +}); + +vi.mock("@/api/agents", () => ({ + agentsApi: { update: updateMock, adapterModels: adapterModelsMock }, +})); + +vi.mock("@/adapters/metadata", () => ({ + listAdapterOptions: () => [ + { value: "codex_local", label: "Codex" }, + { value: "claude_local", label: "Claude" }, + { value: "process", label: "Process" }, + ], +})); + +// Stub the shared pickers so the test can drive them without the full form. +vi.mock("@/components/AgentConfigForm", () => ({ + AdapterTypeDropdown: ({ value }: { value: string }) => ( +
+ ), + ModelDropdown: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + onChange((e.target as HTMLInputElement).value)} + /> + ), +})); + +vi.mock("@/components/agent-config-primitives", () => ({ + Field: ({ label, children }: { label: string; children: React.ReactNode }) => ( + + ), +})); + +function makeState(): BuiltInAgentState { + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares briefs.", + defaultInstructions: "…", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local"], + defaultBudgetMonthlyCents: 0, + }, + status: "not_provisioned", + agentId: null, + agent: null, + pauseReason: null, + }; +} + +async function flushReact() { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +function findButton(text: string): HTMLButtonElement | undefined { + return Array.from(document.body.querySelectorAll("button")).find((b) => + b.textContent?.includes(text), + ) as HTMLButtonElement | undefined; +} + +describe("ConfigureBuiltInAgentModal (PAP-12978)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + const onOpenChange = vi.fn(); + const onConfigured = vi.fn(); + + async function renderModal() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + provisionMock.mockReset(); + updateMock.mockReset(); + adapterModelsMock.mockReset().mockResolvedValue([]); + onOpenChange.mockReset(); + onConfigured.mockReset(); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + }); + + it("disables submit until a model is chosen, then provisions with adapter + model", async () => { + provisionMock.mockResolvedValue({ ...makeState(), status: "ready", agentId: "a1" }); + await renderModal(); + + const submit = findButton("Configure"); + expect(submit).toBeTruthy(); + expect(submit!.disabled).toBe(true); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + expect(modelInput).toBeTruthy(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + const submitReady = findButton("Configure")!; + expect(submitReady.disabled).toBe(false); + flushSync(() => { + submitReady.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5" }, + }); + expect(onConfigured).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("patches the budget after provisioning when set", async () => { + provisionMock.mockResolvedValue({ + ...makeState(), + status: "ready", + agentId: "a1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: { id: "a1", budgetMonthlyCents: 0 } as any, + }); + updateMock.mockResolvedValue({}); + await renderModal(); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + const budgetInput = document.body.querySelector('input[type="number"]') as HTMLInputElement; + flushSync(() => { + setter.call(budgetInput, "50"); + budgetInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + flushSync(() => { + findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(provisionMock).toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalledWith("a1", { budgetMonthlyCents: 5000 }, "c1"); + }); + + it("surfaces provision errors inline instead of closing", async () => { + const { ApiError } = await import("@/api/client"); + provisionMock.mockRejectedValue(new ApiError("Adapter not allowed", 422, null)); + await renderModal(); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + flushSync(() => { + findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Adapter not allowed"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx new file mode 100644 index 00000000000..e80c656808b --- /dev/null +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -0,0 +1,217 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/agent-config-primitives"; +import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm"; +import { InlineBanner } from "@/components/InlineBanner"; +import { listAdapterOptions } from "@/adapters/metadata"; +import { agentsApi } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; +import { ApiError } from "@/api/client"; +import { + builtInAgentsApi, + type BuiltInAgentState, +} from "@/api/builtInAgents"; + +/** Adapters whose config completeness is keyed on a non-empty `model`. */ +function isModelBasedAdapter(adapterType: string): boolean { + return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType); +} + +function defaultAdapterType(state: BuiltInAgentState): string { + return state.definition.allowedAdapterTypes?.[0] ?? "codex_local"; +} + +export interface ConfigureBuiltInAgentModalProps { + companyId: string; + state: BuiltInAgentState; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called after a successful provision (e.g. to navigate to the agent). */ + onConfigured?: (result: BuiltInAgentState) => void; +} + +/** + * Configure-on-first-use modal for a built-in agent. Reuses the shared + * `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker), + * plus an optional monthly budget, and submits to the provision endpoint. + */ +export function ConfigureBuiltInAgentModal({ + companyId, + state, + open, + onOpenChange, + onConfigured, +}: ConfigureBuiltInAgentModalProps) { + const queryClient = useQueryClient(); + const { definition } = state; + + const [adapterType, setAdapterType] = useState( + () => state.agent?.adapterType ?? defaultAdapterType(state), + ); + const [model, setModel] = useState(() => { + const config = state.agent?.adapterConfig; + return typeof config === "object" && config !== null && typeof (config as Record).model === "string" + ? ((config as Record).model as string) + : ""; + }); + const [modelOpen, setModelOpen] = useState(false); + const [budgetDollars, setBudgetDollars] = useState(() => { + const cents = definition.defaultBudgetMonthlyCents ?? 0; + return cents > 0 ? String(cents / 100) : ""; + }); + const [error, setError] = useState(null); + + // Restrict adapter choices to the registry's allow-list. + const disabledTypes = useMemo(() => { + const allowed = new Set(definition.allowedAdapterTypes ?? []); + if (allowed.size === 0) return new Set(); + return new Set( + listAdapterOptions() + .map((option) => option.value) + .filter((value) => !allowed.has(value)), + ); + }, [definition.allowedAdapterTypes]); + + const { data: fetchedModels } = useQuery({ + queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null), + queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}), + enabled: open && Boolean(companyId), + }); + const models = fetchedModels ?? []; + + const modelRequired = isModelBasedAdapter(adapterType); + const canSubmit = !modelRequired || model.trim().length > 0; + + const provision = useMutation({ + mutationFn: async () => { + const adapterConfig: Record = {}; + if (model.trim()) adapterConfig.model = model.trim(); + const result = await builtInAgentsApi.provision(companyId, definition.key, { + adapterType, + adapterConfig, + }); + // Budget lives on the agent row, not the provision payload — patch it + // separately when the operator sets a non-default value. + const trimmed = budgetDollars.trim(); + // Skip the budget patch while approval is pending — the agent isn't active + // yet and the board approval already captured the requested config. + if (result.status !== "pending_approval" && result.agentId && trimmed) { + const cents = Math.round(Number(trimmed) * 100); + if (Number.isFinite(cents) && cents >= 0 && cents !== (result.agent?.budgetMonthlyCents ?? 0)) { + await agentsApi.update(result.agentId, { budgetMonthlyCents: cents }, companyId); + } + } + return result; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); + if (result.agentId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) }); + } + onConfigured?.(result); + onOpenChange(false); + }, + onError: (err) => { + setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent."); + }, + }); + + return ( + (provision.isPending ? undefined : onOpenChange(next))}> + + + Set up the {definition.displayName} + {definition.shortPurpose} + + +
+ + Creates {definition.displayName} in your roster, badged{" "} + Built-in. Companies that require hire approval will queue this for the + board. + + + + { + setAdapterType(next); + setModel(""); + }} + disabledTypes={disabledTypes} + /> + + + {modelRequired && ( + // ModelDropdown supplies its own "Model" Field label + hint. + + )} + + +
+ $ + setBudgetDollars(event.target.value)} + className="w-32" + /> + / month +
+
+ + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 2e2612ab8fb..9fcfc35d909 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -4,21 +4,33 @@ import { Button } from "@/components/ui/button"; interface EmptyStateProps { icon: LucideIcon; + /** Optional bold heading rendered above the message. */ + title?: string; message: string; action?: string; onAction?: () => void; + /** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */ + hideActionIcon?: boolean; } -export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { +export function EmptyState({ + icon: Icon, + title, + message, + action, + onAction, + hideActionIcon = false, +}: EmptyStateProps) { return (
-

{message}

+ {title &&

{title}

} +

{message}

{action && onAction && ( )} diff --git a/ui/src/components/InlineBanner.tsx b/ui/src/components/InlineBanner.tsx new file mode 100644 index 00000000000..670c6dba4d9 --- /dev/null +++ b/ui/src/components/InlineBanner.tsx @@ -0,0 +1,69 @@ +import type { LucideIcon } from "lucide-react"; +import { Info, AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; +import { brandBanner, type BannerTone } from "@/lib/status-colors"; + +const TONE_ICON: Record = { + info: Info, + warning: AlertTriangle, +}; + +export interface InlineBannerProps { + /** Visual tone. `info` (blue) for provenance/context, `warning` (amber) for paused/attention. */ + tone?: BannerTone; + /** Optional bold heading rendered above the body. */ + title?: ReactNode; + /** Body content. */ + children?: ReactNode; + /** Override the leading icon, or pass `false` to omit it. */ + icon?: LucideIcon | false; + /** Optional trailing actions (buttons/links) rendered on the right at ≥sm, wrapped below on mobile. */ + actions?: ReactNode; + /** Denser padding for embedding inside modals/dialogs. */ + compact?: boolean; + className?: string; +} + +/** + * Token-backed inline banner used for full-width informational and warning + * notices. Follows the existing bespoke-banner convention (`border … bg-… + * rounded-lg p-4`) but centralizes the color recipe in `brandBanner` so + * feature surfaces don't hand-roll `bg-yellow-*`/`bg-blue-*` variants. + * + * See `/design-guide` for tone examples. + */ +export function InlineBanner({ + tone = "info", + title, + children, + icon, + actions, + compact = false, + className, +}: InlineBannerProps) { + const Icon = icon === false ? null : (icon ?? TONE_ICON[tone]); + return ( +
+
+ {Icon &&
+ {actions && ( +
{actions}
+ )} +
+ ); +} diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 3ea070ef263..cea2fdaeb80 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -18,6 +18,8 @@ import { useDialogActions } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { useToastActions } from "../context/ToastContext"; import { agentsApi } from "../api/agents"; +import { builtInAgentsApi, type BuiltInAgentStatus } from "../api/builtInAgents"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "./BuiltInAgentBadges"; import { authApi } from "../api/auth"; import { heartbeatsApi } from "../api/heartbeats"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; @@ -112,6 +114,7 @@ function SidebarAgentItem({ rail, runCount, setSidebarOpen, + builtInStatus, starred = false, onToggleStar, starPending = false, @@ -127,6 +130,7 @@ function SidebarAgentItem({ rail: boolean; runCount: number; setSidebarOpen: (open: boolean) => void; + builtInStatus?: BuiltInAgentStatus; starred?: boolean; onToggleStar?: (agent: Agent, starred: boolean) => void; starPending?: boolean; @@ -147,6 +151,17 @@ function SidebarAgentItem({ : isPaused && hasInvalidOrgChain ? "Invalid org chain" : pauseResumeLabel; + const trailingLabelParts: string[] = []; + if (builtInStatus) { + trailingLabelParts.push( + builtInStatus === "pending_approval" + ? "Built-in, pending approval" + : builtInStatus === "needs_setup" + ? "Built-in, needs setup" + : "Built-in", + ); + } + if (hasInvalidOrgChain) trailingLabelParts.push("Invalid reporting chain"); // C11 (DECISION-SHEET.md): the row itself is a SidebarNavItem, so agent rows // share the nav-row chrome (type, active state, rail tooltip, live dot). @@ -164,11 +179,21 @@ function SidebarAgentItem({ starred && !isMobile ? "pr-14" : "pr-8", )} trailing={ - hasInvalidOrgChain ? ( - + builtInStatus || hasInvalidOrgChain ? ( + + {builtInStatus ? ( + <> + + + + ) : null} + {hasInvalidOrgChain ? ( + + ) : null} + ) : undefined } - trailingLabel={hasInvalidOrgChain ? "Invalid reporting chain" : undefined} + trailingLabel={trailingLabelParts.length > 0 ? trailingLabelParts.join(", ") : undefined} liveAccessory={ agent.pauseReason === "budget" ? : undefined } @@ -291,6 +316,18 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: builtInAgents } = useQuery({ + queryKey: queryKeys.builtInAgents.list(selectedCompanyId!), + queryFn: () => builtInAgentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const builtInStatusByAgentId = useMemo(() => { + const map = new Map(); + for (const entry of builtInAgents ?? []) { + if (entry.agentId) map.set(entry.agentId, entry.status); + } + return map; + }, [builtInAgents]); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -517,6 +554,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } rail={rail} runCount={liveCountByAgent.get(agent.id) ?? 0} setSidebarOpen={setSidebarOpen} + builtInStatus={builtInStatusByAgentId.get(agent.id)} starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)} onToggleStar={toggleStarAgent} starPending={agentStarPending(agent)} diff --git a/ui/src/lib/built-in-agent-toast.ts b/ui/src/lib/built-in-agent-toast.ts new file mode 100644 index 00000000000..4b9742f0804 --- /dev/null +++ b/ui/src/lib/built-in-agent-toast.ts @@ -0,0 +1,28 @@ +import type { ToastInput } from "@/context/ToastContext"; + +export interface BuiltInAgentPausedToastOptions { + /** Display name of the paused built-in agent, e.g. "Briefs Agent". */ + displayName: string; + /** Deep link to the agent page (from `agentUrl(agent)`). */ + agentHref: string; + /** Noun for the feature item, e.g. "brief". */ + featureNoun?: string; +} + +/** + * Build the "use-while-paused" toast payload (ux-spec §5 / D9). + * + * `ToastAction` supports a single `href` link only, so v1 carries one + * "View agent" link and Resume happens on the agent page. Deduped so repeated + * feature actions don't stack duplicate toasts. + */ +export function buildBuiltInAgentPausedToast(options: BuiltInAgentPausedToastOptions): ToastInput { + const noun = options.featureNoun ?? "item"; + return { + dedupeKey: `built-in-agent-paused:${options.displayName}`, + title: `${options.displayName} is paused`, + body: `Resume the agent to generate this ${noun}.`, + tone: "warn", + action: { label: "View agent", href: options.agentHref }, + }; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 7e228137a70..edfe492644e 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -57,6 +57,9 @@ export const queryKeys = { detectModel: (companyId: string, adapterType: string) => ["agents", companyId, "detect-model", adapterType] as const, }, + builtInAgents: { + list: (companyId: string) => ["built-in-agents", companyId] as const, + }, issues: { list: (companyId: string) => ["issues", companyId] as const, mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const, diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index f96808db896..fa6b2d3b730 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -167,6 +167,24 @@ export const runningLabelText = "text-[#1D4ED8] dark:text-[#2563EB]"; * (liveness), `todo` amber (queued), `in_review` violet (awaiting review), * `done` green, `blocked` red, `backlog`/`cancelled` gray (inert). */ + +// --------------------------------------------------------------------------- +// Inline banner tones (built-in agents provenance / paused notices) +// +// Softer, full-width banner surface derived from the same brand hue anchors as +// `brandChipBadge`. `info` (blue) carries provenance/informational context; +// `warning` (amber) carries paused/attention context. Consumed by +// `` so feature banners stay token-backed instead of hand-rolling +// per-instance `bg-yellow-*`/`bg-blue-*` recipes. +// --------------------------------------------------------------------------- + +export type BannerTone = "info" | "warning"; + +export const brandBanner: Record = { + info: "border-[#2563EB]/40 bg-[#DBEAFE]/50 text-[#1D4ED8] dark:border-[#2563eb59] dark:bg-[#2563eb14] dark:text-[#93C5FD]", + warning: "border-[#F59E0B]/50 bg-[#FEF3C7]/60 text-[#B45309] dark:border-[#f59e0b59] dark:bg-[#f59e0b12] dark:text-[#F59E0B]", +}; + export const issueStatusColor: Record = { backlog: "gray", todo: "amber", diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 5563a5f4926..2b477778381 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -7,6 +7,8 @@ import { type ClaudeLoginResult, type AgentPermissionUpdate, } from "../api/agents"; +import { builtInAgentsApi } from "../api/builtInAgents"; +import { companySkillsApi } from "../api/companySkills"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -41,6 +43,8 @@ import { StarToggle } from "../components/StarToggle"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentActionButtons } from "../components/AgentActionButtons"; +import { InlineBanner } from "../components/InlineBanner"; +import { BuiltInAgentBadge } from "../components/BuiltInAgentBadges"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { TrustPresetSection } from "../components/TrustPresetSection"; import { FileTree, buildFileTree } from "../components/FileTree"; @@ -707,6 +711,28 @@ export function AgentDetail() { ? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId) : "joined"; + const { data: builtInStates } = useQuery({ + queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!), + queryFn: () => builtInAgentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId, + }); + const builtInState = builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null; + const builtInFeatureLabel = builtInState + ? builtInState.definition.featureKeys + .map((key) => key.charAt(0).toUpperCase() + key.slice(1)) + .join(", ") + : ""; + const resetBuiltIn = useMutation({ + mutationFn: () => builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) }); + if (resolvedAgentId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) }); + } + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + }, + }); + const { data: runtimeState } = useQuery({ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), @@ -1022,7 +1048,10 @@ export function AgentDetail() {
-

{agent.name}

+
+

{agent.name}

+ {builtInState && } +

{roleLabels[agent.role] ?? agent.role} {agent.title ? ` - ${agent.title}` : ""} @@ -1051,6 +1080,21 @@ export function AgentDetail() { workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" onActionError={setActionError} + hideTerminate={Boolean(builtInState)} + pauseConfirm={ + builtInState + ? { + title: `Pause the ${builtInState.definition.displayName}?`, + description: ( + <> + {builtInFeatureLabel} depends on this agent. While paused,{" "} + {builtInFeatureLabel.toLowerCase()} generation is skipped and the{" "} + {builtInFeatureLabel} page shows a warning. + + ), + } + : undefined + } > {mobileLiveRun && (

+ {builtInState && ( + resetBuiltIn.mutate()} + disabled={resetBuiltIn.isPending} + > + {resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"} + + } + > + Ships with Paperclip and powers {builtInFeatureLabel}. Configure it like + any agent — model, instructions, budget. It can be paused but not deleted; pausing it + pauses {builtInFeatureLabel}. + + )} + {!urlRunId && ( ; -type FilterTab = "all" | "active" | "paused" | "error"; +// Lazy-loaded so the roster page doesn't statically pull in the full +// AgentConfigForm module graph (the modal reuses its adapter/model pickers). +const ConfigureBuiltInAgentModal = lazy(() => + import("../components/ConfigureBuiltInAgentModal").then((m) => ({ + default: m.ConfigureBuiltInAgentModal, + })), +); + +type FilterTab = "all" | "active" | "paused" | "error" | "builtin"; interface EnvironmentDescriptor { label: string; @@ -67,9 +77,14 @@ function matchesFilter(status: string, tab: FilterTab): boolean { return true; } -function filterAgents(agents: Agent[], tab: FilterTab): Agent[] { +function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set): Agent[] { return agents - .filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab)) + .filter((a) => { + if (HIDDEN_AGENT_STATUSES.has(a.status)) return false; + // The `builtin` filter keys on the built-in marker, not agent status. + if (tab === "builtin") return builtInAgentIds.has(a.id); + return matchesFilter(a.status, tab); + }) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -135,17 +150,20 @@ function resolveAgentEnvironment( : describeMissingEnvironment(environmentId); } -function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] { +function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set): OrgNode[] { return nodes .reduce((acc, node) => { - const filteredReports = filterOrgTree(node.reports, tab); + const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds); // Hidden agents (terminated / pending_approval) never render as a row, but // any visible reports are promoted so the tree doesn't lose live agents. if (HIDDEN_AGENT_STATUSES.has(node.status)) { acc.push(...filteredReports); return acc; } - if (matchesFilter(node.status, tab) || filteredReports.length > 0) { + const nodeMatches = tab === "builtin" + ? builtInAgentIds.has(node.id) + : matchesFilter(node.status, tab); + if (nodeMatches || filteredReports.length > 0) { acc.push({ ...node, reports: filteredReports }); } return acc; @@ -161,11 +179,26 @@ export function Agents() { const location = useLocation(); const { isMobile } = useSidebar(); const pathSegment = location.pathname.split("/").pop() ?? "all"; - const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; + const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error" || pathSegment === "builtin") ? pathSegment : "all"; const [view, setView] = useState<"list" | "org">("org"); const forceListView = isMobile; const effectiveView: "list" | "org" = forceListView ? "list" : view; + const { data: builtInAgents } = useQuery({ + queryKey: queryKeys.builtInAgents.list(selectedCompanyId!), + queryFn: () => builtInAgentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const builtInByAgentId = useMemo(() => { + const map = new Map(); + for (const entry of builtInAgents ?? []) { + if (entry.agentId) map.set(entry.agentId, entry); + } + return map; + }, [builtInAgents]); + const builtInAgentIds = useMemo(() => new Set(builtInByAgentId.keys()), [builtInByAgentId]); + const [configureState, setConfigureState] = useState(null); + const { data: agents, isLoading, error } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -261,8 +294,8 @@ export function Agents() { return ; } - const filtered = filterAgents(agents ?? [], tab); - const filteredOrg = filterOrgTree(orgTree ?? [], tab); + const filtered = filterAgents(agents ?? [], tab, builtInAgentIds); + const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds); const environmentDataLoading = environmentsEnabled && environments === undefined; const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1); const resolveRenderedEnvironment = (agentId: string) => ( @@ -280,6 +313,7 @@ export function Agents() { const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined; const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined; const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id); + const builtInState = builtInByAgentId.get(agent.id); return ( )} meta={ -
- +
+ {builtInState && ( +
+ {/* Provenance sits immediately after the name, then the + lifecycle chip; the real agent status stays in `trailing`. */} + + + {builtInState.status === "needs_setup" && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} +
+ )} +
+ +
} metaSpacerClassName="hidden xl:block" @@ -390,6 +450,7 @@ export function Agents() { { value: "active", label: "Active" }, { value: "paused", label: "Paused" }, { value: "error", label: "Error" }, + { value: "builtin", label: "Built-in" }, ]} value={tab} onValueChange={(v) => navigate(`/agents/${v}`)} @@ -476,6 +537,8 @@ export function Agents() { tab={tab} memberships={membershipsQuery.data} membershipMutation={membershipMutation} + builtInByAgentId={builtInByAgentId} + onConfigureBuiltIn={setConfigureState} /> ))}
@@ -492,6 +555,18 @@ export function Agents() { No organizational hierarchy defined.

)} + {configureState && selectedCompanyId && ( + + { + if (!open) setConfigureState(null); + }} + /> + + )}
); } @@ -507,6 +582,8 @@ function OrgTreeNode({ tab, memberships, membershipMutation, + builtInByAgentId, + onConfigureBuiltIn, }: { node: OrgNode; depth: number; @@ -518,8 +595,11 @@ function OrgTreeNode({ tab: FilterTab; memberships: ReturnType["data"]; membershipMutation: ReturnType; + builtInByAgentId: Map; + onConfigureBuiltIn: (state: BuiltInAgentState) => void; }) { const agent = agentMap.get(node.id); + const builtInState = builtInByAgentId.get(node.id); const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain"); const membershipState = resourceMembershipState(memberships, "agent", node.id); const pending = membershipMutation.isPending && @@ -546,12 +626,32 @@ function OrgTreeNode({ )} {/* min-w-0 + truncate so deep indentation on narrow screens shortens the name with an ellipsis instead of overflowing the row. */} -
- {node.name} - - {roleLabels[node.role] ?? node.role} - {agent?.title ? ` - ${agent.title}` : ""} - +
+
+ {node.name} + + {roleLabels[node.role] ?? node.role} + {agent?.title ? ` - ${agent.title}` : ""} + +
+ {builtInState && ( +
+ + + {builtInState.status === "needs_setup" && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} +
+ )}
@@ -639,6 +739,8 @@ function OrgTreeNode({ tab={tab} memberships={memberships} membershipMutation={membershipMutation} + builtInByAgentId={builtInByAgentId} + onConfigureBuiltIn={onConfigureBuiltIn} /> ))}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 361f86369be..6ec47167e73 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -24,6 +24,8 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { InlineBanner } from "@/components/InlineBanner"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; @@ -423,6 +425,7 @@ export function DesignGuide() { "StatusBadge", "StatusIcon", "PriorityIcon", "EntityRow", "EmptyState", "MetricCard", "FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor", "PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor", + "InlineBanner", "BuiltInAgentGate", "BuiltInAgentBadge", ].map((name) => ( {name} @@ -1815,6 +1818,72 @@ export function DesignGuide() {
+ + {/* ============================================================ */} + {/* INLINE BANNER + BUILT-IN AGENTS */} + {/* ============================================================ */} +
+

+ Token-backed full-width notice (brandBanner tones). Use{" "} + info for provenance/context and{" "} + warning for paused/attention. Supports an optional bold + title and a trailing actions slot. Replaces hand-rolled{" "} + bg-yellow-*/bg-blue-*{" "} + banners. +

+
+ Reset to defaults} + > + Ships with Paperclip and powers Briefs. It can be paused but not deleted. + + + + + + } + > + Its built-in agent was paused 2 days ago, so new briefs aren't being generated. + + + Compact variant for embedding inside dialogs and modals. + +
+
+ +
+

+ Provenance badge (constant, blue) plus a derived lifecycle chip (amber) for attention + states. The lifecycle chip is separate from the agent status vocabulary and only shows for{" "} + needs_setup / pending_approval. +

+
+
+ + +
+
+ + +
+
+ + +
+
+

+ <BuiltInAgentGate agentKey> composes{" "} + PageSkeleton + EmptyState{" "} + + InlineBanner to render the loading / setup / + pending-approval / paused / ready states of a feature that depends on a built-in agent. +

+
); } diff --git a/ui/storybook/stories/built-in-agents.stories.tsx b/ui/storybook/stories/built-in-agents.stories.tsx new file mode 100644 index 00000000000..b623bde2c14 --- /dev/null +++ b/ui/storybook/stories/built-in-agents.stories.tsx @@ -0,0 +1,260 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent } from "@paperclipai/shared"; + +import { Button } from "@/components/ui/button"; +import { EntityRow } from "@/components/EntityRow"; +import { EmptyState } from "@/components/EmptyState"; +import { InlineBanner } from "@/components/InlineBanner"; +import { AgentStatusBadge } from "@/components/StatusBadge"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges"; +import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import type { BuiltInAgentState } from "@/api/builtInAgents"; +import { Bot, Clock3 } from "lucide-react"; + +const briefsAgent: Agent = { + id: "agent-briefs", + companyId: "company-storybook", + name: "Briefs Agent", + urlKey: "briefs-agent", + role: "general", + title: null, + icon: "sparkles", + status: "idle", + reportsTo: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5" }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: { paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] } }, + createdAt: new Date("2026-06-01T09:00:00.000Z"), + updatedAt: new Date("2026-07-01T09:00:00.000Z"), +}; + +const definition = { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs for the board and agent company.", + defaultInstructions: "You are Paperclip's built-in Briefs agent.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, +}; + +const notProvisionedState: BuiltInAgentState = { + definition, + status: "not_provisioned", + agentId: null, + agent: null, + pauseReason: null, +}; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +const meta: Meta = { + title: "Product/Built-in Agents", + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** Boards 1, 2, 4, 5 — all presentational states in one gallery. */ +export const SurfaceGallery: Story = { + render: () => ( +
+
+ Board 1 — Roster rows +
+ + + + +
+ } + trailing={} + /> + + +
+ } + trailing={} + /> + + + +
+ } + trailing={} + /> +
+ + +
+ Board 2 — Agent detail provenance banner + Reset to defaults} + > + Ships with Paperclip and powers Briefs. Configure it like any agent — + model, instructions, budget. It can be paused but not deleted; pausing it pauses Briefs. + +
+ +
+ Board 4A — Feature gate: setup empty-state +
+ {}} + hideActionIcon + /> +
+
+ +
+ Board 4 — Feature gate: pending approval +
+ +
+
+ +
+ Board 4B — Feature gate: paused banner over stale content +
+ + + + + } + > + Its built-in agent was paused 2 days ago, so new briefs aren't being generated. + +
+ Previously generated briefs stay readable while the agent is paused. +
+
+
+ +
+ Board 5 — Sidebar treatment +
+
+ Briefs Agent + + + + +
+
+ Learning Agent + + + +
+
+
+ +
+ Board 5 — Use-while-paused toast +
+

Briefs Agent is paused

+

Resume the agent to generate this brief.

+ View agent +
+
+ + ), +}; + +/** Board 3 — configure-on-first-use modal (open). */ +export const ConfigureModal: Story = { + render: () => { + const [open, setOpen] = useState(true); + return ( +
+ + +
+ ); + }, +}; + +/** Board 2 — pause confirmation dialog with dependency warning. */ +export const PauseConfirmDialog: Story = { + render: () => ( +
+ + + + Pause the Briefs Agent? + +
+ Briefs depends on this agent. While paused, briefs generation is skipped and the + Briefs page shows a warning. +
+
+
+ + Cancel + Pause anyway + +
+
+
+ ), +}; + +void briefsAgent;