From db72ad4c732c78cb634696e87b094d8b966ed2c7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:05:48 -0500 Subject: [PATCH] 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 --- .../task-chat/RichWorkProductCard.tsx | 35 +++++++++++++++-- .../task-chat/TaskChatProtocolCard.test.tsx | 37 ++++++++++++++++++ .../rich-work-product-cards.stories.tsx | 38 +++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/ui/src/components/task-chat/RichWorkProductCard.tsx b/ui/src/components/task-chat/RichWorkProductCard.tsx index 46581ded661..4b5dedf7226 100644 --- a/ui/src/components/task-chat/RichWorkProductCard.tsx +++ b/ui/src/components/task-chat/RichWorkProductCard.tsx @@ -10,6 +10,7 @@ import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; import { attachmentDownloadPath } from "@/lib/issue-attachments"; import type { IssueWorkProduct } from "@paperclipai/shared"; import { + ChevronDown, ExternalLink, Maximize2, File, @@ -23,7 +24,7 @@ import { } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { GithubIcon } from "@/components/icons/github-icon"; -import { cn } from "@/lib/utils"; +import { cn, formatDateTime } from "@/lib/utils"; type StateChip = { label: string; @@ -135,6 +136,7 @@ export interface RichWorkProductCardProps { 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); @@ -161,7 +163,7 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric break; case "branch": Icon = GitBranch; - meta = [stringMeta(metadata, "repository", "repo", "repositoryName"), stringMeta(metadata, "branch", "branchName") ?? workProduct.externalId, urlLabel(workProduct.url)]; + 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": { @@ -232,6 +234,9 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric ? 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); }; @@ -272,9 +277,22 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric )}
- {workProduct.title} + {workProduct.title} {visibleMeta.length > 0 ?

{visibleMeta.join(" · ")}

: null} {statsLabel ?

{statsLabel}

: null} + {!compact && summary && !linklessBranch ?

{summary}

: null} + {expandable && detailsOpen ? ( +
+ {workProduct.type !== "branch" ?

No link was provided for this work product.

: null} +

{workProduct.provider} · {workProduct.status.replaceAll("_", " ")} · Updated {formatDateTime(workProduct.updatedAt)}

+ {linklessBranch && summary ? ( +
+ Saved description +

{summary}

+
+ ) : null} +
+ ) : null}
{chip ? : null} @@ -296,6 +314,17 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric {compact ? null : {action}} + ) : expandable ? ( + ) : null}
{galleryOpen && mediaPath ? ( diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index 00961fdbaad..c06903a59f0 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -206,6 +206,43 @@ describe("TaskChatProtocolCard", () => { expect(document.querySelector('[role="dialog"]')).toBeNull(); }); + it("shows a branch summary and lets users inspect a work product without a remote URL", () => { + flushSync(() => root.render( + , + )); + expect(container.textContent).not.toContain("Uncommitted implementation changes"); + expect(container.textContent).toContain("Branch · no remote link"); + expect(container.querySelector("strong")?.className).toContain("line-clamp-2"); + expect(container.querySelector("a")).toBeNull(); + const button = container.querySelector('button[aria-label^="Show details:"]'); + expect(button?.getAttribute("aria-expanded")).toBe("false"); + flushSync(() => button!.click()); + const savedDescription = container.querySelector("details"); + expect(savedDescription?.open).toBe(false); + expect(savedDescription?.textContent).toContain("Uncommitted implementation changes"); + flushSync(() => savedDescription!.querySelector("summary")!.click()); + expect(savedDescription?.open).toBe(true); + flushSync(() => savedDescription!.querySelector("summary")!.click()); + expect(container.textContent).toContain("git · active · Updated"); + expect(container.querySelector('button[aria-label^="Hide details:"]')?.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector("strong")?.className).not.toContain("line-clamp-2"); + flushSync(() => container.querySelector('button[aria-label^="Hide details:"]')!.click()); + expect(container.querySelector("details")).toBeNull(); + expect(container.textContent).toContain("Branch · no remote link"); + expect(container.querySelector('button[aria-label^="Show details:"]')?.getAttribute("aria-expanded")).toBe("false"); + }); + it.each(["text/html", "application/zip"])("labels %s artifact links as downloads", (contentType) => { const contentPath = "/api/attachments/file/content"; flushSync(() => root.render( diff --git a/ui/storybook/stories/rich-work-product-cards.stories.tsx b/ui/storybook/stories/rich-work-product-cards.stories.tsx index 3b4e91f6ae7..a85c39c499e 100644 --- a/ui/storybook/stories/rich-work-product-cards.stories.tsx +++ b/ui/storybook/stories/rich-work-product-cards.stories.tsx @@ -303,3 +303,41 @@ export const MessageTailMediaAndTypedChips: Story = { ), }; + +const LOCAL_BRANCH: IssueWorkProduct = { + ...product(KINDS[2], STATES[0]), + id: "local-branch-no-remote", + title: "Company skill update entrypoint", + provider: "git", + updatedAt: new Date("2026-10-03T11:52:54.448Z"), + url: null, + metadata: null, + status: "active", + summary: "Uncommitted implementation changes are in the execution working tree on branch company-skill-update-entrypoint. Includes a guarded skill API, runner tool, docs, and focused regressions. No commit or push was requested.", +}; + +/** A work product without a remote URL still explains what it is and opens its own details. */ +export const BranchWithoutRemoteLink: Story = { + args: { workProduct: LOCAL_BRANCH, href: null }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + expect(canvasElement.textContent).toContain("Branch · no remote link"); + expect(canvasElement.textContent).not.toContain("Uncommitted implementation changes"); + expect(canvasElement.querySelector('button[aria-label^="Show details:"]')).not.toBeNull(); + }, +}; + +export const BranchDetailsExpanded: Story = { + ...BranchWithoutRemoteLink, + play: async ({ canvasElement, userEvent }) => { + const button = canvasElement.querySelector('button[aria-label^="Show details:"]'); + await userEvent.click(button!); + expect(canvasElement.textContent).toContain("git · active · Updated"); + expect(canvasElement.textContent).toContain("Saved description"); + expect(canvasElement.querySelector("details")?.open).toBe(false); + }, +};