mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and supervise governed work. > - Decisions capture high-value operator judgment, and the decision-training foundation merged in #9702 freezes that evidence for later evaluation and learning. > - Operators still need the UI from the closed stacked PR #9718 to intentionally capture examples and inspect the resulting dataset. > - GitHub automatically closed #9718 when its stacked base branch was deleted after #9702 merged, leaving the server foundation on `master` without the corresponding UI. > - This pull request restores the final UI and its still-required supporting API fields directly on current `master`, while excluding the obsolete migration and duplicated server-foundation diffs. > - The benefit is a reviewable replacement PR that preserves the completed decision-training workflow without replaying stale stack history. ## Linked Issues or Issue Description - Refs #9718 - Refs #9702 ## What Changed - Restored the top-level `/training` library and record inspector with search, filters, JSONL export, notes editing, and evidence tabs. - Restored the Decisions-row training affordance and capture drawer, including preview, provenance, deletion, cache refresh, and approval consistency behavior. - Restored the shared types and focused server support needed by the UI without reintroducing decision-training migrations or the already-merged server foundation. - Restored focused UI and attention-service tests from the final #9718 state. - Credit to the authors and reviewers of #9718; this recovery transplants their final reviewed delta after the stacked base deletion. ## Verification - `pnpm exec vitest run ui/src/pages/Training.test.tsx ui/src/components/DecisionTrainingDrawer.test.tsx ui/src/components/AttentionQueueRow.test.tsx server/src/__tests__/attention-service.test.ts server/src/__tests__/decision-training.test.ts` — 5 files, 48 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` — 3 tests passed; confirms exact route/OpenAPI parity for the restored preview endpoint. - `pnpm check:token-gates` — the restored files are clean; the repository-wide command currently reports five pre-existing false positives where comments reference GitHub issue `#9627` as if it were a color literal. ## Risks - Low migration risk: this PR contains no database migrations and is based directly on current `master`. - The main behavioral risk is cache invalidation across Decisions and Training views; focused tests cover capture, update, deletion, row state, and approval refresh behavior. - The token-gate baseline remains red on unrelated `#9627` comment references; this PR does not modify those files. > 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, GPT-5.3-Codex, reasoning with repository/tool access and code execution. Context window not exposed by the runtime. ## 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>
802 lines
31 KiB
TypeScript
802 lines
31 KiB
TypeScript
import { memo, useState, type KeyboardEvent } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AlarmClock,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
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,
|
|
attentionToneStyle,
|
|
isInlineResolvable,
|
|
severityBadge,
|
|
sourceMeta,
|
|
} from "../lib/attention";
|
|
import { isTrainable } from "../lib/decisionTraining";
|
|
import { cn, relativeTime } from "../lib/utils";
|
|
import { Button } from "./ui/button";
|
|
import { Textarea } from "./ui/textarea";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "./ui/dropdown-menu";
|
|
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
|
import { ProjectTile } from "./ProjectTile";
|
|
|
|
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>;
|
|
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,
|
|
currentUserId,
|
|
userLabelMap,
|
|
selected = false,
|
|
}: AttentionQueueRowProps) {
|
|
const meta = sourceMeta(item.sourceKind);
|
|
const tone = attentionToneStyle(item);
|
|
const sevBadge = severityBadge(item.severity);
|
|
const Icon = meta.icon;
|
|
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). Either case gives a
|
|
// header/thumbnail click somewhere to go. Non-inline, image-less rows keep the
|
|
// explicit Open button and never toggle on a stray click.
|
|
const expandable = inline || (!isHidden && hasImages);
|
|
// 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 showCompact = !expanded && compactActions.length > 0;
|
|
const showOpen = !inline && !!href;
|
|
const showRestore = isHidden && !!onRestore;
|
|
const showActionBar = showCompact || showOpen || showRestore;
|
|
// Left gutter width (chevron + gap) so the stacked content aligns under the
|
|
// headline in the wide layout; when narrow, everything runs full-bleed.
|
|
const gutterIndent = "@xl:pl-6";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"@container relative flex flex-col overflow-hidden border border-border bg-card",
|
|
// 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}
|
|
>
|
|
{/* Type accent bar (canonical color map — never severity). */}
|
|
<span className={cn("absolute inset-y-0 left-0 w-1", tone.accent)} aria-hidden />
|
|
|
|
<div className="flex items-start gap-2 py-3 pl-4 pr-3">
|
|
{/* Expand affordance / spacer gutter — keeps headlines aligned across the list. */}
|
|
{expandable ? (
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 shrink-0 rounded-sm p-0.5 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 ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
) : (
|
|
<span className="mt-0.5 hidden h-4 w-4 shrink-0 @xl:block" aria-hidden />
|
|
)}
|
|
|
|
{/* Content column: a single vertical stack that fills the full width on
|
|
mobile (no competing right-hand controls) and reads top-to-bottom. */}
|
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
{/* Meta band: identity on the left, 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-x-2 gap-y-1">
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
|
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
|
|
{meta.label}
|
|
</span>
|
|
{sevBadge && (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
|
|
sevBadge.className,
|
|
)}
|
|
>
|
|
{sevBadge.label}
|
|
</span>
|
|
)}
|
|
{item.relatedIssue?.identifier && (
|
|
<Link
|
|
to={item.relatedIssue.href ?? "#"}
|
|
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{item.relatedIssue.identifier}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
|
|
{trainable && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className={cn(trained ? "text-primary" : "text-muted-foreground")}
|
|
aria-label={trained ? "View training example" : "Train this decision"}
|
|
aria-pressed={trained}
|
|
title={trained ? "Trained — view example" : "Train this decision"}
|
|
data-training-state={trained ? "trained" : "untrained"}
|
|
data-testid="attention-train-button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onTrain?.(item);
|
|
}}
|
|
>
|
|
<GraduationCap className={cn("h-4 w-4", trained && "fill-primary/25")} />
|
|
</Button>
|
|
)}
|
|
{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">
|
|
{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 now 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>
|
|
|
|
{/* Context row: project identity and evidence thumbnails move below the
|
|
text so they never squeeze the headline on mobile. */}
|
|
{(item.project || (hasImages && !expanded) || (trainable && trained)) && (
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
{item.project && <ProjectMeta project={item.project} />}
|
|
{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>
|
|
)}
|
|
{hasImages && !expanded && <ThumbnailStack images={images} />}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action bar: full-width, thumb-reachable buttons on mobile;
|
|
right-aligned dense pills on desktop. Sibling of the headline so
|
|
taps never toggle expand. */}
|
|
{showActionBar && (
|
|
<div
|
|
className={cn("flex flex-wrap items-center gap-2 @xl:justify-end", gutterIndent)}
|
|
data-attention-actions="true"
|
|
>
|
|
{showCompact && (
|
|
<CompactDecisionActions
|
|
item={item}
|
|
companyId={companyId}
|
|
onOpen={() => onToggleExpand(item)}
|
|
/>
|
|
)}
|
|
|
|
{showOpen && (
|
|
<Button asChild variant="outline" size="xs" className={cn(ACTION_BTN, "w-full @xl:w-auto")}>
|
|
<Link to={href!}>
|
|
Open
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
|
|
{showRestore && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="xs"
|
|
className={cn(ACTION_BTN, "w-full @xl:w-auto")}
|
|
onClick={() => onRestore(item)}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Restore
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{expanded && (hasImages || inline) && (
|
|
<div className="space-y-3 border-t border-border/60 bg-muted/20 px-4 py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200">
|
|
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
|
|
{inline && (
|
|
<InlineResolver
|
|
item={item}
|
|
companyId={companyId}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
userLabelMap={userLabelMap}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
/** The compact accept/reject verbs a collapsed row can resolve in place. */
|
|
function collectCompactActions(item: AttentionItem): Array<{ action: CompactDecisionAction; label: string; id: string }> {
|
|
return item.decisionVerbs.slice(0, 3).flatMap((verb) => {
|
|
const action = compactDecisionAction(item, verb.id);
|
|
return action ? [{ action, label: verb.label, id: verb.id }] : [];
|
|
});
|
|
}
|
|
|
|
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 }) => (
|
|
<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";
|
|
}
|
|
|
|
/** Inline project identity keeps useful context without a competing badge. */
|
|
function ProjectMeta({ project }: { project: NonNullable<AttentionItem["project"]> }) {
|
|
return (
|
|
<span
|
|
className="inline-flex max-w-(--sz-12rem) items-center gap-1.5 text-(length:--text-nano) text-muted-foreground"
|
|
title={project.name}
|
|
data-testid="attention-project-meta"
|
|
>
|
|
<ProjectTile color={project.color} icon={project.icon} size="xs" />
|
|
<span className="truncate">{project.name}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** 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}
|
|
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}
|
|
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>
|
|
);
|
|
}
|
|
|
|
/** 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`;
|
|
}
|
|
|
|
function InlineResolver({
|
|
item,
|
|
companyId,
|
|
agentMap,
|
|
currentUserId,
|
|
userLabelMap,
|
|
}: {
|
|
item: AttentionItem;
|
|
companyId: string;
|
|
agentMap?: Map<string, Agent>;
|
|
currentUserId?: string | null;
|
|
userLabelMap?: ReadonlyMap<string, string> | null;
|
|
}) {
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (item.sourceKind === "approval") {
|
|
return <ApprovalResolver item={item} companyId={companyId} />;
|
|
}
|
|
|
|
if (item.sourceKind === "join_request") {
|
|
return <JoinRequestResolver item={item} companyId={companyId} />;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
|
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;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
placeholder="Optional decision note…"
|
|
className="min-h-16 text-sm"
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
|
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
Approve
|
|
</Button>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
|
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 (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
|
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
Approve
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|