feat(annotations): include issue document annotations in agent review context (#11332)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Reviewers annotate plans and issue documents with inline comments,
and assigned agents act on that feedback
> - The server already builds a bounded review context from open plan
annotations and includes it in agent wake payloads
> - Non-plan issue documents did not get the same treatment: their open
annotation threads never reached the agent, and the properties pane did
not surface their annotations
> - This pull request extends the review-context path and the
properties-pane UI to issue documents, at parity with plans
> - The benefit is that agent feedback on any issue document reaches the
assigned agent, not only feedback on the plan

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The review-context pipeline that delivers inline annotation feedback to
assigned agents, and the properties pane that surfaces those annotations
to reviewers.

**Subsystem affected**

The server review-context path
(`server/src/services/plan-review-context.ts`, wake payload assembly in
`server/src/services/heartbeat.ts`, `server/src/routes/issues.ts`),
shared wake-payload types (`packages/shared`, `packages/adapter-utils`),
and the issue properties pane (`ui/src/components/issue-properties/`).

**Current behavior**

A reviewer can annotate any issue document, not only the plan. The agent
wake payload includes open annotation threads for the plan document
only. Feedback left on other issue documents is invisible to the
assigned agent. In the properties pane, the Artifacts tab also gives no
way to see or open a document's annotations.

**Proposed behavior**

Add `buildDocumentReviewContext` beside the existing plan builder. It
collects open annotation threads for all non-plan issue documents,
applies the same thread, comment, and character budgets across
documents, and reports truncation. Include the result as a new
`documentReviewContext` field in agent wake payloads and in the issue
wake-context route. Keep the plan context on its legacy builder and
field so plan-only wakes stay byte-for-byte compatible. Render the new
context in the adapter wake-payload text, and surface annotation counts
and the annotation panel for documents in the properties pane's Plans
and Artifacts tabs.

**Reason and benefit**

The floating annotation popover and persistent highlight UI landed
earlier; this change completes the loop so agent feedback on any issue
document reaches the assigned agent, not only feedback on the plan.

**Breaking changes**

None. The wake payload gains a new optional `documentReviewContext`
field; the existing plan context field and its legacy builder are
unchanged, so plan-only wakes stay byte-for-byte compatible.

## What Changed

- Add `buildDocumentReviewContext` in
`server/src/services/plan-review-context.ts`: bounded review context
(shared thread/comment/character budgets, per-document legacy limits)
over all non-plan issue documents
- Include `documentReviewContext` in agent wake payloads
(`server/src/services/heartbeat.ts`) and in the issue wake-context
response (`server/src/routes/issues.ts`)
- Add shared `DocumentReviewContext` / `DocumentReviewContextDocument`
types in `packages/shared`
- Normalize and render the new context in adapter wake-payload text
(`packages/adapter-utils/src/server-utils.ts`), with tests
- Show a `DocumentAnnotationsCountChip` and the annotation panel for
documents in the properties pane Plans and Artifacts tabs, with tests
- Extend server document-annotations service tests to cover the new
context builder

## Verification

- Run `npx vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/document-annotations-service.test.ts` from the repo
root — 104 tests pass
- Run `TZ=UTC npx vitest run
ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/IssueDocumentAnnotations.test.tsx
ui/src/components/DocumentAnnotationPopover.test.tsx` from the repo root
— 75 tests pass (one pre-existing monitor-row case asserts UTC
timestamps, so use `TZ=UTC` locally; CI runs in UTC)
- `pnpm run typecheck` in `server/` passes
- Manual: annotate a non-plan issue document, then wake the assigned
agent with a comment — the wake payload lists the open document
annotation threads; the Artifacts tab shows the annotation count chip
and opens the panel

## Risks

- The wake payload gains a new optional `documentReviewContext` field;
consumers that ignore unknown fields are unaffected, and the plan
context field is unchanged
- The context is new input to agent wakes; shared budgets (same limits
as the plan context) bound token cost across all documents
- Low UI risk: the properties-pane changes reuse the existing annotation
components

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude (Anthropic), model ID `claude-fable-5` (Claude Fable 5), with
extended thinking and agentic tool use (Claude Code harness)

## 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)
- [x] 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:
scotttong 2026-08-13 14:23:09 -07:00 committed by GitHub
parent 9b1fd42ac1
commit eabecc6f77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 631 additions and 36 deletions

View File

@ -1740,6 +1740,52 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt.indexOf("Open plan comments to incorporate:")).toBeLessThan(prompt.indexOf("New comments in order:"));
});
it("renders grouped non-plan document annotations with editing scope", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: { id: "issue-1", identifier: "PAP-522", title: "Document annotations", status: "in_progress" },
documentReviewContext: {
issueId: "issue-1",
documents: [{
documentKey: "qa-evidence",
documentId: "document-2",
title: "QA evidence",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
threads: [{
id: "thread-2",
documentKey: "qa-evidence",
documentId: "document-2",
status: "open",
revisionNumber: 3,
anchorState: "active",
anchorConfidence: "exact",
selectedText: "Passed in Chrome",
prefixText: "Evidence: ",
suffixText: ".",
comments: [{ id: "comment-2", threadId: "thread-2", body: "Attach the run id.", author: { type: "user", id: "board-user" } }],
commentCount: 1,
}],
totals: { openThreadCount: 1, includedThreadCount: 1, omittedThreadCount: 0, commentCount: 1, includedCommentCount: 1, omittedCommentCount: 0 },
truncated: true,
}],
totals: { openThreadCount: 1, includedThreadCount: 1, omittedThreadCount: 0, commentCount: 1, includedCommentCount: 1, omittedCommentCount: 0 },
truncated: true,
},
comments: [],
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
fallbackFetchNeeded: true,
});
expect(prompt).toContain("## Open document annotations");
expect(prompt).toContain("### QA evidence");
expect(prompt).toContain("selected text: Passed in Chrome");
expect(prompt).toContain("Attach the run id.");
expect(prompt).toContain("propose a child issue before making code changes");
expect(prompt).toContain("prefer replying and resolving the thread over rewriting the snapshot");
expect(prompt).toContain("[document review context truncated]");
});
it("renders dependency-blocked interaction guidance", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",

View File

@ -590,6 +590,14 @@ type PaperclipWakePlanReviewContext = {
truncated: boolean;
};
type PaperclipWakeDocumentReviewContext = {
issueId: string | null;
documents: Array<PaperclipWakePlanReviewContext & { title: string | null }>;
totals: PaperclipWakePlanReviewContext["totals"];
limits: PaperclipWakePlanReviewContext["limits"];
truncated: boolean;
};
type PaperclipWakeContinuationSummary = {
key: string | null;
title: string | null;
@ -678,6 +686,7 @@ type PaperclipWakePayload = {
executionStage: PaperclipWakeExecutionStage | null;
continuationSummary: PaperclipWakeContinuationSummary | null;
planReviewContext: PaperclipWakePlanReviewContext | null;
documentReviewContext: PaperclipWakeDocumentReviewContext | null;
livenessContinuation: PaperclipWakeLivenessContinuation | null;
taskWatchdog: PaperclipWakeTaskWatchdogContext | null;
interactionKind: string | null;
@ -993,6 +1002,41 @@ function normalizePaperclipWakePlanReviewContext(value: unknown): PaperclipWakeP
};
}
function normalizePaperclipWakeDocumentReviewContext(value: unknown): PaperclipWakeDocumentReviewContext | null {
const context = parseObject(value);
const issueId = asString(context.issueId, "").trim() || null;
const documents = Array.isArray(context.documents)
? context.documents.flatMap((value) => {
const document = parseObject(value);
const normalized = normalizePaperclipWakePlanReviewContext({ ...document, issueId });
return normalized
? [{ ...normalized, title: asString(document.title, "").trim() || null }]
: [];
})
: [];
if (!issueId && documents.length === 0) return null;
const totalsRaw = parseObject(context.totals);
const openThreadCount = asNumber(totalsRaw.openThreadCount, documents.reduce((sum, doc) => sum + doc.totals.openThreadCount, 0));
const includedThreadCount = asNumber(totalsRaw.includedThreadCount, documents.reduce((sum, doc) => sum + doc.totals.includedThreadCount, 0));
const commentCount = asNumber(totalsRaw.commentCount, documents.reduce((sum, doc) => sum + doc.totals.commentCount, 0));
const includedCommentCount = asNumber(totalsRaw.includedCommentCount, documents.reduce((sum, doc) => sum + doc.totals.includedCommentCount, 0));
const limits = documents[0]?.limits ?? null;
return {
issueId,
documents,
totals: {
openThreadCount,
includedThreadCount,
omittedThreadCount: asNumber(totalsRaw.omittedThreadCount, Math.max(0, openThreadCount - includedThreadCount)),
commentCount,
includedCommentCount,
omittedCommentCount: asNumber(totalsRaw.omittedCommentCount, Math.max(0, commentCount - includedCommentCount)),
},
limits,
truncated: asBoolean(context.truncated, false),
};
}
function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null {
const summary = parseObject(value);
const body = asString(summary.body, "").trim();
@ -1284,6 +1328,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary);
const planReviewContext = normalizePaperclipWakePlanReviewContext(payload.planReviewContext);
const documentReviewContext = normalizePaperclipWakeDocumentReviewContext(payload.documentReviewContext);
const annotationDeltas = Array.isArray(payload.annotationDeltas)
? payload.annotationDeltas
.map((entry) => normalizePaperclipWakeAnnotationDelta(entry))
@ -1312,7 +1357,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
const checkboxSelection = normalizePaperclipWakeCheckboxSelection(payload.checkboxSelection);
const executionWorkspace = normalizePaperclipWakeExecutionWorkspace(payload.executionWorkspace);
const agentMessage = normalizePaperclipWakeAgentMessage(payload.agentMessage);
if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !executionWorkspace && !agentMessage && !recovery && !normalizePaperclipWakeIssue(payload.issue)) {
if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !documentReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !executionWorkspace && !agentMessage && !recovery && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
@ -1330,6 +1375,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
executionStage,
continuationSummary,
planReviewContext,
documentReviewContext,
annotationDeltas,
livenessContinuation,
taskWatchdog,
@ -1760,6 +1806,50 @@ export function renderPaperclipWakePrompt(
}
}
if (normalized.documentReviewContext) {
const context = normalized.documentReviewContext;
lines.push(
"",
"## Open document annotations",
"",
"These open annotations are grouped by issue document. Resolved annotations were intentionally omitted.",
"Scope: a document annotation authorizes document edits and thread replies only; propose a child issue before making code changes.",
"For snapshot documents such as QA evidence and run summaries, prefer replying and resolving the thread over rewriting the snapshot.",
`- open annotation threads included: ${context.totals.includedThreadCount}/${context.totals.openThreadCount}`,
`- annotation comments included: ${context.totals.includedCommentCount}/${context.totals.commentCount}`,
);
for (const document of context.documents) {
lines.push(
"",
`### ${document.title ?? document.documentKey ?? "Document"}`,
`- document key: ${document.documentKey ?? "unknown"}`,
`- latest revision: ${document.latestRevisionNumber ?? "unknown"}${document.latestRevisionId ? ` (${document.latestRevisionId})` : ""}`,
);
for (const thread of document.threads) {
const state = [
thread.status,
thread.revisionNumber ? `revision #${thread.revisionNumber}` : null,
thread.anchorState,
thread.anchorConfidence,
].filter(Boolean).join(", ");
lines.push(`- thread ${thread.id ?? "unknown"}${state ? ` (${state})` : ""}`);
renderPlanReviewText(" selected text", thread.selectedText, thread.selectedTextTruncated);
renderPlanReviewText(" context before", thread.prefixText, thread.prefixTextTruncated);
renderPlanReviewText(" context after", thread.suffixText, thread.suffixTextTruncated);
for (const comment of thread.comments) {
lines.push(
` comment ${comment.id ?? "unknown"} by ${planReviewAuthorLabel(comment.author)}${comment.createdAt ? ` at ${comment.createdAt}` : ""}:`,
comment.body,
);
if (comment.bodyTruncated) lines.push("[document annotation comment body truncated]");
}
if (thread.commentsTruncated) lines.push("[document annotation thread comments truncated]");
}
if (document.truncated) lines.push("[document annotation context truncated]");
}
if (context.truncated) lines.push("[document review context truncated]");
}
if (executionStage) {
lines.push(
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,

View File

@ -914,6 +914,8 @@ export type {
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
PlanReviewContext,
DocumentReviewContext,
DocumentReviewContextDocument,
PlanReviewContextAuthor,
PlanReviewContextComment,
PlanReviewContextThread,

View File

@ -232,3 +232,22 @@ export interface PlanReviewContext {
};
truncated: boolean;
}
export interface DocumentReviewContextDocument {
documentKey: string;
documentId: string;
title: string | null;
latestRevisionId: string | null;
latestRevisionNumber: number | null;
threads: PlanReviewContextThread[];
totals: PlanReviewContext["totals"];
truncated: boolean;
}
export interface DocumentReviewContext {
issueId: string;
documents: DocumentReviewContextDocument[];
totals: PlanReviewContext["totals"];
limits: PlanReviewContext["limits"];
truncated: boolean;
}

View File

@ -311,6 +311,8 @@ export type {
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
PlanReviewContext,
DocumentReviewContext,
DocumentReviewContextDocument,
PlanReviewContextAuthor,
PlanReviewContextComment,
PlanReviewContextThread,

View File

@ -21,7 +21,7 @@ import {
import { documentAnnotationService } from "../services/document-annotations.js";
import { documentService } from "../services/documents.js";
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
import { buildPlanReviewContext, PLAN_REVIEW_CONTEXT_LIMITS } from "../services/plan-review-context.js";
import { buildDocumentReviewContext, buildPlanReviewContext, PLAN_REVIEW_CONTEXT_LIMITS } from "../services/plan-review-context.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -800,4 +800,61 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
],
});
});
it("groups non-plan annotations by most recently updated document and applies global caps", async () => {
const { companyId, issueId } = await createIssueWithDocument("standard");
const older = (await docs.upsertIssueDocument({
issueId,
key: "qa-evidence",
title: "QA evidence",
format: "markdown",
body: "Alpha selected text omega",
})).document;
const newer = (await docs.upsertIssueDocument({
issueId,
key: "run-summary",
title: "Run summary",
format: "markdown",
body: "Alpha selected text omega",
})).document;
await db.update(issueDocuments)
.set({ updatedAt: new Date("2026-06-01T00:00:00.000Z") })
.where(eq(issueDocuments.documentId, older.id));
await db.update(issueDocuments)
.set({ updatedAt: new Date("2026-06-02T00:00:00.000Z") })
.where(eq(issueDocuments.documentId, newer.id));
for (let index = 0; index < PLAN_REVIEW_CONTEXT_LIMITS.maxThreads + 1; index += 1) {
await annotations.createThread(
issueId,
index === 0 ? "run-summary" : "qa-evidence",
{
baseRevisionId: index === 0 ? newer.latestRevisionId! : older.latestRevisionId!,
baseRevisionNumber: index === 0 ? newer.latestRevisionNumber : older.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: `Annotation ${index}`,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
}
const context = await buildDocumentReviewContext({
db,
companyId,
issueId,
includeForIssueComment: true,
});
expect(context?.documents.map((document) => document.documentKey)).toEqual(["run-summary", "qa-evidence"]);
expect(context?.totals).toMatchObject({
openThreadCount: PLAN_REVIEW_CONTEXT_LIMITS.maxThreads + 1,
includedThreadCount: PLAN_REVIEW_CONTEXT_LIMITS.maxThreads,
omittedThreadCount: 1,
});
expect(context?.documents[1]).toMatchObject({ truncated: true });
expect(context?.truncated).toBe(true);
});
});

View File

@ -135,7 +135,7 @@ import {
routineService,
workProductService,
} from "../services/index.js";
import { buildPlanReviewContext } from "../services/plan-review-context.js";
import { buildDocumentReviewContext, buildPlanReviewContext } from "../services/plan-review-context.js";
import {
decideIssueReviewPathRecovery,
ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
@ -5982,6 +5982,12 @@ export function issueRoutes(
issueWorkMode: issue.workMode,
includeForIssueComment: wakeCommentId !== null,
});
const documentReviewContext = await buildDocumentReviewContext({
db,
companyId: issue.companyId,
issueId: issue.id,
includeForIssueComment: wakeCommentId !== null,
});
const response = {
issue: {
@ -6054,6 +6060,7 @@ export function issueRoutes(
}
: null,
planReviewContext,
documentReviewContext,
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
};
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, response));

View File

@ -172,7 +172,7 @@ import {
getIssueContinuationSummaryDocument,
refreshIssueContinuationSummary,
} from "./issue-continuation-summary.js";
import { buildPlanReviewContext } from "./plan-review-context.js";
import { buildDocumentReviewContext, buildPlanReviewContext } from "./plan-review-context.js";
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { workspaceOperationService, type WorkspaceOperationRecorder } from "./workspace-operations.js";
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
@ -5687,7 +5687,16 @@ export async function buildPaperclipWakePayload(input: {
interactionId,
})
: null;
const payloadTruncated = truncated || issueDescriptionTruncated || planReviewContext?.truncated === true;
const documentReviewContext = issueId
? await buildDocumentReviewContext({
db: input.db,
companyId: input.companyId,
issueId,
includeForIssueComment: commentIds.length > 0,
includeForAnnotationDelta: annotationDeltas.length > 0,
})
: null;
const payloadTruncated = truncated || issueDescriptionTruncated || planReviewContext?.truncated === true || documentReviewContext?.truncated === true;
const recoveryActionId = readNonEmptyString(input.contextSnapshot.recoveryActionId);
const recoveryCause = readNonEmptyString(input.contextSnapshot.recoveryCause);
const recoveryAction = recoveryActionId
@ -5799,6 +5808,7 @@ export async function buildPaperclipWakePayload(input: {
comments,
annotationDeltas,
planReviewContext,
documentReviewContext,
commentWindow: {
requestedCount: commentIds.length,
includedCount: comments.length,

View File

@ -8,6 +8,8 @@ import {
issueThreadInteractions,
} from "@paperclipai/db";
import type {
DocumentReviewContext,
DocumentReviewContextDocument,
PlanReviewContext,
PlanReviewContextAuthor,
PlanReviewInteractionContext,
@ -34,6 +36,8 @@ type BuildPlanReviewContextInput = {
interactionId?: string | null;
};
type BuildDocumentReviewContextInput = Omit<BuildPlanReviewContextInput, "issueWorkMode" | "interactionId">;
function nonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@ -328,3 +332,233 @@ export async function buildPlanReviewContext(input: BuildPlanReviewContextInput)
truncated,
};
}
/**
* Builds the non-plan half of issue-document review context. Plan context stays
* on its legacy builder and payload field so plan-only wakes remain byte-for-byte
* compatible. Budgets are shared across documents, while each document also
* observes the legacy per-document limits.
*/
export async function buildDocumentReviewContext(
input: BuildDocumentReviewContextInput,
): Promise<DocumentReviewContext | null> {
if (input.includeForIssueComment !== true && input.includeForAnnotationDelta !== true) return null;
const documentRows = await input.db
.select({
documentId: documents.id,
documentKey: issueDocuments.key,
title: documents.title,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
updatedAt: issueDocuments.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(
eq(issueDocuments.companyId, input.companyId),
eq(issueDocuments.issueId, input.issueId),
eq(documents.companyId, input.companyId),
sql`${issueDocuments.key} <> 'plan'`,
))
.orderBy(desc(issueDocuments.updatedAt), desc(issueDocuments.id));
if (documentRows.length === 0) return null;
let remainingThreads = PLAN_REVIEW_CONTEXT_LIMITS.maxThreads;
let remainingComments = PLAN_REVIEW_CONTEXT_LIMITS.maxComments;
let remainingBodyChars = PLAN_REVIEW_CONTEXT_LIMITS.maxTotalBodyChars;
let totalOpenThreads = 0;
let totalComments = 0;
let includedThreads = 0;
let includedComments = 0;
let truncated = false;
const groupedDocuments: DocumentReviewContextDocument[] = [];
for (const document of documentRows) {
const [{ count: openThreadCount }] = await input.db
.select({ count: sql<number>`count(*)::int` })
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, document.documentId),
eq(documentAnnotationThreads.documentKey, document.documentKey),
eq(documentAnnotationThreads.status, "open"),
));
if (openThreadCount === 0) continue;
const perDocumentThreadLimit = Math.min(PLAN_REVIEW_CONTEXT_LIMITS.maxThreads, remainingThreads);
const threadRows = perDocumentThreadLimit === 0 ? [] : await input.db
.select({
id: documentAnnotationThreads.id,
documentId: documentAnnotationThreads.documentId,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
revisionId: documentAnnotationThreads.currentRevisionId,
revisionNumber: documentAnnotationThreads.currentRevisionNumber,
anchorState: documentAnnotationThreads.anchorState,
anchorConfidence: documentAnnotationThreads.anchorConfidence,
selectedText: documentAnnotationThreads.selectedText,
prefixText: documentAnnotationThreads.prefixText,
suffixText: documentAnnotationThreads.suffixText,
createdByAgentId: documentAnnotationThreads.createdByAgentId,
createdByUserId: documentAnnotationThreads.createdByUserId,
createdAt: documentAnnotationThreads.createdAt,
updatedAt: documentAnnotationThreads.updatedAt,
})
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, document.documentId),
eq(documentAnnotationThreads.documentKey, document.documentKey),
eq(documentAnnotationThreads.status, "open"),
))
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id))
.limit(perDocumentThreadLimit);
const threadIds = threadRows.map((thread) => thread.id);
const perDocumentCommentLimit = Math.min(PLAN_REVIEW_CONTEXT_LIMITS.maxComments, remainingComments);
const commentRows = threadIds.length === 0 || perDocumentCommentLimit === 0 ? [] : await input.db
.select({
id: documentAnnotationComments.id,
threadId: documentAnnotationComments.threadId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdAt: documentAnnotationComments.createdAt,
updatedAt: documentAnnotationComments.updatedAt,
})
.from(documentAnnotationComments)
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.issueId, input.issueId),
eq(documentAnnotationComments.documentId, document.documentId),
inArray(documentAnnotationComments.threadId, threadIds),
))
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id))
.limit(perDocumentCommentLimit);
const [{ count: commentCount }] = await input.db
.select({ count: sql<number>`count(*)::int` })
.from(documentAnnotationComments)
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.issueId, input.issueId),
eq(documentAnnotationComments.documentId, document.documentId),
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, document.documentId),
eq(documentAnnotationThreads.documentKey, document.documentKey),
eq(documentAnnotationThreads.status, "open"),
));
const commentsByThread = new Map<string, typeof commentRows>();
for (const comment of commentRows) {
const current = commentsByThread.get(comment.threadId) ?? [];
current.push(comment);
commentsByThread.set(comment.threadId, current);
}
let documentIncludedComments = 0;
let documentTruncated = openThreadCount > threadRows.length;
const threads = threadRows.map((thread) => {
const selectedText = truncateText(thread.selectedText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
const prefixText = truncateText(thread.prefixText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
const suffixText = truncateText(thread.suffixText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
if (selectedText.truncated || prefixText.truncated || suffixText.truncated) documentTruncated = true;
const sourceComments = commentsByThread.get(thread.id) ?? [];
const comments = [];
for (const comment of sourceComments) {
if (remainingComments <= 0 || remainingBodyChars <= 0) {
documentTruncated = true;
break;
}
const body = truncateText(
comment.body,
Math.min(PLAN_REVIEW_CONTEXT_LIMITS.maxBodyChars, remainingBodyChars),
);
if (body.truncated) documentTruncated = true;
remainingBodyChars -= body.text.length;
remainingComments -= 1;
documentIncludedComments += 1;
includedComments += 1;
comments.push({
id: comment.id,
threadId: comment.threadId,
body: body.text,
bodyTruncated: body.truncated,
author: authorFrom(comment),
createdAt: comment.createdAt.toISOString(),
updatedAt: comment.updatedAt.toISOString(),
});
}
const commentsTruncated = comments.length < sourceComments.length;
if (commentsTruncated) documentTruncated = true;
return {
id: thread.id,
documentKey: thread.documentKey,
documentId: thread.documentId,
status: thread.status,
revisionId: thread.revisionId,
revisionNumber: thread.revisionNumber,
anchorState: thread.anchorState,
anchorConfidence: thread.anchorConfidence,
selectedText: selectedText.text,
selectedTextTruncated: selectedText.truncated,
prefixText: prefixText.text,
prefixTextTruncated: prefixText.truncated,
suffixText: suffixText.text,
suffixTextTruncated: suffixText.truncated,
author: authorFrom({ authorAgentId: thread.createdByAgentId, authorUserId: thread.createdByUserId }),
commentCount: sourceComments.length,
comments,
commentsTruncated,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
};
});
remainingThreads -= threads.length;
totalOpenThreads += openThreadCount;
totalComments += commentCount;
includedThreads += threads.length;
if (commentCount > documentIncludedComments) documentTruncated = true;
if (documentTruncated) truncated = true;
groupedDocuments.push({
documentKey: document.documentKey,
documentId: document.documentId,
title: document.title,
latestRevisionId: document.latestRevisionId,
latestRevisionNumber: document.latestRevisionNumber,
threads,
totals: {
openThreadCount,
includedThreadCount: threads.length,
omittedThreadCount: Math.max(0, openThreadCount - threads.length),
commentCount,
includedCommentCount: documentIncludedComments,
omittedCommentCount: Math.max(0, commentCount - documentIncludedComments),
},
truncated: documentTruncated,
});
}
if (groupedDocuments.length === 0) return null;
return {
issueId: input.issueId,
documents: groupedDocuments,
totals: {
openThreadCount: totalOpenThreads,
includedThreadCount: includedThreads,
omittedThreadCount: Math.max(0, totalOpenThreads - includedThreads),
commentCount: totalComments,
includedCommentCount: includedComments,
omittedCommentCount: Math.max(0, totalComments - includedComments),
},
limits: { ...PLAN_REVIEW_CONTEXT_LIMITS },
truncated,
};
}

View File

@ -25,7 +25,9 @@ import {
} from "@/lib/issue-artifacts";
import { attachmentOpenPath } from "@/lib/issue-attachments";
import { MarkdownBody } from "@/components/MarkdownBody";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { cn } from "@/lib/utils";
import { useLocation } from "@/lib/router";
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
@ -116,28 +118,51 @@ function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) {
return <div className={ROW_CLASS}>{body}</div>;
}
function DocumentRow({ doc }: { doc: IssueDocument }) {
function DocumentRow({ issueId, doc }: { issueId: string; doc: IssueDocument }) {
const [expanded, setExpanded] = useState(false);
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
const location = useLocation();
const Chevron = expanded ? ChevronDown : ChevronRight;
return (
<div className="rounded-md border border-border bg-card/50">
<button
type="button"
onClick={() => setExpanded((open) => !open)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm hover:bg-accent/50"
aria-expanded={expanded}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{documentDisplayTitle(doc)}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{`Rev ${doc.latestRevisionNumber ?? 1}`}
</span>
<Chevron className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
<div className="flex items-center hover:bg-accent/50">
<button
type="button"
onClick={() => setExpanded((open) => !open)}
className="flex min-w-0 flex-1 items-center gap-2 px-2.5 py-1.5 text-left text-sm"
aria-expanded={expanded}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{documentDisplayTitle(doc)}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{`Rev ${doc.latestRevisionNumber ?? 1}`}
</span>
<Chevron className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
<DocumentAnnotationsCountChip
issueId={issueId}
docKey={doc.key}
panelOpen={annotationPanelOpen}
onToggle={() => setAnnotationPanelOpen((open) => !open)}
/>
</div>
{expanded ? (
<div className="border-t border-border px-2.5 py-2">
{doc.body.trim().length > 0 ? (
<MarkdownBody>{doc.body}</MarkdownBody>
<IssueDocumentAnnotations
issueId={issueId}
doc={doc}
bodyMarkdown={doc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash={location.hash}
panelOpen={annotationPanelOpen}
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="popover"
>
<MarkdownBody>{doc.body}</MarkdownBody>
</IssueDocumentAnnotations>
) : (
<p className="text-sm text-muted-foreground">Document is empty.</p>
)}
@ -200,7 +225,7 @@ export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsT
<ul className="flex flex-col gap-1">
{documentRows.map((doc) => (
<li key={doc.key}>
<DocumentRow doc={doc} />
<DocumentRow issueId={issue.id} doc={doc} />
</li>
))}
</ul>

View File

@ -0,0 +1,77 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
const issueDocument: IssueDocument = {
id: "document-1",
companyId: "company-1",
issueId: "issue-1",
key: "qa-evidence",
title: "QA evidence",
format: "markdown",
body: "Shared annotation target",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
};
const issue = { id: "issue-1", identifier: "PAP-522", workMode: "standard" } as Issue;
vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: [] }) }));
vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: null, isLoading: false }) }));
vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: () => ({ data: [issueDocument] }) }));
vi.mock("@/lib/router", () => ({ useLocation: () => ({ hash: "" }) }));
vi.mock("@/components/IssuePlanDecompositionsSection", () => ({ IssuePlanDecompositionsSection: () => null }));
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
vi.mock("@/components/IssueDocumentAnnotations", () => ({
DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => <span data-testid={`annotation-count-${docKey}`} />,
IssueDocumentAnnotations: ({ doc, children }: { doc: IssueDocument; children: React.ReactNode }) => (
<div data-testid={`annotation-surface-${doc.key}`} data-document-id={doc.id}>{children}</div>
),
}));
describe("issue properties document annotation mounting", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("uses the issue document key on the Plan tab", async () => {
const root = createRoot(container);
await act(async () => root.render(<IssuePropertiesPlansTab issue={issue} />));
expect(container.querySelector('[data-testid="annotation-surface-qa-evidence"]')?.getAttribute("data-document-id"))
.toBe("document-1");
await act(async () => root.unmount());
});
it("shows the count while collapsed and mounts the same target when expanded on Artifacts", async () => {
const root = createRoot(container);
await act(async () => root.render(<IssuePropertiesArtifactsTab issue={issue} />));
expect(container.querySelector('[data-testid="annotation-count-qa-evidence"]')).not.toBeNull();
expect(container.querySelector('[data-testid="annotation-surface-qa-evidence"]')).toBeNull();
const expand = container.querySelector('button[aria-expanded="false"]') as HTMLButtonElement;
await act(async () => expand.click());
expect(container.querySelector('[data-testid="annotation-surface-qa-evidence"]')?.getAttribute("data-document-id"))
.toBe("document-1");
await act(async () => root.unmount());
});
});

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue, IssueThreadInteraction } from "@paperclipai/shared";
import type { Issue, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
@ -28,6 +28,45 @@ function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | und
);
}
function OtherDocumentSection({ issueId, doc, locationHash }: { issueId: string; doc: IssueDocument; locationHash: string }) {
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
return (
<section data-testid="issue-other-document" className="space-y-2 border-t border-border pt-4 first:border-t-0 first:pt-0">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold">{documentDisplayTitle(doc)}</h3>
<div className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
{`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`}
<DocumentAnnotationsCountChip
issueId={issueId}
docKey={doc.key}
panelOpen={annotationPanelOpen}
onToggle={() => setAnnotationPanelOpen((open) => !open)}
/>
</div>
</div>
<IssueDocumentAnnotations
issueId={issueId}
doc={doc}
bodyMarkdown={doc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash={locationHash}
panelOpen={annotationPanelOpen}
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="popover"
>
<MarkdownBody>{doc.body}</MarkdownBody>
</IssueDocumentAnnotations>
</section>
);
}
/**
* Plans tab of the properties pane.
*
@ -120,20 +159,7 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
</section>
) : null}
{otherDocuments.map((doc) => (
<section key={doc.key} data-testid="issue-other-document" className="space-y-2 border-t border-border pt-4 first:border-t-0 first:pt-0">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold">{documentDisplayTitle(doc)}</h3>
<span className="shrink-0 text-xs text-muted-foreground">
{`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`}
</span>
</div>
<MarkdownBody>{doc.body}</MarkdownBody>
</section>
<OtherDocumentSection key={doc.key} issueId={issue.id} doc={doc} locationHash={location.hash} />
))}
{hasPlans ? (
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />