diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 1bfea41d35..526fe36789 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -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", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 80b9909b23..23a73e09b9 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -590,6 +590,14 @@ type PaperclipWakePlanReviewContext = { truncated: boolean; }; +type PaperclipWakeDocumentReviewContext = { + issueId: string | null; + documents: Array; + 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"}`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ad7b5e8d6f..eb81657f74 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -914,6 +914,8 @@ export type { DocumentAnnotationThread, DocumentAnnotationThreadWithComments, PlanReviewContext, + DocumentReviewContext, + DocumentReviewContextDocument, PlanReviewContextAuthor, PlanReviewContextComment, PlanReviewContextThread, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index 43a193cd75..7769a42f1c 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 787f06bc9a..52ff2ed3a6 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -311,6 +311,8 @@ export type { DocumentAnnotationThread, DocumentAnnotationThreadWithComments, PlanReviewContext, + DocumentReviewContext, + DocumentReviewContextDocument, PlanReviewContextAuthor, PlanReviewContextComment, PlanReviewContextThread, diff --git a/server/src/__tests__/document-annotations-service.test.ts b/server/src/__tests__/document-annotations-service.test.ts index d27fd40822..bb2dbf006b 100644 --- a/server/src/__tests__/document-annotations-service.test.ts +++ b/server/src/__tests__/document-annotations-service.test.ts @@ -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); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 694e70490d..50ae72bda7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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)); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2500ac42b6..a63d435601 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, diff --git a/server/src/services/plan-review-context.ts b/server/src/services/plan-review-context.ts index e95e76704e..7a9f370105 100644 --- a/server/src/services/plan-review-context.ts +++ b/server/src/services/plan-review-context.ts @@ -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; + 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 { + 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`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`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(); + 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, + }; +} diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx index 2612d3080a..4b9b2f67ed 100644 --- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx @@ -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
{body}
; } -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 (
- +
+ + setAnnotationPanelOpen((open) => !open)} + /> +
{expanded ? (
{doc.body.trim().length > 0 ? ( - {doc.body} + + {doc.body} + ) : (

Document is empty.

)} @@ -200,7 +225,7 @@ export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsT
    {documentRows.map((doc) => (
  • - +
  • ))}
diff --git a/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx new file mode 100644 index 0000000000..6d6f4a0f4c --- /dev/null +++ b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx @@ -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 }) =>
{children}
})); +vi.mock("@/components/IssueDocumentAnnotations", () => ({ + DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => , + IssueDocumentAnnotations: ({ doc, children }: { doc: IssueDocument; children: React.ReactNode }) => ( +
{children}
+ ), +})); + +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()); + 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()); + 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()); + }); +}); diff --git a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx index 904e49b628..1a5f90ea4c 100644 --- a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx @@ -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 ( +
+
+

{documentDisplayTitle(doc)}

+
+ {`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`} + setAnnotationPanelOpen((open) => !open)} + /> +
+
+ + {doc.body} + +
+ ); +} + /** * Plans tab of the properties pane. * @@ -120,20 +159,7 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) ) : null} {otherDocuments.map((doc) => ( -
-
-

{documentDisplayTitle(doc)}

- - {`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })}`} - -
- {doc.body} -
+ ))} {hasPlans ? (