Files
PaperClipAI/ui/src/components/task-chat/RichWorkProductCard.tsx
T
DottaandPaperclip db72ad4c73 fix(ui): explain branch artifacts without remote links (#15051)
## Thinking Path

> - Paperclip helps people manage agent work and inspect its results.
> - The issue artifact list presents those results as work-product
cards.
> - A branch can have no remote URL or structured metadata.
> - The current card hides its summary and has no action in that case.
> - This pull request shows the summary and provides an accessible
details action.
> - The card labels the absent link without making a false link.

## Linked Issues or Issue Description

**What happened?**
A branch work product without a URL showed a short title and an icon. It
did not show the saved summary or provide an action.

**Expected behavior**
The card must show what the branch contains. People must be able to open
the saved details. It must not imply that a remote URL exists.

**Steps to reproduce**
1. Open the artifact list of an issue with a branch work product that
has `url: null` and `metadata: null`.
2. Find the branch card. Its summary and details action are missing
before this change.

Related work: #12717 introduced rich work-product cards.

## What Changed

- Keep linkless branch cards concise: show the title and no-link state.
Keep the full summary behind an optional Saved description toggle.
- Label a branch with no URL as having no remote link. Let a person open
its details with a keyboard-accessible button.
- Add a focused interaction test and two Storybook states for the
no-link branch.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` passed.
- Focused Vitest tests passed: 45 tests in two files.
- `pnpm --filter @paperclipai/ui build` passed.
- `pnpm --filter @paperclipai/ui build-storybook` passed.
- `pnpm check:token-gates` passed.
- The Storybook collapsed and expanded states rendered in Chromium. Both
screenshots were captured.
- Full workspace typecheck could not finish: the runner Rust package
requires `cargo`, which is not installed in this environment.

## Risks

- Low risk. The change affects card text and the action for records
without a URL. It does not change the saved data or routes.
- Long saved summaries on other linkless work products expand the card
height after a person selects Details.

## Model Used

- OpenAI Codex coding agent. The execution environment does not expose
the exact model ID or context window to this agent. It used code
execution and a browser to validate the change.

## 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)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
(the managed execution workspace fixes this branch name)
- [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
(Storybook examples)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] 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: Paperclip <noreply@paperclip.ing>
2026-10-03 21:05:48 -05:00

347 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { isTextAttachment } from "@/lib/issue-attachments";
import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared";
import { useContext, useState, type CSSProperties } from "react";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
import { attachmentDownloadPath } from "@/lib/issue-attachments";
import type { IssueWorkProduct } from "@paperclipai/shared";
import {
ChevronDown,
ExternalLink,
Maximize2,
File,
FileText,
Film,
GitBranch,
GitCommit,
Globe,
Image,
Server,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { GithubIcon } from "@/components/icons/github-icon";
import { cn, formatDateTime } from "@/lib/utils";
type StateChip = {
label: string;
tone: "progress" | "failure" | "review" | "success" | "neutral";
dashed?: boolean;
};
/** Resting states stay quiet. A completed work product never gets a chip. */
export function stateChipFor(
kind: IssueWorkProduct["type"],
status: string | null | undefined,
reviewState: IssueWorkProduct["reviewState"] | string | null | undefined,
): StateChip | null {
if (reviewState === "changes_requested" || status === "changes_requested") {
return { label: "Changes requested", tone: "failure" };
}
if (reviewState === "needs_board_review" || status === "ready_for_review") {
return { label: "Review", tone: "review" };
}
if (["failed", "unhealthy", "down"].includes(status ?? "")) {
return { label: "Failed", tone: "failure" };
}
if (["pending", "opening"].includes(status ?? "")) {
return { label: status === "opening" ? "Opening" : "Pending", tone: "progress", dashed: true };
}
if (kind === "pull_request" && (status === "active" || status === "open")) {
return { label: "Open", tone: "progress" };
}
if (kind === "pull_request" && status === "draft") {
return { label: "Draft", tone: "review" };
}
if (kind === "pull_request" && status === "merged") {
return { label: "Merged", tone: "success" };
}
if (kind === "pull_request" && status === "closed") {
return { label: "Closed", tone: "neutral" };
}
if (kind === "runtime_service" && status === "active") {
return { label: "Running", tone: "progress" };
}
if (kind === "runtime_service" && status === "closed") {
return { label: "Stopped", tone: "failure" };
}
return null;
}
function stringMeta(metadata: Record<string, unknown> | null, ...keys: string[]): string | null {
for (const key of keys) {
const value = metadata?.[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number") return String(value);
}
return null;
}
function numberMeta(metadata: Record<string, unknown> | null, ...keys: string[]): number | null {
for (const key of keys) {
const value = metadata?.[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return null;
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function urlLabel(url: string | null): string | null {
if (!url) return null;
try {
const parsed = new URL(url, typeof window === "undefined" ? "http://localhost" : window.location.origin);
return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
} catch {
return url;
}
}
function Chip({ chip }: { chip: StateChip }) {
const cssVar = chip.tone === "failure"
? "--status-task-blocked"
: chip.tone === "success"
? "--status-task-done"
: chip.tone === "neutral"
? "--status-task-cancelled"
: chip.tone === "review"
? "--status-task-in_review"
: "--status-task-in_progress";
return (
<span
className={cn(
"status-chip inline-flex shrink-0 items-center rounded-full border px-2 py-1 text-(length:--text-nano) font-medium leading-none",
chip.dashed && "border-dashed",
)}
style={{ "--sc": `var(${cssVar})` } as CSSProperties}
>
{chip.label}
</span>
);
}
export interface RichWorkProductCardProps {
workProduct: IssueWorkProduct;
href: string | null;
variant?: "card" | "compact" | "gallery";
}
export function RichWorkProductCard({ workProduct, href, variant = "card" }: RichWorkProductCardProps) {
const openIssueGallery = useContext(IssueGalleryContext);
const [galleryOpen, setGalleryOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const metadata = workProduct.metadata;
const contentType = stringMeta(metadata, "contentType") ?? "";
const isImage = isImageLikeOutput(contentType, stringMeta(metadata, "originalFilename") ?? workProduct.title);
const isVideo = isVideoLikeOutput(contentType, stringMeta(metadata, "originalFilename") ?? workProduct.title);
let Icon: LucideIcon = File;
let meta: Array<string | null> = [];
let action = "Open preview";
switch (workProduct.type) {
case "pull_request": {
Icon = GithubIcon;
const repository = stringMeta(metadata, "repository", "repo", "repositoryName");
const number = stringMeta(metadata, "number", "pullRequestNumber");
const base = stringMeta(metadata, "baseRef", "base", "baseBranch");
const head = stringMeta(metadata, "headRef", "head", "headBranch", "branch");
meta = [repository, number ? `#${number.replace(/^#/, "")}` : null, base && head ? `${base} ← ${head}` : null, urlLabel(workProduct.url)];
action = "Open on GitHub";
break;
}
case "commit":
Icon = GitCommit;
meta = [stringMeta(metadata, "shortSha", "sha")?.slice(0, 8) ?? workProduct.externalId?.slice(0, 8) ?? null, stringMeta(metadata, "branch", "branchName"), urlLabel(workProduct.url)];
action = "Open on GitHub";
break;
case "branch":
Icon = GitBranch;
meta = [href ? "Branch" : "Branch · no remote link", stringMeta(metadata, "repository", "repo", "repositoryName"), stringMeta(metadata, "branch", "branchName") ?? workProduct.externalId, urlLabel(workProduct.url)];
action = "Open on GitHub";
break;
case "artifact": {
Icon = isImage ? Image : isVideo ? Film : File;
const size = numberMeta(metadata, "byteSize", "size");
meta = [isImage ? "Image" : isVideo ? "Video" : stringMeta(metadata, "kind", "fileType") ?? "File", size === null ? null : formatBytes(size)];
action = isImage || isVideo ? "Open gallery" : "Download";
break;
}
case "document":
Icon = FileText;
meta = ["Document", stringMeta(metadata, "revision", "revisionNumber") ? `rev ${stringMeta(metadata, "revision", "revisionNumber")}` : null];
action = "Open document";
break;
case "preview_url":
Icon = Globe;
meta = [urlLabel(workProduct.url)];
action = "Open preview";
break;
case "runtime_service":
Icon = Server;
meta = [stringMeta(metadata, "service", "serviceName") ?? workProduct.provider, stringMeta(metadata, "port") ? `port ${stringMeta(metadata, "port")}` : null];
action = "Open service";
break;
}
const openText = useContext(TextAttachmentContext);
const textMetadata = getAttachmentArtifactWorkProductMetadata(workProduct);
const canOpenText = Boolean(openText && textMetadata && isTextAttachment(textMetadata));
const additions = numberMeta(metadata, "additions");
const deletions = numberMeta(metadata, "deletions");
const files = numberMeta(metadata, "files", "changedFiles");
const unhealthyChip =
workProduct.healthStatus === "unhealthy"
? workProduct.type === "preview_url"
? { label: "Down", tone: "failure" as const }
: workProduct.type === "runtime_service" && workProduct.status !== "closed"
? { label: "Unhealthy", tone: "failure" as const }
: null
: null;
const chip =
unhealthyChip ??
stateChipFor(
workProduct.type,
workProduct.type === "pull_request"
? stringMeta(metadata, "state") ?? workProduct.status
: workProduct.type === "runtime_service" &&
workProduct.status === "active" &&
workProduct.healthStatus !== "healthy"
? null
: workProduct.status,
workProduct.reviewState,
);
const visibleMeta = meta.filter((value): value is string => Boolean(value));
const changeCounts = [additions === null ? null : `+${additions}`, deletions === null ? null : `−${deletions}`]
.filter(Boolean)
.join(" ");
const fileCount = files === null ? null : `${files} ${files === 1 ? "file" : "files"}`;
const statsLabel = [changeCounts || null, fileCount].filter(Boolean).join(" · ");
const compact = variant === "compact";
const mediaPath = workProduct.type === "artifact" && (isImage || isVideo)
? stringMeta(metadata, "contentPath", "openPath") ?? href
: null;
const artifactContentPath = stringMeta(metadata, "contentPath")
?? (href && /^\/api\/attachments\/[^/?#]+\/content$/.test(href) ? href : null);
const actionHref = workProduct.type === "artifact" && !isImage && !isVideo
? stringMeta(metadata, "downloadPath") ?? (artifactContentPath
? attachmentDownloadPath({ contentPath: artifactContentPath })
: href)
: href;
const expandable = !compact && !mediaPath && !actionHref;
const summary = workProduct.summary?.trim();
const linklessBranch = workProduct.type === "branch" && !href;
const openGallery = () => {
if (mediaPath && !openIssueGallery?.(mediaPath)) setGalleryOpen(true);
};
if (variant === "gallery" && mediaPath) {
return (
<MediaArtifactCard
id={workProduct.id}
title={workProduct.title}
contentPath={mediaPath}
contentType={contentType}
originalFilename={stringMeta(metadata, "originalFilename") ?? workProduct.title}
downloadPath={stringMeta(metadata, "downloadPath") ?? undefined}
detail={visibleMeta.join(" · ")}
badge={chip ? <Chip chip={chip} /> : null}
/>
);
}
return (
<article
className={cn(
"@container relative flex min-w-0 rounded-md border border-border bg-card/60",
(mediaPath || actionHref) && "hover:bg-accent/50",
compact ? "items-center gap-2 px-2.5 py-1.5" : "items-start gap-3 px-3 py-2.5",
)}
data-testid={`task-chat-rich-work-product-${workProduct.type}`}
data-variant={variant}
>
<div className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted/60 text-muted-foreground",
mediaPath ? "w-20" : compact ? "h-8 w-8" : "h-10 w-10",
)}>
{mediaPath ? (
<ArtifactPreview artifact={{ contentPath: mediaPath, title: workProduct.title, mediaKind: isVideo ? "video" : "image" }} />
) : (
<Icon aria-hidden className={compact ? "h-4 w-4" : "h-5 w-5"} />
)}
</div>
<div className="min-w-0 flex-1">
<strong className={cn("block text-sm font-medium text-foreground", expandable ? detailsOpen ? "break-words" : "line-clamp-2 break-words" : "truncate")}>{workProduct.title}</strong>
{visibleMeta.length > 0 ? <p className="mt-1 truncate text-xs text-muted-foreground">{visibleMeta.join(" · ")}</p> : null}
{statsLabel ? <p className="mt-1 whitespace-nowrap text-xs text-muted-foreground">{statsLabel}</p> : null}
{!compact && summary && !linklessBranch ? <p className={cn("mt-1 text-xs text-muted-foreground", detailsOpen ? "whitespace-pre-wrap break-words" : "line-clamp-2")}>{summary}</p> : null}
{expandable && detailsOpen ? (
<div className="mt-2 flex flex-col gap-1 text-xs text-muted-foreground">
{workProduct.type !== "branch" ? <p>No link was provided for this work product.</p> : null}
<p>{workProduct.provider} · {workProduct.status.replaceAll("_", " ")} · Updated {formatDateTime(workProduct.updatedAt)}</p>
{linklessBranch && summary ? (
<details className="relative z-10 mt-1">
<summary className="w-fit cursor-pointer font-medium text-foreground">Saved description</summary>
<p className="mt-1 whitespace-pre-wrap break-words">{summary}</p>
</details>
) : null}
</div>
) : null}
</div>
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
{chip ? <Chip chip={chip} /> : null}
{canOpenText ? (
<button
type="button"
onClick={() => openText!(textMetadata!.attachmentId, textMetadata!.originalFilename ?? workProduct.title)}
aria-label={`Open in tab: ${workProduct.title}`}
className="inline-flex items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
{compact ? null : <span className="hidden @sm:inline">Open in tab</span>}<FileText aria-hidden className="h-3 w-3" />
</button>
) : null}
{mediaPath ? (
<button type="button" onClick={openGallery} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring">
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<Maximize2 aria-hidden className="h-3 w-3" />
</button>
) : actionHref ? (
<a href={actionHref} aria-label={`${action}: ${workProduct.title}`} className={cn("inline-flex items-center gap-1 text-xs font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", canOpenText ? "relative z-10" : "after:absolute after:inset-0 after:rounded-md focus-visible:after:ring-2 focus-visible:after:ring-ring")} target={actionHref.startsWith("http") ? "_blank" : undefined} rel={actionHref.startsWith("http") ? "noreferrer" : undefined}>
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
</a>
) : expandable ? (
<button
type="button"
onClick={() => setDetailsOpen((open) => !open)}
aria-label={`${detailsOpen ? "Hide" : "Show"} details: ${workProduct.title}`}
aria-expanded={detailsOpen}
className="inline-flex cursor-pointer items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
<span>{detailsOpen ? "Hide details" : "Show details"}</span>
<ChevronDown aria-hidden className={cn("h-4 w-4", detailsOpen && "rotate-180")} />
</button>
) : null}
</div>
{galleryOpen && mediaPath ? (
<ImageGalleryModal
items={[{
id: workProduct.id,
contentPath: mediaPath,
downloadPath: stringMeta(metadata, "downloadPath") ?? undefined,
contentType,
originalFilename: stringMeta(metadata, "originalFilename") ?? workProduct.title,
}]}
initialIndex={0}
open
onOpenChange={setGalleryOpen}
/>
) : null}
</article>
);
}