mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
fix(briefs): add settings and dismissal controls
This commit is contained in:
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>;
|
||||
@@ -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"];
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user