From dbb1b571875db0be2fc430fa90ad513b4eeab089 Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 14 Jul 2026 02:46:42 -0700 Subject: [PATCH] design(decisions): drop evidence thumbnail row from cards (5b) Commit 5 of the Quiet Ledger series (Concept A, anatomy 5b). Cards are now title + detail line + project chip + footer; evidence screenshots live one click away behind the 4a "View ..." context link instead of competing with the headline for card height. - AttentionQueueRow: ThumbnailStack render + component deleted; the context row keeps only ProjectMeta. - lib/attention: attentionDetailImages/attentionImageUrl deleted (this row was their only consumer; server payload and shared type keep detail.images for future surfaces). - Test inverted: detail images must never render inline even when the payload carries them. Gates 3/3 CLEAN, typecheck green, ui vitest 2537/2537. Co-Authored-By: Paperclip --- doc/design/DECISION-SHEET.md | 4 +- ui/src/components/AttentionQueueRow.test.tsx | 13 +++--- ui/src/components/AttentionQueueRow.tsx | 44 +++----------------- ui/src/lib/attention.ts | 17 -------- 4 files changed, 14 insertions(+), 64 deletions(-) diff --git a/doc/design/DECISION-SHEET.md b/doc/design/DECISION-SHEET.md index acf726ff950..dc46cf1454c 100644 --- a/doc/design/DECISION-SHEET.md +++ b/doc/design/DECISION-SHEET.md @@ -39,7 +39,9 @@ Every open question from TOKEN-AUDIT.md §8 + batch logs and COMPONENT-INVENTORY | C10 | FeatureGate wrapper pattern (3 near-identical gates) | Nice-to-have shared primitive; backlog, not a run | APPROVED — backlog nice-to-have, not a run | | C11 | Sidebar agents section: hand-rolled rows (own spacing/icon colors, palette-blue liveness dot) instead of `SidebarNavItem`; only section that is collapsible | Run 3 item: unify rows onto `SidebarNavItem`, settle collapsibility policy across sections, liveness dot → canonical status blue. Wholesale shadcn Sidebar adoption REJECTED for now (app already has equivalent machinery incl. icon-rail height trick; highest-regression chrome) — re-evaluate as a dedicated item after Run 3 only if its behaviors (kbd shortcut, persisted state, mobile sheet) are wanted | DONE (Run 3, Jul 7) — agent rows render via `SidebarNavItem` (new additive props: `iconNode`, `active`, `dense`, `trailing`, `liveAccessory`); all live dots (agents + nav rows, rail + expanded) now use `--status-agent-running` (#2563eb, was palette blue-500 #3b82f6 — small hue-darkening delta, per-C11 mandate; "N live" text keeps its palette classes for Run 4). **Collapsibility policy** (superseded Jul 8 by user review): EVERY labeled section (Work, Company, Projects, Agents) is collapsible, default-open, session-scoped state — one affordance everywhere. "See all agents" stays a plain muted Link on purpose (must not adopt active-route highlighting). 48 sidebar vitest assertions pass unchanged. | -## Decisions-page context pathway → footer-left "View … →" link, overflow slimmed (PAP-317 / PAP-313 anatomy 4a, Jul 14 2026) +## Decisions-page cards → thumbnail row dropped (PAP-318 / PAP-313 anatomy 5b, Jul 14 2026) + +Evidence thumbnails (`ThumbnailStack`: overlapping 44px screenshots + "+N" chip) no longer render on Decisions cards — every card is now title + detail line + project chip + footer, so evidence lives one click away behind the 4a footer-left context link instead of competing with the headline for card height. Judgment calls in the same commit: (1) **the now-dead client helpers `attentionDetailImages`/`attentionImageUrl` are deleted** from `lib/attention.ts` (this row was their only consumer — same dead-code-dies-with-its-consumer rule as the 1a tone-system deletion); the server keeps sending `detail.images` and the shared type keeps the field, so richer surfaces (e.g. an Inbox-Split detail pane) can re-adopt them later; (2) the thumbnail test inverts to assert images never render inline even when the payload carries them, so a regression re-introducing the row fails loudly. Every Decisions card with a source href now carries an always-visible footer-left ghost link (muted text + `→`, hover foreground/underline) in the same footer band as the 3a CTAs; the overflow menu keeps only Snooze/Dismiss ("Open source" is gone — the menu no longer hides navigation). Judgment calls in the same commit: (1) **labels name the href's real destination**, via a per-source `contextLabel` in `SOURCE_META` — "View thread" (interactions), "View request" (approvals), "View members" (join requests), "View run" (failed runs), "View budget" (budget alerts), "View agent" (agent errors), "View issue" (recovery/productivity/blocker/review) — rather than echoing the concept artifact's content-specific labels ("View PR"), so the link never over-promises (e.g. recovery cards link to the issue, not a run page); (2) **the link stays in the card body while the resolver is expanded** instead of moving into the resolver's button row as the concept sketches — resolver internals are out of scope per the plan, and a stable position beats a migrating one; (3) **curtain rows get the link too** (beside Restore), matching the approved concept; (4) deep-link cards show both the ghost link and the solid Open/Review CTA even though they share an href — anatomy consistency (every card: context left, verbs right) is the point of 4a. diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 2067ce81ec8..b8600fa0447 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -528,7 +528,10 @@ describe("AttentionQueueRow", () => { expect(issuesApi.acceptInteraction).not.toHaveBeenCalled(); }); - it("renders evidence thumbnails in a centered context row below the text stack", () => { + // Anatomy 5b: the thumbnail row was removed from the card — evidence lives + // one click away behind the footer-left context link, so detail images must + // never render inline even when the payload carries them. + it("does not render evidence thumbnails even when the detail payload has images", () => { render( { />, ); - const image = container?.querySelector('img[alt="Screenshot"]'); - expect(image?.getAttribute("src")).toBe("/api/assets/asset-1/content"); - - const thumbnailStack = image?.parentElement?.parentElement; - expect(thumbnailStack?.getAttribute("class")).toContain("items-center"); - expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center"); + expect(container?.querySelector('img[alt="Screenshot"]')).toBeNull(); + expect(container?.querySelector('img[src*="asset-1"]')).toBeNull(); }); it("is memoized — a parent re-render with identical props does not re-render the row", async () => { diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index d520a5ea384..0d0b76021a6 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -10,7 +10,7 @@ import { RotateCcw, X, } from "lucide-react"; -import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared"; +import type { Agent, AttentionItem } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { accessApi } from "../api/access"; import { approvalsApi } from "../api/approvals"; @@ -18,9 +18,7 @@ import { issuesApi } from "../api/issues"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { - attentionDetailImages, attentionDetailLine, - attentionImageUrl, isInlineResolvable, severityBadge, sourceMeta, @@ -107,7 +105,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ const href = item.subject.href; const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null; const detailLine = attentionDetailLine(item) ?? item.whyNow; - const images = attentionDetailImages(item); // Only inline-resolvable active rows can expand; that's the only case where a // whole-header click has somewhere to go (plan §5). Non-inline rows keep the // explicit Open button and never toggle on a stray click. @@ -268,14 +265,10 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({

{detailLine}

- {/* Context row: project identity and evidence thumbnails move below the - text so they never squeeze the headline on mobile. */} - {(item.project || images.length > 0) && ( -
- {item.project && } - {images.length > 0 && } -
- )} + {/* Context row: project identity only (5b). Evidence thumbnails were + dropped from the card — screenshots live one click away behind the + footer-left context link, keeping the card two text lines tall. */} + {item.project && } {/* Persistent footer bar: context link left (4a), CTAs right at one size (3a). Sibling of the headline so taps never toggle expand. */} @@ -518,33 +511,6 @@ function ProjectMeta({ project }: { project: NonNullable -
- {visible.map((img, i) => ( - {img.alt - ))} -
- {extra > 0 && ( - - +{extra} - - )} - - ); -} - /** Snooze submenu: presets + a custom date-time (plan §6). */ function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) { const [customValue, setCustomValue] = useState(""); diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index 21b72ee18a1..c8002e14dc2 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -12,10 +12,8 @@ import { type LucideIcon, } from "lucide-react"; import type { - AttentionDetailImage, AttentionFeed, AttentionItem, - AttentionItemDetail, AttentionProjectRef, AttentionSeverity, AttentionSourceKind, @@ -155,21 +153,6 @@ export function attentionDetailLine(item: AttentionItem): string | null { } } -/** Screenshot / thumbnail images attached to the detail block, if any. */ -export function attentionDetailImages(item: AttentionItem): AttentionDetailImage[] { - return (item.detail as AttentionItemDetail | null)?.images ?? []; -} - -/** - * Content URL for an attention detail image asset. Already-absolute or data - * URLs pass through unchanged (server may hand back a CDN URL; stories use data - * URIs), otherwise we resolve the in-app asset content route. - */ -export function attentionImageUrl(assetId: string): string { - if (assetId.startsWith("data:") || assetId.startsWith("http")) return assetId; - return `/api/assets/${assetId}/content`; -} - /** * Decisions-only badge count. Every feed row *is* a pending decision (the * server drops anything without a decision verb into Activity, per the §0