mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add issue Documents tab + Open-in-Documents ergonomics (PAP-10569)
UX spec PAP-10520 §6. Inline IssueDocumentsSection plan editing/revision is unchanged; it gains an "Open in Documents" (ExternalLink) button routing to /documents/<id>?from=issue:<key> 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 /<co>/documents/<id> 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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
0245d1f3af
commit
2f23e4f1ca
7 files changed
+694
-4
No files matched your search
@@ -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}
|
||||
<DocumentFeedbackCounts counts={linkedDocumentById.get(doc.id)?.feedbackCounts} />
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground transition-colors"
|
||||
title="Open in Documents"
|
||||
>
|
||||
<Link
|
||||
to={buildDocumentReferenceHref(doc.id, doc.key)}
|
||||
aria-label={`Open ${doc.key} in Documents`}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canManageDocumentLocks ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileText, Plus, Search, X } from "lucide-react";
|
||||
import type { Agent, CompanyDocumentSummary } from "@paperclipai/shared";
|
||||
import { buildDocumentReferenceHref } from "@paperclipai/shared";
|
||||
import { documentsApi } from "../api/documents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useIssueLinkedDocuments } from "../hooks/useIssueLinkedDocuments";
|
||||
import { DocumentRow, type DocumentOwner } from "./documents/DocumentRow";
|
||||
import { hasOpenDocumentFeedback } from "./documents/DocumentFeedbackCounts";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { PageSkeleton } from "./PageSkeleton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
/** Find the inline issue-document key for a company document linked to this issue. */
|
||||
function issueDocumentKeyFor(doc: CompanyDocumentSummary, issueId: string): string | null {
|
||||
const link = doc.backlinks.find(
|
||||
(backlink) => backlink.targetType === "issue" && backlink.targetId === issueId,
|
||||
);
|
||||
return link?.issueDocumentKey ?? null;
|
||||
}
|
||||
|
||||
function isPlanDocument(doc: CompanyDocumentSummary, issueId: string): boolean {
|
||||
return issueDocumentKeyFor(doc, issueId) === "plan";
|
||||
}
|
||||
|
||||
export interface IssueDocumentsTabProps {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
issueIdentifier: string | null;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Documents" tab on the issue detail. Surfaces every company document linked to the
|
||||
* issue (its inline issue documents plus cross-linked company docs), reusing the
|
||||
* library `DocumentRow`. The plan document is pinned to the top; the header has a
|
||||
* search scoped to this issue and an "Add document" control (link existing / create new).
|
||||
*/
|
||||
export function IssueDocumentsTab({ issueId, companyId, issueIdentifier, agentMap }: IssueDocumentsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [linkPickerOpen, setLinkPickerOpen] = useState(false);
|
||||
|
||||
const { documents, isLoading } = useIssueLinkedDocuments(companyId, issueId, { search });
|
||||
|
||||
const ownerById = useMemo(() => {
|
||||
const map = new Map<string, DocumentOwner>();
|
||||
if (agentMap) {
|
||||
for (const agent of agentMap.values()) map.set(agent.id, { name: agent.name });
|
||||
}
|
||||
return map;
|
||||
}, [agentMap]);
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...documents].sort((a, b) => {
|
||||
const aPlan = isPlanDocument(a, issueId);
|
||||
const bPlan = isPlanDocument(b, issueId);
|
||||
if (aPlan && !bPlan) return -1;
|
||||
if (!aPlan && bPlan) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}, [documents, issueId]);
|
||||
|
||||
const linkExisting = useMutation({
|
||||
mutationFn: (documentId: string) =>
|
||||
documentsApi.createLink(companyId, documentId, { targetType: "issue", targetId: issueId }),
|
||||
onSuccess: () => {
|
||||
setLinkPickerOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder="Search this issue's documents..."
|
||||
aria-label="Search issue documents"
|
||||
className="h-9 pl-9 pr-9 text-sm"
|
||||
/>
|
||||
{search.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
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>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add document
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => setLinkPickerOpen(true)}>
|
||||
Link existing document…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="#document-new">Create new document in Chat</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : sortedDocuments.length === 0 ? (
|
||||
search.length > 0 ? (
|
||||
<EmptyState icon={FileText} message="No linked documents match this search." />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
message="Only the plan document is linked. Add a spec, brief, or report to keep more context here."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
{sortedDocuments.map((doc) => {
|
||||
const key = issueDocumentKeyFor(doc, issueId);
|
||||
return (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
to={buildDocumentReferenceHref(doc.id, key ?? issueIdentifier)}
|
||||
identifier={key ?? undefined}
|
||||
owner={doc.ownerAgentId ? ownerById.get(doc.ownerAgentId) ?? null : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkExistingDocumentPicker
|
||||
open={linkPickerOpen}
|
||||
onOpenChange={setLinkPickerOpen}
|
||||
companyId={companyId}
|
||||
linkedIds={new Set(documents.map((doc) => doc.id))}
|
||||
onSelect={(documentId) => linkExisting.mutate(documentId)}
|
||||
pending={linkExisting.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkExistingDocumentPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
companyId,
|
||||
linkedIds,
|
||||
onSelect,
|
||||
pending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companyId: string;
|
||||
linkedIds: Set<string>;
|
||||
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 (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search company documents to link..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No documents found.</CommandEmpty>
|
||||
<CommandGroup heading="Documents">
|
||||
{candidates.map((doc) => (
|
||||
<CommandItem
|
||||
key={doc.id}
|
||||
value={`${doc.title ?? "Untitled document"} ${doc.id}`}
|
||||
disabled={pending}
|
||||
onSelect={() => onSelect(doc.id)}
|
||||
>
|
||||
<FileText className="mr-2 h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate">{doc.title ?? "Untitled document"}</span>
|
||||
{hasOpenDocumentFeedback(doc.feedbackCounts) ? (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -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 `/<co>/documents/<id>`
|
||||
* 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 (
|
||||
<Link
|
||||
to={href}
|
||||
data-mention-kind="document"
|
||||
title={tooltip}
|
||||
className="inline-flex items-center gap-1 rounded-full border px-2 py-px align-[-0.125em] text-[0.85em] font-medium leading-none no-underline bg-[var(--chip-match-document-bg)] text-[var(--chip-match-document-fg)] border-[var(--chip-match-document-border)]"
|
||||
>
|
||||
<FileText className="h-3 w-3" aria-hidden="true" />
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function loadMermaid() {
|
||||
if (!mermaidLoaderPromise) {
|
||||
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||
@@ -658,6 +710,17 @@ export function MarkdownBody({
|
||||
);
|
||||
}
|
||||
|
||||
// Only internal references (e.g. `/PAP/documents/<id>`) become document chips;
|
||||
// external links that happen to contain `/documents/` are left as plain links.
|
||||
const documentRef = href && href.startsWith("/") ? parseDocumentReferenceHref(href) : null;
|
||||
if (documentRef && href) {
|
||||
return (
|
||||
<MarkdownDocumentLink href={href} documentId={documentRef.documentId}>
|
||||
{linkChildren}
|
||||
</MarkdownDocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = href ? parseMentionChipHref(href) : null;
|
||||
if (parsed) {
|
||||
const targetHref = parsed.kind === "project"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MessageSquare, PencilLine } from "lucide-react";
|
||||
import type { DocumentFeedbackCounts as DocumentFeedbackCountsData } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function openCommentCount(counts: DocumentFeedbackCountsData): number {
|
||||
return counts.openComments + counts.openReviewThreads;
|
||||
}
|
||||
|
||||
export function pendingSuggestionCount(counts: DocumentFeedbackCountsData): number {
|
||||
return counts.pendingSuggestions;
|
||||
}
|
||||
|
||||
export function hasOpenDocumentFeedback(counts: DocumentFeedbackCountsData | null | undefined): boolean {
|
||||
if (!counts) return false;
|
||||
return openCommentCount(counts) > 0 || pendingSuggestionCount(counts) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact `💬 N · ✎ M` feedback summary for a document — open comments and pending
|
||||
* suggestions. Shared between the inline issue document card and the Documents tab so
|
||||
* the same counts read identically everywhere. Renders nothing when there is no feedback.
|
||||
*/
|
||||
export function DocumentFeedbackCounts({
|
||||
counts,
|
||||
className,
|
||||
}: {
|
||||
counts: DocumentFeedbackCountsData | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!counts) return null;
|
||||
const comments = openCommentCount(counts);
|
||||
const suggestions = pendingSuggestionCount(counts);
|
||||
if (comments === 0 && suggestions === 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-2 text-[11px] text-muted-foreground", className)}
|
||||
data-testid="document-feedback-counts"
|
||||
>
|
||||
{comments > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={`${comments} open comment(s)`}>
|
||||
<MessageSquare className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="tabular-nums">{comments}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{suggestions > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={`${suggestions} pending suggestion(s)`}>
|
||||
<PencilLine className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="tabular-nums">{suggestions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CompanyDocumentSummary } from "@paperclipai/shared";
|
||||
import { documentsApi, type CompanyDocumentListFilters } from "../api/documents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Company documents linked to an issue (its inline issue documents plus any
|
||||
* cross-linked company docs). Backs both the inline issue document card feedback
|
||||
* counts and the issue "Documents" tab, so they stay in sync from one query.
|
||||
*/
|
||||
export function useIssueLinkedDocuments(
|
||||
companyId: string | null | undefined,
|
||||
issueId: string,
|
||||
options: { search?: string; enabled?: boolean } = {},
|
||||
) {
|
||||
const filters: CompanyDocumentListFilters = useMemo(
|
||||
() => ({
|
||||
targetType: "issue",
|
||||
targetId: issueId,
|
||||
q: options.search?.trim() || undefined,
|
||||
includeArchived: true,
|
||||
limit: 200,
|
||||
}),
|
||||
[issueId, options.search],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.documents.list(companyId ?? "", filters as Record<string, unknown>),
|
||||
queryFn: () => documentsApi.list(companyId!, filters),
|
||||
enabled: Boolean(companyId) && (options.enabled ?? true),
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, CompanyDocumentSummary>();
|
||||
for (const doc of query.data ?? []) map.set(doc.id, doc);
|
||||
return map;
|
||||
}, [query.data]);
|
||||
|
||||
return { ...query, documents: query.data ?? [], byId };
|
||||
}
|
||||
@@ -67,6 +67,9 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueDocumentsTab } from "../components/IssueDocumentsTab";
|
||||
import { useIssueLinkedDocuments } from "../hooks/useIssueLinkedDocuments";
|
||||
import { hasOpenDocumentFeedback } from "../components/documents/DocumentFeedbackCounts";
|
||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
|
||||
import { isImageAttachment } from "../lib/issue-attachments";
|
||||
@@ -127,6 +130,7 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Flag,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
@@ -1543,6 +1547,14 @@ export function IssueDetail() {
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [agents]);
|
||||
const { documents: linkedDocuments } = useIssueLinkedDocuments(issue?.companyId, issue?.id ?? "", {
|
||||
enabled: Boolean(issue?.id),
|
||||
});
|
||||
const linkedDocumentCount = linkedDocuments.length;
|
||||
const hasOpenLinkedDocFeedback = useMemo(
|
||||
() => linkedDocuments.some((doc) => hasOpenDocumentFeedback(doc.feedbackCounts)),
|
||||
[linkedDocuments],
|
||||
);
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
@@ -3918,6 +3930,21 @@ export function IssueDetail() {
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="documents" className="gap-1.5">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Documents
|
||||
{linkedDocumentCount > 0 ? (
|
||||
<span className="ml-0.5 rounded-full bg-muted px-1.5 text-[10px] tabular-nums leading-4 text-muted-foreground">
|
||||
{linkedDocumentCount}
|
||||
</span>
|
||||
) : null}
|
||||
{hasOpenLinkedDocFeedback ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-amber-500"
|
||||
aria-label="Open feedback on linked documents"
|
||||
/>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="related-work" className="gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Related work
|
||||
@@ -4031,6 +4058,17 @@ export function IssueDetail() {
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents">
|
||||
{detailTab === "documents" ? (
|
||||
<IssueDocumentsTab
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
issueIdentifier={issue.identifier}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="related-work">
|
||||
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ChevronDown, Copy, ExternalLink, FileText, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||
import type { CompanyDocumentSummary, DocumentFeedbackCounts as Counts } from "@paperclipai/shared";
|
||||
import { DocumentRow } from "@/components/documents/DocumentRow";
|
||||
import { DocumentFeedbackCounts } from "@/components/documents/DocumentFeedbackCounts";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* Storybook coverage for the issue ↔ documents integration (PAP-10569, UX spec §6).
|
||||
*
|
||||
* Renders the real presentational pieces — `DocumentRow`, `DocumentFeedbackCounts`,
|
||||
* and the `chip-match-document-*` mention chip — inside static shells so UX/QA can
|
||||
* capture sign-off screenshots without a live backend. Interactive behaviour
|
||||
* (autosave, linking, navigation) is covered by unit tests.
|
||||
*/
|
||||
|
||||
function emptyCounts(): Counts {
|
||||
return {
|
||||
openComments: 0,
|
||||
resolvedComments: 0,
|
||||
openReviewThreads: 0,
|
||||
resolvedReviewThreads: 0,
|
||||
pendingSuggestions: 0,
|
||||
acceptedSuggestions: 0,
|
||||
rejectedSuggestions: 0,
|
||||
staleAnchors: 0,
|
||||
orphanedAnchors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const now = new Date("2026-06-07T12:00:00Z").getTime();
|
||||
|
||||
function makeDocument(overrides: Partial<CompanyDocumentSummary>): CompanyDocumentSummary {
|
||||
return {
|
||||
id: "doc",
|
||||
companyId: "company-storybook",
|
||||
title: "Untitled document",
|
||||
format: "markdown",
|
||||
status: "in_review",
|
||||
documentType: "spec",
|
||||
summary: null,
|
||||
ownerAgentId: "agent-1",
|
||||
ownerUserId: null,
|
||||
latestRevisionId: "rev-1",
|
||||
latestRevisionNumber: 1,
|
||||
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(now - 2 * HOUR),
|
||||
backlinks: [],
|
||||
feedbackCounts: emptyCounts(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ISSUE_KEY = "issue";
|
||||
const ISSUE_TARGET = "issue-781c5dcf";
|
||||
|
||||
function issueBacklink(documentKey: string) {
|
||||
return {
|
||||
targetType: "issue",
|
||||
targetId: ISSUE_TARGET,
|
||||
issueDocumentKey: documentKey,
|
||||
identifier: "PAP-10569",
|
||||
} as never;
|
||||
}
|
||||
|
||||
const PLAN_DOC = makeDocument({
|
||||
id: "doc-plan",
|
||||
title: "Plan",
|
||||
documentType: "plan",
|
||||
status: "in_review",
|
||||
summary: "Implementation plan for the issue documents tab and ergonomics.",
|
||||
updatedAt: new Date(now - 1 * HOUR),
|
||||
backlinks: [issueBacklink("plan")],
|
||||
feedbackCounts: { ...emptyCounts(), openComments: 5, pendingSuggestions: 2 },
|
||||
});
|
||||
|
||||
const SPEC_DOC = makeDocument({
|
||||
id: "doc-spec",
|
||||
title: "Documents tab — interaction spec",
|
||||
documentType: "spec",
|
||||
status: "draft",
|
||||
summary: "Scoped search, pinned plan, Add document, cross-issue chip.",
|
||||
updatedAt: new Date(now - 6 * HOUR),
|
||||
backlinks: [issueBacklink("spec")],
|
||||
feedbackCounts: { ...emptyCounts(), openReviewThreads: 1 },
|
||||
});
|
||||
|
||||
const LINKED_REPORT = makeDocument({
|
||||
id: "doc-report",
|
||||
title: "Roughdraft borrow — findings",
|
||||
documentType: "report",
|
||||
status: "approved",
|
||||
summary: "Cross-linked company document surfaced on the issue.",
|
||||
updatedAt: new Date(now - 30 * HOUR),
|
||||
backlinks: [{ targetType: "issue", targetId: ISSUE_TARGET, identifier: "PAP-10507" } as never],
|
||||
});
|
||||
|
||||
const TAB_DOCS = [PLAN_DOC, SPEC_DOC, LINKED_REPORT];
|
||||
|
||||
function Shell({ children, width = 760 }: { children: React.ReactNode; width?: number }) {
|
||||
return (
|
||||
<div className="bg-background text-foreground p-6">
|
||||
<div className="mx-auto w-full space-y-4" style={{ maxWidth: width }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reconstructs the inline issue-document card header (Chat surface). */
|
||||
function InlineDocumentCard({ doc, docKey }: { doc: CompanyDocumentSummary; docKey: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{docKey}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="h-auto px-1.5 py-0 text-[11px] font-normal text-muted-foreground">
|
||||
rev {doc.latestRevisionNumber}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">updated 1h ago</span>
|
||||
<DocumentFeedbackCounts counts={doc.feedbackCounts} />
|
||||
</div>
|
||||
{doc.title && docKey !== "plan" ? <p className="mt-2 text-sm font-medium">{doc.title}</p> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button asChild variant="ghost" size="icon-xs" className="text-muted-foreground" title="Open in Documents">
|
||||
<a href={`/PAP/documents/${doc.id}?from=issue:${docKey}`} aria-label={`Open ${docKey} in Documents`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" title="Copy document">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" title="Document actions">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[15px] leading-7 text-muted-foreground">
|
||||
{docKey === "plan"
|
||||
? "## Goal\nPreserve inline plan editing while making linked docs discoverable…"
|
||||
: doc.summary}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Pages/Issue Documents Integration",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
/** §6.1 — inline card gains an "Open in Documents" button and 💬 / ✎ feedback counts. */
|
||||
export const InlineCard: Story = {
|
||||
render: () => (
|
||||
<Shell>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<div className="space-y-3">
|
||||
<InlineDocumentCard doc={PLAN_DOC} docKey="plan" />
|
||||
<InlineDocumentCard doc={SPEC_DOC} docKey="spec" />
|
||||
</div>
|
||||
</Shell>
|
||||
),
|
||||
};
|
||||
|
||||
/** §6.2 — the new issue "Documents" tab: scoped search, Add document, pinned plan. */
|
||||
export const DocumentsTab: Story = {
|
||||
render: () => (
|
||||
<Shell>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Search this issue's documents..." className="h-9 pl-9 text-sm" readOnly />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add document
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
{TAB_DOCS.map((doc) => {
|
||||
const key = doc.backlinks.find((b) => b.targetType === "issue")?.issueDocumentKey ?? undefined;
|
||||
return (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
to={`/PAP/documents/${doc.id}?from=issue:${key ?? "PAP-10569"}`}
|
||||
identifier={key ?? undefined}
|
||||
owner={{ name: "ClaudeCoder" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Shell>
|
||||
),
|
||||
};
|
||||
|
||||
/** §6.2 — empty state when only the plan is linked. */
|
||||
export const DocumentsTabEmpty: Story = {
|
||||
render: () => (
|
||||
<Shell>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
message="Only the plan document is linked. Add a spec, brief, or report to keep more context here."
|
||||
/>
|
||||
</Shell>
|
||||
),
|
||||
};
|
||||
|
||||
/** §6.3 — cross-issue document mention chip using the chip-match-document tokens. */
|
||||
export const MentionChip: Story = {
|
||||
render: () => (
|
||||
<Shell width={560}>
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-sm leading-7">
|
||||
Tracking the rollout in{" "}
|
||||
<a
|
||||
href="/PAP/documents/doc-spec"
|
||||
data-mention-kind="document"
|
||||
title="Documents tab — interaction spec · draft · 💬 1"
|
||||
className="inline-flex items-center gap-1 rounded-full border px-2 py-px align-[-0.125em] text-[0.85em] font-medium leading-none no-underline bg-[var(--chip-match-document-bg)] text-[var(--chip-match-document-fg)] border-[var(--chip-match-document-border)]"
|
||||
>
|
||||
<FileText className="h-3 w-3" aria-hidden="true" />
|
||||
interaction spec
|
||||
</a>{" "}
|
||||
— please leave suggestions there before Friday.
|
||||
</div>
|
||||
</Shell>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user