diff --git a/packages/db/src/migrations/0191_status_card_mentioned_issue_ids.sql b/packages/db/src/migrations/0191_status_card_mentioned_issue_ids.sql new file mode 100644 index 0000000000..254f6f7444 --- /dev/null +++ b/packages/db/src/migrations/0191_status_card_mentioned_issue_ids.sql @@ -0,0 +1 @@ +ALTER TABLE "status_cards" ADD COLUMN IF NOT EXISTS "mentioned_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 763636e6b2..04b70adf89 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/status_cards.ts b/packages/db/src/schema/status_cards.ts index fb749f872a..90f250b7ca 100644 --- a/packages/db/src/schema/status_cards.ts +++ b/packages/db/src/schema/status_cards.ts @@ -47,6 +47,9 @@ export const statusCards = pgTable( lastChangeAt: timestamp("last_change_at", { withTimezone: true }), fingerprint: jsonb("fingerprint").$type(), 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().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 }), diff --git a/packages/shared/src/validators/status-card.ts b/packages/shared/src/validators/status-card.ts index 4b2b3e7e4c..327d78cbf7 100644 --- a/packages/shared/src/validators/status-card.ts +++ b/packages/shared/src/validators/status-card.ts @@ -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(), diff --git a/server/src/__tests__/status-card-update-engine.test.ts b/server/src/__tests__/status-card-update-engine.test.ts index 3de506cabe..09a660e874 100644 --- a/server/src/__tests__/status-card-update-engine.test.ts +++ b/server/src/__tests__/status-card-update-engine.test.ts @@ -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: [] }); + }); }); diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index 50a69ca604..285060b754 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -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(); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index 83241ed81a..a1553e4a8f 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -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) => { diff --git a/server/src/services/status-card-update-engine.ts b/server/src/services/status-card-update-engine.ts index c4c449baa7..ff14d35c64 100644 --- a/server/src/services/status-card-update-engine.ts +++ b/server/src/services/status-card-update-engine.ts @@ -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/"). Callers must resolve the + * candidates against the card's company before trusting them. + */ +export function extractIssueMentions(markdown: string) { + const identifiers = new Set(); + const issueIds = new Set(); + 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): StatusCardFingerprint { return Object.fromEntries(issues.map((issue) => [issue.id, { status: issue.status, diff --git a/server/src/services/status-cards.ts b/server/src/services/status-cards.ts index dd87b1571b..025fd0dd18 100644 --- a/server/src/services/status-cards.ts +++ b/server/src/services/status-cards.ts @@ -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 <<>> 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 <<>> 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 { + 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(); 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 }; } diff --git a/ui/src/api/statusCards.ts b/ui/src/api/statusCards.ts index 39edd0ad50..46f8415db6 100644 --- a/ui/src/api/statusCards.ts +++ b/ui/src/api/statusCards.ts @@ -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[]; } /** diff --git a/ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx b/ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx index 0021a0e55c..294ef0a4e6 100644 --- a/ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx +++ b/ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx @@ -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( @@ -417,7 +417,7 @@ export function StatusCardDetailDrawer({ - {card.queries.length === 0 ? ( + {card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0 ? (
This card is still setting up — the issues it watches appear here once it's ready.
@@ -430,7 +430,10 @@ export function StatusCardDetailDrawer({ {dryRunQuery.error instanceof Error ? dryRunQuery.error.message : "Try again."} ) : ( - + )}
@@ -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(); 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 (
The compiled query matches no issues right now. @@ -543,20 +548,38 @@ function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] }) ); } return ( -
- {matched.map((issue) => ( -
- - {issue.identifier ?? issue.id.slice(0, 8)} - - - {issue.title} - {relativeTime(issue.updatedAt)} +
+ {matched.length > 0 ? ( +
+ {matched.map((issue) => ( + + ))}
- ))} + ) : null} + {mentionedOnly.length > 0 ? ( +
+

Mentioned in the latest update

+ {mentionedOnly.map((issue) => ( + + ))} +
+ ) : null} +
+ ); +} + +function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) { + return ( +
+ + {issue.identifier ?? issue.id.slice(0, 8)} + + + {issue.title} + {relativeTime(issue.updatedAt)}
); } diff --git a/ui/src/pages/StatusCards/StatusCardTile.test.tsx b/ui/src/pages/StatusCards/StatusCardTile.test.tsx index 951a2577f0..369309d367 100644 --- a/ui/src/pages/StatusCards/StatusCardTile.test.tsx +++ b/ui/src/pages/StatusCards/StatusCardTile.test.tsx @@ -57,6 +57,7 @@ function baseCard(overrides: Partial): StatusCardView { lastChangeAt: null, fingerprint: null, fingerprintAt: null, + mentionedIssueIds: [], documentId: null, lastUpdateRunKind: "full", lastGeneratedAt: "2026-07-22T11:00:00.000Z", diff --git a/ui/src/pages/StatusCards/types.ts b/ui/src/pages/StatusCards/types.ts index 80e19b4b8a..78f4509202 100644 --- a/ui/src/pages/StatusCards/types.ts +++ b/ui/src/pages/StatusCards/types.ts @@ -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;