mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
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:
1 parent
ad454e62f5
commit
b65f74c2a3
18 files changed
+1603
-43
No files matched your search
@@ -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`, {}),
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"><BuiltInAgentGate agentKey></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;
|
||||
Reference in new issue
Block a user