feat(briefs): add managed analyst resources

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-24 02:28:36 +00:00
1 parent 602902ca3d
commit 0f84cb9aed
15 files changed
+2356 -19

No files matched your search

+12 -2
View File
@@ -33,7 +33,8 @@
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
@@ -44,7 +45,7 @@
"scripts": {
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "tsc",
"build": "tsc && node ./scripts/build-ui.mjs",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run",
@@ -56,7 +57,16 @@
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"esbuild": "^0.27.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
@@ -0,0 +1,25 @@
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, "..");
await esbuild.build({
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
outfile: path.join(packageRoot, "dist/ui/index.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
jsx: "automatic",
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@paperclipai/plugin-sdk/ui",
],
logLevel: "info",
});
+263 -2
View File
@@ -1,6 +1,39 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
import {
BRIEFING_ANALYST_INSTRUCTIONS,
DISCOVER_CARDS_SKILL,
DISCOVERY_ROUTINE_DESCRIPTION,
MANUAL_REFRESH_ROUTINE_DESCRIPTION,
UPDATE_CARDS_SKILL,
UPDATE_ROUTINE_DESCRIPTION,
} from "./templates.js";
export const PLUGIN_ID = "paperclipai.plugin-briefs";
export const BRIEFS_ROUTE_PATH = "briefs";
export const BRIEFS_SIDEBAR_SLOT_ID = "briefs-sidebar";
export const BRIEFS_PAGE_SLOT_ID = "briefs-page";
export const BRIEFING_ANALYST_AGENT_KEY = "briefing-analyst";
export const BRIEFS_PROJECT_KEY = "briefs";
export const DISCOVER_CARDS_SKILL_KEY = "briefs-discover-cards";
export const UPDATE_CARDS_SKILL_KEY = "briefs-update-cards";
export const DISCOVER_CARDS_ROUTINE_KEY = "briefs-discover-cards";
export const UPDATE_CARDS_ROUTINE_KEY = "briefs-update-cards";
export const MANUAL_REFRESH_ROUTINE_KEY = "briefs-manual-refresh";
export const BRIEFS_MANAGED_SKILL_KEYS = [
DISCOVER_CARDS_SKILL_KEY,
UPDATE_CARDS_SKILL_KEY,
] as const;
export const BRIEFS_MANAGED_ROUTINE_KEYS = [
DISCOVER_CARDS_ROUTINE_KEY,
UPDATE_CARDS_ROUTINE_KEY,
MANUAL_REFRESH_ROUTINE_KEY,
] as const;
function canonicalSkillKey(skillKey: string) {
return `plugin/paperclipai-plugin-briefs/${skillKey}`;
}
export const BRIEFS_MANAGED_SKILL_CANONICAL_KEYS = BRIEFS_MANAGED_SKILL_KEYS.map(canonicalSkillKey);
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
@@ -16,25 +49,253 @@ const manifest: PaperclipPluginManifestV1 = {
"database.namespace.write",
"companies.read",
"projects.read",
"projects.managed",
"goals.read",
"agents.read",
"agents.managed",
"issues.read",
"issue.subtree.read",
"issue.relations.read",
"issue.comments.read",
"issue.documents.read",
"issues.orchestration.read",
"skills.managed",
"routines.managed",
"agent.tools.register",
"activity.log.write",
"plugin.state.read",
"plugin.state.write"
"plugin.state.write",
"ui.sidebar.register",
"ui.page.register"
],
entrypoints: {
worker: "./dist/worker.js"
worker: "./dist/worker.js",
ui: "./dist/ui"
},
database: {
namespaceSlug: "briefs",
migrationsDir: "migrations",
coreReadTables: ["companies", "issues"]
},
agents: [
{
agentKey: BRIEFING_ANALYST_AGENT_KEY,
displayName: "Briefing Analyst",
role: "analyst",
title: "Briefing Analyst",
icon: "newspaper",
capabilities: "Maintains source-linked Briefing cards, refreshes deterministic card state, and uses cheap-model wording only when budget allows.",
adapterType: "codex_local",
adapterPreference: ["codex_local", "claude_local", "gemini_local", "opencode_local", "cursor", "pi_local"],
adapterConfig: {
paperclipSkillSync: {
desiredSkills: BRIEFS_MANAGED_SKILL_CANONICAL_KEYS
}
},
runtimeConfig: {
modelProfiles: {
cheap: {
purpose: "one-paragraph Briefing card summaries from compact source-linked inputs"
}
}
},
permissions: {
pluginTools: [PLUGIN_ID]
},
status: "paused",
budgetMonthlyCents: 0,
instructions: {
entryFile: "AGENTS.md",
content: BRIEFING_ANALYST_INSTRUCTIONS,
assetPath: "agents/briefing-analyst"
}
}
],
projects: [
{
projectKey: BRIEFS_PROJECT_KEY,
displayName: "Briefs",
description: "Plugin-managed operation area for Briefing card discovery, update, and manual refresh routine issues.",
status: "in_progress",
color: "#0f766e"
}
],
skills: [
{
skillKey: DISCOVER_CARDS_SKILL_KEY,
displayName: "Briefs Discover Cards",
slug: DISCOVER_CARDS_SKILL_KEY,
description: "Discover user-relevant Paperclip issue trees and refresh deterministic Briefing cards.",
markdown: DISCOVER_CARDS_SKILL
},
{
skillKey: UPDATE_CARDS_SKILL_KEY,
displayName: "Briefs Update Cards",
slug: UPDATE_CARDS_SKILL_KEY,
description: "Update existing Briefing cards with budget-aware summary fallback.",
markdown: UPDATE_CARDS_SKILL
}
],
routines: [
{
routineKey: DISCOVER_CARDS_ROUTINE_KEY,
title: "Discover Briefing cards for {{userId}}",
description: DISCOVERY_ROUTINE_DESCRIPTION,
status: "paused",
priority: "low",
assigneeRef: { resourceKind: "agent", resourceKey: BRIEFING_ANALYST_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: BRIEFS_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "userId", label: "User ID", type: "text", defaultValue: null, required: true, options: [] }
],
triggers: [
{
kind: "schedule",
label: "Every 6 hours",
enabled: false,
cronExpression: "0 */6 * * *",
timezone: "UTC",
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:briefs-discover-cards",
billingCode: "plugin-briefs:discovery"
}
},
{
routineKey: UPDATE_CARDS_ROUTINE_KEY,
title: "Update Briefing cards for {{userId}}",
description: UPDATE_ROUTINE_DESCRIPTION,
status: "paused",
priority: "low",
assigneeRef: { resourceKind: "agent", resourceKey: BRIEFING_ANALYST_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: BRIEFS_PROJECT_KEY },
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "userId", label: "User ID", type: "text", defaultValue: null, required: true, options: [] }
],
triggers: [
{
kind: "schedule",
label: "Hourly",
enabled: false,
cronExpression: "0 * * * *",
timezone: "UTC",
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:briefs-update-cards",
billingCode: "plugin-briefs:update"
}
},
{
routineKey: MANUAL_REFRESH_ROUTINE_KEY,
title: "Refresh Briefing card {{rootIssueId}}",
description: MANUAL_REFRESH_ROUTINE_DESCRIPTION,
status: "paused",
priority: "medium",
assigneeRef: { resourceKind: "agent", resourceKey: BRIEFING_ANALYST_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: BRIEFS_PROJECT_KEY },
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "userId", label: "User ID", type: "text", defaultValue: null, required: true, options: [] },
{ name: "rootIssueId", label: "Root issue ID", type: "text", defaultValue: null, required: true, options: [] }
],
triggers: [
{
kind: "api",
label: "Manual refresh",
enabled: true,
cronExpression: null,
timezone: null,
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:briefs-manual-refresh",
billingCode: "plugin-briefs:manual-refresh"
}
}
],
tools: [
{
name: "briefs_list_cards",
displayName: "List Briefing Cards",
description: "List current Briefing cards for a company/user pair.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
userId: { type: "string" },
includeHidden: { type: "boolean" },
limit: { type: "number" }
},
required: ["companyId", "userId"]
}
},
{
name: "briefs_save_card",
displayName: "Save Briefing Card",
description: "Save a deterministic or generated Briefing card from a source bundle.",
parametersSchema: {
type: "object",
properties: {
bundle: { type: "object" },
options: { type: "object" }
},
required: ["bundle"]
}
},
{
name: "briefs_refresh_issue_tree",
displayName: "Refresh Briefing Issue Tree",
description: "Build and save a Briefing card for one Paperclip issue tree using deterministic fallback state.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
userId: { type: "string" },
rootIssueId: { type: "string" },
summary: { type: "string" },
summaryModel: { type: "string" },
summaryTokensIn: { type: "number" },
summaryTokensOut: { type: "number" },
summaryFailureReason: { type: "string" },
budgetCapped: { type: "boolean" }
},
required: ["companyId", "userId", "rootIssueId"]
}
}
],
ui: {
slots: [
{
type: "sidebar",
id: BRIEFS_SIDEBAR_SLOT_ID,
displayName: "Briefing",
exportName: "SidebarLink",
order: 15
},
{
type: "page",
id: BRIEFS_PAGE_SLOT_ID,
displayName: "Briefing",
exportName: "BriefingPage",
routePath: BRIEFS_ROUTE_PATH
}
]
}
};
@@ -300,6 +300,46 @@ export function createBriefsStore(db: PluginDatabaseClient) {
);
},
async loadPreferences(input: { companyId: string; userId: string }): Promise<BriefPreferences> {
const rows = await db.query<{
cadence: BriefPreferences["cadence"];
retention_days: number;
done_retention_hours: number;
stale_after_days: number;
max_unpinned_cards: number;
scope: BriefPreferences["scope"];
}>(
`SELECT cadence, retention_days, done_retention_hours, stale_after_days, max_unpinned_cards, scope
FROM ${preferencesTable}
WHERE company_id = $1 AND user_id = $2
LIMIT 1`,
[input.companyId, input.userId],
);
const row = rows[0];
if (!row) {
return {
companyId: input.companyId,
userId: input.userId,
cadence: "hourly",
retentionDays: 7,
doneRetentionHours: 72,
staleAfterDays: 7,
maxUnpinnedCards: 30,
scope: "user",
};
}
return {
companyId: input.companyId,
userId: input.userId,
cadence: row.cadence,
retentionDays: row.retention_days,
doneRetentionHours: row.done_retention_hours,
staleAfterDays: row.stale_after_days,
maxUnpinnedCards: row.max_unpinned_cards,
scope: row.scope,
};
},
async upsertPreferences(preferences: BriefPreferences): Promise<void> {
await db.execute(
`INSERT INTO ${preferencesTable} (
@@ -0,0 +1,75 @@
export const BRIEFING_ANALYST_INSTRUCTIONS = `# Briefing Analyst
You maintain Paperclip Briefing cards for board users.
Core rules:
- Use Briefs plugin tools as the source of truth for card writes.
- Do not invent tasks, owners, blockers, reviewer state, or status.
- Prefer deterministic source state over prose. If the summary model is unavailable, budget-capped, or unsafe, keep the deterministic fallback card visible.
- Summaries must be one paragraph, at most 260 characters, and grounded in the source rows returned by the Briefs tools.
- Task rows are capped by the plugin at three; do not try to bypass that cap.
- Use the cheapest available model/profile for summary wording. Store the model name and token/cost metadata when a generated summary is used.
- For manual refreshes, leave a concise issue comment describing which root issue/user was refreshed and whether the card used generated prose or fallback state.
`;
export const DISCOVER_CARDS_SKILL = `---
name: "Briefs Discover Cards"
description: "Discover user-relevant Paperclip issue trees and refresh deterministic Briefing cards without inventing status."
---
# Briefs Discover Cards
Use this skill when a Briefs discovery routine asks you to find or refresh cards.
1. Read the routine issue carefully for \`companyId\`, \`userId\`, and any explicit source issue identifiers.
2. Use Paperclip issue context and Briefs tools to refresh cards only for source issue trees that are relevant to the named user.
3. Reuse stable cards by grouping description and slug; do not create a new card for the same root work area under a slightly different title.
4. Keep summaries deterministic unless the routine explicitly asks for cheap-model wording and budget allows it.
5. Never invent tasks, owners, blockers, waiting states, or status. If source state is ambiguous, keep the fallback summary.
6. Close the routine issue with counts of refreshed cards, skipped trees, and any follow-up needed.
`;
export const UPDATE_CARDS_SKILL = `---
name: "Briefs Update Cards"
description: "Update existing Briefing cards from recent Paperclip source activity with budget-aware summary fallback."
---
# Briefs Update Cards
Use this skill when a Briefs update or manual-refresh routine asks you to update cards.
1. Resolve the named \`companyId\`, \`userId\`, and \`rootIssueId\` from the routine issue or trigger payload.
2. Call the Briefs refresh tool for each root issue tree that needs an update.
3. If you write generated prose, use the cheapest available model/profile and keep the summary to one paragraph.
4. Pass model metadata when available: model name, input tokens, output tokens, and generated run id.
5. If model generation fails, budget is capped, or the source inputs are too noisy, save the deterministic fallback card instead.
6. Report the refreshed card slug, state, summary status, and source issue link in the routine issue comment.
`;
export const DISCOVERY_ROUTINE_DESCRIPTION = `Discover user-relevant Briefing cards.
Run procedure:
1. Read the routine variables \`userId\` and optional source hints from the issue body or trigger payload.
2. Inspect recently meaningful Paperclip issue trees for that user. Prefer explicit issue roots if provided.
3. Refresh cards through Briefs tools so stable slug/grouping identity is reused.
4. Keep deterministic fallback state visible when model summary generation is unavailable or budget-capped.
5. Close the routine issue with refreshed/skipped counts and any source trees that need manual attention.`;
export const UPDATE_ROUTINE_DESCRIPTION = `Update existing Briefing cards from recent source activity.
Run procedure:
1. Read \`userId\` and the update window from the routine issue or trigger payload.
2. Refresh cards whose source issue trees changed inside the overlap window.
3. Use deterministic state for blockers, waiting states, live work, stale state, and task rows.
4. Use cheap-model wording only when budget allows it, and store model/token metadata on the snapshot.
5. Close the routine issue with updated card slugs, fallback reasons, and any failures.`;
export const MANUAL_REFRESH_ROUTINE_DESCRIPTION = `Manually refresh a Briefing card for one issue tree.
Run procedure:
1. Read required variables \`userId\` and \`rootIssueId\`.
2. Refresh exactly that issue tree through the Briefs refresh tool.
3. Preserve the existing card through stable grouping description when the tree already has a card.
4. Keep previous deterministic cards visible if generation fails; record fallback reason instead of hiding the card.
5. Close the routine issue with the card slug, state, summary status, and source link.`;
@@ -0,0 +1,958 @@
import {
useHostNavigation,
usePluginAction,
usePluginData,
usePluginToast,
type PluginPageProps,
type PluginSidebarProps,
} from "@paperclipai/plugin-sdk/ui";
import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import type {
BriefCard,
BriefCardSource,
BriefCardState,
BriefPreferences,
BriefSummaryStatus,
BriefTaskRow,
} from "../contracts.js";
import {
countAttention,
formatRelative,
groupCardsIntoSections,
rightTagForRow,
shouldDimCard,
stateBadgeLabel,
stateTone,
summaryFallbackLabel,
truncateTitle,
type BriefSection,
type BriefSectionKey,
} from "./view-model.js";
const fontStack = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
const tokens = {
bg: "var(--background, oklch(0.145 0 0))",
card: "var(--card, oklch(0.205 0 0))",
border: "var(--border, oklch(0.269 0 0))",
fg: "var(--foreground, oklch(0.985 0 0))",
muted: "var(--muted-foreground, oklch(0.708 0 0))",
accent: "var(--accent, oklch(0.269 0 0))",
primary: "var(--primary, oklch(0.985 0 0))",
};
const toneColors: Record<"red" | "warning" | "violet" | "cyan" | "green" | "muted", { accent: string; badgeBg: string; badgeFg: string }> = {
red: { accent: "oklch(0.62 0.21 25)", badgeBg: "oklch(0.27 0.09 25)", badgeFg: "oklch(0.85 0.14 25)" },
warning: { accent: "oklch(0.72 0.15 70)", badgeBg: "oklch(0.27 0.07 70)", badgeFg: "oklch(0.85 0.11 70)" },
violet: { accent: "oklch(0.62 0.18 305)", badgeBg: "oklch(0.27 0.07 305)", badgeFg: "oklch(0.84 0.11 305)" },
cyan: { accent: "oklch(0.7 0.13 200)", badgeBg: "oklch(0.27 0.06 200)", badgeFg: "oklch(0.84 0.11 200)" },
green: { accent: "oklch(0.65 0.16 145)", badgeBg: "oklch(0.27 0.06 145)", badgeFg: "oklch(0.84 0.1 145)" },
muted: { accent: "oklch(0.5 0 0)", badgeBg: "oklch(0.25 0 0)", badgeFg: "oklch(0.75 0 0)" },
};
const sourceKindIcon: Record<BriefCardSource["sourceKind"], string> = {
issue: "■",
issue_tree: "◰",
comment: "❝",
run: "▷",
document: "▤",
work_product: "✱",
interaction: "?",
activity_event: "•",
approval: "✓",
};
type PageData = {
cards: BriefCard[];
preferences: BriefPreferences;
fetchedAt: string;
};
const SECTION_TABS: Array<{ key: BriefSectionKey | "all"; label: string }> = [
{ key: "all", label: "All" },
{ key: "attention", label: "Needs you" },
{ key: "live", label: "Live" },
{ key: "settled", label: "Done" },
];
export function SidebarLink({ context }: PluginSidebarProps) {
const nav = useHostNavigation();
const params = useMemo(() => ({ companyId: context.companyId ?? "", userId: context.userId ?? "" }), [context.companyId, context.userId]);
const enabled = Boolean(params.companyId && params.userId);
const { data } = usePluginData<{ cards: BriefCard[] }>("cards", enabled ? params : undefined);
const cards = data?.cards ?? [];
const count = useMemo(() => countAttention(cards), [cards]);
const link = nav.linkProps("/briefs");
return (
<a
{...link}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
borderRadius: 6,
color: tokens.fg,
textDecoration: "none",
fontSize: 13,
fontFamily: fontStack,
}}
>
<span aria-hidden style={{ display: "inline-block", width: 14, height: 14, borderRadius: 3, background: toneColors.cyan.accent }} />
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>Briefing</span>
{count > 0 ? <AttentionBadge count={count} /> : null}
</a>
);
}
function AttentionBadge({ count }: { count: number }) {
return (
<span
aria-label={`${count} brief${count === 1 ? "" : "s"} need your attention`}
style={{
minWidth: 18,
padding: "0 6px",
height: 18,
borderRadius: 9,
background: toneColors.warning.badgeBg,
color: toneColors.warning.badgeFg,
fontSize: 11,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{count > 99 ? "99+" : count}
</span>
);
}
export function BriefingPage({ context }: PluginPageProps) {
const params = useMemo(() => ({ companyId: context.companyId ?? "", userId: context.userId ?? "" }), [context.companyId, context.userId]);
const enabled = Boolean(params.companyId && params.userId);
const { data, loading, error, refresh } = usePluginData<PageData>("page", enabled ? params : undefined);
if (!enabled) {
return (
<PageShell>
<EmptyState
title="Sign in to see your briefing"
body="The Briefing page is scoped to the signed-in user and company."
/>
</PageShell>
);
}
return (
<PageShell
meta={data ? <PageMeta data={data} /> : null}
action={<RefreshButton onClick={refresh} loading={loading} />}
preferences={data ? <PreferencesControl preferences={data.preferences} onChanged={refresh} /> : null}
>
{error ? (
<ErrorPanel message={error.message} onRetry={refresh} />
) : loading && !data ? (
<LoadingState />
) : data && data.cards.length === 0 ? (
<EmptyState
title="No briefs yet"
body="Cards appear here once the Briefing Analyst picks up recent work. Pinned cards never expire."
/>
) : data ? (
<PageBody data={data} onChanged={refresh} />
) : null}
</PageShell>
);
}
function PageShell({ children, meta, action, preferences }: {
children: ReactNode;
meta?: ReactNode;
action?: ReactNode;
preferences?: ReactNode;
}) {
return (
<div style={{ fontFamily: fontStack, color: tokens.fg, padding: "20px clamp(12px, 4vw, 32px)", maxWidth: 1280, margin: "0 auto", minHeight: "100vh" }}>
<header style={{ display: "flex", flexWrap: "wrap", alignItems: "baseline", gap: 12, marginBottom: 6 }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600, letterSpacing: -0.2 }}>Briefing</h1>
<div style={{ flex: 1, minWidth: 0, fontSize: 12, color: tokens.muted, overflow: "hidden", textOverflow: "ellipsis" }}>{meta}</div>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{preferences}
{action}
</div>
</header>
<p style={{ margin: 0, marginBottom: 18, fontSize: 13, color: tokens.muted }}>
Durable cards for areas of work that involve you. Pin the ones you always want to see.
</p>
{children}
</div>
);
}
function PageMeta({ data }: { data: PageData }) {
const active = data.cards.filter((c) => !c.hidden);
const pinned = active.filter((c) => c.pinned).length;
return (
<span>
{active.length} active · {pinned} pinned · refreshed {formatRelative(data.fetchedAt)}
</span>
);
}
function RefreshButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button
type="button"
onClick={onClick}
disabled={loading}
style={{
padding: "6px 10px",
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: tokens.card,
color: tokens.fg,
fontSize: 12,
cursor: loading ? "wait" : "pointer",
fontFamily: fontStack,
}}
>
{loading ? "Refreshing…" : "Refresh"}
</button>
);
}
function PreferencesControl({ preferences, onChanged }: { preferences: BriefPreferences; onChanged: () => void }) {
const [open, setOpen] = useState(false);
const update = usePluginAction("update-preferences");
const toast = usePluginToast();
const [local, setLocal] = useState(preferences);
const save = useCallback(async (next: BriefPreferences) => {
try {
await update(next);
onChanged();
toast({ tone: "success", title: "Preferences saved" });
} catch (err) {
const message = err instanceof Error ? err.message : "Could not save preferences";
toast({ tone: "error", title: "Save failed", body: message });
}
}, [update, toast, onChanged]);
return (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{
padding: "6px 10px",
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: tokens.card,
color: tokens.fg,
fontSize: 12,
cursor: "pointer",
fontFamily: fontStack,
}}
>
Preferences
</button>
{open ? (
<div
role="dialog"
aria-label="Briefing preferences"
style={{
position: "absolute",
right: 0,
top: "calc(100% + 6px)",
zIndex: 30,
background: tokens.card,
border: `1px solid ${tokens.border}`,
borderRadius: 8,
padding: 12,
width: 280,
boxShadow: "0 12px 32px rgba(0,0,0,0.4)",
}}
>
<PreferenceRow
label="Cadence"
value={local.cadence}
options={[{ value: "manual", label: "Manual only" }, { value: "hourly", label: "Hourly" }, { value: "daily", label: "Daily" }]}
onChange={(value) => setLocal((p) => ({ ...p, cadence: value as BriefPreferences["cadence"] }))}
/>
<PreferenceNumber
label="Retention (days)"
value={local.retentionDays}
min={1}
max={90}
onChange={(value) => setLocal((p) => ({ ...p, retentionDays: value }))}
/>
<PreferenceNumber
label="Done retention (hours)"
value={local.doneRetentionHours}
min={1}
max={168}
onChange={(value) => setLocal((p) => ({ ...p, doneRetentionHours: value }))}
/>
<PreferenceNumber
label="Stale after (days)"
value={local.staleAfterDays}
min={1}
max={30}
onChange={(value) => setLocal((p) => ({ ...p, staleAfterDays: value }))}
/>
<PreferenceNumber
label="Max unpinned cards"
value={local.maxUnpinnedCards}
min={1}
max={100}
onChange={(value) => setLocal((p) => ({ ...p, maxUnpinnedCards: value }))}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 8 }}>
<button
type="button"
onClick={() => { setLocal(preferences); setOpen(false); }}
style={{ padding: "5px 10px", borderRadius: 6, border: `1px solid ${tokens.border}`, background: "transparent", color: tokens.muted, fontSize: 12, cursor: "pointer" }}
>
Cancel
</button>
<button
type="button"
onClick={() => { void save(local).then(() => setOpen(false)); }}
style={{ padding: "5px 10px", borderRadius: 6, border: "1px solid transparent", background: tokens.primary, color: "var(--primary-foreground, oklch(0.205 0 0))", fontSize: 12, fontWeight: 600, cursor: "pointer" }}
>
Save
</button>
</div>
</div>
) : null}
</div>
);
}
function PreferenceRow({ label, value, options, onChange }: {
label: string;
value: string;
options: Array<{ value: string; label: string }>;
onChange: (next: string) => void;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, marginBottom: 8 }}>
<span style={{ color: tokens.muted }}>{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ background: tokens.bg, border: `1px solid ${tokens.border}`, color: tokens.fg, borderRadius: 6, padding: "5px 8px", fontSize: 12 }}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
);
}
function PreferenceNumber({ label, value, min, max, onChange }: {
label: string;
value: number;
min: number;
max: number;
onChange: (next: number) => void;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 12, marginBottom: 8 }}>
<span style={{ color: tokens.muted }}>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
onChange={(event) => {
const next = Number.parseInt(event.target.value, 10);
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
}}
style={{ background: tokens.bg, border: `1px solid ${tokens.border}`, color: tokens.fg, borderRadius: 6, padding: "5px 8px", fontSize: 12 }}
/>
</label>
);
}
function PageBody({ data, onChanged }: { data: PageData; onChanged: () => void }) {
const sections = useMemo(() => groupCardsIntoSections(data.cards), [data.cards]);
const [activeTab, setActiveTab] = useState<BriefSectionKey | "all">(() => (sections.find((s) => s.key === "attention" && s.cards.length > 0) ? "attention" : "all"));
return (
<>
<MobileTabs sections={sections} active={activeTab} onChange={setActiveTab} />
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{sections.map((section) => (
<SectionView
key={section.key}
section={section}
visibleOnMobile={activeTab === "all" || activeTab === section.key}
onChanged={onChanged}
/>
))}
</div>
<Legend />
</>
);
}
function MobileTabs({ sections, active, onChange }: {
sections: BriefSection[];
active: BriefSectionKey | "all";
onChange: (next: BriefSectionKey | "all") => void;
}) {
const counts = useMemo(() => {
const map: Record<string, number> = { all: 0 };
for (const section of sections) {
map[section.key] = section.cards.length;
map.all += section.cards.length;
}
return map;
}, [sections]);
return (
<nav
aria-label="Briefing sections"
data-briefs-mobile-tabs
style={{
display: "none",
position: "sticky",
top: 0,
zIndex: 5,
background: tokens.bg,
gap: 6,
padding: "8px 0",
margin: "0 0 8px",
borderBottom: `1px solid ${tokens.border}`,
}}
>
{SECTION_TABS.map((tab) => {
const count = counts[tab.key] ?? 0;
const isActive = tab.key === active;
return (
<button
key={tab.key}
type="button"
onClick={() => onChange(tab.key)}
aria-pressed={isActive}
style={{
flex: 1,
padding: "8px 6px",
borderRadius: 6,
border: `1px solid ${isActive ? tokens.fg : tokens.border}`,
background: isActive ? "var(--secondary, oklch(0.269 0 0))" : "transparent",
color: isActive ? tokens.fg : tokens.muted,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
<span>{tab.label}</span>
{count > 0 ? <span style={{ marginLeft: 4, color: tokens.muted, fontWeight: 400 }}>{count}</span> : null}
</button>
);
})}
</nav>
);
}
function SectionView({ section, visibleOnMobile, onChanged }: { section: BriefSection; visibleOnMobile: boolean; onChanged: () => void }) {
if (section.cards.length === 0) return null;
return (
<section
data-briefs-section={section.key}
data-mobile-hidden={visibleOnMobile ? undefined : "true"}
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<header style={{ display: "flex", alignItems: "center", gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 12, letterSpacing: 0.6, color: tokens.muted, textTransform: "uppercase", fontWeight: 600 }}>
{section.label}
</h2>
<span style={{ fontSize: 12, color: tokens.muted }}>{section.cards.length}</span>
<span style={{ flex: 1, borderBottom: `1px dashed ${tokens.border}` }} />
</header>
<div
data-briefs-grid
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 360px), 1fr))",
gap: 12,
}}
>
{section.cards.map((card) => (
<BriefCardView key={card.id} card={card} onChanged={onChanged} />
))}
</div>
</section>
);
}
export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged: () => void }) {
const nav = useHostNavigation();
const pin = usePluginAction("pin-card");
const toast = usePluginToast();
const tone = toneColors[stateTone[card.state]];
const dim = shouldDimCard(card);
const taskRows = card.snapshot.taskRows;
const openHref = primaryOpenHref(card);
const togglePin = useCallback(async () => {
try {
await pin({ companyId: card.companyId, userId: card.userId, cardId: card.id, pinned: !card.pinned });
onChanged();
} catch (err) {
const message = err instanceof Error ? err.message : "Could not update pin";
toast({ tone: "error", title: "Pin failed", body: message });
}
}, [pin, card, onChanged, toast]);
return (
<article
data-briefs-card
data-state={card.state}
data-summary-status={card.snapshot.summaryStatus}
data-pinned={card.pinned ? "true" : "false"}
style={{
position: "relative",
background: tokens.card,
border: `1px solid ${tokens.border}`,
borderRadius: 10,
padding: "12px 14px 12px 18px",
display: "flex",
flexDirection: "column",
gap: 8,
minWidth: 0,
opacity: dim ? 0.78 : 1,
}}
>
<span
aria-hidden
style={{
position: "absolute",
top: 8,
bottom: 8,
left: 6,
width: 3,
borderRadius: 2,
background: tone.accent,
}}
/>
<header style={{ display: "flex", alignItems: "flex-start", gap: 8, minWidth: 0 }}>
<PinButton pinned={card.pinned} onToggle={togglePin} />
<h3 style={{ margin: 0, flex: 1, fontSize: 14, fontWeight: 600, lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{truncateTitle(card.title, 70)}
</h3>
<StateBadge state={card.state} />
</header>
<MetaRow card={card} />
<SummarySlot card={card} onRetry={onChanged} />
<SourceRows rows={taskRows} />
<Footer card={card} openHref={openHref} nav={nav} />
</article>
);
}
function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) {
return (
<button
type="button"
onClick={onToggle}
aria-pressed={pinned}
aria-label={pinned ? "Unpin card" : "Pin card"}
style={{
width: 22,
height: 22,
flexShrink: 0,
borderRadius: 6,
border: `1px solid ${pinned ? "oklch(0.8 0.13 80)" : tokens.border}`,
background: pinned ? "oklch(0.4 0.13 80)" : "transparent",
color: pinned ? "oklch(0.95 0.1 80)" : tokens.muted,
fontSize: 12,
lineHeight: 1,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{pinned ? "★" : "☆"}
</button>
);
}
function StateBadge({ state }: { state: BriefCardState }) {
const tone = toneColors[stateTone[state]];
return (
<span
data-briefs-state-badge={state}
style={{
flexShrink: 0,
padding: "2px 8px",
fontSize: 11,
fontWeight: 600,
borderRadius: 999,
background: tone.badgeBg,
color: tone.badgeFg,
whiteSpace: "nowrap",
}}
>
{stateBadgeLabel[state]}
</span>
);
}
function MetaRow({ card }: { card: BriefCard }) {
const identifiers = collectIdentifiers(card);
const issueCount = uniqueIssueCount(card);
const agentCount = uniqueAgentCount(card);
const stamp = formatRelative(card.lastMeaningfulEventAt);
return (
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 8, fontSize: 11, color: tokens.muted, minWidth: 0 }}>
{identifiers.length > 0 ? (
<span style={{ fontFamily: "ui-monospace, SFMono-Regular, monospace", color: tokens.fg }}>{identifiers[0]}</span>
) : null}
{issueCount > 0 ? <span>{issueCount} {issueCount === 1 ? "issue" : "issues"}</span> : null}
{agentCount > 0 ? <span>{agentCount} {agentCount === 1 ? "agent" : "agents"}</span> : null}
{stamp ? <span>· {stamp}</span> : null}
</div>
);
}
function SummarySlot({ card, onRetry }: { card: BriefCard; onRetry: () => void }) {
const status: BriefSummaryStatus = card.snapshot.summaryStatus;
if (status === "ok") {
return (
<p
data-briefs-summary
style={{ margin: 0, fontSize: 13, lineHeight: 1.45, color: tokens.fg, overflow: "hidden", display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }}
>
{card.snapshot.summaryParagraph || derivedSynopsis(card)}
</p>
);
}
if (status === "pending") {
return (
<div data-briefs-summary-pending style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ height: 10, background: "var(--secondary, oklch(0.27 0 0))", borderRadius: 4 }} />
<span style={{ height: 10, width: "70%", background: "var(--secondary, oklch(0.27 0 0))", borderRadius: 4 }} />
</div>
);
}
return <FallbackPanel card={card} onRetry={onRetry} />;
}
function FallbackPanel({ card, onRetry }: { card: BriefCard; onRetry: () => void }) {
const reason = card.snapshot.summaryFailureReason ?? "model_error";
const reasonLabel = formatFailureReason(reason);
return (
<div
data-briefs-summary-fallback
style={{
display: "flex",
gap: 8,
alignItems: "flex-start",
padding: "8px 10px",
borderRadius: 6,
borderLeft: `3px solid ${toneColors.red.accent}`,
background: "oklch(0.21 0.03 25 / 50%)",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: toneColors.red.badgeFg }}>{summaryFallbackLabel}</div>
<div style={{ fontSize: 12, color: tokens.fg, marginTop: 2 }}>{derivedSynopsis(card)}</div>
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 4 }}>{reasonLabel}</div>
</div>
<button
type="button"
onClick={onRetry}
style={{
padding: "4px 8px",
fontSize: 11,
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: tokens.card,
color: tokens.fg,
cursor: "pointer",
}}
>
Refresh
</button>
</div>
);
}
function derivedSynopsis(card: BriefCard): string {
const blocked = card.snapshot.taskRows.find((row) => row.rightTag.toLowerCase().includes("blocked"));
if (blocked) return `${blocked.identifier ?? "Item"} is blocked: ${blocked.titleLine}.`;
const waiting = card.snapshot.taskRows.find((row) => row.rightTag.toLowerCase().includes("ask"));
if (waiting) return `${waiting.identifier ?? "Item"} is waiting on you.`;
if (card.snapshot.taskRows[0]) {
return `${card.snapshot.taskRows[0].identifier ?? "Recent"}: ${card.snapshot.taskRows[0].titleLine}.`;
}
return "Recent activity recorded.";
}
function formatFailureReason(reason: string): string {
switch (reason) {
case "model_error":
return "Generator returned an error";
case "truncation_failed":
return "Source data could not be truncated cleanly";
case "budget_capped":
return "Summary skipped to stay under budget";
case "safety_block":
return "Safety guard blocked the summary";
default:
return reason;
}
}
function SourceRows({ rows }: { rows: BriefTaskRow[] }) {
if (rows.length === 0) return null;
return (
<ul data-briefs-source-rows style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column" }}>
{rows.map((row, index) => (
<li
key={`${row.kind}-${row.sourceId}`}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 0",
borderTop: index === 0 ? "none" : `1px dashed ${tokens.border}`,
fontSize: 12,
minWidth: 0,
}}
>
<span aria-hidden style={{ color: tokens.muted, width: 12, textAlign: "center" }}>{sourceKindIcon[row.kind] ?? "•"}</span>
{row.identifier ? (
<span style={{ fontFamily: "ui-monospace, SFMono-Regular, monospace", color: tokens.fg, fontSize: 11 }}>{row.identifier}</span>
) : null}
<SourceLink row={row} />
<RightTag row={row} />
</li>
))}
</ul>
);
}
function SourceLink({ row }: { row: BriefTaskRow }) {
const nav = useHostNavigation();
const link = nav.linkProps(row.linkPath);
return (
<a
{...link}
title={row.titleLine}
style={{ flex: 1, color: tokens.fg, textDecoration: "none", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}
>
{row.titleLine}
</a>
);
}
function RightTag({ row }: { row: BriefTaskRow }) {
const lower = row.rightTag.toLowerCase();
let tone: keyof typeof toneColors = "muted";
if (lower.includes("block")) tone = "red";
else if (lower.includes("error") || lower.includes("fail")) tone = "red";
else if (lower.includes("ask") || lower.includes("user")) tone = "warning";
else if (lower.includes("review") || lower.includes("approval")) tone = "violet";
else if (lower.includes("run") || lower.includes("in_progress") || lower.includes("live")) tone = "cyan";
else if (lower.includes("done") || lower.includes("merged")) tone = "green";
const swatch = toneColors[tone];
return (
<span
data-briefs-row-tag={tone}
style={{
flexShrink: 0,
fontSize: 10,
padding: "1px 6px",
borderRadius: 999,
background: swatch.badgeBg,
color: swatch.badgeFg,
whiteSpace: "nowrap",
}}
>
{rightTagForRow(row)}
{row.isIntraTreeBlocked ? <span aria-label="intra-tree blocker" style={{ marginLeft: 4, opacity: 0.7 }}>↪</span> : null}
</span>
);
}
function Footer({ card, openHref, nav }: { card: BriefCard; openHref: string | null; nav: ReturnType<typeof useHostNavigation> }) {
const stamp = formatRelative(card.lastMeaningfulEventAt);
return (
<footer style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11, color: tokens.muted, paddingTop: 4, marginTop: "auto" }}>
{card.moreSourceCount > 0 ? <span>+{card.moreSourceCount} more in tree</span> : <span aria-hidden />}
<span style={{ flex: 1 }} />
{stamp ? <span>{stamp}</span> : null}
{openHref ? (
<a
{...nav.linkProps(openHref)}
style={{ color: tokens.fg, textDecoration: "none", fontSize: 11 }}
>
Open tree →
</a>
) : null}
</footer>
);
}
function primaryOpenHref(card: BriefCard): string | null {
const candidate = card.sources[0] ?? card.snapshot.taskRows[0];
return candidate?.linkPath ?? null;
}
function collectIdentifiers(card: BriefCard): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const source of [...card.snapshot.taskRows, ...card.sources]) {
if (source.identifier && !seen.has(source.identifier)) {
seen.add(source.identifier);
out.push(source.identifier);
}
}
return out;
}
function uniqueIssueCount(card: BriefCard): number {
const seen = new Set<string>();
for (const source of card.sources) {
if (source.sourceKind === "issue" && source.issueId) seen.add(source.issueId);
}
return seen.size;
}
function uniqueAgentCount(card: BriefCard): number {
const seen = new Set<string>();
for (const source of card.sources) {
const metadata = source.metadata as Record<string, unknown> | undefined;
const value = metadata && typeof metadata.assigneeAgentId === "string" ? metadata.assigneeAgentId : null;
if (value) seen.add(value);
}
return seen.size;
}
function Legend() {
const items: Array<{ state: BriefCardState }> = [
{ state: "error" },
{ state: "blocked" },
{ state: "waiting-user" },
{ state: "waiting-reviewer" },
{ state: "live" },
{ state: "done" },
{ state: "stale" },
];
return (
<div data-briefs-legend style={{ marginTop: 24, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, fontSize: 11, color: tokens.muted, paddingTop: 12, borderTop: `1px solid ${tokens.border}` }}>
<span style={{ textTransform: "uppercase", letterSpacing: 0.6 }}>Legend</span>
{items.map((item) => {
const tone = toneColors[stateTone[item.state]];
return (
<span key={item.state} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: tone.accent }} aria-hidden />
<span>{stateBadgeLabel[item.state]}</span>
</span>
);
})}
</div>
);
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div
data-briefs-empty
style={{
padding: "40px 24px",
border: `1px dashed ${tokens.border}`,
borderRadius: 10,
textAlign: "center",
color: tokens.muted,
}}
>
<div style={{ fontSize: 15, color: tokens.fg, fontWeight: 600, marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 13 }}>{body}</div>
</div>
);
}
function LoadingState() {
return (
<div data-briefs-loading style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 360px), 1fr))", gap: 12 }}>
{[0, 1, 2, 3].map((index) => (
<div key={index} style={{ height: 160, borderRadius: 10, background: tokens.card, border: `1px solid ${tokens.border}`, padding: 14 }} aria-hidden>
<div style={{ height: 10, width: "60%", background: "var(--secondary, oklch(0.27 0 0))", borderRadius: 4, marginBottom: 8 }} />
<div style={{ height: 8, width: "90%", background: "var(--secondary, oklch(0.27 0 0))", borderRadius: 4, marginBottom: 4 }} />
<div style={{ height: 8, width: "70%", background: "var(--secondary, oklch(0.27 0 0))", borderRadius: 4 }} />
</div>
))}
</div>
);
}
function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div
data-briefs-error
style={{
padding: 16,
border: `1px solid ${toneColors.red.accent}`,
borderRadius: 10,
background: "oklch(0.21 0.04 25 / 60%)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: toneColors.red.badgeFg }}>Could not load briefing</div>
<div style={{ fontSize: 12, color: tokens.muted }}>{message}</div>
<div>
<button
type="button"
onClick={onRetry}
style={{
padding: "6px 10px",
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: tokens.card,
color: tokens.fg,
cursor: "pointer",
fontSize: 12,
}}
>
Try again
</button>
</div>
</div>
);
}
// Inject responsive CSS once per page mount. Inline style attributes cannot
// express media queries, so the mobile rules are added through a stylesheet.
if (typeof document !== "undefined" && !document.getElementById("briefs-plugin-styles")) {
const style = document.createElement("style");
style.id = "briefs-plugin-styles";
style.textContent = `
@media (max-width: 700px) {
[data-briefs-mobile-tabs] { display: flex !important; }
[data-briefs-section] > header { display: none; }
[data-briefs-section][data-mobile-hidden="true"] { display: none; }
[data-briefs-grid] { grid-template-columns: 1fr !important; }
}
`;
document.head.appendChild(style);
}
@@ -0,0 +1 @@
export { SidebarLink, BriefingPage } from "./app.js";
@@ -0,0 +1,152 @@
import type { BriefCard, BriefCardState, BriefSummaryStatus, BriefTaskRow } from "../contracts.js";
export type BriefSectionKey = "attention" | "live" | "settled";
export type BriefSection = {
key: BriefSectionKey;
label: string;
cards: BriefCard[];
};
const ATTENTION_STATES: ReadonlyArray<BriefCardState> = ["error", "blocked", "waiting-user"];
const LIVE_STATES: ReadonlyArray<BriefCardState> = ["waiting-reviewer", "live"];
const SETTLED_STATES: ReadonlyArray<BriefCardState> = ["done", "stale"];
export function sectionForState(state: BriefCardState): BriefSectionKey {
if (ATTENTION_STATES.includes(state)) return "attention";
if (LIVE_STATES.includes(state)) return "live";
return "settled";
}
export function groupCardsIntoSections(cards: BriefCard[]): BriefSection[] {
const buckets: Record<BriefSectionKey, BriefCard[]> = {
attention: [],
live: [],
settled: [],
};
for (const card of cards) {
if (card.hidden) continue;
buckets[sectionForState(card.state)].push(card);
}
for (const key of Object.keys(buckets) as BriefSectionKey[]) {
buckets[key].sort(compareCards);
}
return [
{ key: "attention", label: "Needs your attention", cards: buckets.attention },
{ key: "live", label: "Live and in review", cards: buckets.live },
{ key: "settled", label: "Recently done & stale", cards: buckets.settled },
];
}
function compareCards(a: BriefCard, b: BriefCard): number {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const aT = Date.parse(a.lastMeaningfulEventAt) || 0;
const bT = Date.parse(b.lastMeaningfulEventAt) || 0;
return bT - aT;
}
export function countAttention(cards: BriefCard[]): number {
let n = 0;
for (const card of cards) {
if (card.hidden) continue;
if (ATTENTION_STATES.includes(card.state) || card.state === "waiting-reviewer") n += 1;
}
return n;
}
export const stateBadgeLabel: Record<BriefCardState, string> = {
error: "Error",
blocked: "Blocked",
"waiting-user": "Waiting on you",
"waiting-reviewer": "In review",
live: "Live",
done: "Recently done",
stale: "Stale",
};
export const stateTone: Record<BriefCardState, "red" | "warning" | "violet" | "cyan" | "green" | "muted"> = {
error: "red",
blocked: "red",
"waiting-user": "warning",
"waiting-reviewer": "violet",
live: "cyan",
done: "green",
stale: "muted",
};
export const summaryFallbackLabel = "Summary unavailable";
export function summaryStatusLabel(status: BriefSummaryStatus): string {
switch (status) {
case "ok":
return "ready";
case "pending":
return "generating";
case "fallback":
return summaryFallbackLabel;
default:
return status;
}
}
const RELATIVE_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 60, unit: "second" },
{ amount: 60, unit: "minute" },
{ amount: 24, unit: "hour" },
{ amount: 7, unit: "day" },
{ amount: 4.34524, unit: "week" },
{ amount: 12, unit: "month" },
{ amount: Number.POSITIVE_INFINITY, unit: "year" },
];
export function formatRelative(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "";
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "";
let diff = (t - now.getTime()) / 1000;
for (const division of RELATIVE_DIVISIONS) {
if (Math.abs(diff) < division.amount) {
const value = Math.round(diff);
const abs = Math.abs(value);
const unit = division.unit;
if (value === 0) return "just now";
return value < 0 ? `${abs}${shortUnit(unit)} ago` : `in ${abs}${shortUnit(unit)}`;
}
diff /= division.amount;
}
return "";
}
function shortUnit(unit: Intl.RelativeTimeFormatUnit): string {
switch (unit) {
case "second":
return "s";
case "minute":
return "m";
case "hour":
return "h";
case "day":
return "d";
case "week":
return "w";
case "month":
return "mo";
case "year":
return "y";
default:
return "";
}
}
export function shouldDimCard(card: BriefCard): boolean {
return card.state === "stale" || card.state === "done";
}
export function rightTagForRow(row: BriefTaskRow): string {
return row.rightTag.length > 18 ? `${row.rightTag.slice(0, 17)}…` : row.rightTag;
}
export function truncateTitle(title: string, max: number): string {
if (title.length <= max) return title;
return `${title.slice(0, max - 1)}…`;
}
+379 -14
View File
@@ -1,4 +1,11 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import {
definePlugin,
runWorker,
type Issue,
type PluginContext,
type PluginIssueRelationSummary,
type ToolResult,
} from "@paperclipai/plugin-sdk";
import {
briefPreferencesSchema,
listBriefCardsInputSchema,
@@ -7,9 +14,23 @@ import {
} from "./contracts.js";
import {
buildDeterministicBriefCard,
type BriefsApprovalInput,
type BriefsCommentInput,
type BriefsDocumentInput,
type BriefsIssueInput,
type BriefsRelationInput,
type BriefsRunInput,
type BriefsSourceBundle,
type BriefsWorkProductInput,
type DeterministicBriefOptions,
} from "./deterministic-card-service.js";
import {
BRIEFING_ANALYST_AGENT_KEY,
BRIEFS_MANAGED_ROUTINE_KEYS,
BRIEFS_MANAGED_SKILL_KEYS,
BRIEFS_PROJECT_KEY,
MANUAL_REFRESH_ROUTINE_KEY,
} from "./manifest.js";
import { createBriefsStore } from "./store.js";
function objectParam(value: unknown, name: string): Record<string, unknown> {
@@ -19,6 +40,235 @@ function objectParam(value: unknown, name: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
function stringParam(value: unknown, name: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${name} is required`);
}
return value.trim();
}
function numberParam(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null;
}
function oneParagraph(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 260);
}
function summaryFailureReason(value: unknown): DeterministicBriefOptions["summaryFailureReason"] {
return value === "truncation_failed" || value === "budget_capped" || value === "safety_block" || value === "model_error"
? value
: "model_error";
}
function summaryOptions(input: Record<string, unknown>): DeterministicBriefOptions {
if (input.budgetCapped === true) {
return {
summaryStatus: "fallback",
summaryFailureReason: "budget_capped",
};
}
const summary = typeof input.summary === "string" ? oneParagraph(input.summary) : "";
if (!summary) {
return {
summaryStatus: "fallback",
summaryFailureReason: summaryFailureReason(input.summaryFailureReason),
};
}
return {
summaryStatus: "ok",
summaryParagraph: summary,
summaryModel: typeof input.summaryModel === "string" && input.summaryModel.trim() ? input.summaryModel.trim() : "cheap-model",
summaryTokensIn: numberParam(input.summaryTokensIn),
summaryTokensOut: numberParam(input.summaryTokensOut),
generatedByAgentId: typeof input.generatedByAgentId === "string" ? input.generatedByAgentId : null,
generatedByRunId: typeof input.generatedByRunId === "string" ? input.generatedByRunId : null,
};
}
async function saveBriefCard(
ctx: PluginContext,
store: ReturnType<typeof createBriefsStore>,
bundle: BriefsSourceBundle,
options: DeterministicBriefOptions,
) {
const card = await store.saveCard(buildDeterministicBriefCard(bundle, options));
await ctx.activity.log({
companyId: card.companyId,
message: `Updated briefing card "${card.title}"`,
entityType: "plugin:briefs:card",
entityId: card.id,
metadata: {
userId: card.userId,
state: card.state,
rootIssueId: card.rootIssueId,
summaryStatus: card.summaryStatus,
summaryModel: card.snapshot.summaryModel,
summaryTokensIn: card.snapshot.summaryTokensIn,
summaryTokensOut: card.snapshot.summaryTokensOut,
summaryFailureReason: card.snapshot.summaryFailureReason,
},
});
return card;
}
function issueToBriefIssue(issue: Issue): BriefsIssueInput {
return {
id: issue.id,
companyId: issue.companyId,
parentId: issue.parentId,
title: issue.title,
identifier: issue.identifier,
status: issue.status,
priority: issue.priority,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
createdByUserId: issue.createdByUserId,
activeRecoveryAction: issue.activeRecoveryAction ?? null,
blockerAttention: issue.blockerAttention ? { reason: issue.blockerAttention.reason } : null,
executionState: issue.executionState ?? null,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
completedAt: issue.completedAt,
};
}
function relationToBriefRelation(summary: PluginIssueRelationSummary | undefined): BriefsRelationInput {
return {
blockedBy: (summary?.blockedBy ?? []).map((blocker) => ({
id: blocker.id,
identifier: blocker.identifier,
title: blocker.title,
status: blocker.status,
})),
};
}
function runToBriefRun(run: BriefsRunInput | Awaited<ReturnType<PluginContext["issues"]["summaries"]["getOrchestration"]>>["runs"][number]): BriefsRunInput {
return {
id: run.id,
companyId: "companyId" in run ? run.companyId : "",
issueId: run.issueId,
status: run.status,
error: run.error ?? null,
startedAt: run.startedAt ?? null,
finishedAt: run.finishedAt ?? null,
createdAt: run.createdAt,
};
}
async function buildBundleFromIssueTree(ctx: PluginContext, input: {
companyId: string;
userId: string;
rootIssueId: string;
}): Promise<BriefsSourceBundle> {
const subtree = await ctx.issues.getSubtree(input.rootIssueId, input.companyId, {
includeRoot: true,
includeRelations: true,
includeDocuments: true,
includeActiveRuns: true,
includeAssignees: true,
});
const orchestration = await ctx.issues.summaries.getOrchestration({
issueId: input.rootIssueId,
companyId: input.companyId,
includeSubtree: true,
billingCode: "plugin-briefs:refresh",
});
const comments: BriefsCommentInput[] = [];
const documents: BriefsDocumentInput[] = [];
const workProducts: BriefsWorkProductInput[] = [];
for (const issue of subtree.issues) {
const issueComments = await ctx.issues.listComments(issue.id, input.companyId);
comments.push(...issueComments.map((comment) => ({
id: comment.id,
companyId: comment.companyId,
issueId: comment.issueId,
authorUserId: comment.authorUserId,
body: comment.body,
createdAt: comment.createdAt,
})));
documents.push(...(subtree.documents?.[issue.id] ?? []).map((document) => ({
id: document.id,
companyId: document.companyId,
issueId: document.issueId,
key: document.key,
title: document.title,
createdByUserId: document.createdByUserId,
updatedByUserId: document.updatedByUserId,
updatedAt: document.updatedAt,
})));
workProducts.push(...(issue.workProducts ?? []).map((product) => ({
id: product.id,
companyId: issue.companyId,
issueId: issue.id,
title: product.title,
status: product.status,
createdAt: product.createdAt,
updatedAt: product.updatedAt,
})));
}
const activeRuns = Object.fromEntries(
Object.entries(subtree.activeRuns ?? {}).map(([issueId, runs]) => [
issueId,
runs.map((run) => ({ ...runToBriefRun(run), companyId: input.companyId })),
]),
);
const root = subtree.issues.find((issue) => issue.id === input.rootIssueId) ?? subtree.issues[0];
if (!root) throw new Error(`Issue tree is empty: ${input.rootIssueId}`);
return {
companyId: input.companyId,
userId: input.userId,
rootIssueId: input.rootIssueId,
title: root.title,
groupingDescription: `Issue tree rooted at ${root.identifier ?? root.id}: ${root.title}`,
issues: subtree.issues.map(issueToBriefIssue),
relations: Object.fromEntries(subtree.issues.map((issue) => [
issue.id,
relationToBriefRelation(subtree.relations?.[issue.id]),
])),
activeRuns,
runs: orchestration.runs.map((run) => ({ ...runToBriefRun(run), companyId: input.companyId })),
comments,
documents,
approvals: orchestration.approvals.map((approval): BriefsApprovalInput => ({
id: approval.id,
companyId: input.companyId,
issueId: approval.issueId,
status: approval.status,
reviewerUserId: null,
decidedByUserId: approval.decidedByUserId,
createdAt: approval.createdAt,
decidedAt: approval.decidedAt,
})),
workProducts,
relevantAgentIds: Object.keys(subtree.assignees ?? {}),
};
}
async function refreshIssueTree(
ctx: PluginContext,
store: ReturnType<typeof createBriefsStore>,
rawInput: Record<string, unknown>,
) {
const companyId = stringParam(rawInput.companyId, "companyId");
const userId = stringParam(rawInput.userId, "userId");
const rootIssueId = stringParam(rawInput.rootIssueId, "rootIssueId");
const card = await saveBriefCard(
ctx,
store,
await buildBundleFromIssueTree(ctx, { companyId, userId, rootIssueId }),
{
...summaryOptions(rawInput),
generatedByAgentId: typeof rawInput.generatedByAgentId === "string" ? rawInput.generatedByAgentId : null,
generatedByRunId: typeof rawInput.generatedByRunId === "string" ? rawInput.generatedByRunId : null,
},
);
return { card };
}
const plugin = definePlugin({
async setup(ctx) {
const store = createBriefsStore(ctx.db);
@@ -30,26 +280,92 @@ const plugin = definePlugin({
};
});
ctx.data.register("preferences", async (params) => {
const input = objectParam(params, "params");
const companyId = typeof input.companyId === "string" ? input.companyId : "";
const userId = typeof input.userId === "string" ? input.userId : "";
if (!companyId || !userId) {
throw new Error("companyId and userId are required");
}
return {
preferences: await store.loadPreferences({ companyId, userId }),
};
});
ctx.data.register("page", async (params) => {
const input = objectParam(params, "params");
const companyId = typeof input.companyId === "string" ? input.companyId : "";
const userId = typeof input.userId === "string" ? input.userId : "";
const includeHidden = Boolean(input.includeHidden);
if (!companyId || !userId) {
throw new Error("companyId and userId are required");
}
const [cards, preferences] = await Promise.all([
store.listCards({ companyId, userId, includeHidden }),
store.loadPreferences({ companyId, userId }),
]);
return {
cards,
preferences,
fetchedAt: new Date().toISOString(),
};
});
ctx.actions.register("save-deterministic-card", async (params) => {
const input = objectParam(params, "params");
const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle;
const options = (input.options && typeof input.options === "object" ? input.options : {}) as DeterministicBriefOptions;
const card = await store.saveCard(buildDeterministicBriefCard(bundle, options));
await ctx.activity.log({
companyId: card.companyId,
message: `Updated briefing card "${card.title}"`,
entityType: "plugin:briefs:card",
entityId: card.id,
metadata: {
userId: card.userId,
state: card.state,
rootIssueId: card.rootIssueId,
summaryStatus: card.summaryStatus,
},
});
const card = await saveBriefCard(ctx, store, bundle, options);
return { card };
});
ctx.actions.register("refresh-issue-tree", async (params) => {
return refreshIssueTree(ctx, store, objectParam(params, "params"));
});
ctx.actions.register("reconcile-managed-agent", async (params) => {
return ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, stringParam(params.companyId, "companyId"));
});
ctx.actions.register("reconcile-managed-project", async (params) => {
return ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, stringParam(params.companyId, "companyId"));
});
ctx.actions.register("reconcile-managed-skills", async (params) => {
const companyId = stringParam(params.companyId, "companyId");
return {
managedSkills: await Promise.all(
BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reconcile(skillKey, companyId)),
),
};
});
ctx.actions.register("reconcile-managed-routines", async (params) => {
const companyId = stringParam(params.companyId, "companyId");
return {
managedRoutines: await Promise.all(
BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)),
),
};
});
ctx.actions.register("reconcile-managed-resources", async (params) => {
const companyId = stringParam(params.companyId, "companyId");
const managedProject = await ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, companyId);
const managedSkills = await Promise.all(
BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reconcile(skillKey, companyId)),
);
const managedAgent = await ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId);
const managedRoutines = await Promise.all(
BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)),
);
return { managedProject, managedSkills, managedAgent, managedRoutines };
});
ctx.actions.register("run-manual-refresh-routine", async (params) => {
return ctx.routines.managed.run(MANUAL_REFRESH_ROUTINE_KEY, stringParam(params.companyId, "companyId"));
});
ctx.actions.register("pin-card", async (params) => {
const input = pinBriefCardInputSchema.parse(params);
await store.setPinned(input);
@@ -76,6 +392,55 @@ const plugin = definePlugin({
});
return { preferences };
});
ctx.tools.register("briefs_list_cards", {
displayName: "List Briefing Cards",
description: "List current Briefing cards for a company/user pair.",
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_list_cards")?.parametersSchema ?? { type: "object" },
}, async (params): Promise<ToolResult> => {
const input = objectParam(params, "params");
const cards = await store.listCards(listBriefCardsInputSchema.parse(input));
return {
content: cards.length ? cards.map((card) => `${card.slug}: ${card.state} (${card.summaryStatus})`).join("\n") : "No Briefing cards found.",
data: { cards },
};
});
ctx.tools.register("briefs_save_card", {
displayName: "Save Briefing Card",
description: "Save a deterministic or generated Briefing card from a source bundle.",
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_save_card")?.parametersSchema ?? { type: "object" },
}, async (params, runCtx): Promise<ToolResult> => {
const input = objectParam(params, "params");
const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle;
const rawOptions = input.options && typeof input.options === "object" ? input.options as Record<string, unknown> : {};
const card = await saveBriefCard(ctx, store, bundle, {
...summaryOptions(rawOptions),
generatedByAgentId: runCtx.agentId,
generatedByRunId: runCtx.runId,
});
return {
content: `Saved Briefing card ${card.slug} with ${card.state}/${card.summaryStatus}.`,
data: { card },
};
});
ctx.tools.register("briefs_refresh_issue_tree", {
displayName: "Refresh Briefing Issue Tree",
description: "Build and save a Briefing card for one Paperclip issue tree using deterministic fallback state.",
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "briefs_refresh_issue_tree")?.parametersSchema ?? { type: "object" },
}, async (params, runCtx): Promise<ToolResult> => {
const input = objectParam(params, "params");
const result = await refreshIssueTree(ctx, store, {
...input,
generatedByAgentId: runCtx.agentId,
generatedByRunId: runCtx.runId,
});
return {
content: `Refreshed Briefing card ${result.card.slug} with ${result.card.state}/${result.card.summaryStatus}.`,
data: result,
};
});
},
async onHealth() {
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest, {
BRIEFING_ANALYST_AGENT_KEY,
BRIEFS_MANAGED_ROUTINE_KEYS,
BRIEFS_MANAGED_SKILL_CANONICAL_KEYS,
BRIEFS_MANAGED_SKILL_KEYS,
BRIEFS_PROJECT_KEY,
MANUAL_REFRESH_ROUTINE_KEY,
} from "../src/manifest.js";
import plugin from "../src/worker.js";
const companyId = "11111111-1111-4111-8111-111111111111";
describe("Briefs managed resources", () => {
it("declares the Briefing Analyst, skills, routines, and agent tools", () => {
expect(manifest.capabilities).toEqual(expect.arrayContaining([
"agents.managed",
"skills.managed",
"routines.managed",
"agent.tools.register",
]));
expect(manifest.agents?.[0]).toMatchObject({
agentKey: BRIEFING_ANALYST_AGENT_KEY,
displayName: "Briefing Analyst",
status: "paused",
adapterConfig: {
paperclipSkillSync: {
desiredSkills: BRIEFS_MANAGED_SKILL_CANONICAL_KEYS,
},
},
permissions: {
pluginTools: [manifest.id],
},
});
expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual([...BRIEFS_MANAGED_SKILL_KEYS]);
expect(manifest.routines?.map((routine) => routine.routineKey)).toEqual([...BRIEFS_MANAGED_ROUTINE_KEYS]);
expect(manifest.routines?.find((routine) => routine.routineKey === MANUAL_REFRESH_ROUTINE_KEY)).toMatchObject({
assigneeRef: { resourceKind: "agent", resourceKey: BRIEFING_ANALYST_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: BRIEFS_PROJECT_KEY },
issueTemplate: {
surfaceVisibility: "plugin_operation",
billingCode: "plugin-briefs:manual-refresh",
},
});
expect(manifest.tools?.map((tool) => tool.name)).toEqual([
"briefs_list_cards",
"briefs_save_card",
"briefs_refresh_issue_tree",
]);
});
it("reconciles resources in dependency order so routines resolve their managed refs", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup?.(harness.ctx);
const result = await harness.performAction<{
managedProject: { status: string; projectId: string | null };
managedAgent: { status: string; agentId: string | null };
managedSkills: Array<{ status: string; skillId: string | null }>;
managedRoutines: Array<{ status: string; routineId: string | null; missingRefs: unknown[] }>;
}>("reconcile-managed-resources", { companyId });
expect(result.managedProject).toMatchObject({ status: "created" });
expect(result.managedAgent).toMatchObject({ status: "created" });
expect(result.managedSkills.map((skill) => skill.status)).toEqual(["created", "created"]);
expect(result.managedRoutines).toHaveLength(3);
for (const routine of result.managedRoutines) {
expect(routine.status).toBe("created");
expect(routine.routineId).toBeTruthy();
expect(routine.missingRefs).toEqual([]);
}
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@paperclipai/plugin-sdk/ui", () => {
return {
useHostNavigation: () => ({
resolveHref: (to: string) => to,
navigate: () => {},
linkProps: (to: string) => ({ href: to, onClick: () => {} }),
}),
usePluginAction: () => vi.fn(async () => ({ ok: true })),
usePluginData: () => ({ data: null, loading: false, error: null, refresh: () => {} }),
usePluginToast: () => vi.fn(),
useHostLocation: () => ({ pathname: "/PAP/briefs", search: "", hash: "" }),
usePluginStream: () => ({ events: [], lastEvent: null, connecting: false, connected: false, error: null, close: () => {} }),
};
});
import { renderToStaticMarkup } from "react-dom/server";
import { BriefCardView } from "../../src/ui/app.js";
import { makeCard, makeSnapshot, makeTaskRow } from "./fixtures.js";
function renderCard(card: ReturnType<typeof makeCard>): string {
return renderToStaticMarkup(<BriefCardView card={card} onChanged={() => {}} />);
}
describe("BriefCardView", () => {
it("renders title, single state badge, and primary source rows", () => {
const card = makeCard({
title: "Briefs plugin planning",
state: "live",
snapshot: makeSnapshot({
summaryParagraph: "Phase 5 page UI in flight; deterministic data is done.",
taskRows: [
makeTaskRow({ identifier: "PAP-9963", titleLine: "Wire briefing page UI", rightTag: "in_progress" }),
makeTaskRow({ identifier: "PAP-9961", titleLine: "Deterministic card service", rightTag: "done" }),
],
}),
});
const html = renderCard(card);
expect(html).toContain("Briefs plugin planning");
expect(html).toMatch(/data-briefs-state-badge="live"[^>]*>Live</);
expect(html).toContain("Wire briefing page UI");
expect(html).toContain("Deterministic card service");
expect(html).toContain("PAP-9963");
expect(html).toContain("PAP-9961");
// exactly one state badge per card
expect(html.match(/data-briefs-state-badge=/g)?.length).toBe(1);
// summary slot rendered, no fallback panel
expect(html).toContain("data-briefs-summary");
expect(html).not.toContain("data-briefs-summary-fallback");
});
it("renders the fallback panel without disturbing the state badge", () => {
const card = makeCard({
title: "Cost dashboard improvements",
state: "live",
summaryStatus: "fallback",
snapshot: makeSnapshot({
summaryParagraph: null,
summaryStatus: "fallback",
summaryFailureReason: "budget_capped",
taskRows: [makeTaskRow({ identifier: "PAP-8500", titleLine: "Wire cost chart filters", rightTag: "in_progress" })],
}),
});
const html = renderCard(card);
expect(html).toMatch(/data-briefs-state-badge="live"[^>]*>Live</);
expect(html).toContain("data-briefs-summary-fallback");
expect(html).toContain("Summary unavailable");
expect(html).toContain("Summary skipped to stay under budget");
expect(html.match(/data-briefs-state-badge=/g)?.length).toBe(1);
});
it("annotates intra-tree blocked source rows distinctly from out-of-tree blockers", () => {
const card = makeCard({
title: "Sandbox runner",
state: "blocked",
snapshot: makeSnapshot({
summaryParagraph: "External blocker present.",
taskRows: [
makeTaskRow({ identifier: "PAP-1", titleLine: "Out-of-tree blocker", rightTag: "blocked", isIntraTreeBlocked: false }),
makeTaskRow({ identifier: "PAP-2", titleLine: "Intra-tree blocker", rightTag: "blocked", isIntraTreeBlocked: true }),
],
}),
});
const html = renderCard(card);
const matches = html.match(/aria-label="intra-tree blocker"/g) ?? [];
expect(matches.length).toBe(1);
});
it("renders pin button reflecting card pinned state", () => {
const pinned = makeCard({ pinned: true });
expect(renderCard(pinned)).toMatch(/aria-label="Unpin card"/);
const unpinned = makeCard({ pinned: false });
expect(renderCard(unpinned)).toMatch(/aria-label="Pin card"/);
});
});
@@ -0,0 +1,202 @@
import type { BriefCard, BriefCardState, BriefSummaryStatus, BriefTaskRow, BriefCardSource, BriefSnapshot } from "../../src/contracts.js";
let counter = 0;
function id(prefix: string): string {
counter += 1;
return `${prefix}-${counter.toString(16).padStart(8, "0")}`;
}
export function resetFixtureIds(): void {
counter = 0;
}
export function makeTaskRow(overrides: Partial<BriefTaskRow> = {}): BriefTaskRow {
return {
kind: "issue",
sourceId: id("source"),
issueId: id("issue"),
identifier: "PAP-9963",
titleLine: "Wire briefing page UI",
rightTag: "in_progress",
linkPath: "/PAP/issues/PAP-9963",
isIntraTreeBlocked: null,
eventAt: "2026-05-22T10:00:00.000Z",
...overrides,
};
}
export function makeSource(overrides: Partial<BriefCardSource> = {}): BriefCardSource {
return {
id: id("src"),
companyId: "company-1",
userId: "user-1",
cardId: "card-x",
sourceKind: "issue",
sourceId: id("source"),
issueId: id("issue"),
identifier: "PAP-9963",
titleLine: "Wire briefing page UI",
rightTag: "in_progress",
linkPath: "/PAP/issues/PAP-9963",
isIntraTreeBlocked: null,
eventAt: "2026-05-22T10:00:00.000Z",
metadata: {},
...overrides,
};
}
export function makeSnapshot(overrides: Partial<BriefSnapshot> = {}): BriefSnapshot {
return {
id: id("snap"),
companyId: "company-1",
userId: "user-1",
cardId: "card-x",
summaryParagraph: "Briefing UI work is in flight; pin/unpin works and source rows link back to issues.",
summaryStatus: "ok",
summaryModel: "cheap-model",
summaryTokensIn: 1200,
summaryTokensOut: 220,
summaryFailureReason: null,
taskRows: [
makeTaskRow({ identifier: "PAP-9963", titleLine: "Wire briefing page UI", rightTag: "in_progress" }),
makeTaskRow({ identifier: "PAP-9961", titleLine: "Deterministic card service", rightTag: "done" }),
],
evidenceSourceIds: [],
generatedByAgentId: null,
generatedByRunId: null,
deterministicStateInputs: {},
createdAt: "2026-05-22T10:00:00.000Z",
...overrides,
};
}
export function makeCard(overrides: Partial<BriefCard> = {}): BriefCard {
const snapshot = overrides.snapshot ?? makeSnapshot();
const cardId = overrides.id ?? id("card");
return {
id: cardId,
companyId: "company-1",
userId: "user-1",
slug: `card-${cardId}`,
title: "Briefs plugin planning",
groupingDescription: "Briefs plugin planning tree",
rootIssueId: id("root"),
state: "live" as BriefCardState,
summaryStatus: "ok" as BriefSummaryStatus,
pinned: false,
hidden: false,
staleAt: "2026-05-29T10:00:00.000Z",
expiresAt: null,
latestSnapshotId: snapshot.id,
lastMeaningfulEventAt: "2026-05-22T10:00:00.000Z",
snapshot: { ...snapshot, cardId },
sources: [makeSource({ cardId }), makeSource({ cardId, identifier: "PAP-9961", titleLine: "Deterministic card service", rightTag: "done", linkPath: "/PAP/issues/PAP-9961" })],
moreSourceCount: 0,
...overrides,
};
}
export function gallery(): BriefCard[] {
resetFixtureIds();
const baseEvent = "2026-05-22T10:00:00.000Z";
return [
makeCard({
title: "Briefs plugin planning",
state: "live",
pinned: true,
lastMeaningfulEventAt: baseEvent,
snapshot: makeSnapshot({
summaryParagraph: "Phase 5 page UI in flight on PAP-9963; deterministic data is done and routines are paused for board approval.",
taskRows: [
makeTaskRow({ identifier: "PAP-9963", titleLine: "Wire briefing page UI", rightTag: "in_progress" }),
makeTaskRow({ identifier: "PAP-9961", titleLine: "Deterministic card service", rightTag: "done" }),
makeTaskRow({ identifier: "PAP-9962", titleLine: "Managed agent + routines", rightTag: "paused" }),
],
}),
moreSourceCount: 3,
}),
makeCard({
title: "External-adapter plugin: spec review",
state: "waiting-user",
pinned: true,
lastMeaningfulEventAt: "2026-05-21T13:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "External-adapter sandbox spec is waiting on your reply about credential scoping before publish.",
taskRows: [
makeTaskRow({ identifier: "PAP-9020", titleLine: "Confirm credential scoping", rightTag: "asked you", kind: "interaction", linkPath: "/PAP/issues/PAP-9020#interaction-1" }),
makeTaskRow({ identifier: "PAP-9001", titleLine: "Adapter spec draft", rightTag: "in_review" }),
],
}),
}),
makeCard({
title: "Onboarding flow fixes",
state: "blocked",
lastMeaningfulEventAt: "2026-05-21T08:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "CEO copy decision is blocking the onboarding sign-up rewrite. No engineering action queued.",
taskRows: [
makeTaskRow({ identifier: "PAP-7710", titleLine: "Wait for CEO copy decision", rightTag: "blocked", isIntraTreeBlocked: false }),
makeTaskRow({ identifier: "PAP-7702", titleLine: "Sign-up endpoint rewrite", rightTag: "todo" }),
],
}),
moreSourceCount: 2,
}),
makeCard({
title: "Sandbox runner crash loop",
state: "error",
lastMeaningfulEventAt: "2026-05-22T09:48:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "Sandbox runner restarted 4× in the last hour; recovery action queued and the worker is paused.",
taskRows: [
makeTaskRow({ identifier: "PAP-8201", titleLine: "Recovery action: restart_clean", rightTag: "recovery", kind: "run", linkPath: "/PAP/runs/run-x" }),
makeTaskRow({ identifier: "PAP-8201", titleLine: "Worker auto-paused", rightTag: "failed" }),
],
}),
}),
makeCard({
title: "Release readiness",
state: "waiting-reviewer",
lastMeaningfulEventAt: "2026-05-22T07:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "Release notes drafted; CTO review queued. CI is green and QA validation is done.",
taskRows: [
makeTaskRow({ identifier: "PAP-9410", titleLine: "Release sign-off", rightTag: "in_review", kind: "approval" }),
makeTaskRow({ identifier: "PAP-9408", titleLine: "QA validation", rightTag: "done" }),
],
}),
}),
makeCard({
title: "Cost dashboard improvements",
state: "live",
summaryStatus: "fallback",
lastMeaningfulEventAt: "2026-05-22T09:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: null,
summaryStatus: "fallback",
summaryFailureReason: "budget_capped",
taskRows: [
makeTaskRow({ identifier: "PAP-8500", titleLine: "Wire cost chart filters", rightTag: "in_progress" }),
makeTaskRow({ identifier: "PAP-8501", titleLine: "Cost CSV export", rightTag: "todo" }),
],
}),
}),
makeCard({
title: "Sidebar plugin slot hardening",
state: "done",
lastMeaningfulEventAt: "2026-05-21T22:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "Plugin sidebar slot order locked in; rolling tests green.",
taskRows: [makeTaskRow({ identifier: "PAP-8990", titleLine: "Sidebar plugin slot hardening", rightTag: "done" })],
}),
}),
makeCard({
title: "GA migration spike",
state: "stale",
lastMeaningfulEventAt: "2026-05-11T08:00:00.000Z",
snapshot: makeSnapshot({
summaryParagraph: "Spike paused 11 days; reviewer paged but no follow-up activity.",
taskRows: [makeTaskRow({ identifier: "PAP-7000", titleLine: "Investigate GA path", rightTag: "todo" })],
}),
}),
];
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
countAttention,
formatRelative,
groupCardsIntoSections,
sectionForState,
stateBadgeLabel,
stateTone,
} from "../../src/ui/view-model.js";
import { gallery, makeCard, resetFixtureIds } from "./fixtures.js";
describe("Briefs view model", () => {
it("maps every state to a defined badge label and tone", () => {
for (const state of Object.keys(stateBadgeLabel) as Array<keyof typeof stateBadgeLabel>) {
expect(stateBadgeLabel[state]).toBeTruthy();
expect(stateTone[state]).toBeTruthy();
}
});
it("sorts cards into Needs you / Live / Settled buckets and respects pinning", () => {
resetFixtureIds();
const sections = groupCardsIntoSections(gallery());
const attention = sections.find((s) => s.key === "attention");
const live = sections.find((s) => s.key === "live");
const settled = sections.find((s) => s.key === "settled");
expect(attention?.cards.map((c) => c.title)).toEqual([
"External-adapter plugin: spec review",
"Sandbox runner crash loop",
"Onboarding flow fixes",
]);
expect(live?.cards.map((c) => c.title)).toEqual([
"Briefs plugin planning",
"Cost dashboard improvements",
"Release readiness",
]);
expect(settled?.cards.map((c) => c.title)).toEqual([
"Sidebar plugin slot hardening",
"GA migration spike",
]);
});
it("counts attention cards across error/blocked/waiting-user/waiting-reviewer", () => {
expect(countAttention(gallery())).toBe(4);
});
it("excludes hidden cards from sections", () => {
const cards = [makeCard({ title: "Hidden one", hidden: true })];
const sections = groupCardsIntoSections(cards);
for (const s of sections) expect(s.cards).toHaveLength(0);
});
it("classifies states using the precedence buckets", () => {
expect(sectionForState("error")).toBe("attention");
expect(sectionForState("blocked")).toBe("attention");
expect(sectionForState("waiting-user")).toBe("attention");
expect(sectionForState("waiting-reviewer")).toBe("live");
expect(sectionForState("live")).toBe("live");
expect(sectionForState("done")).toBe("settled");
expect(sectionForState("stale")).toBe("settled");
});
it("formats relative times in compact form", () => {
const now = new Date("2026-05-22T10:00:00.000Z");
expect(formatRelative("2026-05-22T09:45:00.000Z", now)).toBe("15m ago");
expect(formatRelative("2026-05-22T03:00:00.000Z", now)).toBe("7h ago");
expect(formatRelative("2026-05-20T10:00:00.000Z", now)).toBe("2d ago");
expect(formatRelative(null, now)).toBe("");
});
});
@@ -4,6 +4,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
@@ -2,7 +2,10 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
include: ["tests/**/*.spec.{ts,tsx}"],
environment: "node",
},
esbuild: {
jsx: "automatic",
},
});