forked from farhoodlabs/paperclip
[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:
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
|
||||
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_VIEWPORT_MARGIN = 16;
|
||||
|
||||
export interface IssueDocumentAnnotationsProps {
|
||||
issueId: string;
|
||||
doc: IssueDocument;
|
||||
/** The body that is being rendered/edited (current or historical revision). */
|
||||
bodyMarkdown: string;
|
||||
/** True when a draft has unsaved changes or is currently saving. */
|
||||
draftDirty: boolean;
|
||||
/** True when there is a remote conflict that requires user resolution. */
|
||||
draftConflicted: boolean;
|
||||
/** True when the document is being viewed in historical revision preview. */
|
||||
historicalPreview: boolean;
|
||||
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
|
||||
children: ReactNode;
|
||||
/** Current location hash so we can resolve deep-link targets. */
|
||||
locationHash: string;
|
||||
/** 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">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
|
||||
defaultFocusedThreadId?: string;
|
||||
}
|
||||
|
||||
export function IssueDocumentAnnotations({
|
||||
issueId,
|
||||
doc,
|
||||
bodyMarkdown,
|
||||
draftDirty,
|
||||
draftConflicted,
|
||||
historicalPreview,
|
||||
children,
|
||||
locationHash,
|
||||
panelOpen,
|
||||
onPanelOpenChange,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
defaultFocusedThreadId,
|
||||
}: IssueDocumentAnnotationsProps) {
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
|
||||
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<PendingAnchor | null>(null);
|
||||
const [composerAnchor, setComposerAnchor] = useState<PendingAnchor | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [desktopPanelFrame, setDesktopPanelFrame] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const hashHandledRef = useRef<string | null>(null);
|
||||
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
|
||||
const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mediaQuery = window.matchMedia("(max-width: 1023px)");
|
||||
const handler = () => setIsMobile(mediaQuery.matches);
|
||||
handler();
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen || isMobile || typeof window === "undefined") {
|
||||
setDesktopPanelFrame(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePanelFrame = () => {
|
||||
const container = containerRef.current;
|
||||
const rect = container?.getBoundingClientRect();
|
||||
if (!container || !rect) {
|
||||
setDesktopPanelFrame(null);
|
||||
return;
|
||||
}
|
||||
const boundaryRect = container.closest("main")?.getBoundingClientRect();
|
||||
const boundaryLeft = boundaryRect?.left ?? 0;
|
||||
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
|
||||
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
|
||||
const maxPanelWidth = Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
|
||||
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);
|
||||
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||
const spaceRightOfDocument = boundaryRight
|
||||
- desiredLeft
|
||||
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
|
||||
? Math.min(desiredWidth, spaceRightOfDocument)
|
||||
: desiredWidth;
|
||||
const maxVisibleLeft = boundaryRight
|
||||
- width
|
||||
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
setDesktopPanelFrame({
|
||||
left: Math.max(
|
||||
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
Math.min(desiredLeft, maxVisibleLeft),
|
||||
),
|
||||
top,
|
||||
width,
|
||||
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
|
||||
});
|
||||
};
|
||||
|
||||
updatePanelFrame();
|
||||
window.addEventListener("resize", updatePanelFrame);
|
||||
window.addEventListener("scroll", updatePanelFrame, true);
|
||||
const resizeObserver = typeof window.ResizeObserver === "function"
|
||||
? new window.ResizeObserver(updatePanelFrame)
|
||||
: null;
|
||||
const observedContainer = containerRef.current;
|
||||
if (resizeObserver && observedContainer) {
|
||||
resizeObserver.observe(observedContainer);
|
||||
const main = observedContainer.closest("main");
|
||||
if (main) resizeObserver.observe(main);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePanelFrame);
|
||||
window.removeEventListener("scroll", updatePanelFrame, true);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [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 }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allThreads = annotationsQuery.data ?? [];
|
||||
|
||||
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
|
||||
useEffect(() => {
|
||||
if (!locationHash) return;
|
||||
if (hashHandledRef.current === locationHash) return;
|
||||
const target = parseDocumentAnnotationHash(locationHash);
|
||||
if (!target || target.documentKey !== doc.key) return;
|
||||
if (!target.threadId) return;
|
||||
hashHandledRef.current = locationHash;
|
||||
onPanelOpenChange(true);
|
||||
setFocusedThreadId(target.threadId);
|
||||
setFocusedCommentId(target.commentId);
|
||||
}, [doc.key, locationHash, onPanelOpenChange]);
|
||||
|
||||
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
|
||||
const newCommentDisabledReason = historicalPreview
|
||||
? "New comments are disabled while previewing a historical revision."
|
||||
: draftConflicted
|
||||
? "Resolve the document conflict before adding new comments."
|
||||
: draftDirty
|
||||
? "Save the draft to anchor new comments."
|
||||
: !doc.latestRevisionId
|
||||
? "Document has no saved revision yet."
|
||||
: null;
|
||||
|
||||
const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => {
|
||||
setSelectionAnchor(anchor);
|
||||
}, []);
|
||||
|
||||
const handleClearComposerAnchor = useCallback(() => {
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
|
||||
if (newCommentDisabled) return;
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(anchor);
|
||||
onPanelOpenChange(true);
|
||||
}, [newCommentDisabled, onPanelOpenChange]);
|
||||
|
||||
const handleThreadFocus = useCallback((threadId: string | null) => {
|
||||
setFocusedThreadId(threadId);
|
||||
if (threadId) {
|
||||
onPanelOpenChange(true);
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
}, [onPanelOpenChange]);
|
||||
|
||||
const handleRequestCommentFromSelection = useCallback(() => {
|
||||
if (newCommentDisabled) return;
|
||||
if (selectionAnchor) {
|
||||
handleRequestComment(selectionAnchor);
|
||||
return;
|
||||
}
|
||||
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
|
||||
setCaptureSelectionRequestId((current) => current + 1);
|
||||
}, [handleRequestComment, newCommentDisabled, selectionAnchor]);
|
||||
|
||||
// ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open.
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
if (!isMeta || !event.shiftKey) return;
|
||||
if (event.key.toLowerCase() !== "m") return;
|
||||
event.preventDefault();
|
||||
handleRequestCommentFromSelection();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [panelOpen, handleRequestCommentFromSelection]);
|
||||
|
||||
const focusedThread = useMemo(() => {
|
||||
if (!focusedThreadId) return null;
|
||||
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
|
||||
}, [allThreads, focusedThreadId]);
|
||||
|
||||
const overlayThreads = useMemo(
|
||||
() => allThreads.map((thread) => ({
|
||||
id: thread.id,
|
||||
selectedText: thread.selectedText,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
})),
|
||||
[allThreads],
|
||||
);
|
||||
|
||||
const annotationPanel = panelOpen ? (
|
||||
<DocumentAnnotationPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={(open) => {
|
||||
onPanelOpenChange(open);
|
||||
if (!open) {
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(null);
|
||||
setFocusedThreadId(null);
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
}}
|
||||
issueId={issueId}
|
||||
documentKey={doc.key}
|
||||
documentRevisionNumber={doc.latestRevisionNumber}
|
||||
baseRevisionId={doc.latestRevisionId}
|
||||
baseRevisionNumber={doc.latestRevisionNumber}
|
||||
threads={allThreads as DocumentAnnotationThreadWithComments[]}
|
||||
focusedThreadId={focusedThreadId}
|
||||
focusedCommentId={focusedCommentId}
|
||||
onFocusThread={(id) => {
|
||||
setFocusedThreadId(id);
|
||||
if (!id) setFocusedCommentId(null);
|
||||
}}
|
||||
pendingAnchor={composerAnchor}
|
||||
onClearPendingAnchor={handleClearComposerAnchor}
|
||||
onRequestCommentFromSelection={handleRequestCommentFromSelection}
|
||||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
isMobile={isMobile}
|
||||
desktopWidth={desktopPanelFrame?.width}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="paperclip-doc-annotation-host relative">
|
||||
<section
|
||||
ref={(element) => {
|
||||
containerRef.current = element;
|
||||
}}
|
||||
className="relative min-w-0"
|
||||
data-testid={`document-annotation-body-${doc.key}`}
|
||||
>
|
||||
<div className="relative z-[1]">
|
||||
{children}
|
||||
</div>
|
||||
{!historicalPreview && doc.latestRevisionId ? (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={containerRef}
|
||||
markdown={bodyMarkdown}
|
||||
threads={overlayThreads}
|
||||
focusedThreadId={focusedThread?.id ?? null}
|
||||
onThreadFocus={handleThreadFocus}
|
||||
pendingAnchor={selectionAnchor}
|
||||
onPendingAnchorChange={handleSelectionAnchorChange}
|
||||
onRequestComment={handleRequestComment}
|
||||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
hideResolved
|
||||
captureSelectionRequestId={captureSelectionRequestId}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
top: desktopPanelFrame.top,
|
||||
width: desktopPanelFrame.width,
|
||||
}}
|
||||
>
|
||||
{annotationPanel}
|
||||
</div>
|
||||
) : null}
|
||||
{panelOpen && isMobile ? annotationPanel : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationsCountChipProps {
|
||||
issueId: string;
|
||||
docKey: string;
|
||||
panelOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the unresolved-count chip for a document. Lives in the document header row
|
||||
* (next to `rev N ▾`) so it stays visible when the document is folded.
|
||||
*/
|
||||
export function DocumentAnnotationsCountChip({
|
||||
issueId,
|
||||
docKey,
|
||||
panelOpen,
|
||||
onToggle,
|
||||
}: DocumentAnnotationsCountChipProps) {
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const threads = annotationsQuery.data ?? [];
|
||||
const openCount = useMemo(
|
||||
() => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
|
||||
[threads],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
data-state={panelOpen ? "open" : "closed"}
|
||||
className={cn(
|
||||
"h-auto gap-1 rounded-md px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||
panelOpen && "bg-muted text-foreground",
|
||||
openCount > 0 && "text-foreground",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
data-testid={`document-annotation-count-${docKey}`}
|
||||
aria-label={openCount === 0
|
||||
? `Open comments on ${docKey}`
|
||||
: `Open ${openCount} unresolved comments on ${docKey}`}
|
||||
aria-expanded={panelOpen}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="tabular-nums">{openCount}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{openCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user