From 12b18be5d6caa531b77225cb0fd1096faad9c6c8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 23 Jun 2026 10:22:05 +0000 Subject: [PATCH] fix(document-comments): doc-order sort, live updates, save-failure surfacing, stable markdown selection Co-Authored-By: Paperclip --- .../schema/document_annotation_comments.ts | 9 +- .../src/schema/document_annotation_threads.ts | 9 +- .../shared/src/types/document-annotation.ts | 6 +- .../document-annotation-routes.test.ts | 12 + server/src/services/document-annotations.ts | 323 ++++++++++++ ui/src/api/document-annotations.ts | 43 +- ui/src/components/DocumentAnnotationLayer.tsx | 85 +++- ui/src/components/DocumentAnnotationPanel.tsx | 463 +++++++++++++----- .../IssueDocumentAnnotations.test.tsx | 317 +++++++++++- .../components/IssueDocumentAnnotations.tsx | 75 ++- .../components/MarkdownBody.rerender.test.tsx | 114 +++++ ui/src/components/MarkdownBody.tsx | 86 ++-- .../stories/document-comments.stories.tsx | 188 +++++++ 13 files changed, 1501 insertions(+), 229 deletions(-) create mode 100644 ui/src/components/MarkdownBody.rerender.test.tsx create mode 100644 ui/storybook/stories/document-comments.stories.tsx diff --git a/packages/db/src/schema/document_annotation_comments.ts b/packages/db/src/schema/document_annotation_comments.ts index e547c4508df..05214e9b021 100644 --- a/packages/db/src/schema/document_annotation_comments.ts +++ b/packages/db/src/schema/document_annotation_comments.ts @@ -7,6 +7,7 @@ import { documents } from "./documents.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; import { issueComments } from "./issue_comments.js"; import { issues } from "./issues.js"; +import { routines } from "./routines.js"; export const documentAnnotationComments = pgTable( "document_annotation_comments", @@ -14,7 +15,8 @@ export const documentAnnotationComments = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }), - issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), + routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), body: text("body").notNull(), authorType: text("author_type").$type().notNull(), @@ -36,6 +38,11 @@ export const documentAnnotationComments = pgTable( table.issueId, table.createdAt, ), + companyRoutineCreatedAtIdx: index("document_annotation_comments_company_routine_created_at_idx").on( + table.companyId, + table.routineId, + table.createdAt, + ), companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on( table.companyId, table.documentId, diff --git a/packages/db/src/schema/document_annotation_threads.ts b/packages/db/src/schema/document_annotation_threads.ts index c040fbb5cfd..a856c647b41 100644 --- a/packages/db/src/schema/document_annotation_threads.ts +++ b/packages/db/src/schema/document_annotation_threads.ts @@ -10,13 +10,15 @@ import { companies } from "./companies.js"; import { documentRevisions } from "./document_revisions.js"; import { documents } from "./documents.js"; import { issues } from "./issues.js"; +import { routines } from "./routines.js"; export const documentAnnotationThreads = pgTable( "document_annotation_threads", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), - issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), + routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), documentKey: text("document_key").notNull(), status: text("status").$type().notNull().default("open"), @@ -56,6 +58,11 @@ export const documentAnnotationThreads = pgTable( table.issueId, table.status, ), + companyRoutineStatusIdx: index("document_annotation_threads_company_routine_status_idx").on( + table.companyId, + table.routineId, + table.status, + ), companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on( table.companyId, table.documentId, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index a291707f4e7..ec975a954b9 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -55,7 +55,8 @@ export interface DocumentAnnotationAnchorSnapshot { export interface DocumentAnnotationThread { id: string; companyId: string; - issueId: string; + issueId: string | null; + routineId?: string | null; documentId: string; documentKey: string; status: DocumentAnnotationThreadStatus; @@ -86,7 +87,8 @@ export interface DocumentAnnotationComment { id: string; companyId: string; threadId: string; - issueId: string; + issueId: string | null; + routineId?: string | null; documentId: string; body: string; authorType: IssueCommentAuthorType; diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts index 3e5201d74cc..1fd64e02dc2 100644 --- a/server/src/__tests__/document-annotation-routes.test.ts +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -298,6 +298,10 @@ describe("document annotation routes", () => { expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_created", + details: expect.objectContaining({ + key: "plan", + documentKey: "plan", + }), })); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); @@ -324,6 +328,10 @@ describe("document annotation routes", () => { expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_comment_added", + details: expect.objectContaining({ + key: "plan", + documentKey: "plan", + }), })); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); @@ -334,6 +342,10 @@ describe("document annotation routes", () => { expect(resolved.body.status).toBe("resolved"); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_resolved", + details: expect.objectContaining({ + key: "plan", + documentKey: "plan", + }), })); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index d9fa9bfe38c..f98ce426860 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -7,6 +7,7 @@ import { documents, issueComments, issueDocuments, + routineDocuments, } from "@paperclipai/db"; import { anchorSnapshotToSelector, @@ -40,10 +41,21 @@ type IssueDocumentRow = { latestRevisionNumber: number; }; +type RoutineDocumentRow = { + routineId: string; + companyId: string; + documentId: string; + documentKey: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; +}; + const threadSelect = { id: documentAnnotationThreads.id, companyId: documentAnnotationThreads.companyId, issueId: documentAnnotationThreads.issueId, + routineId: documentAnnotationThreads.routineId, documentId: documentAnnotationThreads.documentId, documentKey: documentAnnotationThreads.documentKey, status: documentAnnotationThreads.status, @@ -75,6 +87,7 @@ const commentSelect = { companyId: documentAnnotationComments.companyId, threadId: documentAnnotationComments.threadId, issueId: documentAnnotationComments.issueId, + routineId: documentAnnotationComments.routineId, documentId: documentAnnotationComments.documentId, body: documentAnnotationComments.body, authorType: documentAnnotationComments.authorType, @@ -116,6 +129,27 @@ export function documentAnnotationService(db: Db) { .then((rows: IssueDocumentRow[]) => rows[0] ?? null); } + async function getRoutineDocument( + routineId: string, + key: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select({ + routineId: routineDocuments.routineId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: routineDocuments.key, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(routineDocuments) + .innerJoin(documents, eq(routineDocuments.documentId, documents.id)) + .where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))) + .then((rows: RoutineDocumentRow[]) => rows[0] ?? null); + } + async function getThreadForIssue( issueId: string, documentKey: string, @@ -133,6 +167,23 @@ export function documentAnnotationService(db: Db) { .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); } + async function getThreadForRoutine( + routineId: string, + documentKey: string, + threadId: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.id, threadId), + eq(documentAnnotationThreads.routineId, routineId), + eq(documentAnnotationThreads.documentKey, documentKey), + )) + .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); + } + async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise { if (threadIds.length === 0) return []; return dbOrTx @@ -197,6 +248,39 @@ export function documentAnnotationService(db: Db) { })); }, + listThreadsForRoutineDocument: async ( + routineId: string, + key: string, + options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {}, + ) => { + const doc = await getRoutineDocument(routineId, key); + if (!doc) throw notFound("Document not found"); + const conditions = [ + eq(documentAnnotationThreads.routineId, routineId), + eq(documentAnnotationThreads.documentId, doc.documentId), + ]; + if (options.status && options.status !== "all") { + conditions.push(eq(documentAnnotationThreads.status, options.status)); + } + const threads: DocumentAnnotationThread[] = await db + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and(...conditions)) + .orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)); + if (!options.includeComments) return threads; + const comments = await commentsForThreads(threads.map((thread) => thread.id)); + const commentsByThread = new Map(); + for (const comment of comments) { + const existing = commentsByThread.get(comment.threadId) ?? []; + existing.push(comment); + commentsByThread.set(comment.threadId, existing); + } + return threads.map((thread) => ({ + ...thread, + comments: commentsByThread.get(thread.id) ?? [], + })); + }, + getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => { const thread = await getThreadForIssue(issueId, key, threadId); if (!thread) return null; @@ -204,6 +288,13 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments }; }, + getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => { + const thread = await getThreadForRoutine(routineId, key, threadId); + if (!thread) return null; + const comments = await commentsForThreads([thread.id]); + return { ...thread, comments }; + }, + createThread: async ( issueId: string, key: string, @@ -291,6 +382,94 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments: [comment] }; }), + createRoutineThread: async ( + routineId: string, + key: string, + input: CreateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${routineDocuments} + inner join ${documents} on ${routineDocuments.documentId} = ${documents.id} + where ${and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))} + for update of ${documents} + `); + const doc = await getRoutineDocument(routineId, key, tx); + if (!doc) throw notFound("Document not found"); + if ( + input.baseRevisionId !== doc.latestRevisionId + || input.baseRevisionNumber !== doc.latestRevisionNumber + ) { + throw conflict("Annotation anchor requires the current document revision", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + }); + } + + const verification = verifyDocumentAnchorSelector({ + markdown: doc.latestBody, + selector: input.selector, + }); + if (!verification.ok || !verification.anchor) { + throw unprocessable("Annotation anchor does not match the current document revision", { + reason: verification.reason, + }); + } + + const now = new Date(); + const [thread] = await tx + .insert(documentAnnotationThreads) + .values({ + companyId: doc.companyId, + issueId: null, + routineId, + documentId: doc.documentId, + documentKey: doc.documentKey, + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: doc.latestRevisionId, + originalRevisionNumber: doc.latestRevisionNumber, + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + selectedText: verification.anchor.selectedText, + prefixText: verification.anchor.prefixText, + suffixText: verification.anchor.suffixText, + normalizedStart: verification.anchor.normalizedStart, + normalizedEnd: verification.anchor.normalizedEnd, + markdownStart: verification.anchor.markdownStart, + markdownEnd: verification.anchor.markdownEnd, + anchorSelector: input.selector, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(threadSelect); + + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: doc.companyId, + threadId: thread.id, + issueId: null, + routineId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + + return { ...thread, comments: [comment] }; + }), + addComment: async ( issueId: string, key: string, @@ -326,6 +505,41 @@ export function documentAnnotationService(db: Db) { return comment; }), + addRoutineComment: async ( + routineId: string, + key: string, + threadId: string, + input: CreateDocumentAnnotationComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForRoutine(routineId, key, threadId, tx); + if (!thread) throw notFound("Annotation thread not found"); + const now = new Date(); + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: thread.companyId, + threadId: thread.id, + issueId: null, + routineId: thread.routineId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + await tx + .update(documentAnnotationThreads) + .set({ updatedAt: now }) + .where(eq(documentAnnotationThreads.id, thread.id)); + return comment; + }), + cleanupForIssueCommentDeletion: async ( issueId: string, issueCommentId: string, @@ -425,6 +639,40 @@ export function documentAnnotationService(db: Db) { return updated; }), + updateRoutineThread: async ( + routineId: string, + key: string, + threadId: string, + input: UpdateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForRoutine(routineId, key, threadId, tx); + if (!thread) throw notFound("Annotation thread not found"); + if (!input.status || input.status === thread.status) return thread; + + const now = new Date(); + const [updated] = await tx + .update(documentAnnotationThreads) + .set(input.status === "resolved" + ? { + status: "resolved", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + } + : { + status: "open", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + return updated; + }), + remapOpenThreadsForDocument: async (input: { issueId: string; key: string; @@ -500,6 +748,81 @@ export function documentAnnotationService(db: Db) { return changed; }), + remapOpenThreadsForRoutineDocument: async (input: { + routineId: string; + key: string; + documentId: string; + nextRevisionId: string | null; + nextRevisionNumber: number; + nextBody: string; + }) => db.transaction(async (tx) => { + const threads: DocumentAnnotationThread[] = await tx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.routineId, input.routineId), + eq(documentAnnotationThreads.documentId, input.documentId), + eq(documentAnnotationThreads.status, "open"), + )); + const changed = []; + const now = new Date(); + + for (const thread of threads) { + if (thread.currentRevisionId === input.nextRevisionId) continue; + const previousAnchor = snapshotFromThread(thread); + const remap = remapDocumentAnchor({ + previousAnchor, + nextMarkdown: input.nextBody, + }); + const nextAnchor = remap.anchor; + const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector; + const [updated] = await tx + .update(documentAnnotationThreads) + .set({ + currentRevisionId: input.nextRevisionId, + currentRevisionNumber: input.nextRevisionNumber, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + ...(nextAnchor + ? { + selectedText: nextAnchor.selectedText, + prefixText: nextAnchor.prefixText, + suffixText: nextAnchor.suffixText, + normalizedStart: nextAnchor.normalizedStart, + normalizedEnd: nextAnchor.normalizedEnd, + markdownStart: nextAnchor.markdownStart, + markdownEnd: nextAnchor.markdownEnd, + } + : {}), + anchorSelector: nextSelector, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + const [snapshot] = await tx + .insert(documentAnnotationAnchorSnapshots) + .values({ + companyId: thread.companyId, + threadId: thread.id, + documentId: thread.documentId, + fromRevisionId: thread.currentRevisionId, + fromRevisionNumber: thread.currentRevisionNumber, + toRevisionId: input.nextRevisionId, + toRevisionNumber: input.nextRevisionNumber, + previousAnchor, + nextAnchor, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + failureReason: remap.anchor ? null : remap.reason, + createdAt: now, + }) + .returning(); + changed.push({ thread: updated, snapshot }); + } + + return changed; + }), + selectorToAnchorSnapshot, }; } diff --git a/ui/src/api/document-annotations.ts b/ui/src/api/document-annotations.ts index 81bad503e60..5a939c3a4c1 100644 --- a/ui/src/api/document-annotations.ts +++ b/ui/src/api/document-annotations.ts @@ -11,27 +11,50 @@ import { api } from "./client"; export type DocumentAnnotationListFilter = "open" | "resolved" | "all"; +export type DocumentAnnotationTarget = + | { kind: "issue"; issueId: string; documentKey: string } + | { kind: "routine"; routineId: string; documentKey: "description" }; + +function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget { + return { kind: "issue", issueId, documentKey }; +} + +function targetBasePath(target: DocumentAnnotationTarget) { + if (target.kind === "routine") { + return `/routines/${target.routineId}/description/annotations`; + } + return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; +} + export const documentAnnotationsApi = { list: ( issueId: string, key: string, options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {}, + ) => documentAnnotationsApi.listForTarget(issueTarget(issueId, key), options), + listForTarget: ( + target: DocumentAnnotationTarget, + options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {}, ) => { const params = new URLSearchParams(); if (options.status) params.set("status", options.status); if (options.includeComments) params.set("includeComments", "true"); const qs = params.toString(); return api.get( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`, + `${targetBasePath(target)}${qs ? `?${qs}` : ""}`, ); }, get: (issueId: string, key: string, threadId: string) => + documentAnnotationsApi.getForTarget(issueTarget(issueId, key), threadId), + getForTarget: (target: DocumentAnnotationTarget, threadId: string) => api.get( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + `${targetBasePath(target)}/${threadId}`, ), create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) => + documentAnnotationsApi.createForTarget(issueTarget(issueId, key), data), + createForTarget: (target: DocumentAnnotationTarget, data: CreateDocumentAnnotationThreadRequest) => api.post( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`, + targetBasePath(target), data, ), addComment: ( @@ -39,9 +62,14 @@ export const documentAnnotationsApi = { key: string, threadId: string, data: CreateDocumentAnnotationCommentRequest, + ) => documentAnnotationsApi.addCommentForTarget(issueTarget(issueId, key), threadId, data), + addCommentForTarget: ( + target: DocumentAnnotationTarget, + threadId: string, + data: CreateDocumentAnnotationCommentRequest, ) => api.post( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`, + `${targetBasePath(target)}/${threadId}/comments`, data, ), updateStatus: ( @@ -49,10 +77,15 @@ export const documentAnnotationsApi = { key: string, threadId: string, status: DocumentAnnotationThreadStatus, + ) => documentAnnotationsApi.updateStatusForTarget(issueTarget(issueId, key), threadId, status), + updateStatusForTarget: ( + target: DocumentAnnotationTarget, + threadId: string, + status: DocumentAnnotationThreadStatus, ) => { const payload: UpdateDocumentAnnotationThreadRequest = { status }; return api.patch( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + `${targetBasePath(target)}/${threadId}`, payload, ); }, diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 6859922ab60..7aab5ab415b 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -48,8 +48,17 @@ export interface AnnotationLayerProps { * pending anchor for the keyboard shortcut path. */ captureSelectionRequestId?: number; + /** + * Text of a comment currently being composed. We keep this segment brightly + * highlighted in the document even after the native browser selection is lost + * (e.g. once focus moves into the composer textarea). + */ + pendingHighlightText?: string | null; } +/** Synthetic thread id used to render the in-progress (pending) comment highlight. */ +const PENDING_HIGHLIGHT_THREAD_ID = "__paperclip-pending-annotation__"; + interface HighlightRect { threadId: string; status: DocumentAnnotationThreadStatus; @@ -60,6 +69,8 @@ interface HighlightRect { height: number; /** True for the last rect of this thread (used to anchor a glyph at the run end). */ isTail: boolean; + /** True when this run should render with the brighter focused/pending treatment. */ + focused: boolean; } interface ToolbarPosition { @@ -203,8 +214,10 @@ export function DocumentAnnotationLayer({ newCommentDisabledReason = null, hideResolved = true, captureSelectionRequestId, + pendingHighlightText = null, }: AnnotationLayerProps) { const [highlightRects, setHighlightRects] = useState([]); + const [hoveredThreadId, setHoveredThreadId] = useState(null); const [toolbarPosition, setToolbarPosition] = useState(null); const overlayRef = useRef(null); const lastCaptureSelectionRequestIdRef = useRef(0); @@ -231,19 +244,17 @@ export function DocumentAnnotationLayer({ const overlayRect = overlay.getBoundingClientRect(); const next: HighlightRect[] = []; const nativeRanges = emptyNativeHighlightRanges(); - for (const thread of visibleThreads) { - if (thread.anchorState === "orphaned") continue; - const isFocused = thread.id === focusedThreadId; - const isStale = thread.anchorState === "stale"; - const isResolved = thread.status === "resolved"; - const nativeKind = nativeHighlightKind({ - focused: isFocused, - stale: isStale, - resolved: isResolved, - }); + const pushRunRects = (run: { + threadId: string; + status: DocumentAnnotationThreadStatus; + anchorState: DocumentAnnotationAnchorState; + focused: boolean; + selectedText: string; + nativeKind: NativeHighlightKind; + }) => { const ranges = rangesForNormalizedSpan({ container, - selectedText: thread.selectedText, + selectedText: run.selectedText, }); const startIndex = next.length; for (const range of ranges) { @@ -256,9 +267,10 @@ export function DocumentAnnotationLayer({ if (!visibleRect) continue; rangeIsVisible = true; next.push({ - threadId: thread.id, - status: thread.status, - anchorState: thread.anchorState, + threadId: run.threadId, + status: run.status, + anchorState: run.anchorState, + focused: run.focused, top: visibleRect.top - overlayRect.top, left: visibleRect.left - overlayRect.left, width: visibleRect.width, @@ -266,15 +278,41 @@ export function DocumentAnnotationLayer({ isTail: false, }); } - if (rangeIsVisible) nativeRanges[nativeKind].push(range); + if (rangeIsVisible) nativeRanges[run.nativeKind].push(range); } if (next.length > startIndex) { next[next.length - 1].isTail = true; } + }; + for (const thread of visibleThreads) { + if (thread.anchorState === "orphaned") continue; + const isFocused = thread.id === focusedThreadId; + const isStale = thread.anchorState === "stale"; + const isResolved = thread.status === "resolved"; + pushRunRects({ + threadId: thread.id, + status: thread.status, + anchorState: thread.anchorState, + focused: isFocused, + selectedText: thread.selectedText, + nativeKind: nativeHighlightKind({ focused: isFocused, stale: isStale, resolved: isResolved }), + }); + } + // Keep the in-progress (pending) comment selection brightly highlighted so the + // segment stays anchored in the document while the composer is open. + if (pendingHighlightText && pendingHighlightText.trim().length > 0) { + pushRunRects({ + threadId: PENDING_HIGHLIGHT_THREAD_ID, + status: "open", + anchorState: "active", + focused: true, + selectedText: pendingHighlightText, + nativeKind: "focused", + }); } setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges); setHighlightRects(next); - }, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]); + }, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]); useLayoutEffect(() => { computeHighlightRects(); @@ -398,7 +436,7 @@ export function DocumentAnnotationLayer({