import { useMemo, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, DocumentAnnotationThreadWithComments, Issue, IssueDocument, } from "@paperclipai/shared"; import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel"; import { DocumentAnnotationLayer, type PendingAnchor } from "@/components/DocumentAnnotationLayer"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations, } from "@/components/IssueDocumentAnnotations"; import { IssueDocumentsSection } from "@/components/IssueDocumentsSection"; import { MarkdownBody } from "@/components/MarkdownBody"; import { MarkdownEditor } from "@/components/MarkdownEditor"; import { queryKeys } from "@/lib/queryKeys"; import type { CompanyUserProfile } from "@/lib/company-members"; const sampleMarkdown = `# Plan: Document Highlights And Comment Threads We should **keep** the current markdown document stack for the first version. The existing editor is MDXEditor on top of Lexical, and the current code already uses Lexical-level customization. ## Reader And Goal Reader: board reviewer, CTO, and implementing engineers. ## Anchor Strategy Do not insert comment markers into markdown. The markdown document body must remain portable and readable. Use a sidecar anchor made from two selectors: - Text quote selector: exact selected text plus prefix/suffix context. - Text position selector: normalized rendered-text offsets plus markdown source offsets. ## Future Work Phase 5 covers QA validation across desktop and mobile.`; function makeThread( overrides: Partial = {}, ): DocumentAnnotationThreadWithComments { const id = overrides.id ?? "thread-1"; return { id, companyId: "co-1", issueId: "issue-1", documentId: "doc-1", documentKey: "plan", status: "open", anchorState: "active", anchorConfidence: "exact", originalRevisionId: "rev-4", originalRevisionNumber: 4, currentRevisionId: "rev-4", currentRevisionNumber: 4, selectedText: "keep the current markdown document stack", prefixText: "We should ", suffixText: " for the first version", normalizedStart: 0, normalizedEnd: 40, markdownStart: 0, markdownEnd: 40, anchorSelector: { quote: { exact: "keep the current markdown document stack", prefix: "We should ", suffix: " for the first version", }, position: { normalizedStart: 0, normalizedEnd: 40, markdownStart: 0, markdownEnd: 40 }, }, createdByAgentId: null, createdByUserId: "user-1", resolvedByAgentId: null, resolvedByUserId: null, resolvedAt: null, createdAt: new Date("2026-05-12T10:00:00Z"), updatedAt: new Date("2026-05-12T10:01:00Z"), comments: [ { id: "comment-1", companyId: "co-1", threadId: id, issueId: "issue-1", documentId: "doc-1", body: "Could we benchmark the editor against a CRDT alternative before committing?", authorType: "user", authorAgentId: null, authorUserId: "user-1", createdByRunId: null, createdAt: new Date("2026-05-12T10:00:00Z"), updatedAt: new Date("2026-05-12T10:00:00Z"), }, { id: "comment-2", companyId: "co-1", threadId: id, issueId: "issue-1", documentId: "doc-1", body: "We did a small spike — happy to share results in the plan.", authorType: "agent", authorAgentId: "agent-uxdesigner", authorUserId: null, createdByRunId: "run-1", createdAt: new Date("2026-05-12T10:01:00Z"), updatedAt: new Date("2026-05-12T10:01:00Z"), }, ], ...overrides, }; } const baseThreads: DocumentAnnotationThreadWithComments[] = [ makeThread({ id: "open-1" }), makeThread({ id: "stale-1", anchorState: "stale", anchorConfidence: "fuzzy", selectedText: "two selectors", prefixText: "anchor made from ", suffixText: ":", comments: [ { id: "comment-stale", companyId: "co-1", threadId: "stale-1", issueId: "issue-1", documentId: "doc-1", body: "Original wording was slightly different — re-anchor when convenient.", authorType: "user", authorAgentId: null, authorUserId: "user-1", createdByRunId: null, createdAt: new Date("2026-05-12T11:00:00Z"), updatedAt: new Date("2026-05-12T11:00:00Z"), }, ], }), makeThread({ id: "resolved-1", status: "resolved", selectedText: "Reader: board reviewer, CTO, and implementing engineers", comments: [ { id: "comment-resolved", companyId: "co-1", threadId: "resolved-1", issueId: "issue-1", documentId: "doc-1", body: "Updated reader list to add the security lead.", authorType: "agent", authorAgentId: "agent-uxdesigner", authorUserId: null, createdByRunId: "run-1", createdAt: new Date("2026-05-12T12:00:00Z"), updatedAt: new Date("2026-05-12T12:00:00Z"), }, ], }), makeThread({ id: "orphan-1", anchorState: "orphaned", selectedText: "an earlier paragraph that has been rewritten", comments: [ { id: "comment-orphan", companyId: "co-1", threadId: "orphan-1", issueId: "issue-1", documentId: "doc-1", body: "This anchor lost its location after the rewrite. Original quote preserved.", authorType: "user", authorAgentId: null, authorUserId: "user-1", createdByRunId: null, createdAt: new Date("2026-05-12T13:00:00Z"), updatedAt: new Date("2026-05-12T13:00:00Z"), }, ], }), ]; const integratedAgentMap: ReadonlyMap> = new Map([ ["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }], ]); const integratedUserProfileMap: ReadonlyMap = new Map([ ["user-1", { label: "Dotta", image: null }], ]); function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, mutations: { retry: false }, }, }); } const integratedIssueId = "issue-storybook-1"; const integratedDoc: IssueDocument = { id: "doc-storybook-1", companyId: "co-1", issueId: integratedIssueId, key: "plan", title: "Plan", format: "markdown", body: sampleMarkdown, latestRevisionId: "rev-4", latestRevisionNumber: 4, createdByAgentId: null, createdByUserId: "user-1", updatedByAgentId: null, updatedByUserId: "user-1", lockedAt: null, lockedByAgentId: null, lockedByUserId: null, createdAt: new Date("2026-05-12T09:00:00Z"), updatedAt: new Date("2026-05-12T10:01:00Z"), }; function makeIntegratedIssue(): Issue { return { id: integratedIssueId, companyId: "co-1", projectId: null, projectWorkspaceId: null, goalId: null, parentId: null, title: "Highlighting and comments on documents", description: null, status: "in_progress", workMode: "standard", priority: "medium", assigneeAgentId: null, assigneeUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, createdByAgentId: null, createdByUserId: "user-1", issueNumber: 9402, identifier: "PAP-9402", requestDepth: 0, billingCode: null, assigneeAdapterOverrides: null, executionWorkspaceId: null, executionWorkspacePreference: null, executionWorkspaceSettings: null, startedAt: null, completedAt: null, cancelledAt: null, hiddenAt: null, documentSummaries: [ { id: integratedDoc.id, companyId: integratedDoc.companyId, issueId: integratedIssueId, key: integratedDoc.key, title: integratedDoc.title, format: integratedDoc.format, latestRevisionId: integratedDoc.latestRevisionId, latestRevisionNumber: integratedDoc.latestRevisionNumber, createdByAgentId: null, createdByUserId: "user-1", updatedByAgentId: null, updatedByUserId: "user-1", lockedAt: integratedDoc.lockedAt, lockedByAgentId: integratedDoc.lockedByAgentId, lockedByUserId: integratedDoc.lockedByUserId, createdAt: integratedDoc.createdAt, updatedAt: integratedDoc.updatedAt, }, ], legacyPlanDocument: null, planDocument: integratedDoc, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-12T10:01:00Z"), }; } /** * Storybook fetch stub for the integrated stories. The annotation surface is * driven by prefilled React Query data, but MarkdownEditor in edit mode can * fire an autosave PUT on first onChange. Without this stub the cell would * render a "Request failed: 404" string from the section's error state — which * defeats the purpose of the integrated capture. */ function useIntegratedFetchStub(issueId: string, doc: IssueDocument) { // Install once per mount; the cleanup restores the previous fetch. // The preview's global fetch fixture is still in place — we only intercept // the document mutation URL pattern for this issue. useMemo(() => { if (typeof window === "undefined") return; const upsertUrlPath = `/api/issues/${issueId}/documents/${doc.key}`; const original = window.fetch.bind(window); const wrapped: typeof window.fetch = async (input, init) => { const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const method = (init?.method ?? (typeof input === "object" && "method" in input ? (input as Request).method : "GET")).toUpperCase(); const url = new URL(rawUrl, window.location.origin); if (url.pathname === upsertUrlPath && (method === "PUT" || method === "GET")) { return Response.json({ ...doc, latestRevisionNumber: doc.latestRevisionNumber + 1 }); } return original(input, init); }; window.fetch = wrapped; // eslint-disable-next-line react-hooks/exhaustive-deps }, [issueId, doc.key]); } function IntegratedSurface({ threads = baseThreads, focusedThreadId = "open-1", initialPanelOpen = true, beginEditOnMount = false, }: { threads?: DocumentAnnotationThreadWithComments[]; focusedThreadId?: string | null; initialPanelOpen?: boolean; beginEditOnMount?: boolean; }) { const issue = useMemo(makeIntegratedIssue, []); useIntegratedFetchStub(issue.id, integratedDoc); const queryClient = useMemo(() => { const client = makeClient(); // Prefill documents + annotations cache so React Query renders without hitting the network. client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]); client.setQueryData( queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"), threads, ); return client; // eslint-disable-next-line react-hooks/exhaustive-deps }, [issue.id]); const panelKeys = initialPanelOpen ? [integratedDoc.key] : []; const focusedThreadIds = focusedThreadId ? { [integratedDoc.key]: focusedThreadId } : undefined; const editKey = beginEditOnMount ? integratedDoc.key : null; return (
); } function DirtyDraftWithIntegratedHeader() { const issue = useMemo(makeIntegratedIssue, []); useIntegratedFetchStub(issue.id, integratedDoc); const queryClient = useMemo(() => { const client = makeClient(); client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]); client.setQueryData( queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"), baseThreads, ); return client; // eslint-disable-next-line react-hooks/exhaustive-deps }, [issue.id]); const [panelOpen, setPanelOpen] = useState(true); const [draftBody, setDraftBody] = useState(`${sampleMarkdown}\n\nA work-in-progress edit that is unsaved.`); return (

Documents

plan rev 4 ▾ updated 2h ago setPanelOpen((current) => !current)} />
setDraftBody(body)} placeholder="Markdown body" bordered={false} className="bg-transparent" contentClassName="paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7" />
Autosaving…
); } function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: string }) { const queryClient = useMemo(() => makeClient(), []); const bodyRef = useRef(null); const [pendingAnchor, setPendingAnchor] = useState(null); const [focused, setFocused] = useState(focusedThreadId); return (
{ bodyRef.current = element; }} className="relative" > {sampleMarkdown} ({ id: thread.id, selectedText: thread.selectedText, status: thread.status, anchorState: thread.anchorState, }))} focusedThreadId={focused} onThreadFocus={(id) => setFocused(id)} pendingAnchor={pendingAnchor} onPendingAnchorChange={setPendingAnchor} onRequestComment={() => {}} hideResolved={false} />
{}} issueId="issue-1" documentKey="plan" documentRevisionNumber={4} baseRevisionId="rev-4" baseRevisionNumber={4} threads={baseThreads} focusedThreadId={focused} focusedCommentId={null} onFocusThread={(id) => setFocused(id)} pendingAnchor={null} onClearPendingAnchor={() => setPendingAnchor(null)} agentMap={integratedAgentMap} userProfileMap={integratedUserProfileMap} />
); } const meta = { title: "Product/Documents/Annotations", component: StatesShowcase, parameters: { docs: { description: { component: "Document annotation surface for issue documents. Stories under 'Integrated' render the real IssueDocumentsSection chrome (count chip in header, panel + body in their actual layout). Stories under 'States' isolate the panel/layer for unit-level visual debugging.", }, }, }, } satisfies Meta; export default meta; type Story = StoryObj; // --------------------------------------------------------------------------- // Integrated stories — render IssueDocumentsSection with all chrome. // These are the captures the UX gate requires. // --------------------------------------------------------------------------- export const IntegratedDesktopOpen: Story = { parameters: { viewport: { defaultViewport: "responsive" } }, render: () => , }; export const IntegratedDesktopZeroComments: Story = { parameters: { viewport: { defaultViewport: "responsive" } }, render: () => , }; export const IntegratedDesktopEditMode: Story = { parameters: { viewport: { defaultViewport: "responsive" } }, render: () => ( ), }; export const IntegratedDesktopDirtyDraft: Story = { parameters: { viewport: { defaultViewport: "responsive" } }, render: () => , }; export const IntegratedMobileBottomSheet: Story = { parameters: { viewport: { defaultViewport: "mobile1" } }, render: () => , }; // --------------------------------------------------------------------------- // Isolated state stories (kept for unit-level visual debugging). // --------------------------------------------------------------------------- export const DesktopOpenFocused: Story = { render: () => , }; export const DesktopResolvedFocused: Story = { render: () => , }; export const DesktopStaleFocused: Story = { render: () => , }; export const DesktopOrphanedFocused: Story = { render: () => , };