mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
feat(briefs): add managed analyst resources
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
602902ca3d
commit
0f84cb9aed
15 files changed
+2356
-19
No files matched your search
@@ -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",
|
||||
});
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user