mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
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>
This commit is contained in:
1 parent
2a8a99e4a5
commit
db72ad4c73
3 files changed
+107
-3
No files matched your search
@@ -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
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<strong className="block truncate text-sm font-medium text-foreground">{workProduct.title}</strong>
|
||||
<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}
|
||||
@@ -296,6 +314,17 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
<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 ? (
|
||||
|
||||
@@ -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(
|
||||
<RichWorkProductCard
|
||||
workProduct={workProduct({
|
||||
type: "branch",
|
||||
provider: "git",
|
||||
title: "Company skill update entrypoint",
|
||||
summary: "Uncommitted implementation changes are in the execution working tree on branch company-skill-update-entrypoint.",
|
||||
url: null,
|
||||
metadata: null,
|
||||
status: "active",
|
||||
})}
|
||||
href={null}
|
||||
/>,
|
||||
));
|
||||
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<HTMLButtonElement>('button[aria-label^="Show details:"]');
|
||||
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
||||
flushSync(() => button!.click());
|
||||
const savedDescription = container.querySelector<HTMLDetailsElement>("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<HTMLButtonElement>('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(
|
||||
|
||||
@@ -303,3 +303,41 @@ export const MessageTailMediaAndTypedChips: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
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) => (
|
||||
<div className="w-(--container-md) max-w-full p-6">
|
||||
<RichWorkProductCard {...args} />
|
||||
</div>
|
||||
),
|
||||
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<HTMLButtonElement>('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);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user