[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user