@anthusai/papyrus 5.47.0 → 5.48.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 +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/newsroom-ops-overview.tsx +10 -9
- package/components/newsroom-organizer.css +7 -0
- package/components/newsroom-references-view.tsx +2 -110
- package/components/notification-cards.tsx +14 -9
- package/components/notification-center.tsx +145 -304
- package/components/reference-capture-panel.tsx +2 -2
- package/components/reference-feed.tsx +1 -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 +6 -4
- 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 +17 -4
- package/lib/review-drafts.ts +16 -0
- package/lib/review-fixture.ts +20 -0
- package/package.json +10 -1
- package/routes.manifest.json +1 -1
|
@@ -1,296 +1,53 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import { cn } from "../lib/utils";
|
|
7
|
-
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
5
|
+
import type { EmailDecisionResult, MonitoringEmailDetails, MonitoringEmailSummary } from "../lib/monitoring-overview";
|
|
8
6
|
import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
|
|
9
7
|
import type { DraftLoad, ReviewLoad } from "../lib/review-client";
|
|
10
8
|
import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
type NotificationFilter,
|
|
14
|
-
} from "../lib/notification-feed";
|
|
15
|
-
import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type CyclotronEntry, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
|
|
16
|
-
import type { ReferenceRecord } from "../lib/category-repository";
|
|
9
|
+
import { previewsBySourceAddress, recentDecisions } from "../lib/notification-feed";
|
|
10
|
+
import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
|
|
17
11
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
18
|
-
import {
|
|
12
|
+
import { learnFreshness } from "../lib/reference-feed-ranking";
|
|
13
|
+
import { learnConfidenceThreshold, shapingThisWeek } from "../lib/needs-me-ranking";
|
|
14
|
+
import { buildNeedsMeItems } from "../lib/needs-me-items";
|
|
15
|
+
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
19
16
|
import { normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
20
17
|
import type { ReferencePreview } from "../lib/reference-preview";
|
|
18
|
+
import { referenceLineageId } from "../lib/newsroom-references";
|
|
21
19
|
import { loadNotificationSnapshot, saveNotificationSnapshot } from "../lib/reference-feed-cache";
|
|
22
20
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
23
21
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
24
|
-
import { FIXTURE_DRAFTS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
22
|
+
import { FIXTURE_DRAFTS, FIXTURE_EMAIL_DETAILS, FIXTURE_HELD_EMAILS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
23
|
+
import { NeedsMeView, type NeedsMeDecision } from "./needs-me-feed";
|
|
24
|
+
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
25
|
+
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
27
26
|
import type { ReviewChoice } from "./reference-review-card";
|
|
28
27
|
|
|
29
28
|
export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
30
29
|
export type DraftsLoadState = "loading" | "ready" | "failed";
|
|
31
|
-
export type DraftExtras = { why: string; notThisKind: boolean };
|
|
32
30
|
|
|
33
31
|
/** What a real-data preview shows instead of the invented sample (scripts/export-notification-center-fixture.py). */
|
|
34
|
-
export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][] };
|
|
32
|
+
export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][]; emails?: MonitoringEmailSummary[] };
|
|
35
33
|
|
|
36
|
-
const GRID = "grid gap-5 sm:grid-cols-2 xl:grid-cols-3";
|
|
37
|
-
/** References are shown a screenful at a time, ranked as in the References feed; the rest are one tap away. */
|
|
38
|
-
const REFERENCE_PAGE = 6;
|
|
39
|
-
const CONFIRMING = "Confirming your sign-in. You can answer in a moment.";
|
|
40
|
-
const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
|
|
41
34
|
const NO_VISIT: ReadonlySet<string> = new Set();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
loadState: NotificationLoadState;
|
|
45
|
-
requests: ReviewRequest[];
|
|
46
|
-
/** Canonical References (any status) and the Cyclotron's decisions on them. */
|
|
47
|
-
references?: ReferenceRecord[];
|
|
48
|
-
decisions?: ReadonlyMap<string, RelevanceDecisionView>;
|
|
49
|
-
previews?: ReadonlyMap<string, ReferencePreview>;
|
|
50
|
-
referencesLoading?: boolean;
|
|
51
|
-
predictionLoading?: boolean;
|
|
52
|
-
referenceDisabledReason?: string | null;
|
|
53
|
-
/** What the Cyclotron decided on its own confidence: read only, collapsed at the foot of the page. */
|
|
54
|
-
autoDecided?: CyclotronEntry[];
|
|
55
|
-
now?: number;
|
|
56
|
-
visitRated?: ReadonlySet<string>;
|
|
57
|
-
storyTitles?: Readonly<Record<string, string>>;
|
|
58
|
-
deletedStories?: ReadonlySet<string>;
|
|
59
|
-
demo?: boolean;
|
|
60
|
-
/** True while the cards are a saved copy that is being refreshed. */
|
|
61
|
-
stale?: boolean;
|
|
62
|
-
busyId?: string | null;
|
|
63
|
-
error?: string | null;
|
|
64
|
-
/** The AI drafts that draft reviews are about, by proposal id, and which ids have no proposal on this site. */
|
|
65
|
-
drafts?: Readonly<Record<string, DraftView>>;
|
|
66
|
-
missingDrafts?: ReadonlySet<string>;
|
|
67
|
-
draftsState?: DraftsLoadState;
|
|
68
|
-
/** Keyed `<request id>#<draft id>`. */
|
|
69
|
-
draftErrors?: Readonly<Record<string, string>>;
|
|
70
|
-
draftBusyId?: string | null;
|
|
71
|
-
/** Draft keys and request ids decided in this visit: their cards stay in place showing the answer. */
|
|
72
|
-
stay?: ReadonlySet<string>;
|
|
73
|
-
initialFilter?: NotificationFilter;
|
|
74
|
-
onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
|
|
75
|
-
onDecideDraft?: (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => void;
|
|
76
|
-
onRetryDrafts?: () => void;
|
|
77
|
-
onMarkRead?: (requestId: string) => void;
|
|
78
|
-
onOpenReference?: (lineageId: string) => void;
|
|
79
|
-
onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
80
|
-
onVisitRated?: (lineageId: string) => void;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
function Section({ children, count, id, subtitle, title }: { children: ReactNode; count?: number; id: string; subtitle?: ReactNode; title: string }) {
|
|
84
|
-
return (
|
|
85
|
-
<section aria-labelledby={`notification-${id}`} data-notification-section={id}>
|
|
86
|
-
<h2 className="mb-1 text-3xl font-bold tracking-tight text-primary sm:text-4xl" id={`notification-${id}`}>
|
|
87
|
-
{title}{typeof count === "number" ? <span className="ml-2 text-xl font-semibold tabular-nums text-muted-foreground" data-section-count>{count}</span> : null}
|
|
88
|
-
</h2>
|
|
89
|
-
{subtitle ? <p className="mb-5 text-sm text-muted-foreground">{subtitle}</p> : <div className="mb-5" />}
|
|
90
|
-
{children}
|
|
91
|
-
</section>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function Note({ children, ...rest }: { children: ReactNode } & Record<string, unknown>) {
|
|
96
|
-
return <div className="rounded-[20px] bg-[var(--feed-card)] p-6 text-sm text-muted-foreground" {...rest}>{children}</div>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** The CMS console home. It answers one question, "What needs me?": cards for AI drafts, References and anything else, one tap to decide each. */
|
|
100
|
-
export function NotificationCenterView({
|
|
101
|
-
loadState, requests, references = [], decisions, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
|
|
102
|
-
autoDecided = [], now = 0, visitRated = NO_VISIT, demo = false, stale = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null,
|
|
103
|
-
drafts = {}, missingDrafts = new Set<string>(), draftsState = "ready", draftErrors = {}, draftBusyId = null, stay = new Set<string>(), initialFilter = "all",
|
|
104
|
-
onAnswer, onDecideDraft, onRetryDrafts, onMarkRead, onOpenReference, onRateReference, onVisitRated,
|
|
105
|
-
}: ViewProps) {
|
|
106
|
-
const [filter, setFilter] = useState<NotificationFilter>(initialFilter);
|
|
107
|
-
const [shown, setShown] = useState(REFERENCE_PAGE);
|
|
108
|
-
const session = stale ? CONFIRMING : null;
|
|
109
|
-
|
|
110
|
-
const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now, visitRated }), [references, decisions, now, visitRated]);
|
|
111
|
-
const referenceItems = partition.feed;
|
|
112
|
-
const waitingReferences = useMemo(() => referenceItems.filter((item) => normalizeReferenceCurationStatus(item.reference.curationStatus) === "pending").length, [referenceItems]);
|
|
113
|
-
const items = useMemo(
|
|
114
|
-
() => buildNotificationItems({ requests, drafts, missingDrafts, draftsState, stay, referenceCount: waitingReferences }),
|
|
115
|
-
[requests, drafts, missingDrafts, draftsState, stay, waitingReferences],
|
|
116
|
-
);
|
|
117
|
-
const bySource = useMemo(() => previewsBySourceAddress(references, previews), [references, previews]);
|
|
118
|
-
const recent = useMemo(() => recentDecisions(requests, references, demo), [requests, references, demo]);
|
|
119
|
-
const openDraftRequests = useMemo(() => sectionReviews(requests).draftBatches, [requests]);
|
|
120
|
-
const missingCount = openDraftRequests.reduce((total, request) => total + draftIdsOf(request).filter((id) => missingDrafts.has(id)).length, 0);
|
|
121
|
-
|
|
122
|
-
if (loadState === "loading") return <NotificationSkeleton />;
|
|
123
|
-
if (loadState === "forbidden") return <div data-notification-state="forbidden" role="alert"><strong>The Notification Center is for editors and admins</strong></div>;
|
|
124
|
-
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>;
|
|
125
|
-
|
|
126
|
-
const counts = items.counts;
|
|
127
|
-
const nothing = counts.drafts + counts.references + counts.other === 0;
|
|
128
|
-
const waitingText_ = waitingText(counts);
|
|
129
|
-
const anyCards = items.drafts.length + items.reviews.length + items.flags.length + referenceItems.length > 0;
|
|
130
|
-
const visibleReferences = referenceItems.slice(0, shown);
|
|
131
|
-
const moreReferences = referenceItems.length - visibleReferences.length;
|
|
132
|
-
const sectionGrid = (children: ReactNode, grid = GRID) => <div className={grid} data-notification-grid>{children}</div>;
|
|
133
|
-
|
|
134
|
-
const draftCards = items.drafts.map((item) => (
|
|
135
|
-
<DraftCard
|
|
136
|
-
busy={draftBusyId === item.key} error={draftErrors[item.key] ?? null} item={item} key={item.key} previews={bySource} session={session}
|
|
137
|
-
onDecide={(draftId, choice, extras) => onDecideDraft?.(item.request, draftId, choice, extras)}
|
|
138
|
-
/>
|
|
139
|
-
));
|
|
140
|
-
const requestAnswer = (request: ReviewRequest, choice: DraftChoice, extras: DecisionExtras) =>
|
|
141
|
-
onAnswer?.(request, choice === "accept" ? { decision: "approve", feedback: extras.why || null } : { decision: "reject", feedback: extras.why || null, steer: extras.notThisKind ? "not_this_kind" : null });
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<div className="space-y-10" data-notification-center {...FEED_SURFACE_PROPS}>
|
|
145
|
-
<header className="space-y-4">
|
|
146
|
-
<div className="space-y-1.5">
|
|
147
|
-
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
|
|
148
|
-
<p className="text-lg text-muted-foreground" data-notification-summary role="status">{waitingText_}</p>
|
|
149
|
-
</div>
|
|
150
|
-
<div aria-label="Show" className="flex flex-wrap gap-1" data-notification-filters role="group">
|
|
151
|
-
<div className="inline-flex max-w-full rounded-full bg-[var(--feed-card)] p-1">
|
|
152
|
-
{NOTIFICATION_FILTERS.map((key) => {
|
|
153
|
-
const on = filter === key;
|
|
154
|
-
return (
|
|
155
|
-
<button
|
|
156
|
-
aria-pressed={on}
|
|
157
|
-
className={cn(PILL, "min-h-9 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")}
|
|
158
|
-
data-notification-filter={key}
|
|
159
|
-
key={key}
|
|
160
|
-
onClick={() => setFilter(key)}
|
|
161
|
-
type="button"
|
|
162
|
-
>
|
|
163
|
-
{on ? <CheckIcon aria-hidden="true" className="hidden size-3.5 sm:block" /> : null}
|
|
164
|
-
{FILTER_LABELS[key]}
|
|
165
|
-
<span className="text-xs tabular-nums opacity-70" data-notification-filter-count>{filterCount(key, counts)}</span>
|
|
166
|
-
</button>
|
|
167
|
-
);
|
|
168
|
-
})}
|
|
169
|
-
</div>
|
|
170
|
-
</div>
|
|
171
|
-
<p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
|
|
172
|
-
Decide what you like and leave the rest. A draft you leave alone stays in Proposals, and nothing is published without an editor. Only a hard gate waits for you.
|
|
173
|
-
</p>
|
|
174
|
-
{stale ? <p className="text-xs text-muted-foreground" data-notification-stale role="status">Showing your last view while it updates.</p> : null}
|
|
175
|
-
{demo ? <p className="text-xs text-muted-foreground" data-notification-demo>Preview: what you decide here is not saved.</p> : null}
|
|
176
|
-
{error ? <p className="text-sm text-destructive" data-notification-error role="alert">{error}</p> : null}
|
|
177
|
-
</header>
|
|
178
|
-
|
|
179
|
-
{nothing && !anyCards && !referencesLoading ? (
|
|
180
|
-
<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>
|
|
181
|
-
<CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
|
|
182
|
-
<h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
|
|
183
|
-
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New drafts, references and reviews will appear here as they arrive.</p>
|
|
184
|
-
</div>
|
|
185
|
-
) : null}
|
|
186
|
-
|
|
187
|
-
{showsSection(filter, "drafts") && (items.drafts.length || draftsState === "failed" || missingCount > 0) ? (
|
|
188
|
-
<Section count={counts.drafts} id="drafts" subtitle="AI story drafts. Accept sends one on to an editor in Proposals; Reject declines it." title="Drafts">
|
|
189
|
-
{draftsState === "failed" ? (
|
|
190
|
-
<Note role="alert">
|
|
191
|
-
<p>The drafts could not be loaded right now.</p>
|
|
192
|
-
<button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
|
|
193
|
-
</Note>
|
|
194
|
-
) : sectionGrid(draftCards)}
|
|
195
|
-
{missingCount ? <p className="mt-4 text-sm text-muted-foreground">{missingCount} of these drafts {missingCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
|
|
196
|
-
</Section>
|
|
197
|
-
) : null}
|
|
198
|
-
|
|
199
|
-
{showsSection(filter, "references") && (referenceItems.length || referencesLoading) ? (
|
|
200
|
-
<Section count={referencesLoading ? undefined : waitingReferences} id="references" subtitle="Sources the Cyclotron wants a person to rate. The same cards as the References feed, best bets first." title="References">
|
|
201
|
-
{referencesLoading ? <ReferenceFeedSkeleton count={3} /> : (
|
|
202
|
-
<>
|
|
203
|
-
<div className={GRID} data-notification-grid data-notification-references>
|
|
204
|
-
{visibleReferences.map((item) => (
|
|
205
|
-
<FeedCard
|
|
206
|
-
decision={item.decision} disabledReason={referenceDisabledReason} item={item} key={item.lineageId} lead={false} now={now}
|
|
207
|
-
onOpen={() => onOpenReference?.(item.lineageId)}
|
|
208
|
-
onRate={async (reference, choice, reasonCode) => { onVisitRated?.(item.lineageId); await onRateReference?.(reference, choice, reasonCode); }}
|
|
209
|
-
predictionLoading={predictionLoading} preview={previews.get(item.lineageId)} selected={false} visitRated={visitRated.has(item.lineageId)}
|
|
210
|
-
/>
|
|
211
|
-
))}
|
|
212
|
-
</div>
|
|
213
|
-
<div className="mt-5 flex flex-wrap items-center gap-2">
|
|
214
|
-
{moreReferences > 0 ? (
|
|
215
|
-
<button className={cn(PILL, "bg-[var(--feed-control)] text-foreground")} data-notification-more-references onClick={() => setShown((current) => current + REFERENCE_PAGE)} type="button">
|
|
216
|
-
Show {Math.min(moreReferences, REFERENCE_PAGE)} more
|
|
217
|
-
</button>
|
|
218
|
-
) : null}
|
|
219
|
-
<Link className={cn(PILL, "text-muted-foreground hover:text-foreground")} href={getNewsroomNavHref("/newsroom/references", demo)}>
|
|
220
|
-
{partition.earlier.length ? `Open the References feed (${partition.earlier.length} earlier)` : "Open the References feed"}
|
|
221
|
-
</Link>
|
|
222
|
-
</div>
|
|
223
|
-
</>
|
|
224
|
-
)}
|
|
225
|
-
</Section>
|
|
226
|
-
) : null}
|
|
227
|
-
|
|
228
|
-
{showsSection(filter, "other") && items.reviews.length ? (
|
|
229
|
-
<Section count={items.reviews.filter((item) => item.request.state === "open").length} id="reviews" subtitle="Reviews that are about more than one draft or one source." title="Reviews">
|
|
230
|
-
{sectionGrid(items.reviews.map((item) => (
|
|
231
|
-
<RequestCard
|
|
232
|
-
busy={busyId === item.request.id} deleted={deletedStories} demo={demo} error={null} item={item} key={item.key}
|
|
233
|
-
onAnswer={requestAnswer} onOpen={onMarkRead} session={session} titles={storyTitles}
|
|
234
|
-
/>
|
|
235
|
-
)))}
|
|
236
|
-
</Section>
|
|
237
|
-
) : null}
|
|
238
|
-
|
|
239
|
-
{showsSection(filter, "other") && items.flags.length ? (
|
|
240
|
-
<Section count={items.flags.filter((item) => item.request.state === "open").length} id="flags" subtitle="Nothing is waiting on these. Acknowledge them to clear them." title="For your information">
|
|
241
|
-
{sectionGrid(items.flags.map((item) => (
|
|
242
|
-
<FlagCard
|
|
243
|
-
busy={busyId === item.request.id} deleted={deletedStories} demo={demo} error={null} item={item} key={item.key} onOpen={onMarkRead} session={session} titles={storyTitles}
|
|
244
|
-
onAcknowledge={(request, note) => onAnswer?.(request, { decision: "acknowledge", feedback: note || null })}
|
|
245
|
-
/>
|
|
246
|
-
)))}
|
|
247
|
-
</Section>
|
|
248
|
-
) : null}
|
|
249
|
-
|
|
250
|
-
{!nothing && filter !== "all" && filterCount(filter, counts) === 0 && !referencesLoading ? (
|
|
251
|
-
<Note data-filter-empty>Nothing in {FILTER_LABELS[filter]} needs you right now.</Note>
|
|
252
|
-
) : null}
|
|
253
|
-
|
|
254
|
-
{autoDecided.length ? (
|
|
255
|
-
<details className="group rounded-[20px] bg-[var(--feed-card)] p-4" data-notification-section="auto-decided">
|
|
256
|
-
<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">
|
|
257
|
-
<ChevronDownIcon aria-hidden="true" className="size-4 transition-transform group-open:rotate-180" />
|
|
258
|
-
Decided automatically ({autoDecided.length})
|
|
259
|
-
</summary>
|
|
260
|
-
<p className="mt-2 text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
|
|
261
|
-
<ul className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
262
|
-
{autoDecided.map((entry) => (
|
|
263
|
-
<li data-cyclotron-entry={entry.lineageId} key={entry.lineageId}>
|
|
264
|
-
<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}>
|
|
265
|
-
<span className="block break-words font-medium leading-snug">{entry.title}</span>
|
|
266
|
-
<span className="block text-xs text-muted-foreground">
|
|
267
|
-
Classified as {entry.label} · {entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`}{entry.reviewedByPerson ? " · reviewed by a person" : ""}
|
|
268
|
-
</span>
|
|
269
|
-
</Link>
|
|
270
|
-
</li>
|
|
271
|
-
))}
|
|
272
|
-
</ul>
|
|
273
|
-
</details>
|
|
274
|
-
) : null}
|
|
275
|
-
|
|
276
|
-
{recent.length ? (
|
|
277
|
-
<Section id="decided" subtitle="The last few things you decided." title="Recently decided">
|
|
278
|
-
<RecentDecisions entries={recent} />
|
|
279
|
-
</Section>
|
|
280
|
-
) : null}
|
|
281
|
-
</div>
|
|
282
|
-
);
|
|
283
|
-
}
|
|
35
|
+
const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
|
|
36
|
+
const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
284
37
|
|
|
285
38
|
/** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
|
|
286
39
|
export type NotificationApi = {
|
|
287
40
|
loadReviews: () => Promise<ReviewLoad>;
|
|
288
41
|
loadDrafts: (ids: string[]) => Promise<DraftLoad>;
|
|
289
|
-
loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[] }>;
|
|
42
|
+
loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
|
|
290
43
|
answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
|
|
291
44
|
saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
|
|
292
45
|
markReviewRead: (userKey: string, requestId: string) => Promise<void>;
|
|
293
46
|
transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
|
|
47
|
+
/** Email submissions held for review (Monitoring's own read). Optional: a site without monitoring has none. */
|
|
48
|
+
loadHeldEmails?: () => Promise<MonitoringEmailSummary[]>;
|
|
49
|
+
loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
|
|
50
|
+
decideEmail?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
294
51
|
};
|
|
295
52
|
|
|
296
53
|
const liveApi: NotificationApi = {
|
|
@@ -301,6 +58,20 @@ const liveApi: NotificationApi = {
|
|
|
301
58
|
saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
|
|
302
59
|
markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
|
|
303
60
|
transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
|
|
61
|
+
loadHeldEmails: async () => {
|
|
62
|
+
const { loadMonitoringPage } = await import("../lib/monitoring-client");
|
|
63
|
+
const rows: MonitoringEmailSummary[] = [];
|
|
64
|
+
let cursor: string | null = null;
|
|
65
|
+
do {
|
|
66
|
+
const page = await loadMonitoringPage("emailSubmissions", cursor, "publication", true);
|
|
67
|
+
if (!("rows" in page)) return rows;
|
|
68
|
+
rows.push(...(page.rows as MonitoringEmailSummary[]));
|
|
69
|
+
cursor = page.cursor;
|
|
70
|
+
} while (cursor);
|
|
71
|
+
return rows;
|
|
72
|
+
},
|
|
73
|
+
loadEmail: async (receiptId) => (await import("../lib/monitoring-client")).loadMonitoringEmailSubmission(receiptId),
|
|
74
|
+
decideEmail: async (receiptId, decision, reason) => (await import("../lib/monitoring-client")).decideMonitoringEmailSubmission(receiptId, decision, reason),
|
|
304
75
|
};
|
|
305
76
|
|
|
306
77
|
/** The demo and the real-data preview answer in memory with the same rules as the live data API; nothing leaves the page. */
|
|
@@ -318,6 +89,9 @@ const demoApi: NotificationApi = {
|
|
|
318
89
|
},
|
|
319
90
|
markReviewRead: async () => undefined,
|
|
320
91
|
transitionProposalPath: async () => ({ status: "ok" }),
|
|
92
|
+
loadHeldEmails: async () => FIXTURE_HELD_EMAILS,
|
|
93
|
+
loadEmail: async (receiptId) => FIXTURE_EMAIL_DETAILS[receiptId] ?? { state: "unavailable" },
|
|
94
|
+
decideEmail: async (_receiptId, decision) => ({ ok: true, already: false, quarantine: { state: decision === "accept" ? "released" : "discarded", decision: null, failure: null } }),
|
|
321
95
|
};
|
|
322
96
|
|
|
323
97
|
type ContainerProps = {
|
|
@@ -328,8 +102,11 @@ type ContainerProps = {
|
|
|
328
102
|
referencesLoading?: boolean;
|
|
329
103
|
predictionLoading?: boolean;
|
|
330
104
|
referenceDisabledReason?: string | null;
|
|
331
|
-
|
|
332
|
-
|
|
105
|
+
onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null, note?: string) => Promise<void> | void;
|
|
106
|
+
/** The Reference attachments (previews, captured text), for the cards' pictures and the capture checks. */
|
|
107
|
+
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
108
|
+
/** Saves a pasted or corrected capture. Without it the capture panel has no edit action. */
|
|
109
|
+
onCorrectCapture?: CorrectCaptureAction;
|
|
333
110
|
actorLabel: string;
|
|
334
111
|
demo: boolean;
|
|
335
112
|
/** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
|
|
@@ -339,30 +116,40 @@ type ContainerProps = {
|
|
|
339
116
|
|
|
340
117
|
const REFRESH_MILLISECONDS = 60_000;
|
|
341
118
|
|
|
342
|
-
/** Preview only: ?ncState=loading holds the skeleton, caught-up shows the empty state, sample swaps in the invented hard gate, flags and
|
|
343
|
-
function previewState(): "loading" | "caught-up" | "sample" | null {
|
|
119
|
+
/** Preview only: ?ncState=loading holds the skeleton, caught-up shows the empty state, sample swaps in the invented hard gate, flags, batches and held email, mixed adds the invented hard gate, flag and held email to the real data. */
|
|
120
|
+
function previewState(): "loading" | "caught-up" | "sample" | "mixed" | null {
|
|
344
121
|
if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
|
|
345
122
|
const requested = new URLSearchParams(window.location.search).get("ncState");
|
|
346
|
-
return requested === "loading" || requested === "caught-up" || requested === "sample" ? requested : null;
|
|
123
|
+
return requested === "loading" || requested === "caught-up" || requested === "sample" || requested === "mixed" ? requested : null;
|
|
347
124
|
}
|
|
348
125
|
|
|
126
|
+
const EMAIL_REFUSALS: Record<string, string> = {
|
|
127
|
+
forbidden: "Your session cannot decide held email.",
|
|
128
|
+
bad_request: "That email could not be decided from here.",
|
|
129
|
+
already_decided: "Someone already decided this email. Refresh to see what they chose.",
|
|
130
|
+
not_quarantined: "This email is no longer held.",
|
|
131
|
+
expired: "This email expired and can no longer be accepted.",
|
|
132
|
+
};
|
|
133
|
+
|
|
349
134
|
export function NotificationCenter({
|
|
350
|
-
references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews
|
|
135
|
+
references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
|
|
136
|
+
onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
|
|
351
137
|
}: ContainerProps) {
|
|
352
138
|
const api = demo ? demoApi : liveOverride;
|
|
353
139
|
const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
|
|
354
140
|
const [stale, setStale] = useState(false);
|
|
355
141
|
const [requests, setRequests] = useState<ReviewRequest[]>([]);
|
|
356
142
|
const [userKey, setUserKey] = useState<string | null>(null);
|
|
357
|
-
const [busyId, setBusyId] = useState<string | null>(null);
|
|
358
143
|
const [error, setError] = useState<string | null>(null);
|
|
359
144
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
145
|
+
const [storyExcerpts, setStoryExcerpts] = useState<Record<string, string>>({});
|
|
360
146
|
const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
|
|
361
147
|
const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
|
|
362
148
|
const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
|
|
363
149
|
const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
|
|
364
|
-
const [
|
|
365
|
-
const [
|
|
150
|
+
const [emails, setEmails] = useState<MonitoringEmailSummary[]>([]);
|
|
151
|
+
const [emailsState, setEmailsState] = useState<"loading" | "ready">("loading");
|
|
152
|
+
const [emailsDecided, setEmailsDecided] = useState<ReadonlyMap<string, "accept" | "reject">>(new Map());
|
|
366
153
|
const [stay, setStay] = useState<ReadonlySet<string>>(new Set());
|
|
367
154
|
const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(new Set());
|
|
368
155
|
const [now] = useState(() => Date.now());
|
|
@@ -371,6 +158,10 @@ export function NotificationCenter({
|
|
|
371
158
|
const preview = useMemo(() => previewState(), []);
|
|
372
159
|
const sample = useMemo(() => {
|
|
373
160
|
if (preview === "sample" || !fixture) return { requests: FIXTURE_REVIEW_REQUESTS, drafts: FIXTURE_DRAFTS };
|
|
161
|
+
if (preview === "mixed") {
|
|
162
|
+
const invented = FIXTURE_REVIEW_REQUESTS.filter((request) => request.id === "rr-hard-1" || request.id === "rr-flag-1");
|
|
163
|
+
return { requests: [...invented, ...fixture.requests], drafts: fixture.drafts };
|
|
164
|
+
}
|
|
374
165
|
return { requests: preview === "caught-up" ? [] : fixture.requests, drafts: fixture.drafts };
|
|
375
166
|
}, [fixture, preview]);
|
|
376
167
|
|
|
@@ -399,6 +190,19 @@ export function NotificationCenter({
|
|
|
399
190
|
}
|
|
400
191
|
}, [api, demo, sample]);
|
|
401
192
|
|
|
193
|
+
// Email held for review is read on its own: a site without monitoring, or an editor who may not read it, simply has none.
|
|
194
|
+
const loadEmailsNow = useCallback(async () => {
|
|
195
|
+
if (preview === "caught-up") { setEmails([]); setEmailsState("ready"); return; }
|
|
196
|
+
// The real-data preview has no held email of its own; only the sample and mixed previews show the invented one.
|
|
197
|
+
if (demo && fixture && preview !== "sample" && preview !== "mixed") { setEmails(fixture.emails ?? []); setEmailsState("ready"); return; }
|
|
198
|
+
try {
|
|
199
|
+
setEmails((await api.loadHeldEmails?.()) ?? []);
|
|
200
|
+
} catch {
|
|
201
|
+
// Held email is one source among several; the others still show.
|
|
202
|
+
}
|
|
203
|
+
setEmailsState("ready");
|
|
204
|
+
}, [api, demo, fixture, preview]);
|
|
205
|
+
|
|
402
206
|
// A return visit paints the last view at once, marked as such, while the real reads run.
|
|
403
207
|
useEffect(() => {
|
|
404
208
|
if (demo) return;
|
|
@@ -420,6 +224,7 @@ export function NotificationCenter({
|
|
|
420
224
|
setRequests(sample.requests);
|
|
421
225
|
setLoadState("ready");
|
|
422
226
|
void loadDraftsFor(sample.requests);
|
|
227
|
+
void loadEmailsNow();
|
|
423
228
|
return;
|
|
424
229
|
}
|
|
425
230
|
try {
|
|
@@ -434,14 +239,16 @@ export function NotificationCenter({
|
|
|
434
239
|
setStale(false);
|
|
435
240
|
setLoadState("ready");
|
|
436
241
|
void loadDraftsFor(loaded.requests);
|
|
437
|
-
|
|
242
|
+
void loadEmailsNow();
|
|
243
|
+
const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[], excerpts: {} }));
|
|
438
244
|
setStoryTitles(stories.titles);
|
|
245
|
+
setStoryExcerpts(stories.excerpts ?? {});
|
|
439
246
|
setDeletedStories(new Set(stories.deleted));
|
|
440
247
|
} catch {
|
|
441
248
|
setStale(false);
|
|
442
249
|
setLoadState("unavailable");
|
|
443
250
|
}
|
|
444
|
-
}, [api, demo, loadDraftsFor, sample]);
|
|
251
|
+
}, [api, demo, loadDraftsFor, loadEmailsNow, sample]);
|
|
445
252
|
|
|
446
253
|
useEffect(() => {
|
|
447
254
|
void refresh();
|
|
@@ -461,26 +268,18 @@ export function NotificationCenter({
|
|
|
461
268
|
await api.markReviewRead(userKey, requestId).catch(() => undefined);
|
|
462
269
|
}, [api, demo, userKey]);
|
|
463
270
|
|
|
271
|
+
/** Answers a whole request. Throws a sentence for the card when it cannot be saved. */
|
|
464
272
|
const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
|
|
465
273
|
setError(null);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
471
|
-
await markRead(request.id);
|
|
472
|
-
} catch (failure) {
|
|
473
|
-
setError(failure instanceof Error ? failure.message : "Could not save the answer.");
|
|
474
|
-
} finally {
|
|
475
|
-
setBusyId(null);
|
|
476
|
-
}
|
|
274
|
+
const saved = await api.answerReviewRequest(request, input, actorLabel);
|
|
275
|
+
keep(request.id);
|
|
276
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
277
|
+
await markRead(request.id);
|
|
477
278
|
}, [actorLabel, api, keep, markRead]);
|
|
478
279
|
|
|
479
280
|
/** Accept or Reject one draft: the Proposal moves to approved or declined, the decision is recorded on the request, and the request closes once every draft is decided. */
|
|
480
|
-
const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras:
|
|
481
|
-
|
|
482
|
-
setDraftErrors(({ [key]: _gone, ...rest }) => rest);
|
|
483
|
-
setDraftBusyId(key);
|
|
281
|
+
const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => {
|
|
282
|
+
setError(null);
|
|
484
283
|
resolving.current.add(request.id);
|
|
485
284
|
try {
|
|
486
285
|
const view = drafts[draftId];
|
|
@@ -492,7 +291,7 @@ export function NotificationCenter({
|
|
|
492
291
|
const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
|
|
493
292
|
const nextDrafts = { ...drafts, [draftId]: decided };
|
|
494
293
|
setDrafts(nextDrafts);
|
|
495
|
-
keep(
|
|
294
|
+
keep(`${request.id}#${draftId}`);
|
|
496
295
|
const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
|
|
497
296
|
const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
|
|
498
297
|
const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
|
|
@@ -504,11 +303,10 @@ export function NotificationCenter({
|
|
|
504
303
|
throw new Error(`The draft was ${choice === "accept" ? "accepted" : "rejected"} in Proposals, but this request could not record it. ${failure instanceof Error ? failure.message : ""}`.trim());
|
|
505
304
|
}
|
|
506
305
|
} catch (failure) {
|
|
507
|
-
setDraftErrors((current) => ({ ...current, [key]: failure instanceof Error ? failure.message : "Could not save the decision." }));
|
|
508
306
|
if (!demo) void loadDraftsFor([request]);
|
|
307
|
+
throw failure;
|
|
509
308
|
} finally {
|
|
510
309
|
resolving.current.delete(request.id);
|
|
511
|
-
setDraftBusyId(null);
|
|
512
310
|
}
|
|
513
311
|
}, [actorLabel, api, demo, drafts, keep, loadDraftsFor, markRead]);
|
|
514
312
|
|
|
@@ -534,18 +332,61 @@ export function NotificationCenter({
|
|
|
534
332
|
}
|
|
535
333
|
}, [api, demo, drafts, draftsState, requests, stale]);
|
|
536
334
|
|
|
537
|
-
|
|
538
|
-
const
|
|
335
|
+
/** One decision, whatever the type: it goes to the action that already owns that kind of thing. */
|
|
336
|
+
const decide = useCallback(async (item: NeedsMeEntry, decision: NeedsMeDecision) => {
|
|
337
|
+
const { payload } = item;
|
|
338
|
+
if (payload.kind === "reference") {
|
|
339
|
+
setVisitRated((current) => (current.has(payload.lineageId) ? current : new Set(current).add(payload.lineageId)));
|
|
340
|
+
await onRateReference?.(payload.reference, decision.choice, decision.choice === "reject" ? decision.reasonCode : null, decision.why || undefined);
|
|
341
|
+
} else if (payload.kind === "draft") {
|
|
342
|
+
await decideDraft(payload.item.request, payload.item.draftId, decision.choice, { why: decision.why, notThisKind: decision.notThisKind });
|
|
343
|
+
} else if (payload.kind === "request") {
|
|
344
|
+
const { request } = payload;
|
|
345
|
+
if (request.mode === "flag") await answer(request, { decision: "acknowledge", feedback: decision.why || null });
|
|
346
|
+
else if (decision.choice === "accept") await answer(request, { decision: "approve", feedback: decision.why || null });
|
|
347
|
+
else await answer(request, { decision: "reject", feedback: decision.why || null, steer: decision.notThisKind ? "not_this_kind" : null });
|
|
348
|
+
} else if (payload.kind === "email") {
|
|
349
|
+
const result = await api.decideEmail?.(payload.summary.receiptId, decision.choice, decision.why);
|
|
350
|
+
if (!result) throw new Error("Held email cannot be decided from here.");
|
|
351
|
+
if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
|
|
352
|
+
setEmailsDecided((current) => new Map(current).set(payload.summary.receiptId, decision.choice));
|
|
353
|
+
}
|
|
354
|
+
}, [answer, api, decideDraft, onRateReference]);
|
|
355
|
+
|
|
539
356
|
const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
|
|
357
|
+
const bySource = useMemo(() => previewsBySourceAddress(shownReferences, previews), [shownReferences, previews]);
|
|
358
|
+
const lineageIds = useMemo(() => shownReferences.map(referenceLineageId), [shownReferences]);
|
|
359
|
+
const { statuses: captures, teaserLine } = useMemo(() => captureStatusesByLineage(referenceAttachments, lineageIds), [lineageIds, referenceAttachments]);
|
|
360
|
+
|
|
361
|
+
const entries = useMemo(
|
|
362
|
+
() => buildNeedsMeItems({
|
|
363
|
+
references: shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource,
|
|
364
|
+
storyTitles, storyExcerpts, deletedStories, emails, emailsDecided,
|
|
365
|
+
}),
|
|
366
|
+
[shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource, storyTitles, storyExcerpts, deletedStories, emails, emailsDecided],
|
|
367
|
+
);
|
|
368
|
+
const freshness = useMemo(() => learnFreshness(shownReferences, visitRated), [shownReferences, visitRated]);
|
|
369
|
+
const threshold = useMemo(() => learnConfidenceThreshold(shownReferences, decisions, visitRated), [shownReferences, decisions, visitRated]);
|
|
370
|
+
const recent = useMemo(() => recentDecisions(requests, references, demo), [requests, references, demo]);
|
|
371
|
+
const shaping = useMemo(
|
|
372
|
+
() => shapingThisWeek({ decidedAt: recentDecisions(requests, references, demo, Number.MAX_SAFE_INTEGER).map((entry) => entry.at), references, decisions, now }),
|
|
373
|
+
[requests, references, demo, decisions, now],
|
|
374
|
+
);
|
|
375
|
+
const shownIds = useMemo(() => new Set(entries.map((item) => item.id)), [entries]);
|
|
376
|
+
const legacyAuto = useMemo(
|
|
377
|
+
() => cyclotronAutoDecided(shownReferences, decisions, humanCuratedReferenceLineageIds, demo).filter((entry) => !shownIds.has(entry.lineageId)),
|
|
378
|
+
[shownReferences, decisions, humanCuratedReferenceLineageIds, demo, shownIds],
|
|
379
|
+
);
|
|
380
|
+
const openDraftRequests = useMemo(() => sectionReviews(requests).draftBatches, [requests]);
|
|
381
|
+
const missingCount = openDraftRequests.reduce((total, request) => total + draftIdsOf(request).filter((id) => missingDrafts.has(id)).length, 0);
|
|
382
|
+
const settled = !stale && loadState === "ready" && draftsState !== "loading" && emailsState === "ready" && !referencesLoading && !predictionLoading;
|
|
383
|
+
|
|
540
384
|
return (
|
|
541
|
-
<
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
onVisitRated={(lineageId) => setVisitRated((current) => (current.has(lineageId) ? current : new Set(current).add(lineageId)))}
|
|
547
|
-
predictionLoading={predictionLoading} previews={previews} referenceDisabledReason={referenceDisabledReason} references={shownReferences} referencesLoading={referencesLoading}
|
|
548
|
-
requests={requests} stale={stale} stay={stay} storyTitles={storyTitles} visitRated={visitRated}
|
|
385
|
+
<NeedsMeView
|
|
386
|
+
captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
|
|
387
|
+
loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide}
|
|
388
|
+
onRetryDrafts={() => void loadDraftsFor(requests)} predictionLoading={predictionLoading} recent={recent} referenceAttachments={referenceAttachments}
|
|
389
|
+
referenceDisabledReason={referenceDisabledReason} referencesLoading={referencesLoading} settled={settled} shaping={shaping} stale={stale} threshold={threshold}
|
|
549
390
|
/>
|
|
550
391
|
);
|
|
551
392
|
}
|
|
@@ -113,9 +113,9 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
|
|
116
|
-
export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
|
|
116
|
+
export function ReferenceCapturePanel({ capture, startEditing = false }: { capture: CaptureView; startEditing?: boolean }) {
|
|
117
117
|
const [expanded, setExpanded] = useState(false);
|
|
118
|
-
const [editing, setEditing] = useState(
|
|
118
|
+
const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
|
|
119
119
|
const [savedNote, setSavedNote] = useState(false);
|
|
120
120
|
const line = captureLine(capture.status);
|
|
121
121
|
const Icon = ICON[line.state];
|
|
@@ -35,7 +35,7 @@ export const FEED_SURFACE_STYLE = {} as CSSProperties;
|
|
|
35
35
|
/** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
|
|
36
36
|
export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
|
|
37
37
|
|
|
38
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
38
|
+
export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
39
39
|
out_of_scope: "Out of scope",
|
|
40
40
|
policy_exclusion: "Policy exclusion",
|
|
41
41
|
duplicate: "Duplicate",
|
|
@@ -3109,7 +3109,8 @@ function NewsDeskDashboard({
|
|
|
3109
3109
|
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3110
3110
|
newsroomSections={newsroomSections}
|
|
3111
3111
|
notificationFixture={dashboard.notificationFixture ?? null}
|
|
3112
|
-
|
|
3112
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3113
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
|
|
3113
3114
|
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
|
|
3114
3115
|
predictionState={referenceReviewData.state}
|
|
3115
3116
|
referenceAttachments={referenceAttachments}
|