Files
PaperClipAI/ui/src/pages/StatusCards/StatusCardTile.test.tsx
T
DottaandPaperclip 8f08ec5ce6 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>
2026-07-24 16:44:56 -05:00

222 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StatusCard } from "@paperclipai/shared";
import { StatusCardTile } from "./StatusCardTile";
import type { StatusCardView } from "./types";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
if (!HTMLElement.prototype.hasPointerCapture) {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: () => false,
});
}
// Keep the tile deterministic: the streaming hook is exercised elsewhere.
vi.mock("@/components/useSummaryDraftStream", () => ({
useSummaryDraftStream: () => ({ runId: null, statusLine: null, draft: null, draftClosed: false, hasStream: false }),
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown-body">{children}</div>,
}));
function baseCard(overrides: Partial<StatusCardView>): StatusCardView {
const card: StatusCard = {
id: "card-1",
companyId: "company-1",
createdByUserId: null,
createdByAgentId: null,
title: "Decisions v1 rollout",
titlePinned: false,
interestPrompt: "issues that help ship the new install subcommand",
queries: [],
queryVersion: 1,
queryCompiledAt: "2026-07-22T10:00:00.000Z",
queryCompiledByAgentId: null,
agentId: null,
refreshPolicy: {
mode: "interval",
intervalMinutes: 15,
triggers: {
statusTransitions: true,
membershipChanges: true,
humanComments: true,
assigneeChanges: true,
anyUpdate: false,
},
},
state: "active",
pendingChangeCount: 0,
lastChangeAt: null,
fingerprint: null,
fingerprintAt: null,
mentionedIssueIds: [],
documentId: null,
lastUpdateRunKind: "full",
lastGeneratedAt: "2026-07-22T11:00:00.000Z",
lastModel: "claude-haiku",
generatingIssueId: null,
failureReason: null,
nextEvalAt: null,
archivedAt: null,
archivedByUserId: null,
archivedByAgentId: null,
createdAt: "2026-07-22T09:00:00.000Z",
updatedAt: "2026-07-22T11:00:00.000Z",
};
return { ...card, summaryBody: "All on track. Next: review the deep-link fix, then merge.", ...overrides };
}
let container: HTMLDivElement;
let root: Root;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function render(node: ReactNode) {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{node}</MemoryRouter>
</QueryClientProvider>,
);
});
}
const noop = () => {};
function tile(card: StatusCardView, handlers: Partial<Record<"onOpen" | "onRefresh" | "onRecompile", () => void>> = {}) {
return (
<StatusCardTile
card={card}
companyId="company-1"
onOpen={handlers.onOpen ?? noop}
onRefresh={handlers.onRefresh ?? noop}
onRecompile={handlers.onRecompile ?? noop}
onEditInterest={noop}
onOpenDebug={noop}
onArchive={noop}
/>
);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
describe("StatusCardTile lifecycle rendering", () => {
it("renders a fresh card with its summary and policy footer", () => {
render(tile(baseCard({ state: "active", pendingChangeCount: 0 })));
const el = container.querySelector('[data-testid="status-card-tile"]');
expect(el?.getAttribute("data-lifecycle")).toBe("fresh");
expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toContain("All on track");
expect(container.textContent).toContain("every 15m if changed");
});
it("renders a stale card with the pending-change strip", () => {
render(tile(baseCard({ state: "active", pendingChangeCount: 5 })));
expect(container.querySelector('[data-lifecycle="stale"]')).toBeTruthy();
expect(container.textContent).toContain("5 changes since last update");
// Stale keeps the last good summary visible (never blank).
expect(container.querySelector('[data-testid="markdown-body"]')).toBeTruthy();
});
it("renders a compiling card (setup in flight) with a live spinner and a link to the setup task, no Run now", () => {
render(
tile(baseCard({ state: "compiling", title: null, summaryBody: null, generatingIssueId: "issue-setup" })),
);
const el = container.querySelector('[data-lifecycle="compiling"]');
expect(el).toBeTruthy();
expect(el?.className).toContain("border-dashed");
expect(container.textContent).toContain("Setting up your card");
// A live spinner (animate-spin), not a fading pulse.
expect(container.querySelector(".animate-spin")).toBeTruthy();
// The running setup task is linkable…
const link = container.querySelector('a[href="/issues/issue-setup"]');
expect(link?.textContent).toContain("View setup task");
// …and "Run now" is not offered while the run is live (no duplicate/race).
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.includes("Run now"))).toBe(false);
});
it("renders an updating card with the delta banner and keeps the old summary", () => {
render(tile(baseCard({ generatingIssueId: "issue-9", pendingChangeCount: 3 })));
expect(container.querySelector('[data-lifecycle="updating"]')).toBeTruthy();
expect(container.textContent).toContain("Integrating 3 changes");
const link = container.querySelector('a[href="/issues/issue-9"]');
expect(link?.textContent).toContain("View update task");
expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toContain("All on track");
});
it("renders an error card with retry/details and the last good summary", () => {
render(tile(baseCard({ state: "error", failureReason: "run failed" })));
expect(container.querySelector('[data-lifecycle="error"]')).toBeTruthy();
expect(container.textContent).toContain("Last update failed");
expect(container.textContent).toContain("Retry");
expect(container.textContent).toContain("Showing last good summary");
});
it("renders paused (budget) with a paused banner", () => {
render(tile(baseCard({ state: "paused_budget" })));
expect(container.querySelector('[data-lifecycle="paused_budget"]')).toBeTruthy();
expect(container.textContent).toContain("Daily token cap reached");
});
it("renders paused (hours)", () => {
render(tile(baseCard({ state: "paused_hours" })));
expect(container.querySelector('[data-lifecycle="paused_hours"]')).toBeTruthy();
expect(container.textContent).toContain("Outside active hours");
});
it("shows tokens and cost in the footer when provided", () => {
render(tile(baseCard({ todayTokens: 1100, todayCostCents: 62 })));
expect(container.textContent).toContain("1.1k tok");
expect(container.textContent).toContain("$0.62");
});
it("opens the card when the tile body is clicked", () => {
let opened = 0;
render(tile(baseCard({}), { onOpen: () => (opened += 1) }));
const el = container.querySelector<HTMLElement>('[data-testid="status-card-tile"]');
flushSync(() => el?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(opened).toBe(1);
});
it("does not open the card when the actions menu trigger is clicked", () => {
let opened = 0;
render(tile(baseCard({}), { onOpen: () => (opened += 1) }));
const trigger = container.querySelector<HTMLElement>('[aria-label="Card actions"]');
flushSync(() => trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(opened).toBe(0);
});
it("offers a Run now action on a stuck compiling card (no setup run) without opening the card", () => {
let opened = 0;
let recompiled = 0;
render(
// generatingIssueId null → the first run stalled, so a manual re-kick is offered.
tile(baseCard({ state: "compiling", title: null, summaryBody: null, generatingIssueId: null }), {
onOpen: () => (opened += 1),
onRecompile: () => (recompiled += 1),
}),
);
expect(container.textContent).toContain("Setup didn’t finish");
const runButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Run now"));
expect(runButton).toBeTruthy();
flushSync(() => runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(recompiled).toBe(1);
expect(opened).toBe(0);
});
});