mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
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:
1 parent
752b4a32a5
commit
f2b71590cc
6 files changed
+888
-10
No files matched your search
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user