From f9a88c02da208dba55bf8dba8ea4f3f6bdf4cece Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 24 May 2026 02:28:23 +0000 Subject: [PATCH] fix(briefs): add settings and dismissal controls --- .../plugins/plugin-briefs/src/contracts.ts | 7 + packages/plugins/plugin-briefs/src/store.ts | 30 +- .../plugins/plugin-briefs/src/templates.ts | 23 +- packages/plugins/plugin-briefs/src/ui/app.tsx | 354 +++++++++++++++--- packages/plugins/plugin-briefs/src/worker.ts | 116 ++++-- .../tests/managed-resources.spec.ts | 96 ++++- .../plugins/plugin-briefs/tests/store.spec.ts | 132 +++++++ .../tests/ui/brief-card.spec.tsx | 13 +- .../tests/ui/briefing-interactions.spec.tsx | 140 +++++++ .../tests/ui/briefing-page.spec.tsx | 28 +- packages/plugins/sdk/src/testing.ts | 67 +++- ui/src/App.tsx | 1 + ui/src/pages/PluginPage.test.tsx | 4 +- ui/src/pages/PluginPage.tsx | 18 +- ui/src/pages/PluginSettings.test.tsx | 148 ++++++-- ui/src/pages/PluginSettings.tsx | 30 +- 16 files changed, 1060 insertions(+), 147 deletions(-) create mode 100644 packages/plugins/plugin-briefs/tests/store.spec.ts create mode 100644 packages/plugins/plugin-briefs/tests/ui/briefing-interactions.spec.tsx diff --git a/packages/plugins/plugin-briefs/src/contracts.ts b/packages/plugins/plugin-briefs/src/contracts.ts index 4eeed614908..fd1ef1bc6fb 100644 --- a/packages/plugins/plugin-briefs/src/contracts.ts +++ b/packages/plugins/plugin-briefs/src/contracts.ts @@ -125,6 +125,12 @@ export const pinBriefCardInputSchema = z.object({ pinned: z.boolean(), }).strict(); +export const dismissBriefCardInputSchema = z.object({ + companyId: z.string().min(1), + userId: z.string().min(1), + cardId: z.string().min(1), +}).strict(); + export const updateBriefPreferencesInputSchema = briefPreferencesSchema.partial({ cadence: true, retentionDays: true, @@ -154,5 +160,6 @@ export type BriefCard = z.infer; export type BriefPreferences = z.infer; export type ListBriefCardsInput = z.infer; export type PinBriefCardInput = z.infer; +export type DismissBriefCardInput = z.infer; export type UpdateBriefPreferencesInput = z.infer; export type BriefCursorEvent = z.infer; diff --git a/packages/plugins/plugin-briefs/src/store.ts b/packages/plugins/plugin-briefs/src/store.ts index 618fe3bdaf3..1c696987bf0 100644 --- a/packages/plugins/plugin-briefs/src/store.ts +++ b/packages/plugins/plugin-briefs/src/store.ts @@ -176,6 +176,14 @@ export function createBriefsStore(db: PluginDatabaseClient) { }, async saveCard(card: BriefCard): Promise { + const existingBeforeRows = await db.query<{ id: string; pinned: boolean; hidden: boolean }>( + `SELECT id, pinned, hidden FROM ${cardsTable} WHERE company_id = $1 AND user_id = $2 AND slug = $3 LIMIT 1`, + [card.companyId, card.userId, card.slug], + ); + const existingBefore = existingBeforeRows[0] ?? null; + const pinned = Boolean(existingBefore?.pinned || card.pinned); + const hidden = Boolean(existingBefore?.hidden || card.hidden); + await db.execute( `INSERT INTO ${cardsTable} ( id, company_id, user_id, slug, title, grouping_description, root_issue_id, state, summary_status, @@ -189,7 +197,7 @@ export function createBriefsStore(db: PluginDatabaseClient) { state = EXCLUDED.state, summary_status = EXCLUDED.summary_status, pinned = ${cardsTable}.pinned OR EXCLUDED.pinned, - hidden = EXCLUDED.hidden, + hidden = ${cardsTable}.hidden OR EXCLUDED.hidden, stale_at = EXCLUDED.stale_at, expires_at = CASE WHEN ${cardsTable}.pinned OR EXCLUDED.pinned THEN NULL ELSE EXCLUDED.expires_at END, last_meaningful_event_at = EXCLUDED.last_meaningful_event_at, @@ -204,16 +212,16 @@ export function createBriefsStore(db: PluginDatabaseClient) { card.rootIssueId, card.state, card.summaryStatus, - card.pinned, - card.hidden, + pinned, + hidden, card.staleAt, - card.expiresAt, + pinned ? null : card.expiresAt, card.lastMeaningfulEventAt, ], ); - const existingRows = await db.query<{ id: string; pinned: boolean }>( - `SELECT id, pinned FROM ${cardsTable} WHERE company_id = $1 AND user_id = $2 AND slug = $3 LIMIT 1`, + const existingRows = await db.query<{ id: string; pinned: boolean; hidden: boolean }>( + `SELECT id, pinned, hidden FROM ${cardsTable} WHERE company_id = $1 AND user_id = $2 AND slug = $3 LIMIT 1`, [card.companyId, card.userId, card.slug], ); const persistedCardId = existingRows[0]?.id ?? card.id; @@ -285,6 +293,7 @@ export function createBriefsStore(db: PluginDatabaseClient) { ...card, id: persistedCardId, pinned: existingRows[0]?.pinned ?? card.pinned, + hidden: existingRows[0]?.hidden ?? card.hidden, latestSnapshotId: card.snapshot.id, snapshot: { ...card.snapshot, cardId: persistedCardId }, sources: card.sources.map((source) => ({ ...source, cardId: persistedCardId })), @@ -300,6 +309,15 @@ export function createBriefsStore(db: PluginDatabaseClient) { ); }, + async dismissCard(input: { companyId: string; userId: string; cardId: string }): Promise { + await db.execute( + `UPDATE ${cardsTable} + SET hidden = true, pinned = false, updated_at = now() + WHERE company_id = $1 AND user_id = $2 AND id = $3`, + [input.companyId, input.userId, input.cardId], + ); + }, + async loadPreferences(input: { companyId: string; userId: string }): Promise { const rows = await db.query<{ cadence: BriefPreferences["cadence"]; diff --git a/packages/plugins/plugin-briefs/src/templates.ts b/packages/plugins/plugin-briefs/src/templates.ts index 1299a4bedab..b8201304859 100644 --- a/packages/plugins/plugin-briefs/src/templates.ts +++ b/packages/plugins/plugin-briefs/src/templates.ts @@ -14,6 +14,7 @@ Core rules: - Do not put issue identifiers, raw issue titles, or issue-status jargon in the description paragraph. The issue rows already carry source links; the paragraph should stand alone for someone who does not know the issue numbers. - Do not lead with stale/waiting/todo/in-review bookkeeping. Mention completed work only when it changes what should happen next; focus more on what is left to do. - Avoid generic templates like "This brief tracks work around", "current rows show", or "PAP-123 is todo". Use the source rows to infer the underlying initiative and describe it in plain business/product language. +- Dismissed cards are intentional user feedback. Before discovery runs, inspect existing cards with hidden entries included and do not revive hidden/dismissed work areas unless the user explicitly asks for that root again. - Task rows are capped by the plugin at three; do not try to bypass that cap. - The normal refresh flow is two-pass: call \`briefs_refresh_issue_tree\` once to get deterministic card state/source rows, draft the title and description from those rows, then call \`briefs_refresh_issue_tree\` again for the same root with \`title\`, \`summary\`, \`allowGeneratedSummary: true\`, and available model/run metadata. - Lack of exact model id or token counts is not a reason to skip generated prose. If exact metadata is unavailable, pass \`summaryModel: "agent-generated"\` and omit token counts; the plugin stamps the agent/run ids. @@ -32,10 +33,11 @@ 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. For each selected root, call \`briefs_refresh_issue_tree\` once to obtain deterministic rows, then call it again with a generated title and executive standup description grounded in those rows. -5. Never invent tasks, owners, blockers, waiting states, or status. If source rows are unsafe or unavailable, keep the fallback summary and say why. -6. Close the routine issue with counts of refreshed cards, skipped trees, and any follow-up needed. +3. Call \`briefs_list_cards\` with \`includeHidden: true\` before selecting new roots. Treat hidden cards as recently dismissed and skip their root issue/work area unless the user explicitly requested it. +4. 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. +5. For each selected root, call \`briefs_refresh_issue_tree\` once to obtain deterministic rows, then call it again with a generated title and executive standup description grounded in those rows. +6. Never invent tasks, owners, blockers, waiting states, or status. If source rows are unsafe or unavailable, keep the fallback summary and say why. +7. Close the routine issue with counts of refreshed cards, skipped trees, and any follow-up needed. `; export const UPDATE_CARDS_SKILL = `--- @@ -48,7 +50,7 @@ description: "Update existing Briefing cards from recent Paperclip source activi 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. For API/manual update runs, refresh every visible card for the user, even if it already has generated prose and even if it is outside the normal recent-overlap window. Do not preserve older generated text after the Briefing writing guidance changes. +2. For API/manual update runs, refresh every visible card for the user, even if it already has generated prose and even if it is outside the normal recent-overlap window. Hidden cards were dismissed by the user; do not bring them back as visible cards. 3. For each changed card, call \`briefs_refresh_issue_tree\` once to obtain deterministic rows, then call it again with a generated title and executive standup description grounded in those rows. 4. Pass \`allowGeneratedSummary: true\` and model metadata when available. If exact metadata is unavailable, pass \`summaryModel: "agent-generated"\` and omit token counts. 5. If source inputs are unsafe, unavailable, or the run is explicitly budget-stopped, save the deterministic fallback card instead and state the reason. @@ -59,16 +61,17 @@ export const DISCOVERY_ROUTINE_DESCRIPTION = `Discover user-relevant Briefing ca 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. Generate title and description from the deterministic rows returned by the Briefs refresh tool; do not skip generated prose just because no separate summary API exists. -5. Close the routine issue with refreshed/skipped counts and any source trees that need manual attention.`; +2. Inspect current cards with hidden entries included, then skip hidden/dismissed roots unless the user explicitly requested that root. +3. Inspect recently meaningful Paperclip issue trees for that user. Prefer explicit issue roots if provided. +4. Refresh cards through Briefs tools so stable slug/grouping identity is reused. +5. Generate title and description from the deterministic rows returned by the Briefs refresh tool; do not skip generated prose just because no separate summary API exists. +6. 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. For API/manual update runs, refresh every visible card for the user regardless of age or prior summary status. Scheduled runs may use the overlap window, but manual/API runs are rewrite passes. +2. For API/manual update runs, refresh every visible card for the user regardless of age or prior summary status. Scheduled runs may use the overlap window, but manual/API runs are rewrite passes. Hidden cards are recently dismissed and should remain hidden. 3. Use deterministic state for blockers, waiting states, live work, stale state, and task rows. 4. Generate title and description from the deterministic rows returned by the Briefs refresh tool. Use \`summaryModel: "agent-generated"\` when exact model/token metadata is unavailable. 5. Close the routine issue with updated card slugs, fallback reasons, and any failures.`; diff --git a/packages/plugins/plugin-briefs/src/ui/app.tsx b/packages/plugins/plugin-briefs/src/ui/app.tsx index 9d583189c02..9a8bdc135f3 100644 --- a/packages/plugins/plugin-briefs/src/ui/app.tsx +++ b/packages/plugins/plugin-briefs/src/ui/app.tsx @@ -1,6 +1,7 @@ import { IssueRow as PluginIssueRow, ManagedRoutinesList as PluginManagedRoutinesList, + useHostContext, useHostNavigation, usePluginAction, usePluginData, @@ -13,6 +14,7 @@ import { } from "@paperclipai/plugin-sdk/ui"; import { useCallback, + useEffect, useMemo, useState, type CSSProperties, @@ -21,10 +23,10 @@ import { import type { BriefCard, BriefCardSource, + BriefPreferences, BriefTaskRow, } from "../contracts.js"; import { - countAttention, formatRelative, sortBriefCards, } from "./view-model.js"; @@ -70,6 +72,8 @@ type PageData = { const DISCOVER_CARDS_ROUTINE_KEY = "briefs-discover-cards"; const UPDATE_CARDS_ROUTINE_KEY = "briefs-update-cards"; const MANUAL_REFRESH_ROUTINE_KEY = "briefs-manual-refresh"; +const BRIEFS_PLUGIN_SETTINGS_PATH = "/instance/settings/plugins/paperclipai.plugin-briefs"; +const DISMISS_ANIMATION_MS = 220; const ROUTINE_FALLBACKS: Record = { [DISCOVER_CARDS_ROUTINE_KEY]: { title: "Discover Briefing cards", schedule: "0 */6 * * *" }, @@ -77,15 +81,8 @@ const ROUTINE_FALLBACKS: Record ({ companyId: context.companyId ?? "" }), [context.companyId]); - const enabled = Boolean(params.companyId); - 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} ); } @@ -130,29 +126,6 @@ function BriefingSidebarIcon() { ); } -function AttentionBadge({ count }: { count: number }) { - return ( - - {count > 99 ? "99+" : count} - - ); -} - export function BriefingPage({ context }: PluginPageProps) { const params = useMemo(() => ({ companyId: context.companyId ?? "" }), [context.companyId]); const enabled = Boolean(params.companyId); @@ -172,7 +145,12 @@ export function BriefingPage({ context }: PluginPageProps) { return ( : null} - action={} + action={( + <> + + + + )} > {error ? ( @@ -231,6 +209,7 @@ type SettingsData = { managedProject: ManagedProject; managedSkills: ManagedSkill[]; managedRoutines: ManagedRoutine[]; + preferences: BriefPreferences; agentOptions: Array<{ id: string; name: string; status?: string | null; adapterType?: string | null; icon?: string | null }>; projectOptions: Array<{ id: string; name: string; status?: string | null; color?: string | null }>; }; @@ -244,8 +223,10 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { const updateRoutineStatus = usePluginAction("update-managed-routine-status"); const runRoutine = usePluginAction("run-managed-routine"); const runBriefingRoutines = usePluginAction("run-briefing-routines"); + const updatePreferences = usePluginAction("update-preferences"); const toast = usePluginToast(); const [busy, setBusy] = useState(null); + const [draftPreferences, setDraftPreferences] = useState(null); if (!enabled) { return Choose a company to view Briefing settings.; @@ -261,9 +242,9 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { } const data = settings.data; + const preferences = draftPreferences ?? data.preferences; const managedRoutineItems = buildManagedRoutineItems(data.managedRoutines); const assigneeAgentId = data.managedAgent.agentId ?? data.managedAgent.agent?.id ?? null; - const projectId = data.managedProject.projectId ?? data.managedProject.project?.id ?? null; const resourcesHealthy = resourceReady(data.managedAgent) && resourceReady(data.managedProject) && data.managedSkills.every(resourceReady) && data.managedRoutines.every(resourceReady); @@ -288,7 +269,6 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { companyId: params.companyId, ...(context.userId ? { userId: context.userId } : {}), assigneeAgentId, - projectId, }); toast({ tone: "success", title: "Briefing routines queued" }); settings.refresh(); @@ -311,7 +291,6 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { routineKey: routine.resourceKey ?? routine.key, ...(context.userId ? { userId: context.userId } : {}), assigneeAgentId: routine.assigneeAgentId ?? assigneeAgentId, - projectId: routine.projectId ?? projectId, variables: context.userId ? { userId: context.userId } : undefined, }); toast({ tone: "success", title: "Routine queued" }); @@ -349,7 +328,6 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { companyId: params.companyId, routineKey: routine.resourceKey, assigneeAgentId, - projectId, }); toast({ tone: "success", title: "Routine reconciled" }); settings.refresh(); @@ -360,9 +338,66 @@ export function SettingsPage({ context }: PluginSettingsPageProps) { } } + async function savePreferences() { + setBusy("preferences"); + try { + await updatePreferences({ + ...preferences, + companyId: params.companyId, + ...(context.userId ? { userId: context.userId } : {}), + }); + toast({ tone: "success", title: "Briefing settings saved" }); + settings.refresh(); + setDraftPreferences(null); + } catch (err) { + toast({ tone: "error", title: "Settings save failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + function patchPreferences(patch: Partial) { + setDraftPreferences((current) => ({ ...(current ?? data.preferences), ...patch })); + } + return (
+
+
+

Dashboard settings

+ Control refresh cadence, stale windows, retention, and card count limits. +
+
+ + + + + patchPreferences({ maxUnpinnedCards })} /> + + + patchPreferences({ retentionDays })} /> + + + patchPreferences({ staleAfterDays })} /> + + + patchPreferences({ doneRetentionHours })} /> + +
+
+ Save dashboard settings +
+
+

Managed resources

@@ -413,6 +448,42 @@ function Tiny({ children }: { children: ReactNode }) { return {children}; } +const fieldStyle: CSSProperties = { + width: "100%", + boxSizing: "border-box", + borderRadius: 6, + border: `1px solid ${tokens.border}`, + background: tokens.card, + color: tokens.fg, + padding: "7px 9px", + fontSize: 13, + fontFamily: fontStack, +}; + +function PreferenceField({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function NumberField({ value, min, onChange }: { value: number; min: number; onChange: (value: number) => void }) { + return ( + { + const next = Number.parseInt(event.currentTarget.value, 10); + if (Number.isFinite(next)) onChange(Math.max(min, next)); + }} + style={fieldStyle} + /> + ); +} + function Callout({ children, tone = "default" }: { children: ReactNode; tone?: "default" | "danger" }) { const danger = tone === "danger"; return ( @@ -521,7 +592,13 @@ function PageShell({ children, meta, action }: { action?: ReactNode; }) { return ( -
+
+

Briefing

{meta}
@@ -534,6 +611,52 @@ function PageShell({ children, meta, action }: { ); } +function SettingsGearLink() { + const context = useHostContext(); + const nav = useHostNavigation(); + const companyPrefix = context.companyPrefix?.trim(); + const settingsPath = companyPrefix + ? `/${companyPrefix.toUpperCase()}${BRIEFS_PLUGIN_SETTINGS_PATH}` + : BRIEFS_PLUGIN_SETTINGS_PATH; + const link = nav.linkProps(settingsPath); + return ( + + + + ); +} + +function GearIcon() { + return ( + + ); +} + function PageMeta({ data }: { data: PageData }) { const active = data.cards.filter((c) => !c.hidden); const pinned = active.filter((c) => c.pinned).length; @@ -567,10 +690,58 @@ function RefreshButton({ onClick, loading }: { onClick: () => void; loading: boo } function PageBody({ data, onChanged }: { data: PageData; onChanged: () => void }) { - const cards = useMemo(() => sortBriefCards(data.cards), [data.cards]); + const [optimisticPins, setOptimisticPins] = useState>({}); + const [dismissingIds, setDismissingIds] = useState>(() => new Set()); + const [removedIds, setRemovedIds] = useState>(() => new Set()); + + useEffect(() => { + const liveIds = new Set(data.cards.map((card) => card.id)); + setOptimisticPins((current) => Object.fromEntries( + Object.entries(current).filter(([cardId]) => liveIds.has(cardId)), + )); + setDismissingIds((current) => filterIdSet(current, liveIds)); + setRemovedIds((current) => filterIdSet(current, liveIds)); + }, [data.cards]); + + const cards = useMemo(() => sortBriefCards( + data.cards + .filter((card) => !removedIds.has(card.id)) + .map((card) => ({ + ...card, + pinned: optimisticPins[card.id] ?? card.pinned, + })), + ), [data.cards, optimisticPins, removedIds]); + + const handlePinChanged = useCallback((cardId: string, pinned: boolean) => { + setOptimisticPins((current) => ({ ...current, [cardId]: pinned })); + }, []); + + const handlePinFailed = useCallback((cardId: string, pinned: boolean) => { + setOptimisticPins((current) => ({ ...current, [cardId]: pinned })); + }, []); + + const handleDismissStarted = useCallback((cardId: string) => { + setDismissingIds((current) => new Set([...current, cardId])); + window.setTimeout(() => { + setRemovedIds((current) => new Set([...current, cardId])); + }, DISMISS_ANIMATION_MS); + }, []); + + const handleDismissFailed = useCallback((cardId: string) => { + setDismissingIds((current) => { + const next = new Set(current); + next.delete(cardId); + return next; + }); + setRemovedIds((current) => { + const next = new Set(current); + next.delete(cardId); + return next; + }); + }, []); return ( -
+

Recent work and next steps @@ -579,29 +750,77 @@ function PageBody({ data, onChanged }: { data: PageData; onChanged: () => void } Sorted by the latest meaningful activity across the work areas Briefing is tracking.

-
+
{cards.map((card) => ( - + ))}
); } -export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged: () => void }) { +function filterIdSet(current: Set, allowed: Set): Set { + const next = new Set(); + for (const id of current) { + if (allowed.has(id)) next.add(id); + } + return next.size === current.size ? current : next; +} + +export function BriefCardView({ + card, + dismissing = false, + onChanged, + onPinChanged, + onPinFailed, + onDismissStarted, + onDismissFailed, +}: { + card: BriefCard; + dismissing?: boolean; + onChanged: () => void; + onPinChanged?: (cardId: string, pinned: boolean) => void; + onPinFailed?: (cardId: string, pinned: boolean) => void; + onDismissStarted?: (cardId: string) => void; + onDismissFailed?: (cardId: string) => void; +}) { const pin = usePluginAction("pin-card"); + const dismiss = usePluginAction("dismiss-card"); const toast = usePluginToast(); const taskRows = useMemo(() => dedupeTaskRows(card.snapshot.taskRows), [card.snapshot.taskRows]); const togglePin = useCallback(async () => { + const nextPinned = !card.pinned; + onPinChanged?.(card.id, nextPinned); try { - await pin({ companyId: card.companyId, userId: card.userId, cardId: card.id, pinned: !card.pinned }); - onChanged(); + await pin({ companyId: card.companyId, userId: card.userId, cardId: card.id, pinned: nextPinned }); } catch (err) { + onPinFailed?.(card.id, card.pinned); const message = err instanceof Error ? err.message : "Could not update pin"; toast({ tone: "error", title: "Pin failed", body: message }); } - }, [pin, card, onChanged, toast]); + }, [pin, card, onPinChanged, onPinFailed, toast]); + + const dismissCard = useCallback(async () => { + onDismissStarted?.(card.id); + try { + await dismiss({ companyId: card.companyId, userId: card.userId, cardId: card.id }); + } catch (err) { + onDismissFailed?.(card.id); + const message = err instanceof Error ? err.message : "Could not dismiss card"; + toast({ tone: "error", title: "Dismiss failed", body: message }); + onChanged(); + } + }, [dismiss, card, onDismissStarted, onDismissFailed, onChanged, toast]); return (
@@ -625,6 +850,7 @@ export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged:

{card.title}

+
@@ -654,6 +880,8 @@ function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void display: "inline-flex", alignItems: "center", justifyContent: "center", + transform: pinned ? "scale(1.04)" : "scale(1)", + transition: "background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease", }} > {pinned ? "โ˜…" : "โ˜†"} @@ -661,6 +889,33 @@ function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void ); } +function DismissButton({ onDismiss }: { onDismiss: () => void }) { + return ( + + ); +} + function MetaRow({ card }: { card: BriefCard }) { const identifiers = collectIdentifiers(card); const issueCount = uniqueIssueCount(card); @@ -707,14 +962,14 @@ function briefSummaryParagraphs(card: BriefCard): string[] { function SourceRows({ rows }: { rows: BriefTaskRow[] }) { if (rows.length === 0) return null; return ( -
+
{rows.map((row) => ( row.kind === "issue" && (row.identifier || row.issueId) ? ( ) : ( @@ -725,7 +980,6 @@ function SourceRows({ rows }: { rows: BriefTaskRow[] }) { alignItems: "center", gap: 8, padding: "7px 0", - borderBottom: `1px solid ${tokens.border}`, fontSize: 12, minWidth: 0, }} diff --git a/packages/plugins/plugin-briefs/src/worker.ts b/packages/plugins/plugin-briefs/src/worker.ts index 91733d7415c..4bec5dc4d8e 100644 --- a/packages/plugins/plugin-briefs/src/worker.ts +++ b/packages/plugins/plugin-briefs/src/worker.ts @@ -9,6 +9,7 @@ import { } from "@paperclipai/plugin-sdk"; import { briefPreferencesSchema, + dismissBriefCardInputSchema, listBriefCardsInputSchema, pinBriefCardInputSchema, updateBriefPreferencesInputSchema, @@ -402,15 +403,18 @@ const plugin = definePlugin({ }; }); - ctx.data.register("settings", async (params) => { - const companyId = stringParam((params as Record).companyId, "companyId"); - const [managedProject, managedAgent, managedSkills, managedRoutines, agentOptions, projectOptions] = await Promise.all([ + ctx.data.register("settings", async (params, request) => { + const input = bridgeParams(params); + const companyId = stringParam(input.companyId, "companyId"); + const userId = scopedUserId(input, request); + const [managedProject, managedAgent, managedSkills, managedRoutines, agentOptions, projectOptions, preferences] = await Promise.all([ ctx.projects.managed.get(BRIEFS_PROJECT_KEY, companyId), ctx.agents.managed.get(BRIEFING_ANALYST_AGENT_KEY, companyId), Promise.all(BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.get(skillKey, companyId))), Promise.all(BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.get(routineKey, companyId))), ctx.agents.list({ companyId, limit: 200 }), ctx.projects.list({ companyId, limit: 200 }), + store.loadPreferences({ companyId, userId }), ]); return { @@ -418,6 +422,7 @@ const plugin = definePlugin({ managedAgent, managedSkills, managedRoutines, + preferences, agentOptions: agentOptions.map((agent) => ({ id: agent.id, name: agent.name, @@ -458,6 +463,48 @@ const plugin = definePlugin({ return ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, stringParam(params.companyId, "companyId")); }); + async function ensureBriefsProjectReady(companyId: string) { + const managedProject = await ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, companyId); + if (!managedProject.projectId) { + throw new Error("Briefs managed project is not available"); + } + return { managedProject, projectId: managedProject.projectId }; + } + + function managedRoutineOverrides(input: { projectId: string; agentId?: string | null }) { + return { + projectId: input.projectId, + ...(input.agentId ? { assigneeAgentId: input.agentId } : {}), + }; + } + + async function reconcileBriefsRoutineRefs(companyId: string) { + const [{ managedProject, projectId }, managedAgent] = await Promise.all([ + ensureBriefsProjectReady(companyId), + ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId), + ]); + return { + managedProject, + managedAgent, + overrides: managedRoutineOverrides({ projectId, agentId: managedAgent.agentId }), + }; + } + + async function reconcileBriefsRoutine( + routineKey: string, + companyId: string, + overrides: { projectId: string; assigneeAgentId?: string }, + ) { + const resolved = await ctx.routines.managed.reconcile(routineKey, companyId, overrides); + const routine = resolved.routine; + const wrongProject = routine && routine.projectId !== overrides.projectId; + const wrongAssignee = routine && overrides.assigneeAgentId && routine.assigneeAgentId !== overrides.assigneeAgentId; + if (wrongProject || wrongAssignee) { + return ctx.routines.managed.reset(routineKey, companyId, overrides); + } + return resolved; + } + ctx.actions.register("reconcile-managed-skills", async (params) => { const companyId = stringParam(params.companyId, "companyId"); return { @@ -478,44 +525,46 @@ const plugin = definePlugin({ ctx.actions.register("reconcile-managed-routines", async (params) => { const companyId = stringParam(params.companyId, "companyId"); + const { managedProject, managedAgent, overrides } = await reconcileBriefsRoutineRefs(companyId); return { managedRoutines: await Promise.all( - BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)), + BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => reconcileBriefsRoutine(routineKey, companyId, overrides)), ), + managedProject, + managedAgent, }; }); ctx.actions.register("reconcile-managed-routine", async (params) => { - return ctx.routines.managed.reconcile( + const companyId = stringParam(params.companyId, "companyId"); + const { overrides } = await reconcileBriefsRoutineRefs(companyId); + return reconcileBriefsRoutine( stringParam(params.routineKey, "routineKey"), - stringParam(params.companyId, "companyId"), - { - assigneeAgentId: typeof params.assigneeAgentId === "string" ? params.assigneeAgentId : null, - projectId: typeof params.projectId === "string" ? params.projectId : null, - }, + companyId, + overrides, ); }); ctx.actions.register("reset-managed-routine", async (params) => { + const companyId = stringParam(params.companyId, "companyId"); + const { overrides } = await reconcileBriefsRoutineRefs(companyId); return ctx.routines.managed.reset( stringParam(params.routineKey, "routineKey"), - stringParam(params.companyId, "companyId"), - { - assigneeAgentId: typeof params.assigneeAgentId === "string" ? params.assigneeAgentId : null, - projectId: typeof params.projectId === "string" ? params.projectId : null, - }, + companyId, + overrides, ); }); 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 [{ managedProject, projectId }, managedAgent, managedSkills] = await Promise.all([ + ensureBriefsProjectReady(companyId), + ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId), + Promise.all(BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reconcile(skillKey, companyId))), + ]); + const overrides = managedRoutineOverrides({ projectId, agentId: managedAgent.agentId }); const managedRoutines = await Promise.all( - BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.reconcile(routineKey, companyId)), + BRIEFS_MANAGED_ROUTINE_KEYS.map((routineKey) => reconcileBriefsRoutine(routineKey, companyId, overrides)), ); return { managedProject, managedSkills, managedAgent, managedRoutines }; }); @@ -573,9 +622,10 @@ const plugin = definePlugin({ companyId, typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, ); + const { projectId } = await ensureBriefsProjectReady(companyId); return ctx.routines.managed.run(routineKey, companyId, { assigneeAgentId, - projectId: typeof input.projectId === "string" ? input.projectId : null, + projectId, variables: nextVariables, }); }); @@ -588,7 +638,7 @@ const plugin = definePlugin({ companyId, typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, ); - const projectId = typeof input.projectId === "string" ? input.projectId : null; + const { projectId } = await ensureBriefsProjectReady(companyId); const routineKeys = BRIEFS_MANAGED_ROUTINE_KEYS.filter((routineKey) => routineKey !== MANUAL_REFRESH_ROUTINE_KEY); const runs = []; for (const routineKey of routineKeys) { @@ -609,9 +659,10 @@ const plugin = definePlugin({ companyId, typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, ); + const { projectId } = await ensureBriefsProjectReady(companyId); return ctx.routines.managed.run(MANUAL_REFRESH_ROUTINE_KEY, companyId, { assigneeAgentId, - projectId: typeof input.projectId === "string" ? input.projectId : null, + projectId, variables: { userId: scopedUserId(input, request), rootIssueId, @@ -636,6 +687,23 @@ const plugin = definePlugin({ return { ok: true }; }); + ctx.actions.register("dismiss-card", async (params, request) => { + const raw = bridgeParams(params); + const input = dismissBriefCardInputSchema.parse({ + ...raw, + userId: scopedUserId(raw, request), + }); + await store.dismissCard(input); + await ctx.activity.log({ + companyId: input.companyId, + message: "Dismissed briefing card", + entityType: "plugin:briefs:card", + entityId: input.cardId, + metadata: { userId: input.userId }, + }); + return { ok: true }; + }); + ctx.actions.register("update-preferences", async (params, request) => { const raw = bridgeParams(params); const input = updateBriefPreferencesInputSchema.parse({ diff --git a/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts b/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts index 085f5d28712..468561e5283 100644 --- a/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts +++ b/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts @@ -23,6 +23,7 @@ const bridgeActor = { describe("Briefs managed resources", () => { it("declares the Briefing Analyst, skills, routines, and agent tools", () => { expect(manifest.capabilities).toEqual(expect.arrayContaining([ + "projects.managed", "agents.resume", "agents.managed", "skills.managed", @@ -51,10 +52,25 @@ describe("Briefs managed resources", () => { expect(manifest.agents?.[0]?.instructions?.content).toContain("executive standup updates"); expect(manifest.agents?.[0]?.instructions?.content).toContain("Do not put issue identifiers"); expect(manifest.agents?.[0]?.instructions?.content).toContain("focus more on what is left to do"); + expect(manifest.agents?.[0]?.instructions?.content).toContain("Dismissed cards are intentional user feedback"); + expect(manifest.skills?.find((skill) => skill.skillKey === "briefs-discover-cards")?.markdown).toContain("includeHidden: true"); expect(manifest.skills?.find((skill) => skill.skillKey === "briefs-update-cards")?.markdown).toContain("refresh every visible card"); + expect(manifest.skills?.find((skill) => skill.skillKey === "briefs-update-cards")?.markdown).toContain("Hidden cards were dismissed by the user"); + expect(manifest.routines?.find((routine) => routine.routineKey === "briefs-discover-cards")?.description).toContain("skip hidden/dismissed roots"); expect(manifest.routines?.find((routine) => routine.routineKey === "briefs-update-cards")?.description).toContain("manual/API runs are rewrite passes"); expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual([...BRIEFS_MANAGED_SKILL_KEYS]); + expect(manifest.projects).toEqual([ + expect.objectContaining({ + projectKey: BRIEFS_PROJECT_KEY, + displayName: "Briefs", + status: "in_progress", + }), + ]); expect(manifest.routines?.map((routine) => routine.routineKey)).toEqual([...BRIEFS_MANAGED_ROUTINE_KEYS]); + expect(manifest.routines?.every((routine) => + routine.projectRef?.resourceKind === "project" + && routine.projectRef.resourceKey === BRIEFS_PROJECT_KEY + )).toBe(true); 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 }, @@ -131,7 +147,7 @@ describe("Briefs managed resources", () => { 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[] }>; + managedRoutines: Array<{ status: string; routineId: string | null; routine: { projectId: string | null } | null; missingRefs: unknown[] }>; }>("reconcile-managed-resources", { companyId }); expect(result.managedProject).toMatchObject({ status: "created" }); @@ -141,10 +157,75 @@ describe("Briefs managed resources", () => { for (const routine of result.managedRoutines) { expect(routine.status).toBe("created"); expect(routine.routineId).toBeTruthy(); + expect(routine.routine?.projectId).toBe(result.managedProject.projectId); expect(routine.missingRefs).toEqual([]); } }); + it("reconciles managed routines through the plugin-managed project from settings", 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 }; + managedRoutines: Array<{ status: string; routineId: string | null; routine: { projectId: string | null } | null; missingRefs: unknown[] }>; + }>("reconcile-managed-routines", { companyId }); + + expect(result.managedProject).toMatchObject({ status: "created" }); + expect(result.managedProject.projectId).toBeTruthy(); + expect(result.managedAgent.agentId).toBeTruthy(); + expect(result.managedRoutines).toHaveLength(3); + for (const routine of result.managedRoutines) { + expect(routine.status).toBe("created"); + expect(routine.routine?.projectId).toBe(result.managedProject.projectId); + expect(routine.missingRefs).toEqual([]); + } + }); + + it("ignores caller-supplied project overrides when repairing Briefs routines", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup?.(harness.ctx); + + const routine = await harness.performAction<{ + status: string; + routineId: string | null; + routine: { projectId: string | null } | null; + missingRefs: unknown[]; + }>("reconcile-managed-routine", { + companyId, + routineKey: MANUAL_REFRESH_ROUTINE_KEY, + projectId: "operator-selected-project", + }); + const managedProject = await harness.ctx.projects.managed.get(BRIEFS_PROJECT_KEY, companyId); + + expect(routine.status).toBe("created"); + expect(routine.routineId).toBeTruthy(); + expect(routine.routine?.projectId).toBe(managedProject.projectId); + expect(routine.routine?.projectId).not.toBe("operator-selected-project"); + expect(routine.missingRefs).toEqual([]); + }); + + it("repairs legacy Briefs routines that are not linked to the plugin-managed project", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup?.(harness.ctx); + const managedProject = await harness.ctx.projects.managed.reconcile(BRIEFS_PROJECT_KEY, companyId); + const managedAgent = await harness.ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId); + const legacy = await harness.ctx.routines.managed.reconcile(MANUAL_REFRESH_ROUTINE_KEY, companyId, { + assigneeAgentId: managedAgent.agentId, + projectId: "legacy-project", + }); + expect(legacy.routine?.projectId).toBe("legacy-project"); + + const result = await harness.performAction<{ + managedRoutines: Array<{ status: string; resourceKey: string; routine: { projectId: string | null } | null }>; + }>("reconcile-managed-resources", { companyId }); + const repaired = result.managedRoutines.find((routine) => routine.resourceKey === MANUAL_REFRESH_ROUTINE_KEY); + + expect(repaired?.status).toBe("reset"); + expect(repaired?.routine?.projectId).toBe(managedProject.projectId); + }); + it("resumes the managed analyst before running user briefing routines", async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup?.(harness.ctx); @@ -153,12 +234,18 @@ describe("Briefs managed resources", () => { const before = await harness.ctx.agents.managed.get(BRIEFING_ANALYST_AGENT_KEY, companyId); expect(before.agent?.status).toBe("paused"); + const managedProject = await harness.ctx.projects.managed.get(BRIEFS_PROJECT_KEY, companyId); const result = await harness.performAction<{ runs: Array<{ status: string; triggerPayload: { variables: Record } | null }>; - }>("run-briefing-routines", { companyId }, { actor: bridgeActor }); + }>("run-briefing-routines", { companyId, projectId: "operator-selected-project" }, { actor: bridgeActor }); const after = await harness.ctx.agents.managed.get(BRIEFING_ANALYST_AGENT_KEY, companyId); expect(after.agent?.status).toBe("idle"); + for (const routineKey of BRIEFS_MANAGED_ROUTINE_KEYS.filter((routineKey) => routineKey !== MANUAL_REFRESH_ROUTINE_KEY)) { + const routine = await harness.ctx.routines.managed.get(routineKey, companyId); + expect(routine.routine?.projectId).toBe(managedProject.projectId); + expect(routine.routine?.projectId).not.toBe("operator-selected-project"); + } expect(result.runs).toHaveLength(2); expect(result.runs.map((run) => run.status)).toEqual(["queued", "queued"]); expect(result.runs.map((run) => run.triggerPayload?.variables?.userId)).toEqual([ @@ -180,9 +267,14 @@ describe("Briefs managed resources", () => { cardId: "card-1", pinned: true, }, context)).rejects.toThrow("Briefs user scope mismatch"); + await expect(harness.performAction("dismiss-card", { + ...victimParams, + cardId: "card-1", + }, context)).rejects.toThrow("Briefs user scope mismatch"); await expect(harness.performAction("update-preferences", { ...victimParams, cadence: "daily", }, context)).rejects.toThrow("Briefs user scope mismatch"); }); + }); diff --git a/packages/plugins/plugin-briefs/tests/store.spec.ts b/packages/plugins/plugin-briefs/tests/store.spec.ts new file mode 100644 index 00000000000..23816de2e80 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/store.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { PluginDatabaseClient } from "@paperclipai/plugin-sdk"; + +import { + buildDeterministicBriefCard, + type BriefsIssueInput, + type BriefsSourceBundle, +} from "../src/deterministic-card-service.js"; +import { createBriefsStore } from "../src/store.js"; + +const companyId = "11111111-1111-4111-8111-111111111111"; +const userId = "user-1"; +const rootIssueId = "22222222-2222-4222-8222-222222222222"; + +function ids() { + let counter = 0; + return () => `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`; +} + +function issue(overrides: Partial = {}): BriefsIssueInput { + return { + id: rootIssueId, + companyId, + parentId: null, + title: "Dismissed card source", + identifier: "PAP-1", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: userId, + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T11:00:00.000Z", + ...overrides, + }; +} + +function bundle(): BriefsSourceBundle { + return { + companyId, + userId, + rootIssueId, + title: "Dismissed card source", + groupingDescription: "Issue tree rooted at PAP-1: Dismissed card source", + issues: [issue()], + relations: {}, + activeRuns: {}, + runs: [], + comments: [], + documents: [], + interactions: [], + approvals: [], + workProducts: [], + relevantAgentIds: [], + }; +} + +type CardRow = { + id: string; + company_id: string; + user_id: string; + slug: string; + pinned: boolean; + hidden: boolean; +}; + +function createFakeDb(): PluginDatabaseClient { + const cards = new Map(); + const key = (company: unknown, user: unknown, slug: unknown) => `${company}:${user}:${slug}`; + + return { + namespace: "plugin_briefs_test", + async query(sql: string, params: unknown[] = []): Promise { + if (sql.includes("SELECT id, pinned, hidden FROM plugin_briefs_test.briefs_cards")) { + const row = cards.get(key(params[0], params[1], params[2])); + return (row ? [{ id: row.id, pinned: row.pinned, hidden: row.hidden }] : []) as T[]; + } + return []; + }, + async execute(sql: string, params: unknown[] = []): Promise<{ rowCount: number }> { + if (sql.includes("INSERT INTO plugin_briefs_test.briefs_cards")) { + const rowKey = key(params[1], params[2], params[3]); + const existing = cards.get(rowKey); + cards.set(rowKey, { + id: existing?.id ?? String(params[0]), + company_id: String(params[1]), + user_id: String(params[2]), + slug: String(params[3]), + pinned: Boolean(existing?.pinned || params[9]), + hidden: Boolean(existing?.hidden || params[10]), + }); + } + if (sql.includes("SET hidden = true, pinned = false")) { + for (const [rowKey, row] of cards) { + if (row.company_id === params[0] && row.user_id === params[1] && row.id === params[2]) { + cards.set(rowKey, { ...row, hidden: true, pinned: false }); + } + } + } + return { rowCount: 1 }; + }, + }; +} + +describe("Briefs store", () => { + it("keeps dismissed cards hidden when the same card is regenerated", async () => { + const store = createBriefsStore(createFakeDb()); + const first = await store.saveCard(buildDeterministicBriefCard(bundle(), { + now: "2026-05-22T12:00:00.000Z", + idFactory: ids(), + summaryStatus: "ok", + summaryParagraph: "The source area is being prepared for follow-up. Next action is to finish the implementation pass.", + summaryModel: "test", + allowGeneratedSummary: true, + })); + + await store.dismissCard({ companyId, userId, cardId: first.id }); + + const regenerated = await store.saveCard(buildDeterministicBriefCard(bundle(), { + now: "2026-05-22T13:00:00.000Z", + idFactory: ids(), + summaryStatus: "ok", + summaryParagraph: "A newer generated summary should not resurrect the dismissed card. Next action is still hidden from the dashboard.", + summaryModel: "test", + allowGeneratedSummary: true, + })); + + expect(regenerated.id).toBe(first.id); + expect(regenerated.hidden).toBe(true); + expect(regenerated.pinned).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx index de8c5ce44d7..f1e2cee8563 100644 --- a/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx @@ -9,8 +9,8 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { linkProps: (to: string) => ({ href: to, onClick: () => {} }), }), usePluginAction: () => vi.fn(async () => ({ ok: true })), - IssueRow: ({ issue, trailingMeta }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode }) => ( - {issue.identifier} {issue.title} {trailingMeta} + IssueRow: ({ issue, trailingMeta, className }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode; className?: string }) => ( + {issue.identifier} {issue.title} {trailingMeta} ), usePluginData: () => ({ data: null, loading: false, error: null, refresh: () => {} }), usePluginToast: () => vi.fn(), @@ -118,4 +118,13 @@ describe("BriefCardView", () => { const unpinned = makeCard({ pinned: false }); expect(renderCard(unpinned)).toMatch(/aria-label="Pin card"/); }); + + it("renders a dismiss action and removes issue-row bottom dividers", () => { + const card = makeCard(); + const html = renderCard(card); + + expect(html).toContain('aria-label="Dismiss briefing card"'); + expect(html).toContain("Dismiss"); + expect(html).toContain("!border-b-0"); + }); }); diff --git a/packages/plugins/plugin-briefs/tests/ui/briefing-interactions.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/briefing-interactions.spec.tsx new file mode 100644 index 00000000000..d78e42aa4a7 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/ui/briefing-interactions.spec.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { BriefCard } from "../../src/contracts.js"; +import { makeCard, makeSnapshot, makeTaskRow } from "./fixtures.js"; + +const actionCalls = vi.hoisted(() => ({ + pin: vi.fn(async () => ({ ok: true })), + dismiss: vi.fn(async () => ({ ok: true })), +})); + +let mockPageData: { cards: BriefCard[]; fetchedAt: string }; + +vi.mock("@paperclipai/plugin-sdk/ui", () => { + return { + ManagedRoutinesList: () => null, + IssueRow: ({ issue, trailingMeta, className }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode; className?: string }) => ( + {issue.identifier} {issue.title} {trailingMeta} + ), + useHostNavigation: () => ({ + resolveHref: (to: string) => to, + navigate: () => {}, + linkProps: (to: string) => ({ href: to, onClick: () => {} }), + }), + usePluginAction: (key: string) => { + if (key === "pin-card") return actionCalls.pin; + if (key === "dismiss-card") return actionCalls.dismiss; + return vi.fn(async () => ({ ok: true })); + }, + usePluginData: (key: string) => { + if (key === "page") { + return { data: mockPageData, loading: false, error: null, refresh: vi.fn() }; + } + return { data: null, loading: false, error: null, refresh: vi.fn() }; + }, + usePluginToast: () => vi.fn(), + }; +}); + +import { BriefingPage } from "../../src/ui/app.js"; + +const hostContext = { + companyId: "company-1", + companyPrefix: "PAP", + projectId: null, + entityId: null, + entityType: null, + userId: "user-1", +} as const; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makeCards(): BriefCard[] { + const newest = makeCard({ + id: "card-new", + title: "Newest unpinned work", + pinned: false, + lastMeaningfulEventAt: "2026-05-23T11:00:00.000Z", + snapshot: makeSnapshot({ + taskRows: [makeTaskRow({ identifier: "PAP-1", titleLine: "Newest issue" })], + }), + }); + const older = makeCard({ + id: "card-old", + title: "Older unpinned work", + pinned: false, + lastMeaningfulEventAt: "2026-05-22T11:00:00.000Z", + snapshot: makeSnapshot({ + taskRows: [makeTaskRow({ identifier: "PAP-2", titleLine: "Older issue" })], + }), + }); + return [newest, older]; +} + +describe("BriefingPage interactions", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + mockPageData = { cards: makeCards(), fetchedAt: "2026-05-23T12:00:00.000Z" }; + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + function renderPage() { + flushSync(() => { + root.render(); + }); + } + + it("pins optimistically and moves the card to the top without a full refresh", async () => { + renderPage(); + + const oldCard = [...container.querySelectorAll("article[data-briefs-card]")] + .find((node) => node.textContent?.includes("Older unpinned work")); + const pinButton = oldCard?.querySelector('button[aria-label="Pin card"]') as HTMLButtonElement | null; + expect(pinButton).not.toBeNull(); + + flushSync(() => { + pinButton?.click(); + }); + + const titles = [...container.querySelectorAll("article[data-briefs-card] h3")].map((node) => node.textContent); + expect(titles[0]).toBe("Older unpinned work"); + expect(actionCalls.pin).toHaveBeenCalledWith(expect.objectContaining({ + cardId: "card-old", + pinned: true, + })); + }); + + it("starts the dismiss fade immediately and persists the hidden card", async () => { + renderPage(); + + const firstCard = container.querySelector("article[data-briefs-card]") as HTMLElement | null; + const dismissButton = firstCard?.querySelector('button[aria-label="Dismiss briefing card"]') as HTMLButtonElement | null; + expect(firstCard).not.toBeNull(); + expect(dismissButton).not.toBeNull(); + + flushSync(() => { + dismissButton?.click(); + }); + + expect(firstCard?.style.opacity).toBe("0"); + expect(actionCalls.dismiss).toHaveBeenCalledWith(expect.objectContaining({ + cardId: "card-new", + })); + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx index 74c2d659c6c..bd61b33ad8e 100644 --- a/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx @@ -27,6 +27,14 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { navigate: () => {}, linkProps: (to: string) => ({ href: to, onClick: () => {} }), }), + useHostContext: () => ({ + companyId: "company-1", + companyPrefix: "PAP", + projectId: null, + entityId: null, + entityType: null, + userId: "user-1", + }), usePluginAction: () => vi.fn(async () => ({ ok: true })), usePluginData: (key: string) => { if (key === "page") { @@ -63,6 +71,16 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { routine: { id: "routine-2", title: "Update Briefing cards for {{userId}}", status: "paused", projectId: "project-1", assigneeAgentId: "agent-1" }, }, ], + preferences: { + companyId: "company-1", + userId: "user-1", + cadence: "hourly", + retentionDays: 7, + doneRetentionHours: 72, + staleAfterDays: 7, + maxUnpinnedCards: 30, + scope: "user", + }, agentOptions: [{ id: "agent-1", name: "Briefing Analyst", icon: "newspaper" }], projectOptions: [{ id: "project-1", name: "Briefs", color: "#0f766e" }], }, @@ -101,6 +119,8 @@ describe("BriefingPage", () => { const html = renderPage(gallery()); expect(html).toContain("data-briefs-list"); + expect(html).toContain("data-briefs-card-grid"); + expect(html).toContain("grid-template-columns:repeat(2, minmax(0, 1fr))"); expect(html).toContain("Recent work and next steps"); expect(html).not.toContain("data-briefs-mobile-tabs"); expect(html).not.toContain("data-briefs-section"); @@ -136,6 +156,8 @@ describe("BriefingPage", () => { expect(html).toContain("data-briefs-page-header"); expect(html).toContain("data-briefs-page-meta"); + expect(html).toContain('aria-label="Briefing settings"'); + expect(html).toContain("/PAP/instance/settings/plugins/paperclipai.plugin-briefs"); expect(html).not.toContain("Preferences"); expect(html).not.toContain("Durable cards for areas of work"); }); @@ -148,13 +170,14 @@ describe("BriefingPage", () => { }); describe("SidebarLink", () => { - it("renders the Briefing sidebar entry with an icon and company route", () => { + it("renders the Briefing sidebar entry with an icon, company route, and no badge count", () => { const html = renderToStaticMarkup(); expect(html).toContain("Briefing"); expect(html).toContain('href="/briefs"'); expect(html).toContain("data-briefs-sidebar-icon"); expect(html).toContain("color:currentColor"); + expect(html).not.toContain("briefs need your attention"); }); }); @@ -163,6 +186,9 @@ describe("SettingsPage", () => { const html = renderToStaticMarkup(); expect(html).toContain("Managed resources"); + expect(html).toContain("Dashboard settings"); + expect(html).toContain("Maximum unpinned cards"); + expect(html).toContain("Mark stale after days"); expect(html).toContain("Briefing Analyst"); expect(html).toContain("data-managed-routines-list"); expect(html).toContain("Discover Briefing cards"); diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index b511ff68303..7db1a776af4 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1147,13 +1147,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const now = new Date(); const agentRef = declaration.assigneeRef; const projectRef = declaration.projectRef; - const assigneeAgentId = overrides?.assigneeAgentId - ?? (agentRef?.resourceKind === "agent" + const assigneeAgentId = overrides && "assigneeAgentId" in overrides + ? overrides.assigneeAgentId ?? null + : (agentRef?.resourceKind === "agent" ? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id : null) ?? null; - const projectId = overrides?.projectId - ?? (projectRef?.resourceKind === "project" + const projectId = overrides && "projectId" in overrides + ? overrides.projectId ?? null + : (projectRef?.resourceKind === "project" ? [...projects.values()].find((project) => ( isInCompany(project, companyId) && project.managedByPlugin?.pluginKey === manifest.id @@ -1240,7 +1242,62 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, async reset(routineKey, companyId, overrides) { const resolved = await this.reconcile(routineKey, companyId, overrides); - return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution; + const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey); + if (!declaration || !resolved.routine) { + return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution; + } + const agentRef = declaration.assigneeRef; + const projectRef = declaration.projectRef; + const assigneeAgentId = overrides && "assigneeAgentId" in overrides + ? overrides.assigneeAgentId ?? null + : (agentRef?.resourceKind === "agent" + ? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id + : null) + ?? null; + const projectId = overrides && "projectId" in overrides + ? overrides.projectId ?? null + : (projectRef?.resourceKind === "project" + ? [...projects.values()].find((project) => ( + isInCompany(project, companyId) + && project.managedByPlugin?.pluginKey === manifest.id + && project.managedByPlugin?.resourceKey === projectRef.resourceKey + ))?.id + : null) + ?? null; + const missingRefs: NonNullable = []; + if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id }); + if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id }); + if (missingRefs.length > 0) { + return { + ...resolved, + status: "missing_refs", + missingRefs, + } satisfies PluginManagedRoutineResolution; + } + const now = new Date(); + const routine = { + ...resolved.routine, + projectId, + goalId: declaration.goalId ?? null, + title: declaration.title, + description: declaration.description ?? null, + assigneeAgentId, + priority: declaration.priority ?? "medium", + status: declaration.status ?? (assigneeAgentId ? "active" : "paused"), + concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", + catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + variables: declaration.variables ?? [], + updatedAt: now, + managedByPlugin: resolved.routine.managedByPlugin + ? { + ...resolved.routine.managedByPlugin, + defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null }, + updatedAt: now, + } + : resolved.routine.managedByPlugin, + } as Routine; + routines.set(routine.id, routine); + return { ...resolved, routine, status: "reset", missingRefs: [] } satisfies PluginManagedRoutineResolution; }, async update(routineKey, companyId, patch) { const resolved = await this.get(routineKey, companyId); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 905804a9738..e539c77946c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -141,6 +141,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/PluginPage.test.tsx b/ui/src/pages/PluginPage.test.tsx index be8f922ccdc..44b829baeb9 100644 --- a/ui/src/pages/PluginPage.test.tsx +++ b/ui/src/pages/PluginPage.test.tsx @@ -169,7 +169,7 @@ describe("PluginPage", () => { await flushReact(); }); - it("hides the Back button for the Briefs dashboard while keeping settings available", async () => { + it("hides the host Back and settings buttons for the Briefs dashboard", async () => { mockParams.pluginRoutePath = "briefs"; mockPluginsApi.listUiContributions.mockResolvedValue([ pageContribution({ @@ -192,7 +192,7 @@ describe("PluginPage", () => { expect(container.textContent).not.toContain("Back"); expect(container.querySelector('a[href="/PAP/dashboard"]')).toBeNull(); - expect(container.querySelector('a[href="/instance/settings/plugins/plugin-briefs"]')).not.toBeNull(); + expect(container.querySelector('a[href="/instance/settings/plugins/plugin-briefs"]')).toBeNull(); root.unmount(); await flushReact(); diff --git a/ui/src/pages/PluginPage.tsx b/ui/src/pages/PluginPage.tsx index 33e6edfcaea..db4a399e7d4 100644 --- a/ui/src/pages/PluginPage.tsx +++ b/ui/src/pages/PluginPage.tsx @@ -161,20 +161,18 @@ export function PluginPage() { return ; } - const hideBackButton = pageSlot.pluginKey === "paperclipai.plugin-briefs"; + const isBriefsDashboard = pageSlot.pluginKey === "paperclipai.plugin-briefs"; return (
- {!routeSidebarActive && ( + {!routeSidebarActive && !isBriefsDashboard && (
- {hideBackButton ? : ( - - )} +