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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 20:47:44 +00:00
1 parent ad454e62f5
commit b65f74c2a3
18 files changed
+1603 -43

No files matched your search

+66
View File
@@ -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<string, unknown>;
}
/**
* 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<BuiltInAgentState[]>(`/companies/${companyId}/built-in-agents`),
provision: (companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/provision`, input),
reset: (companyId: string, key: string) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reset`, {}),
};
+52 -12
View File
@@ -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({
) : (
<PauseResumeButton
isPaused={isPaused}
onPause={() => agentAction.mutate("pause")}
onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))}
onResume={() => agentAction.mutate("resume")}
disabled={pauseResumeDisabled}
size={size}
/>
)}
{pauseConfirm && (
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pauseConfirm.title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>{pauseConfirm.description}</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => agentAction.mutate("pause")}>
Pause anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{showStatus && (
<span className="hidden sm:inline">
<AgentStatusBadge status={agent.status} />
@@ -372,16 +410,18 @@ export function AgentActionButtons({
<RotateCcw className="h-3 w-3" />
Reset Sessions
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
{!hideTerminate && (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
)}
</PopoverContent>
</Popover>
</div>
+2 -2
View File
@@ -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,
+66
View File
@@ -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 (
<Badge
variant="outline"
className={cn(
brandChipBadge.blue,
compact && "px-1.5 py-0 text-[10px]",
className,
)}
title="Ships with Paperclip"
>
Built-in
</Badge>
);
}
/**
* 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 (
<Badge
variant="outline"
className={cn(
brandChipBadge.amber,
compact && "px-1.5 py-0 text-[10px]",
className,
)}
title={
isPendingApproval
? "Waiting on board hire approval before the feature can run"
: "Needs adapter/model setup before the feature can run"
}
>
{isPendingApproval ? (compact ? "Approval" : "Pending approval") : compact ? "Setup" : "Needs setup"}
</Badge>
);
}
+163
View File
@@ -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<typeof import("@/api/builtInAgents")>();
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 ? <div data-testid="configure-modal" /> : null,
}));
vi.mock("react-router-dom", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
function makeState(status: BuiltInAgentStatus, overrides: Partial<BuiltInAgentState> = {}): 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(
<QueryClientProvider client={queryClient}>
<BuiltInAgentGate agentKey="briefs" companyId="c1" featureLabel="Briefs">
<div data-testid="feature">brief content</div>
</BuiltInAgentGate>
</QueryClientProvider>,
);
});
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();
});
});
+125
View File
@@ -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 <PageSkeleton variant="detail" />;
if (!state) return <>{children}</>;
const label = featureLabel ?? state.definition.displayName;
if (state.status === "not_provisioned" || state.status === "needs_setup") {
return (
<>
<EmptyState
icon={Bot}
title={`Set up the ${state.definition.displayName}`}
message={`${label} is generated by a built-in agent. Configure its model to enable the feature.`}
action={`Set up ${state.definition.displayName}`}
onAction={() => setConfigureOpen(true)}
hideActionIcon
/>
<ConfigureBuiltInAgentModal
companyId={companyId}
state={state}
open={configureOpen}
onOpenChange={setConfigureOpen}
/>
</>
);
}
if (state.status === "pending_approval") {
return (
<EmptyState
icon={Clock3}
title={`${state.definition.displayName} is pending approval`}
message={`${label} will be available after the board approves this built-in agent hire.`}
/>
);
}
if (state.status === "paused" && state.agent) {
const pausedAt = state.agent.pausedAt ? relativeTime(state.agent.pausedAt) : null;
return (
<div className="space-y-4">
<InlineBanner
tone="warning"
title={`${label} is paused.`}
actions={
<>
<Button variant="ghost" size="sm" asChild>
<Link to={agentUrl(state.agent)}>View agent</Link>
</Button>
<Button
size="sm"
onClick={() => state.agent && resume.mutate(state.agent.id)}
disabled={resume.isPending}
>
{resume.isPending ? "Resuming…" : "Resume agent"}
</Button>
</>
}
>
Its built-in agent was paused{pausedAt ? ` ${pausedAt}` : ""}, so new{" "}
{label.toLowerCase()} isn't being generated.
</InlineBanner>
{/* Paused ≠ hidden: keep existing content readable, marked stale. */}
<div className="opacity-70">{children}</div>
</div>
);
}
return <>{children}</>;
}
@@ -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<typeof import("@/api/builtInAgents")>();
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 }) => (
<div data-testid="adapter-dropdown" data-value={value} />
),
ModelDropdown: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input
data-testid="model-input"
value={value}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
/>
),
}));
vi.mock("@/components/agent-config-primitives", () => ({
Field: ({ label, children }: { label: string; children: React.ReactNode }) => (
<label>
{label}
{children}
</label>
),
}));
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(
<QueryClientProvider client={queryClient}>
<ConfigureBuiltInAgentModal
companyId="c1"
state={makeState()}
open
onOpenChange={onOpenChange}
onConfigured={onConfigured}
/>
</QueryClientProvider>,
);
});
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);
});
});
@@ -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<string>(
() => state.agent?.adapterType ?? defaultAdapterType(state),
);
const [model, setModel] = useState<string>(() => {
const config = state.agent?.adapterConfig;
return typeof config === "object" && config !== null && typeof (config as Record<string, unknown>).model === "string"
? ((config as Record<string, unknown>).model as string)
: "";
});
const [modelOpen, setModelOpen] = useState(false);
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
const cents = definition.defaultBudgetMonthlyCents ?? 0;
return cents > 0 ? String(cents / 100) : "";
});
const [error, setError] = useState<string | null>(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<string>();
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<string, unknown> = {};
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 (
<Dialog open={open} onOpenChange={(next) => (provision.isPending ? undefined : onOpenChange(next))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Set up the {definition.displayName}</DialogTitle>
<DialogDescription>{definition.shortPurpose}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<InlineBanner tone="info" compact>
Creates <strong>{definition.displayName}</strong> in your roster, badged{" "}
<strong>Built-in</strong>. Companies that require hire approval will queue this for the
board.
</InlineBanner>
<Field label="Adapter type">
<AdapterTypeDropdown
value={adapterType}
onChange={(next) => {
setAdapterType(next);
setModel("");
}}
disabledTypes={disabledTypes}
/>
</Field>
{modelRequired && (
// ModelDropdown supplies its own "Model" Field label + hint.
<ModelDropdown
models={models}
value={model}
onChange={setModel}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required
groupByProvider={false}
creatable
/>
)}
<Field label="Monthly budget (optional)" hint="Leave blank for no cap.">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">$</span>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
placeholder="0"
value={budgetDollars}
onChange={(event) => setBudgetDollars(event.target.value)}
className="w-32"
/>
<span className="text-sm text-muted-foreground">/ month</span>
</div>
</Field>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={provision.isPending}
>
Not now
</Button>
<Button
onClick={() => {
setError(null);
provision.mutate();
}}
disabled={!canSubmit || provision.isPending}
>
{provision.isPending ? "Configuring…" : `Configure & enable ${definition.displayName}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -3
View File
@@ -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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted/50 p-4 mb-4">
<Icon className="h-10 w-10 text-muted-foreground/50" />
</div>
<p className="text-sm text-muted-foreground mb-4">{message}</p>
{title && <p className="text-base font-semibold text-foreground mb-1.5">{title}</p>}
<p className="text-sm text-muted-foreground mb-4 max-w-md">{message}</p>
{action && onAction && (
<Button onClick={onAction}>
<Plus className="h-4 w-4 mr-1.5" />
{!hideActionIcon && <Plus className="h-4 w-4 mr-1.5" />}
{action}
</Button>
)}
+69
View File
@@ -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<BannerTone, LucideIcon> = {
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 (
<div
role="note"
className={cn(
"flex flex-col gap-2 rounded-lg border sm:flex-row sm:items-start sm:justify-between",
compact ? "p-3" : "p-4",
brandBanner[tone],
className,
)}
>
<div className="flex items-start gap-2.5">
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />}
<div className="space-y-1 text-sm">
{title && <p className="font-medium leading-tight">{title}</p>}
{children && <div className="leading-snug opacity-90">{children}</div>}
</div>
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2 pl-6 sm:pl-0">{actions}</div>
)}
</div>
);
}
+41 -3
View File
@@ -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 ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
builtInStatus || hasInvalidOrgChain ? (
<span className="flex min-w-0 shrink-0 items-center gap-1">
{builtInStatus ? (
<>
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status={builtInStatus} compact />
</>
) : null}
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
</span>
) : undefined
}
trailingLabel={hasInvalidOrgChain ? "Invalid reporting chain" : undefined}
trailingLabel={trailingLabelParts.length > 0 ? trailingLabelParts.join(", ") : undefined}
liveAccessory={
agent.pauseReason === "budget" ? <BudgetSidebarMarker title="Agent paused by 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<string, BuiltInAgentStatus>();
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)}
+28
View File
@@ -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 },
};
}
+3
View File
@@ -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,
+18
View File
@@ -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
// `<InlineBanner>` 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<BannerTone, string> = {
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<string, BrandChipColor> = {
backlog: "gray",
todo: "amber",
+66 -1
View File
@@ -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() {
</button>
</AgentIconPicker>
<div className="min-w-0">
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
{builtInState && <BuiltInAgentBadge />}
</div>
<p className="text-sm text-muted-foreground truncate">
{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 && (
<Link
@@ -1068,6 +1112,27 @@ export function AgentDetail() {
</div>
</div>
{builtInState && (
<InlineBanner
tone="info"
title="Built-in agent"
actions={
<Button
variant="outline"
size="sm"
onClick={() => resetBuiltIn.mutate()}
disabled={resetBuiltIn.isPending}
>
{resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"}
</Button>
}
>
Ships with Paperclip and powers <strong>{builtInFeatureLabel}</strong>. Configure it like
any agent — model, instructions, budget. It can be paused but not deleted; pausing it
pauses {builtInFeatureLabel}.
</InlineBanner>
)}
{!urlRunId && (
<Tabs
value={activeView}
+124 -22
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { builtInAgentsApi, type BuiltInAgentState } from "../api/builtInAgents";
import { environmentsApi } from "../api/environments";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -15,6 +16,7 @@ import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
@@ -34,7 +36,15 @@ import { getAdapterLabel } from "../adapters/adapter-display-registry";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
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<string>): 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<string>): OrgNode[] {
return nodes
.reduce<OrgNode[]>((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<string, BuiltInAgentState>();
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<BuiltInAgentState | null>(null);
const { data: agents, isLoading, error } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
@@ -261,8 +294,8 @@ export function Agents() {
return <PageSkeleton variant="list" />;
}
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 (
<EntityRow
key={agent.id}
@@ -305,12 +339,38 @@ export function Agents() {
<AgentStatusCapsule status={agent.status} />
)}
meta={
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns
agent={agent}
environment={resolveRenderedEnvironment(agent.id)}
showEnvironment={showEnvironmentColumn}
/>
<div className="flex items-center gap-3">
{builtInState && (
<div className="flex items-center gap-1.5">
{/* Provenance sits immediately after the name, then the
lifecycle chip; the real agent status stays in `trailing`. */}
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button
size="xs"
variant="outline"
onClick={() => setConfigureState(builtInState)}
>
Set up
</Button>
</span>
)}
</div>
)}
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns
agent={agent}
environment={resolveRenderedEnvironment(agent.id)}
showEnvironment={showEnvironmentColumn}
/>
</div>
</div>
}
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}
/>
))}
</div>
@@ -492,6 +555,18 @@ export function Agents() {
No organizational hierarchy defined.
</p>
)}
{configureState && selectedCompanyId && (
<Suspense fallback={null}>
<ConfigureBuiltInAgentModal
companyId={selectedCompanyId}
state={configureState}
open={configureState !== null}
onOpenChange={(open) => {
if (!open) setConfigureState(null);
}}
/>
</Suspense>
)}
</div>
);
}
@@ -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<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
builtInByAgentId: Map<string, BuiltInAgentState>;
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. */}
<div className="flex-1 min-w-0 truncate">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
{agent?.title ? ` - ${agent.title}` : ""}
</span>
<div className="flex-1 min-w-0 flex items-center gap-2">
<div className="min-w-0 truncate">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role}
{agent?.title ? ` - ${agent.title}` : ""}
</span>
</div>
{builtInState && (
<div className="flex items-center gap-1.5 shrink-0">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button size="xs" variant="outline" onClick={() => onConfigureBuiltIn(builtInState)}>
Set up
</Button>
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="sm:hidden">
@@ -639,6 +739,8 @@ function OrgTreeNode({
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
builtInByAgentId={builtInByAgentId}
onConfigureBuiltIn={onConfigureBuiltIn}
/>
))}
</div>
+69
View File
@@ -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) => (
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
{name}
@@ -1815,6 +1818,72 @@ export function DesignGuide() {
</ResizablePanelGroup>
</div>
</Section>
{/* ============================================================ */}
{/* INLINE BANNER + BUILT-IN AGENTS */}
{/* ============================================================ */}
<Section title="Inline Banner">
<p className="text-sm text-muted-foreground">
Token-backed full-width notice (<span className="font-mono">brandBanner</span> tones). Use{" "}
<span className="font-mono">info</span> for provenance/context and{" "}
<span className="font-mono">warning</span> for paused/attention. Supports an optional bold
title and a trailing actions slot. Replaces hand-rolled{" "}
<span className="font-mono">bg-yellow-*</span>/<span className="font-mono">bg-blue-*</span>{" "}
banners.
</p>
<div className="space-y-3">
<InlineBanner
tone="info"
title="Built-in agent"
actions={<Button variant="outline" size="sm">Reset to defaults</Button>}
>
Ships with Paperclip and powers <strong>Briefs</strong>. It can be paused but not deleted.
</InlineBanner>
<InlineBanner
tone="warning"
title="Briefs is paused."
actions={
<>
<Button variant="ghost" size="sm">View agent</Button>
<Button size="sm">Resume agent</Button>
</>
}
>
Its built-in agent was paused 2 days ago, so new briefs aren't being generated.
</InlineBanner>
<InlineBanner tone="info" compact>
Compact variant for embedding inside dialogs and modals.
</InlineBanner>
</div>
</Section>
<Section title="Built-in Agent Badges">
<p className="text-sm text-muted-foreground">
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{" "}
<span className="font-mono">needs_setup</span> / <span className="font-mono">pending_approval</span>.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="needs_setup" />
</div>
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="pending_approval" />
</div>
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status="needs_setup" compact />
</div>
</div>
<p className="mt-3 text-sm text-muted-foreground">
<span className="font-mono">&lt;BuiltInAgentGate agentKey&gt;</span> composes{" "}
<span className="font-mono">PageSkeleton</span> + <span className="font-mono">EmptyState</span>{" "}
+ <span className="font-mono">InlineBanner</span> to render the loading / setup /
pending-approval / paused / ready states of a feature that depends on a built-in agent.
</p>
</Section>
</div>
);
}
@@ -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 (
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{children}
</p>
);
}
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: () => (
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="space-y-3">
<SectionLabel>Board 1 — Roster rows</SectionLabel>
<div className="rounded-lg border border-border divide-y divide-border">
<EntityRow
title="Briefs Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="needs_setup" />
<Button size="xs" variant="outline">Set up</Button>
</div>
}
trailing={<AgentStatusBadge status="idle" />}
/>
<EntityRow
title="Learning Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
</div>
}
trailing={<AgentStatusBadge status="active" />}
/>
<EntityRow
title="Briefs Agent"
subtitle="General"
meta={
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="pending_approval" />
</div>
}
trailing={<AgentStatusBadge status="idle" />}
/>
</div>
</div>
<div className="space-y-3">
<SectionLabel>Board 2 — Agent detail provenance banner</SectionLabel>
<InlineBanner
tone="info"
title="Built-in agent"
actions={<Button variant="outline" size="sm">Reset to defaults</Button>}
>
Ships with Paperclip and powers <strong>Briefs</strong>. Configure it like any agent —
model, instructions, budget. It can be paused but not deleted; pausing it pauses Briefs.
</InlineBanner>
</div>
<div className="space-y-3">
<SectionLabel>Board 4A — Feature gate: setup empty-state</SectionLabel>
<div className="rounded-lg border border-border">
<EmptyState
icon={Bot}
title="Set up the Briefs Agent"
message="Briefs is generated by a built-in agent. Configure its model to enable the feature."
action="Set up Briefs Agent"
onAction={() => {}}
hideActionIcon
/>
</div>
</div>
<div className="space-y-3">
<SectionLabel>Board 4 — Feature gate: pending approval</SectionLabel>
<div className="rounded-lg border border-border">
<EmptyState
icon={Clock3}
title="Briefs Agent is pending approval"
message="Briefs will be available after the board approves this built-in agent hire."
/>
</div>
</div>
<div className="space-y-3">
<SectionLabel>Board 4B — Feature gate: paused banner over stale content</SectionLabel>
<div className="space-y-4">
<InlineBanner
tone="warning"
title="Briefs is paused."
actions={
<>
<Button variant="ghost" size="sm">View agent</Button>
<Button size="sm">Resume agent</Button>
</>
}
>
Its built-in agent was paused 2 days ago, so new briefs aren't being generated.
</InlineBanner>
<div className="opacity-70 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Previously generated briefs stay readable while the agent is paused.
</div>
</div>
</div>
<div className="space-y-3">
<SectionLabel>Board 5 — Sidebar treatment</SectionLabel>
<div className="w-64 rounded-lg border border-border p-2 space-y-1">
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
<span className="min-w-0 truncate">Briefs Agent</span>
<span className="ml-1 flex items-center gap-1">
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status="needs_setup" compact />
</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
<span className="min-w-0 truncate">Learning Agent</span>
<span className="ml-1 flex items-center gap-1">
<BuiltInAgentBadge compact />
</span>
</div>
</div>
</div>
<div className="space-y-3">
<SectionLabel>Board 5 — Use-while-paused toast</SectionLabel>
<div className="w-80 rounded-lg border border-[#F59E0B]/50 bg-[#FEF3C7]/60 p-3 text-sm text-[#B45309] dark:bg-[#f59e0b12] dark:text-[#F59E0B]">
<p className="font-medium">Briefs Agent is paused</p>
<p className="opacity-90">Resume the agent to generate this brief.</p>
<a href="#" className="mt-1 inline-block text-xs font-medium underline">View agent</a>
</div>
</div>
</div>
),
};
/** Board 3 — configure-on-first-use modal (open). */
export const ConfigureModal: Story = {
render: () => {
const [open, setOpen] = useState(true);
return (
<div className="p-6">
<Button onClick={() => setOpen(true)}>Open configure modal</Button>
<ConfigureBuiltInAgentModal
companyId="company-storybook"
state={notProvisionedState}
open={open}
onOpenChange={setOpen}
/>
</div>
);
},
};
/** Board 2 — pause confirmation dialog with dependency warning. */
export const PauseConfirmDialog: Story = {
render: () => (
<div className="p-6">
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Pause the Briefs Agent?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
Briefs depends on this agent. While paused, briefs generation is skipped and the
Briefs page shows a warning.
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Pause anyway</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
),
};
void briefsAgent;