From f2b71590ccbaa74e8dc1ffc500b37e17cb4a75f2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 8 Jul 2026 18:24:32 +0000 Subject: [PATCH] Add Reflection Coach built-in bundle status UI (PAP-13099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/api/builtInAgents.ts | 88 ++++- ui/src/components/BuiltInBundlePanel.test.tsx | 200 +++++++++++ ui/src/components/BuiltInBundlePanel.tsx | 327 ++++++++++++++++++ ui/src/components/ResourceStatusChip.tsx | 101 ++++++ ui/src/pages/AgentDetail.tsx | 51 ++- .../stories/built-in-agents.stories.tsx | 131 ++++++- 6 files changed, 888 insertions(+), 10 deletions(-) create mode 100644 ui/src/components/BuiltInBundlePanel.test.tsx create mode 100644 ui/src/components/BuiltInBundlePanel.tsx create mode 100644 ui/src/components/ResourceStatusChip.tsx diff --git a/ui/src/api/builtInAgents.ts b/ui/src/api/builtInAgents.ts index 30375a36d8f..5e6bf1b4814 100644 --- a/ui/src/api/builtInAgents.ts +++ b/ui/src/api/builtInAgents.ts @@ -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(`/companies/${companyId}/built-in-agents`), provision: (companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) => api.post(`/companies/${companyId}/built-in-agents/${key}/provision`, input), - reset: (companyId: string, key: string) => - api.post(`/companies/${companyId}/built-in-agents/${key}/reset`, {}), + /** + * 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( + `/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(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}), }; diff --git a/ui/src/components/BuiltInBundlePanel.test.tsx b/ui/src/components/BuiltInBundlePanel.test.tsx new file mode 100644 index 00000000000..f37fee67e70 --- /dev/null +++ b/ui/src/components/BuiltInBundlePanel.test.tsx @@ -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 }) => {children}, +})); + +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( + {})} + 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(""); + }); +}); diff --git a/ui/src/components/BuiltInBundlePanel.tsx b/ui/src/components/BuiltInBundlePanel.tsx new file mode 100644 index 00000000000..4d2c5e97992 --- /dev/null +++ b/ui/src/components/BuiltInBundlePanel.tsx @@ -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 ( + + + + + + + {copy.title} + {copy.body} + + + Cancel + {copy.confirmLabel} + + + + ); +} + +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 ( +
+
+
+ {label} + {secondary && ( + {secondary} + )} + {chips} +
+ {detail && ( +

+ {detail} +

+ )} +
+ {actions &&
{actions}
} +
+ ); +} + +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 ( + + + {drift && } + + } + detail={driftDetail(resource)} + actions={ + <> + + onResetResource(kind)} + pending={resettingResource === kind} + /> + + } + /> + ); + }; + + return ( +
+

Bundle status

+ +
+ {/* Adapter — no resource entry; readiness is the agent lifecycle. */} + } + detail={adapterDetail} + actions={ + + } + /> + + {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. */} + + + {routine && driftVariant(routine) && ( + + )} + + } + detail="Nothing runs until you enable the weekly schedule — it costs zero tokens by default." + actions={ + routine && driftVariant(routine) ? ( + onResetResource("routine")} + pending={resettingResource === "routine"} + /> + ) : undefined + } + /> +
+
+ ); +} diff --git a/ui/src/components/ResourceStatusChip.tsx b/ui/src/components/ResourceStatusChip.tsx new file mode 100644 index 00000000000..119946a13f0 --- /dev/null +++ b/ui/src/components/ResourceStatusChip.tsx @@ -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 = { + 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 ( + + + {label ?? spec.label} + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 045e5d587f9..0ecbca7262b 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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() { )} + {builtInState?.definition.bundle && ( + setShowBuiltInConfigure(true)} + onResetResource={(kind) => resetBuiltInResource.mutate(kind)} + resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null} + /> + )} + + {builtInState && resolvedCompanyId && ( + { + setShowBuiltInConfigure(false); + invalidateBuiltIn(); + }} + /> + )} + {!urlRunId && ( +

{title}

+ {}} + onResetResource={() => {}} + /> + + ); +} + +/** + * 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: () => ( +
+ + + + + +
+ ), +}; + void briefsAgent;