@anthusai/papyrus 5.43.0-next.16 → 5.43.0-next.18
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/newsroom-references-view.tsx +119 -7
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +84 -51
- package/components/reference-review-card.tsx +21 -66
- package/components/review-rating-grid.tsx +9 -6
- package/components/topic-steering-workspace.tsx +2 -0
- package/components/use-reference-ratings.ts +132 -0
- package/lib/content-actions-client.ts +18 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-ranking.ts +44 -0
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -21,6 +21,9 @@ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
|
21
21
|
import { cn } from "../lib/utils";
|
|
22
22
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
23
|
import { RatingGrid } from "./review-rating-grid";
|
|
24
|
+
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
26
|
+
import type { CaptureStatus } from "../lib/reference-capture";
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
@@ -62,20 +65,33 @@ export type ReferenceFeedProps = {
|
|
|
62
65
|
predictionLoading?: boolean;
|
|
63
66
|
previews: ReadonlyMap<string, ReferencePreview>;
|
|
64
67
|
selectedLineageId?: string;
|
|
68
|
+
/** Capture status per Reference lineage, for the small badge on each card. Optional. */
|
|
69
|
+
captures?: ReadonlyMap<string, CaptureStatus>;
|
|
70
|
+
/** The sections and lead, when the caller keeps the order stable (a snapshot). Without them the feed groups its own partition. */
|
|
71
|
+
groups?: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
|
|
72
|
+
leadId?: string;
|
|
73
|
+
/** Scrolls the grid to a card, deliberately (Previous / Next). A new nonce is a new request; selecting alone never scrolls. */
|
|
74
|
+
scrollTarget?: { id: string; nonce: number } | null;
|
|
75
|
+
/** A rating store shared with the detail pane, so a vote or reason in either shows in both. Optional. */
|
|
76
|
+
ratings?: RatingController;
|
|
65
77
|
/** True when the cards are a saved copy that is being refreshed. */
|
|
66
78
|
stale?: boolean;
|
|
67
79
|
tab: ReferenceFeedTabKey;
|
|
68
80
|
visitRated: ReadonlySet<string>;
|
|
69
81
|
};
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
/** Columns follow the width the feed has (a container query, not the screen), so they stay put while a detail pane holds its place beside it. */
|
|
84
|
+
export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
|
|
85
|
+
const GRID = FEED_GRID;
|
|
86
|
+
/** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
|
|
87
|
+
const LEAD_SPAN = "@[36rem]:col-span-2";
|
|
72
88
|
|
|
73
89
|
/** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
|
|
74
90
|
export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
|
|
75
91
|
return (
|
|
76
|
-
<div aria-busy="true" aria-label="Loading references" className={
|
|
92
|
+
<div aria-busy="true" aria-label="Loading references" className={cn("@container", GRID)} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
|
|
77
93
|
{Array.from({ length: count }, (_, index) => (
|
|
78
|
-
<div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 &&
|
|
94
|
+
<div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && LEAD_SPAN)} key={index}>
|
|
79
95
|
<div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
|
|
80
96
|
<div className="space-y-3 p-5">
|
|
81
97
|
<div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
@@ -145,14 +161,17 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
145
161
|
const element = ref.current;
|
|
146
162
|
if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
|
|
147
163
|
}, [src]);
|
|
148
|
-
|
|
164
|
+
// The frame is decided before the picture loads (from its address), so loading never moves the cards around. A picture found to be
|
|
165
|
+
// a 2:1 text card afterwards is contained inside the same frame instead of changing it.
|
|
166
|
+
const hostCard = isCardStyleImage(src, null);
|
|
167
|
+
const card = hostCard || isCardStyleImage(src, ratio);
|
|
149
168
|
return (
|
|
150
169
|
<div
|
|
151
|
-
className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !
|
|
170
|
+
className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !hostCard && (lead ? "aspect-[16/10] @[36rem]:aspect-[2/1] @[60rem]:aspect-[21/9]" : "aspect-[16/10]"))}
|
|
152
171
|
data-feed-image
|
|
153
172
|
data-feed-image-fit={card ? "contain" : "cover"}
|
|
154
173
|
data-feed-image-state={state}
|
|
155
|
-
style={
|
|
174
|
+
style={hostCard ? { aspectRatio: "2" } : undefined}
|
|
156
175
|
>
|
|
157
176
|
{state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
|
|
158
177
|
{src && state !== "failed" ? (
|
|
@@ -175,9 +194,8 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
175
194
|
);
|
|
176
195
|
}
|
|
177
196
|
|
|
178
|
-
type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
|
|
179
|
-
|
|
180
197
|
export function FeedCard({
|
|
198
|
+
capture,
|
|
181
199
|
decision,
|
|
182
200
|
disabledReason,
|
|
183
201
|
item,
|
|
@@ -187,9 +205,12 @@ export function FeedCard({
|
|
|
187
205
|
onRate,
|
|
188
206
|
predictionLoading,
|
|
189
207
|
preview,
|
|
208
|
+
ratings,
|
|
190
209
|
selected,
|
|
191
210
|
visitRated,
|
|
192
211
|
}: {
|
|
212
|
+
/** Optional: the capture badge. */
|
|
213
|
+
capture?: CaptureStatus | undefined;
|
|
193
214
|
decision: RelevanceDecisionView | undefined;
|
|
194
215
|
disabledReason: string | null;
|
|
195
216
|
item: FeedItem;
|
|
@@ -199,6 +220,8 @@ export function FeedCard({
|
|
|
199
220
|
onRate: ReferenceFeedProps["onRate"];
|
|
200
221
|
predictionLoading: boolean;
|
|
201
222
|
preview: ReferencePreview | undefined;
|
|
223
|
+
/** Optional: a shared rating store. Without it the card keeps its own. */
|
|
224
|
+
ratings?: RatingController | undefined;
|
|
202
225
|
selected: boolean;
|
|
203
226
|
visitRated: boolean;
|
|
204
227
|
}) {
|
|
@@ -206,8 +229,15 @@ export function FeedCard({
|
|
|
206
229
|
const titleId = useId();
|
|
207
230
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
208
231
|
const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
|
|
209
|
-
|
|
210
|
-
const
|
|
232
|
+
// Rating state lives in a store: the shared one when given (so the detail pane and this card stay in step), else a private one.
|
|
233
|
+
const local = useRatingStore({
|
|
234
|
+
save: async (_id, choice, detail) => { await onRate(reference, choice, detail.reasonCode); },
|
|
235
|
+
saved: () => answer,
|
|
236
|
+
seed: () => ({ note: "", reasonCode: null }),
|
|
237
|
+
});
|
|
238
|
+
const store = ratings ?? local;
|
|
239
|
+
const rating = store.get(item.lineageId);
|
|
240
|
+
const chosen = rating.pending ?? answer;
|
|
211
241
|
const host = referenceSourceHost(reference);
|
|
212
242
|
const site = preview?.siteName ?? host ?? "Unknown source";
|
|
213
243
|
const title = referenceDisplayTitle(reference);
|
|
@@ -215,42 +245,26 @@ export function FeedCard({
|
|
|
215
245
|
const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
|
|
216
246
|
const time = referenceDisplayTime(reference);
|
|
217
247
|
const age = formatFeedAge(time, now);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (disabledReason || inFlight.current) return;
|
|
221
|
-
inFlight.current = true;
|
|
222
|
-
setRate({ phase: "saving" });
|
|
223
|
-
try {
|
|
224
|
-
await onRate(reference, choice, reasonCode);
|
|
225
|
-
setRate({ phase: "saved" });
|
|
226
|
-
} catch (failure) {
|
|
227
|
-
setRate({ phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." });
|
|
228
|
-
} finally {
|
|
229
|
-
inFlight.current = false;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function choose(choice: ReviewChoice) {
|
|
234
|
-
if (disabledReason) return;
|
|
235
|
-
if (answer === choice) return; // the chosen answer never unselects itself; the other choice is the undo
|
|
236
|
-
if (choice === "reject") { setRate({ phase: "reason" }); return; }
|
|
237
|
-
void save("accept", null);
|
|
238
|
-
}
|
|
248
|
+
const feedback = rating.feedback.phase === "idle" && visitRated && answer ? { phase: "saved" as const } : rating.feedback;
|
|
249
|
+
const reasonLabel = answer === "reject" && rating.reasonCode ? REASON_LABELS[rating.reasonCode] : null;
|
|
239
250
|
|
|
240
251
|
return (
|
|
241
252
|
<article
|
|
242
253
|
aria-labelledby={titleId}
|
|
254
|
+
aria-current={selected ? "true" : undefined}
|
|
243
255
|
className={cn(
|
|
244
|
-
"flex flex-col overflow-hidden rounded-[20px] text-card-foreground",
|
|
245
|
-
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
246
|
-
lead &&
|
|
256
|
+
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
257
|
+
selected ? "bg-[var(--feed-control)] shadow-xl" : "bg-[var(--feed-card)]",
|
|
258
|
+
lead && LEAD_SPAN,
|
|
247
259
|
)}
|
|
248
260
|
data-feed-card={item.lineageId}
|
|
249
261
|
data-feed-lead={lead || undefined}
|
|
250
262
|
data-feed-rated={answer ?? undefined}
|
|
263
|
+
data-feed-selected={selected || undefined}
|
|
251
264
|
data-feed-visit-rated={visitRated || undefined}
|
|
252
265
|
data-newsroom-card-id={item.lineageId}
|
|
253
266
|
>
|
|
267
|
+
{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}
|
|
254
268
|
<button
|
|
255
269
|
aria-label={`Open: ${title}`}
|
|
256
270
|
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
@@ -259,42 +273,48 @@ export function FeedCard({
|
|
|
259
273
|
type="button"
|
|
260
274
|
>
|
|
261
275
|
<FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
|
|
262
|
-
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5
|
|
276
|
+
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
263
277
|
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
264
278
|
<SourceMark host={host} />
|
|
265
279
|
<span className="truncate">{site}</span>
|
|
266
280
|
</span>
|
|
267
|
-
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl
|
|
281
|
+
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
|
|
268
282
|
{title}
|
|
269
283
|
</span>
|
|
270
284
|
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
271
|
-
<span className="mt-auto pt-1 text-xs text-muted-foreground">
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
286
|
+
<span>
|
|
287
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
288
|
+
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
289
|
+
{host}
|
|
290
|
+
</span>
|
|
291
|
+
{capture ? <CaptureBadge status={capture} /> : null}
|
|
275
292
|
</span>
|
|
276
293
|
</span>
|
|
277
294
|
</button>
|
|
278
295
|
|
|
279
|
-
<div className="
|
|
296
|
+
<div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
|
|
280
297
|
<RatingGrid
|
|
281
|
-
answer={
|
|
298
|
+
answer={chosen}
|
|
299
|
+
decided={answer}
|
|
282
300
|
disabled={Boolean(disabledReason)}
|
|
283
301
|
disabledReason={disabledReason}
|
|
284
|
-
feedback={
|
|
285
|
-
|
|
302
|
+
feedback={feedback}
|
|
303
|
+
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
304
|
+
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
286
305
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
287
306
|
/>
|
|
288
|
-
{
|
|
289
|
-
|
|
307
|
+
{rating.askReason ? (
|
|
308
|
+
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
309
|
+
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
290
310
|
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
291
311
|
<div className="flex flex-wrap gap-1.5">
|
|
292
312
|
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
293
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() =>
|
|
313
|
+
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
|
|
294
314
|
{REASON_LABELS[code]}
|
|
295
315
|
</button>
|
|
296
316
|
))}
|
|
297
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() =>
|
|
317
|
+
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
298
318
|
</div>
|
|
299
319
|
</div>
|
|
300
320
|
) : null}
|
|
@@ -380,6 +400,11 @@ export function ReferenceFeed({
|
|
|
380
400
|
predictionLoading = false,
|
|
381
401
|
previews,
|
|
382
402
|
selectedLineageId = "",
|
|
403
|
+
captures,
|
|
404
|
+
groups: stableGroups,
|
|
405
|
+
leadId: stableLeadId,
|
|
406
|
+
ratings,
|
|
407
|
+
scrollTarget = null,
|
|
383
408
|
stale = false,
|
|
384
409
|
tab,
|
|
385
410
|
visitRated,
|
|
@@ -388,6 +413,7 @@ export function ReferenceFeed({
|
|
|
388
413
|
|
|
389
414
|
const renderCard = (item: FeedItem, lead: boolean) => (
|
|
390
415
|
<FeedCard
|
|
416
|
+
capture={captures?.get(item.lineageId)}
|
|
391
417
|
decision={item.decision}
|
|
392
418
|
disabledReason={disabledReason}
|
|
393
419
|
item={item}
|
|
@@ -398,6 +424,7 @@ export function ReferenceFeed({
|
|
|
398
424
|
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|
|
399
425
|
predictionLoading={predictionLoading}
|
|
400
426
|
preview={previews.get(item.lineageId)}
|
|
427
|
+
ratings={ratings}
|
|
401
428
|
selected={selectedLineageId === item.lineageId}
|
|
402
429
|
visitRated={visitRated.has(item.lineageId)}
|
|
403
430
|
/>
|
|
@@ -407,12 +434,18 @@ export function ReferenceFeed({
|
|
|
407
434
|
<div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
|
|
408
435
|
);
|
|
409
436
|
|
|
410
|
-
|
|
437
|
+
// Previous / Next in the detail pane scrolls the grid to the same card, smoothly. Selecting a card by clicking it never scrolls.
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!scrollTarget || typeof document === "undefined") return;
|
|
440
|
+
document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
441
|
+
}, [scrollTarget]);
|
|
442
|
+
|
|
443
|
+
const groups = stableGroups ?? groupFeedItems(partition.feed);
|
|
411
444
|
const headings = groups.length > 1;
|
|
412
|
-
const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
445
|
+
const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
413
446
|
|
|
414
447
|
return (
|
|
415
|
-
<div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
|
|
448
|
+
<div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
|
|
416
449
|
<div className="flex items-center justify-between gap-2">
|
|
417
450
|
<div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
|
|
418
451
|
{(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
|
|
@@ -13,6 +13,7 @@ import { useEffect, useRef, useState, type ReactNode, type PointerEvent as React
|
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
14
|
import { cn } from "../lib/utils";
|
|
15
15
|
import { RatingGrid } from "./review-rating-grid";
|
|
16
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
16
17
|
import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
17
18
|
|
|
18
19
|
export type ReviewChoice = "accept" | "reject";
|
|
@@ -141,8 +142,11 @@ export function ReviewCard({
|
|
|
141
142
|
summary,
|
|
142
143
|
title,
|
|
143
144
|
titleHref,
|
|
145
|
+
ratings,
|
|
144
146
|
}: {
|
|
145
147
|
children?: ReactNode;
|
|
148
|
+
/** A shared rating store, so the grid card and this card stay in step. Optional: the card works alone without it. */
|
|
149
|
+
ratings?: RatingController;
|
|
146
150
|
/** The saved human answer, or null while the item is undecided. */
|
|
147
151
|
decided: ReviewChoice | null;
|
|
148
152
|
/** When set, the controls explain in words why they cannot change anything. */
|
|
@@ -157,70 +161,21 @@ export function ReviewCard({
|
|
|
157
161
|
title: string;
|
|
158
162
|
titleHref?: string | null;
|
|
159
163
|
}) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}, [initialNote]);
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (!reasonTouched.current) setReason(initialReason);
|
|
178
|
-
}, [initialReason]);
|
|
179
|
-
|
|
180
|
-
const chosen = draft ?? decided;
|
|
181
|
-
|
|
182
|
-
async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
|
|
183
|
-
if (disabledReason || inFlight.current) return;
|
|
184
|
-
if (choice === "reject" && !nextReason) return;
|
|
185
|
-
inFlight.current = true;
|
|
186
|
-
setSaving(true);
|
|
187
|
-
setSaved(false);
|
|
188
|
-
setError(null);
|
|
189
|
-
try {
|
|
190
|
-
await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
|
|
191
|
-
savedNote.current = nextNote.trim();
|
|
192
|
-
noteTouched.current = false;
|
|
193
|
-
reasonTouched.current = false;
|
|
194
|
-
setDraft(null);
|
|
195
|
-
setSaved(true);
|
|
196
|
-
} catch (failure) {
|
|
197
|
-
setDraft(null);
|
|
198
|
-
setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
|
|
199
|
-
} finally {
|
|
200
|
-
inFlight.current = false;
|
|
201
|
-
setSaving(false);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function choose(choice: ReviewChoice) {
|
|
206
|
-
if (disabledReason) return;
|
|
207
|
-
if (chosen === choice && draft === null) return; // the selected answer never unselects itself
|
|
208
|
-
setError(null);
|
|
209
|
-
setDraft(choice);
|
|
210
|
-
void save(choice, reason, note);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function chooseReason(code: ReferenceRejectionReasonCode) {
|
|
214
|
-
if (disabledReason) return;
|
|
215
|
-
setReason(code);
|
|
216
|
-
reasonTouched.current = true;
|
|
217
|
-
if (chosen === "reject") void save("reject", code, note);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function commitNote() {
|
|
221
|
-
if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
|
|
222
|
-
void save(decided, reason, note);
|
|
223
|
-
}
|
|
164
|
+
// The rating state lives in a store. Given one (`ratings`), the grid card and this card share it, so a vote or a reason typed in
|
|
165
|
+
// either shows in both at once; without one the card keeps a private store, exactly as before.
|
|
166
|
+
const local = useRatingStore({
|
|
167
|
+
save: (_id, choice, detail) => onDecide(choice, detail),
|
|
168
|
+
saved: () => decided,
|
|
169
|
+
seed: () => ({ note: initialNote, reasonCode: initialReason }),
|
|
170
|
+
});
|
|
171
|
+
const store = ratings ?? local;
|
|
172
|
+
const item = store.get(itemId);
|
|
173
|
+
const note = item.note ?? initialNote;
|
|
174
|
+
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
175
|
+
const chosen = item.pending ?? decided;
|
|
176
|
+
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
177
|
+
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
178
|
+
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
224
179
|
|
|
225
180
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
226
181
|
useEffect(() => {
|
|
@@ -253,7 +208,7 @@ export function ReviewCard({
|
|
|
253
208
|
decided={decided}
|
|
254
209
|
disabled={Boolean(disabledReason)}
|
|
255
210
|
disabledReason={disabledReason}
|
|
256
|
-
feedback={
|
|
211
|
+
feedback={item.feedback}
|
|
257
212
|
onChoose={choose}
|
|
258
213
|
prediction={prediction}
|
|
259
214
|
size="large"
|
|
@@ -266,7 +221,7 @@ export function ReviewCard({
|
|
|
266
221
|
aria-label="Why? (optional)"
|
|
267
222
|
className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
|
|
268
223
|
onBlur={commitNote}
|
|
269
|
-
onChange={(event) =>
|
|
224
|
+
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
270
225
|
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
271
226
|
value={note}
|
|
272
227
|
/>
|
|
@@ -42,6 +42,7 @@ export function RatingGrid({
|
|
|
42
42
|
disabled = false,
|
|
43
43
|
disabledReason = null,
|
|
44
44
|
feedback = { phase: "idle" },
|
|
45
|
+
note = null,
|
|
45
46
|
onChoose,
|
|
46
47
|
prediction,
|
|
47
48
|
showPrediction = true,
|
|
@@ -55,6 +56,8 @@ export function RatingGrid({
|
|
|
55
56
|
disabled?: boolean;
|
|
56
57
|
disabledReason?: string | null;
|
|
57
58
|
feedback?: RatingFeedback;
|
|
59
|
+
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
60
|
+
note?: string | null;
|
|
58
61
|
onChoose: (choice: ReviewChoice) => void;
|
|
59
62
|
prediction: CardPrediction;
|
|
60
63
|
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
@@ -128,11 +131,10 @@ export function RatingGrid({
|
|
|
128
131
|
) : null}
|
|
129
132
|
<span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
|
|
130
133
|
</div>
|
|
131
|
-
{prediction
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
)}
|
|
134
|
+
{/* The line is always there, so a prediction arriving later never changes the card's height. */}
|
|
135
|
+
<small className="min-h-4 text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
136
|
+
{prediction.state === "ready" ? "\u00a0" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
137
|
+
</small>
|
|
136
138
|
</div>
|
|
137
139
|
</div> : null}
|
|
138
140
|
|
|
@@ -163,7 +165,8 @@ export function RatingGrid({
|
|
|
163
165
|
})}
|
|
164
166
|
</div>
|
|
165
167
|
|
|
166
|
-
<div className={cn("min-h-5 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
|
|
168
|
+
<div className={cn("flex min-h-5 flex-wrap items-center gap-x-3 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
|
|
169
|
+
{note ? <span data-feed-reason>{note}</span> : null}
|
|
167
170
|
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
168
171
|
{feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
|
|
169
172
|
</div>
|
|
@@ -94,6 +94,7 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
|
94
94
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
95
95
|
import type { ReaderAuthSnapshot } from "./reader-auth-state";
|
|
96
96
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
97
|
+
import { correctReferenceCapture } from "../lib/content-actions-client";
|
|
97
98
|
import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
|
|
98
99
|
import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
99
100
|
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
@@ -3213,6 +3214,7 @@ function NewsDeskDashboard({
|
|
|
3213
3214
|
referenceAttachments={referenceAttachments}
|
|
3214
3215
|
references={references}
|
|
3215
3216
|
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3217
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3216
3218
|
savedCopy={savedFeedCopy}
|
|
3217
3219
|
/>
|
|
3218
3220
|
</NewsroomCurationBootstrap>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
+
import type { RatingFeedback } from "./review-rating-grid";
|
|
7
|
+
|
|
8
|
+
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
|
+
export const NOTE_AUTOSAVE_MS = 1200;
|
|
10
|
+
|
|
11
|
+
export type ItemRating = {
|
|
12
|
+
/** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
|
|
13
|
+
note?: string;
|
|
14
|
+
reasonCode?: ReferenceRejectionReasonCode | null;
|
|
15
|
+
/** A vote that is waiting for a reason (Reject needs one) or is still saving. */
|
|
16
|
+
pending: ReviewChoice | null;
|
|
17
|
+
/** True while a Reject is waiting for its reason. */
|
|
18
|
+
askReason: boolean;
|
|
19
|
+
feedback: RatingFeedback;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
|
|
23
|
+
|
|
24
|
+
export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
|
|
25
|
+
|
|
26
|
+
export type RatingController = {
|
|
27
|
+
get: (id: string) => ItemRating;
|
|
28
|
+
choose: (id: string, choice: ReviewChoice) => void;
|
|
29
|
+
setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
|
|
30
|
+
setNote: (id: string, text: string) => void;
|
|
31
|
+
/** Save a typed reason now (on blur). */
|
|
32
|
+
commitNote: (id: string) => void;
|
|
33
|
+
cancelReason: (id: string) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
|
|
38
|
+
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
|
+
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
|
+
* reason, then vote -> the vote carries the reason
|
|
41
|
+
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
|
+
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
|
+
*/
|
|
44
|
+
export function useRatingStore({ save, saved, seed }: {
|
|
45
|
+
save: RatingSave;
|
|
46
|
+
saved: (id: string) => ReviewChoice | null;
|
|
47
|
+
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
|
+
}): RatingController {
|
|
49
|
+
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
50
|
+
const itemsRef = useRef(items);
|
|
51
|
+
const latest = useRef({ save, saved, seed });
|
|
52
|
+
latest.current = { save, saved, seed };
|
|
53
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
54
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
55
|
+
const dirty = useRef<Set<string>>(new Set());
|
|
56
|
+
|
|
57
|
+
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
58
|
+
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
59
|
+
itemsRef.current = next;
|
|
60
|
+
setItems(next);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
|
|
64
|
+
const get = useCallback((id: string): ItemRating => {
|
|
65
|
+
const item = items[id] ?? EMPTY;
|
|
66
|
+
const seeded = latest.current.seed(id);
|
|
67
|
+
return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
68
|
+
}, [items]);
|
|
69
|
+
|
|
70
|
+
const values = (id: string) => {
|
|
71
|
+
const item = itemsRef.current[id] ?? EMPTY;
|
|
72
|
+
const seeded = latest.current.seed(id);
|
|
73
|
+
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
77
|
+
const { note, reasonCode } = values(id);
|
|
78
|
+
if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
79
|
+
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
80
|
+
inFlight.current.add(id);
|
|
81
|
+
dirty.current.delete(id);
|
|
82
|
+
const started = itemsRef.current[id] ?? EMPTY;
|
|
83
|
+
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
84
|
+
try {
|
|
85
|
+
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
86
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
87
|
+
const now = itemsRef.current[id] ?? EMPTY;
|
|
88
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
// The typed reason stays in the store; only the vote is undone.
|
|
91
|
+
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
92
|
+
} finally {
|
|
93
|
+
inFlight.current.delete(id);
|
|
94
|
+
}
|
|
95
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
96
|
+
}, [patch]);
|
|
97
|
+
|
|
98
|
+
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
99
|
+
|
|
100
|
+
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
101
|
+
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
102
|
+
void commit(id, choice);
|
|
103
|
+
}, [commit]);
|
|
104
|
+
|
|
105
|
+
const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
|
|
106
|
+
patch(id, { reasonCode: code });
|
|
107
|
+
// Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
|
|
108
|
+
if (effective(id) === "reject") void commit(id, "reject");
|
|
109
|
+
}, [commit, patch]);
|
|
110
|
+
|
|
111
|
+
const setNote = useCallback((id: string, text: string) => {
|
|
112
|
+
patch(id, { note: text, feedback: { phase: "idle" } });
|
|
113
|
+
dirty.current.add(`note:${id}`);
|
|
114
|
+
clearTimeout(timers.current[id]);
|
|
115
|
+
timers.current[id] = setTimeout(() => {
|
|
116
|
+
const choice = effective(id);
|
|
117
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
118
|
+
}, NOTE_AUTOSAVE_MS);
|
|
119
|
+
}, [commit, patch]);
|
|
120
|
+
|
|
121
|
+
const commitNote = useCallback((id: string) => {
|
|
122
|
+
clearTimeout(timers.current[id]);
|
|
123
|
+
const choice = effective(id);
|
|
124
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
125
|
+
}, [commit]);
|
|
126
|
+
|
|
127
|
+
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
|
|
130
|
+
|
|
131
|
+
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
132
|
+
}
|
|
@@ -30,6 +30,18 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
|
|
|
30
30
|
export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
|
|
31
31
|
export type UnpublishItemResult = { ok: true; changed: boolean };
|
|
32
32
|
|
|
33
|
+
export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
|
|
34
|
+
export type CorrectReferenceCaptureInput = {
|
|
35
|
+
referenceId: string;
|
|
36
|
+
/** The full corrected or pasted text. */
|
|
37
|
+
text?: string;
|
|
38
|
+
grobid?: Record<string, unknown>;
|
|
39
|
+
/** Required with text; alone it is just a label on the capture. */
|
|
40
|
+
assessment?: CaptureAssessment;
|
|
41
|
+
note?: string;
|
|
42
|
+
};
|
|
43
|
+
export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
|
|
44
|
+
|
|
33
45
|
export class ContentActionFailure extends Error {
|
|
34
46
|
readonly errors: ContentActionError[];
|
|
35
47
|
|
|
@@ -48,6 +60,7 @@ export type ContentActionsTransport = {
|
|
|
48
60
|
saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
49
61
|
publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
50
62
|
unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
63
|
+
correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
51
64
|
};
|
|
52
65
|
};
|
|
53
66
|
|
|
@@ -80,6 +93,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
|
|
|
80
93
|
async publishItem(id: string): Promise<PublishItemResult> {
|
|
81
94
|
return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
82
95
|
},
|
|
96
|
+
async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
|
|
97
|
+
return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
|
|
98
|
+
},
|
|
83
99
|
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
84
100
|
return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
85
101
|
},
|
|
@@ -99,3 +115,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
|
|
|
99
115
|
export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
|
|
100
116
|
export const publishItem = (id: string) => contentActionsClient().publishItem(id);
|
|
101
117
|
export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
|
|
118
|
+
|
|
119
|
+
export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);
|