From c36f1a4afd91e4ddf0e5c7224b288ce722c7404f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:28:38 -0500 Subject: [PATCH] fix(ui): make mobile decision rows readable (#9472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent companies > - Human operators use the Decisions attention queue to review and resolve work that needs them > - Decision rows were composed as a fixed content column plus a right-side controls column > - At phone widths, timestamps, actions, menus, and evidence thumbnails compressed the decision headline until it was barely readable > - This pull request makes each row respond to its own container width and stacks metadata, content, evidence, and actions on narrow surfaces while preserving the dense desktop layout > - The benefit is a useful, thumb-reachable Decisions workflow on phones and narrow side panels without regressing wide-screen density or scrolling performance ## Linked Issues or Issue Description No public GitHub issue exactly matches this bug, so it is described here using the bug-report fields. **What happened** Decision rows used a fixed two-column layout. On narrow screens, the right-hand timestamp, overflow menu, decision buttons, and optional thumbnails squeezed the headline into a truncated sliver. **Expected behavior** Decision headlines should remain readable on mobile, supporting context should flow below the headline, and primary actions should remain easy to tap. Wide rows should retain the compact desktop presentation. **Steps to reproduce** 1. Open the Decisions / What needs me surface with populated attention items. 2. Reduce the row container to a phone-width layout (approximately 390px). 3. Observe rows with multiple actions or evidence thumbnails. **Paperclip version / deployment mode** Current `master`, board UI in local or hosted deployments. **Related public work found during dedup search** - Refs: #9311 — original What needs me attention queue work. - Refs: #9468 — recent Decisions scrolling performance work preserved by this change. ## What Changed - Reworked `AttentionQueueRow` into a container-query-driven vertical stack on narrow surfaces, with the existing compact layout restored at wide row widths. - Made decision titles wrap to two lines, moved project/evidence context below the headline, and promoted actions to full-width mobile tap targets. - Preserved upstream row memoization and `content-visibility` scrolling optimizations while rebasing onto current `master`. - Added three 390px Storybook scenarios covering populated rows, type/detail variants, and snoozed/dismissed curtains. - Updated the focused row test to assert the new thumbnail/context alignment. ## Verification - `pnpm exec vitest run ui/src/components/AttentionQueueRow.test.tsx` — 1 file passed, 16 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build-storybook` — completed successfully. - `git diff --check public/master...HEAD` — passed. ## Risks - Low risk: the behavior is isolated to the Decisions row presentation and its Storybook coverage. - Container-query breakpoints could need future visual tuning for unusual embedded widths, but the wide layout remains available at the row-level breakpoint. - The mobile layout increases row height by design in exchange for readable content and usable actions. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex CLI coding agent. The exact model ID and context-window size are not exposed to this runtime; reasoning, repository editing, shell execution, and test execution capabilities 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/AttentionQueueRow.test.tsx | 32 +- ui/src/components/AttentionQueueRow.tsx | 305 ++++++++++-------- .../stories/what-needs-me.stories.tsx | 53 ++- 3 files changed, 251 insertions(+), 139 deletions(-) diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 14056b9411..313fb95121 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -203,6 +203,25 @@ describe("AttentionQueueRow", () => { expect(onToggleExpand).toHaveBeenCalledTimes(1); }); + it("exposes the visible expand chevron as an accessible button", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + + const chevronButton = container?.querySelector('button[aria-label="Expand decision"]'); + expect(chevronButton).toBeTruthy(); + expect(chevronButton?.getAttribute("aria-expanded")).toBe("false"); + act(() => chevronButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleExpand).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); + }); + it("does not navigate on title click — the title is plain text, not a link", () => { render( { expect(row?.getAttribute("class")).toContain("ring-ring"); }); - it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => { + it("renders collapsed inline decision verbs in a dedicated action bar with semantic variants", () => { render( { expect(decisionActions?.textContent).toContain("Approve"); expect(decisionActions?.textContent).toContain("Reject"); + // The action bar is its own full-width band (mobile-first) that collapses to + // a right-aligned pill row once the row's container is wide (container query) + // — no longer a stretched right column. const actionArea = decisionActions?.closest('[data-attention-actions="true"]'); - expect(actionArea?.getAttribute("class")).toContain("mt-auto"); - - const controls = decisionActions?.closest('[data-attention-controls="true"]'); - expect(controls?.getAttribute("class")).toContain("self-stretch"); - expect(controls?.getAttribute("class")).toContain("justify-between"); + expect(actionArea?.getAttribute("class")).toContain("@xl:justify-end"); const rowMenu = container?.querySelector('[aria-label="Row actions"]'); expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy(); @@ -423,7 +441,7 @@ describe("AttentionQueueRow", () => { expect(issuesApi.rejectInteraction).not.toHaveBeenCalled(); }); - it("centers thumbnails beside the full card text stack", () => { + it("renders evidence thumbnails in a centered context row below the text stack", () => { render( 0; + const showOpen = !inline && !!href; + const showRestore = isHidden && !!onRestore; + const showActionBar = showCompact || showOpen || showRestore; + // Left gutter width (chevron + gap) so the stacked content aligns under the + // headline in the wide layout; when narrow, everything runs full-bleed. + const gutterIndent = "@xl:pl-6"; + return (
-
- {/* Clickable header region: toggles expand for inline rows (plan §2/§5). */} -
- {/* Expand affordance / source icon */} - {expandable ? ( - - {expanded ? : } - - ) : ( - - - - )} +
+ {/* Expand affordance / spacer gutter — keeps headlines aligned across the list. */} + {expandable ? ( + + ) : ( + + )} -
-
-
- - - {meta.label} + {/* Content column: a single vertical stack that fills the full width on + mobile (no competing right-hand controls) and reads top-to-bottom. */} +
+ {/* Meta band: identity on the left, recency + overflow on the right. + Not part of the clickable headline, so the menu never toggles it. */} +
+
+ + + {meta.label} + + {sevBadge && ( + + {sevBadge.label} - {sevBadge && ( - - {sevBadge.label} - - )} - {item.relatedIssue?.identifier && ( - e.stopPropagation()} - > - {item.relatedIssue.identifier} - - )} -
- -
- - {item.subject.title ?? meta.label} - -

{detailLine}

- - {item.project && ( -
- -
- )} -
+ )} + {item.relatedIssue?.identifier && ( + e.stopPropagation()} + > + {item.relatedIssue.identifier} + + )}
- {images.length > 0 && } -
-
- - {/* Controls: kept as siblings (not inside the clickable header) so they - never toggle expand and stay valid interactive targets. */} -
-
- {isHidden && snoozedUntil ? ( - - Reappears {reappearLabel(snoozedUntil)} - - ) : ( - {relativeTime(item.activityAt)} - )} - {!isHidden && ( - - - - - - {onSnooze && onSnooze(item, iso)} />} - onDismiss(item)}> - - Dismiss - - {href && ( - <> - - - Open source - - - )} - - - )} +
+ {isHidden && snoozedUntil ? ( + + Reappears {reappearLabel(snoozedUntil)} + + ) : ( + {relativeTime(item.activityAt)} + )} + {!isHidden && ( + + + + + + {onSnooze && onSnooze(item, iso)} />} + onDismiss(item)}> + + Dismiss + + {href && ( + <> + + + Open source + + + )} + + + )} +
-
- {!expanded && onToggleExpand(item)} />} + {/* Headline — the primary expand target for inline rows. Title now wraps + to two lines instead of truncating to a sliver on narrow screens. */} +
+ + {item.subject.title ?? meta.label} + +

{detailLine}

+
-
- {!inline && href && ( - )} - {isHidden && onRestore && ( - )}
-
+ )}
@@ -325,6 +364,14 @@ function compactDecisionAction(item: AttentionItem, verbId: string): CompactDeci return null; } +/** The compact accept/reject verbs a collapsed row can resolve in place. */ +function collectCompactActions(item: AttentionItem): Array<{ action: CompactDecisionAction; label: string; id: string }> { + return item.decisionVerbs.slice(0, 3).flatMap((verb) => { + const action = compactDecisionAction(item, verb.id); + return action ? [{ action, label: verb.label, id: verb.id }] : []; + }); +} + function CompactDecisionActions({ item, companyId, @@ -336,12 +383,7 @@ function CompactDecisionActions({ }) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); - const actions = item.decisionVerbs - .slice(0, 3) - .flatMap((verb) => { - const action = compactDecisionAction(item, verb.id); - return action ? [{ action, label: verb.label, id: verb.id }] : []; - }); + const actions = collectCompactActions(item); const decision = useMutation({ mutationFn: (action: CompactDecisionAction) => { @@ -387,13 +429,14 @@ function CompactDecisionActions({ if (actions.length === 0) return null; return ( -
+
{actions.map(({ action, id, label }) => (