Files
PaperClipAI/ui/src/components/AttentionQueueRow.tsx
T
DottaandPaperclip 8e7f1c03eb feat(decisions): improve desk triage and queue parity (#10785)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The decisions desk and queue help operators find work that needs a
human decision.
> - The current views use different grouping, sorting, and labels.
> - Repeated confirmation requests can also leave stale pending actions
in the queue.
> - Blocked-work attention can point at an intermediate issue instead of
the terminal blocker.
> - This pull request aligns the server contract and both user
interfaces.
> - The benefit is a smaller, clearer queue that ranks the decisions
with the largest impact.

## Linked Issues or Issue Description

Related PR: #10774

**What existing behavior does this improve?**

The decisions desk and queue currently use different triage rules. They
can show stale repeated confirmations and can rank blocked work by an
intermediate issue.

**Subsystem affected**

This change affects attention aggregation, issue thread interactions,
shared attention contracts, and the decisions user interface.

**Current behavior**

The desk uses a can-wait group that has no clear arrival meaning. The
queue has fewer controls than the desk. Repeated pending confirmations
remain actionable. Blocked-work rows do not always identify the terminal
actionable blocker.

**Proposed behavior**

Group desk items by arrival date, and reserve Decide now for explicit
due dates. Use one toolbar and shelf model on both pages. Supersede
older repeated pending confirmations. Aggregate blocked work under the
terminal actionable blocker and rank it by impact.

**Reason and benefit**

Operators get one consistent triage model. The badge reflects new and
overdue work. High-impact blockers move to the top. Duplicate
confirmation work no longer consumes attention.

**Breaking changes**

The attention summary field `decideNowCount` changes to
`deskBadgeCount`. Consumers must use the new field. Older repeated
confirmation interactions can now finish with the
`superseded_by_newer_request` outcome.

## What Changed

- Supersede older pending confirmation requests for the same issue and
record the mutation in activity history.
- Resolve blocked-work attention to actionable terminal blockers,
suppress live blocker trees, and rank rows by blocked-work impact.
- Group the decisions desk into New today and Earlier, and count new
plus overdue work in the desk badge.
- Share the decision toolbar and shelf components across the desk and
queue.
- Add queue grouping, sorting, filtering, aging, visible training
controls, and clearer recommendation copy.
- Add server, shared-contract, and user-interface tests for the new
behavior.

## Verification

- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run` (all
server, UI, CLI, shared, and catalog tests passed; one fixed five-second
DB timeout flaked under full-suite load)
- `NODE_ENV=test pnpm --filter @paperclipai/db exec vitest run
src/status-card-migrations.test.ts` (passed in isolation)
- `pnpm build`

## Risks

- The attention summary field rename requires synchronized consumers.
- Terminal-blocker traversal uses cycle and depth guards. A malformed
dependency graph can stop at the last safe node.
- The new arrival grouping changes which items contribute to the
decisions badge.
- Superseding repeated confirmations changes the terminal state of older
pending interactions.

> 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 with GPT-5. The runtime did not expose a dated model
snapshot or context-window size. The model used reasoning, repository
tools, code execution, and test execution.

## 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>
2026-08-03 21:31:45 -05:00

928 lines
36 KiB
TypeScript

import { memo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlarmClock,
CalendarClock,
ChevronDown,
ChevronUp,
ExternalLink,
GraduationCap,
Loader2,
MoreHorizontal,
RotateCcw,
X,
} from "lucide-react";
import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { accessApi } from "../api/access";
import { approvalsApi } from "../api/approvals";
import { issuesApi } from "../api/issues";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import {
attentionDetailImages,
attentionDetailLine,
attentionImageUrl,
attentionStatus,
attentionTaskRef,
decideByLabel,
isInlineResolvable,
sourceMeta,
} from "../lib/attention";
import { isTrainable } from "../lib/decisionTraining";
import { cn, relativeTime } from "../lib/utils";
import { DecisionTriageStrip } from "./DecisionTriageStrip";
import { StatusGlyph } from "./StatusGlyph";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
import { Textarea } from "./ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
import { DecisionResolver } from "./DecisionResolver";
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();
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
return d.toISOString();
}
/** Snooze presets, resolved to a future ISO timestamp at click time. */
const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [
{ label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() },
{ label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() },
{ label: "Tomorrow morning", resolve: tomorrowMorningIso },
{ label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
];
interface AttentionQueueRowProps {
item: AttentionItem;
companyId: string;
expanded: boolean;
/** Receives the row's item so the parent can pass one stable callback for every row. */
onToggleExpand: (item: AttentionItem) => void;
onDismiss: (item: AttentionItem) => void;
onSnooze?: (item: AttentionItem, snoozedUntil: string) => void;
/** Open the decision-training drawer for this row (create or view). */
onTrain?: (item: AttentionItem) => void;
/** Restore a snoozed/dismissed row (curtain variant only). */
onRestore?: (item: AttentionItem) => void;
/** "active" renders the live queue row; "hidden" renders a curtain row. */
variant?: "active" | "hidden";
agentMap?: Map<string, Agent>;
/** Company agents, for the triage strip's route-to-agent picker. */
agents?: Agent[];
/** Render the per-card triage strip (queue/decide-by/snooze/route) when expanded. */
showTriage?: boolean;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
selected?: boolean;
}
/**
* Memoized (PAP-13784): the queue renders every feed row in one flat list, so
* without memo a single keyboard-selection or expand toggle re-renders every
* row (each carrying a Radix dropdown + mutation). All props are stable or
* primitive; `item` identity is preserved across refetches by react-query's
* structural sharing.
*/
export const AttentionQueueRow = memo(function AttentionQueueRow({
item,
companyId,
expanded,
onToggleExpand,
onDismiss,
onSnooze,
onTrain,
onRestore,
variant = "active",
agentMap,
agents,
showTriage = false,
currentUserId,
userLabelMap,
selected = false,
}: AttentionQueueRowProps) {
const meta = sourceMeta(item.sourceKind);
// Colour + glyph are borrowed wholesale from the task status system, so a
// blocking decision reads exactly like a blocked task (DESIGN.md principle 5).
const status = attentionStatus(item);
// The task this row belongs to, whichever field the feed put it in.
const taskRef = attentionTaskRef(item);
const isHidden = variant === "hidden";
const inline = !isHidden && isInlineResolvable(item);
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);
const hasImages = images.length > 0;
// The issue (or source) this row points at — used as the target for the
// "n more" affordance in the expanded gallery.
const issueHref = item.relatedIssue?.href ?? href;
// Inline-resolvable active rows expand to reveal their resolver; rows with
// images expand to reveal a larger gallery (PAP-13544); triage-enabled rows
// expand to reveal the per-card triage strip (PAP-16032 §4.5). Any of these
// gives a header/thumbnail click somewhere to go. Non-inline, image-less rows
// with no triage keep the explicit Open button and never toggle on a stray click.
const triageEnabled = showTriage && !isHidden;
const expandable = inline || (!isHidden && hasImages) || triageEnabled;
// Any issue-anchored approval or interaction is
// trainable at any time (pending or resolved). Trained/untrained renders
// purely from the feed's `trainingExampleId` — no per-row fetch.
const trainable = !isHidden && !!onTrain && isTrainable(item);
const trained = item.trainingExampleId != null;
const activate = () => {
if (expandable) onToggleExpand(item);
};
const onHeaderKeyDown = (e: KeyboardEvent) => {
if (!expandable) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleExpand(item);
}
};
// 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 showOpen = !inline && !!href;
const showRestore = isHidden && !!onRestore;
// An expanded inline row hands its footer to the resolver, which owns the
// decision verbs — so the toggle rides alongside them on one row rather than
// stranding a lone "See less" under the buttons. That makes the collapsed
// footer a swap rather than a survivor, so it crossfades with the panel.
const hasCollapsedOnlyContent = hasImages || inline;
// Disclosure control. Now the row's only expand affordance: it names what it
// does instead of leaving a bare chevron to be decoded, and it sits at the
// bottom-left where the eye lands after reading the row.
const toggle = expandable ? (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-md text-xs font-medium 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 ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{expanded ? "See less" : "See more"}
</button>
) : null;
/**
* The row's action bar: disclosure on the left, decision verbs on the right.
* Rendered either inside the collapsed-only cluster (inline rows, where the
* resolver takes it over once expanded) or as a standing sibling (everything
* else). `compact` is false for the standing copy so an expanded row does not
* show collapsed verbs beside the panel's own.
*/
const renderFooter = ({ compact }: { compact: boolean }) => {
const showCompact = compactActions.length > 0 && (compact || !expanded);
if (!toggle && !showCompact && !showOpen && !showRestore) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
{toggle ?? <span />}
<div className="flex flex-wrap items-center gap-2 @xl:justify-end">
{showCompact && (
<CompactDecisionActions item={item} companyId={companyId} onOpen={() => onToggleExpand(item)} />
)}
{showOpen && (
<Button asChild variant="default" size="xs" className={ACTION_BTN}>
<Link to={href!}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)}
{showRestore && (
<Button type="button" variant="outline" size="xs" className={ACTION_BTN} onClick={() => onRestore(item)}>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
)}
</div>
</div>
);
};
return (
<div
className={cn(
"@container relative flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card px-4 pt-3 pb-4",
// 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.
"[content-visibility:auto] [contain-intrinsic-size:auto_104px]",
"motion-safe:transition-[opacity,transform,border-color,background-color] motion-safe:duration-200 motion-safe:ease-out hover:border-border/80",
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
selected && "border-ring ring-1 ring-ring",
)}
id={`attention-row-${item.id}`}
data-attention-row
data-attention-row-id={item.id}
data-attention-source={item.sourceKind}
data-attention-severity={item.severity}
>
{/* Meta band: one breadcrumb of identity on the left (kind → task →
project), 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-1">
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
<StatusGlyph status={status} size="md" />
{meta.label}
</span>
{taskRef && (
<>
<EyebrowSeparator />
<Link
to={taskRef.href ?? "#"}
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{taskRef.identifier}
</Link>
</>
)}
{item.decideBy && (
<>
<EyebrowSeparator />
<span
className="inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground"
data-attention-decide-by={item.decideBy}
title={decideByProvenance(item) ? `Set by ${decideByProvenance(item)}` : undefined}
>
<CalendarClock className="h-3 w-3" />
{decideByLabel(item.decideBy)}
{decideByProvenance(item) && (
<span className="text-muted-foreground/80">· set by {decideByProvenance(item)}</span>
)}
</span>
</>
)}
{trainable && trained && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-1.5 py-px text-(length:--text-nano) font-medium text-primary hover:bg-primary/15"
onClick={(event) => {
event.stopPropagation();
onTrain?.(item);
}}
data-testid="attention-trained-badge"
>
<GraduationCap className="h-3 w-3 fill-primary/25" />
Trained ✓
</button>
)}
{/* Visible train affordance for untrained rows. Trained
rows already carry the "Trained ✓" badge above; both surfaces also
keep the overflow "Train this decision" entry. Sits in the same slot
as the badge so a row's training state reads from one place. */}
{trainable && !trained && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-px text-(length:--text-nano) font-medium text-muted-foreground hover:border-primary/40 hover:text-primary"
onClick={(event) => {
event.stopPropagation();
onTrain?.(item);
}}
data-testid="attention-train-inline"
>
<GraduationCap className="h-3 w-3" />
Train
</button>
)}
</div>
<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">
{/* Training moved off the header strip (which now carries only
recency + overflow) but keeps its testids so the affordance
is still addressable. */}
{trainable && (
<DropdownMenuItem
data-training-state={trained ? "trained" : "untrained"}
data-testid="attention-train-button"
onClick={() => onTrain?.(item)}
>
<GraduationCap className={cn("h-4 w-4", trained && "fill-primary/25")} />
{trained ? "View training example" : "Train this decision"}
</DropdownMenuItem>
)}
{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>
{/* Headline — the primary expand target for inline rows. Title 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>
{/* Collapsed-only content. It has no counterpart to morph into — the
thumbnail strip becomes a full gallery, and an inline row's footer is
replaced by the resolver's own — so it rides an inverse disclosure and
crossfades against the panel below: this shrinks and fades out on the
same tokens as that grows and fades in, instead of popping. */}
{hasCollapsedOnlyContent && (
<Collapsible open={!expanded} className="contents">
<CollapsibleContent data-decision-disclosure className="-mt-4">
<div className="flex flex-col gap-4 pt-4">
{hasImages && <ThumbnailStack images={images} />}
{inline && renderFooter({ compact: true })}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* The disclosure panel. Collapsible measures the panel and publishes its
height, so the card grows and shrinks to a real number rather than
snapping open. `contents` keeps the Root out of the layout, so a
collapsed row does not pay a flex gap for an empty wrapper — and once
the exit finishes Radix unmounts the panel, so a collapsed row is not
left with a live resolver behind it. */}
<Collapsible open={expanded} onOpenChange={() => onToggleExpand(item)} className="contents">
<CollapsibleContent data-decision-disclosure className="-mt-4">
<div className="flex flex-col gap-4 pt-4">
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
{triageEnabled && <DecisionTriageStrip item={item} companyId={companyId} agents={agents} />}
{inline && (
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
toggle={toggle}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* A non-inline row keeps one footer across both states — its Open or
Restore button and its toggle are the same control either way, so it
stays put rather than crossfading with itself. */}
{!inline && renderFooter({ compact: false })}
</div>
);
});
/**
* "·" between eyebrow segments.
*
* The eyebrow is a flat list of two facts (decision kind, task key), not a
* hierarchy, so a middle dot reads more honestly than the "/" this started as —
* a slash implies containment that the two segments do not have.
*/
function EyebrowSeparator() {
return (
<span className="text-xs text-muted-foreground" aria-hidden>
·
</span>
);
}
type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision";
function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null {
if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) {
return verbId;
}
if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) {
return verbId;
}
if (
item.sourceKind === "issue_thread_interaction"
&& item.subject.metadata?.kind === "request_confirmation"
&& (verbId === "accept" || verbId === "reject")
) {
return verbId;
}
return null;
}
/**
* Weight used to order a decision's verbs. The affirmative verb always lands
* rightmost — the same place in every row, collapsed or expanded — so the
* operator's aim never has to move with the verb list.
*/
const VERB_ORDER: Record<"outline" | "destructive" | "default", number> = {
outline: 0,
destructive: 1,
default: 2,
};
interface CompactAction {
action: CompactDecisionAction;
label: string;
id: string;
description: string;
}
/** The compact accept/reject verbs a collapsed row can resolve in place. */
function collectCompactActions(item: AttentionItem): CompactAction[] {
return item.decisionVerbs
.slice(0, 3)
.flatMap((verb) => {
const action = compactDecisionAction(item, verb.id);
return action ? [{ action, label: verb.label, id: verb.id, description: verb.description ?? "" }] : [];
})
.sort((a, b) => VERB_ORDER[decisionVerbVariant(a)] - VERB_ORDER[decisionVerbVariant(b)]);
}
function CompactDecisionActions({
item,
companyId,
onOpen,
}: {
item: AttentionItem;
companyId: string;
onOpen: () => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const actions = collectCompactActions(item);
const decision = useMutation<unknown, Error, CompactDecisionAction>({
mutationFn: (action: CompactDecisionAction) => {
if (item.sourceKind === "approval") {
if (action === "approve") return approvalsApi.approve(item.subject.id);
if (action === "reject") return approvalsApi.reject(item.subject.id);
return approvalsApi.requestRevision(item.subject.id);
}
if (item.sourceKind === "join_request") {
return action === "approve"
? accessApi.approveJoinRequest(companyId, item.subject.id)
: accessApi.rejectJoinRequest(companyId, item.subject.id);
}
if (item.sourceKind === "issue_thread_interaction") {
const issueId = item.subject.metadata?.issueId;
if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision.");
if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id);
return issuesApi.rejectInteraction(issueId, item.subject.id);
}
throw new Error("This decision must be completed from its detail view.");
},
onSuccess: (_result, action) => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
if (item.sourceKind === "approval") {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
} else {
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
}
pushToast({
title: compactDecisionSuccessLabel(item.sourceKind, action),
tone: "success",
});
},
onError: (error, action) => {
pushToast({
title: `Could not ${decisionLabel(action)}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
if (actions.length === 0) return null;
return (
<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, description }) => (
<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();
if (item.sourceKind === "issue_thread_interaction" && action === "reject") {
onOpen();
return;
}
decision.mutate(action);
}}
>
{decision.isPending && decision.variables === action && <Loader2 className="h-3 w-3 animate-spin" />}
{label}
</Button>
))}
</div>
);
}
function decisionLabel(action: CompactDecisionAction): string {
if (action === "request_revision") return "sent for revision";
if (action === "accept" || action === "approve") return "approved";
return "rejected";
}
function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string {
if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`;
if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`;
return action === "accept" ? "Confirmation accepted" : "Confirmation declined";
}
function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" {
const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase();
if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive";
if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default";
return "outline";
}
/** 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, index) => (
<img
key={`${img.assetId}-${index}`}
src={attentionImageUrl(img.assetId)}
alt={img.alt ?? ""}
loading="lazy"
style={{ zIndex: visible.length - index }}
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>
);
}
/**
* Larger image gallery shown when a row is expanded (PAP-13544). Shows the
* first three screenshots at a readable size; if more exist, an "n more" tile
* links through to the issue where the full set lives.
*/
function ExpandedImages({ images, issueHref }: { images: AttentionDetailImage[]; issueHref: string | null }) {
const visible = images.slice(0, 3);
const extra = images.length - visible.length;
return (
<div className="flex flex-wrap items-stretch gap-2" data-attention-expanded-images="true">
{visible.map((img, index) => {
const src = attentionImageUrl(img.assetId);
const key = `${img.assetId}-${index}`;
const image = (
<img
src={src}
alt={img.alt ?? ""}
loading="lazy"
className="h-32 w-44 rounded-md border border-border bg-muted object-cover shadow-sm"
/>
);
return issueHref ? (
<Link
key={key}
to={issueHref}
// No task quicklook on evidence. `Link` upgrades any /issues/ href
// into a hover preview, which here pops a text card over the very
// screenshot being examined — and because expanding a row mounts
// this gallery directly under a stationary pointer, the preview
// opens unbidden and can outlive the pointer that never entered it.
disableIssueQuicklook
className="block rounded-md focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
onClick={(e) => e.stopPropagation()}
>
{image}
</Link>
) : (
<span key={key} className="block">
{image}
</span>
);
})}
{extra > 0 && (issueHref ? (
<Link
to={issueHref}
// Same gallery, same pointer trap — see the thumbnail note above.
disableIssueQuicklook
onClick={(e) => e.stopPropagation()}
className="flex h-32 w-24 flex-col items-center justify-center rounded-md border border-dashed border-border bg-muted/40 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
>
<span className="text-base font-semibold">{extra} more</span>
<span className="mt-0.5 inline-flex items-center gap-1 text-(length:--text-nano)">
View issue
<ExternalLink className="h-3 w-3" />
</span>
</Link>
) : (
<span className="flex h-32 w-24 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 text-sm font-semibold text-muted-foreground">
{extra} more
</span>
))}
</div>
);
}
/** Snooze submenu: presets + a custom date-time (plan §6). */
function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) {
const [customValue, setCustomValue] = useState("");
const applyCustom = () => {
if (!customValue) return;
const ts = new Date(customValue);
if (Number.isNaN(ts.getTime())) return;
onSnooze(ts.toISOString());
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlarmClock className="h-4 w-4" />
Snooze
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SNOOZE_PRESETS.map((preset) => (
<DropdownMenuItem key={preset.label} onClick={() => onSnooze(preset.resolve())}>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* Custom picker: a non-menu-item region so interacting with the input
doesn't close the menu (guard keydown/select against Radix typeahead). */}
<div
className="flex flex-col gap-1.5 px-2 py-1.5"
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<span className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Custom
</span>
<input
type="datetime-local"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
/>
<Button type="button" size="xs" disabled={!customValue} onClick={applyCustom}>
Snooze until…
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/**
* Who set this row's decide-by deadline, for the card's provenance line
* ("· set by Prioritizer"). Returns null when unattributed so the chip shows
* the deadline alone rather than a hollow "set by".
*/
function decideByProvenance(item: AttentionItem): string | null {
const attribution = item.decideByAttribution;
if (!attribution) return null;
if (attribution.type === "agent") return attribution.agentName ?? "an agent";
return "you";
}
/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */
function reappearLabel(snoozedUntil: string): string {
const diffMs = new Date(snoozedUntil).getTime() - Date.now();
if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 60) return `in ${diffMin}m`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `in ${diffHr}h`;
const diffDay = Math.round(diffHr / 24);
return `in ${diffDay}d`;
}
/**
* Expanded-row content. Resolvers that own their decision verbs also render the
* row's footer, so the disclosure toggle (`toggle`) sits on the same line as the
* buttons. The issue-thread interaction card keeps its verbs internally — it is
* shared with the issue thread surface — so there the toggle gets its own row.
*/
function InlineResolver({
item,
companyId,
agentMap,
currentUserId,
userLabelMap,
toggle,
}: {
item: AttentionItem;
companyId: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
toggle: ReactNode;
}) {
if (item.sourceKind === "decision") {
return (
<DecisionResolver
companyId={companyId}
decisionId={item.subject.id}
originIssue={item.relatedIssue}
agentMap={agentMap}
/>
);
}
if (item.sourceKind === "issue_thread_interaction") {
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
if (!issueId) {
return <p className="text-xs text-muted-foreground">Missing issue reference for this decision.</p>;
}
return (
<>
<AttentionInteractionResolver
companyId={companyId}
issueId={issueId}
interactionId={item.subject.id}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
{toggle && <div className="flex items-center">{toggle}</div>}
</>
);
}
if (item.sourceKind === "approval") {
return <ApprovalResolver item={item} companyId={companyId} toggle={toggle} />;
}
if (item.sourceKind === "join_request") {
return <JoinRequestResolver item={item} companyId={companyId} toggle={toggle} />;
}
return null;
}
/** Footer shared by the resolvers that own their verbs: toggle left, verbs right. */
function ResolverFooter({ toggle, children }: { toggle: ReactNode; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
{toggle ?? <span />}
<div className="flex flex-wrap items-center gap-2">{children}</div>
</div>
);
}
function ApprovalResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
const queryClient = useQueryClient();
const [note, setNote] = useState("");
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
};
const approve = useMutation({
mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined),
onSuccess: invalidate,
});
const reject = useMutation({
mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined),
onSuccess: invalidate,
});
const revise = useMutation({
mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined),
onSuccess: invalidate,
});
const pending = approve.isPending || reject.isPending || revise.isPending;
// Verb order matches the collapsed row exactly (revise → reject → approve),
// so expanding never moves the button the operator was already aiming at.
return (
<>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional decision note…"
className="min-h-16 text-sm"
/>
<ResolverFooter toggle={toggle}>
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Request revision
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
</ResolverFooter>
</>
);
}
function JoinRequestResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
};
const approve = useMutation({
mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const reject = useMutation({
mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const pending = approve.isPending || reject.isPending;
return (
<ResolverFooter toggle={toggle}>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
</ResolverFooter>
);
}