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 ( + +