fix(briefs): add settings and dismissal controls

This commit is contained in:
Dotta committed 2026-05-24 02:35:26 +00:00
1 parent 602c68370c
commit f9a88c02da
16 files changed
+1060 -147

No files matched your search

@@ -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<typeof briefCardSchema>;
export type BriefPreferences = z.infer<typeof briefPreferencesSchema>;
export type ListBriefCardsInput = z.infer<typeof listBriefCardsInputSchema>;
export type PinBriefCardInput = z.infer<typeof pinBriefCardInputSchema>;
export type DismissBriefCardInput = z.infer<typeof dismissBriefCardInputSchema>;
export type UpdateBriefPreferencesInput = z.infer<typeof updateBriefPreferencesInputSchema>;
export type BriefCursorEvent = z.infer<typeof briefCursorEventSchema>;
+24 -6
View File
@@ -176,6 +176,14 @@ export function createBriefsStore(db: PluginDatabaseClient) {
},
async saveCard(card: BriefCard): Promise<BriefCard> {
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<void> {
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<BriefPreferences> {
const rows = await db.query<{
cadence: BriefPreferences["cadence"];
+13 -10
View File
@@ -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.`;
+304 -50
View File
@@ -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<string, { title: string; schedule: string | null }> = {
[DISCOVER_CARDS_ROUTINE_KEY]: { title: "Discover Briefing cards", schedule: "0 */6 * * *" },
@@ -77,15 +81,8 @@ const ROUTINE_FALLBACKS: Record<string, { title: string; schedule: string | null
[MANUAL_REFRESH_ROUTINE_KEY]: { title: "Refresh Briefing card", schedule: "Manual" },
};
export function SidebarLink({ context }: PluginSidebarProps) {
export function SidebarLink(_props: PluginSidebarProps) {
const nav = useHostNavigation();
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 ?? [];
const count = useMemo(() => countAttention(cards), [cards]);
const link = nav.linkProps("/briefs");
return (
<a
@@ -104,7 +101,6 @@ export function SidebarLink({ context }: PluginSidebarProps) {
>
<BriefingSidebarIcon />
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>Briefing</span>
{count > 0 ? <AttentionBadge count={count} /> : null}
</a>
);
}
@@ -130,29 +126,6 @@ function BriefingSidebarIcon() {
);
}
function AttentionBadge({ count }: { count: number }) {
return (
<span
aria-label={`${count} brief${count === 1 ? "" : "s"} need your attention`}
style={{
minWidth: 18,
padding: "0 6px",
height: 18,
borderRadius: 9,
background: toneColors.warning.badgeBg,
color: toneColors.warning.badgeFg,
fontSize: 11,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{count > 99 ? "99+" : count}
</span>
);
}
export function BriefingPage({ context }: PluginPageProps) {
const params = useMemo(() => ({ companyId: context.companyId ?? "" }), [context.companyId]);
const enabled = Boolean(params.companyId);
@@ -172,7 +145,12 @@ export function BriefingPage({ context }: PluginPageProps) {
return (
<PageShell
meta={data ? <PageMeta data={data} /> : null}
action={<RefreshButton onClick={refresh} loading={loading} />}
action={(
<>
<RefreshButton onClick={refresh} loading={loading} />
<SettingsGearLink />
</>
)}
>
{error ? (
<ErrorPanel message={error.message} onRetry={refresh} />
@@ -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<string | null>(null);
const [draftPreferences, setDraftPreferences] = useState<BriefPreferences | null>(null);
if (!enabled) {
return <SettingsShell><Callout>Choose a company to view Briefing settings.</Callout></SettingsShell>;
@@ -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<BriefPreferences>) {
setDraftPreferences((current) => ({ ...(current ?? data.preferences), ...patch }));
}
return (
<SettingsShell>
<section style={{ display: "grid", gap: 18 }}>
<div style={{ display: "grid", gap: 12, maxWidth: 760 }}>
<div style={{ display: "grid", gap: 4 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>Dashboard settings</h2>
<Tiny>Control refresh cadence, stale windows, retention, and card count limits.</Tiny>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", gap: 10 }}>
<PreferenceField label="Refresh cadence">
<select
value={preferences.cadence}
onChange={(event) => patchPreferences({ cadence: event.currentTarget.value as BriefPreferences["cadence"] })}
style={fieldStyle}
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
</select>
</PreferenceField>
<PreferenceField label="Maximum unpinned cards">
<NumberField value={preferences.maxUnpinnedCards} min={1} onChange={(maxUnpinnedCards) => patchPreferences({ maxUnpinnedCards })} />
</PreferenceField>
<PreferenceField label="Keep active cards for days">
<NumberField value={preferences.retentionDays} min={1} onChange={(retentionDays) => patchPreferences({ retentionDays })} />
</PreferenceField>
<PreferenceField label="Mark stale after days">
<NumberField value={preferences.staleAfterDays} min={1} onChange={(staleAfterDays) => patchPreferences({ staleAfterDays })} />
</PreferenceField>
<PreferenceField label="Keep done cards for hours">
<NumberField value={preferences.doneRetentionHours} min={1} onChange={(doneRetentionHours) => patchPreferences({ doneRetentionHours })} />
</PreferenceField>
</div>
<div>
<SettingsButton onClick={savePreferences} loading={busy === "preferences"} primary>Save dashboard settings</SettingsButton>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
<div style={{ display: "grid", gap: 4 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>Managed resources</h2>
@@ -413,6 +448,42 @@ function Tiny({ children }: { children: ReactNode }) {
return <span style={{ fontSize: 12, color: tokens.muted }}>{children}</span>;
}
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 (
<label style={{ display: "grid", gap: 5, minWidth: 0 }}>
<span style={{ fontSize: 12, color: tokens.muted, fontWeight: 550 }}>{label}</span>
{children}
</label>
);
}
function NumberField({ value, min, onChange }: { value: number; min: number; onChange: (value: number) => void }) {
return (
<input
type="number"
min={min}
value={value}
onChange={(event) => {
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 (
<div style={{ fontFamily: fontStack, color: tokens.fg, padding: "20px clamp(12px, 4vw, 32px)", maxWidth: 1280, margin: "0 auto", minHeight: "100vh" }}>
<div style={{ fontFamily: fontStack, color: tokens.fg, padding: "20px clamp(12px, 3vw, 28px)", width: "100%", boxSizing: "border-box", margin: 0, minHeight: "100vh" }}>
<style>{`
@media (max-width: 900px) {
[data-briefs-card-grid] { grid-template-columns: 1fr !important; }
[data-briefs-page-header] > [data-briefs-page-meta] { flex-basis: 100% !important; order: 2 !important; }
}
`}</style>
<header data-briefs-page-header style={{ display: "flex", flexWrap: "wrap", alignItems: "baseline", gap: 12, marginBottom: 20 }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600, letterSpacing: -0.2 }}>Briefing</h1>
<div data-briefs-page-meta style={{ flex: 1, minWidth: 0, fontSize: 12, color: tokens.muted, overflow: "hidden", textOverflow: "ellipsis" }}>{meta}</div>
@@ -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 (
<a
{...link}
title="Briefing settings"
aria-label="Briefing settings"
style={{
width: 30,
height: 30,
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: tokens.card,
color: tokens.fg,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
boxSizing: "border-box",
}}
>
<GearIcon />
</a>
);
}
function GearIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" style={{ width: 15, height: 15 }} fill="none">
<path
d="M6.9 1.75h2.2l.28 1.44c.33.12.64.3.92.53l1.38-.5 1.1 1.9-1.1.95c.03.18.05.37.05.56s-.02.38-.05.56l1.1.95-1.1 1.9-1.38-.5c-.28.23-.59.41-.92.53l-.28 1.44H6.9l-.28-1.44a3.52 3.52 0 0 1-.92-.53l-1.38.5-1.1-1.9 1.1-.95a3.3 3.3 0 0 1-.05-.56c0-.19.02-.38.05-.56l-1.1-.95 1.1-1.9 1.38.5c.28-.23.59-.41.92-.53l.28-1.44Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<circle cx="8" cy="7.75" r="1.7" stroke="currentColor" strokeWidth="1.2" />
</svg>
);
}
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<Record<string, boolean>>({});
const [dismissingIds, setDismissingIds] = useState<Set<string>>(() => new Set());
const [removedIds, setRemovedIds] = useState<Set<string>>(() => 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 (
<section data-briefs-list style={{ display: "flex", flexDirection: "column", gap: 12, maxWidth: 980 }}>
<section data-briefs-list style={{ display: "flex", flexDirection: "column", gap: 12, width: "100%", maxWidth: "none" }}>
<header style={{ display: "grid", gap: 4, marginBottom: 4 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 650, lineHeight: 1.2, maxWidth: 520 }}>
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.
</div>
</header>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div data-briefs-card-grid style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", alignItems: "start", gap: 12, width: "100%" }}>
{cards.map((card) => (
<BriefCardView key={card.id} card={card} onChanged={onChanged} />
<BriefCardView
key={card.id}
card={card}
dismissing={dismissingIds.has(card.id)}
onChanged={onChanged}
onPinChanged={handlePinChanged}
onPinFailed={handlePinFailed}
onDismissStarted={handleDismissStarted}
onDismissFailed={handleDismissFailed}
/>
))}
</div>
</section>
);
}
export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged: () => void }) {
function filterIdSet(current: Set<string>, allowed: Set<string>): Set<string> {
const next = new Set<string>();
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 (
<article
@@ -618,6 +837,12 @@ export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged:
flexDirection: "column",
gap: 10,
minWidth: 0,
opacity: dismissing ? 0 : 1,
transform: dismissing ? "translateY(-4px)" : "translateY(0)",
maxHeight: dismissing ? 0 : 1200,
overflow: "hidden",
transition: `opacity ${DISMISS_ANIMATION_MS}ms ease, transform ${DISMISS_ANIMATION_MS}ms ease, max-height ${DISMISS_ANIMATION_MS}ms ease, padding ${DISMISS_ANIMATION_MS}ms ease, border-color ${DISMISS_ANIMATION_MS}ms ease`,
pointerEvents: dismissing ? "none" : "auto",
}}
>
<header style={{ display: "flex", alignItems: "flex-start", gap: 8, minWidth: 0 }}>
@@ -625,6 +850,7 @@ export function BriefCardView({ card, onChanged }: { card: BriefCard; onChanged:
<h3 style={{ margin: 0, flex: 1, fontSize: 15, fontWeight: 650, lineHeight: 1.28, overflow: "hidden", display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }}>
{card.title}
</h3>
<DismissButton onDismiss={dismissCard} />
</header>
<MetaRow card={card} />
<SummarySlot card={card} />
@@ -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 (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss briefing card"
title="Dismiss"
style={{
height: 22,
padding: "0 7px",
flexShrink: 0,
borderRadius: 6,
border: `1px solid ${tokens.border}`,
background: "transparent",
color: tokens.muted,
fontSize: 11,
lineHeight: 1,
cursor: "pointer",
fontFamily: fontStack,
transition: "background 140ms ease, color 140ms ease, border-color 140ms ease",
}}
>
Dismiss
</button>
);
}
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 (
<div data-briefs-source-rows style={{ margin: 0, padding: "2px 0 0", display: "flex", flexDirection: "column", borderTop: `1px solid ${tokens.border}` }}>
<div data-briefs-source-rows style={{ margin: 0, padding: "2px 0 0", display: "flex", flexDirection: "column", gap: 1 }}>
{rows.map((row) => (
row.kind === "issue" && (row.identifier || row.issueId) ? (
<PluginIssueRow
key={sourceRowKey(row)}
issue={taskRowToIssue(row)}
trailingMeta={formatRelative(row.eventAt)}
className="!pl-0 !pr-0"
className="!border-b-0 !pl-0 !pr-0 !py-1.5"
disableIssueQuicklook={false}
/>
) : (
@@ -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,
}}
+92 -24
View File
@@ -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<string, unknown>).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({
@@ -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<string, unknown> } | 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");
});
});
@@ -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> = {}): 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<string, CardRow>();
const key = (company: unknown, user: unknown, slug: unknown) => `${company}:${user}:${slug}`;
return {
namespace: "plugin_briefs_test",
async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
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);
});
});
@@ -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 }) => (
<a data-plugin-issue-row={issue.identifier ?? ""} href={`/issues/${issue.identifier ?? ""}`}>{issue.identifier} {issue.title} {trailingMeta}</a>
IssueRow: ({ issue, trailingMeta, className }: { issue: { identifier?: string | null; title: string }; trailingMeta?: ReactNode; className?: string }) => (
<a data-plugin-issue-row={issue.identifier ?? ""} className={className} href={`/issues/${issue.identifier ?? ""}`}>{issue.identifier} {issue.title} {trailingMeta}</a>
),
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");
});
});
@@ -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 }) => (
<a data-plugin-issue-row={issue.identifier ?? ""} className={className} href={`/issues/${issue.identifier ?? ""}`}>{issue.identifier} {issue.title} {trailingMeta}</a>
),
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<typeof createRoot>;
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(<BriefingPage context={hostContext as never} />);
});
}
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",
}));
});
});
@@ -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(<SidebarLink context={hostContext as never} />);
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(<SettingsPage context={hostContext as never} />);
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");
+62 -5
View File
@@ -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<PluginManagedRoutineResolution["missingRefs"]> = [];
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);