fix(ui): make mobile decision rows readable (#9472)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-12 21:28:38 -05:00 committed by GitHub
parent 8a0db228a6
commit c36f1a4afd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 251 additions and 139 deletions

View File

@ -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(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded={false}
onToggleExpand={onToggleExpand}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
@ -274,7 +293,7 @@ describe("AttentionQueueRow", () => {
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(
<AttentionQueueRow
item={buildItem({
@ -298,12 +317,11 @@ describe("AttentionQueueRow", () => {
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(
<AttentionQueueRow
item={buildItem({

View File

@ -45,6 +45,12 @@ import { ProjectTile } from "./ProjectTile";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
// Decision-action buttons: a comfortable tap target when the row is narrow
// (h-9 / text-sm), shrinking back to the dense pill (h-6 / text-xs) once the
// row's own container is wide enough (`@xl` ≈ 576px). Container-query driven so
// the row also reflows correctly inside narrow side panels, not just on phones.
const ACTION_BTN = "h-9 gap-1.5 px-3 text-sm @xl:h-6 @xl:gap-1 @xl:px-2 @xl:text-xs";
/** Tomorrow at 9am local time. */
function tomorrowMorningIso(): string {
const d = new Date();
@ -126,10 +132,21 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
}
};
// Which rows contribute an action bar. Inline rows carry compact decision
// verbs; deep-link rows carry an Open button; curtain rows carry Restore.
const compactActions = !isHidden ? collectCompactActions(item) : [];
const showCompact = !expanded && compactActions.length > 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 (
<div
className={cn(
"relative flex flex-col overflow-hidden border border-border bg-card",
"@container relative flex flex-col overflow-hidden border border-border bg-card",
// The feed is uncapped, so off-screen rows must not cost layout/paint
// while scrolling. The intrinsic-size estimate only matters before a
// row's first paint; `auto` keeps the real measured height afterwards.
@ -147,147 +164,169 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
{/* Type accent bar (canonical color map — never severity). */}
<span className={cn("absolute inset-y-0 left-0 w-1", tone.accent)} aria-hidden />
<div className="flex items-start gap-3 py-3 pl-4 pr-3">
{/* Clickable header region: toggles expand for inline rows (plan §2/§5). */}
<div
className={cn(
"flex min-w-0 flex-1 items-start gap-3 rounded-md",
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
)}
{...(expandable
? {
role: "button",
tabIndex: 0,
"aria-expanded": expanded,
"aria-label": expanded ? "Collapse decision" : "Expand decision",
onClick: activate,
onKeyDown: onHeaderKeyDown,
}
: {})}
>
{/* Expand affordance / source icon */}
{expandable ? (
<span className="mt-0.5 shrink-0 p-0.5 text-muted-foreground" aria-hidden>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
) : (
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
<Icon className={cn("h-4 w-4", tone.icon)} />
</span>
)}
<div className="flex items-start gap-2 py-3 pl-4 pr-3">
{/* Expand affordance / spacer gutter — keeps headlines aligned across the list. */}
{expandable ? (
<button
type="button"
className="mt-0.5 shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
aria-label={expanded ? "Collapse decision" : "Expand decision"}
aria-expanded={expanded}
onClick={activate}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="mt-0.5 hidden h-4 w-4 shrink-0 @xl:block" aria-hidden />
)}
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
{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. */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
{/* Meta band: identity on the left, recency + overflow on the right.
Not part of the clickable headline, so the menu never toggles it. */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
{meta.label}
</span>
{sevBadge && (
<span
className={cn(
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
sevBadge.className,
)}
>
{sevBadge.label}
</span>
{sevBadge && (
<span
className={cn(
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
sevBadge.className,
)}
>
{sevBadge.label}
</span>
)}
{item.relatedIssue?.identifier && (
<Link
to={item.relatedIssue.href ?? "#"}
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{item.relatedIssue.identifier}
</Link>
)}
</div>
<div className="mt-1">
<span className="block truncate text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
{item.subject.title ?? meta.label}
</span>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
{item.project && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<ProjectMeta project={item.project} />
</div>
)}
</div>
)}
{item.relatedIssue?.identifier && (
<Link
to={item.relatedIssue.href ?? "#"}
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{item.relatedIssue.identifier}
</Link>
)}
</div>
{images.length > 0 && <ThumbnailStack images={images} />}
</div>
</div>
{/* Controls: kept as siblings (not inside the clickable header) so they
never toggle expand and stay valid interactive targets. */}
<div className="flex shrink-0 self-stretch flex-col items-end justify-between gap-2" data-attention-controls="true">
<div className="flex items-center justify-end gap-1" data-attention-menu="true">
{isHidden && snoozedUntil ? (
<span
className="text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
)}
{!isHidden && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
{isHidden && snoozedUntil ? (
<span
className="text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
)}
{!isHidden && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="mt-auto flex flex-col items-end gap-1" data-attention-actions="true">
{!expanded && <CompactDecisionActions item={item} companyId={companyId} onOpen={() => 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. */}
<div
className={cn(
"min-w-0 rounded-md",
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
)}
{...(expandable
? {
role: "button",
tabIndex: 0,
"aria-expanded": expanded,
"aria-label": expanded ? "Collapse decision" : "Expand decision",
onClick: activate,
onKeyDown: onHeaderKeyDown,
}
: {})}
>
<span className="line-clamp-2 text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
{item.subject.title ?? meta.label}
</span>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
</div>
<div className="flex items-start justify-end gap-1">
{!inline && href && (
<Button asChild variant="outline" size="xs">
<Link to={href}>
{/* Context row: project identity and evidence thumbnails move below the
text so they never squeeze the headline on mobile. */}
{(item.project || images.length > 0) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{item.project && <ProjectMeta project={item.project} />}
{images.length > 0 && <ThumbnailStack images={images} />}
</div>
)}
{/* Action bar: full-width, thumb-reachable buttons on mobile;
right-aligned dense pills on desktop. Sibling of the headline so
taps never toggle expand. */}
{showActionBar && (
<div
className={cn("flex flex-wrap items-center gap-2 @xl:justify-end", gutterIndent)}
data-attention-actions="true"
>
{showCompact && (
<CompactDecisionActions
item={item}
companyId={companyId}
onOpen={() => onToggleExpand(item)}
/>
)}
{showOpen && (
<Button asChild variant="outline" size="xs" className={cn(ACTION_BTN, "w-full @xl:w-auto")}>
<Link to={href!}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)}
{isHidden && onRestore && (
<Button type="button" variant="outline" size="xs" onClick={() => onRestore(item)}>
{showRestore && (
<Button
type="button"
variant="outline"
size="xs"
className={cn(ACTION_BTN, "w-full @xl:w-auto")}
onClick={() => onRestore(item)}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
)}
</div>
</div>
)}
</div>
</div>
@ -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<unknown, Error, CompactDecisionAction>({
mutationFn: (action: CompactDecisionAction) => {
@ -387,13 +429,14 @@ function CompactDecisionActions({
if (actions.length === 0) return null;
return (
<div className="flex flex-wrap justify-end gap-1" aria-label="Decision actions">
<div className="flex w-full flex-wrap items-center gap-2 @xl:w-auto @xl:justify-end @xl:gap-1" aria-label="Decision actions">
{actions.map(({ action, id, label }) => (
<Button
key={id}
type="button"
variant={decisionVerbVariant({ id, label, description: "" })}
size="xs"
className={cn(ACTION_BTN, "min-w-0 flex-1 @xl:flex-none")}
disabled={decision.isPending}
onClick={(event) => {
event.stopPropagation();

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ArrowUpDown, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
import type { AttentionItem, AttentionSourceKind, AttentionSeverity, InboxDismissalKind } from "@paperclipai/shared";
@ -504,3 +504,54 @@ export const DismissUndoToast: StoryObj = {
export const ZeroState: Story = {
args: { items: [] },
};
/**
* A 390×844 phone frame. Rows use container queries, so the stacked mobile
* layout renders here at any Storybook viewport the row reflows off its own
* column width, not the browser width.
*/
function PhoneFrame({ children }: { children: ReactNode }) {
return (
<div className="flex justify-center bg-background p-4">
<div className="w-[390px] overflow-hidden rounded-xl border border-border bg-background shadow-sm">
{children}
</div>
</div>
);
}
/** Mobile: the populated queue at phone width — full-width headlines + actions. */
export const MobilePopulated: StoryObj = {
name: "Mobile · Populated",
render: () => (
<PhoneFrame>
<Queue items={POPULATED_DATED} groupBy="date" />
</PhoneFrame>
),
};
/** Mobile: the type-color + detail + thumbnail showcase at phone width. */
export const MobileShowcase: StoryObj = {
name: "Mobile · Type colors & detail",
render: () => (
<PhoneFrame>
<Queue items={SHOWCASE} groupBy="type" />
</PhoneFrame>
),
};
/** Mobile: snoozed / dismissed curtains and the restore affordance at phone width. */
export const MobileCurtains: StoryObj = {
name: "Mobile · Curtains",
render: () => (
<PhoneFrame>
<Queue
items={POPULATED_DATED.slice(0, 2)}
groupBy="date"
snoozed={SNOOZED}
dismissed={DISMISSED}
openCurtains
/>
</PhoneFrame>
),
};