Add Reflection Coach built-in bundle status UI (PAP-13099)

Implements the bundle status panel for the Reflection Coach built-in agent on
AgentDetail, per the PAP-13097 UX spec and the PAP-13098 server contract:

- ResourceStatusChip: readiness + drift chip grammar (ready/needs-setup/missing/
  update-available/drifted/schedule-off), token-only via brandChipBadge, glyph +
  word so state is never color-alone (WCAG 1.4.1).
- BuiltInBundlePanel: adapter/skill/instructions/routine rows with per-resource
  Update/Reset/Recreate actions (confirm dialogs, ux-spec §8 copy), View links,
  and the zero-token-by-default routine row. Adapter readiness derives from the
  agent lifecycle status; skill/instructions/routine from state.resources.
- Scoped per-resource reset wired to built-in-agents/:key/reset { resources:[kind] }
  (re-materializes one resource to the newest shipped default, preserving other
  edits and adapter credentials); Configure opens the existing modal.
- API client: BuiltInAgentBundleMeta + BuiltInManagedResourceState types,
  resources[] on BuiltInAgentState, scoped reset + reconcile.
- Storybook board + BuiltInBundlePanel.test.tsx (7 tests). tsc, token-gates,
  and tests green.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 20:50:34 +00:00
1 parent 752b4a32a5
commit f2b71590cc
6 files changed
+888 -10

No files matched your search

+86 -2
View File
@@ -17,6 +17,31 @@ export type BuiltInAgentStatus =
| "ready"
| "paused";
/**
* Redacted bundle metadata returned alongside a built-in agent that ships a
* managed resource bundle (instructions + skill + routine). The server strips
* file bodies to key lists; the UI only needs the identity/labels to render the
* bundle status panel. Present only on bundle-backed built-ins (Reflection
* Coach); flat built-ins (briefs/learning) omit it.
*/
export interface BuiltInAgentBundleMeta {
stockVersion: string;
instructions: { entryFile: string; files: string[] };
skill: {
skillKey: string;
displayName: string;
slug: string;
canonicalKey: string;
files: string[];
};
routine: {
routineKey: string;
title: string;
status: "active" | "paused";
triggerCount: number;
};
}
export interface BuiltInAgentDefinition {
key: string;
displayName: string;
@@ -26,6 +51,38 @@ export interface BuiltInAgentDefinition {
defaultRole: string;
allowedAdapterTypes?: string[];
defaultBudgetMonthlyCents?: number;
bundle?: BuiltInAgentBundleMeta;
}
/** Managed resources a bundle materializes; drift is tracked per kind. */
export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine";
/**
* Drift status of one managed resource versus the shipped stock default:
* - `missing` — expected resource absent; a reconcile will recreate it.
* - `stock_current` — present and byte-identical to the shipped default.
* - `stock_update_available` — unedited, but Paperclip shipped a newer default.
* - `operator_modified` — operator-edited; reconcile preserves these edits.
*/
export type BuiltInManagedResourceStockStatus =
| "missing"
| "stock_current"
| "stock_update_available"
| "operator_modified";
export interface BuiltInManagedResourceState {
resourceKind: BuiltInManagedResourceKind;
resourceKey: string;
resourceId: string | null;
stockVersion: string;
stockHash: string;
currentHash: string | null;
stockStatus: BuiltInManagedResourceStockStatus;
/** True when an unedited resource has a newer shipped default to apply. */
updateAvailable: boolean;
/** True when the resource has drifted and can be reset to the default. */
resetAvailable: boolean;
changedFiles?: string[];
}
export interface BuiltInAgentState {
@@ -34,6 +91,8 @@ export interface BuiltInAgentState {
agentId: string | null;
agent: Agent | null;
pauseReason: string | null;
/** Per-resource drift/readiness for bundle-backed built-ins (may be empty). */
resources?: BuiltInManagedResourceState[];
/** Present when provisioning queued a board hire approval (HTTP 202). */
approval?: Approval | null;
}
@@ -44,6 +103,14 @@ export interface BuiltInAgentProvisionInput {
budgetMonthlyCents?: number;
}
/**
* Selectors accepted by the reset endpoint. `agent` resets the agent config
* (adapter/model/budget defaults) only; the resource kinds each reset a single
* managed resource back to its shipped default. Omitting the array resets
* everything (the agent-level "Reset to defaults" button).
*/
export type BuiltInResetResource = "agent" | BuiltInManagedResourceKind;
/**
* 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
@@ -62,6 +129,23 @@ export const builtInAgentsApi = {
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`, {}),
/**
* Reset built-in defaults. Pass `resources` to scope the reset to specific
* managed resources (e.g. `["skill"]`); omit it to reset the whole agent +
* bundle. A single-resource reset re-applies that resource's newest shipped
* default — the same path used for both "reset drifted edits" and "apply an
* available stock update".
*/
reset: (companyId: string, key: string, resources?: BuiltInResetResource[]) =>
api.post<BuiltInAgentState>(
`/companies/${companyId}/built-in-agents/${key}/reset`,
resources ? { resources } : {},
),
/**
* Re-materialize the bundle. Applies the newest shipped defaults to unedited
* (`stock_update_available`) and `missing` resources while preserving
* `operator_modified` edits — it is the safe "apply available updates" path.
*/
reconcile: (companyId: string, key: string) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}),
};
@@ -0,0 +1,200 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BuiltInBundlePanel } from "./BuiltInBundlePanel";
import type {
BuiltInAgentState,
BuiltInManagedResourceState,
BuiltInManagedResourceStockStatus,
} from "@/api/builtInAgents";
// The panel links to agent tabs via `@/lib/router` (company-prefixed Link).
// Stub it to a plain anchor so the panel test doesn't need CompanyContext.
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
function resource(
resourceKind: BuiltInManagedResourceState["resourceKind"],
stockStatus: BuiltInManagedResourceStockStatus,
): BuiltInManagedResourceState {
return {
resourceKind,
resourceKey: resourceKind === "skill" ? "reflection-coach" : resourceKind === "routine" ? "recent-agent-reflection" : "AGENTS.md",
resourceId: "res-1",
stockVersion: "2026-07-08",
stockHash: "aaaa",
currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb",
stockStatus,
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
resetAvailable: stockStatus !== "stock_current",
};
}
function makeState(
status: BuiltInAgentState["status"],
resources: BuiltInManagedResourceState[],
): BuiltInAgentState {
return {
definition: {
key: "reflection-coach",
displayName: "Reflection Coach",
featureKeys: ["reflection"],
shortPurpose: "Coaches recent agents.",
defaultInstructions: "…",
defaultRole: "general",
allowedAdapterTypes: ["codex_local"],
defaultBudgetMonthlyCents: 0,
bundle: {
stockVersion: "2026-07-08",
instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] },
skill: {
skillKey: "reflection-coach",
displayName: "reflection-coach",
slug: "reflection-coach",
canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach",
files: ["reflection-coach/SKILL.md"],
},
routine: {
routineKey: "recent-agent-reflection",
title: "Recent agent reflection",
status: "paused",
triggerCount: 1,
},
},
},
status,
agentId: "agent-1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent: { id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any,
pauseReason: null,
resources,
};
}
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
const READY_RESOURCES = [
resource("skill", "stock_current"),
resource("instructions", "stock_current"),
resource("routine", "stock_current"),
];
describe("BuiltInBundlePanel (PAP-13099)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
function render(state: BuiltInAgentState, handlers: Partial<{
onConfigure: () => void;
onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void;
}> = {}) {
root = createRoot(container);
flushSync(() => {
root!.render(
<BuiltInBundlePanel
state={state}
agentRef="reflectioncoach"
onConfigure={handlers.onConfigure ?? (() => {})}
onResetResource={handlers.onResetResource ?? (() => {})}
/>,
);
});
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
// Radix portals dialog content onto body; clear leftovers between tests.
document.body.querySelectorAll("[data-slot='alert-dialog-portal']").forEach((node) => node.remove());
});
it("renders four resource rows with ready + schedule-off chips when healthy", () => {
render(makeState("ready", READY_RESOURCES));
const text = container.textContent ?? "";
expect(text).toContain("Bundle status");
expect(text).toContain("Adapter");
expect(text).toContain("Skill");
expect(text).toContain("Instructions");
expect(text).toContain("Routine");
// Zero-token guarantee copy is always present on the routine row.
expect(text).toContain("costs zero tokens by default");
expect(text).toContain("Schedule off");
expect(text).toContain("Ready");
});
it("shows Needs setup for the adapter when the agent is not configured yet", () => {
render(makeState("needs_setup", READY_RESOURCES));
const text = container.textContent ?? "";
expect(text).toContain("Needs setup");
expect(text).toContain("Pick an adapter this coach can run on");
});
it("surfaces an update-available chip and Update action for unedited stock drift", () => {
render(makeState("ready", [
resource("skill", "stock_current"),
resource("instructions", "stock_update_available"),
resource("routine", "stock_current"),
]));
const text = container.textContent ?? "";
expect(text).toContain("Update available");
expect(text).toContain("Paperclip shipped a newer default");
// The per-resource Update trigger button is present.
const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent);
expect(buttons).toContain("Update");
});
it("surfaces a Drifted chip and Reset action for operator-modified resources", () => {
render(makeState("ready", [
resource("skill", "operator_modified"),
resource("instructions", "stock_current"),
resource("routine", "stock_current"),
]));
const text = container.textContent ?? "";
expect(text).toContain("Drifted");
expect(text).toContain("Your changes are kept");
const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent);
expect(buttons).toContain("Reset");
});
it("shows a Missing chip when a resource is not materialized", () => {
render(makeState("ready", [
resource("skill", "missing"),
resource("instructions", "stock_current"),
resource("routine", "stock_current"),
]));
expect(container.textContent).toContain("Missing");
});
it("fires onConfigure when the adapter Configure button is clicked", () => {
const onConfigure = vi.fn();
render(makeState("needs_setup", READY_RESOURCES), { onConfigure });
const configureBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "Configure",
);
expect(configureBtn).toBeTruthy();
flushSync(() => configureBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onConfigure).toHaveBeenCalledTimes(1);
});
it("renders nothing for a built-in without a bundle", () => {
const flat = makeState("ready", READY_RESOURCES);
delete flat.definition.bundle;
render(flat);
expect(container.textContent).toBe("");
});
});
+327
View File
@@ -0,0 +1,327 @@
import type { ReactNode } from "react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ResourceStatusChip, type ResourceStatusVariant } from "@/components/ResourceStatusChip";
import { cn } from "@/lib/utils";
import type {
BuiltInAgentState,
BuiltInManagedResourceKind,
BuiltInManagedResourceState,
} from "@/api/builtInAgents";
/**
* Bundle status panel for a bundle-backed built-in agent (Reflection Coach —
* [PAP-13099], ux-spec §3–§8). Renders one row per managed resource
* (adapter · skill · instructions · routine, dependency order) with a readiness
* chip, drift chip, inline copy, and the wireable per-resource actions.
*
* Presentational: the parent owns queries/mutations and passes handlers. The
* confirm-before-mutate dialogs and copy live here (ux-spec §8). Adapter
* readiness is derived from the agent lifecycle `status` (there is no adapter
* resource in `resources[]`); skill/instructions/routine come from
* `state.resources`.
*
* Both "apply an available stock update" and "reset drifted edits" route
* through the same scoped reset (`onResetResource(kind)` →
* `built-in-agents/:key/reset { resources: [kind] }`), which re-materializes
* that one resource to Paperclip's newest shipped default without touching
* adapter credentials or the other resources.
*/
function findResource(
resources: BuiltInManagedResourceState[] | undefined,
kind: BuiltInManagedResourceKind,
): BuiltInManagedResourceState | undefined {
return resources?.find((resource) => resource.resourceKind === kind);
}
/** Readiness chip for a materialized resource. */
function readinessVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant {
if (resource.stockStatus === "missing") return "missing";
return "ready";
}
/** Drift chip shown alongside a `ready` readiness chip, or `null`. */
function driftVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant | null {
if (resource.stockStatus === "missing") return null; // readiness wins; drift suppressed
if (resource.stockStatus === "stock_update_available") return "update_available";
if (resource.stockStatus === "operator_modified") return "drifted";
return null;
}
interface ResourceActionCopy {
title: string;
body: string;
confirmLabel: string;
triggerLabel: string;
}
/** Confirm-dialog copy per drift state (ux-spec §8 copy deck). */
function resourceActionCopy(
resource: BuiltInManagedResourceState,
label: string,
): ResourceActionCopy | null {
if (resource.stockStatus === "stock_update_available") {
return {
title: `Update ${label} to the newest default?`,
body: `You haven't edited this, so Paperclip will replace it with the newer shipped version. Nothing you customized is affected, and your adapter credentials and settings are not touched.`,
confirmLabel: "Update",
triggerLabel: "Update",
};
}
if (resource.stockStatus === "operator_modified") {
return {
title: `Reset ${label} to the shipped default?`,
body: `This replaces your edited version with Paperclip's current default. Your edits can't be recovered. Adapter credentials and settings are not touched.`,
confirmLabel: `Reset ${label}`,
triggerLabel: "Reset",
};
}
if (resource.stockStatus === "missing") {
return {
title: `Recreate ${label}?`,
body: `This resource is missing. Paperclip will recreate it from the shipped default. Adapter credentials and settings are not touched.`,
confirmLabel: "Recreate",
triggerLabel: "Recreate",
};
}
return null;
}
function ResourceActionButton({
resource,
label,
onConfirm,
pending,
}: {
resource: BuiltInManagedResourceState;
label: string;
onConfirm: () => void;
pending: boolean;
}) {
const copy = resourceActionCopy(resource, label);
if (!copy) return null;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={pending}>
{pending ? "Working…" : copy.triggerLabel}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{copy.title}</AlertDialogTitle>
<AlertDialogDescription>{copy.body}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{copy.confirmLabel}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
interface BundleRowProps {
label: string;
secondary?: string;
chips: ReactNode;
detail?: ReactNode;
detailTone?: "muted" | "error";
actions?: ReactNode;
}
function BundleRow({ label, secondary, chips, detail, detailTone = "muted", actions }: BundleRowProps) {
return (
<div className="flex flex-col gap-1 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{label}</span>
{secondary && (
<span className="text-(length:--text-micro) text-muted-foreground">{secondary}</span>
)}
{chips}
</div>
{detail && (
<p
className={cn(
"text-(length:--text-micro) leading-snug",
detailTone === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground",
)}
>
{detail}
</p>
)}
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
function driftDetail(resource: BuiltInManagedResourceState): string | undefined {
switch (resource.stockStatus) {
case "operator_modified":
return "You've edited this. Your changes are kept until you reset.";
case "stock_update_available":
return "Paperclip shipped a newer default.";
case "missing":
return "Not materialized yet — recreate it from the shipped default.";
default:
return undefined;
}
}
export interface BuiltInBundlePanelProps {
state: BuiltInAgentState;
/** Route ref used to link View › actions to the agent's tabs. */
agentRef: string;
/** Opens the adapter configure modal. */
onConfigure: () => void;
/** Scoped reset for one resource (apply update / reset drift / recreate). */
onResetResource: (kind: BuiltInManagedResourceKind) => void;
/** The resource kind whose reset is currently in flight, if any. */
resettingResource?: BuiltInManagedResourceKind | null;
className?: string;
}
export function BuiltInBundlePanel({
state,
agentRef,
onConfigure,
onResetResource,
resettingResource = null,
className,
}: BuiltInBundlePanelProps) {
const { status, definition, resources } = state;
const bundle = definition.bundle;
if (!bundle) return null;
const adapterReady = status === "ready" || status === "paused";
// --- Adapter row (derived from the agent lifecycle status) -----------------
let adapterChip: ResourceStatusVariant = "ready";
let adapterDetail: string | undefined;
if (status === "pending_approval") {
adapterChip = "pending_approval";
adapterDetail = "Waiting on board hire approval before this coach can run.";
} else if (!adapterReady) {
adapterChip = "needs_setup";
adapterDetail = "Pick an adapter this coach can run on.";
}
const skill = findResource(resources, "skill");
const instructions = findResource(resources, "instructions");
const routine = findResource(resources, "routine");
const renderResourceRow = (
kind: BuiltInManagedResourceKind,
label: string,
secondary: string,
viewHref: string,
resource: BuiltInManagedResourceState,
) => {
const drift = driftVariant(resource);
return (
<BundleRow
key={kind}
label={label}
secondary={secondary}
chips={
<>
<ResourceStatusChip variant={readinessVariant(resource)} />
{drift && <ResourceStatusChip variant={drift} />}
</>
}
detail={driftDetail(resource)}
actions={
<>
<Button asChild variant="link" size="sm">
<Link to={viewHref}>View</Link>
</Button>
<ResourceActionButton
resource={resource}
label={label}
onConfirm={() => onResetResource(kind)}
pending={resettingResource === kind}
/>
</>
}
/>
);
};
return (
<section className={cn("space-y-2", className)} aria-label="Bundle status">
<h3 className="text-sm font-medium">Bundle status</h3>
<div className="divide-y rounded-lg border px-4">
{/* Adapter — no resource entry; readiness is the agent lifecycle. */}
<BundleRow
label="Adapter"
chips={<ResourceStatusChip variant={adapterChip} />}
detail={adapterDetail}
actions={
<Button variant="outline" size="sm" onClick={onConfigure}>
Configure
</Button>
}
/>
{skill &&
renderResourceRow(
"skill",
"Skill",
bundle.skill.displayName || skill.resourceKey,
`/agents/${agentRef}/skills`,
skill,
)}
{instructions &&
renderResourceRow(
"instructions",
"Instructions",
bundle.instructions.entryFile,
`/agents/${agentRef}/instructions`,
instructions,
)}
{/* Routine — zero-token-by-default; the weekly schedule ships off. */}
<BundleRow
label="Routine"
secondary={bundle.routine.title}
chips={
<>
<ResourceStatusChip variant="schedule_off" />
{routine && driftVariant(routine) && (
<ResourceStatusChip variant={driftVariant(routine)!} />
)}
</>
}
detail="Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
actions={
routine && driftVariant(routine) ? (
<ResourceActionButton
resource={routine}
label="routine"
onConfirm={() => onResetResource("routine")}
pending={resettingResource === "routine"}
/>
) : undefined
}
/>
</div>
</section>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { brandChipBadge, type BrandChipColor } from "@/lib/status-colors";
/**
* The load-bearing visual grammar for the built-in bundle status panel
* (Reflection Coach — [PAP-13099], ux-spec §4). Each variant double-encodes
* state as glyph + word + color so it never relies on color alone
* (WCAG 1.4.1). Colors route through the shared `brandChipBadge` families — no
* bespoke tints are minted here (ux-spec §10).
*
* A single resource shows at most one readiness chip and at most one drift
* chip; when both a readiness problem and a drift state coexist, the caller
* suppresses the drift chip until readiness is `ready` (ux-spec §4).
*/
export type ResourceStatusVariant =
| "ready"
| "needs_setup"
| "missing"
| "error"
| "update_available"
| "drifted"
| "schedule_off"
| "schedule_on"
| "pending_approval"
| "proposal_pending";
interface VariantSpec {
color: BrandChipColor;
glyph: string;
label: string;
title: string;
}
const VARIANTS: Record<ResourceStatusVariant, VariantSpec> = {
ready: { color: "green", glyph: "●", label: "Ready", title: "Materialized and matches the shipped default" },
needs_setup: { color: "amber", glyph: "⚠", label: "Needs setup", title: "Present but not usable yet" },
missing: { color: "amber", glyph: "⚠", label: "Missing", title: "Expected resource absent; reconcile will recreate it" },
error: { color: "red", glyph: "✕", label: "Error", title: "Failed to load or reconcile" },
update_available: {
color: "blue",
glyph: "↑",
label: "Update available",
title: "Unedited — a newer shipped default can be applied",
},
drifted: {
color: "gray",
glyph: "✎",
label: "Drifted",
title: "You've edited this; your changes are kept, not overwritten",
},
schedule_off: {
color: "gray",
glyph: "◌",
label: "Schedule off",
title: "No background work runs until you enable it — costs zero tokens",
},
schedule_on: { color: "green", glyph: "●", label: "Weekly", title: "Runs on the weekly schedule" },
pending_approval: {
color: "amber",
glyph: "⚠",
label: "Pending approval",
title: "Waiting on board hire approval before it can run",
},
proposal_pending: {
color: "blue",
glyph: "↑",
label: "Proposal pending",
title: "A proposed update is waiting for your review",
},
};
export function ResourceStatusChip({
variant,
label,
compact = false,
className,
}: {
variant: ResourceStatusVariant;
/** Override the default label (e.g. "Weekly · Mon 09:00 UTC"). */
label?: string;
compact?: boolean;
className?: string;
}) {
const spec = VARIANTS[variant];
return (
<Badge
variant="outline"
className={cn(
brandChipBadge[spec.color],
"font-medium",
compact && "px-1.5 py-0 text-(length:--text-nano)",
className,
)}
title={spec.title}
>
<span aria-hidden="true">{spec.glyph}</span>
{label ?? spec.label}
</Badge>
);
}
+44 -7
View File
@@ -7,7 +7,7 @@ import {
type ClaudeLoginResult,
type AgentPermissionUpdate,
} from "../api/agents";
import { builtInAgentsApi } from "../api/builtInAgents";
import { builtInAgentsApi, type BuiltInManagedResourceKind } from "../api/builtInAgents";
import { companySkillsApi } from "../api/companySkills";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
@@ -45,6 +45,8 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { InlineBanner } from "../components/InlineBanner";
import { BuiltInAgentBadge } from "../components/BuiltInAgentBadges";
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { TrustPresetSection } from "../components/TrustPresetSection";
import { FileTree, buildFileTree } from "../components/FileTree";
@@ -730,14 +732,26 @@ export function AgentDetail() {
.map((key) => key.charAt(0).toUpperCase() + key.slice(1))
.join(", ")
: "";
const invalidateBuiltIn = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) });
if (resolvedAgentId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) });
}
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
}, [queryClient, resolvedCompanyId, resolvedAgentId, routeAgentRef]);
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) });
onSuccess: invalidateBuiltIn,
});
const [showBuiltInConfigure, setShowBuiltInConfigure] = useState(false);
const resetBuiltInResource = useMutation({
mutationFn: (kind: BuiltInManagedResourceKind) =>
builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key, [kind]),
onSuccess: invalidateBuiltIn,
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update bundle resource");
},
});
@@ -1141,6 +1155,29 @@ export function AgentDetail() {
</InlineBanner>
)}
{builtInState?.definition.bundle && (
<BuiltInBundlePanel
state={builtInState}
agentRef={canonicalAgentRef}
onConfigure={() => setShowBuiltInConfigure(true)}
onResetResource={(kind) => resetBuiltInResource.mutate(kind)}
resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null}
/>
)}
{builtInState && resolvedCompanyId && (
<ConfigureBuiltInAgentModal
companyId={resolvedCompanyId}
state={builtInState}
open={showBuiltInConfigure}
onOpenChange={setShowBuiltInConfigure}
onConfigured={() => {
setShowBuiltInConfigure(false);
invalidateBuiltIn();
}}
/>
)}
{!urlRunId && (
<Tabs
value={activeView}
@@ -9,6 +9,7 @@ import { InlineBanner } from "@/components/InlineBanner";
import { AgentStatusBadge } from "@/components/StatusBadge";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal";
import { BuiltInBundlePanel } from "@/components/BuiltInBundlePanel";
import {
AlertDialog,
AlertDialogAction,
@@ -19,7 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { BuiltInAgentState } from "@/api/builtInAgents";
import type { BuiltInAgentState, BuiltInManagedResourceState } from "@/api/builtInAgents";
import { Bot, Clock3 } from "lucide-react";
const briefsAgent: Agent = {
@@ -268,4 +269,132 @@ export const PauseConfirmDialog: Story = {
),
};
// ---------------------------------------------------------------------------
// Reflection Coach bundle status panel (PAP-13099).
// ---------------------------------------------------------------------------
const reflectionBundle = {
stockVersion: "2026-07-08",
instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] },
skill: {
skillKey: "reflection-coach",
displayName: "reflection-coach",
slug: "reflection-coach",
canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach",
files: ["reflection-coach/SKILL.md"],
},
routine: {
routineKey: "recent-agent-reflection",
title: "Recent agent reflection",
status: "paused" as const,
triggerCount: 1,
},
};
const reflectionDefinition = {
key: "reflection-coach",
displayName: "Reflection Coach",
featureKeys: ["reflection"],
shortPurpose: "Reviews recent agents and coaches them.",
defaultInstructions: "You are Paperclip's built-in Reflection Coach.",
defaultRole: "general",
allowedAdapterTypes: ["codex_local", "claude_local"],
defaultBudgetMonthlyCents: 0,
bundle: reflectionBundle,
};
function bundleResource(
resourceKind: BuiltInManagedResourceState["resourceKind"],
stockStatus: BuiltInManagedResourceState["stockStatus"],
): BuiltInManagedResourceState {
return {
resourceKind,
resourceKey:
resourceKind === "skill"
? "reflection-coach"
: resourceKind === "routine"
? "recent-agent-reflection"
: "AGENTS.md",
resourceId: "res-1",
stockVersion: "2026-07-08",
stockHash: "aaaa",
currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb",
stockStatus,
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
resetAvailable: stockStatus !== "stock_current",
};
}
function bundleState(
status: BuiltInAgentState["status"],
resources: BuiltInManagedResourceState[],
): BuiltInAgentState {
return {
definition: reflectionDefinition,
status,
agentId: "agent-reflection",
agent: null,
pauseReason: null,
resources,
};
}
const READY = [
bundleResource("skill", "stock_current"),
bundleResource("instructions", "stock_current"),
bundleResource("routine", "stock_current"),
];
function BundleCase({ title, state }: { title: string; state: BuiltInAgentState }) {
return (
<div className="space-y-2">
<p className="text-(length:--text-micro) font-medium text-muted-foreground">{title}</p>
<BuiltInBundlePanel
state={state}
agentRef="reflectioncoach"
onConfigure={() => {}}
onResetResource={() => {}}
/>
</div>
);
}
/**
* Board — Reflection Coach bundle status panel across the ux-spec states
* (§5a needs-adapter, §5b all-ready, §5c update available, §5d drifted,
* §5f missing). Light + dark are captured by the screenshot recipe.
*/
export const BundleStatusPanel: Story = {
render: () => (
<div className="mx-auto grid max-w-3xl gap-8 p-6">
<BundleCase title="§5a — needs adapter (nothing runs yet)" state={bundleState("needs_setup", READY)} />
<BundleCase title="§5b — all ready, schedule off (healthy default)" state={bundleState("ready", READY)} />
<BundleCase
title="§5c — update available (unedited stock, newer default shipped)"
state={bundleState("ready", [
bundleResource("skill", "stock_current"),
bundleResource("instructions", "stock_update_available"),
bundleResource("routine", "stock_current"),
])}
/>
<BundleCase
title="§5d — drifted (operator-modified, edits preserved)"
state={bundleState("ready", [
bundleResource("skill", "operator_modified"),
bundleResource("instructions", "stock_current"),
bundleResource("routine", "stock_current"),
])}
/>
<BundleCase
title="§5f — missing resource (reconcile recreates it)"
state={bundleState("ready", [
bundleResource("skill", "missing"),
bundleResource("instructions", "stock_current"),
bundleResource("routine", "stock_current"),
])}
/>
</div>
),
};
void briefsAgent;