From 14691bd51f2dc9a4417b49d651934616e44405a2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 01:25:05 +0000 Subject: [PATCH] Add Documents library UI (PAP-10524 slice 1) Adds a first-class company Documents surface so board users can find documents without knowing their source issue: - documentsApi client (list/get/backlinks/metadata/links + review index) - Documents library page at /:co/documents with URL-state search, status/ type/owner filters, open-feedback and trusted-source toggles, sort, and active filter chips - DocumentRow (reuses EntityRow): status dot + type icon, owner chip, source-trust warning, backlink/comment/suggestion counts, updated time - Sidebar "Documents" entry + route wiring - draft status badge color; documents queryKeys - Page tests covering rows, empty states, filter chips, query wiring Remaining slices (child issues): detail/review view, issue Documents tab, done-reviewing handoff dialog. Co-Authored-By: Paperclip --- ui/src/App.tsx | 2 + ui/src/api/documents.ts | 99 ++++ ui/src/components/Sidebar.tsx | 2 + ui/src/components/documents/DocumentRow.tsx | 140 ++++++ ui/src/lib/queryKeys.ts | 10 + ui/src/lib/status-colors.ts | 5 + ui/src/pages/Documents.test.tsx | 226 +++++++++ ui/src/pages/Documents.tsx | 492 ++++++++++++++++++++ 8 files changed, 976 insertions(+) create mode 100644 ui/src/api/documents.ts create mode 100644 ui/src/components/documents/DocumentRow.tsx create mode 100644 ui/src/pages/Documents.test.tsx create mode 100644 ui/src/pages/Documents.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6e51901c392..dd0bbff61df 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,7 @@ import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; import { Artifacts } from "./pages/Artifacts"; +import { Documents } from "./pages/Documents"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; @@ -128,6 +129,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/documents.ts b/ui/src/api/documents.ts new file mode 100644 index 00000000000..a259a9923fa --- /dev/null +++ b/ui/src/api/documents.ts @@ -0,0 +1,99 @@ +import type { + CompanyDocument, + CompanyDocumentSummary, + DocumentBacklink, + DocumentLink, + DocumentLinkTargetType, + DocumentReviewIndex, + DocumentStatus, + DocumentType, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export interface CompanyDocumentListFilters { + q?: string; + status?: DocumentStatus[]; + type?: DocumentType[]; + ownerAgentId?: string; + ownerUserId?: string; + targetType?: DocumentLinkTargetType; + targetId?: string; + projectId?: string; + hasOpenFeedback?: boolean; + trustedOnly?: boolean; + includeArchived?: boolean; + updatedAfter?: string; + updatedBefore?: string; + limit?: number; + offset?: number; +} + +export interface UpdateDocumentMetadataInput { + title?: string | null; + status?: DocumentStatus; + documentType?: DocumentType; + summary?: string | null; + ownerAgentId?: string | null; + ownerUserId?: string | null; +} + +export interface CreateDocumentLinkInput { + targetType: DocumentLinkTargetType; + targetId: string; + relationship?: string; +} + +function buildDocumentListQuery(filters: CompanyDocumentListFilters): string { + const params = new URLSearchParams(); + if (filters.q) params.set("q", filters.q); + for (const status of filters.status ?? []) params.append("status", status); + for (const type of filters.type ?? []) params.append("type", type); + if (filters.ownerAgentId) params.set("ownerAgentId", filters.ownerAgentId); + if (filters.ownerUserId) params.set("ownerUserId", filters.ownerUserId); + if (filters.targetType) params.set("targetType", filters.targetType); + if (filters.targetId) params.set("targetId", filters.targetId); + if (filters.projectId) params.set("projectId", filters.projectId); + if (filters.hasOpenFeedback) params.set("hasOpenFeedback", "true"); + if (filters.trustedOnly) params.set("trustedOnly", "true"); + if (filters.includeArchived) params.set("includeArchived", "true"); + if (filters.updatedAfter) params.set("updatedAfter", filters.updatedAfter); + if (filters.updatedBefore) params.set("updatedBefore", filters.updatedBefore); + if (filters.limit !== undefined) params.set("limit", String(filters.limit)); + if (filters.offset !== undefined) params.set("offset", String(filters.offset)); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +export const documentsApi = { + list: (companyId: string, filters: CompanyDocumentListFilters = {}) => + api.get( + `/companies/${companyId}/documents${buildDocumentListQuery(filters)}`, + ), + listForProject: (projectId: string, filters: CompanyDocumentListFilters = {}) => + api.get( + `/projects/${projectId}/documents${buildDocumentListQuery(filters)}`, + ), + get: (companyId: string, documentId: string) => + api.get(`/companies/${companyId}/documents/${documentId}`), + backlinks: (companyId: string, documentId: string) => + api.get(`/companies/${companyId}/documents/${documentId}/backlinks`), + updateMetadata: (companyId: string, documentId: string, data: UpdateDocumentMetadataInput) => + api.patch(`/companies/${companyId}/documents/${documentId}`, data), + createLink: (companyId: string, documentId: string, data: CreateDocumentLinkInput) => + api.post(`/companies/${companyId}/documents/${documentId}/links`, data), + deleteLink: (companyId: string, documentId: string, linkId: string) => + api.delete(`/companies/${companyId}/documents/${documentId}/links/${linkId}`), + /** + * The review index is keyed by the owning issue + document key (the same shape + * the issue annotation rail consumes). The library/detail surface looks it up + * by following a document's `issue` backlink. + */ + reviewIndex: (issueId: string, key: string, options: { rev?: number } = {}) => { + const params = new URLSearchParams(); + if (options.rev !== undefined) params.set("rev", String(options.rev)); + const qs = params.toString(); + return api.get( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-index${qs ? `?${qs}` : ""}`, + ); + }, +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 28e1e8880b6..66288b790c5 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { Package, Settings, FolderOpen, + FileText, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { NavLink } from "@/lib/router"; @@ -104,6 +105,7 @@ export function Sidebar() { + {showWorkspacesLink ? ( diff --git a/ui/src/components/documents/DocumentRow.tsx b/ui/src/components/documents/DocumentRow.tsx new file mode 100644 index 00000000000..657831422b0 --- /dev/null +++ b/ui/src/components/documents/DocumentRow.tsx @@ -0,0 +1,140 @@ +import { useMemo } from "react"; +import { + BarChart3, + BookOpen, + ClipboardList, + FileText, + ListTodo, + MessageSquare, + PencilLine, + ShieldAlert, + Link2, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { CompanyDocumentSummary, DocumentType } from "@paperclipai/shared"; +import { EntityRow } from "../EntityRow"; +import { StatusBadge } from "../StatusBadge"; +import { Identity, deriveInitials } from "../Identity"; +import { relativeTime } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +const DOC_TYPE_ICON: Record = { + plan: ListTodo, + spec: BookOpen, + brief: ClipboardList, + report: BarChart3, + other: FileText, +}; + +/** Status dot color matching the StatusBadge palette (see status-colors.ts). */ +const STATUS_DOT: Record = { + draft: "bg-sky-500", + in_review: "bg-violet-500", + approved: "bg-green-500", + archived: "bg-muted-foreground", +}; + +export interface DocumentOwner { + name: string; + avatarUrl?: string | null; +} + +interface DocumentRowProps { + document: CompanyDocumentSummary; + /** Company prefix used to build the detail link (e.g. "PAP"). */ + to: string; + owner?: DocumentOwner | null; + /** Short identifier shown in monospace — typically the issue document key. */ + identifier?: string; + selected?: boolean; +} + +/** + * Library row for a company document. Reuses `EntityRow` so it matches the + * issues/projects list rhythm: leading status dot + type icon, monospace + * identifier, title, then owner / source-trust / feedback-count / updated meta. + */ +export function DocumentRow({ document, to, owner, identifier, selected }: DocumentRowProps) { + const TypeIcon = DOC_TYPE_ICON[document.documentType] ?? FileText; + const counts = document.feedbackCounts; + const openComments = counts.openComments + counts.openReviewThreads; + const pendingSuggestions = counts.pendingSuggestions; + // Source trust: documents are trusted unless their source was quarantined by + // the trust policy (matches the server-side `trustedOnly` filter). + const quarantined = document.sourceTrust?.disposition === "quarantined"; + const backlinkCount = document.backlinks.length; + + const primaryBacklink = useMemo(() => { + const issueLink = document.backlinks.find((link) => link.identifier); + return issueLink ?? document.backlinks[0] ?? null; + }, [document.backlinks]); + + const resolvedIdentifier = identifier ?? primaryBacklink?.issueDocumentKey ?? undefined; + + return ( + +