mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
fix(document-comments): doc-order sort, live updates, save-failure surfacing, stable markdown selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fa764504ed
commit
12b18be5d6
13 files changed
+1501
-229
No files matched your search
@@ -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<IssueCommentAuthorType>().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,
|
||||
|
||||
@@ -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<DocumentAnnotationThreadStatus>().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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<RoutineDocumentRow | null> {
|
||||
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<DocumentAnnotationThread | null> {
|
||||
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<DocumentAnnotationComment[]> {
|
||||
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<string, DocumentAnnotationComment[]>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<DocumentAnnotationThreadWithComments[]>(
|
||||
`/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<DocumentAnnotationThreadWithComments>(
|
||||
`/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<DocumentAnnotationThreadWithComments>(
|
||||
`/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<DocumentAnnotationComment>(
|
||||
`/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<DocumentAnnotationThread>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
`${targetBasePath(target)}/${threadId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<HighlightRect[]>([]);
|
||||
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastCaptureSelectionRequestIdRef = useRef<number>(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({
|
||||
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
|
||||
<div className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
const isFocused = rect.focused;
|
||||
const isStale = rect.anchorState === "stale";
|
||||
const isResolved = rect.status === "resolved";
|
||||
return (
|
||||
@@ -437,7 +475,9 @@ export function DocumentAnnotationLayer({
|
||||
>
|
||||
<div ref={overlayRef} className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
if (rect.threadId === PENDING_HIGHLIGHT_THREAD_ID) return null;
|
||||
const isFocused = rect.focused;
|
||||
const isHovered = rect.threadId === hoveredThreadId;
|
||||
return (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
@@ -446,9 +486,12 @@ export function DocumentAnnotationLayer({
|
||||
data-anchor-state={rect.anchorState}
|
||||
data-status={rect.status}
|
||||
data-focused={isFocused || undefined}
|
||||
data-hovered={isHovered || undefined}
|
||||
aria-label="Open annotation thread"
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent transition-colors",
|
||||
// Tint the run on hover so it's obvious which highlight you're over.
|
||||
isHovered && "bg-amber-400/40 dark:bg-amber-300/30",
|
||||
isFocused && "ring-1 ring-transparent",
|
||||
)}
|
||||
style={{
|
||||
@@ -457,6 +500,10 @@ export function DocumentAnnotationLayer({
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredThreadId(rect.threadId)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredThreadId((current) => (current === rect.threadId ? null : current))
|
||||
}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onThreadFocus(rect.threadId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationThreadStatus,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -22,26 +21,23 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import type { PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
|
||||
|
||||
const FILTERS: { id: AnnotationFilter; label: string }[] = [
|
||||
{ id: "open", label: "Open" },
|
||||
{ id: "resolved", label: "Resolved" },
|
||||
{ id: "stale", label: "Stale" },
|
||||
{ id: "orphan", label: "Orphaned" },
|
||||
];
|
||||
|
||||
export interface AnnotationPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
issueId: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
issueId?: string;
|
||||
documentKey: string;
|
||||
documentRevisionNumber: number;
|
||||
baseRevisionId: string | null;
|
||||
@@ -63,7 +59,7 @@ export interface AnnotationPanelProps {
|
||||
desktopWidth?: number;
|
||||
className?: string;
|
||||
/** Resolve `<authorAgentId>` to a display name. */
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
/** Resolve `<authorUserId>` to a display name. */
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}
|
||||
@@ -107,76 +103,195 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
|
||||
function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<AnnotationFilter>("open");
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
|
||||
if (props.target) return props.target;
|
||||
if (!props.issueId) throw new Error("Document annotation panel requires an annotation target.");
|
||||
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
|
||||
}, [props.documentKey, props.issueId, props.target]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return props.threads.filter((thread) => {
|
||||
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
|
||||
if (filter === "resolved") return thread.status === "resolved";
|
||||
if (filter === "stale") return thread.anchorState === "stale";
|
||||
if (filter === "orphan") return thread.anchorState === "orphaned";
|
||||
return true;
|
||||
});
|
||||
}, [props.threads, filter]);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const currentUser = useMemo(() => {
|
||||
const user = session?.user;
|
||||
return {
|
||||
id: user?.id ?? null,
|
||||
name: user?.name?.trim() || user?.email?.trim() || "You",
|
||||
image: user?.image ?? null,
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
|
||||
for (const thread of props.threads) {
|
||||
if (thread.status === "resolved") result.resolved += 1;
|
||||
if (thread.anchorState === "stale") result.stale += 1;
|
||||
if (thread.anchorState === "orphaned") result.orphan += 1;
|
||||
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
|
||||
}
|
||||
return result;
|
||||
}, [props.threads]);
|
||||
// Show every thread that can be anchored in the document (orphaned threads have
|
||||
// lost their anchor). Filters were removed in favour of a single simple list.
|
||||
// Sort in document order (top-to-bottom) — not by recency — so the comment list
|
||||
// stays congruent with the highlights as you scroll the document.
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
props.threads
|
||||
.filter((thread) => thread.anchorState !== "orphaned")
|
||||
.sort((a, b) =>
|
||||
(a.normalizedStart - b.normalizedStart)
|
||||
|| (a.markdownStart - b.markdownStart)
|
||||
|| (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())),
|
||||
[props.threads],
|
||||
);
|
||||
|
||||
const annotationsQueryKey = useMemo(
|
||||
() => annotationTarget.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
|
||||
[annotationTarget],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === props.issueId
|
||||
&& query.queryKey[3] === props.documentKey,
|
||||
predicate: (query) => {
|
||||
if (!Array.isArray(query.queryKey)) return false;
|
||||
if (annotationTarget.kind === "routine") {
|
||||
return query.queryKey[0] === "routines"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.routineId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
}
|
||||
return query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.issueId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
},
|
||||
});
|
||||
}, [props.documentKey, props.issueId, queryClient]);
|
||||
}, [annotationTarget, queryClient]);
|
||||
|
||||
const createThread = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
|
||||
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
|
||||
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
|
||||
return documentAnnotationsApi.createForTarget(annotationTarget, {
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
selector: props.pendingAnchor.selector,
|
||||
body,
|
||||
});
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
// Optimistically drop the new thread into the cache so submission feels instant.
|
||||
onMutate: async (body: string) => {
|
||||
const anchor = props.pendingAnchor;
|
||||
if (!anchor || !props.baseRevisionId) return undefined;
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticThread = buildOptimisticThread({
|
||||
body,
|
||||
selectedText: anchor.selectedText,
|
||||
target: annotationTarget,
|
||||
documentKey: annotationTarget.documentKey,
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
normalizedStart: anchor.selector.position.normalizedStart,
|
||||
markdownStart: anchor.selector.position.markdownStart,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => [...(current ?? []), optimisticThread],
|
||||
);
|
||||
props.onFocusThread(optimisticThread.id);
|
||||
return { previous, optimisticId: optimisticThread.id };
|
||||
},
|
||||
onError: (error, _body, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to create comment.");
|
||||
},
|
||||
onSuccess: (thread, _body, context) => {
|
||||
// Swap the optimistic placeholder for the real thread before refetch settles.
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((entry) =>
|
||||
entry.id === context?.optimisticId ? thread : entry,
|
||||
),
|
||||
);
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
setMutationError(null);
|
||||
props.onFocusThread(thread.id);
|
||||
invalidateAll();
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
|
||||
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
|
||||
onSuccess: (_data, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
invalidateAll();
|
||||
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
|
||||
// Optimistically append the reply so it stays on screen through the round-trip.
|
||||
onMutate: async ({ threadId, body }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticComment = buildOptimisticComment({
|
||||
body,
|
||||
threadId,
|
||||
target: annotationTarget,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId
|
||||
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to add reply.");
|
||||
},
|
||||
onSuccess: (_comment, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
setMutationError(null);
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||
onSuccess: () => invalidateAll(),
|
||||
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
|
||||
onMutate: async ({ threadId, status }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId ? { ...thread, status } : thread,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to update comment status.");
|
||||
},
|
||||
onSuccess: () => setMutationError(null),
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,28 +306,31 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
}
|
||||
}, [props.open, props.pendingAnchor]);
|
||||
|
||||
// Keep the comment list congruent with the document: when a thread becomes
|
||||
// focused — whether by clicking its highlight in the doc or by adding a new
|
||||
// comment — scroll that card into view in the pane.
|
||||
const listScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.focusedThreadId) return;
|
||||
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
|
||||
if (!focused) return;
|
||||
if (focused.anchorState === "orphaned") setFilter("orphan");
|
||||
else if (focused.anchorState === "stale") setFilter("stale");
|
||||
else if (focused.status === "resolved") setFilter("resolved");
|
||||
else setFilter("open");
|
||||
}, [props.focusedThreadId, props.threads]);
|
||||
const container = listScrollRef.current;
|
||||
if (!container) return;
|
||||
const card = container.querySelector<HTMLElement>(
|
||||
`[data-thread-id="${props.focusedThreadId}"]`,
|
||||
);
|
||||
if (card && typeof card.scrollIntoView === "function") {
|
||||
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [props.focusedThreadId, visibleThreads]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<div
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
|
||||
className="flex items-center justify-end gap-1 border-b border-border bg-popover px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
@@ -226,31 +344,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border bg-popover px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(entry.id)}
|
||||
data-active={isActive || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
|
||||
isActive
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{entry.label}</span>
|
||||
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
@@ -260,14 +353,18 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
</p>
|
||||
) : (
|
||||
{mutationError ? (
|
||||
<p
|
||||
data-testid="document-annotation-error"
|
||||
className="border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
||||
>
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{visibleThreads.length === 0 ? null : (
|
||||
<ul className="space-y-2">
|
||||
{filteredThreads.map((thread) => (
|
||||
{visibleThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
@@ -303,24 +400,40 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-popover px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground">
|
||||
<blockquote className="mb-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{currentUser.image ? <AvatarImage src={currentUser.image} alt={currentUser.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(currentUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{currentUser.name}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
data-testid="document-annotation-composer"
|
||||
rows={3}
|
||||
value={composerValue}
|
||||
onChange={(event) => setComposerValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isSubmitShortcut(event)) {
|
||||
event.preventDefault();
|
||||
const body = composerValue.trim();
|
||||
if (
|
||||
body
|
||||
&& !createThread.isPending
|
||||
&& !props.newCommentDisabled
|
||||
&& props.baseRevisionId
|
||||
) {
|
||||
createThread.mutate(body);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Write a comment…"
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y rounded-none text-sm"
|
||||
/>
|
||||
{createThread.isError ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{(createThread.error as Error).message || "Failed to create comment"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -365,18 +478,10 @@ function ThreadCard(props: {
|
||||
onCopyLink: () => void;
|
||||
pendingReply: boolean;
|
||||
pendingStatus: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
|
||||
thread.status === "resolved"
|
||||
? { variant: "outline", label: "Resolved" }
|
||||
: thread.anchorState === "orphaned"
|
||||
? { variant: "outline", label: "Orphaned" }
|
||||
: thread.anchorState === "stale"
|
||||
? { variant: "outline", label: "Stale" }
|
||||
: { variant: "default", label: "Open" };
|
||||
const latestComment = thread.comments[thread.comments.length - 1];
|
||||
|
||||
return (
|
||||
@@ -389,23 +494,17 @@ function ThreadCard(props: {
|
||||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-1 ring-ring/70",
|
||||
"scroll-mt-2 rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-2 ring-primary/80 ring-offset-1 ring-offset-popover",
|
||||
thread.status === "resolved" && "bg-muted",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
<span>{relativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
|
||||
"mx-3 mt-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -427,6 +526,14 @@ function ThreadCard(props: {
|
||||
rows={2}
|
||||
value={props.replyDraft}
|
||||
onChange={(event) => props.onReplyChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isSubmitShortcut(event)) {
|
||||
event.preventDefault();
|
||||
if (props.replyDraft.trim() && !props.pendingReply) {
|
||||
props.onSubmitReply();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Reply…"
|
||||
className="resize-y rounded-none text-sm"
|
||||
disabled={props.pendingReply}
|
||||
@@ -506,7 +613,7 @@ function CommentRow({
|
||||
}: {
|
||||
comment: DocumentAnnotationComment;
|
||||
focused: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const author = resolveAuthor(comment, { agentMap, userProfileMap });
|
||||
@@ -520,31 +627,49 @@ function CommentRow({
|
||||
)}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">{author.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{author.role === "agent" ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<>
|
||||
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="truncate font-medium text-foreground">{author.name}</span>
|
||||
{author.role === "agent" ? (
|
||||
<span className="ml-1 text-muted-foreground">· agent</span>
|
||||
<span className="text-muted-foreground">· agent</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ⌘/Ctrl + Enter submits the composer or reply. */
|
||||
function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
|
||||
}
|
||||
|
||||
function resolveAuthor(
|
||||
comment: DocumentAnnotationComment,
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): { name: string; role: "board" | "agent" } {
|
||||
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
|
||||
if (comment.authorAgentId) {
|
||||
const agent = maps.agentMap?.get(comment.authorAgentId);
|
||||
return {
|
||||
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
|
||||
role: "agent",
|
||||
agentIcon: agent?.icon,
|
||||
};
|
||||
}
|
||||
if (comment.authorUserId) {
|
||||
@@ -552,11 +677,93 @@ function resolveAuthor(
|
||||
return {
|
||||
name: profile?.label ?? comment.authorUserId.slice(0, 8),
|
||||
role: "board",
|
||||
imageUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
|
||||
}
|
||||
|
||||
interface OptimisticAuthor {
|
||||
id: string | null;
|
||||
name: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
function optimisticId(prefix: string): string {
|
||||
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
||||
return `${prefix}-${random}`;
|
||||
}
|
||||
|
||||
function buildOptimisticComment(input: {
|
||||
body: string;
|
||||
threadId: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationComment {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: optimisticId("optimistic-comment"),
|
||||
companyId: "",
|
||||
threadId: input.threadId,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentId: "",
|
||||
body: input.body,
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: input.author.id,
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptimisticThread(input: {
|
||||
body: string;
|
||||
selectedText: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
documentKey: string;
|
||||
baseRevisionId: string;
|
||||
baseRevisionNumber: number;
|
||||
normalizedStart: number;
|
||||
markdownStart: number;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationThreadWithComments {
|
||||
const id = optimisticId("optimistic-thread");
|
||||
const now = new Date();
|
||||
const comment = buildOptimisticComment({
|
||||
body: input.body,
|
||||
threadId: id,
|
||||
target: input.target,
|
||||
author: input.author,
|
||||
});
|
||||
// Only the fields the panel + overlay read need to be accurate; the optimistic
|
||||
// thread is swapped for the server copy on success. Cast through unknown so we
|
||||
// don't have to fabricate every backend-only column.
|
||||
return {
|
||||
id,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentKey: input.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
selectedText: input.selectedText,
|
||||
normalizedStart: input.normalizedStart,
|
||||
markdownStart: input.markdownStart,
|
||||
originalRevisionId: input.baseRevisionId,
|
||||
originalRevisionNumber: input.baseRevisionNumber,
|
||||
currentRevisionId: input.baseRevisionId,
|
||||
currentRevisionNumber: input.baseRevisionNumber,
|
||||
createdByUserId: input.author.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
comments: [comment],
|
||||
} as unknown as DocumentAnnotationThreadWithComments;
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
|
||||
@@ -13,13 +13,31 @@ import {
|
||||
IssueDocumentAnnotations,
|
||||
} from "./IssueDocumentAnnotations";
|
||||
|
||||
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
const mockAnnotationsApi = vi.hoisted(() => {
|
||||
const api = {
|
||||
list: vi.fn(),
|
||||
listForTarget: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getForTarget: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createForTarget: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
addCommentForTarget: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
updateStatusForTarget: vi.fn(),
|
||||
};
|
||||
api.listForTarget.mockImplementation((target, options) =>
|
||||
target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options));
|
||||
api.getForTarget.mockImplementation((target, threadId) =>
|
||||
target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId));
|
||||
api.createForTarget.mockImplementation((target, data) =>
|
||||
target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data));
|
||||
api.addCommentForTarget.mockImplementation((target, threadId, data) =>
|
||||
target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data));
|
||||
api.updateStatusForTarget.mockImplementation((target, threadId, status) =>
|
||||
target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status));
|
||||
return api;
|
||||
});
|
||||
|
||||
const mockPendingAnchor = vi.hoisted(() => ({
|
||||
selector: {
|
||||
@@ -110,6 +128,12 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchSubmitShortcut(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -342,6 +366,61 @@ describe("IssueDocumentAnnotations", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offsets the desktop annotation panel from the document with a left margin when there is room", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
if (this instanceof HTMLElement && this.id === "main-content") {
|
||||
return rectFor(0, 0, 1400, 800);
|
||||
}
|
||||
if (
|
||||
this instanceof HTMLElement
|
||||
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
|
||||
) {
|
||||
return rectFor(80, 120, 640, 620);
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<main id="main-content">
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</main>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
|
||||
expect(anchor).not.toBeNull();
|
||||
// The document body ends at 640; the panel should clear it with a margin
|
||||
// rather than sitting flush against the document's right edge.
|
||||
expect(parseFloat(anchor!.style.left)).toBeGreaterThan(640);
|
||||
expect(anchor!.style.left).toBe("664px");
|
||||
} finally {
|
||||
rectSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
|
||||
const root = createRoot(container);
|
||||
@@ -387,10 +466,11 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(reason!.textContent).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("filters resolved threads behind their tab", async () => {
|
||||
it("shows open and resolved threads together in a single list (no filter tabs)", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
makeThread({ id: "orphan-1", anchorState: "orphaned" }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
@@ -406,19 +486,43 @@ describe("IssueDocumentAnnotations", () => {
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Open filter shows only open
|
||||
// Open + resolved both render without any filter interaction.
|
||||
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
// Orphaned threads can't be anchored in the doc, so they stay hidden.
|
||||
expect(container.querySelector('[data-thread-id="orphan-1"]')).toBeNull();
|
||||
|
||||
// Switch to Resolved
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
// The Open/Resolved/Stale/Orphaned filter chips are gone.
|
||||
const filterChip = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
["Open", "Resolved", "Stale", "Orphaned"].includes((button.textContent ?? "").trim()),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
expect(filterChip).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders threads by document position, not API/recency order", async () => {
|
||||
// Returned out of document order: later-in-doc first, earlier-in-doc last.
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "thread-late", normalizedStart: 900, markdownStart: 900 }),
|
||||
makeThread({ id: "thread-early", normalizedStart: 10, markdownStart: 10 }),
|
||||
makeThread({ id: "thread-mid", normalizedStart: 400, markdownStart: 400 }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
const order = Array.from(container.querySelectorAll("[data-thread-id]"))
|
||||
.map((el) => el.getAttribute("data-thread-id"));
|
||||
expect(order).toEqual(["thread-early", "thread-mid", "thread-late"]);
|
||||
});
|
||||
|
||||
it("renders author name + role from agent and user maps", async () => {
|
||||
@@ -500,11 +604,15 @@ describe("IssueDocumentAnnotations", () => {
|
||||
await act(async () => threadCard!.click());
|
||||
await flush();
|
||||
|
||||
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
|
||||
const expandedThread = container.querySelector('[data-thread-id="open-1"]');
|
||||
const expandedText = expandedThread?.textContent ?? "";
|
||||
expect(expandedText).toContain("Dotta");
|
||||
expect(expandedText).not.toContain("· board");
|
||||
expect(expandedText).toContain("UXDesigner");
|
||||
expect(expandedText).toContain("· agent");
|
||||
// Each rendered comment shows an author avatar.
|
||||
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
|
||||
expect(avatars.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not render a persistent New comment on selection hint when panel is open", async () => {
|
||||
@@ -624,6 +732,173 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps the composer visible with the draft when creating a thread fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockRejectedValue(new Error("Annotation anchor does not match the current document revision"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
expect(selectButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
selectButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => {
|
||||
setTextareaValue(composer!, "New anchored comment");
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Comment",
|
||||
);
|
||||
expect(submit).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
submit!.click();
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const composerAfterFailure = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composerAfterFailure).not.toBeNull();
|
||||
expect(composerAfterFailure!.value).toBe("New anchored comment");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Annotation anchor does not match the current document revision");
|
||||
});
|
||||
|
||||
it("submits a new anchored comment with ⌘↵", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
await act(async () => selectButton!.click());
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => setTextareaValue(composer!, "Submitted via shortcut"));
|
||||
await flush();
|
||||
await act(async () => dispatchSubmitShortcut(composer!));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
||||
baseRevisionId: "rev-4",
|
||||
baseRevisionNumber: 4,
|
||||
selector: mockPendingAnchor.selector,
|
||||
body: "Submitted via shortcut",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a reply with ⌘↵", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
||||
mockAnnotationsApi.addComment.mockResolvedValue(makeThread({ id: "open-1" }).comments[0]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const reply = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(reply).not.toBeNull();
|
||||
await act(async () => setTextareaValue(reply!, "Replying via shortcut"));
|
||||
await flush();
|
||||
await act(async () => dispatchSubmitShortcut(reply!));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockAnnotationsApi.addComment).toHaveBeenCalledWith("issue-1", "plan", "open-1", {
|
||||
body: "Replying via shortcut",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a reply draft visible when submitting the reply fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
||||
mockAnnotationsApi.addComment.mockRejectedValue(new Error("Failed to add reply"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
expect(openThread).not.toBeNull();
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const reply = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(reply).not.toBeNull();
|
||||
await act(async () => setTextareaValue(reply!, "Reply should stay visible"));
|
||||
await flush();
|
||||
|
||||
const replyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Reply",
|
||||
);
|
||||
expect(replyButton).not.toBeUndefined();
|
||||
await act(async () => replyButton!.click());
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const replyAfterFailure = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(replyAfterFailure).not.toBeNull();
|
||||
expect(replyAfterFailure!.value).toBe("Reply should stay visible");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Failed to add reply");
|
||||
});
|
||||
|
||||
it("shows resolve and reopen actions and updates thread status", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
@@ -657,13 +932,7 @@ describe("IssueDocumentAnnotations", () => {
|
||||
await flush();
|
||||
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
|
||||
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
// Resolved threads stay in the same list (filter tabs were removed).
|
||||
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
||||
expect(resolvedThread).not.toBeNull();
|
||||
await act(async () => resolvedThread!.click());
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
@@ -13,12 +13,15 @@ import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
|
||||
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 24;
|
||||
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
|
||||
|
||||
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
|
||||
|
||||
export interface IssueDocumentAnnotationsProps {
|
||||
issueId: string;
|
||||
doc: IssueDocument;
|
||||
doc: AnnotationDocument;
|
||||
target?: DocumentAnnotationTarget;
|
||||
/** The body that is being rendered/edited (current or historical revision). */
|
||||
bodyMarkdown: string;
|
||||
/** True when a draft has unsaved changes or is currently saving. */
|
||||
@@ -34,7 +37,7 @@ export interface IssueDocumentAnnotationsProps {
|
||||
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
|
||||
panelOpen: boolean;
|
||||
onPanelOpenChange: (open: boolean) => void;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
|
||||
defaultFocusedThreadId?: string;
|
||||
@@ -43,6 +46,7 @@ export interface IssueDocumentAnnotationsProps {
|
||||
export function IssueDocumentAnnotations({
|
||||
issueId,
|
||||
doc,
|
||||
target,
|
||||
bodyMarkdown,
|
||||
draftDirty,
|
||||
draftConflicted,
|
||||
@@ -105,7 +109,12 @@ export function IssueDocumentAnnotations({
|
||||
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
);
|
||||
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
|
||||
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
|
||||
// Clamp the panel below the sticky top nav (the scroll container's top edge)
|
||||
// so the comments thread never tucks under the nav bar while scrolling.
|
||||
const boundaryTop = boundaryRect?.top ?? DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const minTop = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, boundaryTop)
|
||||
+ DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const top = Math.max(minTop, rect.top);
|
||||
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||
const spaceRightOfDocument = boundaryRight
|
||||
- desiredLeft
|
||||
@@ -147,8 +156,12 @@ export function IssueDocumentAnnotations({
|
||||
}, [doc.key, isMobile, panelOpen]);
|
||||
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allThreads = annotationsQuery.data ?? [];
|
||||
@@ -242,6 +255,30 @@ export function IssueDocumentAnnotations({
|
||||
[allThreads],
|
||||
);
|
||||
|
||||
const fallbackDesktopPanelFrame = useMemo(() => {
|
||||
if (!panelOpen || isMobile || desktopPanelFrame || typeof window === "undefined") return null;
|
||||
const width = Math.min(
|
||||
DESKTOP_ANNOTATION_PANEL_WIDTH,
|
||||
Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
|
||||
window.innerWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
),
|
||||
);
|
||||
return {
|
||||
left: Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
window.innerWidth - width - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
),
|
||||
top: DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
maxHeight: Math.max(
|
||||
240,
|
||||
window.innerHeight - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
),
|
||||
width,
|
||||
};
|
||||
}, [desktopPanelFrame, isMobile, panelOpen]);
|
||||
const renderedDesktopPanelFrame = desktopPanelFrame ?? fallbackDesktopPanelFrame;
|
||||
|
||||
const annotationPanel = panelOpen ? (
|
||||
<DocumentAnnotationPanel
|
||||
open={panelOpen}
|
||||
@@ -255,6 +292,7 @@ export function IssueDocumentAnnotations({
|
||||
}
|
||||
}}
|
||||
issueId={issueId}
|
||||
target={target}
|
||||
documentKey={doc.key}
|
||||
documentRevisionNumber={doc.latestRevisionNumber}
|
||||
baseRevisionId={doc.latestRevisionId}
|
||||
@@ -272,7 +310,7 @@ export function IssueDocumentAnnotations({
|
||||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
isMobile={isMobile}
|
||||
desktopWidth={desktopPanelFrame?.width}
|
||||
desktopWidth={renderedDesktopPanelFrame?.width}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
@@ -304,18 +342,19 @@ export function IssueDocumentAnnotations({
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
hideResolved
|
||||
captureSelectionRequestId={captureSelectionRequestId}
|
||||
pendingHighlightText={composerAnchor?.selectedText ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
{panelOpen && !isMobile && renderedDesktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed z-[60] hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
top: desktopPanelFrame.top,
|
||||
width: desktopPanelFrame.width,
|
||||
left: renderedDesktopPanelFrame.left,
|
||||
maxHeight: renderedDesktopPanelFrame.maxHeight,
|
||||
top: renderedDesktopPanelFrame.top,
|
||||
width: renderedDesktopPanelFrame.width,
|
||||
}}
|
||||
>
|
||||
{annotationPanel}
|
||||
@@ -329,6 +368,7 @@ export function IssueDocumentAnnotations({
|
||||
export interface DocumentAnnotationsCountChipProps {
|
||||
issueId: string;
|
||||
docKey: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
panelOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
@@ -340,12 +380,17 @@ export interface DocumentAnnotationsCountChipProps {
|
||||
export function DocumentAnnotationsCountChip({
|
||||
issueId,
|
||||
docKey,
|
||||
target,
|
||||
panelOpen,
|
||||
onToggle,
|
||||
}: DocumentAnnotationsCountChipProps) {
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const threads = annotationsQuery.data ?? [];
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
flushSync(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
const SAMPLE = "Some text\n\n```ts\nconst answer = 42;\n```\n\nAnd a [link](https://example.com).";
|
||||
|
||||
function tree(children: string, queryClient: QueryClient) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("MarkdownBody re-render stability (PAP-10767)", () => {
|
||||
it("preserves rendered DOM nodes across a parent re-render with unchanged props", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const preBefore = container.querySelector("pre");
|
||||
const codeBefore = container.querySelector("pre code");
|
||||
const anchorBefore = container.querySelector("a");
|
||||
expect(preBefore).not.toBeNull();
|
||||
expect(codeBefore).not.toBeNull();
|
||||
expect(anchorBefore).not.toBeNull();
|
||||
|
||||
// Re-render the identical tree. Before the memoization fix, MarkdownBody
|
||||
// rebuilt its react-markdown `components` map on every render, giving each
|
||||
// custom element (pre/code/a/...) a brand-new component *type* — which made
|
||||
// React unmount and remount the whole subtree, discarding scroll position
|
||||
// and text selection and producing the visible flashing in the file viewer.
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const preAfter = container.querySelector("pre");
|
||||
const codeAfter = container.querySelector("pre code");
|
||||
const anchorAfter = container.querySelector("a");
|
||||
|
||||
// Same DOM node instances ⇒ React updated in place rather than remounting.
|
||||
expect(preAfter).toBe(preBefore);
|
||||
expect(codeAfter).toBe(codeBefore);
|
||||
expect(anchorAfter).toBe(anchorBefore);
|
||||
});
|
||||
|
||||
it("preserves text selection across a parent re-render with unchanged props", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const paragraph = container.querySelector("p");
|
||||
const textNode = paragraph?.firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, 0);
|
||||
range.setEnd(textNode!, "Some text".length);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
expect(selection?.toString()).toBe("Some text");
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
expect(window.getSelection()?.toString()).toBe("Some text");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
@@ -644,7 +644,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownBody({
|
||||
function MarkdownBodyImpl({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
@@ -663,9 +663,13 @@ export function MarkdownBody({
|
||||
// may lack a CompanyProvider. A null context (or no companies yet) leaves
|
||||
// knownPrefixes undefined, which keeps issue auto-linking permissive.
|
||||
const company = useOptionalCompany();
|
||||
const knownPrefixes = company?.companies.length
|
||||
? company.companies.map((c) => c.issuePrefix)
|
||||
: undefined;
|
||||
const companies = company?.companies;
|
||||
// Stable identity so it can feed the memoized remark plugins without
|
||||
// re-creating them (and forcing a full markdown re-parse) every render.
|
||||
const knownPrefixes = useMemo(
|
||||
() => (companies?.length ? companies.map((c) => c.issuePrefix) : undefined),
|
||||
[companies],
|
||||
);
|
||||
const externalReferenceLookup = useMemo<MarkdownExternalReferenceMap | null>(() => {
|
||||
if (!externalReferences) return null;
|
||||
const lookup: MarkdownExternalReferenceMap = {};
|
||||
@@ -675,20 +679,30 @@ export function MarkdownBody({
|
||||
}
|
||||
return lookup;
|
||||
}, [externalReferences]);
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkWorkspaceFileRefs) {
|
||||
remarkPlugins.push(remarkWorkspaceFileRefs);
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
||||
}
|
||||
if (softBreaks) {
|
||||
remarkPlugins.push(remarkSoftBreaks);
|
||||
}
|
||||
const components: Components = {
|
||||
// react-markdown treats the values of `components` as component *types* and
|
||||
// the `remarkPlugins` array by identity. Rebuilding either on every render
|
||||
// forces react-markdown to unmount/remount the rendered tree, which discards
|
||||
// scroll position and text selection and causes visible flashing when a
|
||||
// parent re-renders frequently (see PAP-10767). Memoize both so re-renders
|
||||
// that don't change the inputs are cheap and non-destructive.
|
||||
const remarkPlugins = useMemo<NonNullable<Options["remarkPlugins"]>>(() => {
|
||||
const plugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
plugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkWorkspaceFileRefs) {
|
||||
plugins.push(remarkWorkspaceFileRefs);
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
plugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
||||
}
|
||||
if (softBreaks) {
|
||||
plugins.push(remarkSoftBreaks);
|
||||
}
|
||||
return plugins;
|
||||
}, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, knownPrefixes, softBreaks]);
|
||||
const components = useMemo<Components>(() => {
|
||||
const map: Components = {
|
||||
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (
|
||||
<p {...paragraphProps} style={mergeWrapStyle(paragraphStyle as React.CSSProperties | undefined)}>
|
||||
{paragraphChildren}
|
||||
@@ -828,22 +842,24 @@ export function MarkdownBody({
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||
const finalSrc = resolved ?? src;
|
||||
return (
|
||||
<img
|
||||
{...imgProps}
|
||||
src={finalSrc}
|
||||
alt={alt ?? ""}
|
||||
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
map.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||
const finalSrc = resolved ?? src;
|
||||
return (
|
||||
<img
|
||||
{...imgProps}
|
||||
src={finalSrc}
|
||||
alt={alt ?? ""}
|
||||
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [theme, linkIssueReferences, externalReferenceLookup, resolveImageSrc, onImageClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -864,3 +880,5 @@ export function MarkdownBody({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MarkdownBody = memo(MarkdownBodyImpl);
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
DocumentAnnotationThreadStatus,
|
||||
DocumentAnnotationAnchorState,
|
||||
} from "@paperclipai/shared";
|
||||
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
|
||||
import type { PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const issueId = "issue-doc-comments";
|
||||
const documentKey = "plan";
|
||||
const currentUserId = "user-board";
|
||||
|
||||
const userProfileMap = new Map<string, CompanyUserProfile>([
|
||||
[currentUserId, { label: "Dotta", image: null }],
|
||||
["user-pm", { label: "Mara Product", image: null }],
|
||||
]);
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
const status: DocumentAnnotationThreadStatus = overrides.status ?? "open";
|
||||
const anchorState: DocumentAnnotationAnchorState = overrides.anchorState ?? "active";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
documentKey,
|
||||
status,
|
||||
anchorState,
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText:
|
||||
"the assistant should keep the existing editor selection highlighted while the comment composer is open",
|
||||
prefixText: "We agreed ",
|
||||
suffixText: ".",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 22,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 22,
|
||||
anchorSelector: {
|
||||
quote: { exact: "selection", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: currentUserId,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:02:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: `${id}-c1`,
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
body: "Please confirm this is still the behaviour we want.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-pm",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingAnchor: PendingAnchor = {
|
||||
selector: {
|
||||
quote: { exact: "extremely snappy", prefix: "feels ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 16, markdownStart: 0, markdownEnd: 16 },
|
||||
},
|
||||
selectedText:
|
||||
"submission of comments should be optimistic so the whole interaction feels extremely snappy",
|
||||
};
|
||||
|
||||
function PanelFrame({
|
||||
label,
|
||||
threads,
|
||||
focusedThreadId = null,
|
||||
pending = null,
|
||||
}: {
|
||||
label: string;
|
||||
threads: DocumentAnnotationThreadWithComments[];
|
||||
focusedThreadId?: string | null;
|
||||
pending?: PendingAnchor | null;
|
||||
}) {
|
||||
const [client] = useState(() => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(queryKeys.auth.session, {
|
||||
session: { id: "sess-1", userId: currentUserId },
|
||||
user: { id: currentUserId, email: "dotta@magicmachine.co", name: "Dotta", image: null },
|
||||
});
|
||||
return qc;
|
||||
});
|
||||
const [focused, setFocused] = useState<string | null>(focusedThreadId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="paperclip-story__label">{label}</div>
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="h-[460px]">
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
issueId={issueId}
|
||||
documentKey={documentKey}
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={threads}
|
||||
focusedThreadId={focused}
|
||||
onFocusThread={setFocused}
|
||||
focusedCommentId={null}
|
||||
pendingAnchor={pending}
|
||||
onClearPendingAnchor={() => undefined}
|
||||
desktopWidth={360}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentCommentsMatrix() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">Document comments · PAP-10960</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">Simplified annotation panel</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No header, no filter chips, no empty-state copy. Revision indicator sits top-right.
|
||||
The composer shows the author avatar + name, two-line clamped quote, and a plain
|
||||
"Reply" affordance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-6 p-5">
|
||||
<PanelFrame label="No comments yet" threads={[]} />
|
||||
<PanelFrame
|
||||
label="With comments (one expanded)"
|
||||
threads={[
|
||||
makeThread({ id: "thread-1" }),
|
||||
makeThread({ id: "thread-2", status: "resolved", selectedText: "a resolved thread stays in the same list" }),
|
||||
]}
|
||||
focusedThreadId="thread-1"
|
||||
/>
|
||||
<PanelFrame label="Composing a new comment" threads={[makeThread({ id: "thread-1" })]} pending={pendingAnchor} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Document Comments",
|
||||
component: DocumentCommentsMatrix,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Inline document comment panel (PAP-10960): simplified to drop the header, filter chips, and empty-state, surface the author identity in the composer, and clamp quotes to two lines.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof DocumentCommentsMatrix>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Panel: Story = {};
|
||||
Reference in new issue
Block a user