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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-07 01:25:05 +00:00
1 parent 93acc1d1a2
commit 14691bd51f
8 files changed
+976

No files matched your search

+2
View File
@@ -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() {
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="documents" element={<Documents />} />
<Route path="artifacts" element={<Artifacts />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
+99
View File
@@ -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<CompanyDocumentSummary[]>(
`/companies/${companyId}/documents${buildDocumentListQuery(filters)}`,
),
listForProject: (projectId: string, filters: CompanyDocumentListFilters = {}) =>
api.get<CompanyDocumentSummary[]>(
`/projects/${projectId}/documents${buildDocumentListQuery(filters)}`,
),
get: (companyId: string, documentId: string) =>
api.get<CompanyDocument>(`/companies/${companyId}/documents/${documentId}`),
backlinks: (companyId: string, documentId: string) =>
api.get<DocumentBacklink[]>(`/companies/${companyId}/documents/${documentId}/backlinks`),
updateMetadata: (companyId: string, documentId: string, data: UpdateDocumentMetadataInput) =>
api.patch<CompanyDocument>(`/companies/${companyId}/documents/${documentId}`, data),
createLink: (companyId: string, documentId: string, data: CreateDocumentLinkInput) =>
api.post<DocumentLink>(`/companies/${companyId}/documents/${documentId}/links`, data),
deleteLink: (companyId: string, documentId: string, linkId: string) =>
api.delete<void>(`/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<DocumentReviewIndex>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-index${qs ? `?${qs}` : ""}`,
);
},
};
+2
View File
@@ -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() {
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
<SidebarNavItem to="/documents" label="Documents" icon={FileText} />
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
+140
View File
@@ -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<DocumentType, LucideIcon> = {
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<string, string> = {
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 (
<EntityRow
to={to}
selected={selected}
leading={
<>
<span
className={cn("h-2 w-2 shrink-0 rounded-full", STATUS_DOT[document.status] ?? "bg-muted-foreground")}
aria-hidden="true"
/>
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</>
}
identifier={resolvedIdentifier}
title={document.title ?? "Untitled document"}
subtitle={document.summary ?? undefined}
reserveSubtitleSpace
meta={
<>
<StatusBadge status={document.status} />
{owner ? (
<Identity
name={owner.name}
avatarUrl={owner.avatarUrl}
initials={deriveInitials(owner.name)}
size="sm"
className="text-muted-foreground"
/>
) : null}
{quarantined ? (
<span
className="inline-flex items-center text-amber-600 dark:text-amber-400"
title="Untrusted source — quarantined by trust policy"
>
<ShieldAlert className="h-3.5 w-3.5" aria-label="Untrusted source" />
</span>
) : null}
</>
}
trailing={
<div className="flex items-center gap-2.5 text-xs text-muted-foreground">
{backlinkCount > 0 ? (
<span className="inline-flex items-center gap-0.5" title={`${backlinkCount} linked item(s)`}>
<Link2 className="h-3.5 w-3.5" aria-hidden="true" />
{backlinkCount}
</span>
) : null}
{openComments > 0 ? (
<span className="inline-flex items-center gap-0.5" title={`${openComments} open comment(s)`}>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
{openComments}
</span>
) : null}
{pendingSuggestions > 0 ? (
<span className="inline-flex items-center gap-0.5" title={`${pendingSuggestions} pending suggestion(s)`}>
<PencilLine className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSuggestions}
</span>
) : null}
<span className="tabular-nums whitespace-nowrap" title={new Date(document.updatedAt).toLocaleString()}>
{relativeTime(document.updatedAt)}
</span>
</div>
}
/>
);
}
+10
View File
@@ -116,6 +116,16 @@ export const queryKeys = {
list: (companyId: string) => ["goals", companyId] as const,
detail: (id: string) => ["goals", "detail", id] as const,
},
documents: {
list: (companyId: string, filters: Record<string, unknown> = {}) =>
["documents", companyId, filters] as const,
detail: (companyId: string, documentId: string) =>
["documents", "detail", companyId, documentId] as const,
backlinks: (companyId: string, documentId: string) =>
["documents", "backlinks", companyId, documentId] as const,
reviewIndex: (issueId: string, key: string) =>
["documents", "review-index", issueId, key] as const,
},
artifacts: {
list: (
companyId: string,
+5
View File
@@ -78,6 +78,11 @@ export const statusBadge: Record<string, string> = {
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
done: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
cancelled: "bg-muted text-muted-foreground",
// Document statuses — draft (blue) → in_review (violet) → approved (green)
// → archived (neutral). `in_review`, `approved`, and `archived` reuse hues
// defined above; only `draft` is document-specific.
draft: "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
};
export const statusBadgeDefault = "bg-muted text-muted-foreground";
+226
View File
@@ -0,0 +1,226 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, CompanyDocumentSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Documents } from "./Documents";
const mockDocumentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
// A mutable search-params store so tests can seed the URL state and observe writes.
const searchStore = vi.hoisted(() => ({ value: "" }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>
{children}
</a>
),
useSearchParams: () => {
const params = new URLSearchParams(searchStore.value);
const setParams = (
next: URLSearchParams | Record<string, string> | ((prev: URLSearchParams) => URLSearchParams),
) => {
const resolved =
typeof next === "function"
? next(new URLSearchParams(searchStore.value))
: new URLSearchParams(next as Record<string, string>);
searchStore.value = resolved.toString();
};
return [params, setParams];
},
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../api/documents", () => ({
documentsApi: mockDocumentsApi,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeDocument(overrides: Partial<CompanyDocumentSummary>): CompanyDocumentSummary {
return {
id: "doc-1",
companyId: "company-1",
title: "Paperclip Documents review flow",
format: "markdown",
status: "in_review",
documentType: "spec",
summary: "How review works",
ownerAgentId: "agent-1",
ownerUserId: null,
latestRevisionId: "rev-1",
latestRevisionNumber: 12,
createdByAgentId: "agent-1",
createdByUserId: null,
updatedByAgentId: "agent-1",
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
sourceTrust: null,
archivedAt: null,
archivedByAgentId: null,
archivedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-06T00:00:00Z"),
backlinks: [],
feedbackCounts: {
openComments: 3,
resolvedComments: 0,
openReviewThreads: 0,
resolvedReviewThreads: 0,
pendingSuggestions: 2,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
},
...overrides,
};
}
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "ClaudeCoder",
urlKey: "claudecoder",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
describe("Documents library", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
let queryClient: QueryClient;
beforeEach(() => {
searchStore.value = "";
container = document.createElement("div");
document.body.appendChild(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockAgentsApi.list.mockResolvedValue([makeAgent({})]);
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
root = null;
}
container.remove();
queryClient.clear();
vi.clearAllMocks();
});
async function render() {
root = createRoot(container);
await act(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<Documents />
</QueryClientProvider>,
);
});
// Allow react-query microtasks to flush.
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
it("renders a row per document with title and feedback counts", async () => {
mockDocumentsApi.list.mockResolvedValue([
makeDocument({ id: "doc-1", title: "Review flow spec" }),
makeDocument({ id: "doc-2", title: "Onboarding plan", documentType: "plan", status: "draft" }),
]);
await render();
expect(container.textContent).toContain("Review flow spec");
expect(container.textContent).toContain("Onboarding plan");
// Feedback counts (3 open comments, 2 pending suggestions) are surfaced.
expect(container.textContent).toContain("3");
expect(container.textContent).toContain("2");
expect(mockDocumentsApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ limit: 100 }));
});
it("shows the empty state when there are no documents and no filters", async () => {
mockDocumentsApi.list.mockResolvedValue([]);
await render();
expect(container.textContent).toContain("No documents yet");
});
it("renders an active filter chip and the filtered empty state when a status filter is set", async () => {
searchStore.value = "status=approved";
mockDocumentsApi.list.mockResolvedValue([]);
await render();
expect(container.textContent).toContain("Status: Approved");
expect(container.textContent).toContain("No documents match these filters");
});
it("passes the status filter through to the API query", async () => {
searchStore.value = "status=in_review";
mockDocumentsApi.list.mockResolvedValue([makeDocument({})]);
await render();
expect(mockDocumentsApi.list).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ status: ["in_review"] }),
);
});
});
+492
View File
@@ -0,0 +1,492 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, FileText, Search, SlidersHorizontal, X } from "lucide-react";
import type { DocumentStatus, DocumentType } from "@paperclipai/shared";
import { DOCUMENT_STATUSES, DOCUMENT_TYPES } from "@paperclipai/shared";
import { documentsApi, type CompanyDocumentListFilters } from "../api/documents";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { DocumentRow, type DocumentOwner } from "../components/documents/DocumentRow";
import { useSearchParams } from "@/lib/router";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const SEARCH_DEBOUNCE_MS = 250;
const STATUS_LABELS: Record<DocumentStatus, string> = {
draft: "Draft",
in_review: "In review",
approved: "Approved",
archived: "Archived",
};
const TYPE_LABELS: Record<DocumentType, string> = {
plan: "Plan",
spec: "Spec",
brief: "Brief",
report: "Report",
other: "Other",
};
type SortOption = "updated_desc" | "updated_asc" | "title_asc" | "feedback_desc" | "status";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "updated_desc", label: "Updated (newest)" },
{ value: "updated_asc", label: "Updated (oldest)" },
{ value: "title_asc", label: "Title (A–Z)" },
{ value: "feedback_desc", label: "Open feedback" },
{ value: "status", label: "Status" },
];
function parseMulti<T extends string>(values: string[], allowed: readonly T[]): T[] {
const set = new Set<string>(allowed);
return values.filter((value): value is T => set.has(value));
}
function parseSort(value: string | null): SortOption {
return SORT_OPTIONS.some((option) => option.value === value)
? (value as SortOption)
: "updated_desc";
}
const STATUS_ORDER: Record<DocumentStatus, number> = {
draft: 0,
in_review: 1,
approved: 2,
archived: 3,
};
export function Documents() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q") ?? "";
const statusFilter = useMemo(
() => parseMulti(searchParams.getAll("status"), DOCUMENT_STATUSES),
[searchParams],
);
const typeFilter = useMemo(
() => parseMulti(searchParams.getAll("type"), DOCUMENT_TYPES),
[searchParams],
);
const ownerFilter = searchParams.get("owner") ?? undefined;
const hasFeedback = searchParams.get("feedback") === "1";
const trustedOnly = searchParams.get("trusted") === "1";
const includeArchived = statusFilter.includes("archived");
const sort = parseSort(searchParams.get("sort"));
const [draftQuery, setDraftQuery] = useState(query);
useEffect(() => {
setBreadcrumbs([{ label: "Documents" }]);
}, [setBreadcrumbs]);
// Keep the search box synced when `q` changes from outside (back/forward).
useEffect(() => {
setDraftQuery((prev) => (prev.trim() === query ? prev : query));
}, [query]);
// Debounce the search box into the `q` URL param.
useEffect(() => {
const trimmed = draftQuery.trim();
if (trimmed === query) return;
const timer = setTimeout(() => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (trimmed) next.set("q", trimmed);
else next.delete("q");
return next;
},
{ replace: true },
);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [draftQuery, query, setSearchParams]);
const updateParams = useCallback(
(mutate: (params: URLSearchParams) => void) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
mutate(next);
return next;
});
},
[setSearchParams],
);
const toggleMulti = useCallback(
(key: "status" | "type", value: string) => {
updateParams((params) => {
const current = params.getAll(key);
params.delete(key);
const next = current.includes(value)
? current.filter((entry) => entry !== value)
: [...current, value];
for (const entry of next) params.append(key, entry);
});
},
[updateParams],
);
const toggleFlag = useCallback(
(key: "feedback" | "trusted") => {
updateParams((params) => {
if (params.get(key) === "1") params.delete(key);
else params.set(key, "1");
});
},
[updateParams],
);
const setSort = useCallback(
(value: SortOption) => {
updateParams((params) => {
if (value === "updated_desc") params.delete("sort");
else params.set("sort", value);
});
},
[updateParams],
);
const clearFilters = useCallback(() => {
setDraftQuery("");
setSearchParams({}, { replace: true });
}, [setSearchParams]);
const filters: CompanyDocumentListFilters = useMemo(
() => ({
q: query || undefined,
status: statusFilter.length > 0 ? statusFilter : undefined,
type: typeFilter.length > 0 ? typeFilter : undefined,
ownerAgentId: ownerFilter,
hasOpenFeedback: hasFeedback || undefined,
trustedOnly: trustedOnly || undefined,
includeArchived: includeArchived || undefined,
limit: 100,
}),
[query, statusFilter, typeFilter, ownerFilter, hasFeedback, trustedOnly, includeArchived],
);
const {
data: documents,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.documents.list(selectedCompanyId!, filters as Record<string, unknown>),
queryFn: () => documentsApi.list(selectedCompanyId!, filters),
enabled: !!selectedCompanyId,
placeholderData: (previous) => previous,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const ownerById = useMemo(() => {
const map = new Map<string, DocumentOwner>();
for (const agent of agents ?? []) {
map.set(agent.id, { name: agent.name });
}
return map;
}, [agents]);
const sortedDocuments = useMemo(() => {
const list = [...(documents ?? [])];
list.sort((a, b) => {
switch (sort) {
case "updated_asc":
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case "title_asc":
return (a.title ?? "").localeCompare(b.title ?? "");
case "feedback_desc": {
const open = (doc: typeof a) =>
doc.feedbackCounts.openComments +
doc.feedbackCounts.openReviewThreads +
doc.feedbackCounts.pendingSuggestions;
return open(b) - open(a);
}
case "status":
return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
case "updated_desc":
default:
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
});
return list;
}, [documents, sort]);
const activeFilterCount =
statusFilter.length + typeFilter.length + (ownerFilter ? 1 : 0) + (hasFeedback ? 1 : 0) + (trustedOnly ? 1 : 0);
const hasActiveFilters = activeFilterCount > 0 || query.length > 0;
if (!selectedCompanyId) {
return <EmptyState icon={FileText} message="Select a company to view documents." />;
}
const ownerAgent = ownerFilter ? ownerById.get(ownerFilter) : undefined;
return (
<div className="w-full max-w-5xl space-y-4">
{/* Search + filter controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftQuery}
onChange={(event) => setDraftQuery(event.currentTarget.value)}
placeholder="Search documents..."
aria-label="Search documents"
className="h-9 pl-9 pr-9 text-sm"
/>
{draftQuery.length > 0 ? (
<button
type="button"
onClick={() => setDraftQuery("")}
aria-label="Clear document search"
className="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{/* Status filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("h-8", statusFilter.length > 0 && "bg-accent")}
data-testid="documents-status-filter"
>
Status{statusFilter.length > 0 ? ` (${statusFilter.length})` : ""}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{DOCUMENT_STATUSES.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={statusFilter.includes(status)}
onSelect={(event) => {
event.preventDefault();
toggleMulti("status", status);
}}
>
{STATUS_LABELS[status]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Type filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("h-8", typeFilter.length > 0 && "bg-accent")}
data-testid="documents-type-filter"
>
Type{typeFilter.length > 0 ? ` (${typeFilter.length})` : ""}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Type</DropdownMenuLabel>
{DOCUMENT_TYPES.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={typeFilter.includes(type)}
onSelect={(event) => {
event.preventDefault();
toggleMulti("type", type);
}}
>
{TYPE_LABELS[type]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Owner filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("h-8", ownerFilter && "bg-accent")}
data-testid="documents-owner-filter"
>
{ownerAgent ? ownerAgent.name : "Owner"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 w-52 overflow-y-auto">
<DropdownMenuLabel>Owner</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() => updateParams((params) => params.delete("owner"))}
className="justify-between"
>
Any owner
{!ownerFilter ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(agents ?? []).map((agent) => (
<DropdownMenuItem
key={agent.id}
onSelect={() => updateParams((params) => params.set("owner", agent.id))}
className="justify-between"
>
<span className="truncate">{agent.name}</span>
{ownerFilter === agent.id ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Toggles + sort */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8", (hasFeedback || trustedOnly) && "bg-accent")}
aria-label="More filters and sort"
title="More filters and sort"
data-testid="documents-more-filters"
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filters</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={hasFeedback}
onSelect={(event) => {
event.preventDefault();
toggleFlag("feedback");
}}
>
With open feedback
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustedOnly}
onSelect={(event) => {
event.preventDefault();
toggleFlag("trusted");
}}
>
Trusted sources only
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
{SORT_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => setSort(option.value)}
className="justify-between"
>
{option.label}
{sort === option.value ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Active filter chips */}
{hasActiveFilters ? (
<div className="flex flex-wrap items-center gap-1.5 text-xs">
{statusFilter.map((status) => (
<FilterChip key={`status-${status}`} label={`Status: ${STATUS_LABELS[status]}`} onRemove={() => toggleMulti("status", status)} />
))}
{typeFilter.map((type) => (
<FilterChip key={`type-${type}`} label={`Type: ${TYPE_LABELS[type]}`} onRemove={() => toggleMulti("type", type)} />
))}
{ownerFilter ? (
<FilterChip
label={`Owner: ${ownerAgent?.name ?? "Unknown"}`}
onRemove={() => updateParams((params) => params.delete("owner"))}
/>
) : null}
{hasFeedback ? <FilterChip label="Open feedback" onRemove={() => toggleFlag("feedback")} /> : null}
{trustedOnly ? <FilterChip label="Trusted sources" onRemove={() => toggleFlag("trusted")} /> : null}
<button
type="button"
onClick={clearFilters}
className="rounded-md px-2 py-1 font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
Clear all
</button>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{(error as Error).message}</p> : null}
{isLoading ? (
<PageSkeleton variant="list" />
) : sortedDocuments.length === 0 ? (
hasActiveFilters ? (
<EmptyState icon={FileText} message="No documents match these filters." action="Clear filters" onAction={clearFilters} />
) : (
<EmptyState
icon={FileText}
message="No documents yet. Plans, specs, and briefs from across the company will appear here for review."
/>
)
) : (
<div className="overflow-hidden rounded-md border border-border">
{sortedDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
to={`/documents/${document.id}`}
owner={
document.ownerAgentId ? ownerById.get(document.ownerAgentId) ?? null : null
}
/>
))}
</div>
)}
</div>
);
}
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-2 py-1 font-medium text-foreground">
{label}
<button
type="button"
onClick={onRemove}
aria-label={`Remove filter ${label}`}
className="inline-flex h-4 w-4 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</span>
);
}