diff --git a/packages/plugins/plugin-briefs/src/contracts.ts b/packages/plugins/plugin-briefs/src/contracts.ts index b8401194b4c..4eeed614908 100644 --- a/packages/plugins/plugin-briefs/src/contracts.ts +++ b/packages/plugins/plugin-briefs/src/contracts.ts @@ -65,7 +65,7 @@ export const briefSnapshotSchema = z.object({ companyId: z.string().min(1), userId: z.string().min(1), cardId: z.string().min(1), - summaryParagraph: z.string().max(260).nullable(), + summaryParagraph: z.string().max(900).nullable(), summaryStatus: briefSummaryStatusSchema, summaryModel: z.string().min(1).nullable(), summaryTokensIn: z.number().int().nonnegative().nullable(), diff --git a/packages/plugins/plugin-briefs/src/deterministic-card-service.ts b/packages/plugins/plugin-briefs/src/deterministic-card-service.ts index 5798882c40e..13ef2f6c2e8 100644 --- a/packages/plugins/plugin-briefs/src/deterministic-card-service.ts +++ b/packages/plugins/plugin-briefs/src/deterministic-card-service.ts @@ -148,6 +148,7 @@ export type DeterministicBriefOptions = { now?: Date | string; pinned?: boolean; hidden?: boolean; + title?: string | null; summaryStatus?: BriefSummaryStatus; summaryParagraph?: string | null; summaryFailureReason?: BriefSummaryFailureReason | null; @@ -460,6 +461,26 @@ function toTaskRow(source: BriefCardSource): BriefTaskRow | null { }; } +function taskRowDedupeKey(row: BriefTaskRow): string { + return row.issueId || row.identifier || `${row.kind}:${row.sourceId}`; +} + +function taskRowTime(row: BriefTaskRow): number { + return new Date(row.eventAt).getTime() || 0; +} + +export function dedupeBriefTaskRows(rows: BriefTaskRow[]): BriefTaskRow[] { + const byKey = new Map(); + for (const row of rows) { + const key = taskRowDedupeKey(row); + const current = byKey.get(key); + if (!current || taskRowTime(row) > taskRowTime(current)) { + byKey.set(key, row); + } + } + return Array.from(byKey.values()).sort((a, b) => taskRowTime(b) - taskRowTime(a)); +} + function issueRightTag(issue: BriefsIssueInput, bundle: BriefsSourceBundle, treeIssueIds: Set): { tag: string; intraTreeBlocked: boolean | null } { if (issue.status === "blocked") { const relation = bundle.relations?.[issue.id]; @@ -673,14 +694,14 @@ export function buildDeterministicBriefCard(bundle: BriefsSourceBundle, options: ); const slug = slugifyBriefGrouping(groupingDescription); const sources = buildSources(bundle, cardId, idFactory); - const taskRows = sources.map(toTaskRow).filter((row): row is BriefTaskRow => Boolean(row)).slice(0, 3); + const taskRows = dedupeBriefTaskRows(sources.map(toTaskRow).filter((row): row is BriefTaskRow => Boolean(row))).slice(0, 3); const staleAt = addMs(lastEventMs, days(options.preferences?.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS)); const retentionMs = stateResult.state === "done" ? hours(options.preferences?.doneRetentionHours ?? DEFAULT_DONE_RETENTION_HOURS) : days(options.preferences?.retentionDays ?? DEFAULT_RETENTION_DAYS); const pinned = options.pinned ?? false; const summaryParagraph = summaryStatus === "ok" - ? truncate(options.summaryParagraph ?? fallbackSummary(stateResult.state, taskRows), 260) + ? truncate(options.summaryParagraph ?? fallbackSummary(stateResult.state, taskRows), 900) : null; const snapshot: BriefSnapshot = { @@ -707,7 +728,7 @@ export function buildDeterministicBriefCard(bundle: BriefsSourceBundle, options: companyId: bundle.companyId, userId: bundle.userId, slug, - title: truncate(bundle.title ?? root.title, 90), + title: truncate(options.title ?? bundle.title ?? root.title, 90), groupingDescription, rootIssueId: root.id, state: stateResult.state, diff --git a/packages/plugins/plugin-briefs/src/manifest.ts b/packages/plugins/plugin-briefs/src/manifest.ts index 19d7bf32aa1..58a6ceb7e2a 100644 --- a/packages/plugins/plugin-briefs/src/manifest.ts +++ b/packages/plugins/plugin-briefs/src/manifest.ts @@ -52,6 +52,7 @@ const manifest: PaperclipPluginManifestV1 = { "projects.managed", "goals.read", "agents.read", + "agents.resume", "agents.managed", "issues.read", "issue.subtree.read", @@ -65,6 +66,7 @@ const manifest: PaperclipPluginManifestV1 = { "activity.log.write", "plugin.state.read", "plugin.state.write", + "instance.settings.register", "ui.sidebar.register", "ui.page.register" ], @@ -84,10 +86,12 @@ const manifest: PaperclipPluginManifestV1 = { 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.", + capabilities: "Maintains source-linked Briefing cards, refreshes deterministic card state, and writes grounded card titles and descriptions from source rows.", adapterType: "codex_local", adapterPreference: ["codex_local", "claude_local", "gemini_local", "opencode_local", "cursor", "pi_local"], adapterConfig: { + dangerouslyBypassApprovalsAndSandbox: true, + extraArgs: ["--skip-git-repo-check"], paperclipSkillSync: { desiredSkills: BRIEFS_MANAGED_SKILL_CANONICAL_KEYS } @@ -103,7 +107,7 @@ const manifest: PaperclipPluginManifestV1 = { pluginTools: [PLUGIN_ID] }, status: "paused", - budgetMonthlyCents: 0, + budgetMonthlyCents: 500, instructions: { entryFile: "AGENTS.md", content: BRIEFING_ANALYST_INSTRUCTIONS, @@ -261,13 +265,14 @@ const manifest: PaperclipPluginManifestV1 = { { 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.", + description: "Build and save a Briefing card for one Paperclip issue tree. Call once to inspect deterministic rows, then again with title, summary, and allowGeneratedSummary: true.", parametersSchema: { type: "object", properties: { companyId: { type: "string" }, userId: { type: "string" }, rootIssueId: { type: "string" }, + title: { type: "string" }, summary: { type: "string" }, summaryModel: { type: "string" }, summaryTokensIn: { type: "number" }, @@ -295,6 +300,12 @@ const manifest: PaperclipPluginManifestV1 = { displayName: "Briefing", exportName: "BriefingPage", routePath: BRIEFS_ROUTE_PATH + }, + { + type: "settingsPage", + id: "briefs-settings", + displayName: "Briefing", + exportName: "SettingsPage" } ] } diff --git a/packages/plugins/plugin-briefs/src/safety.ts b/packages/plugins/plugin-briefs/src/safety.ts index 5b7a8622b0c..7f984b49d52 100644 --- a/packages/plugins/plugin-briefs/src/safety.ts +++ b/packages/plugins/plugin-briefs/src/safety.ts @@ -35,6 +35,13 @@ const PROMPT_INJECTION_PATTERNS = [ /\breveal (the )?(secret|token|credential|api key|password)\b/i, ]; +const ISSUE_IDENTIFIER_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/; +const LOW_LEVEL_STATUS_LABEL_PATTERNS = [ + /\b(is|are|was|were|remains?|still)\s+(todo|done|stale|blocked|in[- ]review|in[- ]progress)\b/i, + /\b(todo|done|stale|blocked|in[- ]review|in[- ]progress)\s+(issue|row|card|state|status)\b/i, + /\bwaiting[- ]user|waiting[- ]reviewer\b/i, +]; + type SummaryFacts = { hasBlocked: boolean; hasWaitingUser: boolean; @@ -134,6 +141,11 @@ function unsupportedOwnerClaim(summary: string): boolean { return /\b(owner|owned by|assignee|assigned to|responsible agent|responsible user)\b/i.test(summary); } +function usesLowLevelIssueLabels(summary: string): boolean { + return ISSUE_IDENTIFIER_PATTERN.test(summary) + || LOW_LEVEL_STATUS_LABEL_PATTERNS.some((pattern) => pattern.test(summary)); +} + function fallbackOptions(reason: BriefSummaryFailureReason, generated: Pick): DeterministicBriefOptions { return { summaryStatus: "fallback", @@ -143,6 +155,13 @@ function fallbackOptions(reason: BriefSummaryFailureReason, generated: Pick = { - issue: "■", - issue_tree: "◰", + issue: "●", + issue_tree: "●", comment: "❝", run: "▷", document: "▤", @@ -70,21 +64,23 @@ const sourceKindIcon: Record = { 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" }, -]; +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 ROUTINE_FALLBACKS: Record = { + [DISCOVER_CARDS_ROUTINE_KEY]: { title: "Discover Briefing cards", schedule: "0 */6 * * *" }, + [UPDATE_CARDS_ROUTINE_KEY]: { title: "Update Briefing cards", schedule: "0 * * * *" }, + [MANUAL_REFRESH_ROUTINE_KEY]: { title: "Refresh Briefing card", schedule: "Manual" }, +}; 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 params = useMemo(() => ({ companyId: context.companyId ?? "" }), [context.companyId]); + const enabled = Boolean(params.companyId); const { data } = usePluginData<{ cards: BriefCard[] }>("cards", enabled ? params : undefined); const cards = data?.cards ?? []; @@ -97,8 +93,8 @@ export function SidebarLink({ context }: PluginSidebarProps) { style={{ display: "flex", alignItems: "center", - gap: 8, - padding: "6px 10px", + gap: 10, + padding: "8px 12px", borderRadius: 6, color: tokens.fg, textDecoration: "none", @@ -106,13 +102,34 @@ export function SidebarLink({ context }: PluginSidebarProps) { fontFamily: fontStack, }} > - + Briefing {count > 0 ? : null} ); } +function BriefingSidebarIcon() { + return ( + + ); +} + function AttentionBadge({ count }: { count: number }) { return ( ({ companyId: context.companyId ?? "", userId: context.userId ?? "" }), [context.companyId, context.userId]); - const enabled = Boolean(params.companyId && params.userId); + const params = useMemo(() => ({ companyId: context.companyId ?? "" }), [context.companyId]); + const enabled = Boolean(params.companyId); const { data, loading, error, refresh } = usePluginData("page", enabled ? params : undefined); if (!enabled) { return ( ); @@ -156,7 +173,6 @@ export function BriefingPage({ context }: PluginPageProps) { : null} action={} - preferences={data ? : null} > {error ? ( @@ -174,25 +190,345 @@ export function BriefingPage({ context }: PluginPageProps) { ); } -function PageShell({ children, meta, action, preferences }: { +type ManagedAgent = { + status: string; + agentId?: string | null; + agent?: { id?: string; name?: string; status?: string | null; adapterType?: string | null; icon?: string | null } | null; + defaultDrift?: { entryFile: string; changedFiles: string[] } | null; +}; + +type ManagedProject = { + status: string; + projectId?: string | null; + project?: { id?: string; name?: string; status?: string | null; color?: string | null } | null; +}; + +type ManagedSkill = { + status: string; + skillId?: string | null; + resourceKey?: string | null; + skill?: { id?: string; name?: string; key?: string; description?: string | null } | null; +}; + +type ManagedRoutine = { + status: string; + routineId?: string | null; + resourceKey?: string | null; + missingRefs?: Array<{ resourceKind: string; resourceKey: string }>; + routine?: { + id?: string; + title?: string; + status?: string; + projectId?: string | null; + assigneeAgentId?: string | null; + lastTriggeredAt?: string | Date | null; + managedByPlugin?: { pluginDisplayName?: string; resourceKey?: string } | null; + } | null; +}; + +type SettingsData = { + managedAgent: ManagedAgent; + managedProject: ManagedProject; + managedSkills: ManagedSkill[]; + managedRoutines: ManagedRoutine[]; + 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 }>; +}; + +export function SettingsPage({ context }: PluginSettingsPageProps) { + const params = useMemo(() => ({ companyId: context.companyId ?? "" }), [context.companyId]); + const enabled = Boolean(params.companyId); + const settings = usePluginData("settings", enabled ? params : undefined); + const reconcileResources = usePluginAction("reconcile-managed-resources"); + const reconcileRoutine = usePluginAction("reconcile-managed-routine"); + const updateRoutineStatus = usePluginAction("update-managed-routine-status"); + const runRoutine = usePluginAction("run-managed-routine"); + const runBriefingRoutines = usePluginAction("run-briefing-routines"); + const toast = usePluginToast(); + const [busy, setBusy] = useState(null); + + if (!enabled) { + return Choose a company to view Briefing settings.; + } + if (settings.loading && !settings.data) { + return Loading Briefing settings...; + } + if (settings.error) { + return {settings.error.message}; + } + if (!settings.data) { + return No settings available.; + } + + const data = settings.data; + 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); + + async function syncAll() { + setBusy("sync"); + try { + await reconcileResources({ companyId: params.companyId }); + toast({ tone: "success", title: "Managed resources synced" }); + settings.refresh(); + } catch (err) { + toast({ tone: "error", title: "Sync failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + async function runForCurrentUser() { + setBusy("run-all"); + try { + await runBriefingRoutines({ + companyId: params.companyId, + ...(context.userId ? { userId: context.userId } : {}), + assigneeAgentId, + projectId, + }); + toast({ tone: "success", title: "Briefing routines queued" }); + settings.refresh(); + } catch (err) { + toast({ tone: "error", title: "Routine run failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + async function runOne(routine: ManagedRoutinesListItem) { + if (routine.resourceKey === MANUAL_REFRESH_ROUTINE_KEY) { + toast({ tone: "warn", title: "Open a card to refresh one issue tree" }); + return; + } + setBusy(`run:${routine.key}`); + try { + await runRoutine({ + companyId: params.companyId, + 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" }); + settings.refresh(); + } catch (err) { + toast({ tone: "error", title: "Routine run failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + async function toggleRoutine(routine: ManagedRoutinesListItem, enabledNow: boolean) { + if (!routine.resourceKey) return; + setBusy(`status:${routine.key}`); + try { + await updateRoutineStatus({ + companyId: params.companyId, + routineKey: routine.resourceKey, + status: enabledNow ? "paused" : "active", + }); + toast({ tone: "success", title: enabledNow ? "Routine paused" : "Routine enabled" }); + settings.refresh(); + } catch (err) { + toast({ tone: "error", title: "Routine update failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + async function repairRoutine(routine: ManagedRoutinesListItem) { + if (!routine.resourceKey) return; + setBusy(`repair:${routine.key}`); + try { + await reconcileRoutine({ + companyId: params.companyId, + routineKey: routine.resourceKey, + assigneeAgentId, + projectId, + }); + toast({ tone: "success", title: "Routine reconciled" }); + settings.refresh(); + } catch (err) { + toast({ tone: "error", title: "Routine repair failed", body: err instanceof Error ? err.message : String(err) }); + } finally { + setBusy(null); + } + } + + return ( + +
+
+
+

Managed resources

+ {resourcesHealthy ? "Agent, project, skills, and routines are linked." : "Some managed resources need sync."} +
+
+ Sync resources + Run for me +
+
+ +
+ + + + +
+ +
+

Routines

+ +
+
+
+ ); +} + +function SettingsShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Tiny({ children }: { children: ReactNode }) { + return {children}; +} + +function Callout({ children, tone = "default" }: { children: ReactNode; tone?: "default" | "danger" }) { + const danger = tone === "danger"; + return ( +
+ {children} +
+ ); +} + +function SettingsButton({ children, onClick, loading = false, disabled = false, primary = false }: { + children: ReactNode; + onClick: () => void; + loading?: boolean; + disabled?: boolean; + primary?: boolean; +}) { + return ( + + ); +} + +function ResourceStatus({ label, status, detail }: { label: string; status: string; detail: string }) { + const ok = status === "resolved" || status === "created" || status === "relinked" || status === "reset"; + return ( +
+ {label} + {detail} + {statusLabel(status)} +
+ ); +} + +function BadgeLike({ tone, children }: { tone: "good" | "warn"; children: ReactNode }) { + const colors = tone === "good" ? toneColors.green : toneColors.warning; + return ( + + {children} + + ); +} + +function buildManagedRoutineItems(routines: ManagedRoutine[]): ManagedRoutinesListItem[] { + return routines.map((routine) => { + const key = routine.resourceKey ?? routine.routineId ?? "managed-routine"; + const fallback = ROUTINE_FALLBACKS[key] ?? { title: key, schedule: null }; + const status = routine.routine?.status ?? (routine.status === "missing" || routine.status === "missing_refs" ? "paused" : routine.status); + return { + key, + title: routine.routine?.title ?? fallback.title, + status, + routineId: routine.routineId ?? routine.routine?.id ?? null, + href: routine.routineId ? `/routines/${routine.routineId}` : null, + resourceKey: routine.resourceKey ?? key, + projectId: routine.routine?.projectId ?? null, + assigneeAgentId: routine.routine?.assigneeAgentId ?? null, + cronExpression: fallback.schedule, + lastRunAt: routine.routine?.lastTriggeredAt ?? null, + managedByPluginDisplayName: routine.routine?.managedByPlugin?.pluginDisplayName ?? "Briefs", + missingRefs: routine.missingRefs, + }; + }); +} + +function resourceReady(resource: { status: string }) { + return resource.status === "resolved" || resource.status === "created" || resource.status === "relinked" || resource.status === "reset"; +} + +function aggregateStatus(resources: Array<{ status: string }>) { + if (resources.length === 0) return "missing"; + return resources.every(resourceReady) ? "resolved" : "missing"; +} + +function statusLabel(status: string) { + return status.replace(/_/g, " "); +} + +function PageShell({ children, meta, action }: { 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}
); @@ -203,7 +539,7 @@ function PageMeta({ data }: { data: PageData }) { const pinned = active.filter((c) => c.pinned).length; return ( - {active.length} active · {pinned} pinned · refreshed {formatRelative(data.fetchedAt)} + {active.length} work {active.length === 1 ? "area" : "areas"} · {pinned} pinned · refreshed {formatRelative(data.fetchedAt)} ); } @@ -230,269 +566,21 @@ function RefreshButton({ onClick, loading }: { onClick: () => void; loading: boo ); } -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")); + const cards = useMemo(() => sortBriefCards(data.cards), [data.cards]); 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} +
+
+

+ Recent work and next steps

- {section.cards.length} - +
+ Sorted by the latest meaningful activity across the work areas Briefing is tracking. +
-
- {section.cards.map((card) => ( +
+ {cards.map((card) => ( ))}
@@ -501,13 +589,9 @@ function SectionView({ section, visibleOnMobile, onChanged }: { section: BriefSe } 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 taskRows = useMemo(() => dedupeTaskRows(card.snapshot.taskRows), [card.snapshot.taskRows]); const togglePin = useCallback(async () => { try { @@ -526,41 +610,25 @@ export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged: data-summary-status={card.snapshot.summaryStatus} data-pinned={card.pinned ? "true" : "false"} style={{ - position: "relative", background: tokens.card, border: `1px solid ${tokens.border}`, - borderRadius: 10, - padding: "12px 14px 12px 18px", + borderRadius: 8, + padding: "12px 14px", display: "flex", flexDirection: "column", - gap: 8, + gap: 10, minWidth: 0, - opacity: dim ? 0.78 : 1, }} > -
-

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

+ {card.title}

-
- + -
); } @@ -593,31 +661,9 @@ function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void ); } -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 (
@@ -625,25 +671,13 @@ function MetaRow({ card }: { card: BriefCard }) { {identifiers[0]} ) : null} {issueCount > 0 ? {issueCount} {issueCount === 1 ? "issue" : "issues"} : null} - {agentCount > 0 ? {agentCount} {agentCount === 1 ? "agent" : "agents"} : null} - {stamp ? · {stamp} : null} + {stamp ? Updated {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") { +function SummarySlot({ card }: { card: BriefCard }) { + if (card.snapshot.summaryStatus === "pending") { return (
@@ -651,169 +685,126 @@ function SummarySlot({ card, onRetry }: { card: BriefCard; onRetry: () => void }
); } - return ; -} - -function FallbackPanel({ card, onRetry }: { card: BriefCard; onRetry: () => void }) { - const reason = card.snapshot.summaryFailureReason ?? "model_error"; - const reasonLabel = formatFailureReason(reason); + const paragraphs = briefSummaryParagraphs(card); return (
-
-
{summaryFallbackLabel}
-
{derivedSynopsis(card)}
-
{reasonLabel}
-
- + {paragraphs.map((paragraph) => ( +

{paragraph}

+ ))}
); } -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 briefSummaryParagraphs(card: BriefCard): string[] { + const explicit = card.snapshot.summaryParagraph?.trim(); + if (!explicit) return ["Briefing Analyst has not generated this summary yet."]; + return explicit.split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean).slice(0, 2); } function SourceRows({ rows }: { rows: BriefTaskRow[] }) { if (rows.length === 0) return null; return ( -
    - {rows.map((row, index) => ( -
  • - {sourceKindIcon[row.kind] ?? "•"} - {row.identifier ? ( - {row.identifier} - ) : null} - - -
  • +
    + {rows.map((row) => ( + row.kind === "issue" && (row.identifier || row.issueId) ? ( + + ) : ( +
    + {sourceKindIcon[row.kind] ?? "•"} + {row.identifier ? ( + {row.identifier} + ) : null} + + {formatRelative(row.eventAt)} +
    + ) ))} -
+
); } function SourceLink({ row }: { row: BriefTaskRow }) { const nav = useHostNavigation(); const link = nav.linkProps(row.linkPath); + const style: CSSProperties = { + flex: 1, + color: tokens.fg, + textDecoration: "none", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: 0, + }; 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 sourceRowKey(row: BriefTaskRow): string { + return row.issueId || row.identifier || `${row.kind}:${row.sourceId}`; } -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 dedupeTaskRows(rows: BriefTaskRow[]): BriefTaskRow[] { + const byKey = new Map(); + for (const row of rows) { + const key = sourceRowKey(row); + const current = byKey.get(key); + if (!current || rowTime(row) > rowTime(current)) { + byKey.set(key, row); + } + } + return Array.from(byKey.values()).sort((a, b) => rowTime(b) - rowTime(a)); } -function primaryOpenHref(card: BriefCard): string | null { - const candidate = card.sources[0] ?? card.snapshot.taskRows[0]; - return candidate?.linkPath ?? null; +function rowTime(row: BriefTaskRow): number { + return Date.parse(row.eventAt) || 0; +} + +function taskRowToIssue(row: BriefTaskRow): IssueRowIssue { + return { + id: row.issueId ?? row.identifier ?? row.sourceId, + identifier: row.identifier, + title: row.titleLine, + status: statusFromRightTag(row.rightTag), + updatedAt: row.eventAt, + blockerAttention: row.rightTag === "blocked" && row.isIntraTreeBlocked === false, + }; +} + +function statusFromRightTag(tag: string): IssueRowIssue["status"] { + if (tag === "asked you") return "in_review"; + if (tag === "approval") return "in_review"; + if (tag === "running") return "in_progress"; + if (tag === "failed" || tag === "error" || tag === "recovery") return "blocked"; + if (tag === "paused") return "backlog"; + if (tag === "document" || tag === "comment") return "in_progress"; + return tag; } function collectIdentifiers(card: BriefCard): string[] { @@ -836,42 +827,6 @@ function uniqueIssueCount(card: BriefCard): number { 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 (
void
); } - -// 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"; - // !important is required so these rules win against the inline - // style attributes set on
and its
; - // attribute-selector specificity (0,1,1) loses to inline (1,0,0,0) otherwise. - style.textContent = ` - @media (max-width: 700px) { - [data-briefs-mobile-tabs] { display: flex !important; } - [data-briefs-section] > header { display: none !important; } - [data-briefs-section][data-mobile-hidden="true"] { display: none !important; } - [data-briefs-grid] { grid-template-columns: 1fr !important; } - [data-briefs-page-header] > [data-briefs-page-meta] { flex-basis: 100% !important; order: 2 !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 index 97d5644846b..ee29f49e157 100644 --- a/packages/plugins/plugin-briefs/src/ui/index.tsx +++ b/packages/plugins/plugin-briefs/src/ui/index.tsx @@ -1 +1 @@ -export { SidebarLink, BriefingPage } from "./app.js"; +export { SidebarLink, BriefingPage, SettingsPage } 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 index b9688e1c72c..4ad83063b82 100644 --- a/packages/plugins/plugin-briefs/src/ui/view-model.ts +++ b/packages/plugins/plugin-briefs/src/ui/view-model.ts @@ -38,6 +38,10 @@ export function groupCardsIntoSections(cards: BriefCard[]): BriefSection[] { ]; } +export function sortBriefCards(cards: BriefCard[]): BriefCard[] { + return cards.filter((card) => !card.hidden).sort(compareCards); +} + function compareCards(a: BriefCard, b: BriefCard): number { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; const aT = Date.parse(a.lastMeaningfulEventAt) || 0; diff --git a/packages/plugins/plugin-briefs/src/worker.ts b/packages/plugins/plugin-briefs/src/worker.ts index 1f37732ac48..91733d7415c 100644 --- a/packages/plugins/plugin-briefs/src/worker.ts +++ b/packages/plugins/plugin-briefs/src/worker.ts @@ -31,6 +31,7 @@ import { BRIEFS_MANAGED_SKILL_KEYS, BRIEFS_PROJECT_KEY, MANUAL_REFRESH_ROUTINE_KEY, + PLUGIN_ID, } from "./manifest.js"; import { hardenGeneratedSummaryOptions, @@ -45,6 +46,21 @@ function objectParam(value: unknown, name: string): Record { return value as Record; } +function bridgeParams(value: unknown): Record { + const raw = objectParam(value, "params"); + const nested = raw.params && typeof raw.params === "object" && !Array.isArray(raw.params) + ? raw.params as Record + : null; + if (nested) { + return { + ...nested, + ...(typeof raw.companyId === "string" && typeof nested.companyId !== "string" ? { companyId: raw.companyId } : {}), + }; + } + const { renderEnvironment: _renderEnvironment, ...params } = raw; + return params; +} + function stringParam(value: unknown, name: string): string { if (typeof value !== "string" || !value.trim()) { throw new Error(`${name} is required`); @@ -62,12 +78,30 @@ function assertTrustedBridgeUserScope(userId: string, request?: PluginBridgeRequ } } +function bridgeUserId(request?: PluginBridgeRequestContext): string { + const actorUserId = request?.actor?.userId; + if (!actorUserId) { + throw new Error("Briefs user-scoped UI calls require a signed-in user"); + } + return actorUserId; +} + +function scopedUserId(input: Record, request?: PluginBridgeRequestContext): string { + const requested = typeof input.userId === "string" && input.userId.trim() ? input.userId.trim() : bridgeUserId(request); + assertTrustedBridgeUserScope(requested, request); + return requested; +} + 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); + return value.replace(/\s+/g, " ").trim().slice(0, 900); +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 90); } function summaryFailureReason(value: unknown): DeterministicBriefOptions["summaryFailureReason"] { @@ -76,6 +110,39 @@ function summaryFailureReason(value: unknown): DeterministicBriefOptions["summar : "model_error"; } +function routineVariablesParam(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { + result[key] = entry; + } + } + return result; +} + +function codexManagedAgentNeedsRepoCheckBypass(agent: { adapterType?: string | null; adapterConfig?: unknown }) { + if (agent.adapterType !== "codex_local") return false; + const config = agent.adapterConfig && typeof agent.adapterConfig === "object" && !Array.isArray(agent.adapterConfig) + ? agent.adapterConfig as Record + : {}; + if (config.dangerouslyBypassApprovalsAndSandbox !== true && config.dangerouslyBypassSandbox !== true) { + return true; + } + const args = Array.isArray(config.extraArgs) + ? config.extraArgs + : Array.isArray(config.args) + ? config.args + : []; + return !args.includes("--skip-git-repo-check"); +} + +function managedAgentNeedsBriefsToolPermission(agent: { permissions?: unknown }) { + if (!agent.permissions || typeof agent.permissions !== "object" || Array.isArray(agent.permissions)) return true; + const pluginTools = (agent.permissions as Record).pluginTools; + return !Array.isArray(pluginTools) || !pluginTools.includes(PLUGIN_ID); +} + function summaryOptions(input: Record): DeterministicBriefOptions { if (input.budgetCapped === true) { return { @@ -91,6 +158,7 @@ function summaryOptions(input: Record): DeterministicBriefOptio }; } return { + title: typeof input.title === "string" ? oneLine(input.title) : null, summaryStatus: "ok", summaryParagraph: summary, summaryModel: typeof input.summaryModel === "string" && input.summaryModel.trim() ? input.summaryModel.trim() : "cheap-model", @@ -293,35 +361,36 @@ const plugin = definePlugin({ const store = createBriefsStore(ctx.db); ctx.data.register("cards", async (params, request) => { - const input = listBriefCardsInputSchema.parse(params); - assertTrustedBridgeUserScope(input.userId, request); + const raw = bridgeParams(params); + const input = listBriefCardsInputSchema.parse({ + ...raw, + userId: scopedUserId(raw, request), + }); return { cards: await store.listCards(input), }; }); ctx.data.register("preferences", async (params, request) => { - const input = objectParam(params, "params"); + const input = bridgeParams(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"); + if (!companyId) { + throw new Error("companyId is required"); } - assertTrustedBridgeUserScope(userId, request); + const userId = scopedUserId(input, request); return { preferences: await store.loadPreferences({ companyId, userId }), }; }); ctx.data.register("page", async (params, request) => { - const input = objectParam(params, "params"); + const input = bridgeParams(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"); + if (!companyId) { + throw new Error("companyId is required"); } - assertTrustedBridgeUserScope(userId, request); + const userId = scopedUserId(input, request); const [cards, preferences] = await Promise.all([ store.listCards({ companyId, userId, includeHidden }), store.loadPreferences({ companyId, userId }), @@ -333,8 +402,40 @@ 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.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 }), + ]); + + return { + managedProject, + managedAgent, + managedSkills, + managedRoutines, + agentOptions: agentOptions.map((agent) => ({ + id: agent.id, + name: agent.name, + status: agent.status, + adapterType: agent.adapterType ?? null, + icon: agent.icon ?? null, + })), + projectOptions: projectOptions.map((project) => ({ + id: project.id, + name: project.name, + status: project.status, + color: "color" in project && typeof project.color === "string" ? project.color : null, + })), + }; + }); + ctx.actions.register("save-deterministic-card", async (params) => { - const input = objectParam(params, "params"); + const input = bridgeParams(params); const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle; const options = (input.options && typeof input.options === "object" ? input.options : {}) as DeterministicBriefOptions; const card = await saveBriefCard(ctx, store, bundle, options); @@ -342,13 +443,17 @@ const plugin = definePlugin({ }); ctx.actions.register("refresh-issue-tree", async (params) => { - return refreshIssueTree(ctx, store, objectParam(params, "params")); + return refreshIssueTree(ctx, store, bridgeParams(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("reset-managed-agent", async (params) => { + return ctx.agents.managed.reset(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")); }); @@ -362,6 +467,15 @@ const plugin = definePlugin({ }; }); + ctx.actions.register("reset-managed-skills", async (params) => { + const companyId = stringParam(params.companyId, "companyId"); + return { + managedSkills: await Promise.all( + BRIEFS_MANAGED_SKILL_KEYS.map((skillKey) => ctx.skills.managed.reset(skillKey, companyId)), + ), + }; + }); + ctx.actions.register("reconcile-managed-routines", async (params) => { const companyId = stringParam(params.companyId, "companyId"); return { @@ -371,6 +485,28 @@ const plugin = definePlugin({ }; }); + ctx.actions.register("reconcile-managed-routine", async (params) => { + return ctx.routines.managed.reconcile( + stringParam(params.routineKey, "routineKey"), + stringParam(params.companyId, "companyId"), + { + assigneeAgentId: typeof params.assigneeAgentId === "string" ? params.assigneeAgentId : null, + projectId: typeof params.projectId === "string" ? params.projectId : null, + }, + ); + }); + + ctx.actions.register("reset-managed-routine", async (params) => { + 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, + }, + ); + }); + 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); @@ -384,13 +520,111 @@ const plugin = definePlugin({ 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("update-managed-routine-status", async (params) => { + return ctx.routines.managed.update( + stringParam(params.routineKey, "routineKey"), + stringParam(params.companyId, "companyId"), + { status: stringParam(params.status, "status") }, + ); + }); + + async function ensureBriefingAnalystReady(companyId: string, assigneeAgentId: string | null) { + if (assigneeAgentId) return assigneeAgentId; + let managedAgent = await ctx.agents.managed.reconcile(BRIEFING_ANALYST_AGENT_KEY, companyId); + if ( + managedAgent.agent && + ( + codexManagedAgentNeedsRepoCheckBypass(managedAgent.agent) || + managedAgentNeedsBriefsToolPermission(managedAgent.agent) + ) + ) { + managedAgent = await ctx.agents.managed.reset(BRIEFING_ANALYST_AGENT_KEY, companyId); + } + const agent = managedAgent.agent; + if (!agent) { + throw new Error("Briefing Analyst managed agent is not available"); + } + if (agent.status === "pending_approval") { + throw new Error("Briefing Analyst is pending approval and cannot run yet"); + } + if (agent.status === "terminated") { + throw new Error("Briefing Analyst has been terminated and cannot run"); + } + if (agent.status === "paused" || agent.status === "error") { + const resumed = await ctx.agents.resume(agent.id, companyId); + return resumed.id; + } + return agent.id; + } + + ctx.actions.register("run-managed-routine", async (params, request) => { + const input = bridgeParams(params); + const routineKey = stringParam(input.routineKey, "routineKey"); + const companyId = stringParam(input.companyId, "companyId"); + const variables = routineVariablesParam(input.variables); + const userId = "userId" in variables && typeof variables.userId === "string" && variables.userId.trim() + ? variables.userId.trim() + : scopedUserId(input, request); + const nextVariables: Record = { ...variables, userId }; + if (routineKey === MANUAL_REFRESH_ROUTINE_KEY && typeof nextVariables.rootIssueId !== "string") { + throw new Error("rootIssueId is required for manual card refresh"); + } + const assigneeAgentId = await ensureBriefingAnalystReady( + companyId, + typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, + ); + return ctx.routines.managed.run(routineKey, companyId, { + assigneeAgentId, + projectId: typeof input.projectId === "string" ? input.projectId : null, + variables: nextVariables, + }); + }); + + ctx.actions.register("run-briefing-routines", async (params, request) => { + const input = bridgeParams(params); + const companyId = stringParam(input.companyId, "companyId"); + const userId = scopedUserId(input, request); + const assigneeAgentId = await ensureBriefingAnalystReady( + companyId, + typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, + ); + const projectId = typeof input.projectId === "string" ? input.projectId : null; + const routineKeys = BRIEFS_MANAGED_ROUTINE_KEYS.filter((routineKey) => routineKey !== MANUAL_REFRESH_ROUTINE_KEY); + const runs = []; + for (const routineKey of routineKeys) { + runs.push(await ctx.routines.managed.run(routineKey, companyId, { + assigneeAgentId, + projectId, + variables: { userId }, + })); + } + return { runs }; + }); + + ctx.actions.register("run-manual-refresh-routine", async (params, request) => { + const input = bridgeParams(params); + const rootIssueId = stringParam(input.rootIssueId, "rootIssueId"); + const companyId = stringParam(input.companyId, "companyId"); + const assigneeAgentId = await ensureBriefingAnalystReady( + companyId, + typeof input.assigneeAgentId === "string" ? input.assigneeAgentId : null, + ); + return ctx.routines.managed.run(MANUAL_REFRESH_ROUTINE_KEY, companyId, { + assigneeAgentId, + projectId: typeof input.projectId === "string" ? input.projectId : null, + variables: { + userId: scopedUserId(input, request), + rootIssueId, + }, + }); }); ctx.actions.register("pin-card", async (params, request) => { - const input = pinBriefCardInputSchema.parse(params); - assertTrustedBridgeUserScope(input.userId, request); + const raw = bridgeParams(params); + const input = pinBriefCardInputSchema.parse({ + ...raw, + userId: scopedUserId(raw, request), + }); await store.setPinned(input); await ctx.activity.log({ companyId: input.companyId, @@ -403,8 +637,11 @@ const plugin = definePlugin({ }); ctx.actions.register("update-preferences", async (params, request) => { - const input = updateBriefPreferencesInputSchema.parse(params); - assertTrustedBridgeUserScope(input.userId, request); + const raw = bridgeParams(params); + const input = updateBriefPreferencesInputSchema.parse({ + ...raw, + userId: scopedUserId(raw, request), + }); const preferences = briefPreferencesSchema.parse(input); await store.upsertPreferences(preferences); await ctx.activity.log({ @@ -422,7 +659,7 @@ const plugin = definePlugin({ 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 input = bridgeParams(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.", @@ -435,7 +672,7 @@ const plugin = definePlugin({ 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 input = bridgeParams(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, { @@ -451,10 +688,10 @@ const plugin = definePlugin({ 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.", + description: "Build and save a Briefing card for one Paperclip issue tree. Call once to inspect deterministic rows, then again with title, summary, and allowGeneratedSummary: true.", 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 input = bridgeParams(params); const result = await refreshIssueTree(ctx, store, { ...input, generatedByAgentId: runCtx.agentId, diff --git a/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts b/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts index f07452edba2..7b86e0cd0f0 100644 --- a/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts +++ b/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts @@ -177,6 +177,36 @@ describe("deterministic Briefs cards", () => { expect(runSource?.linkPath).toBe("/ACME/agents/agent-1/runs/run-1"); }); + it("dedupes card task rows by issue and keeps the latest row", () => { + const card = buildDeterministicBriefCard(bundle({ + rootIssueId: "issue-root", + issues: [issue()], + runs: [{ + id: "run-1", + companyId, + issueId: "issue-root", + agentId: "agent-1", + status: "running", + createdAt: "2026-05-22T11:45:00.000Z", + }], + comments: [{ + id: "comment-1", + companyId, + issueId: "issue-root", + authorUserId: userId, + body: "Latest work note", + createdAt: "2026-05-22T11:50:00.000Z", + }], + }), { now, idFactory: ids() }); + + expect(card.snapshot.taskRows).toHaveLength(1); + expect(card.snapshot.taskRows[0]).toMatchObject({ + identifier: "PAP-2381", + kind: "comment", + titleLine: "Latest work note", + }); + }); + it("gives out-of-tree blockers precedence over waiting and live signals", () => { const blocked = issue({ id: "issue-child", @@ -276,6 +306,38 @@ describe("deterministic Briefs cards", () => { }); }); + it("downgrades generated summaries that expose issue ids or status labels", () => { + const options = hardenGeneratedSummaryOptions(bundle(), { + summaryStatus: "ok", + summaryParagraph: "PAP-2381 is todo, while related writing issues are in review.", + summaryModel: "cheap-model", + allowGeneratedSummary: true, + }); + + expect(options).toMatchObject({ + summaryStatus: "fallback", + summaryFailureReason: "safety_block", + }); + }); + + it("stores generated card titles only when generated wording is explicitly allowed", () => { + const source = bundle(); + const blockedTitle = hardenGeneratedSummaryOptions(source, { + title: "Briefing work is moving", + summaryStatus: "fallback", + }); + const allowedTitle = hardenGeneratedSummaryOptions(source, { + title: "Briefing work is moving", + summaryStatus: "ok", + summaryParagraph: "The Briefing work is moving through implementation, with the remaining next step focused on validating the dashboard experience.", + summaryModel: "cheap-model", + allowGeneratedSummary: true, + }); + + expect(blockedTitle.title).toBeNull(); + expect(buildDeterministicBriefCard(source, { ...allowedTitle, now, idFactory: ids() }).title).toBe("Briefing work is moving"); + }); + it("keeps deterministic cards visible when generated summary validation is blocked", () => { const source = sanitizeBriefSourceBundle(bundle({ comments: [{ diff --git a/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts b/packages/plugins/plugin-briefs/tests/managed-resources.spec.ts index a62be2be1b6..085f5d28712 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([ + "agents.resume", "agents.managed", "skills.managed", "routines.managed", @@ -32,7 +33,11 @@ describe("Briefs managed resources", () => { agentKey: BRIEFING_ANALYST_AGENT_KEY, displayName: "Briefing Analyst", status: "paused", + capabilities: expect.stringContaining("writes grounded card titles and descriptions"), + budgetMonthlyCents: 500, adapterConfig: { + dangerouslyBypassApprovalsAndSandbox: true, + extraArgs: ["--skip-git-repo-check"], paperclipSkillSync: { desiredSkills: BRIEFS_MANAGED_SKILL_CANONICAL_KEYS, }, @@ -41,6 +46,13 @@ describe("Briefs managed resources", () => { pluginTools: [manifest.id], }, }); + expect(manifest.agents?.[0]?.instructions?.content).toContain("You are the LLM that generates Briefing card titles and descriptions"); + expect(manifest.agents?.[0]?.instructions?.content).toContain('summaryModel: "agent-generated"'); + 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.skills?.find((skill) => skill.skillKey === "briefs-update-cards")?.markdown).toContain("refresh every visible card"); + 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.routines?.map((routine) => routine.routineKey)).toEqual([...BRIEFS_MANAGED_ROUTINE_KEYS]); expect(manifest.routines?.find((routine) => routine.routineKey === MANUAL_REFRESH_ROUTINE_KEY)).toMatchObject({ @@ -56,6 +68,59 @@ describe("Briefs managed resources", () => { "briefs_save_card", "briefs_refresh_issue_tree", ]); + expect(manifest.tools?.find((tool) => tool.name === "briefs_refresh_issue_tree")?.description).toContain("inspect deterministic rows"); + expect(manifest.ui?.slots).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "sidebar", + displayName: "Briefing", + exportName: "SidebarLink", + }), + expect.objectContaining({ + type: "page", + displayName: "Briefing", + routePath: "briefs", + exportName: "BriefingPage", + }), + expect.objectContaining({ + type: "settingsPage", + displayName: "Briefing", + exportName: "SettingsPage", + }), + ])); + }); + + it("can reset the managed analyst when declared instructions change", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup?.(harness.ctx); + await harness.performAction("reconcile-managed-resources", { companyId }); + + const result = await harness.performAction<{ status: string; agentId: string | null }>( + "reset-managed-agent", + { companyId }, + ); + + expect(result).toMatchObject({ status: "reset" }); + expect(result.agentId).toBeTruthy(); + }); + + it("can reset managed skills and routines when declared defaults change", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup?.(harness.ctx); + await harness.performAction("reconcile-managed-resources", { companyId }); + + const skills = await harness.performAction<{ managedSkills: Array<{ status: string; skillId: string | null }> }>( + "reset-managed-skills", + { companyId }, + ); + const routine = await harness.performAction<{ status: string; routineId: string | null }>( + "reset-managed-routine", + { companyId, routineKey: MANUAL_REFRESH_ROUTINE_KEY }, + ); + + expect(skills.managedSkills.map((skill) => skill.status)).toEqual(["reset", "reset"]); + expect(skills.managedSkills.every((skill) => skill.skillId)).toBe(true); + expect(routine).toMatchObject({ status: "reset" }); + expect(routine.routineId).toBeTruthy(); }); it("reconciles resources in dependency order so routines resolve their managed refs", async () => { @@ -80,6 +145,28 @@ describe("Briefs managed resources", () => { } }); + it("resumes the managed analyst before running user briefing routines", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup?.(harness.ctx); + await harness.performAction("reconcile-managed-resources", { companyId }); + + const before = await harness.ctx.agents.managed.get(BRIEFING_ANALYST_AGENT_KEY, companyId); + expect(before.agent?.status).toBe("paused"); + + const result = await harness.performAction<{ + runs: Array<{ status: string; triggerPayload: { variables: Record } | null }>; + }>("run-briefing-routines", { companyId }, { actor: bridgeActor }); + + const after = await harness.ctx.agents.managed.get(BRIEFING_ANALYST_AGENT_KEY, companyId); + expect(after.agent?.status).toBe("idle"); + 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([ + "signed-in-user", + "signed-in-user", + ]); + }); + it("rejects user-scoped UI bridge calls for a different user", async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup?.(harness.ctx); 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 f3635f05a52..de8c5ce44d7 100644 --- a/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/brief-card.spec.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; vi.mock("@paperclipai/plugin-sdk/ui", () => { @@ -8,6 +9,9 @@ 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} + ), usePluginData: () => ({ data: null, loading: false, error: null, refresh: () => {} }), usePluginToast: () => vi.fn(), useHostLocation: () => ({ pathname: "/PAP/briefs", search: "", hash: "" }), @@ -24,7 +28,7 @@ function renderCard(card: ReturnType): string { } describe("BriefCardView", () => { - it("renders title, single state badge, and primary source rows", () => { + it("renders title, summary text, and host issue rows for source rows", () => { const card = makeCard({ title: "Briefs plugin planning", state: "live", @@ -39,21 +43,17 @@ describe("BriefCardView", () => { const html = renderCard(card); expect(html).toContain("Briefs plugin planning"); - expect(html).toMatch(/data-briefs-state-badge="live"[^>]*>Live { + it("does not invent a descriptive summary when model summary fallback was used", () => { const card = makeCard({ title: "Cost dashboard improvements", state: "live", @@ -67,14 +67,33 @@ describe("BriefCardView", () => { }); const html = renderCard(card); - expect(html).toMatch(/data-briefs-state-badge="live"[^>]*>Live { + it("dedupes repeated issue rows before rendering", () => { + const sharedIssueId = "issue-duplicate"; + const card = makeCard({ + snapshot: makeSnapshot({ + taskRows: [ + makeTaskRow({ issueId: sharedIssueId, identifier: "PAP-8500", titleLine: "Older work note", eventAt: "2026-05-22T08:00:00.000Z" }), + makeTaskRow({ issueId: sharedIssueId, identifier: "PAP-8500", titleLine: "Latest work note", eventAt: "2026-05-22T10:00:00.000Z" }), + ], + }), + }); + const html = renderCard(card); + + expect(html.match(/data-plugin-issue-row="PAP-8500"/g)).toHaveLength(1); + expect(html).toContain("Latest work note"); + expect(html).not.toContain("Older work note"); + }); + + it("does not render tree-specific blocker annotations", () => { const card = makeCard({ title: "Sandbox runner", state: "blocked", @@ -88,7 +107,9 @@ describe("BriefCardView", () => { }); const html = renderCard(card); const matches = html.match(/aria-label="intra-tree blocker"/g) ?? []; - expect(matches.length).toBe(1); + expect(matches.length).toBe(0); + expect(html).not.toContain("more in tree"); + expect(html).not.toContain("Open tree"); }); it("renders pin button reflecting card pinned state", () => { 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 157709d7f5a..74c2d659c6c 100644 --- a/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx @@ -1,43 +1,27 @@ -/** - * Regression tests for the Briefing page chrome — the parts the screenshot - * fixture used to skip (MobileTabs, Legend, responsive CSS injection). - * - * These tests served as the verification path for the UX review on PAP-9963: - * before the fix, the screenshot fixture hand-wrote the page shell and never - * exercised these components, which masked a CSS-specificity bug that broke - * the mobile section filter. Asserting on the real `` output - * keeps that gap closed. - */ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import type { BriefCard, BriefPreferences } from "../../src/contracts.js"; +import type { BriefCard } from "../../src/contracts.js"; import { gallery } from "./fixtures.js"; type PageData = { cards: BriefCard[]; - preferences: BriefPreferences; fetchedAt: string; }; -const defaultPreferences: BriefPreferences = { - companyId: "company-1", - userId: "user-1", - cadence: "daily", - retentionDays: 14, - doneRetentionHours: 48, - staleAfterDays: 7, - maxUnpinnedCards: 24, - scope: "user", -}; - let mockPageData: PageData = { cards: [], - preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z", }; vi.mock("@paperclipai/plugin-sdk/ui", () => { return { + ManagedRoutinesList: ({ routines }: { routines: Array<{ title: string }> }) => ( +
{routines.map((routine) => routine.title).join("|")}
+ ), + IssueRow: ({ issue, trailingMeta }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode }) => ( + {issue.identifier} {issue.title} {trailingMeta} + ), useHostNavigation: () => ({ resolveHref: (to: string) => to, navigate: () => {}, @@ -48,6 +32,45 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { if (key === "page") { return { data: mockPageData, loading: false, error: null, refresh: () => {} }; } + if (key === "settings") { + return { + data: { + managedAgent: { + status: "resolved", + agentId: "agent-1", + agent: { id: "agent-1", name: "Briefing Analyst", status: "paused", adapterType: "codex_local", icon: "newspaper" }, + }, + managedProject: { + status: "resolved", + projectId: "project-1", + project: { id: "project-1", name: "Briefs", status: "in_progress", color: "#0f766e" }, + }, + managedSkills: [ + { status: "resolved", skillId: "skill-1", resourceKey: "briefs-discover-cards", skill: { id: "skill-1", name: "Briefs Discover Cards" } }, + { status: "resolved", skillId: "skill-2", resourceKey: "briefs-update-cards", skill: { id: "skill-2", name: "Briefs Update Cards" } }, + ], + managedRoutines: [ + { + status: "resolved", + routineId: "routine-1", + resourceKey: "briefs-discover-cards", + routine: { id: "routine-1", title: "Discover Briefing cards for {{userId}}", status: "paused", projectId: "project-1", assigneeAgentId: "agent-1" }, + }, + { + status: "resolved", + routineId: "routine-2", + resourceKey: "briefs-update-cards", + routine: { id: "routine-2", title: "Update Briefing cards for {{userId}}", status: "paused", projectId: "project-1", assigneeAgentId: "agent-1" }, + }, + ], + agentOptions: [{ id: "agent-1", name: "Briefing Analyst", icon: "newspaper" }], + projectOptions: [{ id: "project-1", name: "Briefs", color: "#0f766e" }], + }, + loading: false, + error: null, + refresh: () => {}, + }; + } return { data: null, loading: false, error: null, refresh: () => {} }; }, usePluginToast: () => vi.fn(), @@ -57,7 +80,7 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { }); import { renderToStaticMarkup } from "react-dom/server"; -import { BriefingPage } from "../../src/ui/app.js"; +import { BriefingPage, SettingsPage, SidebarLink } from "../../src/ui/app.js"; const hostContext = { companyId: "company-1", @@ -69,48 +92,79 @@ const hostContext = { } as const; function renderPage(cards: BriefCard[]): string { - mockPageData = { cards, preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z" }; + mockPageData = { cards, fetchedAt: "2026-05-22T10:00:00.000Z" }; return renderToStaticMarkup(); } describe("BriefingPage", () => { - it("renders the MobileTabs sticky bar so it appears in screenshots", () => { + it("renders a single sorted briefing list instead of section tabs", () => { const html = renderPage(gallery()); - expect(html).toContain("data-briefs-mobile-tabs"); - expect(html).toMatch(/aria-label="Briefing sections"/); - // All four tabs must be present: All / Needs you / Live / Done. - expect(html).toContain(">All<"); - expect(html).toContain(">Needs you<"); - expect(html).toContain(">Live<"); - expect(html).toContain(">Done<"); - }); - it("renders the Legend below the section grid", () => { - const html = renderPage(gallery()); - expect(html).toContain("data-briefs-legend"); - }); - - it("marks non-active sections with data-mobile-hidden so the CSS filter has a target", () => { - const html = renderPage(gallery()); - // The default mobile tab is "attention", so every other section must be - // tagged hidden. Without the CSS `!important` fix, this attribute existed - // but did nothing — the test still guards the data contract. - expect(html).toContain('data-briefs-section="live"'); - expect(html).toContain('data-briefs-section="settled"'); - expect(html).toMatch(/data-briefs-section="live"[^>]*data-mobile-hidden="true"/); - expect(html).toMatch(/data-briefs-section="settled"[^>]*data-mobile-hidden="true"/); - }); - - it("tags the page header meta block so mobile reflow CSS can reorder it", () => { - const html = renderPage(gallery()); - expect(html).toContain("data-briefs-page-header"); - expect(html).toContain("data-briefs-page-meta"); - }); - - it("renders the empty state without rendering MobileTabs or sections", () => { - const html = renderPage([]); + expect(html).toContain("data-briefs-list"); + expect(html).toContain("Recent work and next steps"); expect(html).not.toContain("data-briefs-mobile-tabs"); expect(html).not.toContain("data-briefs-section"); + expect(html).not.toContain("data-briefs-legend"); + expect(html).not.toContain("Needs your attention"); + expect(html).not.toContain("Recently done & stale"); + }); + + it("does not write UI-generated summaries when model summary fallback was used", () => { + const html = renderPage(gallery()); + + expect(html).toContain("Cost dashboard improvements"); + expect(html).toContain("Briefing Analyst has not generated this summary yet."); + expect(html).not.toContain("This brief tracks"); + expect(html).not.toContain("Next:"); + expect(html).not.toContain("Summary unavailable"); + expect(html).not.toContain("Summary skipped to stay under budget"); + }); + + it("renders issue rows with the host IssueRow bridge and no custom state chips", () => { + const html = renderPage(gallery()); + + expect(html).toContain('data-plugin-issue-row="PAP-9963"'); + expect(html).toContain("Wire briefing page UI"); + expect(html).not.toContain("data-briefs-state-badge"); + expect(html).not.toContain("data-briefs-row-tag"); + expect(html).not.toContain("Open tree"); + expect(html).not.toContain("more in tree"); + }); + + it("keeps the dashboard header lean", () => { + const html = renderPage(gallery()); + + expect(html).toContain("data-briefs-page-header"); + expect(html).toContain("data-briefs-page-meta"); + expect(html).not.toContain("Preferences"); + expect(html).not.toContain("Durable cards for areas of work"); + }); + + it("renders the empty state without rendering the briefing list", () => { + const html = renderPage([]); + expect(html).not.toContain("data-briefs-list"); expect(html).toContain("No briefs yet"); }); }); + +describe("SidebarLink", () => { + it("renders the Briefing sidebar entry with an icon and company route", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Briefing"); + expect(html).toContain('href="/briefs"'); + expect(html).toContain("data-briefs-sidebar-icon"); + expect(html).toContain("color:currentColor"); + }); +}); + +describe("SettingsPage", () => { + it("renders managed resource status and routine controls", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Managed resources"); + expect(html).toContain("Briefing Analyst"); + expect(html).toContain("data-managed-routines-list"); + expect(html).toContain("Discover Briefing cards"); + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx index 9e4673b25cb..6b0fab2c549 100644 --- a/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx @@ -6,38 +6,26 @@ * Skipped by default — set `BRIEFS_CAPTURE_SCREENSHOTS=1` to opt in. * * Mounts the real `` component (the same React tree the host - * renders) so the captured PNGs reflect MobileTabs, Legend, and the live - * responsive CSS — not a hand-rolled approximation. The Paperclip plugin SDK + * renders) so the captured PNGs reflect the live dashboard composition instead + * of a hand-rolled approximation. The Paperclip plugin SDK * is mocked so `usePluginData("page", …)` returns the gallery fixture. */ import path from "node:path"; import fs from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; +import type { ReactNode } from "react"; import { afterAll, describe, expect, it, vi } from "vitest"; -import type { BriefCard, BriefPreferences } from "../../src/contracts.js"; +import type { BriefCard } from "../../src/contracts.js"; import { gallery } from "./fixtures.js"; type PageData = { cards: BriefCard[]; - preferences: BriefPreferences; fetchedAt: string; }; -const defaultPreferences: BriefPreferences = { - companyId: "company-1", - userId: "user-1", - cadence: "daily", - retentionDays: 14, - doneRetentionHours: 48, - staleAfterDays: 7, - maxUnpinnedCards: 24, - scope: "user", -}; - let mockPageData: PageData = { cards: [], - preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z", }; @@ -56,6 +44,9 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { return { data: null, loading: false, error: null, refresh: () => {} }; }, usePluginToast: () => vi.fn(), + IssueRow: ({ issue, trailingMeta }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode }) => ( + {issue.identifier} {issue.title} {trailingMeta} + ), useHostLocation: () => ({ pathname: "/PAP/briefs", search: "", hash: "" }), usePluginStream: () => ({ events: [], lastEvent: null, connecting: false, connected: false, error: null, close: () => {} }), }; @@ -84,11 +75,8 @@ const hostContext = { } as const; function renderPageHtml({ cards, viewportWidth }: { cards: BriefCard[]; viewportWidth: number }): string { - mockPageData = { cards, preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z" }; + mockPageData = { cards, fetchedAt: "2026-05-22T10:00:00.000Z" }; const isMobile = viewportWidth < 700; - // BriefingPage injects the responsive stylesheet via document.head on mount. - // SSR doesn't run that side effect, so we inline the same rules in the page - //