From 2f23e4f1cafd8edbdb7f85f66cb5c5c44a8ad24d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 01:55:08 +0000 Subject: [PATCH] Add issue Documents tab + Open-in-Documents ergonomics (PAP-10569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX spec PAP-10520 §6. Inline IssueDocumentsSection plan editing/revision is unchanged; it gains an "Open in Documents" (ExternalLink) button routing to /documents/?from=issue: and a 💬N · ✎M feedback summary per doc. New issue "Documents" tab (next to Chat/Activity/Related work) surfaces every company document linked to the issue, reusing the library DocumentRow, pinning the plan to the top, with scoped search and an Add document control (link existing via picker / create new in Chat). Tab label shows the linked-doc count plus an open-feedback dot. Cross-issue document mention chip renders internal //documents/ links in markdown using the chip-match-document-* tokens, with a hover summary of the document title, status, and feedback counts. Feedback counts come from one issue-scoped company-documents query shared by the inline card and the tab (useIssueLinkedDocuments). Storybook stories added for UX sign-off. Co-Authored-By: Paperclip --- ui/src/components/IssueDocumentsSection.tsx | 27 +- ui/src/components/IssueDocumentsTab.tsx | 221 +++++++++++++++ ui/src/components/MarkdownBody.tsx | 65 ++++- .../documents/DocumentFeedbackCounts.tsx | 54 ++++ ui/src/hooks/useIssueLinkedDocuments.ts | 42 +++ ui/src/pages/IssueDetail.tsx | 38 +++ .../issue-documents-integration.stories.tsx | 251 ++++++++++++++++++ 7 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/IssueDocumentsTab.tsx create mode 100644 ui/src/components/documents/DocumentFeedbackCounts.tsx create mode 100644 ui/src/hooks/useIssueLinkedDocuments.ts create mode 100644 ui/storybook/stories/issue-documents-integration.stories.tsx diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 4b329cfeab1..5946190ec16 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -9,11 +9,13 @@ import type { Issue, IssueDocument, } from "@paperclipai/shared"; -import { isSystemIssueDocumentKey } from "@paperclipai/shared"; -import { useLocation } from "@/lib/router"; +import { buildDocumentReferenceHref, isSystemIssueDocumentKey } from "@paperclipai/shared"; +import { Link, useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; +import { useIssueLinkedDocuments } from "../hooks/useIssueLinkedDocuments"; +import { DocumentFeedbackCounts } from "./documents/DocumentFeedbackCounts"; import { deriveDocumentRevisionState } from "../lib/document-revisions"; import type { CompanyUserProfile } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; @@ -35,7 +37,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, ExternalLink, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react"; import { DocumentDiffModal } from "./DocumentDiffModal"; import { SourceTrustBadge } from "./SourceTrustBadge"; @@ -219,6 +221,10 @@ export function IssueDocumentsSection({ queryFn: () => issuesApi.listDocuments(issue.id), }); + // Company-document summaries for this issue carry feedback counts (open comments + // / pending suggestions) keyed by the same id as the inline issue document. + const { byId: linkedDocumentById } = useIssueLinkedDocuments(issue.companyId, issue.id); + const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({ queryKey: revisionMenuOpenKey ? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey) @@ -1000,10 +1006,25 @@ export function IssueDocumentsSection({ onToggle={() => toggleAnnotationPanel(doc.key)} /> ) : null} + {showTitle &&

{displayedTitle}

}
+ {canManageDocumentLocks ? ( + ) : null} +
+ + + + + + + setLinkPickerOpen(true)}> + Link existing document… + + + Create new document in Chat + + + + + + {isLoading ? ( + + ) : sortedDocuments.length === 0 ? ( + search.length > 0 ? ( + + ) : ( + + ) + ) : ( +
+ {sortedDocuments.map((doc) => { + const key = issueDocumentKeyFor(doc, issueId); + return ( + + ); + })} +
+ )} + + doc.id))} + onSelect={(documentId) => linkExisting.mutate(documentId)} + pending={linkExisting.isPending} + /> + + ); +} + +function LinkExistingDocumentPicker({ + open, + onOpenChange, + companyId, + linkedIds, + onSelect, + pending, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + companyId: string; + linkedIds: Set; + onSelect: (documentId: string) => void; + pending: boolean; +}) { + const { data: allDocuments } = useQuery({ + queryKey: queryKeys.documents.list(companyId, { picker: true }), + queryFn: () => documentsApi.list(companyId, { limit: 100 }), + enabled: open && Boolean(companyId), + }); + + const candidates = useMemo( + () => (allDocuments ?? []).filter((doc) => !linkedIds.has(doc.id)), + [allDocuments, linkedIds], + ); + + return ( + + + + No documents found. + + {candidates.map((doc) => ( + onSelect(doc.id)} + > + + ))} + + + + ); +} diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 4d4cb8e7bf7..107ae8413ba 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,7 @@ import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; +import { Check, Copy, ExternalLink, FileText, Github, WrapText } from "lucide-react"; +import { parseDocumentReferenceHref } from "@paperclipai/shared"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -9,6 +10,7 @@ import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; +import { documentsApi } from "../api/documents"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; @@ -68,6 +70,56 @@ function MarkdownIssueLink({ ); } +/** + * Cross-issue document mention chip. Renders a reference to `//documents/` + * using the `chip-match-document-*` token family. Hovering surfaces the document + * title, status, and feedback counts via a composed tooltip. + */ +function MarkdownDocumentLink({ + href, + documentId, + children, +}: { + href: string; + documentId: string; + children: ReactNode; +}) { + const company = useOptionalCompany(); + const companyId = company?.selectedCompanyId ?? null; + const { data } = useQuery({ + queryKey: queryKeys.documents.detail(companyId ?? "", documentId), + queryFn: () => documentsApi.get(companyId!, documentId), + enabled: Boolean(companyId), + staleTime: 60_000, + }); + + const title = data?.title ?? null; + const status = data?.status ?? null; + const counts = data?.feedbackCounts ?? null; + const openComments = counts ? counts.openComments + counts.openReviewThreads : 0; + const pendingSuggestions = counts?.pendingSuggestions ?? 0; + const tooltip = [ + title ?? "Document", + status ? `· ${status.replace(/_/g, " ")}` : null, + openComments > 0 ? `· 💬 ${openComments}` : null, + pendingSuggestions > 0 ? `· ✎ ${pendingSuggestions}` : null, + ] + .filter(Boolean) + .join(" "); + + return ( + +