feat(status-cards): join summary-mentioned issues to watched set (#10205)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Status cards summarize changing company work and watch issues so
later changes can produce useful deltas
> - A summary can explicitly reference issues that are important to the
update even when those issues do not match the card's configured queries
> - Previously, those referenced issues were not retained in the watched
set, so their later status, assignee, or comment changes could be missed
> - The watched snapshot must avoid artificial additions or removals
caused only by a summary changing which issues it references
> - This pull request resolves issue references when a summary is
written, persists them, and joins them to the watched snapshot with
stable delta semantics
> - The benefit is that status cards continue tracking the exact issues
their latest update called out while keeping follow-up updates relevant
and non-duplicative

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip (or can reproduce
on `master`).
- [x] I have confirmed the error originates in Paperclip itself — not in
my agent adapter, API provider, or local configuration.

### What happened?

When a status-card summary explicitly referenced an issue by identifier
or `/issues/<uuid>` URL, that issue was not automatically retained in
the card's watched set unless it independently matched a configured
query. Later status, assignee, or comment changes to an issue
highlighted by the latest update could therefore be omitted.

### Expected behavior

References in the latest summary should resolve only within the card's
company, appear in dry runs and the watched-issues UI, count and
fingerprint like query matches, and enter or leave the watched set
without artificial added/removed deltas already represented by the
summary change.

### Steps to reproduce

1. Create a status card whose query does not match a second issue in the
same company.
2. Write a summary that references the second issue by identifier or
issue URL.
3. Inspect the card's watched count or Watched issues tab.
4. Change the referenced issue's status, assignee, or comments and run
the next update.
5. Before this change, the referenced issue is absent from the watched
snapshot and its later change does not produce the expected delta.

### Paperclip version or commit

- Reproduced on `master` before this PR (base commit `762ce5b4ef`).

### Deployment mode

- Local dev (`pnpm dev`), built from source.

### Agent adapter(s) involved

- Not adapter-specific (core bug).

### Database mode

- Embedded Postgres test environment; the schema change uses standard
PostgreSQL JSONB.

### Access context

- Board (human operator).

## What Changed

- Added migration `0191` and schema support for persisted
`status_cards.mentioned_issue_ids`.
- Resolved summary references by issue identifier or `/issues/<uuid>`
URL within the status card's company when summaries are written.
- Joined mentioned issues into watched counts and fingerprints so later
status, assignee, and comment changes generate normal update deltas.
- Suppressed artificial added/removed deltas when the latest summary
starts or stops mentioning an issue.
- Added `mentionedIssues` to dry-run responses and a “Mentioned in the
latest update” group in the Watched issues tab.
- Updated the summarizer prompt to explain that referenced issues
automatically join the watched set.
- Added focused server and UI coverage for reference resolution,
snapshot behavior, deltas, API responses, and rendering.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/status-cards.test.ts
src/__tests__/status-card-update-engine.test.ts` — 31 tests passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/StatusCards/StatusCardTile.test.tsx` — 11 tests passed.
- Earlier implementation verification also passed database/shared/server
typechecks, UI `tsc -b`, the broader StatusCards UI test set, and
embedded-Postgres migration application.

### Visual Verification

- Greptile T-Rex ran Playwright browser checks successfully and captured
the Status Card drawer Watched tab showing the new “Mentioned in the
latest update” grouping:
https://app.greptile.com/trex/runs/15796101/artifacts

## Risks

- The migration adds a nullable JSONB column and is backward-compatible;
existing cards have no mentioned issues until their next summary write.
- Reference extraction is company-scoped to prevent cross-company issue
association.
- Watched counts and future fingerprints change for cards whose latest
summaries reference issues; tests cover additions, removals, and
suppression of spurious deltas.
- This targeted status-card fix does not introduce a new roadmap
subsystem or external integration.

## Model Used

- Anthropic Claude Fable 5 (Paperclip model alias; exact underlying
provider model ID and context window were not recorded in the
implementation task metadata), with extended reasoning, tool use, and
code execution.
- OpenAI Codex coding agent (runtime model identifier and context window
not exposed to this task) prepared the PR, rebased the branch, and ran
focused verification with terminal tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-24 16:44:56 -05:00 committed by GitHub
parent 665408c6d0
commit 8f08ec5ce6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 303 additions and 26 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "status_cards" ADD COLUMN IF NOT EXISTS "mentioned_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL;

View File

@ -1324,6 +1324,13 @@
"when": 1784916885226,
"tag": "0190_status_card_single_prompt",
"breakpoints": true
},
{
"idx": 191,
"version": "7",
"when": 1784916885227,
"tag": "0191_status_card_mentioned_issue_ids",
"breakpoints": true
}
]
}

View File

@ -47,6 +47,9 @@ export const statusCards = pgTable(
lastChangeAt: timestamp("last_change_at", { withTimezone: true }),
fingerprint: jsonb("fingerprint").$type<StatusCardFingerprint>(),
fingerprintAt: timestamp("fingerprint_at", { withTimezone: true }),
// Issues referenced in the latest summary markdown (by identifier or issue
// link) that join the watched set alongside the compiled-query matches.
mentionedIssueIds: jsonb("mentioned_issue_ids").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
documentId: uuid("document_id").references(() => documents.id, { onDelete: "set null" }),
lastUpdateRunKind: text("last_update_run_kind").$type<"full" | "incremental">(),
lastGeneratedAt: timestamp("last_generated_at", { withTimezone: true }),

View File

@ -85,6 +85,7 @@ export const statusCardSchema = z.object({
lastChangeAt: z.string().datetime().nullable(),
fingerprint: statusCardFingerprintSchema.nullable(),
fingerprintAt: z.string().datetime().nullable(),
mentionedIssueIds: z.array(z.string().uuid()).default([]),
documentId: z.string().uuid().nullable(),
lastUpdateRunKind: z.enum(["full", "incremental"]).nullable(),
lastGeneratedAt: z.string().datetime().nullable(),

View File

@ -4,6 +4,7 @@ import {
chooseStatusCardUpdateKind,
diffStatusCardFingerprint,
evaluateStatusCardPolicy,
extractIssueMentions,
filterStatusCardChanges,
isWithinStatusCardActiveHours,
nextStatusCardEvaluationAt,
@ -87,4 +88,17 @@ describe("status card update engine", () => {
expect(chooseStatusCardUpdateKind({ ...base, explicitFull: true })).toBe("full");
expect(chooseStatusCardUpdateKind({ ...base, restoreRefresh: true })).toBe("full");
});
it("extracts identifier and issue-link mentions from summary markdown", () => {
const markdown = [
"**Decide:** [PAP-15357](/issues/PAP-15357) is blocked; PAP-15357 and pap-99 (lowercase) plus SC2-4 moved.",
"See [the launch issue](/issues/0F5A2C71-9F5C-4B6C-8A9E-1B2C3D4E5F60#comment-1) and /issues/not-a-uuid.",
].join("\n");
expect(extractIssueMentions(markdown)).toEqual({
identifiers: ["PAP-15357", "SC2-4"],
issueIds: ["0f5a2c71-9f5c-4b6c-8a9e-1b2c3d4e5f60"],
});
expect(extractIssueMentions("No references here.")).toEqual({ identifiers: [], issueIds: [] });
});
});

View File

@ -916,6 +916,124 @@ describeEmbeddedPostgres("status card routes", () => {
expect(summaryWrite.status).toBe(404);
});
it("joins issues mentioned in the summary to the watched set and tracks their later changes", async () => {
const company = await seedCompany();
await enableStatusCards();
const summarizer = await seedSummarizer(company.id);
const boardApp = createApp(db, localBoardActor());
const service = statusCardService(db);
const created = await request(boardApp)
.post(`/api/companies/${company.id}/status-cards`)
.send({ interestPrompt: "Blocked launch tasks" });
const compileIssueId = created.body.generatingIssueId as string;
const compileRun = await seedRun(company.id, summarizer.id);
await db.update(issues).set({ checkoutRunId: compileRun.id }).where(eq(issues.id, compileIssueId));
const matchedIssue = await db.insert(issues).values({ companyId: company.id, title: "Launch blocked on approval", status: "blocked", priority: "high" }).returning().then((rows) => rows[0]!);
const mentionedIdentifier = `M${randomUUID().replace(/[^0-9]/g, "").slice(0, 6)}X-7`;
const mentionedIssue = await db.insert(issues).values({ companyId: company.id, identifier: mentionedIdentifier, title: "Related migration follow-up", status: "in_progress", priority: "medium" }).returning().then((rows) => rows[0]!);
const compileApp = createApp(db, agentActor(company.id, summarizer.id, compileRun.id));
const queryWrite = await request(compileApp).put(`/api/status-cards/${created.body.id}/query`).send({
queries: [{ scope: "issues", status: ["blocked"], updatedWithin: "7d", sort: "updated", limit: 20, offset: 0 }],
title: "Launch blockers",
changeSummary: "Compiled the blocker query.",
generationIssueId: compileIssueId,
});
expect(queryWrite.status).toBe(200);
// The first summary mentions an issue the compiled query does not match —
// by identifier and by issue link — plus noise that must not resolve.
const summaryWrite = await request(compileApp).put(`/api/status-cards/${created.body.id}/summary`).send({
markdown: `**Decide:** unblock approval.\n\nAlso tracking ${mentionedIdentifier} ([details](/issues/${mentionedIssue.id})) and the unrelated UTF-8 / NOPE-99 tokens.`,
title: "Launch blockers",
changeSummary: "First full summary.",
generationIssueId: compileIssueId,
model: "gpt-5.4",
});
expect(summaryWrite.status).toBe(200);
const afterFirstSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
expect(afterFirstSummary.mentionedIssueIds).toEqual([mentionedIssue.id]);
expect(Object.keys(afterFirstSummary.fingerprint ?? {}).sort()).toEqual([matchedIssue.id, mentionedIssue.id].sort());
const detail = await request(boardApp).get(`/api/status-cards/${created.body.id}`);
expect(detail.status).toBe(200);
expect(detail.body.watchedIssueCount).toBe(2);
const dryRun = await request(boardApp).get(`/api/status-cards/${created.body.id}/dry-run`);
expect(dryRun.status).toBe(200);
expect(dryRun.body.mentionedIssues).toEqual([
expect.objectContaining({ id: mentionedIssue.id, identifier: mentionedIdentifier, status: "in_progress" }),
]);
// Joining the mention must not by itself produce a pending delta.
const interval = { ...defaultStatusCardRefreshPolicy, mode: "interval" as const, intervalMinutes: 15 };
await db.update(statusCards).set({ refreshPolicy: interval, nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
// A status change on the mentioned issue now fires like any watched issue.
await db.update(issues).set({ status: "todo", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
const tick = await service.tickDueStatusCards(new Date());
expect(tick.enqueued).toHaveLength(1);
const updateIssueId = tick.enqueued[0]!.generatingIssue.id;
const updateRow = await db.select().from(statusCardUpdates).then((rows) => rows.find((row) => row.generationIssueId === updateIssueId)!);
expect(updateRow.changes).toEqual([
expect.objectContaining({ issueId: mentionedIssue.id, changeKind: "status", from: "in_progress", to: "todo" }),
]);
// If the issue changes again while the update summary is being written,
// continuing to mention it refreshes the snapshot to the latest state so
// the same change is not queued again on the next tick.
await db.update(issues).set({ status: "in_review", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
const updateRun = await seedRun(company.id, summarizer.id);
await db.update(issues).set({ checkoutRunId: updateRun.id }).where(eq(issues.id, updateIssueId));
const secondSummary = await request(createApp(db, agentActor(company.id, summarizer.id, updateRun.id)))
.put(`/api/status-cards/${created.body.id}/summary`)
.send({
markdown: `**Decide:** unblock approval. ${mentionedIdentifier} remains in the launch scope.`,
changeSummary: "Covered the follow-up issue's latest state.",
generationIssueId: updateIssueId,
model: "gpt-5.4",
});
expect(secondSummary.status).toBe(200);
const afterSecondSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
expect(afterSecondSummary.mentionedIssueIds).toEqual([mentionedIssue.id]);
expect(afterSecondSummary.fingerprint?.[mentionedIssue.id]).toEqual(expect.objectContaining({ status: "in_review" }));
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
// A later summary that stops mentioning the issue drops it from the
// watched set without queuing a spurious "removed" delta afterwards.
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
const nextTick = await service.tickDueStatusCards(new Date());
expect(nextTick.enqueued).toHaveLength(1);
const nextUpdateIssueId = nextTick.enqueued[0]!.generatingIssue.id;
const nextUpdateRun = await seedRun(company.id, summarizer.id);
await db.update(issues).set({ checkoutRunId: nextUpdateRun.id }).where(eq(issues.id, nextUpdateIssueId));
const thirdSummary = await request(createApp(db, agentActor(company.id, summarizer.id, nextUpdateRun.id)))
.put(`/api/status-cards/${created.body.id}/summary`)
.send({
markdown: "**Decide:** unblock approval. The follow-up left the launch scope.",
changeSummary: "Dropped the follow-up issue.",
generationIssueId: nextUpdateIssueId,
model: "gpt-5.4",
});
expect(thirdSummary.status).toBe(200);
const afterThirdSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
expect(afterThirdSummary.mentionedIssueIds).toEqual([]);
expect(Object.keys(afterThirdSummary.fingerprint ?? {})).toEqual([matchedIssue.id]);
expect((await request(boardApp).get(`/api/status-cards/${created.body.id}`)).body.watchedIssueCount).toBe(1);
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
});
it("writes a compiled query and first summary, dry-runs live rows, and bumps the version after recompile", async () => {
const company = await seedCompany();
await enableStatusCards();

View File

@ -260,7 +260,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
if (!decision.allowed) {
throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision));
}
res.json({ cardId: card.id, queryVersion: card.queryVersion, queries: await service.dryRun(card) });
res.json({
cardId: card.id,
queryVersion: card.queryVersion,
queries: await service.dryRun(card),
mentionedIssues: await service.listMentionedIssues(card),
});
});
router.put("/status-cards/:id/query", validate(writeStatusCardQuerySchema), async (req, res) => {

View File

@ -22,6 +22,25 @@ export type StatusCardDeltaChange = {
changeKind: "new" | "removed" | "status" | "assignee" | "human_comment" | "updated";
};
/** Upper bound on summary-mentioned issues joined to a card's watched set. */
export const STATUS_CARD_MAX_MENTIONED_ISSUES = 200;
const ISSUE_IDENTIFIER_MENTION_PATTERN = /\b[A-Z][A-Z0-9]{0,9}-\d{1,7}\b/g;
const ISSUE_LINK_MENTION_PATTERN = /\/issues\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\b/g;
/**
* Pull issue references out of summary markdown: bare identifiers ("PAP-123")
* and issue links carrying a UUID ("/issues/<id>"). Callers must resolve the
* candidates against the card's company before trusting them.
*/
export function extractIssueMentions(markdown: string) {
const identifiers = new Set<string>();
const issueIds = new Set<string>();
for (const match of markdown.matchAll(ISSUE_IDENTIFIER_MENTION_PATTERN)) identifiers.add(match[0]);
for (const match of markdown.matchAll(ISSUE_LINK_MENTION_PATTERN)) issueIds.add(match[1]!.toLowerCase());
return { identifiers: [...identifiers], issueIds: [...issueIds] };
}
export function buildStatusCardFingerprint(issues: Array<CompanySearchIssueSummary & { latestHumanCommentAt?: string | null }>): StatusCardFingerprint {
return Object.fromEntries(issues.map((issue) => [issue.id, {
status: issue.status,

View File

@ -31,8 +31,10 @@ import {
chooseStatusCardUpdateKind,
diffStatusCardFingerprint,
evaluateStatusCardPolicy,
extractIssueMentions,
filterStatusCardChanges,
nextStatusCardEvaluationAt,
STATUS_CARD_MAX_MENTIONED_ISSUES,
statusCardChangesHash,
statusCardFingerprintHash,
type StatusCardDeltaChange,
@ -86,7 +88,7 @@ function updateDescription(input: {
previousSummary: string | null;
snapshot: CompanySearchIssueSummary[];
}) {
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<<SUMMARY-DRAFT>>> sentinels used by the Summarizer.`;
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<<SUMMARY-DRAFT>>> sentinels used by the Summarizer. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`;
// The card prompt is the board's single standing request: it already says
// what to watch and how the update should read, so it doubles as the
// summary instructions — there is no separate default prompt to append to
@ -153,7 +155,7 @@ export function statusCardService(
const searchSvc = companySearchService(db);
async function readWatchedIssueCount(card: StatusCardRow) {
if (card.queries.length === 0) return 0;
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) return 0;
try {
return (await executeQueries(card)).length;
} catch (err) {
@ -513,6 +515,54 @@ export function statusCardService(
});
}
async function loadIssueSummaries(companyId: string, issueIds: string[]): Promise<CompanySearchIssueSummary[]> {
if (issueIds.length === 0) return [];
const rows = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
projectId: issues.projectId,
updatedAt: issues.updatedAt,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
return rows.map((row) => ({
...row,
status: row.status as CompanySearchIssueSummary["status"],
priority: row.priority as CompanySearchIssueSummary["priority"],
updatedAt: row.updatedAt.toISOString(),
}));
}
/**
* Resolve markdown issue mentions to real issue ids in the card's company.
* Unknown identifiers and foreign-company links drop out here, so a summary
* cannot join arbitrary ids to the watched set.
*/
async function resolveMentionedIssueIds(companyId: string, markdown: string) {
const mentions = extractIssueMentions(markdown);
const conditions = [
...(mentions.identifiers.length > 0 ? [inArray(issues.identifier, mentions.identifiers)] : []),
...(mentions.issueIds.length > 0 ? [inArray(issues.id, mentions.issueIds)] : []),
];
if (conditions.length === 0) return [];
const rows = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), or(...conditions)))
.limit(STATUS_CARD_MAX_MENTIONED_ISSUES);
return rows.map((row) => row.id).sort();
}
async function listMentionedIssues(card: StatusCardRow) {
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? []);
}
async function executeQueries(card: StatusCardRow) {
const issueMap = new Map<string, CompanySearchIssueSummary>();
for (const storedQuery of card.queries) {
@ -522,6 +572,13 @@ export function statusCardService(
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
}
}
// Issues mentioned in the latest summary join the watched set alongside the
// compiled-query matches, so their later changes fire deltas too.
const mentioned = await loadIssueSummaries(
card.companyId,
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
);
for (const issue of mentioned) issueMap.set(issue.id, issue);
const snapshot = [...issueMap.values()];
if (snapshot.length === 0) return snapshot;
const latestHumanComments = await db
@ -733,9 +790,33 @@ export function statusCardService(
const trigger = payload?.operation === "update" && ["manual", "interval", "reactive", "restore"].includes(String(payload.trigger))
? payload.trigger as "manual" | "interval" | "reactive" | "restore"
: "manual";
const snapshot = payload?.operation === "update" && payload.fingerprint && typeof payload.fingerprint === "object"
const payloadFingerprint = payload?.operation === "update" && payload.fingerprint && typeof payload.fingerprint === "object"
? payload.fingerprint as StatusCardFingerprint
: buildStatusCardFingerprint(await executeQueries(current));
: null;
const mentionedIssueIds = await resolveMentionedIssueIds(current.companyId, input.markdown);
// Current watched membership: compiled-query matches plus the issues this
// summary mentions.
const watchedNow = buildStatusCardFingerprint(
await executeQueries({ ...current, mentionedIssueIds }),
);
let snapshot: StatusCardFingerprint;
if (payloadFingerprint) {
// Keep the generation-time fingerprint as the change baseline: issues
// that changed (or newly matched the query) while this summary was
// being written must still fire at the next diff. Mentions are the
// exception — the summary just covered them, so they join silently —
// and mention-only entries whose reference dropped out leave the set.
snapshot = { ...payloadFingerprint };
for (const droppedId of current.mentionedIssueIds ?? []) {
if (!mentionedIssueIds.includes(droppedId) && !watchedNow[droppedId]) delete snapshot[droppedId];
}
for (const issueId of mentionedIssueIds) {
const entry = watchedNow[issueId];
if (entry) snapshot[issueId] = entry;
}
} else {
snapshot = watchedNow;
}
const existing = current.documentId
? await tx.select().from(documents).where(and(eq(documents.id, current.documentId), eq(documents.companyId, current.companyId))).then((rows) => rows[0] ?? null)
: null;
@ -784,6 +865,7 @@ export function statusCardService(
lastModel: input.model ?? null,
fingerprint: snapshot,
fingerprintAt: now,
mentionedIssueIds,
pendingChangeCount: 0,
pendingChangeHash: null,
lastChangeAt: null,
@ -831,5 +913,5 @@ export function statusCardService(
return Promise.all(card.queries.map(async (query) => ({ query, result: await searchSvc.search(card.companyId, query) })));
}
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, listMentionedIssues, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
}

View File

@ -1,4 +1,5 @@
import type {
CompanySearchIssueSummary,
CompanySearchQuery,
CompanySearchResponse,
CreateStatusCard,
@ -13,6 +14,8 @@ export interface StatusCardDryRun {
cardId: string;
queryVersion: number;
queries: Array<{ query: CompanySearchQuery; result: CompanySearchResponse }>;
/** Issues referenced in the latest summary that joined the watched set. */
mentionedIssues: CompanySearchIssueSummary[];
}
/**

View File

@ -98,7 +98,7 @@ export function StatusCardDetailDrawer({
const dryRunQuery = useQuery({
queryKey: card ? queryKeys.statusCards.dryRun(card.id) : ["status-cards", "detail", "none", "dry-run"],
queryFn: () => statusCardsApi.dryRun(card!.id),
enabled: Boolean(card && open && tab === "watched" && card.queries.length > 0),
enabled: Boolean(card && open && tab === "watched" && (card.queries.length > 0 || (card.mentionedIssueIds?.length ?? 0) > 0)),
});
const lifecycle = card ? deriveStatusCardLifecycle(card) : "fresh";
const generatingIssue = useMemo<SummarySlotIssueRef | null>(
@ -417,7 +417,7 @@ export function StatusCardDetailDrawer({
</TabsContent>
<TabsContent value="watched" className="mt-0 space-y-3">
{card.queries.length === 0 ? (
{card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
This card is still setting up the issues it watches appear here once it's ready.
</div>
@ -430,7 +430,10 @@ export function StatusCardDetailDrawer({
{dryRunQuery.error instanceof Error ? dryRunQuery.error.message : "Try again."}
</InlineBanner>
) : (
<MatchedIssueList queries={dryRunQuery.data?.queries ?? []} />
<MatchedIssueList
queries={dryRunQuery.data?.queries ?? []}
mentioned={dryRunQuery.data?.mentionedIssues ?? []}
/>
)}
</TabsContent>
@ -523,9 +526,10 @@ function QueryDebugSection({ card }: { card: StatusCardView }) {
/**
* Live matched-issue list for the Watched tab, fed by the dry-run endpoint.
* Queries in the compiled array are a union, so issues matched by more than
* one query are deduplicated by id.
* one query are deduplicated by id. Issues mentioned in the latest summary
* join the watched set too and render as their own group below the matches.
*/
function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] }) {
function MatchedIssueList({ queries, mentioned }: { queries: StatusCardDryRun["queries"]; mentioned: CompanySearchIssueSummary[] }) {
const seen = new Set<string>();
const matched: CompanySearchIssueSummary[] = [];
for (const { result } of queries) {
@ -535,7 +539,8 @@ function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] })
matched.push(item.issue);
}
}
if (matched.length === 0) {
const mentionedOnly = mentioned.filter((issue) => !seen.has(issue.id));
if (matched.length === 0 && mentionedOnly.length === 0) {
return (
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
The compiled query matches no issues right now.
@ -543,20 +548,38 @@ function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] })
);
}
return (
<div className="space-y-1.5">
{matched.map((issue) => (
<div key={issue.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
<IssueStatusBadge status={issue.status} />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
<div className="space-y-3">
{matched.length > 0 ? (
<div className="space-y-1.5">
{matched.map((issue) => (
<WatchedIssueRow key={issue.id} issue={issue} />
))}
</div>
))}
) : null}
{mentionedOnly.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Mentioned in the latest update</p>
{mentionedOnly.map((issue) => (
<WatchedIssueRow key={issue.id} issue={issue} />
))}
</div>
) : null}
</div>
);
}
function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
return (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
<IssueStatusBadge status={issue.status} />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</div>
);
}

View File

@ -57,6 +57,7 @@ function baseCard(overrides: Partial<StatusCardView>): StatusCardView {
lastChangeAt: null,
fingerprint: null,
fingerprintAt: null,
mentionedIssueIds: [],
documentId: null,
lastUpdateRunKind: "full",
lastGeneratedAt: "2026-07-22T11:00:00.000Z",

View File

@ -14,7 +14,7 @@ import type { StatusCard, StatusCardUpdate } from "@paperclipai/shared";
export interface StatusCardView extends StatusCard {
/** Latest summary markdown (from the card's summary document). */
summaryBody?: string | null;
/** Number of issues currently matched by the compiled query. */
/** Watched-issue count: compiled-query matches plus summary mentions. */
watchedIssueCount?: number;
/** Tokens spent by this card so far today. */
todayTokens?: number;