mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(decisions): add desk workflow and retention (#10672)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Decisions desk shows work that needs a human decision > - The queue foundation can group and rank decision work > - Operators also need a focused daily view and a safe way to handle old work > - This pull request adds the desk controls, the aging shelf, and reversible retention > - It also binds bulk archive decisions to the exact reviewed item set > - The benefit is a smaller daily queue without lost or orphaned work ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: server, UI, database, and shared contracts. **Problem or motivation** Decision work can grow into one large company list. Operators need quick queue and date controls. Old items also need a safe retention path that does not delete work. **Proposed solution** Add queue and date controls to the Decisions desk. Compute the aging shelf on the server. Archive idle items after 90 days unless an operator keeps them. Keep archived items searchable and revivable. Notify origin agents in one batch per sweep. Bind bulk archive proposals to a signed, exact item manifest. **Alternatives considered** Client-only aging can drift across browsers and source kinds. Deleting old rows removes audit and recovery paths. An unsigned dynamic bulk query can archive items that the reviewer did not inspect. **Roadmap alignment** This work supports the Work Queues and decision-memory directions in `ROADMAP.md`. It extends the Decisions and attention-feed foundation from #10651. Related earlier work includes #9380, #10010, and #10474. ## What Changed - Added the queue rail, date chips, decide split, triage strip, queue page, and aging shelf UI. - Added server-owned shelf state with per-queue retention overrides. - Added reversible retention state, archive history, and an idempotent notification outbox. - Added the 90-day archive sweeper, Keep exemption, archived feed query, and revive actions. - Added one origin-agent notification per agent and sweep. - Added signed bulk archive proposals with exact-set and version checks. - Persisted queue-exclusion reasons atomically and kept cross-domain source resolution per-item until it has an exact-set transaction contract. - Added API contracts, OpenAPI entries, migration coverage, focused tests, and Storybook screens. ## Verification - `pnpm -r typecheck` - `pnpm test:run` (server: 329 files and 3,453 tests passed; UI: 408 files and 3,362 tests passed) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts` - `pnpm build` - `pnpm check:token-gates` - Focused retention, attention, decisions, migration replay, startup, and UI API tests. - Complete queue snapshot regression with 51 items across the normal 50-item page boundary. The unmodified CLI test reports one warning assertion in this runtime because the harness injects static AWS credential variables. The isolated test passes when those two variables are removed. ## Risks - The migration adds retention and notification outbox tables. It uses idempotent table, index, and foreign-key creation. - Retention runs on the heartbeat scheduler interval. Compare-and-set version checks prevent stale archive writes. - Bulk archive acceptance fails closed when authority, activity, version, or the reviewed set changes. - Archive is reversible and does not delete source records. > 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.6-sol`. The model used tool calls, code execution, database migration generation, and test execution. The context-window size is not exposed in this 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: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
46 files changed
+41814
-71
No files matched your search
@@ -117,6 +117,11 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
ruleKey: overrides.ruleKey ?? null,
|
||||
originAgentName: overrides.originAgentName ?? null,
|
||||
queues: overrides.queues ?? [],
|
||||
shelf: overrides.shelf ?? false,
|
||||
retentionDays: overrides.retentionDays ?? 30,
|
||||
keep: overrides.keep ?? false,
|
||||
archivedAt: overrides.archivedAt ?? null,
|
||||
retentionVersion: overrides.retentionVersion ?? 1,
|
||||
decideBy: overrides.decideBy ?? null,
|
||||
decideByAttribution: overrides.decideByAttribution ?? null,
|
||||
snoozedUntil: overrides.snoozedUntil ?? null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
@@ -24,11 +25,13 @@ import {
|
||||
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";
|
||||
@@ -86,6 +89,10 @@ interface AttentionQueueRowProps {
|
||||
/** "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;
|
||||
@@ -109,6 +116,8 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
onRestore,
|
||||
variant = "active",
|
||||
agentMap,
|
||||
agents,
|
||||
showTriage = false,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
selected = false,
|
||||
@@ -130,10 +139,12 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
// "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);
|
||||
// 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.
|
||||
@@ -256,6 +267,22 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
</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"
|
||||
@@ -378,6 +405,7 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
<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}
|
||||
@@ -704,6 +732,18 @@ function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
import {
|
||||
ATTENTION_DATE_RANGE_OPTIONS,
|
||||
type AttentionDateRangeId,
|
||||
} from "../lib/attention";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
export interface AttentionCustomRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
interface DecisionDateChipsProps {
|
||||
value: AttentionDateRangeId;
|
||||
custom: AttentionCustomRange;
|
||||
onChange: (value: AttentionDateRangeId, custom: AttentionCustomRange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-range chips (PAP-16032 §4.2 / wireframe screen 1, annotation 2). Filter
|
||||
* the desk on activity date; the resolved bounds go to the server
|
||||
* (`activitySince`/`activityUntil`) so the page never ships the whole feed to
|
||||
* filter on the client. "Custom" opens a from/to range picker.
|
||||
*/
|
||||
export function DecisionDateChips({ value, custom, onChange }: DecisionDateChipsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-decision-date-chips>
|
||||
{ATTENTION_DATE_RANGE_OPTIONS.map(([id, label]) => (
|
||||
<ChipButton key={id} active={value === id} onClick={() => onChange(id, custom)}>
|
||||
{label}
|
||||
</ChipButton>
|
||||
))}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<ChipButton active={value === "custom"} asChild>
|
||||
<button type="button">
|
||||
<CalendarRange className="h-3.5 w-3.5" />
|
||||
{value === "custom" && (custom.from || custom.to)
|
||||
? `${custom.from ?? "…"} → ${custom.to ?? "…"}`
|
||||
: "Custom"}
|
||||
</button>
|
||||
</ChipButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto space-y-2 p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={custom.from ?? ""}
|
||||
max={custom.to ?? undefined}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => onChange("custom", { ...custom, from: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={custom.to ?? ""}
|
||||
min={custom.from ?? undefined}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => onChange("custom", { ...custom, to: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onChange("all", { from: null, to: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipButton({
|
||||
active,
|
||||
onClick,
|
||||
asChild,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick?: () => void;
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const className = cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
);
|
||||
if (asChild) {
|
||||
// The child is a real <button>; merge the chip class straight onto it so the
|
||||
// flex layout (icon + label inline) lands on the button, not a wrapper span.
|
||||
// A bare wrapper span left the button unstyled → its icon stacked above the
|
||||
// label and the chip grew taller than its siblings.
|
||||
if (isValidElement<{ className?: string }>(children)) {
|
||||
return cloneElement(children, {
|
||||
className: cn(className, children.props.className),
|
||||
});
|
||||
}
|
||||
return <span className={className}>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick} aria-pressed={active}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const RECENT_ACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Href for a queue page under the current company prefix, or `/decisions` for All. */
|
||||
export function decisionsHref(queueKey?: string | null): string {
|
||||
return queueKey ? `/decisions/queues/${encodeURIComponent(queueKey)}` : "/decisions";
|
||||
}
|
||||
|
||||
interface DecisionQueueRailProps {
|
||||
companyId: string;
|
||||
/** The active queue key, or null when the desk (All) is active. */
|
||||
activeQueueKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue quicklinks (PAP-16032 §4.1 / wireframe screen 1, annotation 1). The
|
||||
* chips are *this company's queues*, ordered by most-recently-updated (the
|
||||
* server already sorts them that way) — not a hardcoded set of types. "All" is
|
||||
* the only fixed chip. A dot marks a queue with recent activity; the count is
|
||||
* the queue's pending items.
|
||||
*/
|
||||
export function DecisionQueueRail({ companyId, activeQueueKey = null }: DecisionQueueRailProps) {
|
||||
const { data: queues } = useQuery({
|
||||
queryKey: queryKeys.decisionQueues.list(companyId),
|
||||
queryFn: () => decisionQueuesApi.list(companyId),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
// Nothing to show until at least one queue exists — the desk still works
|
||||
// without the rail, so render nothing rather than a lone "All" chip.
|
||||
if (!queues || queues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-1.5" aria-label="Decision queues" data-decision-queue-rail>
|
||||
<Chip href={decisionsHref(null)} active={activeQueueKey == null} label="All" />
|
||||
{queues.map((queue) => {
|
||||
const recent = now - new Date(queue.updatedAt).getTime() < RECENT_ACTIVITY_MS;
|
||||
return (
|
||||
<Chip
|
||||
key={queue.key}
|
||||
href={decisionsHref(queue.key)}
|
||||
active={activeQueueKey === queue.key}
|
||||
label={queue.title}
|
||||
count={queue.itemCount}
|
||||
recent={recent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
href,
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
recent,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
label: string;
|
||||
count?: number;
|
||||
recent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{recent && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-label="Recent activity" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="tabular-nums text-(length:--text-nano) text-muted-foreground">{count}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,11 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
ruleKey: overrides.ruleKey ?? null,
|
||||
originAgentName: overrides.originAgentName ?? null,
|
||||
queues: overrides.queues ?? [],
|
||||
shelf: overrides.shelf ?? false,
|
||||
retentionDays: overrides.retentionDays ?? 30,
|
||||
keep: overrides.keep ?? false,
|
||||
archivedAt: overrides.archivedAt ?? null,
|
||||
retentionVersion: overrides.retentionVersion ?? 1,
|
||||
decideBy: overrides.decideBy ?? null,
|
||||
decideByAttribution: overrides.decideByAttribution ?? null,
|
||||
snoozedUntil: overrides.snoozedUntil ?? null,
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlarmClock, CalendarClock, ChevronDown, Loader2, Plus, UserPlus, X } from "lucide-react";
|
||||
import { buildAgentMentionHref, type Agent, type AttentionItem, type AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { decisionQueuesApi } from "../api/decisionQueues";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
attentionTaskRef,
|
||||
DECIDE_BY_OPTIONS,
|
||||
decideByLabel,
|
||||
type DecideByPreset,
|
||||
} from "../lib/attention";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** Snooze presets shared with the row menu, resolved 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", resolve: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
} },
|
||||
{ label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
|
||||
];
|
||||
|
||||
/** Slugify a queue title into a URL-safe kebab key the API will accept. */
|
||||
function toQueueKey(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
/** Compact countdown/overdue label for an expiry timestamp. */
|
||||
function expiryLabel(expiresAt: string): { text: string; overdue: boolean } {
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
if (!Number.isFinite(diff)) return { text: "", overdue: false };
|
||||
if (diff <= 0) return { text: "Expired", overdue: true };
|
||||
const mins = Math.round(diff / 60000);
|
||||
if (mins < 60) return { text: `Expires in ${mins}m`, overdue: false };
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return { text: `Expires in ${hrs}h`, overdue: false };
|
||||
const days = Math.round(hrs / 24);
|
||||
return { text: `Expires in ${days}d`, overdue: false };
|
||||
}
|
||||
|
||||
interface DecisionTriageStripProps {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
/** Company agents, for the route-to-agent picker (optional). */
|
||||
agents?: Agent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card triage strip (PAP-16032 §4.5 / wireframe screen 3). Exposes the
|
||||
* write path the "agents alongside" also use: decide-by urgency (drives the
|
||||
* desk's Decide-now / Can-wait split), queue membership, snooze, and
|
||||
* route-to-agent. Every write is attributed and rendered as a setting, never a
|
||||
* silent mutation. Queue/decide-by/snooze go through the P1 triage APIs; the
|
||||
* expiry chip surfaces the underlying decision's TTL read-only.
|
||||
*/
|
||||
export function DecisionTriageStrip({ item, companyId, agents }: DecisionTriageStripProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const sourceKind = item.sourceKind;
|
||||
const sourceId = item.subject.id;
|
||||
const taskRef = attentionTaskRef(item);
|
||||
const relatedIssueId = item.relatedIssue?.id
|
||||
?? (typeof item.subject.metadata?.issueId === "string" ? item.subject.metadata.issueId : null)
|
||||
?? (item.subject.kind === "issue" ? item.subject.id : null);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.triage(companyId, sourceKind, sourceId) });
|
||||
};
|
||||
|
||||
const onError = (verb: string) => (error: unknown) =>
|
||||
pushToast({
|
||||
title: `Could not ${verb}`,
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
|
||||
const setDecideBy = useMutation({
|
||||
mutationFn: (decideBy: string | null) =>
|
||||
decisionQueuesApi.updateTriage(companyId, sourceKind, sourceId, { decideBy }),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("set when to decide"),
|
||||
});
|
||||
const setSnooze = useMutation({
|
||||
mutationFn: (snoozedUntil: string | null) =>
|
||||
decisionQueuesApi.updateTriage(companyId, sourceKind, sourceId, { snoozedUntil }),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("snooze"),
|
||||
});
|
||||
const addToQueue = useMutation({
|
||||
mutationFn: (key: string) => decisionQueuesApi.addItem(companyId, key, sourceKind, sourceId),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("add to queue"),
|
||||
});
|
||||
const removeFromQueue = useMutation({
|
||||
mutationFn: (key: string) => decisionQueuesApi.removeItem(companyId, key, sourceKind, sourceId),
|
||||
onSuccess: invalidate,
|
||||
onError: onError("remove from queue"),
|
||||
});
|
||||
const routeToAgent = useMutation({
|
||||
mutationFn: (agent: Agent) => {
|
||||
if (!relatedIssueId) throw new Error("This decision has no linked task to route from.");
|
||||
const mention = `[@${agent.name}](${buildAgentMentionHref(agent.id)})`;
|
||||
const body =
|
||||
`${mention} — could you look at this decision${taskRef ? ` on ${taskRef.identifier}` : ""}, `
|
||||
+ `prepare a recommendation, and re-surface it on the decisions desk? (routed from the desk)`;
|
||||
return issuesApi.addComment(relatedIssueId, body);
|
||||
},
|
||||
onSuccess: (_result, agent) => {
|
||||
invalidate();
|
||||
pushToast({ title: `Routed to ${agent.name}`, tone: "success" });
|
||||
},
|
||||
onError: onError("route to agent"),
|
||||
});
|
||||
|
||||
const pending = setDecideBy.isPending || setSnooze.isPending || addToQueue.isPending || removeFromQueue.isPending;
|
||||
const decideBy = item.decideBy;
|
||||
const isDatePreset = decideBy != null && /^\d{4}-\d{2}-\d{2}$/.test(decideBy);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3"
|
||||
data-decision-triage-strip
|
||||
// Contained inside the expandable panel; stop the card's expand toggle
|
||||
// from firing when the operator interacts with a control.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* Expiry — read-only countdown so a dying confirmation is visible. */}
|
||||
{item.expiresAt && (() => {
|
||||
const { text, overdue } = expiryLabel(item.expiresAt);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<AlarmClock className={cn("h-3.5 w-3.5", overdue ? "text-destructive" : "text-muted-foreground")} />
|
||||
<span className={overdue ? "font-medium text-destructive" : "text-muted-foreground"}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* When to decide — the importance signal that drives desk ordering. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">When to decide</span>
|
||||
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="When to decide">
|
||||
{DECIDE_BY_OPTIONS.map(([value, label]) => (
|
||||
<SegmentButton
|
||||
key={value}
|
||||
active={decideBy === (value satisfies DecideByPreset)}
|
||||
disabled={pending}
|
||||
onClick={() => setDecideBy.mutate(decideBy === value ? null : value)}
|
||||
>
|
||||
{label}
|
||||
</SegmentButton>
|
||||
))}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<SegmentButton active={isDatePreset} disabled={pending}>
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
{isDatePreset ? decideByLabel(decideBy) : "Pick date"}
|
||||
</SegmentButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<input
|
||||
type="date"
|
||||
defaultValue={isDatePreset ? decideBy ?? "" : ""}
|
||||
className="rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
onChange={(event) => {
|
||||
if (event.target.value) setDecideBy.mutate(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{/* Provenance ("· set by …") is rendered once, in the card meta row
|
||||
(AttentionQueueRow), which shows in both collapsed and expanded
|
||||
states — repeating it here duplicated it on the expanded card. */}
|
||||
</div>
|
||||
|
||||
{/* Queues — current membership as removable chips + add/create. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Queues</span>
|
||||
{item.queues.map((queue) => (
|
||||
<span
|
||||
key={queue.key}
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{queue.title}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
|
||||
aria-label={`Remove from ${queue.title}`}
|
||||
disabled={pending}
|
||||
onClick={() => removeFromQueue.mutate(queue.key)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<QueuePicker
|
||||
companyId={companyId}
|
||||
existingKeys={new Set(item.queues.map((queue) => queue.key))}
|
||||
disabled={pending}
|
||||
onAdd={(key) => addToQueue.mutate(key)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Snooze + route-to-agent. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.snoozedUntil ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<AlarmClock className="h-3.5 w-3.5" />
|
||||
Snoozed until {new Date(item.snoozedUntil).toLocaleString()}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={() => setSnooze.mutate(null)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 gap-1" disabled={pending}>
|
||||
<AlarmClock className="h-3.5 w-3.5" />
|
||||
Snooze
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem key={preset.label} onClick={() => setSnooze.mutate(preset.resolve())}>
|
||||
{preset.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<RouteToAgentPicker
|
||||
agents={agents ?? []}
|
||||
disabled={routeToAgent.isPending || !relatedIssueId}
|
||||
disabledReason={!relatedIssueId ? "No linked task to route from" : undefined}
|
||||
onRoute={(agent) => routeToAgent.mutate(agent)}
|
||||
/>
|
||||
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentButton({
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-xs transition-colors disabled:opacity-50",
|
||||
active
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuePicker({
|
||||
companyId,
|
||||
existingKeys,
|
||||
disabled,
|
||||
onAdd,
|
||||
}: {
|
||||
companyId: string;
|
||||
existingKeys: Set<string>;
|
||||
disabled?: boolean;
|
||||
onAdd: (key: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const { data: queues } = useQuery({
|
||||
queryKey: queryKeys.decisionQueues.list(companyId),
|
||||
queryFn: () => decisionQueuesApi.list(companyId),
|
||||
enabled: open,
|
||||
});
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
decisionQueuesApi.create(companyId, { key: toQueueKey(name), title: name.trim() }),
|
||||
onSuccess: (queue) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(companyId) });
|
||||
onAdd(queue.key);
|
||||
setTitle("");
|
||||
setCreating(false);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Could not create queue",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const available = (queues ?? []).filter((queue) => !existingKeys.has(queue.key));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 gap-1" disabled={disabled}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Queue
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-1">
|
||||
{creating ? (
|
||||
<div className="flex flex-col gap-1.5 p-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && title.trim()) create.mutate(title);
|
||||
if (event.key === "Escape") setCreating(false);
|
||||
}}
|
||||
placeholder="New queue name…"
|
||||
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="xs" disabled={!title.trim() || create.isPending} onClick={() => create.mutate(title)}>
|
||||
{create.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto">
|
||||
{available.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No other queues yet.</p>
|
||||
)}
|
||||
{available.map((queue) => (
|
||||
<button
|
||||
key={queue.key}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onAdd(queue.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{queue.title}</span>
|
||||
{queue.itemCount > 0 && (
|
||||
<span className="ml-2 shrink-0 text-(length:--text-nano) tabular-nums text-muted-foreground">
|
||||
{queue.itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuSeparatorLike />
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New queue…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparatorLike() {
|
||||
return <div className="my-1 h-px bg-border" />;
|
||||
}
|
||||
|
||||
function RouteToAgentPicker({
|
||||
agents,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onRoute,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
onRoute: (agent: Agent) => void;
|
||||
}) {
|
||||
const active = agents.filter((agent) => agent.status !== "terminated");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 gap-1"
|
||||
disabled={disabled || active.length === 0}
|
||||
title={disabledReason}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Route to agent
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
|
||||
{active.map((agent) => (
|
||||
<DropdownMenuItem key={agent.id} onClick={() => onRoute(agent)}>
|
||||
{agent.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user