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 <noreply@paperclip.ing>
This commit is contained in:
scotttongandPaperclip committed 2026-07-14 12:11:47 -07:00
1 parent e95bc433c8
commit dbb1b57187
4 files changed
+14 -64

No files matched your search

+3 -1
View File
@@ -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.
+6 -7
View File
@@ -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(
<AttentionQueueRow
item={buildItem({
@@ -545,12 +548,8 @@ describe("AttentionQueueRow", () => {
/>,
);
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 () => {
+5 -39
View File
@@ -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({
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
</div>
{/* 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>
)}
{/* 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 && <ProjectMeta project={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<AttentionItem["project"
);
}
/** Square screenshot thumbnails at the right of the description (plan §10). */
function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
const visible = images.slice(0, 3);
const extra = images.length - visible.length;
return (
<div className="flex shrink-0 items-center">
<div className="flex -space-x-3">
{visible.map((img, i) => (
<img
key={img.assetId}
src={attentionImageUrl(img.assetId)}
alt={img.alt ?? ""}
loading="lazy"
style={{ zIndex: visible.length - i }}
className="h-11 w-11 rounded-md border border-border bg-muted object-cover shadow-sm"
/>
))}
</div>
{extra > 0 && (
<span className="ml-1 inline-flex h-6 items-center rounded-md border border-border bg-muted px-1.5 text-(length:--text-nano) font-medium text-muted-foreground">
+{extra}
</span>
)}
</div>
);
}
/** Snooze submenu: presets + a custom date-time (plan §6). */
function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) {
const [customValue, setCustomValue] = useState("");
-17
View File
@@ -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