mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Add document annotations and comments (#6733)
## Thinking Path > - Paperclip orchestrates AI-agent companies through issues, documents, runs, and durable company-scoped state. > - Issue documents are where agents and operators capture plans, handoffs, and work products. > - Before this change, document collaboration could only happen through whole-document edits and detached issue comments. > - Inline document annotations need stable anchors, revision-aware persistence, and UI affordances that do not break existing document editing. > - This pull request adds company-scoped document annotation threads, comments, anchor snapshots, API routes, and board UI. > - The benefit is that operators and agents can discuss specific document passages without losing context as documents evolve. ## What Changed - Added document annotation tables, schema exports, shared types, validators, anchor hashing, and text-anchor helpers. - Added server-side document annotation services and issue routes for listing, creating, commenting, resolving, and reopening annotation threads. - Included annotation summaries in relevant issue document reads and backup/recovery document workspace behavior. - Added React UI for inline document highlights, comment panels, mobile sheet behavior, deep-link focus, and resolved/open filtering. - Added annotation design artifacts, Storybook coverage, screenshots, and a screenshot helper script. - Rebased the branch onto current `paperclipai/paperclip` `master` and renumbered the annotation migration from `0085_old_swarm` to `0091_old_swarm`; the SQL uses `IF NOT EXISTS` guards so environments that previously applied the old migration number can safely apply the new one. - Adjusted the new annotation UI tests to use a local async flush helper because this workspace's React 19.2.4 export does not expose `React.act`. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/document-anchors.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/document-annotations-service.test.ts ui/src/components/DocumentAnnotationLayer.test.tsx ui/src/components/IssueDocumentAnnotations.test.tsx ui/src/lib/document-annotation-hash.test.ts ui/src/lib/document-annotation-selection.test.ts` - Confirmed `git diff --check` passes. - Confirmed no `pnpm-lock.yaml` or `.github/workflows/*` files are included in the PR diff. ## Risks - Medium risk: this adds new persisted annotation tables and routes across db/shared/server/ui. - Migration risk is reduced by moving the branch migration to `0091_old_swarm` after upstream `0090_resource_memberships` and keeping the SQL idempotent for old `0085_old_swarm` adopters. - UI risk is mostly around text range anchoring and panel positioning across long documents, folded content, and mobile layouts; the PR includes focused unit coverage and design screenshots. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-using software engineering mode. Context window size is not exposed in this Paperclip runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f0ddd24d61
commit
b7545823be
55 changed files with 25070 additions and 31 deletions
413
server/src/services/document-annotations.ts
Normal file
413
server/src/services/document-annotations.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
documentAnnotationAnchorSnapshots,
|
||||
documentAnnotationComments,
|
||||
documentAnnotationThreads,
|
||||
documents,
|
||||
issueDocuments,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
anchorSnapshotToSelector,
|
||||
remapDocumentAnchor,
|
||||
selectorToAnchorSnapshot,
|
||||
verifyDocumentAnchorSelector,
|
||||
type DocumentAnnotationAnchorSnapshot,
|
||||
type DocumentAnnotationComment,
|
||||
type DocumentAnnotationThread,
|
||||
CreateDocumentAnnotationComment,
|
||||
CreateDocumentAnnotationThread,
|
||||
UpdateDocumentAnnotationThread,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
type ActorInput = {
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
runId?: string | null;
|
||||
};
|
||||
|
||||
type IssueDocumentRow = {
|
||||
issueId: 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,
|
||||
documentId: documentAnnotationThreads.documentId,
|
||||
documentKey: documentAnnotationThreads.documentKey,
|
||||
status: documentAnnotationThreads.status,
|
||||
anchorState: documentAnnotationThreads.anchorState,
|
||||
anchorConfidence: documentAnnotationThreads.anchorConfidence,
|
||||
originalRevisionId: documentAnnotationThreads.originalRevisionId,
|
||||
originalRevisionNumber: documentAnnotationThreads.originalRevisionNumber,
|
||||
currentRevisionId: documentAnnotationThreads.currentRevisionId,
|
||||
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
|
||||
selectedText: documentAnnotationThreads.selectedText,
|
||||
prefixText: documentAnnotationThreads.prefixText,
|
||||
suffixText: documentAnnotationThreads.suffixText,
|
||||
normalizedStart: documentAnnotationThreads.normalizedStart,
|
||||
normalizedEnd: documentAnnotationThreads.normalizedEnd,
|
||||
markdownStart: documentAnnotationThreads.markdownStart,
|
||||
markdownEnd: documentAnnotationThreads.markdownEnd,
|
||||
anchorSelector: documentAnnotationThreads.anchorSelector,
|
||||
createdByAgentId: documentAnnotationThreads.createdByAgentId,
|
||||
createdByUserId: documentAnnotationThreads.createdByUserId,
|
||||
resolvedByAgentId: documentAnnotationThreads.resolvedByAgentId,
|
||||
resolvedByUserId: documentAnnotationThreads.resolvedByUserId,
|
||||
resolvedAt: documentAnnotationThreads.resolvedAt,
|
||||
createdAt: documentAnnotationThreads.createdAt,
|
||||
updatedAt: documentAnnotationThreads.updatedAt,
|
||||
};
|
||||
|
||||
const commentSelect = {
|
||||
id: documentAnnotationComments.id,
|
||||
companyId: documentAnnotationComments.companyId,
|
||||
threadId: documentAnnotationComments.threadId,
|
||||
issueId: documentAnnotationComments.issueId,
|
||||
documentId: documentAnnotationComments.documentId,
|
||||
body: documentAnnotationComments.body,
|
||||
authorType: documentAnnotationComments.authorType,
|
||||
authorAgentId: documentAnnotationComments.authorAgentId,
|
||||
authorUserId: documentAnnotationComments.authorUserId,
|
||||
createdByRunId: documentAnnotationComments.createdByRunId,
|
||||
createdAt: documentAnnotationComments.createdAt,
|
||||
updatedAt: documentAnnotationComments.updatedAt,
|
||||
};
|
||||
|
||||
function snapshotFromThread(thread: Pick<DocumentAnnotationThread, "selectedText" | "prefixText" | "suffixText" | "normalizedStart" | "normalizedEnd" | "markdownStart" | "markdownEnd">): DocumentAnnotationAnchorSnapshot {
|
||||
return {
|
||||
selectedText: thread.selectedText,
|
||||
prefixText: thread.prefixText,
|
||||
suffixText: thread.suffixText,
|
||||
normalizedStart: thread.normalizedStart,
|
||||
normalizedEnd: thread.normalizedEnd,
|
||||
markdownStart: thread.markdownStart,
|
||||
markdownEnd: thread.markdownEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function documentAnnotationService(db: Db) {
|
||||
async function getIssueDocument(issueId: string, key: string, dbOrTx: any = db): Promise<IssueDocumentRow | null> {
|
||||
return dbOrTx
|
||||
.select({
|
||||
issueId: issueDocuments.issueId,
|
||||
companyId: documents.companyId,
|
||||
documentId: documents.id,
|
||||
documentKey: issueDocuments.key,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getThreadForIssue(
|
||||
issueId: 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.issueId, issueId),
|
||||
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
|
||||
.select(commentSelect)
|
||||
.from(documentAnnotationComments)
|
||||
.where(inArray(documentAnnotationComments.threadId, threadIds))
|
||||
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
|
||||
}
|
||||
|
||||
return {
|
||||
listThreadsForIssueDocument: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const doc = await getIssueDocument(issueId, key);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
const conditions = [
|
||||
eq(documentAnnotationThreads.issueId, issueId),
|
||||
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;
|
||||
const comments = await commentsForThreads([thread.id]);
|
||||
return { ...thread, comments };
|
||||
},
|
||||
|
||||
createThread: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
input: CreateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${documents.id}
|
||||
from ${issueDocuments}
|
||||
inner join ${documents} on ${issueDocuments.documentId} = ${documents.id}
|
||||
where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))}
|
||||
for update of ${documents}
|
||||
`);
|
||||
const doc = await getIssueDocument(issueId, 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,
|
||||
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,
|
||||
documentId: doc.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
|
||||
return { ...thread, comments: [comment] };
|
||||
}),
|
||||
|
||||
addComment: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: CreateDocumentAnnotationComment,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForIssue(issueId, 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: thread.issueId,
|
||||
documentId: thread.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(documentAnnotationThreads.id, thread.id));
|
||||
return comment;
|
||||
}),
|
||||
|
||||
updateThread: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: UpdateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForIssue(issueId, 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;
|
||||
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.issueId, input.issueId),
|
||||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue