feat(ui): surface all issue documents and agent artifacts in chat-style sidebar (#11226)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task detail page uses a chat-style thread with a right sidebar; the sidebar has a Plan tab and an Artifacts tab (#11101 made this UI the default) > - The Plan tab only showed the one issue document named `plan`, and the Artifacts tab only listed formal work products; other agent-authored documents (for example a `synthesis` doc) and agent-attached files were invisible in the sidebar > - Users could see an agent mention a document in the thread but had no way to find that document in the sidebar, which breaks trust in the task view as the record of the work > - This pull request surfaces every non-system issue document in the Plan tab, composes the Artifacts tab from work products, documents, and agent-created attachments, and gives thread images a full-screen lightbox with download > - The benefit is that anything an agent produces on a task is now reachable from the sidebar, while user uploads stay with their comments in the thread ## Linked Issues or Issue Description Refs #11101 (chat-style task UI default — this PR extends its sidebar). **Subsystem affected** Task detail UI (chat-style thread sidebar): Plan tab, Artifacts tab, and thread attachment rendering in `ui/src`. **Current behavior** The Plan tab renders only the issue document literally named `plan`. The Artifacts tab renders only formal work products. Agent-authored documents with any other name, and files agents attach to comments, do not appear anywhere in the sidebar. Thread images open as bare links. **Proposed behavior** The Plan tab lists every non-system issue document, with the `plan` document first and the others rendered inline below it. The Artifacts tab composes three sources — work products, issue documents, and agent-created comment attachments — deduplicated against attachment-backed work products via `metadata.attachmentId`, and shows whenever any source is non-empty. Work-product rows without a resolvable attachment or document fall back to links found in their metadata so they stay clickable. Images in the thread open a shared full-screen lightbox with a download action. Files uploaded by users stay thread-only and are not mixed into the Artifacts tab. **Reason and benefit** Agents routinely produce documents that are not named `plan` and attach files to their comments. Users reading the thread must be able to find every one of those outputs from the sidebar. Redundant surfacing is acceptable; an unfindable document is not. **Breaking changes** None. This is additive rendering; no schema or API changes. ## What Changed - `IssuePropertiesPlansTab.tsx`: renders all non-system issue documents, `plan` primary, others inline below via `MarkdownBody` - `IssuePropertiesArtifactsTab.tsx`: composes work products + documents + agent-created attachments with dedupe; rows without an attachment/document target fall back to `metadata` links - `IssueProperties.tsx`: Artifacts tab visibility now derives from the composed source set - New `ui/src/lib/issue-artifacts.ts`: pure composition/dedupe logic, unit-tested - New `ui/src/components/task-chat/task-chat-attachments.ts`: splits agent vs user comment attachments, unit-tested - `TaskChatBubble.tsx`: thread images open the shared full-screen lightbox with download - `useIssueDocuments.ts`: hook now exposes the full issue-document list ## Verification - `pnpm typecheck` — passes across the workspace - `pnpm check:token-gates` — 3/3 CLEAN - `cd ui && pnpm vitest run src/lib/issue-artifacts.test.ts src/components/task-chat/task-chat-attachments.test.ts src/pages/IssueDetail.test.tsx` — 74 tests pass - Manual: open a task whose agent created a document not named `plan` (for example `synthesis`); confirm it appears in the Plan tab below the plan and in the Artifacts tab; confirm an image the agent attached appears under Artifacts; confirm a user-uploaded image stays only in the thread and opens full screen with a download button Snapshot baselines are intentionally not updated for this visual change, per the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - Low risk: rendering-only change scoped to the task sidebar and thread bubbles; composition logic is pure and unit-tested - Dedupe relies on `metadata.attachmentId` linkage; a work product with malformed metadata would render as a duplicate row (cosmetic only) ## Model Used - Claude (Anthropic), model id `claude-fable-5`, extended thinking enabled, agentic tool use via Claude Agent SDK (Claude Code harness) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0044fa8904
commit
5bb2490b86
|
|
@ -14,6 +14,8 @@ import { executionWorkspacesApi } from "../../api/execution-workspaces";
|
|||
import { instanceSettingsApi } from "../../api/instanceSettings";
|
||||
import { issuesApi } from "../../api/issues";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
|
||||
import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts";
|
||||
import { projectsApi } from "../../api/projects";
|
||||
import { useCompany } from "../../context/CompanyContext";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
|
|
@ -206,11 +208,25 @@ export function IssueProperties({
|
|||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
enabled: taskChatShellEnabled,
|
||||
});
|
||||
const { data: paneTabWorkProducts } = useQuery({
|
||||
queryKey: queryKeys.issues.workProducts(issue.id),
|
||||
queryFn: () => issuesApi.listWorkProducts(issue.id),
|
||||
enabled: taskChatShellEnabled,
|
||||
});
|
||||
const { data: paneTabDocuments } = useIssueDocuments(taskChatShellEnabled ? issue.id : null);
|
||||
const hasPlanTab =
|
||||
Boolean(paneTabPlanDocument)
|
||||
|| (paneTabAcceptedPlans?.length ?? 0) > 0
|
||||
|| (paneTabDocuments?.length ?? 0) > 0
|
||||
|| issue.workMode === "planning";
|
||||
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
|
||||
// Artifacts covers the same three sources the tab body composes: work
|
||||
// products, documents (redundant with the Plan tab, intentionally), and
|
||||
// agent-created attachments. User comment uploads stay thread-only and
|
||||
// no longer summon the tab.
|
||||
const hasArtifactsTab =
|
||||
(paneTabWorkProducts?.length ?? 0) > 0
|
||||
|| (paneTabDocuments?.length ?? 0) > 0
|
||||
|| selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
|
||||
const [paneTab, setPaneTab] = useState("properties");
|
||||
// Once a plan document exists, surface it: switch the pane to the Plan tab so
|
||||
// the write-up is exposed alongside the plan-approval card, instead of leaving
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import type { Issue, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
Globe,
|
||||
Package,
|
||||
Paperclip,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
|
||||
import {
|
||||
documentDisplayTitle,
|
||||
selectAgentArtifactAttachments,
|
||||
workProductHref,
|
||||
} from "@/lib/issue-artifacts";
|
||||
import { attachmentOpenPath } from "@/lib/issue-attachments";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IssuePropertiesArtifactsTabProps {
|
||||
issue: Issue;
|
||||
|
|
@ -14,44 +37,196 @@ function formatBytes(n: number): string {
|
|||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function workProductIcon(type: string): LucideIcon {
|
||||
switch (type) {
|
||||
case "document": return FileText;
|
||||
case "pull_request": return GitBranch;
|
||||
case "branch": return GitBranch;
|
||||
case "commit": return GitCommit;
|
||||
case "preview_url": return Globe;
|
||||
case "runtime_service": return Server;
|
||||
default: return Package;
|
||||
}
|
||||
}
|
||||
|
||||
/** Work-product status → label + `--status-task-*` base-hue var for `.status-chip`. */
|
||||
function workProductStatusBadge(status: string): { label: string; cssVar: string } | null {
|
||||
switch (status) {
|
||||
case "active":
|
||||
case "draft":
|
||||
return { label: "In progress", cssVar: "--status-task-in_progress" };
|
||||
case "ready_for_review":
|
||||
return { label: "For review", cssVar: "--status-task-in_review" };
|
||||
case "approved":
|
||||
case "merged":
|
||||
return { label: "Done", cssVar: "--status-task-done" };
|
||||
case "changes_requested":
|
||||
return { label: "Changes requested", cssVar: "--status-task-todo" };
|
||||
case "failed":
|
||||
return { label: "Failed", cssVar: "--status-task-blocked" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function SectionHeading({ children }: { children: string }) {
|
||||
return (
|
||||
<h3 className="px-1 pt-1 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW_CLASS =
|
||||
"flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm";
|
||||
|
||||
function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) {
|
||||
const Icon = workProductIcon(workProduct.type);
|
||||
const badge = workProductStatusBadge(workProduct.status);
|
||||
const href = workProductHref(workProduct);
|
||||
const body = (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{workProduct.title}</span>
|
||||
{badge ? (
|
||||
<span
|
||||
className="status-chip inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-(length:--text-nano) leading-none whitespace-nowrap"
|
||||
style={{ "--sc": `var(${badge.cssVar})` } as CSSProperties}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
{href ? (
|
||||
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(ROW_CLASS, "hover:bg-accent/50")}
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <div className={ROW_CLASS}>{body}</div>;
|
||||
}
|
||||
|
||||
function DocumentRow({ doc }: { doc: IssueDocument }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm hover:bg-accent/50"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{documentDisplayTitle(doc)}</span>
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
|
||||
{`Rev ${doc.latestRevisionNumber ?? 1}`}
|
||||
</span>
|
||||
<Chevron className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="border-t border-border px-2.5 py-2">
|
||||
{doc.body.trim().length > 0 ? (
|
||||
<MarkdownBody>{doc.body}</MarkdownBody>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Document is empty.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifacts tab of the properties pane.
|
||||
* Artifacts tab of the properties pane (PAP-491).
|
||||
*
|
||||
* A read-only gallery of the task's attachments / work products. Uploads,
|
||||
* previews, and deletes stay on the existing attachment surfaces for the
|
||||
* baseline; this tab consolidates the "what did this task produce" view.
|
||||
* A read-only "what did this task produce" view composed from three sources:
|
||||
* work products, issue documents (also readable in the Plan tab — the
|
||||
* redundancy is intentional), and agent-created attachments. Attachments
|
||||
* already promoted to attachment-backed work products are deduped out, and
|
||||
* user uploads are excluded — those stay first-class in the conversation
|
||||
* thread.
|
||||
*/
|
||||
export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsTabProps) {
|
||||
const { data } = useQuery({
|
||||
const { data: attachments } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issue.id),
|
||||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
});
|
||||
const attachments = data ?? [];
|
||||
const { data: workProducts } = useQuery({
|
||||
queryKey: queryKeys.issues.workProducts(issue.id),
|
||||
queryFn: () => issuesApi.listWorkProducts(issue.id),
|
||||
});
|
||||
const { data: documents } = useIssueDocuments(issue.id);
|
||||
|
||||
if (attachments.length === 0) {
|
||||
const workProductRows = workProducts ?? [];
|
||||
const documentRows = documents ?? [];
|
||||
const fileRows = selectAgentArtifactAttachments(attachments, workProducts);
|
||||
|
||||
if (workProductRows.length === 0 && documentRows.length === 0 && fileRows.length === 0) {
|
||||
return (
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
No artifacts yet. Attachments and work products will appear here.
|
||||
No artifacts yet. Work products, documents, and agent-produced files will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 py-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a
|
||||
href={a.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm hover:bg-accent/50"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{workProductRows.length > 0 ? (
|
||||
<>
|
||||
<SectionHeading>Work products</SectionHeading>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{workProductRows.map((wp) => (
|
||||
<li key={wp.id}>
|
||||
<WorkProductRow workProduct={wp} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{documentRows.length > 0 ? (
|
||||
<>
|
||||
<SectionHeading>Documents</SectionHeading>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{documentRows.map((doc) => (
|
||||
<li key={doc.key}>
|
||||
<DocumentRow doc={doc} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{fileRows.length > 0 ? (
|
||||
<>
|
||||
<SectionHeading>Files</SectionHeading>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{fileRows.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a
|
||||
href={attachmentOpenPath(a)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(ROW_CLASS, "hover:bg-accent/50")}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecomposit
|
|||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
|
||||
import { documentDisplayTitle } from "@/lib/issue-artifacts";
|
||||
import { useLocation } from "@/lib/router";
|
||||
|
||||
interface IssuePropertiesPlansTabProps {
|
||||
|
|
@ -47,10 +49,14 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
|
|||
queryKey: queryKeys.issues.interactions(issue.id),
|
||||
queryFn: () => issuesApi.listInteractions(issue.id),
|
||||
});
|
||||
const { data: documents } = useIssueDocuments(issue.id);
|
||||
const hasPlans = (data?.length ?? 0) > 0;
|
||||
const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions);
|
||||
// Every other non-system document (e.g. `synthesis`) renders below the plan;
|
||||
// the `plan` doc itself stays on its dedicated annotated surface above.
|
||||
const otherDocuments = (documents ?? []).filter((doc) => doc.key !== "plan");
|
||||
|
||||
if (!planDocument && !hasPlans) {
|
||||
if (!planDocument && !hasPlans && otherDocuments.length === 0) {
|
||||
return (
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
{planDocumentLoading ? (
|
||||
|
|
@ -113,6 +119,22 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
|
|||
</IssueDocumentAnnotations>
|
||||
</section>
|
||||
) : null}
|
||||
{otherDocuments.map((doc) => (
|
||||
<section key={doc.key} data-testid="issue-other-document" className="space-y-2 border-t border-border pt-4 first:border-t-0 first:pt-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{documentDisplayTitle(doc)}</h3>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`}
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody>{doc.body}</MarkdownBody>
|
||||
</section>
|
||||
))}
|
||||
{hasPlans ? (
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { CommentAttributionChip } from "@/components/CommentAttributionChip";
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/components/ui/attachment";
|
||||
import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments";
|
||||
import { extractAttachmentRefs, extractImageRefs, fileKindForName } from "./task-chat-attachments";
|
||||
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
|
|
@ -49,7 +50,21 @@ function initialsForName(name: string) {
|
|||
* bubble with an avatar author header (the agent's assigned icon + name · mode
|
||||
* chip); system notices are centered and recede.
|
||||
*/
|
||||
function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
|
||||
return {
|
||||
id: src,
|
||||
contentPath: src,
|
||||
// The modal only inspects contentType/filename to spot videos; embedded
|
||||
// markdown images are always images, so an empty type is safe here.
|
||||
contentType: "",
|
||||
originalFilename: name?.trim() ? name : "image",
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
|
||||
// Clicking an embedded image opens the full-screen lightbox (with download);
|
||||
// arrow keys walk across the other images in the same bubble.
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
if (item.interstitial) {
|
||||
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
||||
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
||||
|
|
@ -67,6 +82,16 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
|
|||
// Non-image file references ("[name](/api/attachments/…/content)") render as
|
||||
// attachment chips under the bubble; link-only lines leave the body text.
|
||||
const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text);
|
||||
const imageRefs = extractImageRefs(bodyText);
|
||||
const galleryItems: GalleryMediaItem[] =
|
||||
lightboxSrc !== null && !imageRefs.some((ref) => ref.url === lightboxSrc)
|
||||
// A clicked image the extractor missed (e.g. inline HTML) still gets a
|
||||
// single-item lightbox rather than nothing.
|
||||
? [galleryItemForImage(lightboxSrc)]
|
||||
: imageRefs.map((ref) => galleryItemForImage(ref.url, ref.name));
|
||||
const lightboxIndex = lightboxSrc === null
|
||||
? -1
|
||||
: Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc));
|
||||
return (
|
||||
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
|
||||
{!isHuman && item.authorName ? (
|
||||
|
|
@ -114,6 +139,7 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
|
|||
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
|
||||
softBreaks
|
||||
linkIssueReferences
|
||||
onImageClick={setLightboxSrc}
|
||||
>
|
||||
{bodyText}
|
||||
</MarkdownBody>
|
||||
|
|
@ -175,6 +201,16 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
|
|||
{item.timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
{lightboxSrc !== null && lightboxIndex >= 0 ? (
|
||||
<ImageGalleryModal
|
||||
items={galleryItems}
|
||||
initialIndex={lightboxIndex}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLightboxSrc(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractAttachmentRefs,
|
||||
extractImageRefs,
|
||||
fileKindForName,
|
||||
formatFileSize,
|
||||
isImageFilename,
|
||||
|
|
@ -86,3 +87,30 @@ describe("extractAttachmentRefs", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractImageRefs", () => {
|
||||
it("collects image embeds in order, skipping plain links", () => {
|
||||
const body =
|
||||
"\nSee [notes.txt](/api/attachments/n/content)\n";
|
||||
expect(extractImageRefs(body)).toEqual([
|
||||
{ name: "a.png", url: "/api/attachments/a/content" },
|
||||
{ name: "b.jpg", url: "https://example.com/b.jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes repeated embeds and tolerates empty alt text", () => {
|
||||
const body = "\n";
|
||||
expect(extractImageRefs(body)).toEqual([{ name: "", url: "/api/attachments/a/content" }]);
|
||||
});
|
||||
|
||||
it("unescapes bracket-escaped alt text", () => {
|
||||
const body = String.raw`![shot \[1\].png](/api/attachments/s/content)`;
|
||||
expect(extractImageRefs(body)).toEqual([
|
||||
{ name: "shot [1].png", url: "/api/attachments/s/content" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for bodies without images", () => {
|
||||
expect(extractImageRefs("just text and a [link](/api/attachments/x/content)")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,6 +115,26 @@ export interface AttachmentRef {
|
|||
const ATTACHMENT_LINK_RE =
|
||||
/(?<!!)\[((?:\\.|[^\]\\])+)\]\((\/api\/(?:attachments|assets)\/[^()\s]+\/content(?:\?[^()\s]*)?)\)/g;
|
||||
|
||||
/** Markdown image embeds (``), same escaped-label grammar. */
|
||||
const IMAGE_EMBED_RE = /!\[((?:\\.|[^\]\\])*)\]\(([^()\s]+)\)/g;
|
||||
|
||||
/**
|
||||
* Every image embedded in a message body, in document order, deduped by URL.
|
||||
* Feeds the bubble's lightbox: the refs become the gallery items and the
|
||||
* clicked <img> src picks the initial index.
|
||||
*/
|
||||
export function extractImageRefs(body: string): AttachmentRef[] {
|
||||
const refs: AttachmentRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of body.matchAll(IMAGE_EMBED_RE)) {
|
||||
const [, name, url] = match;
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
refs.push({ name: name.replace(/\\([[\]])/g, "$1"), url });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export interface ExtractedAttachmentRefs {
|
||||
refs: AttachmentRef[];
|
||||
/** Body with lines that were nothing but extracted links removed. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* All of the issue's non-system documents, bodies included (the list endpoint
|
||||
* filters system keys server-side and returns full documents). Shared by the
|
||||
* properties pane's tab gating and the Plan/Artifacts tab bodies so they all
|
||||
* consume one cached fetch. Keyed under queryKeys.issues.documents so
|
||||
* document-scope invalidations refresh it alongside the single-doc queries.
|
||||
*/
|
||||
export function useIssueDocuments(issueId: string | null | undefined) {
|
||||
return useQuery<IssueDocument[]>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "list"],
|
||||
enabled: Boolean(issueId),
|
||||
queryFn: () => issuesApi.listDocuments(issueId!),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import {
|
||||
documentDisplayTitle,
|
||||
isAgentAttachment,
|
||||
selectAgentArtifactAttachments,
|
||||
workProductHref,
|
||||
} from "./issue-artifacts";
|
||||
|
||||
function makeAttachment(overrides: Partial<IssueAttachment> & { id: string }): IssueAttachment {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
assetId: "asset-1",
|
||||
provider: "local",
|
||||
objectKey: `objects/${overrides.id}`,
|
||||
contentType: "image/png",
|
||||
byteSize: 1024,
|
||||
sha256: "0".repeat(64),
|
||||
originalFilename: "shot.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-08-01T12:00:00Z"),
|
||||
updatedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
contentPath: `/api/attachments/${overrides.id}/content`,
|
||||
...overrides,
|
||||
} as IssueAttachment;
|
||||
}
|
||||
|
||||
function makePromotingWorkProduct(attachmentId: string): IssueWorkProduct {
|
||||
return {
|
||||
id: `wp-${attachmentId}`,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceId: null,
|
||||
runtimeServiceId: null,
|
||||
type: "artifact",
|
||||
provider: "paperclip",
|
||||
externalId: null,
|
||||
title: "output.png",
|
||||
url: null,
|
||||
status: "active",
|
||||
reviewState: "none",
|
||||
isPrimary: false,
|
||||
healthStatus: "unknown",
|
||||
summary: null,
|
||||
metadata: {
|
||||
attachmentId,
|
||||
contentType: "image/png",
|
||||
byteSize: 1024,
|
||||
contentPath: `/api/attachments/${attachmentId}/content`,
|
||||
openPath: `/api/attachments/${attachmentId}/content`,
|
||||
downloadPath: `/api/attachments/${attachmentId}/content?download=1`,
|
||||
originalFilename: "output.png",
|
||||
},
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-08-01T12:00:00Z"),
|
||||
updatedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
} as IssueWorkProduct;
|
||||
}
|
||||
|
||||
describe("isAgentAttachment", () => {
|
||||
it("accepts agent-authored rows regardless of comment binding", () => {
|
||||
expect(isAgentAttachment(makeAttachment({ id: "a", createdByAgentId: "agent-1" }))).toBe(true);
|
||||
expect(
|
||||
isAgentAttachment(
|
||||
makeAttachment({ id: "b", createdByAgentId: "agent-1", issueCommentId: "comment-1" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects user uploads", () => {
|
||||
expect(isAgentAttachment(makeAttachment({ id: "c", createdByUserId: "user-1" }))).toBe(false);
|
||||
expect(
|
||||
isAgentAttachment(
|
||||
makeAttachment({ id: "d", createdByUserId: "user-1", issueCommentId: "comment-1" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats authorless rows as agent output only when not comment-bound", () => {
|
||||
expect(isAgentAttachment(makeAttachment({ id: "e" }))).toBe(true);
|
||||
expect(isAgentAttachment(makeAttachment({ id: "f", issueCommentId: "comment-1" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectAgentArtifactAttachments", () => {
|
||||
it("keeps agent attachments and drops user uploads", () => {
|
||||
const agent = makeAttachment({ id: "agent-file", createdByAgentId: "agent-1" });
|
||||
const user = makeAttachment({
|
||||
id: "user-file",
|
||||
createdByUserId: "user-1",
|
||||
issueCommentId: "comment-1",
|
||||
});
|
||||
expect(selectAgentArtifactAttachments([agent, user], [])).toEqual([agent]);
|
||||
});
|
||||
|
||||
it("dedupes attachments already promoted to work products", () => {
|
||||
// The promotion metadata schema requires a UUID attachmentId.
|
||||
const promotedId = "00000000-0000-4000-8000-000000000001";
|
||||
const promoted = makeAttachment({ id: promotedId, createdByAgentId: "agent-1" });
|
||||
const loose = makeAttachment({ id: "loose", createdByAgentId: "agent-1" });
|
||||
const result = selectAgentArtifactAttachments(
|
||||
[promoted, loose],
|
||||
[makePromotingWorkProduct(promotedId)],
|
||||
);
|
||||
expect(result.map((a) => a.id)).toEqual(["loose"]);
|
||||
});
|
||||
|
||||
it("tolerates missing inputs", () => {
|
||||
expect(selectAgentArtifactAttachments(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workProductHref", () => {
|
||||
it("prefers the top-level url", () => {
|
||||
expect(
|
||||
workProductHref({ url: "https://example.com/pr/1", metadata: { openPath: "/api/x" } }),
|
||||
).toBe("https://example.com/pr/1");
|
||||
});
|
||||
|
||||
it("falls back to metadata.openPath, then metadata.url", () => {
|
||||
expect(
|
||||
workProductHref({ url: null, metadata: { openPath: "/api/attachments/a/content" } }),
|
||||
).toBe("/api/attachments/a/content");
|
||||
expect(
|
||||
workProductHref({ url: null, metadata: { url: "https://tunnel.here.now/x" } }),
|
||||
).toBe("https://tunnel.here.now/x");
|
||||
expect(
|
||||
workProductHref({
|
||||
url: null,
|
||||
metadata: { openPath: "/api/attachments/a/content", url: "https://tunnel.here.now/x" },
|
||||
}),
|
||||
).toBe("/api/attachments/a/content");
|
||||
});
|
||||
|
||||
it("ignores non-string and blank metadata values", () => {
|
||||
expect(workProductHref({ url: null, metadata: { openPath: 42, url: " " } })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without url or metadata links", () => {
|
||||
expect(workProductHref({ url: null, metadata: null })).toBeNull();
|
||||
expect(workProductHref({ url: null, metadata: { attachmentId: "a" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentDisplayTitle", () => {
|
||||
it("prefers the stored title", () => {
|
||||
expect(documentDisplayTitle({ key: "synthesis", title: "Findings Synthesis" })).toBe(
|
||||
"Findings Synthesis",
|
||||
);
|
||||
});
|
||||
|
||||
it("humanizes the key when no title is set", () => {
|
||||
expect(documentDisplayTitle({ key: "synthesis", title: null })).toBe("Synthesis");
|
||||
expect(documentDisplayTitle({ key: "design_notes-v2", title: " " })).toBe("Design notes v2");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { IssueAttachment, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { getPromotedOutputAttachmentIds } from "./issue-output";
|
||||
|
||||
/**
|
||||
* Selectors for the properties pane's Artifacts tab (PAP-491): which
|
||||
* attachments count as agent-produced artifacts, as opposed to user uploads
|
||||
* that live in the conversation thread.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An attachment authored by an agent. Rows with no author at all are treated
|
||||
* as agent output when they are not bound to a comment — legacy agent uploads
|
||||
* predate attribution, while user uploads always arrive through a comment.
|
||||
*/
|
||||
export function isAgentAttachment(
|
||||
attachment: Pick<IssueAttachment, "createdByAgentId" | "createdByUserId" | "issueCommentId">,
|
||||
): boolean {
|
||||
if (attachment.createdByAgentId) return true;
|
||||
return !attachment.createdByUserId && !attachment.issueCommentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-authored attachments minus the ones already promoted to
|
||||
* attachment-backed work products (`metadata.attachmentId`), so the Artifacts
|
||||
* tab lists each file once.
|
||||
*/
|
||||
export function selectAgentArtifactAttachments(
|
||||
attachments: IssueAttachment[] | null | undefined,
|
||||
workProducts: IssueWorkProduct[] | null | undefined,
|
||||
): IssueAttachment[] {
|
||||
const promoted = getPromotedOutputAttachmentIds(workProducts);
|
||||
return (attachments ?? []).filter(
|
||||
(attachment) => isAgentAttachment(attachment) && !promoted.has(attachment.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a work-product row should link. Many rows carry `url: null` with the
|
||||
* usable link only in metadata — `openPath` for attachment-backed artifacts
|
||||
* (same field OutputRow opens) or `url` for provider-specific links — so fall
|
||||
* back to those before rendering the row inert. Expired links are still
|
||||
* returned; titles announce expiry where the producer recorded it.
|
||||
*/
|
||||
export function workProductHref(
|
||||
workProduct: Pick<IssueWorkProduct, "url" | "metadata">,
|
||||
): string | null {
|
||||
if (workProduct.url) return workProduct.url;
|
||||
const metadata = workProduct.metadata;
|
||||
if (!metadata) return null;
|
||||
for (const key of ["openPath", "url"]) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Display title for an issue document: its title, else its key humanized. */
|
||||
export function documentDisplayTitle(
|
||||
doc: Pick<IssueDocumentSummary, "key" | "title">,
|
||||
): string {
|
||||
if (doc.title?.trim()) return doc.title;
|
||||
const words = doc.key.replace(/[-_]+/g, " ").trim();
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
Loading…
Reference in New Issue