@anthusai/papyrus 5.47.0 → 5.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/needs-me-bodies.tsx +300 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +578 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/newsroom-ops-overview.tsx +44 -37
- package/components/newsroom-ops-shell.tsx +4 -4
- package/components/newsroom-organizer.css +30 -11
- package/components/newsroom-references-view.tsx +5 -113
- package/components/notification-cards.tsx +14 -9
- package/components/notification-center.tsx +197 -304
- package/components/reference-capture-panel.tsx +2 -2
- package/components/reference-feed.tsx +1 -1
- package/components/reference-review-card.tsx +5 -1
- package/components/topic-steering-workspace.tsx +2 -1
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +57 -6
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/notification-feed.ts +0 -38
- package/lib/reference-preview.ts +2 -2
- package/lib/review-client.ts +33 -4
- package/lib/review-drafts.ts +49 -8
- package/lib/review-fixture.ts +20 -0
- package/lib/review-requests.ts +25 -0
- package/package.json +10 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CheckIcon, ChevronDownIcon, CircleCheckBigIcon, CircleCheckIcon, CircleXIcon, ClipboardCheckIcon, FileTextIcon, FileWarningIcon, FlagIcon, InfoIcon, LightbulbIcon,
|
|
5
|
+
Loader2Icon, LockIcon, MailIcon, NewspaperIcon, SlidersHorizontalIcon, Undo2Icon, type LucideIcon,
|
|
6
|
+
} from "lucide-react";
|
|
7
|
+
import Link from "next/link";
|
|
8
|
+
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
9
|
+
import { ScrollArea } from "./ui/scroll-area";
|
|
10
|
+
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./ui/sheet";
|
|
11
|
+
import { cn } from "../lib/utils";
|
|
12
|
+
import type { ReferenceAttachmentRecord } from "../lib/category-repository";
|
|
13
|
+
import type { MonitoringEmailDetails } from "../lib/monitoring-overview";
|
|
14
|
+
import {
|
|
15
|
+
NEEDS_ME_FILTERS, NEEDS_ME_FILTER_LABELS, NEEDS_ME_TYPE_LABELS, applyNeedsMeSnapshot, countNeedsMe, matchesFilter, rankNeedsMe, takeNeedsMeSnapshot,
|
|
16
|
+
type ConfidenceThreshold, type NeedsMeFilter, type NeedsMeSnapshot, type NeedsMeType, type ShapingStats,
|
|
17
|
+
} from "../lib/needs-me-ranking";
|
|
18
|
+
import { waitingTextFor, type NeedsMeEntry } from "../lib/needs-me-items";
|
|
19
|
+
import { cannotMoveReason } from "../lib/review-drafts";
|
|
20
|
+
import type { CyclotronEntry } from "../lib/review-requests";
|
|
21
|
+
import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
22
|
+
import { formatFeedAge } from "../lib/reference-preview";
|
|
23
|
+
import type { FeedFreshness } from "../lib/reference-feed-ranking";
|
|
24
|
+
import type { RecentDecision } from "../lib/notification-feed";
|
|
25
|
+
import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
|
|
26
|
+
import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
|
|
27
|
+
import { FlagFooter, decidedChoiceOf, isReferenceLike, recordedAnswer } from "./needs-me-shared";
|
|
28
|
+
import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
29
|
+
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SourceMark } from "./reference-feed";
|
|
30
|
+
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
31
|
+
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
32
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
33
|
+
import { useMediaQuery } from "./use-media-query";
|
|
34
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
35
|
+
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
36
|
+
|
|
37
|
+
/** What the editor chose and typed: the one shape every type's decision arrives in. */
|
|
38
|
+
export type NeedsMeDecision = { choice: ReviewChoice; reasonCode: ReferenceRejectionReasonCode | null; why: string; notThisKind: boolean };
|
|
39
|
+
|
|
40
|
+
const TYPE_ICON: Record<NeedsMeType, LucideIcon> = {
|
|
41
|
+
reference: NewspaperIcon, draft: FileTextIcon, stage_review: ClipboardCheckIcon, held_email: MailIcon, incomplete_capture: FileWarningIcon, concept_proposal: LightbulbIcon, flag: FlagIcon,
|
|
42
|
+
};
|
|
43
|
+
const TYPE_HUE: Record<NeedsMeType, number> = { reference: 215, draft: 150, stage_review: 270, held_email: 25, incomplete_capture: 45, concept_proposal: 330, flag: 190 };
|
|
44
|
+
|
|
45
|
+
function cardPrediction(item: NeedsMeEntry, predictionLoading: boolean): CardPrediction {
|
|
46
|
+
if (item.payload.kind === "reference" && item.payload.decision) return predictionFromDecision(item.payload.decision);
|
|
47
|
+
if (item.prediction) return { state: "ready", choice: item.prediction.label, confidence: item.prediction.confidence };
|
|
48
|
+
return predictionLoading ? { state: "loading" } : { state: "none" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Words for why an item cannot be decided right now, or null. */
|
|
52
|
+
export function disabledFor(item: NeedsMeEntry, session: string | null, referenceReason: string | null): string | null {
|
|
53
|
+
if (session) return session;
|
|
54
|
+
if (item.payload.kind === "draft") return item.payload.loading ? "Loading the draft…" : item.payload.item.view ? cannotMoveReason(item.payload.item.view) : null;
|
|
55
|
+
if (isReferenceLike(item)) return referenceReason;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- the card -----------------------------------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** For an item with no picture: the type's own tile, an icon and its name, so a card is never just a colour. */
|
|
62
|
+
function TypeTile({ type }: { type: NeedsMeType }) {
|
|
63
|
+
const Icon = TYPE_ICON[type];
|
|
64
|
+
const hue = TYPE_HUE[type];
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className="relative flex aspect-[16/10] w-full flex-col items-center justify-center gap-2 overflow-hidden p-4 text-white"
|
|
68
|
+
data-needs-me-tile={type}
|
|
69
|
+
style={{ background: `linear-gradient(135deg, oklch(0.58 0.1 ${hue}), oklch(0.4 0.11 ${(hue + 45) % 360}))` }}
|
|
70
|
+
>
|
|
71
|
+
<Icon aria-hidden="true" className="size-12" />
|
|
72
|
+
<span className="text-sm font-semibold tracking-wide opacity-90">{NEEDS_ME_TYPE_LABELS[type]}</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function TypeBadge({ item }: { item: NeedsMeEntry }) {
|
|
78
|
+
const Icon = item.hardGate ? LockIcon : TYPE_ICON[item.type];
|
|
79
|
+
return (
|
|
80
|
+
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-foreground" data-needs-me-badge={item.type}>
|
|
81
|
+
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
82
|
+
{NEEDS_ME_TYPE_LABELS[item.type]}{item.hardGate ? " · Hard gate" : ""}
|
|
83
|
+
</span>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, selected, disabledReason }: {
|
|
88
|
+
item: NeedsMeEntry;
|
|
89
|
+
now: number;
|
|
90
|
+
onOpen: () => void;
|
|
91
|
+
predictionLoading: boolean;
|
|
92
|
+
ratings: RatingController;
|
|
93
|
+
selected: boolean;
|
|
94
|
+
disabledReason: string | null;
|
|
95
|
+
}) {
|
|
96
|
+
const titleId = useId();
|
|
97
|
+
const refLike = isReferenceLike(item);
|
|
98
|
+
const decided = decidedChoiceOf(item);
|
|
99
|
+
const rating = ratings.get(item.id);
|
|
100
|
+
const chosen = rating.pending ?? decided;
|
|
101
|
+
const locked = !refLike && item.decisionState !== "open";
|
|
102
|
+
const feedback = rating.feedback.phase === "idle" && decided ? { phase: "saved" as const } : rating.feedback;
|
|
103
|
+
const reason = item.payload.kind === "reference" ? rating.reasonCode : null;
|
|
104
|
+
const reasonLabel = decided === "reject" && reason ? REASON_LABELS[reason] : null;
|
|
105
|
+
// A decided item shows the Why it was given (clipped; the whole text is edited in the detail pane).
|
|
106
|
+
const why = !refLike && locked ? rating.note?.trim() ?? "" : "";
|
|
107
|
+
const whyLine = why ? `Why: ${why.length > 90 ? `${why.slice(0, 89).trimEnd()}\u2026` : why}` : null;
|
|
108
|
+
const time = item.payload.kind === "reference" ? item.payload.displayTime ?? item.createdAt : item.createdAt;
|
|
109
|
+
const age = formatFeedAge(time, now);
|
|
110
|
+
const picture = item.image || item.host ? <FeedImage host={item.host} lead={false} name={item.source} src={item.image} /> : <TypeTile type={item.type} />;
|
|
111
|
+
const draftId = item.payload.kind === "draft" ? item.payload.item.draftId : undefined;
|
|
112
|
+
const requestId = item.payload.kind === "draft" ? item.payload.item.request.id : item.payload.kind === "request" ? item.payload.request.id : undefined;
|
|
113
|
+
const flag = item.type === "flag";
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<article
|
|
117
|
+
aria-current={selected ? "true" : undefined}
|
|
118
|
+
aria-labelledby={titleId}
|
|
119
|
+
className={cn(
|
|
120
|
+
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
121
|
+
selected ? "bg-[var(--feed-control)] shadow-xl" : "bg-[var(--feed-card)]",
|
|
122
|
+
)}
|
|
123
|
+
data-feed-card={item.id}
|
|
124
|
+
data-feed-rated={decided ?? undefined}
|
|
125
|
+
data-feed-selected={selected || undefined}
|
|
126
|
+
data-needs-me-card={item.type}
|
|
127
|
+
data-needs-me-state={item.decisionState}
|
|
128
|
+
data-newsroom-card-id={item.id}
|
|
129
|
+
data-review-draft={draftId}
|
|
130
|
+
data-review-request={requestId}
|
|
131
|
+
>
|
|
132
|
+
{selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
|
|
133
|
+
<button
|
|
134
|
+
aria-label={`Open: ${item.title}`}
|
|
135
|
+
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
136
|
+
data-feed-open
|
|
137
|
+
onClick={onOpen}
|
|
138
|
+
type="button"
|
|
139
|
+
>
|
|
140
|
+
{picture}
|
|
141
|
+
<span className="flex flex-1 flex-col gap-2.5 p-5">
|
|
142
|
+
<TypeBadge item={item} />
|
|
143
|
+
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
144
|
+
<SourceMark host={item.host} />
|
|
145
|
+
<span className="truncate">{item.source}</span>
|
|
146
|
+
</span>
|
|
147
|
+
<span className="line-clamp-3 break-words text-xl font-bold leading-[1.15] tracking-tight text-foreground" data-feed-title id={titleId}>{item.title}</span>
|
|
148
|
+
{item.dek ? <span className="line-clamp-2 break-words text-sm text-muted-foreground" data-feed-dek>{item.dek}</span> : null}
|
|
149
|
+
<span className="mt-auto flex flex-col gap-1 pt-1 text-xs text-muted-foreground">
|
|
150
|
+
<span>
|
|
151
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()}>{age}</time> : null}
|
|
152
|
+
{age && item.host ? <span aria-hidden="true"> · </span> : null}
|
|
153
|
+
{item.host}
|
|
154
|
+
</span>
|
|
155
|
+
{decided || item.decisionState === "acknowledged" ? null : (
|
|
156
|
+
<span className="line-clamp-2 flex items-start gap-1.5" data-needs-me-default>
|
|
157
|
+
<Undo2Icon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
|
|
158
|
+
<span>{item.defaultOutcome}</span>
|
|
159
|
+
</span>
|
|
160
|
+
)}
|
|
161
|
+
</span>
|
|
162
|
+
</span>
|
|
163
|
+
</button>
|
|
164
|
+
|
|
165
|
+
<div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
|
|
166
|
+
{flag ? (
|
|
167
|
+
<FlagFooter busy={rating.feedback.phase === "saving"} disabledReason={disabledReason} done={item.decisionState === "acknowledged"} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
168
|
+
) : (
|
|
169
|
+
<RatingGrid
|
|
170
|
+
answer={chosen}
|
|
171
|
+
decided={decided}
|
|
172
|
+
disabled={Boolean(disabledReason) || locked}
|
|
173
|
+
disabledReason={disabledReason}
|
|
174
|
+
feedback={feedback}
|
|
175
|
+
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
|
|
176
|
+
onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
|
|
177
|
+
prediction={cardPrediction(item, predictionLoading)}
|
|
178
|
+
showPrediction={refLike}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
{refLike && rating.askReason ? (
|
|
182
|
+
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
183
|
+
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
184
|
+
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
185
|
+
<div className="flex flex-wrap gap-1.5">
|
|
186
|
+
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
187
|
+
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => ratings.setReason(item.id, code)} type="button">{REASON_LABELS[code]}</button>
|
|
188
|
+
))}
|
|
189
|
+
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => ratings.cancelReason(item.id)} type="button">Cancel</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
) : null}
|
|
193
|
+
</div>
|
|
194
|
+
</article>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- the strip, the pills, the recipe -----------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
201
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** "You're shaping this": what you decided this week, and how it sat with the Cyclotron. Words and icons, never colour alone. */
|
|
205
|
+
export function ShapingStrip({ stats }: { stats: ShapingStats }) {
|
|
206
|
+
const none = stats.decisions === 0;
|
|
207
|
+
return (
|
|
208
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-[20px] bg-[var(--feed-card)] px-4 py-3 text-sm" data-shaping-strip role="status">
|
|
209
|
+
<span className="inline-flex items-center gap-1.5 font-semibold"><SlidersHorizontalIcon aria-hidden="true" className="size-4" />You’re shaping this</span>
|
|
210
|
+
{none ? (
|
|
211
|
+
<span className="text-muted-foreground" data-shaping-empty>Nothing decided yet this week. Each answer teaches the Cyclotron and reorders what comes next.</span>
|
|
212
|
+
) : (
|
|
213
|
+
<>
|
|
214
|
+
<span className="text-muted-foreground">This week</span>
|
|
215
|
+
<span className="inline-flex items-center gap-1.5" data-shaping-decisions><CheckIcon aria-hidden="true" className="size-4" /><b className="tabular-nums">{stats.decisions}</b> {stats.decisions === 1 ? "decision" : "decisions"}</span>
|
|
216
|
+
<span className="inline-flex items-center gap-1.5" data-shaping-agreed><CircleCheckIcon aria-hidden="true" className="size-4" /><b className="tabular-nums">{stats.agreed}</b> agreed with the Cyclotron</span>
|
|
217
|
+
<span className="inline-flex items-center gap-1.5" data-shaping-overruled><CircleXIcon aria-hidden="true" className="size-4" /><b className="tabular-nums">{stats.overruled}</b> overruled it</span>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function plainDays(days: number): string {
|
|
225
|
+
if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
|
|
226
|
+
return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The plain-words recipe, with this site's learned numbers in it. */
|
|
230
|
+
export function NeedsMeRecipe({ freshness, threshold }: { freshness: FeedFreshness; threshold: ConfidenceThreshold }) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="space-y-3 rounded-[20px] bg-[var(--feed-card)] p-5 text-sm" data-needs-me-recipe>
|
|
233
|
+
<p className="text-base font-bold">A feed run by algorithms you control</p>
|
|
234
|
+
<ol className="list-decimal space-y-2.5 pl-5">
|
|
235
|
+
<li><strong>Hard gates first.</strong> Nothing continues until you decide one.</li>
|
|
236
|
+
<li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters, times how fresh it is. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
|
|
237
|
+
<li><strong>Age fades the score.</strong> {freshness.tauDays !== null && freshness.medianAgeDays !== null
|
|
238
|
+
? <>It loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} References you accepted.</>
|
|
239
|
+
: <>Age does not change the order yet. The pace is learned from the References you accept, and so far there are {freshness.examples} of the 10 needed.</>}</li>
|
|
240
|
+
<li><strong>A mix, not a run.</strong> Never more than two of one kind in a row while something else is waiting.</li>
|
|
241
|
+
<li><strong>Confident items do not ask for you.</strong> {threshold.threshold !== null
|
|
242
|
+
? <>Items the Cyclotron is surer about than {Math.round((1 - threshold.threshold) * 100)}% (the median of the {threshold.examples} predictions you confirmed) wait under Decided automatically. You can overrule any of them.</>
|
|
243
|
+
: <>Nothing is skipped yet. The line is learned from the predictions you confirm, and so far there are {threshold.examples} of the 10 needed.</>}</li>
|
|
244
|
+
<li><strong>Your answers stay put.</strong> The order is taken once when you arrive. What you decide stays where it is for this visit.</li>
|
|
245
|
+
</ol>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- the view -----------------------------------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export type NeedsMeLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
253
|
+
const NO_EXTRAS: DecisionExtras = { notThisKind: false };
|
|
254
|
+
const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
255
|
+
|
|
256
|
+
export type NeedsMeViewProps = {
|
|
257
|
+
loadState: NeedsMeLoadState;
|
|
258
|
+
entries: NeedsMeEntry[];
|
|
259
|
+
freshness: FeedFreshness;
|
|
260
|
+
threshold: ConfidenceThreshold;
|
|
261
|
+
now: number;
|
|
262
|
+
shaping: ShapingStats;
|
|
263
|
+
recent: RecentDecision[];
|
|
264
|
+
/** Read-only classifications the Cyclotron made on its own, other than the confident items above. */
|
|
265
|
+
legacyAuto?: CyclotronEntry[];
|
|
266
|
+
/** True once every source has been read: the order is taken then, and not before. */
|
|
267
|
+
settled: boolean;
|
|
268
|
+
demo?: boolean;
|
|
269
|
+
/** True while the cards are a saved copy that is being refreshed. */
|
|
270
|
+
stale?: boolean;
|
|
271
|
+
error?: string | null;
|
|
272
|
+
referencesLoading?: boolean;
|
|
273
|
+
predictionLoading?: boolean;
|
|
274
|
+
referenceDisabledReason?: string | null;
|
|
275
|
+
draftsFailed?: boolean;
|
|
276
|
+
missingDraftCount?: number;
|
|
277
|
+
initialFilter?: NeedsMeFilter;
|
|
278
|
+
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
279
|
+
captureTeaserLine?: number;
|
|
280
|
+
onCorrectCapture?: CorrectCaptureAction;
|
|
281
|
+
onDecide: (item: NeedsMeEntry, decision: NeedsMeDecision) => Promise<void>;
|
|
282
|
+
/** Changes only the Why of an item that is already decided (an AI draft, a review request, a held email). Throws a sentence when it cannot. */
|
|
283
|
+
onEditReason?: (item: NeedsMeEntry, why: string) => Promise<void>;
|
|
284
|
+
onRetryDrafts?: () => void;
|
|
285
|
+
onOpened?: (item: NeedsMeEntry) => void;
|
|
286
|
+
loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
|
|
287
|
+
/** Other sections of the page (open assignments, sections). They scroll inside the feed, below the cards, so the feed and the detail keep the viewport's full height. */
|
|
288
|
+
below?: ReactNode;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
function Note({ children, ...rest }: { children: ReactNode } & Record<string, unknown>) {
|
|
292
|
+
return <div className="rounded-[20px] bg-[var(--feed-card)] p-6 text-sm text-muted-foreground" {...rest}>{children}</div>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
|
|
296
|
+
export function NeedsMeView({
|
|
297
|
+
loadState, entries, freshness, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
|
|
298
|
+
referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
|
|
299
|
+
}: NeedsMeViewProps) {
|
|
300
|
+
const themeMode = useResolvedPapyrusTheme();
|
|
301
|
+
const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
|
|
302
|
+
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
303
|
+
const [filter, setFilter] = useState<NeedsMeFilter>(initialFilter);
|
|
304
|
+
const [selectedId, setSelectedId] = useState("");
|
|
305
|
+
const [caughtUp, setCaughtUp] = useState(false);
|
|
306
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
307
|
+
const [recipeOpen, setRecipeOpen] = useState(false);
|
|
308
|
+
const [extras, setExtras] = useState<Record<string, DecisionExtras>>({});
|
|
309
|
+
const [scrollTarget, setScrollTarget] = useState<{ id: string; nonce: number } | null>(null);
|
|
310
|
+
const [, bumpOrder] = useState(0);
|
|
311
|
+
const session = stale ? "Confirming your sign-in. You can answer in a moment." : null;
|
|
312
|
+
|
|
313
|
+
// The order is taken ONCE per visit, when every source has been read, and then left alone: selecting, deciding, a refresh or a cache
|
|
314
|
+
// revalidation changes what the cards say, never where they are. Items that arrive later wait behind a "N new items: show" pill.
|
|
315
|
+
const live = useMemo(() => rankNeedsMe(entries, { now, freshness, threshold }), [entries, now, freshness, threshold]);
|
|
316
|
+
const snapshot = useRef<NeedsMeSnapshot | null>(null);
|
|
317
|
+
if (!snapshot.current && settled && entries.length > 0) snapshot.current = takeNeedsMeSnapshot(live);
|
|
318
|
+
const placed = snapshot.current ? applyNeedsMeSnapshot(snapshot.current, live) : { feed: live.feed, auto: live.auto, newCount: 0 };
|
|
319
|
+
const showNewItems = () => { snapshot.current = takeNeedsMeSnapshot(live); bumpOrder((count) => count + 1); };
|
|
320
|
+
|
|
321
|
+
const byId = useMemo(() => new Map(entries.map((item) => [item.id, item])), [entries]);
|
|
322
|
+
const latest = useRef({ byId, extras });
|
|
323
|
+
latest.current = { byId, extras };
|
|
324
|
+
const ratings = useRatingStore({
|
|
325
|
+
save: async (id, choice, detail) => {
|
|
326
|
+
const item = latest.current.byId.get(id);
|
|
327
|
+
if (!item) throw new Error("This item is no longer in the list.");
|
|
328
|
+
const typed = latest.current.extras[id] ?? NO_EXTRAS;
|
|
329
|
+
await onDecide(item, { choice, reasonCode: detail.reasonCode, why: detail.note, notThisKind: choice === "reject" && typed.notThisKind });
|
|
330
|
+
},
|
|
331
|
+
// The Why of an AI draft, a review request or a held email is typed in either order and edited after the decision, onto that decision.
|
|
332
|
+
sticky: (id) => { const item = latest.current.byId.get(id); return item ? !isReferenceLike(item) : false; },
|
|
333
|
+
saveNote: async (id, note) => {
|
|
334
|
+
const item = latest.current.byId.get(id);
|
|
335
|
+
if (!item) throw new Error("This item is no longer in the list.");
|
|
336
|
+
if (!onEditReason) throw new Error("A reason cannot be changed here.");
|
|
337
|
+
await onEditReason(item, note);
|
|
338
|
+
},
|
|
339
|
+
// An acknowledged flag counts as an Accept: it is decided, and only its note can change.
|
|
340
|
+
saved: (id) => { const item = latest.current.byId.get(id); return item ? decidedChoiceOf(item) ?? (item.decisionState === "acknowledged" ? "accept" : null) : null; },
|
|
341
|
+
seed: (id) => {
|
|
342
|
+
const item = latest.current.byId.get(id);
|
|
343
|
+
if (!item) return { note: "", reasonCode: null };
|
|
344
|
+
if (item.payload.kind !== "reference") return { note: recordedAnswer(item).why ?? "", reasonCode: null };
|
|
345
|
+
const { reference } = item.payload;
|
|
346
|
+
const pending = normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
347
|
+
return { note: pending ? "" : reference.curationStatusReason ?? "", reasonCode: referenceReasonCode(reference) ?? null };
|
|
348
|
+
},
|
|
349
|
+
needsReason: (id) => { const item = latest.current.byId.get(id); return item ? isReferenceLike(item) : true; },
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const counts = useMemo(() => countNeedsMe(placed.feed), [placed.feed]);
|
|
353
|
+
const visible = placed.feed.filter((item) => matchesFilter(item, filter));
|
|
354
|
+
const queue = visible.map((item) => item.id);
|
|
355
|
+
const selected = byId.get(selectedId) ?? null;
|
|
356
|
+
const selectedIndex = selected ? queue.indexOf(selected.id) : -1;
|
|
357
|
+
const previousId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
|
|
358
|
+
const nextId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
|
|
359
|
+
|
|
360
|
+
const select = (item: NeedsMeEntry) => {
|
|
361
|
+
setCaughtUp(false);
|
|
362
|
+
setSelectedId(item.id);
|
|
363
|
+
if (isMobile) setSheetOpen(true);
|
|
364
|
+
onOpened?.(item);
|
|
365
|
+
};
|
|
366
|
+
const goNext = () => {
|
|
367
|
+
if (nextId) { setCaughtUp(false); setSelectedId(nextId); setScrollTarget({ id: nextId, nonce: Date.now() }); const next = byId.get(nextId); if (next) onOpened?.(next); }
|
|
368
|
+
else if (!caughtUp) { setCaughtUp(true); setSelectedId(""); }
|
|
369
|
+
};
|
|
370
|
+
const goPrevious = () => {
|
|
371
|
+
if (previousId) { setCaughtUp(false); setSelectedId(previousId); setScrollTarget({ id: previousId, nonce: Date.now() }); }
|
|
372
|
+
};
|
|
373
|
+
const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
|
|
374
|
+
const closeDetail = () => { setCaughtUp(false); setSheetOpen(false); setSelectedId(""); };
|
|
375
|
+
|
|
376
|
+
// Previous / Next scrolls the feed to the same card, smoothly. Selecting a card by clicking it never scrolls.
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!scrollTarget || typeof document === "undefined") return;
|
|
379
|
+
document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
380
|
+
}, [scrollTarget]);
|
|
381
|
+
|
|
382
|
+
if (loadState === "loading") return <NotificationSkeleton />;
|
|
383
|
+
if (loadState === "forbidden") return <div data-notification-state="forbidden" role="alert"><strong>The Notification Center is for editors and admins</strong></div>;
|
|
384
|
+
if (loadState === "unavailable") return <div data-notification-state="unavailable" role="alert"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
|
|
385
|
+
|
|
386
|
+
const waiting = counts.all;
|
|
387
|
+
const autoCount = placed.auto.length + legacyAuto.length;
|
|
388
|
+
const nothing = waiting === 0 && placed.feed.length === 0 && !referencesLoading;
|
|
389
|
+
const detailBody = (item: NeedsMeEntry) => (
|
|
390
|
+
<NeedsMeDetail
|
|
391
|
+
attachments={referenceAttachments} captureTeaserLine={captureTeaserLine} demo={demo} disabledReason={disabledFor(item, session, referenceDisabledReason)} extras={extras[item.id] ?? NO_EXTRAS}
|
|
392
|
+
item={item} loadEmail={loadEmail} onCorrectCapture={onCorrectCapture} onExtras={(next) => setExtras((current) => ({ ...current, [item.id]: next }))}
|
|
393
|
+
predictionLoading={predictionLoading} ratings={ratings}
|
|
394
|
+
/>
|
|
395
|
+
);
|
|
396
|
+
const caughtUpPanel = (
|
|
397
|
+
<div className="space-y-2 p-6 text-center" data-review-caught-up>
|
|
398
|
+
<h3 className="text-lg font-semibold">All caught up</h3>
|
|
399
|
+
<p className="text-sm text-muted-foreground">There is nothing more in this list. Use Previous to go back.</p>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
const queueBar = queue.length || caughtUp ? (
|
|
403
|
+
<ReviewQueueBar anchored caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={selectedIndex >= 0 ? selectedIndex + 1 : null} total={queue.length} />
|
|
404
|
+
) : null;
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-detail-reserved="true" data-notification-center {...FEED_SURFACE_PROPS}>
|
|
408
|
+
<div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto md:pr-1" data-needs-me-main data-needs-me-scroll>
|
|
409
|
+
<header className="space-y-4">
|
|
410
|
+
<div className="space-y-1.5">
|
|
411
|
+
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
|
|
412
|
+
<p className="text-lg text-muted-foreground" data-notification-summary role="status">{waitingTextFor(counts)}</p>
|
|
413
|
+
</div>
|
|
414
|
+
<ShapingStrip stats={shaping} />
|
|
415
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
416
|
+
<div aria-label="Show" className="inline-flex max-w-full overflow-x-auto rounded-full bg-[var(--feed-card)] p-1" data-notification-filters role="group">
|
|
417
|
+
{NEEDS_ME_FILTERS.map((key) => {
|
|
418
|
+
const on = filter === key;
|
|
419
|
+
return (
|
|
420
|
+
<button
|
|
421
|
+
aria-pressed={on}
|
|
422
|
+
className={cn(PILL, "min-h-9 shrink-0 gap-1 px-2 text-[0.8rem] sm:gap-1.5 sm:px-3.5 sm:text-sm", on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "text-muted-foreground hover:text-foreground")}
|
|
423
|
+
data-notification-filter={key}
|
|
424
|
+
key={key}
|
|
425
|
+
onClick={() => setFilter(key)}
|
|
426
|
+
type="button"
|
|
427
|
+
>
|
|
428
|
+
{on ? <CheckIcon aria-hidden="true" className="hidden size-3.5 sm:block" /> : null}
|
|
429
|
+
{NEEDS_ME_FILTER_LABELS[key]}
|
|
430
|
+
<span className="text-xs tabular-nums opacity-70" data-notification-filter-count>{counts[key]}</span>
|
|
431
|
+
</button>
|
|
432
|
+
);
|
|
433
|
+
})}
|
|
434
|
+
</div>
|
|
435
|
+
<button
|
|
436
|
+
aria-expanded={recipeOpen}
|
|
437
|
+
className={cn(PILL, "text-muted-foreground hover:text-foreground", recipeOpen && "bg-[var(--feed-card)] text-foreground")}
|
|
438
|
+
data-needs-me-recipe-toggle
|
|
439
|
+
onClick={() => setRecipeOpen((open) => !open)}
|
|
440
|
+
type="button"
|
|
441
|
+
>
|
|
442
|
+
<InfoIcon aria-hidden="true" className="size-4" />How this is ordered
|
|
443
|
+
</button>
|
|
444
|
+
</div>
|
|
445
|
+
{recipeOpen ? <NeedsMeRecipe freshness={freshness} threshold={threshold} /> : null}
|
|
446
|
+
<p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
|
|
447
|
+
Decide what you like and leave the rest. Each item says what happens if you do nothing, and only a hard gate waits for you.
|
|
448
|
+
</p>
|
|
449
|
+
{stale ? <p className="text-xs text-muted-foreground" data-notification-stale role="status"><Loader2Icon aria-hidden="true" className="mr-1 inline size-3.5 animate-spin" />Showing your last view while it updates.</p> : null}
|
|
450
|
+
{demo ? <p className="text-xs text-muted-foreground" data-notification-demo>Preview: what you decide here is not saved.</p> : null}
|
|
451
|
+
{error ? <p className="text-sm text-destructive" data-notification-error role="alert">{error}</p> : null}
|
|
452
|
+
</header>
|
|
453
|
+
|
|
454
|
+
{placed.newCount > 0 ? (
|
|
455
|
+
// Sits on top of the feed, outside its flow: announcing new items never moves a card.
|
|
456
|
+
<div className="pointer-events-none sticky top-3 z-20 flex h-0 justify-center overflow-visible">
|
|
457
|
+
<button
|
|
458
|
+
className="pointer-events-auto inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium text-foreground shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
459
|
+
data-feed-new-items
|
|
460
|
+
onClick={showNewItems}
|
|
461
|
+
type="button"
|
|
462
|
+
>
|
|
463
|
+
{placed.newCount} new {placed.newCount === 1 ? "item" : "items"}: show
|
|
464
|
+
</button>
|
|
465
|
+
</div>
|
|
466
|
+
) : null}
|
|
467
|
+
|
|
468
|
+
{nothing ? (
|
|
469
|
+
<div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
|
|
470
|
+
<CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
|
|
471
|
+
<h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
|
|
472
|
+
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New drafts, references, emails and reviews will appear here as they arrive.</p>
|
|
473
|
+
</div>
|
|
474
|
+
) : null}
|
|
475
|
+
|
|
476
|
+
{draftsFailed ? (
|
|
477
|
+
<Note role="alert">
|
|
478
|
+
<p>The drafts could not be loaded right now.</p>
|
|
479
|
+
<button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
|
|
480
|
+
</Note>
|
|
481
|
+
) : null}
|
|
482
|
+
{missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these drafts {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
|
|
483
|
+
|
|
484
|
+
{referencesLoading && !placed.feed.length ? (
|
|
485
|
+
<NotificationSkeleton count={3} bare />
|
|
486
|
+
) : (
|
|
487
|
+
<section aria-label="Needs you" data-needs-me-feed>
|
|
488
|
+
<div className={FEED_GRID} data-feed-grid data-notification-grid>
|
|
489
|
+
{visible.map((item) => (
|
|
490
|
+
<NeedsMeCard
|
|
491
|
+
disabledReason={disabledFor(item, session, referenceDisabledReason)} item={item} key={item.id} now={now} onOpen={() => select(item)}
|
|
492
|
+
predictionLoading={predictionLoading} ratings={ratings} selected={selectedId === item.id}
|
|
493
|
+
/>
|
|
494
|
+
))}
|
|
495
|
+
</div>
|
|
496
|
+
{!nothing && visible.length === 0 && !referencesLoading ? <Note data-filter-empty>Nothing in {NEEDS_ME_FILTER_LABELS[filter]} needs you right now.</Note> : null}
|
|
497
|
+
</section>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{autoCount ? (
|
|
501
|
+
<details className="group rounded-[20px] bg-[var(--feed-card)] p-4" data-notification-section="auto-decided">
|
|
502
|
+
<summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 text-sm font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
503
|
+
<ChevronDownIcon aria-hidden="true" className="size-4 transition-transform group-open:rotate-180" />
|
|
504
|
+
Decided automatically ({autoCount})
|
|
505
|
+
</summary>
|
|
506
|
+
<p className="mt-2 text-xs text-muted-foreground">The Cyclotron was sure enough to go ahead without you, and its answer stands unless you overrule it. Overrule opens the item beside the feed.</p>
|
|
507
|
+
<ul className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
508
|
+
{placed.auto.map((item) => (
|
|
509
|
+
<li data-auto-decided={item.id} key={item.id}>
|
|
510
|
+
<div className="flex h-full flex-col gap-2 rounded-[16px] bg-[var(--feed-control)] p-3 text-sm">
|
|
511
|
+
<span className="block break-words font-medium leading-snug">{item.title}</span>
|
|
512
|
+
<span className="block text-xs text-muted-foreground" data-auto-decided-prediction>
|
|
513
|
+
Cyclotron: {item.prediction?.label === "reject" ? "Reject" : "Accept"}{item.prediction?.confidence != null ? ` ${Math.round(item.prediction.confidence * 100)}%` : ""} · {NEEDS_ME_TYPE_LABELS[item.type]}
|
|
514
|
+
</span>
|
|
515
|
+
<button className={cn(PILL, "mt-auto min-h-9 self-start bg-[var(--feed-card)] px-3 text-xs")} data-overrule onClick={() => select(item)} type="button">
|
|
516
|
+
<Undo2Icon aria-hidden="true" className="size-3.5" />Overrule
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
</li>
|
|
520
|
+
))}
|
|
521
|
+
{legacyAuto.map((entry) => (
|
|
522
|
+
<li data-cyclotron-entry={entry.lineageId} key={entry.lineageId}>
|
|
523
|
+
<Link className="block rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring" href={entry.href}>
|
|
524
|
+
<span className="block break-words font-medium leading-snug">{entry.title}</span>
|
|
525
|
+
<span className="block text-xs text-muted-foreground">
|
|
526
|
+
Classified as {entry.label} · {entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`}{entry.reviewedByPerson ? " · reviewed by a person" : ""}
|
|
527
|
+
</span>
|
|
528
|
+
</Link>
|
|
529
|
+
</li>
|
|
530
|
+
))}
|
|
531
|
+
</ul>
|
|
532
|
+
</details>
|
|
533
|
+
) : null}
|
|
534
|
+
|
|
535
|
+
{recent.length ? (
|
|
536
|
+
<section aria-labelledby="notification-decided" data-notification-section="decided">
|
|
537
|
+
<h2 className="mb-1 text-3xl font-bold tracking-tight text-primary sm:text-4xl" id="notification-decided">Recently decided</h2>
|
|
538
|
+
<p className="mb-5 text-sm text-muted-foreground">The last few things you decided.</p>
|
|
539
|
+
<RecentDecisions entries={recent} />
|
|
540
|
+
</section>
|
|
541
|
+
) : null}
|
|
542
|
+
{below}
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<aside
|
|
546
|
+
aria-label="Detail"
|
|
547
|
+
className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:h-full md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]"
|
|
548
|
+
data-feed-flat=""
|
|
549
|
+
data-needs-me-detail-shell
|
|
550
|
+
data-detail-open={selected ? "true" : "false"}
|
|
551
|
+
>
|
|
552
|
+
<div className="flex shrink-0 items-center justify-between gap-2 px-4 pb-1 pt-3"><span className="text-sm font-medium text-foreground">Detail</span></div>
|
|
553
|
+
<div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
|
|
554
|
+
<ScrollArea className="min-h-0 flex-1">
|
|
555
|
+
{isMobile ? null : selected ? <div key={selected.id}>{detailBody(selected)}</div> : caughtUp ? caughtUpPanel : (
|
|
556
|
+
<p className="p-4 text-sm text-muted-foreground" data-needs-me-detail-empty>Select an item to read it here and decide.</p>
|
|
557
|
+
)}
|
|
558
|
+
</ScrollArea>
|
|
559
|
+
</div>
|
|
560
|
+
{queueBar}
|
|
561
|
+
</aside>
|
|
562
|
+
|
|
563
|
+
{isMobile ? (
|
|
564
|
+
<Sheet onOpenChange={(open) => { if (!open) closeDetail(); else setSheetOpen(true); }} open={(sheetOpen && Boolean(selected)) || caughtUp}>
|
|
565
|
+
<SheetContent className="gap-0 rounded-t-[length:var(--feed-radius-xl)] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" initialFocus={false} side="bottom" {...themeAttributes}>
|
|
566
|
+
<SheetHeader className="px-4 py-3"><SheetTitle className="text-left text-base">{selected ? NEEDS_ME_TYPE_LABELS[selected.type] : "Detail"}</SheetTitle></SheetHeader>
|
|
567
|
+
{selected ? (
|
|
568
|
+
<div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
|
|
569
|
+
<ScrollArea className="max-h-[70dvh]"><div key={selected.id}>{detailBody(selected)}</div></ScrollArea>
|
|
570
|
+
</div>
|
|
571
|
+
) : caughtUp ? caughtUpPanel : null}
|
|
572
|
+
{queueBar}
|
|
573
|
+
</SheetContent>
|
|
574
|
+
</Sheet>
|
|
575
|
+
) : null}
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|