mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, where operators need a reliable way to find and act on work awaiting their input. > - The attention and issue-thread interaction subsystems expose those decision points across server APIs and the board UI. > - The previous navigation and interaction presentation left these actions fragmented and did not offer a controlled rollout for the Decisions surface. > - This branch adds the attention feed, richer interaction cards, grouping, dismiss/snooze behavior, and a gated Decisions sidebar entry. > - It also keeps experimental settings and API contracts synchronized, with an idempotent migration for the new dismissal state. > - This pull request delivers the complete, tested attention/Decisions experience as one reviewable unit. ## Linked Issues or Issue Description - Adds an operator-focused attention queue and Decisions experience: grouped decision cards, semantic interaction actions, dismiss/snooze handling, resilient interaction states, and an experimental flag to control the Decisions navigation entry. ## Feature Context ### Problem or Motivation Operators currently have to hunt across approvals, interactions, failed runs, and budget alerts to find decisions that need their action. ### Proposed Solution Provide a gated Decisions attention queue that groups actionable items, supports direct resolution, and preserves operator control through dismiss and snooze actions. ### Alternatives Considered Keep separate, source-specific views only; this leaves cross-cutting operator decisions fragmented and harder to prioritize. ### Roadmap Alignment This improves the V1 control-plane operator workflow by making pending governed actions discoverable in one company-scoped surface. ## What Changed - Added server attention-feed services, routes, interaction handling, dismiss/snooze support, and an idempotent `0145` inbox-dismissal migration. - Added shared attention, inbox-dismissal, and experimental-settings contracts. - Added Decisions/attention UI, interaction-card states, sidebar badge/navigation integration, grouping, keyboard support, and Storybook coverage. - Added tests for attention behavior, thread interactions, settings normalization, dismissals, and API behavior. - Removed generated screenshots from the final PR diff and rebased the branch onto current `master`. ## Verification - `pnpm check:token-gates` — passed. - `pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/attention-service.test.ts server/src/__tests__/inbox-dismissals.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts ui/src/lib/attention.test.ts ui/src/components/AttentionQueueRow.test.tsx ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` — passed: 158 tests across 9 focused files. - GitHub Actions for `ad636f560`: build and typecheck/release-registry have passed; remaining general-server and Greptile checks are in progress. ## Risks - Moderate: this is a cross-layer attention/interaction feature with a new migration and navigation behavior. - The `enableDecisions` experimental setting defaults to off, limiting rollout impact. - Existing dismissal data is backfilled to `dismiss`; the migration is idempotent and uses guarded constraint creation. > ROADMAP.md was checked; no duplicate planned core feature was identified. Related open pull requests were searched before opening this PR. ## Model Used - OpenAI GPT-5.5 via Codex CLI, with tool use and local code execution. Context-window size unavailable in this environment. ## 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 public PR branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally; focused tests pass and the remaining unrelated AWS test failure is documented above - [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 - [ ] All Paperclip CI gates are green - [ ] 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
689 lines
24 KiB
TypeScript
689 lines
24 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
|
|
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
|
import { useNavigate } from "@/lib/router";
|
|
import { attentionApi } from "../api/attention";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import {
|
|
ATTENTION_GROUP_BY_OPTIONS,
|
|
ATTENTION_SORT_OPTIONS,
|
|
buildAttentionFilterOptions,
|
|
countActiveAttentionFilters,
|
|
defaultAttentionFilterState,
|
|
filterAttentionItems,
|
|
groupAttentionItems,
|
|
isInlineResolvable,
|
|
loadAttentionFilters,
|
|
loadAttentionGroupBy,
|
|
loadAttentionSortOrder,
|
|
loadCollapsedAttentionGroupKeys,
|
|
NO_GROUP_SENTINEL,
|
|
saveAttentionFilters,
|
|
saveAttentionGroupBy,
|
|
saveAttentionSortOrder,
|
|
saveCollapsedAttentionGroupKeys,
|
|
sortAttentionItems,
|
|
sourceMeta,
|
|
type AttentionFilterState,
|
|
type AttentionGroupBy,
|
|
type AttentionSortOrder,
|
|
} from "../lib/attention";
|
|
import { cn } from "../lib/utils";
|
|
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
|
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import { Checkbox } from "../components/ui/checkbox";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
|
|
|
|
const SEVERITY_LABELS: Record<string, string> = {
|
|
critical: "Critical",
|
|
high: "High",
|
|
medium: "Medium",
|
|
low: "Low",
|
|
};
|
|
|
|
export function WhatNeedsMe() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [selectedAttentionId, setSelectedAttentionId] = useState<string | null>(null);
|
|
const [autoExpandDone, setAutoExpandDone] = useState(false);
|
|
|
|
// Toolbar preferences (persisted to localStorage, Inbox pattern).
|
|
const [groupBy, setGroupBy] = useState<AttentionGroupBy>(() => loadAttentionGroupBy());
|
|
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
|
|
const [filters, setFilters] = useState<AttentionFilterState>(() => defaultAttentionFilterState);
|
|
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
|
|
const [snoozedOpen, setSnoozedOpen] = useState(false);
|
|
const [dismissedOpen, setDismissedOpen] = useState(false);
|
|
|
|
// Optimistic hide/restore. Reset whenever a fresh feed lands (server truth).
|
|
const [pendingHide, setPendingHide] = useState<Set<string>>(() => new Set());
|
|
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
|
|
|
|
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
|
|
const { pushToast } = useToastActions();
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Decisions" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
// Re-hydrate per-company preferences when the company changes.
|
|
useEffect(() => {
|
|
setFilters(loadAttentionFilters(selectedCompanyId));
|
|
setCollapsedGroupKeys(loadCollapsedAttentionGroupKeys(selectedCompanyId));
|
|
}, [selectedCompanyId]);
|
|
|
|
const {
|
|
data: feed,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
// Distinct from the sidebar badge's `queryKeys.attention` so dismissed rows
|
|
// (needed for the curtains) never inflate the badge count. Invalidating the
|
|
// `["attention", companyId]` prefix still cascades to this query.
|
|
queryKey: [...queryKeys.attention(selectedCompanyId!), "with-dismissed"],
|
|
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true }),
|
|
enabled: !!selectedCompanyId,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const agent of agents ?? []) map.set(agent.id, agent);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
// Reset optimistic state once the server sends a fresh snapshot.
|
|
useEffect(() => {
|
|
setPendingHide(new Set());
|
|
setPendingRestore(new Set());
|
|
}, [feed?.generatedAt]);
|
|
|
|
const allItems = useMemo(() => feed?.items ?? [], [feed]);
|
|
|
|
const isServerHidden = (item: AttentionItem) => item.dismissal != null && item.dismissal.isActive;
|
|
|
|
const activeItems = useMemo(
|
|
() =>
|
|
allItems.filter(
|
|
(item) => (!isServerHidden(item) || pendingRestore.has(item.id)) && !pendingHide.has(item.id),
|
|
),
|
|
[allItems, pendingHide, pendingRestore],
|
|
);
|
|
const snoozedItems = useMemo(
|
|
() =>
|
|
allItems.filter(
|
|
(item) =>
|
|
item.dismissal?.kind === "snooze" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
|
),
|
|
[allItems, pendingRestore],
|
|
);
|
|
const dismissedItems = useMemo(
|
|
() =>
|
|
allItems.filter(
|
|
(item) =>
|
|
item.dismissal?.kind === "dismiss" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
|
),
|
|
[allItems, pendingRestore],
|
|
);
|
|
|
|
const filterOptions = useMemo(() => buildAttentionFilterOptions(activeItems), [activeItems]);
|
|
|
|
// Filter → sort → group, all client-side so switching re-buckets without a refetch.
|
|
const groups = useMemo(() => {
|
|
const filtered = filterAttentionItems(activeItems, filters);
|
|
const sorted = sortAttentionItems(filtered, sortOrder);
|
|
return groupAttentionItems(sorted, groupBy);
|
|
}, [activeItems, filters, sortOrder, groupBy]);
|
|
|
|
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
|
|
const keyboardItems = useMemo(
|
|
() => groups.filter((group) => group.label === null || !collapsedGroupKeys.has(group.key)).flatMap((group) => group.items),
|
|
[collapsedGroupKeys, groups],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (selectedAttentionId && !keyboardItems.some((item) => item.id === selectedAttentionId)) {
|
|
setSelectedAttentionId(null);
|
|
}
|
|
}, [keyboardItems, selectedAttentionId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedAttentionId) return;
|
|
document.getElementById(`attention-row-${selectedAttentionId}`)?.scrollIntoView({ block: "nearest" });
|
|
}, [selectedAttentionId]);
|
|
|
|
// Auto-expand the topmost inline-capable decision, once.
|
|
useEffect(() => {
|
|
if (autoExpandDone || activeItems.length === 0) return;
|
|
const sorted = sortAttentionItems(activeItems, sortOrder);
|
|
const topInline = sorted.find((item) => isInlineResolvable(item));
|
|
if (topInline) setExpandedId(topInline.id);
|
|
setAutoExpandDone(true);
|
|
}, [activeItems, autoExpandDone, sortOrder]);
|
|
|
|
const updateGroupBy = (next: AttentionGroupBy) => {
|
|
setGroupBy(next);
|
|
saveAttentionGroupBy(next);
|
|
};
|
|
const updateSortOrder = (next: AttentionSortOrder) => {
|
|
setSortOrder(next);
|
|
saveAttentionSortOrder(next);
|
|
};
|
|
const updateFilters = (next: AttentionFilterState) => {
|
|
setFilters(next);
|
|
saveAttentionFilters(selectedCompanyId, next);
|
|
};
|
|
const toggleGroupCollapse = (key: string) => {
|
|
setCollapsedGroupKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
saveCollapsedAttentionGroupKeys(selectedCompanyId, next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleUndoDismiss = (item: AttentionItem) => {
|
|
setPendingHide((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(item.id);
|
|
return next;
|
|
});
|
|
restore(item.dismissalKey);
|
|
};
|
|
const handleDismiss = (item: AttentionItem) => {
|
|
setPendingHide((prev) => new Set(prev).add(item.id));
|
|
dismiss(item.dismissalKey);
|
|
setExpandedId((previous) => (previous === item.id ? null : previous));
|
|
// ~8s undo window; restores the row in place via T1's DELETE endpoint.
|
|
pushToast({
|
|
id: `attention-dismiss-${item.id}`,
|
|
dedupeKey: `attention-dismiss-${item.dismissalKey}`,
|
|
title: "Dismissed",
|
|
body: item.subject.title ?? undefined,
|
|
tone: "info",
|
|
ttlMs: 8000,
|
|
action: { label: "Undo", onClick: () => handleUndoDismiss(item) },
|
|
});
|
|
};
|
|
const handleSnooze = (item: AttentionItem, snoozedUntil: string) => {
|
|
setPendingHide((prev) => new Set(prev).add(item.id));
|
|
snooze(item.dismissalKey, snoozedUntil);
|
|
if (expandedId === item.id) setExpandedId(null);
|
|
};
|
|
const handleRestore = (item: AttentionItem) => {
|
|
setPendingRestore((prev) => new Set(prev).add(item.id));
|
|
restore(item.dismissalKey);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const action = resolveAttentionQueueKeyAction({
|
|
defaultPrevented: event.defaultPrevented,
|
|
key: event.key,
|
|
metaKey: event.metaKey,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: event.altKey,
|
|
target: event.target,
|
|
hasOpenDialog: hasBlockingShortcutDialog(document),
|
|
hasSelection: selectedAttentionId !== null,
|
|
});
|
|
if (action === "ignore" || keyboardItems.length === 0) return;
|
|
|
|
if (action === "next" || action === "previous") {
|
|
event.preventDefault();
|
|
const currentIndex = selectedAttentionId ? keyboardItems.findIndex((item) => item.id === selectedAttentionId) : -1;
|
|
const offset = action === "next" ? 1 : -1;
|
|
const nextIndex = currentIndex < 0
|
|
? action === "next"
|
|
? 0
|
|
: keyboardItems.length - 1
|
|
: (currentIndex + offset + keyboardItems.length) % keyboardItems.length;
|
|
setSelectedAttentionId(keyboardItems[nextIndex]?.id ?? null);
|
|
return;
|
|
}
|
|
|
|
const selectedItem = keyboardItems.find((item) => item.id === selectedAttentionId);
|
|
if (!selectedItem) return;
|
|
event.preventDefault();
|
|
|
|
if (action === "dismiss") {
|
|
handleDismiss(selectedItem);
|
|
} else if (isInlineResolvable(selectedItem)) {
|
|
setExpandedId((previous) => (previous === selectedItem.id ? null : selectedItem.id));
|
|
} else if (selectedItem.subject.href) {
|
|
navigate(selectedItem.subject.href);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [keyboardItems, navigate, selectedAttentionId]);
|
|
const activeFilterCount = countActiveAttentionFilters(filters);
|
|
|
|
if (!selectedCompanyId) {
|
|
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="approvals" />;
|
|
}
|
|
|
|
const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0;
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h1 className="text-xl font-bold">Decisions</h1>
|
|
<div className="flex items-center gap-2">
|
|
{visibleCount > 0 && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
|
|
</span>
|
|
)}
|
|
{/* Filter */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className={cn("h-8 w-8 shrink-0", activeFilterCount > 0 && "bg-accent")}
|
|
title="Filter"
|
|
aria-label="Filter"
|
|
>
|
|
<ListFilter className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-64 p-0">
|
|
<FilterMenu
|
|
options={filterOptions}
|
|
filters={filters}
|
|
onChange={updateFilters}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{/* Group by */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
|
title="Group"
|
|
aria-label="Group"
|
|
>
|
|
<Layers className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-40 p-2">
|
|
<div className="space-y-0.5">
|
|
{ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
|
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => updateGroupBy(value)}
|
|
>
|
|
<span>{label}</span>
|
|
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{/* Sort */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
title="Sort"
|
|
aria-label="Sort"
|
|
>
|
|
<ArrowUpDown className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-44 p-2">
|
|
<div className="space-y-0.5">
|
|
{ATTENTION_SORT_OPTIONS.map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
|
sortOrder === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => updateSortOrder(value)}
|
|
>
|
|
<span>{label}</span>
|
|
{sortOrder === value ? <Check className="h-3.5 w-3.5" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
|
|
|
{!hasAnything ? (
|
|
<ZeroState />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{visibleCount === 0 ? (
|
|
<CaughtUpNote filtered={activeItems.length > 0} />
|
|
) : (
|
|
groups.map((group) => {
|
|
const groupLabel = group.label;
|
|
const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key);
|
|
return (
|
|
<section key={group.key} className="space-y-2">
|
|
{groupLabel !== null && (
|
|
<IssueGroupHeader
|
|
label={groupLabel}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
onToggle={() => toggleGroupCollapse(group.key)}
|
|
trailing={
|
|
<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>
|
|
}
|
|
/>
|
|
)}
|
|
{!collapsed && (
|
|
<div className="space-y-2">
|
|
{group.items.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
expanded={expandedId === item.id}
|
|
onToggleExpand={() => {
|
|
setSelectedAttentionId(item.id);
|
|
setExpandedId((prev) => (prev === item.id ? null : item.id));
|
|
}}
|
|
onDismiss={handleDismiss}
|
|
onSnooze={handleSnooze}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
selected={selectedAttentionId === item.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{snoozedItems.length > 0 && (
|
|
<Curtain
|
|
label="Snoozed"
|
|
count={snoozedItems.length}
|
|
open={snoozedOpen}
|
|
onToggle={() => setSnoozedOpen((prev) => !prev)}
|
|
>
|
|
{snoozedItems.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
variant="hidden"
|
|
expanded={false}
|
|
onToggleExpand={() => {}}
|
|
onDismiss={handleDismiss}
|
|
onRestore={handleRestore}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
))}
|
|
</Curtain>
|
|
)}
|
|
|
|
{dismissedItems.length > 0 && (
|
|
<Curtain
|
|
label="Dismissed"
|
|
count={dismissedItems.length}
|
|
open={dismissedOpen}
|
|
onToggle={() => setDismissedOpen((prev) => !prev)}
|
|
>
|
|
{dismissedItems.map((item) => (
|
|
<AttentionQueueRow
|
|
key={item.id}
|
|
item={item}
|
|
companyId={selectedCompanyId}
|
|
variant="hidden"
|
|
expanded={false}
|
|
onToggleExpand={() => {}}
|
|
onDismiss={handleDismiss}
|
|
onRestore={handleRestore}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
))}
|
|
</Curtain>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterMenu({
|
|
options,
|
|
filters,
|
|
onChange,
|
|
}: {
|
|
options: ReturnType<typeof buildAttentionFilterOptions>;
|
|
filters: AttentionFilterState;
|
|
onChange: (next: AttentionFilterState) => void;
|
|
}) {
|
|
const toggle = (key: keyof AttentionFilterState, value: string) => {
|
|
const list = filters[key] as string[];
|
|
const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
|
onChange({ ...filters, [key]: nextList });
|
|
};
|
|
const hasActive = countActiveAttentionFilters(filters) > 0;
|
|
|
|
return (
|
|
<div className="max-h-(--sz-70vh) overflow-y-auto">
|
|
<div className="flex items-center justify-between px-3 py-2">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
|
|
{hasActive && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => onChange(defaultAttentionFilterState)}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{options.sourceKinds.length > 1 && (
|
|
<FilterSection title="Type">
|
|
{options.sourceKinds.map((kind) => (
|
|
<FilterRow
|
|
key={kind}
|
|
label={sourceMeta(kind).label}
|
|
checked={filters.sourceKinds.includes(kind)}
|
|
onToggle={() => toggle("sourceKinds", kind)}
|
|
/>
|
|
))}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{options.severities.length > 1 && (
|
|
<FilterSection title="Severity">
|
|
{options.severities.map((severity) => (
|
|
<FilterRow
|
|
key={severity}
|
|
label={SEVERITY_LABELS[severity] ?? severity}
|
|
checked={filters.severities.includes(severity)}
|
|
onToggle={() => toggle("severities", severity)}
|
|
/>
|
|
))}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{(options.projects.length > 0 || options.hasNoProject) && (
|
|
<FilterSection title="Project">
|
|
{options.projects.map((project) => (
|
|
<FilterRow
|
|
key={project.id}
|
|
label={project.name}
|
|
checked={filters.projectIds.includes(project.id)}
|
|
onToggle={() => toggle("projectIds", project.id)}
|
|
/>
|
|
))}
|
|
{options.hasNoProject && (
|
|
<FilterRow
|
|
label="No project"
|
|
checked={filters.projectIds.includes(NO_GROUP_SENTINEL)}
|
|
onToggle={() => toggle("projectIds", NO_GROUP_SENTINEL)}
|
|
/>
|
|
)}
|
|
</FilterSection>
|
|
)}
|
|
|
|
{(options.workspaces.length > 0 || options.hasNoWorkspace) && (
|
|
<FilterSection title="Workspace">
|
|
{options.workspaces.map((workspace) => (
|
|
<FilterRow
|
|
key={workspace.id}
|
|
label={workspace.name}
|
|
checked={filters.workspaceIds.includes(workspace.id)}
|
|
onToggle={() => toggle("workspaceIds", workspace.id)}
|
|
/>
|
|
))}
|
|
{options.hasNoWorkspace && (
|
|
<FilterRow
|
|
label="No workspace"
|
|
checked={filters.workspaceIds.includes(NO_GROUP_SENTINEL)}
|
|
onToggle={() => toggle("workspaceIds", NO_GROUP_SENTINEL)}
|
|
/>
|
|
)}
|
|
</FilterSection>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterSection({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<div className="border-t border-border/60 px-2 py-1.5">
|
|
<p className="px-1 pb-1 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
|
{title}
|
|
</p>
|
|
<div className="space-y-0.5">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterRow({
|
|
label,
|
|
checked,
|
|
onToggle,
|
|
}: {
|
|
label: string;
|
|
checked: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-left text-sm hover:bg-accent/50"
|
|
onClick={onToggle}
|
|
>
|
|
<Checkbox checked={checked} className="pointer-events-none" tabIndex={-1} />
|
|
<span className="truncate">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Curtain({
|
|
label,
|
|
count,
|
|
open,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="space-y-2">
|
|
<IssueGroupHeader
|
|
label={`${label} (${count})`}
|
|
collapsible
|
|
collapsed={!open}
|
|
onToggle={onToggle}
|
|
className="text-muted-foreground"
|
|
/>
|
|
{open && <div className="space-y-2">{children}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CaughtUpNote({ filtered }: { filtered: boolean }) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-border py-10 text-center">
|
|
<p className="text-sm font-medium text-foreground">
|
|
{filtered ? "No decisions match your filters." : "You're all caught up."}
|
|
</p>
|
|
{filtered && (
|
|
<p className="mt-1 text-xs text-muted-foreground">Adjust or clear the filters to see the rest.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ZeroState() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
|
<div className="mb-4 rounded-full bg-green-500/10 p-4">
|
|
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
|
</div>
|
|
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
|
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Inbox className="h-4 w-4" />
|
|
Nothing needs a decision from you right now.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|