[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:
Dotta
2026-05-26 08:41:23 -05:00
committed by GitHub
parent f0ddd24d61
commit b7545823be
55 changed files with 25070 additions and 31 deletions
+106 -27
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
DocumentRevision,
FeedbackDataSharingPreference,
FeedbackVote,
@@ -14,9 +15,11 @@ import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@@ -151,6 +154,11 @@ export function IssueDocumentsSection({
imageUploadHandler,
onVote,
extraActions,
agentMap,
userProfileMap,
defaultAnnotationPanelOpenKeys,
defaultAnnotationFocusedThreadIds,
forceEditDocumentKey,
}: {
issue: Issue;
canDeleteDocuments: boolean;
@@ -166,6 +174,17 @@ export function IssueDocumentsSection({
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
extraActions?: ReactNode;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/**
* Seed which document annotation panels are open on first render. Mostly useful
* for Storybook / screenshot harnesses; runtime callers usually omit this.
*/
defaultAnnotationPanelOpenKeys?: string[];
/** Per-doc seed for the focused annotation thread id (Storybook-only). */
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
/** Force a doc into edit mode on mount (Storybook-only). */
forceEditDocumentKey?: string | null;
}) {
const queryClient = useQueryClient();
const location = useLocation();
@@ -174,6 +193,9 @@ export function IssueDocumentsSection({
const [draft, setDraft] = useState<DraftState | null>(null);
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
() => (defaultAnnotationPanelOpenKeys ?? []),
);
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
@@ -213,8 +235,10 @@ export function IssueDocumentsSection({
predicate: (query) =>
Array.isArray(query.queryKey)
&& query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-revisions"
&& query.queryKey[2] === issue.id,
&& (
(query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id)
|| (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id)
),
});
}, [issue.id, queryClient]);
@@ -368,6 +392,17 @@ export function IssueDocumentsSection({
setError(null);
};
const initialEditAppliedRef = useRef(false);
useEffect(() => {
if (!forceEditDocumentKey) return;
if (initialEditAppliedRef.current) return;
const target = (documents ?? []).find((entry) => entry.key === forceEditDocumentKey);
if (!target) return;
initialEditAppliedRef.current = true;
beginEdit(forceEditDocumentKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceEditDocumentKey, documents]);
const cancelDraft = () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
@@ -726,6 +761,24 @@ export function IssueDocumentsSection({
: [...current, key],
);
};
const setAnnotationPanelOpen = useCallback((key: string, nextOpen: boolean) => {
setAnnotationPanelOpenKeys((current) => {
const isOpen = current.includes(key);
if (nextOpen && !isOpen) return [...current, key];
if (!nextOpen && isOpen) return current.filter((entry) => entry !== key);
return current;
});
if (nextOpen) {
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
}
}, []);
const toggleAnnotationPanel = useCallback((key: string) => {
setAnnotationPanelOpenKeys((current) => {
if (current.includes(key)) return current.filter((entry) => entry !== key);
setFoldedDocumentKeys((folded) => folded.filter((entry) => entry !== key));
return [...current, key];
});
}, []);
return (
<div className="space-y-3">
@@ -936,6 +989,14 @@ export function IssueDocumentsSection({
>
updated {relativeTime(displayedUpdatedAt)}
</a>
{!isSystemIssueDocumentKey(doc.key) ? (
<DocumentAnnotationsCountChip
issueId={issue.id}
docKey={doc.key}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onToggle={() => toggleAnnotationPanel(doc.key)}
/>
) : null}
</div>
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
</div>
@@ -1153,31 +1214,49 @@ export function IssueDocumentsSection({
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
}`}
>
{isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
markDocumentDirty(doc.key);
setDraft((current) => {
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return current;
});
}}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName={documentBodyContentClassName}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName)
)}
<IssueDocumentAnnotations
issueId={issue.id}
doc={doc}
bodyMarkdown={displayedBody}
draftDirty={Boolean(activeDraft) && (
(activeDraft?.body ?? doc.body) !== doc.body
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
)}
draftConflicted={Boolean(activeConflict)}
historicalPreview={isHistoricalPreview}
locationHash={location.hash}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
agentMap={agentMap}
userProfileMap={userProfileMap}
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
>
{isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
markDocumentDirty(doc.key);
setDraft((current) => {
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return current;
});
}}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName={documentBodyContentClassName}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName)
)}
</IssueDocumentAnnotations>
</div>
<div className="flex min-h-4 items-center justify-end px-1">
<span