diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1dab6f5f3..31033ea98c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,14 @@ git push -u origin git push origin --delete ``` +### Keep Screenshots Out of the Repo + +Screenshots, screen recordings, and other review evidence belong in the PR description or a PR comment, not in the repository. Do **not** commit them (for example under `docs/pr-screenshots/`). Committed review images land on `master` permanently and make every clone bigger, even though nobody needs them after the PR merges. + +To show a visual change, drag the image into the PR description or a comment on GitHub, which hosts the file for you. If your tooling cannot upload to GitHub, leave the images out and describe what you checked under **Verification**. + +Images that are part of the product documentation itself, such as a screenshot embedded in a guide under `doc/`, are fine to commit. + ### Model Used (Required) Every PR must include a **Model Used** section specifying which AI model produced or assisted with the change. Include the provider, exact model ID/version, context window size, and any relevant capability details (e.g., reasoning mode, tool use). If no AI was used, write "None — human-authored". This applies to all contributors — human and AI alike. diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 2dd3d1ef26..3921e02074 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -872,9 +872,74 @@ describe("IssueThreadInteractionCard", () => { expect(host.textContent).toContain("Plan v4"); expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan"); expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan"); + expect(host.querySelector(".lucide-chevron-right")).not.toBeNull(); expect(host.textContent).not.toContain("Approve plan"); }); + describe("custom request confirmation targets", () => { + const prKey = (pull: number) => `github:paperclipai/paperclip-desktop:pull:${pull}`; + const unlabeled = (pull: number) => ({ type: "custom" as const, key: prKey(pull) }); + const labeled = (pull: number) => ({ + ...unlabeled(pull), + label: `PR #${pull}`, + href: `https://github.com/paperclipai/paperclip-desktop/pull/${pull}`, + }); + const staleTargetChange = ( + staleTarget: ReturnType, + target: ReturnType, + ) => ({ + ...staleTargetRequestConfirmationInteraction, + payload: { ...staleTargetRequestConfirmationInteraction.payload, target }, + result: { version: 1 as const, outcome: "stale_target" as const, staleTarget }, + }); + + it("hides an unlabeled target key on a pending card", () => { + const host = renderCard({ + interaction: { + ...pendingRequestConfirmationInteraction, + payload: { ...pendingRequestConfirmationInteraction.payload, target: unlabeled(16) }, + }, + }); + expect(host.textContent).not.toContain("github:paperclipai"); + }); + + it("links a labeled target on a pending card", () => { + const host = renderCard({ + interaction: { + ...pendingRequestConfirmationInteraction, + payload: { ...pendingRequestConfirmationInteraction.payload, target: labeled(16) }, + }, + }); + const link = host.querySelector( + 'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]', + ); + expect(link?.textContent).toContain("PR #16"); + expect(host.textContent).not.toContain("github:paperclipai"); + }); + + it("shows no chips or chevron for an unlabeled stale-target change", () => { + const host = renderCard({ interaction: staleTargetChange(unlabeled(15), unlabeled(16)) }); + expect(host.textContent).toContain("Expired by target change"); + expect(host.textContent).not.toContain("github:paperclipai"); + expect(host.querySelector(".lucide-chevron-right")).toBeNull(); + }); + + it("links both labeled targets across a stale-target chevron", () => { + const host = renderCard({ interaction: staleTargetChange(labeled(15), labeled(16)) }); + expect(host.textContent).toContain("PR #15"); + expect(host.textContent).toContain("PR #16"); + expect(host.textContent).not.toContain("github:paperclipai"); + expect(host.querySelector(".lucide-chevron-right")).not.toBeNull(); + }); + + it("drops the chevron when only one side of the stale-target change is labeled", () => { + const host = renderCard({ interaction: staleTargetChange(unlabeled(15), labeled(16)) }); + expect(host.textContent).toContain("PR #16"); + expect(host.textContent).not.toContain("github:paperclipai"); + expect(host.querySelector(".lucide-chevron-right")).toBeNull(); + }); + }); + it("renders a jump link for confirmations expired by comment", () => { const host = renderCard({ interaction: commentExpiredRequestConfirmationInteraction, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 402e06c1e7..1cc6009d85 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1478,6 +1478,15 @@ function AskUserQuestionsCard({ ); } +/** A custom target key is a machine identifier (e.g. `github:org/repo:pull:16`), + * not display copy, so unlabeled custom targets render no chip. */ +function isDisplayableRequestConfirmationTarget( + target: RequestConfirmationTarget | null | undefined, +): target is RequestConfirmationTarget { + if (!target) return false; + return target.type !== "custom" || Boolean(target.label); +} + function requestConfirmationTargetLabel(target: RequestConfirmationTarget) { if (target.label) return target.label; const revision = target.revisionNumber ? ` v${target.revisionNumber}` : ""; @@ -1511,7 +1520,7 @@ function RequestConfirmationTargetChip({ target: RequestConfirmationTarget | null | undefined; tone?: "default" | "subtle"; }) { - if (!target) return null; + if (!isDisplayableRequestConfirmationTarget(target)) return null; const href = requestConfirmationTargetHref({ interaction, target }); const className = cn( @@ -1652,7 +1661,8 @@ function RequestConfirmationResolution({ target={staleTarget} tone="subtle" /> - {staleTarget && target ? ( + {isDisplayableRequestConfirmationTarget(staleTarget) + && isDisplayableRequestConfirmationTarget(target) ? ( ) : null} diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx index 8dbcdd52e6..1fca49b253 100644 --- a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -275,9 +275,11 @@ function targetLabel( ) { const target = interaction.payload.target; if (!target) return null; - const label = - target.label ?? - (target.type === "issue_document" ? target.key : target.key); + // A custom target key is a machine identifier for idempotency and stale + // detection (e.g. `github:org/repo:pull:16`), not display copy. Only show a + // custom target when the agent supplied a human label. + if (target.type === "custom" && !target.label) return null; + const label = target.label ?? target.key; const revision = target.revisionNumber == null ? null : `v${target.revisionNumber}`; return [label, revision].filter(Boolean).join(" · "); @@ -862,8 +864,8 @@ function ConfirmationCard({ planDocument={planDocument} /> ) : isPlanConfirmation && rejecting ? null : ( -
-

+

+

{isPlanConfirmation ? "Do you accept this plan?" : interaction.payload.prompt} @@ -1152,8 +1154,8 @@ function CheckboxConfirmationCard({ return (

-
-

+

+

{interaction.payload.prompt}

diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 7a5a4284ec..194855c99c 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -370,6 +370,95 @@ describe("TaskChatInteractionCard", () => { ).toBeNull(); }); + it("stacks the confirmation prompt full-width and hides unlabeled custom target keys", () => { + const render = (target: RequestConfirmationInteraction["payload"]["target"]) => + flushSync(() => { + root.render( + + + + + , + ); + }); + + render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" }); + expect(container.textContent).not.toContain("github:paperclipai"); + const prompt = Array.from(container.querySelectorAll("p")).find((node) => + node.textContent?.startsWith("The change is open"), + ); + expect(prompt?.className).not.toContain("flex-1"); + expect(prompt?.className).toContain("whitespace-pre-line"); + expect(prompt?.parentElement?.className).not.toContain("justify-between"); + + render({ + type: "custom", + key: "github:paperclipai/paperclip-desktop:pull:16", + label: "PR #16", + href: "https://github.com/paperclipai/paperclip-desktop/pull/16", + }); + const link = container.querySelector( + 'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]', + ); + expect(link?.textContent).toBe("PR #16"); + expect(container.textContent).not.toContain("github:paperclipai"); + }); + + it("stacks the checkbox confirmation prompt full-width and hides unlabeled custom target keys", () => { + const render = (target: RequestConfirmationInteraction["payload"]["target"]) => + flushSync(() => { + root.render( + + + + + , + ); + }); + + render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" }); + expect(container.textContent).not.toContain("github:paperclipai"); + const prompt = Array.from(container.querySelectorAll("p")).find((node) => + node.textContent?.startsWith("Pick the drafts"), + ); + expect(prompt?.className).not.toContain("flex-1"); + expect(prompt?.className).toContain("whitespace-pre-line"); + expect(prompt?.parentElement?.className).not.toContain("justify-between"); + + render({ + type: "custom", + key: "github:paperclipai/paperclip-desktop:pull:16", + label: "PR #16", + href: "https://github.com/paperclipai/paperclip-desktop/pull/16", + }); + const link = container.querySelector( + 'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]', + ); + expect(link?.textContent).toBe("PR #16"); + expect(container.textContent).not.toContain("github:paperclipai"); + }); + it("keeps the plan revision in the header and hides the prompt while requesting changes", async () => { const interaction = createRequestConfirmation({ sourceRunId: "run-plan",