diff --git a/packages/plugins/plugin-briefs/package.json b/packages/plugins/plugin-briefs/package.json index 02bcfc6a6b0..156de50f4f4 100644 --- a/packages/plugins/plugin-briefs/package.json +++ b/packages/plugins/plugin-briefs/package.json @@ -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" } } diff --git a/packages/plugins/plugin-briefs/scripts/build-ui.mjs b/packages/plugins/plugin-briefs/scripts/build-ui.mjs new file mode 100644 index 00000000000..1edf9dc46a1 --- /dev/null +++ b/packages/plugins/plugin-briefs/scripts/build-ui.mjs @@ -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", +}); diff --git a/packages/plugins/plugin-briefs/src/manifest.ts b/packages/plugins/plugin-briefs/src/manifest.ts index e81d0cd00b7..2b1b70d63a9 100644 --- a/packages/plugins/plugin-briefs/src/manifest.ts +++ b/packages/plugins/plugin-briefs/src/manifest.ts @@ -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 + } + ] } }; diff --git a/packages/plugins/plugin-briefs/src/store.ts b/packages/plugins/plugin-briefs/src/store.ts index 8c0ab7f2053..618fe3bdaf3 100644 --- a/packages/plugins/plugin-briefs/src/store.ts +++ b/packages/plugins/plugin-briefs/src/store.ts @@ -300,6 +300,46 @@ export function createBriefsStore(db: PluginDatabaseClient) { ); }, + async loadPreferences(input: { companyId: string; userId: string }): Promise { + 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 { await db.execute( `INSERT INTO ${preferencesTable} ( diff --git a/packages/plugins/plugin-briefs/src/templates.ts b/packages/plugins/plugin-briefs/src/templates.ts new file mode 100644 index 00000000000..54a753d514b --- /dev/null +++ b/packages/plugins/plugin-briefs/src/templates.ts @@ -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.`; diff --git a/packages/plugins/plugin-briefs/src/ui/app.tsx b/packages/plugins/plugin-briefs/src/ui/app.tsx new file mode 100644 index 00000000000..0be7fff793c --- /dev/null +++ b/packages/plugins/plugin-briefs/src/ui/app.tsx @@ -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 = { + 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 ( + + + Briefing + {count > 0 ? : null} + + ); +} + +function AttentionBadge({ count }: { count: number }) { + return ( + + {count > 99 ? "99+" : count} + + ); +} + +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("page", enabled ? params : undefined); + + if (!enabled) { + return ( + + + + ); + } + + return ( + : null} + action={} + preferences={data ? : null} + > + {error ? ( + + ) : loading && !data ? ( + + ) : data && data.cards.length === 0 ? ( + + ) : data ? ( + + ) : null} + + ); +} + +function PageShell({ children, meta, action, preferences }: { + children: ReactNode; + meta?: ReactNode; + action?: ReactNode; + preferences?: ReactNode; +}) { + return ( +
+
+

Briefing

+
{meta}
+
+ {preferences} + {action} +
+
+

+ Durable cards for areas of work that involve you. Pin the ones you always want to see. +

+ {children} +
+ ); +} + +function PageMeta({ data }: { data: PageData }) { + const active = data.cards.filter((c) => !c.hidden); + const pinned = active.filter((c) => c.pinned).length; + return ( + + {active.length} active · {pinned} pinned · refreshed {formatRelative(data.fetchedAt)} + + ); +} + +function RefreshButton({ onClick, loading }: { onClick: () => void; loading: boolean }) { + return ( + + ); +} + +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 ( +
+ + {open ? ( +
+ setLocal((p) => ({ ...p, cadence: value as BriefPreferences["cadence"] }))} + /> + setLocal((p) => ({ ...p, retentionDays: value }))} + /> + setLocal((p) => ({ ...p, doneRetentionHours: value }))} + /> + setLocal((p) => ({ ...p, staleAfterDays: value }))} + /> + setLocal((p) => ({ ...p, maxUnpinnedCards: value }))} + /> +
+ + +
+
+ ) : null} +
+ ); +} + +function PreferenceRow({ label, value, options, onChange }: { + label: string; + value: string; + options: Array<{ value: string; label: string }>; + onChange: (next: string) => void; +}) { + return ( + + ); +} + +function PreferenceNumber({ label, value, min, max, onChange }: { + label: string; + value: number; + min: number; + max: number; + onChange: (next: number) => void; +}) { + return ( + + ); +} + +function PageBody({ data, onChanged }: { data: PageData; onChanged: () => void }) { + const sections = useMemo(() => groupCardsIntoSections(data.cards), [data.cards]); + const [activeTab, setActiveTab] = useState(() => (sections.find((s) => s.key === "attention" && s.cards.length > 0) ? "attention" : "all")); + + return ( + <> + +
+ {sections.map((section) => ( + + ))} +
+ + + ); +} + +function MobileTabs({ sections, active, onChange }: { + sections: BriefSection[]; + active: BriefSectionKey | "all"; + onChange: (next: BriefSectionKey | "all") => void; +}) { + const counts = useMemo(() => { + const map: Record = { all: 0 }; + for (const section of sections) { + map[section.key] = section.cards.length; + map.all += section.cards.length; + } + return map; + }, [sections]); + + return ( + + ); +} + +function SectionView({ section, visibleOnMobile, onChanged }: { section: BriefSection; visibleOnMobile: boolean; onChanged: () => void }) { + if (section.cards.length === 0) return null; + return ( +
+
+

+ {section.label} +

+ {section.cards.length} + +
+
+ {section.cards.map((card) => ( + + ))} +
+
+ ); +} + +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 ( +
+ +
+ +

+ {truncateTitle(card.title, 70)} +

+ +
+ + + +
+
+ ); +} + +function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) { + return ( + + ); +} + +function StateBadge({ state }: { state: BriefCardState }) { + const tone = toneColors[stateTone[state]]; + return ( + + {stateBadgeLabel[state]} + + ); +} + +function MetaRow({ card }: { card: BriefCard }) { + const identifiers = collectIdentifiers(card); + const issueCount = uniqueIssueCount(card); + const agentCount = uniqueAgentCount(card); + const stamp = formatRelative(card.lastMeaningfulEventAt); + return ( +
+ {identifiers.length > 0 ? ( + {identifiers[0]} + ) : null} + {issueCount > 0 ? {issueCount} {issueCount === 1 ? "issue" : "issues"} : null} + {agentCount > 0 ? {agentCount} {agentCount === 1 ? "agent" : "agents"} : null} + {stamp ? · {stamp} : null} +
+ ); +} + +function SummarySlot({ card, onRetry }: { card: BriefCard; onRetry: () => void }) { + const status: BriefSummaryStatus = card.snapshot.summaryStatus; + if (status === "ok") { + return ( +

+ {card.snapshot.summaryParagraph || derivedSynopsis(card)} +

+ ); + } + if (status === "pending") { + return ( +
+ + +
+ ); + } + return ; +} + +function FallbackPanel({ card, onRetry }: { card: BriefCard; onRetry: () => void }) { + const reason = card.snapshot.summaryFailureReason ?? "model_error"; + const reasonLabel = formatFailureReason(reason); + return ( +
+
+
{summaryFallbackLabel}
+
{derivedSynopsis(card)}
+
{reasonLabel}
+
+ +
+ ); +} + +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 ( +
    + {rows.map((row, index) => ( +
  • + {sourceKindIcon[row.kind] ?? "•"} + {row.identifier ? ( + {row.identifier} + ) : null} + + +
  • + ))} +
+ ); +} + +function SourceLink({ row }: { row: BriefTaskRow }) { + const nav = useHostNavigation(); + const link = nav.linkProps(row.linkPath); + return ( + + {row.titleLine} + + ); +} + +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 ( + + {rightTagForRow(row)} + {row.isIntraTreeBlocked ? ↪ : null} + + ); +} + +function Footer({ card, openHref, nav }: { card: BriefCard; openHref: string | null; nav: ReturnType }) { + const stamp = formatRelative(card.lastMeaningfulEventAt); + return ( +
+ {card.moreSourceCount > 0 ? +{card.moreSourceCount} more in tree : } + + {stamp ? {stamp} : null} + {openHref ? ( + + Open tree → + + ) : null} +
+ ); +} + +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(); + 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(); + 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(); + for (const source of card.sources) { + const metadata = source.metadata as Record | 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 ( +
+ Legend + {items.map((item) => { + const tone = toneColors[stateTone[item.state]]; + return ( + + + {stateBadgeLabel[item.state]} + + ); + })} +
+ ); +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
{title}
+
{body}
+
+ ); +} + +function LoadingState() { + return ( +
+ {[0, 1, 2, 3].map((index) => ( +
+
+
+
+
+ ))} +
+ ); +} + +function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
Could not load briefing
+
{message}
+
+ +
+
+ ); +} + +// 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); +} diff --git a/packages/plugins/plugin-briefs/src/ui/index.tsx b/packages/plugins/plugin-briefs/src/ui/index.tsx new file mode 100644 index 00000000000..97d5644846b --- /dev/null +++ b/packages/plugins/plugin-briefs/src/ui/index.tsx @@ -0,0 +1 @@ +export { SidebarLink, BriefingPage } from "./app.js"; diff --git a/packages/plugins/plugin-briefs/src/ui/view-model.ts b/packages/plugins/plugin-briefs/src/ui/view-model.ts new file mode 100644 index 00000000000..b9688e1c72c --- /dev/null +++ b/packages/plugins/plugin-briefs/src/ui/view-model.ts @@ -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 = ["error", "blocked", "waiting-user"]; +const LIVE_STATES: ReadonlyArray = ["waiting-reviewer", "live"]; +const SETTLED_STATES: ReadonlyArray = ["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 = { + 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 = { + error: "Error", + blocked: "Blocked", + "waiting-user": "Waiting on you", + "waiting-reviewer": "In review", + live: "Live", + done: "Recently done", + stale: "Stale", +}; + +export const stateTone: Record = { + 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)}…`; +} diff --git a/packages/plugins/plugin-briefs/src/worker.ts b/packages/plugins/plugin-briefs/src/worker.ts index 595189de9f1..0eb12c86b7f 100644 --- a/packages/plugins/plugin-briefs/src/worker.ts +++ b/packages/plugins/plugin-briefs/src/worker.ts @@ -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 { @@ -19,6 +40,235 @@ function objectParam(value: unknown, name: string): Record { return value as Record; } +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): 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, + 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>["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 { + 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, + rawInput: Record, +) { + 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 => { + 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 => { + 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 : {}; + 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 => { + 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() { diff --git a/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts b/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts new file mode 100644 index 00000000000..ad7bae1f8ff --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts @@ -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([]); + } + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx new file mode 100644 index 00000000000..f3635f05a52 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx @@ -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): string { + return renderToStaticMarkup( {}} />); +} + +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 { + 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 { + 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"/); + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/fixtures.ts b/packages/plugins/plugin-briefs/tests/ui/fixtures.ts new file mode 100644 index 00000000000..7b38a2c3cd9 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/ui/fixtures.ts @@ -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 { + 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 { + 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 { + 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 { + 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" })], + }), + }), + ]; +} diff --git a/packages/plugins/plugin-briefs/tests/ui/view-model.spec.ts b/packages/plugins/plugin-briefs/tests/ui/view-model.spec.ts new file mode 100644 index 00000000000..0fba98179f7 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/ui/view-model.spec.ts @@ -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) { + 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(""); + }); +}); diff --git a/packages/plugins/plugin-briefs/tsconfig.json b/packages/plugins/plugin-briefs/tsconfig.json index 63913161d36..cf31221062c 100644 --- a/packages/plugins/plugin-briefs/tsconfig.json +++ b/packages/plugins/plugin-briefs/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", "strict": true, "skipLibCheck": true, "declaration": true, diff --git a/packages/plugins/plugin-briefs/vitest.config.ts b/packages/plugins/plugin-briefs/vitest.config.ts index 649a293ef95..85703994f43 100644 --- a/packages/plugins/plugin-briefs/vitest.config.ts +++ b/packages/plugins/plugin-briefs/vitest.config.ts @@ -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", + }, });