From e95bc433c8554a97d83f1cae7a88d97782f2aed8 Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 14 Jul 2026 02:35:27 -0700 Subject: [PATCH] =?UTF-8?q?design(decisions):=20footer-left=20"View=20?= =?UTF-8?q?=E2=80=A6"=20context=20link,=20overflow=20slimmed=20(4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the context pathway out of the overflow menu into an always-visible footer-left ghost link on every card — active, expanded, and curtain rows alike. The overflow menu keeps only Snooze/Dismiss. - SOURCE_META gains a per-source contextLabel named for where subject.href actually lands ("View thread", "View request", "View run", "View issue", …) so the link never over-promises. - The footer band splits context-left / CTAs-right (justify-between); the 3a justify-end contract moves to the CTA group. Two tests that encoded the pre-4a shape (first-anchor-is-CTA in the review deep-link test, justify-end on the outer band) update accordingly, plus new coverage for link presence, footer-left position, expanded persistence, and curtain rows. Verified: token gates 3/3 CLEAN, pnpm typecheck green, ui vitest 2537/2537, light+dark before/after screenshots reviewed. Co-Authored-By: Paperclip --- doc/design/DECISION-SHEET.md | 4 + ui/src/components/AttentionQueueRow.test.tsx | 67 +++++++++++++++- ui/src/components/AttentionQueueRow.tsx | 82 ++++++++++++-------- ui/src/lib/attention.test.ts | 2 + ui/src/lib/attention.ts | 28 ++++--- 5 files changed, 136 insertions(+), 47 deletions(-) diff --git a/doc/design/DECISION-SHEET.md b/doc/design/DECISION-SHEET.md index fc4187733a5..acf726ff950 100644 --- a/doc/design/DECISION-SHEET.md +++ b/doc/design/DECISION-SHEET.md @@ -39,6 +39,10 @@ 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) + +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. + ## Decisions-page type signal → mono icon + label, accent bar removed (PAP-315 / PAP-313 anatomy 1a, Jul 14 2026) The five-tone type→color system (sky/violet/rose/amber/neutral accent bar + tinted source icon) is retired from the Decisions feed: the left accent bar is deleted and the source icon renders in `text-muted-foreground`, so a row's type reads through icon shape + text label alone and color is freed up for severity (Critical `destructive`, per 2a) and CTAs. Judgment call in the same commit: `attentionTone`/`attentionToneStyle`/`TONE_STYLE` (lib/attention.ts) were deleted rather than kept as an unused map — a repo-wide grep found exactly one consumer (AttentionQueueRow itself), so no other surface loses the palette and no debt remains; the tone unit tests went with them (type identity is still covered by the `sourceMeta` label/icon tests). diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 2bc58687da7..2067ce81ec8 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -160,8 +160,10 @@ describe("AttentionQueueRow", () => { />, ); // Deep-link cards carry one solid advance verb — "Review" for review rows. + // Selected via data-variant: the footer's first anchor is now the 4a + // context link, which is a plain link rather than a Button. const actionArea = container?.querySelector('[data-attention-actions="true"]'); - const cta = actionArea?.querySelector("a"); + const cta = actionArea?.querySelector("a[data-variant]"); expect(cta?.textContent).toBe("Review"); expect(cta?.getAttribute("href")).toBe("/PAP/issues/PAP-1"); expect(cta?.getAttribute("data-variant")).toBe("default"); @@ -325,9 +327,11 @@ describe("AttentionQueueRow", () => { // The third verb stays in the expanded resolver — a card carries at most two CTAs. expect(decisionActions?.textContent).not.toContain("Request revision"); - // The action bar is a persistent footer-right band at every width (3a). + // The footer band now splits context-left / CTAs-right (4a), so the + // justify-end contract moved from the outer bar to the CTA group. const actionArea = decisionActions?.closest('[data-attention-actions="true"]'); - expect(actionArea?.getAttribute("class")).toContain("justify-end"); + expect(actionArea?.getAttribute("class")).toContain("justify-between"); + expect(decisionActions?.parentElement?.getAttribute("class")).toContain("justify-end"); const rowMenu = container?.querySelector('[aria-label="Row actions"]'); expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy(); @@ -585,4 +589,61 @@ describe("AttentionQueueRow", () => { ); expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull(); }); + + it("always shows a footer-left context link named for the source (4a)", () => { + render( + , + ); + + const link = container?.querySelector('[data-attention-context-link="true"]'); + expect(link?.textContent).toBe("View request→"); + expect(link?.getAttribute("href")).toBe("/PAP/approvals/approval-1"); + // Footer-left: the link leads the footer band, ahead of the CTA group. + expect(link?.closest('[data-attention-actions="true"]')?.firstElementChild).toBe(link); + }); + + it("keeps the context link visible while the resolver is expanded", () => { + render( + , + ); + + const link = container?.querySelector('[data-attention-context-link="true"]'); + expect(link?.textContent).toContain("View request"); + }); + + it("gives curtain rows the context link beside Restore", () => { + const onRestore = vi.fn(); + render( + , + ); + + const link = container?.querySelector('[data-attention-context-link="true"]'); + expect(link?.getAttribute("href")).toBe("/PAP/approvals/approval-1"); + const restore = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Restore", + ); + expect(restore).toBeTruthy(); + }); }); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index c3de1edb059..d520a5ea384 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -134,7 +134,11 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ const showExpandVerb = !expanded && !decisionCtas && inline; const showOpen = !isHidden && !decisionCtas && !inline && !!href; const showRestore = isHidden && !!onRestore; - const showActionBar = showDecision || showExpandVerb || showOpen || showRestore; + const showCtas = showDecision || showExpandVerb || showOpen || showRestore; + // Context pathway (4a): every card with a source href carries a footer-left + // "View …" link — active, expanded, and curtain rows alike — so context is + // never buried in the overflow menu. + const showActionBar = showCtas || !!href; return (
+ {/* Deferral verbs only (4a): the context pathway moved to the + always-visible footer-left link, so the menu no longer + hides navigation. */} {onSnooze && onSnooze(item, iso)} />} onDismiss(item)}> Dismiss - {href && ( - <> - - - Open source - - - )} )} @@ -278,37 +277,54 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
)} - {/* Persistent footer bar (3a): CTAs sit footer-right at one size on - every viewport. Sibling of the headline so taps never toggle - expand. */} + {/* Persistent footer bar: context link left (4a), CTAs right at one + size (3a). Sibling of the headline so taps never toggle expand. */} {showActionBar && (
- {(showDecision || showExpandVerb) && ( - onToggleExpand(item)} - /> + {href ? ( + e.stopPropagation()} + > + {meta.contextLabel} + → + + ) : ( + )} - {showOpen && ( - - )} + {showCtas && ( +
+ {(showDecision || showExpandVerb) && ( + onToggleExpand(item)} + /> + )} - {showRestore && ( - + {showOpen && ( + + )} + + {showRestore && ( + + )} +
)}
)} diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index 406c782b6fc..f9dbfc337b4 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -124,6 +124,8 @@ describe("sourceMeta", () => { for (const kind of kinds) { expect(sourceMeta(kind).label.length).toBeGreaterThan(0); expect(sourceMeta(kind).icon).toBeTruthy(); + // 4a footer-left context link: every kind names its destination. + expect(sourceMeta(kind).contextLabel).toMatch(/^View /); } }); diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index ca64d043711..21b72ee18a1 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -41,23 +41,29 @@ export function isInlineResolvable(item: AttentionItem): boolean { interface SourceMeta { label: string; icon: LucideIcon; + /** + * Footer-left context-link label (plan 4a), named for where `subject.href` + * actually lands (issue thread, run page, /costs…) so the link never + * over-promises relative to its destination. + */ + contextLabel: string; } const SOURCE_META: Record = { - approval: { label: "Approval", icon: ShieldCheck }, - issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote }, - join_request: { label: "Join request", icon: UserPlus }, - recovery_action: { label: "Recovery", icon: LifeBuoy }, - productivity_review: { label: "Productivity review", icon: Zap }, - blocker_attention: { label: "Blocked dependency", icon: Ban }, - review: { label: "Review", icon: Eye }, - failed_run: { label: "Failed run", icon: RefreshCw }, - budget_alert: { label: "Budget", icon: DollarSign }, - agent_error_alert: { label: "Agent error", icon: AlertTriangle }, + approval: { label: "Approval", icon: ShieldCheck, contextLabel: "View request" }, + issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote, contextLabel: "View thread" }, + join_request: { label: "Join request", icon: UserPlus, contextLabel: "View members" }, + recovery_action: { label: "Recovery", icon: LifeBuoy, contextLabel: "View issue" }, + productivity_review: { label: "Productivity review", icon: Zap, contextLabel: "View issue" }, + blocker_attention: { label: "Blocked dependency", icon: Ban, contextLabel: "View issue" }, + review: { label: "Review", icon: Eye, contextLabel: "View issue" }, + failed_run: { label: "Failed run", icon: RefreshCw, contextLabel: "View run" }, + budget_alert: { label: "Budget", icon: DollarSign, contextLabel: "View budget" }, + agent_error_alert: { label: "Agent error", icon: AlertTriangle, contextLabel: "View agent" }, }; export function sourceMeta(kind: AttentionSourceKind): SourceMeta { - return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle }; + return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle, contextLabel: "View source" }; } /**