From 14d755824c819f9c71fb65518d8436100d12e7de Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:23:16 -0500 Subject: [PATCH] Remove decision and review summaries from issue headers (#10891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators use issue pages to read task state and control task work > - The issue header showed separate summaries for open decisions and review paths > - These summaries repeated state that belongs in the Decisions view > - The extra sections added noise before the issue description and thread > - This pull request removes both header summaries and keeps decision actions in the Decisions view > - The benefit is a simpler issue header with one place for decision work ## Linked Issues or Issue Description **What existing behavior does this improve?** The issue detail header shows separate pending-decision and review-path sections. **Subsystem affected** `ui/` — React and Vite board UI. **Current behavior** An issue header can show a decision strip and a larger review panel before the issue content. **Proposed behavior** The issue header does not show either decision section. Operators continue to manage decisions and stalled reviews in the Decisions view. **Reason and benefit** This removes duplicate decision state from the issue header and reduces visual noise. **Breaking changes** The issue page no longer provides these summaries or shortcuts. Decision data, review state, and the Decisions view do not change. ## What Changed - Removed the pending-decision strip and review-path panel from the issue detail header. - Deleted the two unused header components and the panel-specific test. - Kept stalled-review actions and their Storybook examples in the Decisions queue. - Added an issue-detail regression test that covers both removed sections. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/pages/IssueDetail.test.tsx` (46 tests passed) - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - `pnpm build-storybook` - `git diff --check` ## Risks - Low risk. This change removes two issue-header surfaces. It does not change decision APIs or data. - Users must open the Decisions view to find pending decisions and stalled-review actions. > This change does not duplicate planned core work in `ROADMAP.md`. GitHub searches found no related open issue or pull request. ## Model Used - OpenAI Codex, GPT-5. The exact deployment ID and context window are not exposed. Tool use and code execution were enabled. ## 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 - [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 --- ui/src/components/IssueReviewPanel.test.tsx | 183 ------------------ ui/src/components/IssueReviewPanel.tsx | 160 --------------- ui/src/components/PendingDecisionStrip.tsx | 38 ---- ui/src/pages/IssueDetail.test.tsx | 42 ++++ ui/src/pages/IssueDetail.tsx | 6 - .../stories/issue-review-panel.stories.tsx | 163 ++++------------ 6 files changed, 84 insertions(+), 508 deletions(-) delete mode 100644 ui/src/components/IssueReviewPanel.test.tsx delete mode 100644 ui/src/components/IssueReviewPanel.tsx delete mode 100644 ui/src/components/PendingDecisionStrip.tsx diff --git a/ui/src/components/IssueReviewPanel.test.tsx b/ui/src/components/IssueReviewPanel.test.tsx deleted file mode 100644 index ab08bf2243..0000000000 --- a/ui/src/components/IssueReviewPanel.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// @vitest-environment jsdom - -import { createRoot } from "react-dom/client"; -import { flushSync } from "react-dom"; -import type { ReactElement } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { IssueReviewAttention } from "@paperclipai/shared"; -import { IssueReviewPanel, type ReviewPanelIssue } from "./IssueReviewPanel"; -import { ToastProvider } from "../context/ToastContext"; -import { ToastViewport } from "./ToastViewport"; - -const decideStalledReviewMock = vi.hoisted(() => vi.fn(() => Promise.resolve({}))); - -vi.mock("../api/issues", () => ({ - issuesApi: { - decideStalledReview: decideStalledReviewMock, - }, -})); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -function act(callback: () => T): T { - let result: T | undefined; - flushSync(() => { - result = callback(); - }); - return result as T; -} - -/** react-query dispatches the mutationFn on a microtask — let it settle. */ -async function flushMicrotasks() { - await Promise.resolve(); - await Promise.resolve(); -} - -let root: ReturnType | null = null; -let container: HTMLDivElement | null = null; - -afterEach(() => { - if (root) act(() => root?.unmount()); - root = null; - container?.remove(); - container = null; - vi.clearAllMocks(); -}); - -function render(element: ReactElement) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - act(() => - root?.render( - - - {element} - - - , - ), - ); - return container; -} - -function buildIssue(reviewAttention: IssueReviewAttention | undefined, status: ReviewPanelIssue["status"] = "in_review"): ReviewPanelIssue { - return { id: "issue-1", companyId: "c1", status, reviewAttention }; -} - -const coveredAttention: IssueReviewAttention = { - state: "covered", - reason: "Review has a maintained action path.", - paths: [ - { - kind: "interaction", - label: "Pending request confirmation", - responder: "Board", - since: "2026-08-02T00:00:00.000Z", - ref: "interaction-1", - }, - { - kind: "human_reviewer", - label: "Human reviewer", - responder: "Dotta", - since: "2026-08-02T00:00:00.000Z", - ref: null, - }, - ], -}; - -const stalledAttention: IssueReviewAttention = { - state: "stalled", - reason: "Issue is in review without a maintained action path.", - paths: [], -}; - -describe("IssueReviewPanel", () => { - it("renders nothing when the issue is not in review", () => { - const el = render(); - expect(el.querySelector('[data-testid="issue-review-panel"]')).toBeNull(); - }); - - it("renders nothing when reviewAttention is absent (older payloads)", () => { - const el = render(); - expect(el.querySelector('[data-testid="issue-review-panel"]')).toBeNull(); - }); - - it("covered: names each maintained path with its responder and outcome hint", () => { - const el = render(); - const panel = el.querySelector('[data-testid="issue-review-panel"]'); - expect(panel?.getAttribute("data-review-state")).toBe("covered"); - expect(panel?.textContent).toContain("In review"); - expect(panel?.textContent).toContain("Pending request confirmation"); - expect(panel?.textContent).toContain("Board"); - expect(panel?.textContent).toContain("Human reviewer"); - expect(panel?.textContent).toContain("Dotta"); - // The outcome hint tells the operator what each verb does. - expect(panel?.textContent).toContain("Approving marks this issue done"); - // Covered reviews do not expose the escape actions. - expect(panel?.textContent).not.toContain("Send back to work"); - }); - - it("stalled: shows the amber notice and the three review actions", () => { - const el = render(); - const panel = el.querySelector('[data-testid="issue-review-panel"]'); - expect(panel?.getAttribute("data-review-state")).toBe("stalled"); - expect(panel?.textContent).toContain("Nobody is reviewing this"); - expect(panel?.textContent).toContain("Approve"); - expect(panel?.textContent).toContain("Request changes"); - expect(panel?.textContent).toContain("Send back to work"); - }); - - it("stalled: request-changes is disabled until a note is entered", () => { - const el = render(); - const requestChanges = el.querySelector( - '[data-testid="stalled-review-request-changes"]', - ); - expect(requestChanges?.disabled).toBe(true); - - const note = el.querySelector('[data-testid="stalled-review-note"]'); - act(() => { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )!.set!; - setter.call(note, "Please fix the failing test"); - note!.dispatchEvent(new Event("input", { bubbles: true })); - }); - expect(requestChanges?.disabled).toBe(false); - }); - - it("stalled: approving posts approve to the decision endpoint", async () => { - const el = render(); - const approve = el.querySelector('[data-testid="stalled-review-approve"]'); - act(() => approve!.click()); - await flushMicrotasks(); - expect(decideStalledReviewMock).toHaveBeenCalledWith("issue-1", { - action: "approve", - note: undefined, - }); - }); - - it("stalled: send-back forwards the typed note", async () => { - const el = render(); - const note = el.querySelector('[data-testid="stalled-review-note"]'); - act(() => { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )!.set!; - setter.call(note, "Back to you"); - note!.dispatchEvent(new Event("input", { bubbles: true })); - }); - const sendBack = el.querySelector('[data-testid="stalled-review-send-back"]'); - act(() => sendBack!.click()); - await flushMicrotasks(); - expect(decideStalledReviewMock).toHaveBeenCalledWith("issue-1", { - action: "send_back", - note: "Back to you", - }); - }); -}); diff --git a/ui/src/components/IssueReviewPanel.tsx b/ui/src/components/IssueReviewPanel.tsx deleted file mode 100644 index 25888ef61f..0000000000 --- a/ui/src/components/IssueReviewPanel.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import type { IssueReviewAttention, IssueReviewAttentionPath, IssueStatus } from "@paperclipai/shared"; -import { - Activity, - AlertTriangle, - Bell, - Clock, - HelpCircle, - LifeBuoy, - ShieldCheck, - UserCheck, - Users, - type LucideIcon, -} from "lucide-react"; -import { cn, relativeTime } from "../lib/utils"; -import { StatusGlyph } from "./StatusGlyph"; -import { StalledReviewActions } from "./StalledReviewActions"; - -/** Minimal shape the panel needs — the full `Issue` satisfies it. */ -export interface ReviewPanelIssue { - id: string; - companyId: string; - status: IssueStatus; - reviewAttention?: IssueReviewAttention; -} - -const PATH_ICON: Record = { - execution_participant: Users, - interaction: HelpCircle, - approval: ShieldCheck, - monitor: Clock, - human_reviewer: UserCheck, - active_run: Activity, - queued_wake: Bell, - recovery: LifeBuoy, -}; - -/** - * Persistent review panel pinned above the thread whenever an issue is - * `in_review` (PAP-16080 §4.4). Driven by `issue.reviewAttention` (P2): - * - * - **covered** — names WHAT is being reviewed (each maintained path), WHO - * decides it, and since when, plus what each outcome does. Keeps a stalled - * review from ever being the *only* thing an operator sees, and surfaces the - * responder so a covered review reads as "someone has this". - * - **stalled** — the amber "nobody is reviewing this" notice with the three - * escape actions (approve / request changes / send back), so an agent-owned - * review can never become an invisible zombie (the PAP-14994 failure). - * - * Renders nothing when the issue is not in review, or when `reviewAttention` is - * absent (older payloads) — the thread simply shows as it does today. - */ -export function IssueReviewPanel({ issue }: { issue: ReviewPanelIssue }) { - const reviewAttention = issue.reviewAttention; - if (issue.status !== "in_review" || !reviewAttention) return null; - - if (reviewAttention.state === "stalled") { - return ; - } - if (reviewAttention.state === "covered") { - return ; - } - return null; -} - -function CoveredReviewPanel({ reviewAttention }: { reviewAttention: IssueReviewAttention }) { - return ( -
-
- -
-

In review

-

- {reviewAttention.reason ?? "This issue has a maintained review path."} -

-
-
- -
    - {reviewAttention.paths.map((path, index) => ( - - ))} -
- -

- Approving marks this issue done. Requesting changes or sending it back returns it to the - assignee. -

-
- ); -} - -function ReviewPathRow({ path }: { path: IssueReviewAttentionPath }) { - const Icon = PATH_ICON[path.kind] ?? Activity; - return ( -
  • - - {path.label} - {path.responder && ( - <> - - {path.responder} - - )} - {path.since && ( - <> - - - {relativeTime(path.since)} - - - )} -
  • - ); -} - -function PathDot() { - return ( - - · - - ); -} - -function StalledReviewPanel({ - issue, - reason, -}: { - issue: ReviewPanelIssue; - reason: string | null; -}) { - return ( -
    -
    - -
    -

    - Nobody is reviewing this -

    -

    - {reason - ?? "No reviewer, interaction, approval, or monitor exists — the review has no owner."} -

    -
    -
    - - -
    - ); -} diff --git a/ui/src/components/PendingDecisionStrip.tsx b/ui/src/components/PendingDecisionStrip.tsx deleted file mode 100644 index 18d5a58de5..0000000000 --- a/ui/src/components/PendingDecisionStrip.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Scale } from "lucide-react"; -import { Link } from "@/lib/router"; -import { decisionsApi } from "../api/decisions"; -import { queryKeys } from "../lib/queryKeys"; - -/** - * Information-scent breadcrumb on a target issue: when a decision proposed - * *elsewhere* targets this issue (via `decision_target_issues`), surface it so - * the pending decision isn't lost in another thread. It never actuates inline — - * decisions are always decided from the one Decisions inbox (PAP-14966 §3). - */ -export function PendingDecisionStrip({ companyId, issueId }: { companyId: string; issueId: string }) { - const { data } = useQuery({ - queryKey: queryKeys.decisions.forTargetIssue(companyId, issueId), - queryFn: () => decisionsApi.list(companyId, { targetIssueId: issueId, status: "open" }), - enabled: !!companyId && !!issueId, - }); - - const count = data?.length ?? 0; - if (count === 0) return null; - - // Deep-link to the single decision when there's just one; otherwise the inbox. - const to = count === 1 ? `/decisions?decisionId=${data![0]!.id}` : "/decisions"; - - return ( - - - - {count === 1 ? "1 pending decision affects this issue" : `${count} pending decisions affect this issue`} - - Review in Decisions → - - ); -} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index cb06be0c7c..fa0d1b5c4a 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -69,6 +69,10 @@ const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn(), })); +const mockDecisionsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + const mockInstanceSettingsApi = vi.hoisted(() => ({ getGeneral: vi.fn(), getExperimental: vi.fn(), @@ -134,6 +138,10 @@ vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi, })); +vi.mock("../api/decisions", () => ({ + decisionsApi: mockDecisionsApi, +})); + vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); @@ -1010,6 +1018,7 @@ describe("IssueDetail", () => { mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockAuthApi.getSession.mockResolvedValue({ session: null, user: null }); mockProjectsApi.list.mockResolvedValue([]); + mockDecisionsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", @@ -1066,6 +1075,39 @@ describe("IssueDetail", () => { ).toBe(false); }); + it("does not load or render decision sections in the issue header", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_review", + reviewAttention: { + state: "covered", + reason: "Review has a maintained action path.", + paths: [ + { + kind: "interaction", + label: "Pending request confirmation", + responder: "Board", + since: "2026-04-21T00:00:00.000Z", + ref: "interaction-1", + }, + ], + }, + })); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Issue detail smoke"); + expect(container.querySelector('[data-testid="issue-review-panel"]')).toBeNull(); + expect(mockDecisionsApi.list).not.toHaveBeenCalled(); + }); + it("updates status and priority from the task header controls", async () => { const issue = createIssue({ status: "todo", priority: "medium" }); mockIssuesApi.get.mockResolvedValue(issue); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index e87f6afb90..27c674bf2d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -122,8 +122,6 @@ import { } from "../components/IssueMonitorBanner"; import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; -import { PendingDecisionStrip } from "../components/PendingDecisionStrip"; -import { IssueReviewPanel } from "../components/IssueReviewPanel"; import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; import { computePauseAffectsSummary } from "../lib/interrupt-handoff"; import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects"; @@ -4527,10 +4525,6 @@ export function IssueDetail() { checkingNow={checkIssueMonitorNow.isPending} /> - - - - {taskChatShellEnabled ? null : ( & Pick): IssueReviewAttentionPath { - return { - responder: null, - since: "2026-08-02T00:30:00.000Z", - ref: null, - ...overrides, - }; -} - -function issue(reviewAttention: IssueReviewAttention): ReviewPanelIssue { - return { id: "issue-1", companyId: "company-1", status: "in_review", reviewAttention }; -} +// Stalled review actions remain available where decisions are handled. The +// issue header no longer duplicates decision or review-path summaries. function Frame({ label, @@ -29,7 +13,7 @@ function Frame({ }: { label: string; children: React.ReactNode; - /** Content width in px — narrow (390) exercises the stalled action row's base stacked layout. */ + /** Content width in px — narrow (390) exercises the action row's stacked layout. */ width?: number; }) { return ( @@ -40,101 +24,6 @@ function Frame({ ); } -const meta = { - title: "Product/Issue/Review panel", - component: IssueReviewPanel, - args: { issue: issue({ state: "none", paths: [], reason: null }) }, - parameters: { - docs: { - description: { - component: - "Pinned above the thread whenever an issue is `in_review`. Covered names each maintained review path (what/who/since) and what each outcome does; stalled shows the amber \"nobody is reviewing this\" notice with approve / request-changes / send-back. The same three verbs actuate inline on the /decisions card when a review goes stalled.", - }, - }, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const CoveredSinglePath: Story = { - name: "Covered · one maintained path", - render: () => ( - - - - ), -}; - -export const CoveredMultiplePaths: Story = { - name: "Covered · reviewer + monitor", - render: () => ( - - - - ), -}; - -export const Stalled: Story = { - name: "Stalled · nobody is reviewing this", - render: () => ( - - - - ), -}; - -export const StalledNarrow: Story = { - name: "Stalled · 390px phone (action row stacks)", - render: () => ( - - - - ), -}; - const stalledReviewRow: AttentionItem = { id: "review:issue-1", companyId: "company-1", @@ -152,8 +41,16 @@ const stalledReviewRow: AttentionItem = { whyNow: "Issue is in review without a maintained reviewer, interaction, approval, monitor, run, wake, or recovery path.", decisionVerbs: [ - { id: "choose_review_path", label: "Choose review path", description: "Add a reviewer or waiting path, return the issue to work, or accept it." }, - { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." }, + { + id: "choose_review_path", + label: "Choose review path", + description: "Add a reviewer or waiting path, return the issue to work, or accept it.", + }, + { + id: "request_changes", + label: "Request changes", + description: "Return the issue to the assignee with changes requested.", + }, ], inlineResolvable: true, entryRule: "", @@ -185,12 +82,36 @@ const stalledReviewRow: AttentionItem = { trainingExampleId: null, }; +const meta = { + title: "Product/Decisions/Stalled review actions", + component: AttentionQueueRow, + args: { + item: stalledReviewRow, + companyId: "company-1", + expanded: true, + onToggleExpand: () => {}, + onDismiss: () => {}, + }, + parameters: { + docs: { + description: { + component: + "Stalled reviews remain actionable in the Decisions queue after removing decision summaries from the issue header.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + export const DecisionsCardInline: Story = { - name: "Decisions card · stalled review resolves in-row", + name: "Stalled review resolves in-row", render: () => { const [expanded, setExpanded] = useState(true); return ( - + { const [expanded, setExpanded] = useState(true); return ( - +