mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
e95bc433c8
commit
dbb1b57187
4 files changed
+14
-64
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user