@anthusai/papyrus 5.57.0 → 5.59.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/amplify/data/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +28 -3
- package/amplify/data/resource.ts +28 -3
- package/amplify/data/schema.js +34 -5
- package/amplify/data/schema.ts +34 -5
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +154 -25
- package/components/conversations-memory-source.ts +83 -0
- package/components/conversations-network-source.ts +52 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +36 -0
- package/components/conversations-view.tsx +680 -0
- package/components/decision-control.tsx +307 -0
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +18 -17
- package/components/needs-me-feed.tsx +16 -32
- package/components/news-desk-taxonomy-client.ts +216 -62
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +34 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +9 -8
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +129 -127
- package/components/reference-review-card.tsx +9 -135
- package/components/references-page-loading.tsx +12 -6
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +44 -786
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +14 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +73 -2
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +12 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
|
|
4
|
-
import { useEffect, useId, useRef, useState, type CSSProperties
|
|
3
|
+
import { CheckCheckIcon, CircleDashedIcon, ClockIcon, InfoIcon, Loader2Icon, MessageSquareIcon, SparklesIcon } from "lucide-react";
|
|
4
|
+
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
|
|
5
5
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
6
6
|
import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
7
7
|
import {
|
|
8
8
|
referenceDisplayTime,
|
|
9
|
-
groupFeedItems,
|
|
10
|
-
EARLIER_AFTER_TAUS,
|
|
11
|
-
MINIMUM_FRESHNESS_EXAMPLES,
|
|
12
|
-
type FeedGroupKey,
|
|
13
9
|
type FeedItem,
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type ReferenceFeedTabKey,
|
|
10
|
+
type FeedShow,
|
|
11
|
+
type FeedSort,
|
|
17
12
|
} from "../lib/reference-feed-ranking";
|
|
18
13
|
import { dekAddsInformation, isCardStyleImage, referenceDek, referenceSourceHost, formatFeedAge, type ReferencePreview } from "../lib/reference-preview";
|
|
19
14
|
import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
20
15
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
21
16
|
import { cn } from "../lib/utils";
|
|
22
17
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
|
-
import {
|
|
18
|
+
import { DecisionControl } from "./decision-control";
|
|
24
19
|
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
20
|
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
26
21
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
@@ -47,8 +42,14 @@ export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
|
47
42
|
other: "Other",
|
|
48
43
|
};
|
|
49
44
|
|
|
50
|
-
const
|
|
51
|
-
|
|
45
|
+
const SORTS: Array<{ key: FeedSort; label: string; Icon: typeof SparklesIcon }> = [
|
|
46
|
+
{ key: "best", label: "Best bets", Icon: SparklesIcon },
|
|
47
|
+
{ key: "newest", label: "Newest", Icon: ClockIcon },
|
|
48
|
+
];
|
|
49
|
+
const SHOWS: Array<{ key: FeedShow; label: string; Icon: typeof SparklesIcon }> = [
|
|
50
|
+
{ key: "unrated", label: "Unrated only", Icon: CircleDashedIcon },
|
|
51
|
+
{ key: "all", label: "Include rated", Icon: CheckCheckIcon },
|
|
52
|
+
];
|
|
52
53
|
|
|
53
54
|
export const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
54
55
|
|
|
@@ -63,16 +64,20 @@ export type ReferenceFeedProps = {
|
|
|
63
64
|
onOpen: (lineageId: string) => void;
|
|
64
65
|
onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
65
66
|
onVisitRated: (lineageId: string) => void;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
onSortChange: (sort: FeedSort) => void;
|
|
68
|
+
onShowChange: (show: FeedShow) => void;
|
|
69
|
+
/** The visible list, already ordered by the chosen sort and filtered by the chosen Show. */
|
|
70
|
+
items: FeedItem[];
|
|
71
|
+
/** How many References already have an answer, for the empty text. */
|
|
72
|
+
ratedCount: number;
|
|
73
|
+
sort: FeedSort;
|
|
74
|
+
show: FeedShow;
|
|
69
75
|
predictionLoading?: boolean;
|
|
70
76
|
previews: ReadonlyMap<string, ReferencePreview>;
|
|
71
77
|
selectedLineageId?: string;
|
|
72
78
|
/** Capture status per Reference lineage, for the small badge on each card. Optional. */
|
|
73
79
|
captures?: ReadonlyMap<string, CaptureStatus>;
|
|
74
|
-
/** The
|
|
75
|
-
groups?: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
|
|
80
|
+
/** The large lead card, if any. */
|
|
76
81
|
leadId?: string;
|
|
77
82
|
/** Scrolls the grid to a card, deliberately (Previous / Next). A new nonce is a new request; selecting alone never scrolls. */
|
|
78
83
|
scrollTarget?: { id: string; nonce: number } | null;
|
|
@@ -80,7 +85,6 @@ export type ReferenceFeedProps = {
|
|
|
80
85
|
ratings?: RatingController;
|
|
81
86
|
/** True when the cards are a saved copy that is being refreshed. */
|
|
82
87
|
stale?: boolean;
|
|
83
|
-
tab: ReferenceFeedTabKey;
|
|
84
88
|
visitRated: ReadonlySet<string>;
|
|
85
89
|
};
|
|
86
90
|
|
|
@@ -202,6 +206,58 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
202
206
|
);
|
|
203
207
|
}
|
|
204
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The standard Reference header: its picture, source, headline, dek and age. The feed card wraps it in the button that opens the
|
|
211
|
+
* Reference, and the Messages page shows it under a conversation as the thing the conversation is about. No decision controls.
|
|
212
|
+
*/
|
|
213
|
+
export function ReferenceCardHeader({ capture, lead = false, messageCount = 0, now, preview, reference, titleId }: {
|
|
214
|
+
capture?: CaptureStatus | undefined;
|
|
215
|
+
lead?: boolean;
|
|
216
|
+
messageCount?: number;
|
|
217
|
+
now: number;
|
|
218
|
+
preview: ReferencePreview | undefined;
|
|
219
|
+
reference: ReferenceRecord;
|
|
220
|
+
titleId?: string;
|
|
221
|
+
}) {
|
|
222
|
+
const host = referenceSourceHost(reference);
|
|
223
|
+
const site = preview?.siteName ?? host ?? "Unknown source";
|
|
224
|
+
const title = referenceDisplayTitle(reference);
|
|
225
|
+
const dek = referenceDek(reference, preview);
|
|
226
|
+
const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
|
|
227
|
+
const time = referenceDisplayTime(reference);
|
|
228
|
+
const age = formatFeedAge(time, now);
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
<FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
|
|
232
|
+
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
233
|
+
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
234
|
+
<SourceMark host={host} />
|
|
235
|
+
<span className="truncate">{site}</span>
|
|
236
|
+
</span>
|
|
237
|
+
<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}>
|
|
238
|
+
{title}
|
|
239
|
+
</span>
|
|
240
|
+
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
241
|
+
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
242
|
+
<span>
|
|
243
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
244
|
+
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
245
|
+
{host}
|
|
246
|
+
</span>
|
|
247
|
+
{capture ? <CaptureBadge status={capture} /> : null}
|
|
248
|
+
{messageCount > 0 ? (
|
|
249
|
+
<span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
|
|
250
|
+
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
|
|
251
|
+
{messageCount}
|
|
252
|
+
</span>
|
|
253
|
+
) : null}
|
|
254
|
+
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
255
|
+
</span>
|
|
256
|
+
</span>
|
|
257
|
+
</>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
205
261
|
export function FeedCard({
|
|
206
262
|
capture,
|
|
207
263
|
messageCount = 0,
|
|
@@ -283,72 +339,33 @@ export function FeedCard({
|
|
|
283
339
|
onClick={onOpen}
|
|
284
340
|
type="button"
|
|
285
341
|
>
|
|
286
|
-
<
|
|
287
|
-
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
288
|
-
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
289
|
-
<SourceMark host={host} />
|
|
290
|
-
<span className="truncate">{site}</span>
|
|
291
|
-
</span>
|
|
292
|
-
<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}>
|
|
293
|
-
{title}
|
|
294
|
-
</span>
|
|
295
|
-
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
296
|
-
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
297
|
-
<span>
|
|
298
|
-
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
299
|
-
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
300
|
-
{host}
|
|
301
|
-
</span>
|
|
302
|
-
{capture ? <CaptureBadge status={capture} /> : null}
|
|
303
|
-
{messageCount > 0 ? (
|
|
304
|
-
<span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
|
|
305
|
-
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
|
|
306
|
-
{messageCount}
|
|
307
|
-
</span>
|
|
308
|
-
) : null}
|
|
309
|
-
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
310
|
-
</span>
|
|
311
|
-
</span>
|
|
342
|
+
<ReferenceCardHeader capture={capture} lead={lead} messageCount={messageCount} now={now} preview={preview} reference={reference} titleId={titleId} />
|
|
312
343
|
</button>
|
|
313
344
|
|
|
314
345
|
<div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
|
|
315
|
-
<
|
|
346
|
+
<DecisionControl
|
|
316
347
|
answer={chosen}
|
|
317
348
|
decided={answer}
|
|
318
349
|
disabled={Boolean(disabledReason)}
|
|
319
350
|
disabledReason={disabledReason}
|
|
320
351
|
feedback={feedback}
|
|
352
|
+
needsReason={rating.askReason}
|
|
321
353
|
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
322
354
|
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
355
|
+
onReasonChange={(text) => store.setNote(item.lineageId, text)}
|
|
356
|
+
onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
|
|
323
357
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
358
|
+
reason={rating.note ?? ""}
|
|
359
|
+
reasonRequiredFor="reject"
|
|
324
360
|
statusOverlay
|
|
325
361
|
/>
|
|
326
|
-
{rating.askReason ? (
|
|
327
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
328
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
329
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
330
|
-
<div className="flex flex-wrap gap-1.5">
|
|
331
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
332
|
-
<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">
|
|
333
|
-
{REASON_LABELS[code]}
|
|
334
|
-
</button>
|
|
335
|
-
))}
|
|
336
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
337
|
-
</div>
|
|
338
|
-
</div>
|
|
339
|
-
) : null}
|
|
340
362
|
</div>
|
|
341
363
|
</article>
|
|
342
364
|
);
|
|
343
365
|
}
|
|
344
366
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** The plain-words recipe, with this site's learned numbers in it. */
|
|
351
|
-
function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
|
|
367
|
+
/** The plain-words recipe. */
|
|
368
|
+
function RankingPopover() {
|
|
352
369
|
const [open, setOpen] = useState(false);
|
|
353
370
|
const panelId = useId();
|
|
354
371
|
const root = useRef<HTMLDivElement | null>(null);
|
|
@@ -387,15 +404,10 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
|
|
|
387
404
|
>
|
|
388
405
|
<p className="text-base font-bold">A feed run by algorithms you control</p>
|
|
389
406
|
<ol className="list-decimal space-y-2.5 pl-5">
|
|
390
|
-
<li><strong>Best bets
|
|
391
|
-
<li><strong>
|
|
392
|
-
<li><strong>
|
|
393
|
-
|
|
394
|
-
: <>Age does not change the order yet. The pace is learned from the items you accept, and so far there are {freshness.examples} of the {MINIMUM_FRESHNESS_EXAMPLES} needed.</>}</li>
|
|
395
|
-
<li><strong>Earlier.</strong> {freshness.tauDays !== null
|
|
396
|
-
? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
|
|
397
|
-
: <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
|
|
398
|
-
<li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit. Next time it is under Rated.</li>
|
|
407
|
+
<li><strong>Best bets</strong> puts the items the Cyclotron is most sure you will Accept first. It goes by confidence alone: an Accept at 90% ranks above an Accept at 60%, and the ones it expects you to Reject most surely come last. Items with no prediction sit in the middle. Age does not change this order.</li>
|
|
408
|
+
<li><strong>Newest</strong> puts the items that arrived most recently first.</li>
|
|
409
|
+
<li><strong>Unrated only</strong> shows items nobody has answered yet. <strong>Include rated</strong> adds the ones you have already accepted or rejected to the same list, showing your answer, in the same sort. Nothing is hidden because it is old.</li>
|
|
410
|
+
<li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit, and new items wait behind a button until you ask for them.</li>
|
|
399
411
|
</ol>
|
|
400
412
|
</div>
|
|
401
413
|
) : null}
|
|
@@ -403,10 +415,6 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
|
|
|
403
415
|
);
|
|
404
416
|
}
|
|
405
417
|
|
|
406
|
-
export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
|
|
407
|
-
return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
418
|
export function ReferenceFeed({
|
|
411
419
|
disabledReason = null,
|
|
412
420
|
loading = false,
|
|
@@ -414,22 +422,23 @@ export function ReferenceFeed({
|
|
|
414
422
|
now,
|
|
415
423
|
onOpen,
|
|
416
424
|
onRate,
|
|
417
|
-
|
|
425
|
+
onShowChange,
|
|
426
|
+
onSortChange,
|
|
418
427
|
onVisitRated,
|
|
419
|
-
|
|
428
|
+
items,
|
|
429
|
+
ratedCount,
|
|
420
430
|
predictionLoading = false,
|
|
421
431
|
previews,
|
|
422
432
|
selectedLineageId = "",
|
|
423
433
|
captures,
|
|
424
|
-
|
|
425
|
-
leadId: stableLeadId,
|
|
434
|
+
leadId,
|
|
426
435
|
ratings,
|
|
427
436
|
scrollTarget = null,
|
|
437
|
+
show,
|
|
438
|
+
sort,
|
|
428
439
|
stale = false,
|
|
429
|
-
tab,
|
|
430
440
|
visitRated,
|
|
431
441
|
}: ReferenceFeedProps) {
|
|
432
|
-
const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
|
|
433
442
|
|
|
434
443
|
const renderCard = (item: FeedItem, lead: boolean) => (
|
|
435
444
|
<FeedCard
|
|
@@ -461,28 +470,39 @@ export function ReferenceFeed({
|
|
|
461
470
|
document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
462
471
|
}, [scrollTarget]);
|
|
463
472
|
|
|
464
|
-
const groups = stableGroups ?? groupFeedItems(partition.feed);
|
|
465
|
-
const headings = groups.length > 1;
|
|
466
|
-
const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
467
473
|
|
|
468
474
|
return (
|
|
469
|
-
<div className="@container space-y-5" data-reference-feed data-feed-
|
|
470
|
-
<div className="flex items-center justify-between gap-2">
|
|
471
|
-
<div
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
475
|
+
<div className="@container space-y-5" data-reference-feed data-feed-sort={sort} data-feed-show={show} {...FEED_SURFACE_PROPS}>
|
|
476
|
+
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
|
477
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
478
|
+
<div aria-label="Sort" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" data-feed-control="sort" role="group">
|
|
479
|
+
{SORTS.map(({ key, label, Icon }) => (
|
|
480
|
+
<button
|
|
481
|
+
aria-pressed={sort === key}
|
|
482
|
+
className={cn(PILL, "px-3.5", sort === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
483
|
+
data-feed-sort-button={key}
|
|
484
|
+
key={key}
|
|
485
|
+
onClick={() => onSortChange(key)}
|
|
486
|
+
type="button"
|
|
487
|
+
>
|
|
488
|
+
<Icon aria-hidden="true" className="size-4" />{label}
|
|
489
|
+
</button>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
<div aria-label="Show" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" data-feed-control="show" role="group">
|
|
493
|
+
{SHOWS.map(({ key, label, Icon }) => (
|
|
494
|
+
<button
|
|
495
|
+
aria-pressed={show === key}
|
|
496
|
+
className={cn(PILL, "px-3.5", show === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
497
|
+
data-feed-show-button={key}
|
|
498
|
+
key={key}
|
|
499
|
+
onClick={() => onShowChange(key)}
|
|
500
|
+
type="button"
|
|
501
|
+
>
|
|
502
|
+
<Icon aria-hidden="true" className="size-4" />{label}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
486
506
|
</div>
|
|
487
507
|
<div className="flex items-center gap-2">
|
|
488
508
|
{stale ? (
|
|
@@ -490,33 +510,15 @@ export function ReferenceFeed({
|
|
|
490
510
|
<Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
|
|
491
511
|
</span>
|
|
492
512
|
) : null}
|
|
493
|
-
<RankingPopover
|
|
513
|
+
<RankingPopover />
|
|
494
514
|
</div>
|
|
495
515
|
</div>
|
|
496
516
|
{stale ? <p className="text-xs text-muted-foreground sm:hidden" role="status"><Loader2Icon aria-hidden="true" className="mr-1 inline size-3.5 animate-spin" />Showing your last view while it updates</p> : null}
|
|
497
517
|
|
|
498
518
|
{loading ? <ReferenceFeedSkeleton /> : (
|
|
499
|
-
|
|
500
|
-
{
|
|
501
|
-
|
|
502
|
-
{groups.length ? groups.map((group) => (
|
|
503
|
-
<section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
|
|
504
|
-
{headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
|
|
505
|
-
<div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
|
|
506
|
-
</section>
|
|
507
|
-
)) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
|
|
508
|
-
{partition.earlier.length ? (
|
|
509
|
-
<section aria-labelledby="feed-earlier-heading" data-feed-earlier>
|
|
510
|
-
<SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
|
|
511
|
-
<p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
|
|
512
|
-
<div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
|
|
513
|
-
</section>
|
|
514
|
-
) : null}
|
|
515
|
-
</div>
|
|
516
|
-
) : null}
|
|
517
|
-
{tab === "rated" ? (partition.rated.length ? <div className={FEED_GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
|
|
518
|
-
{tab === "earlier" ? (partition.earlier.length ? <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
|
|
519
|
-
</div>
|
|
519
|
+
items.length ? (
|
|
520
|
+
<div className={FEED_GRID} data-feed-grid>{items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
|
|
521
|
+
) : empty(ratedCount && show === "unrated" ? "You are all caught up. Choose Include rated to see your answers." : "No references yet.")
|
|
520
522
|
)}
|
|
521
523
|
</div>
|
|
522
524
|
);
|
|
@@ -2,20 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ArchiveIcon,
|
|
5
|
-
CheckIcon,
|
|
6
5
|
ChevronLeftIcon,
|
|
7
6
|
ChevronRightIcon,
|
|
8
|
-
CircleCheckIcon,
|
|
9
|
-
CircleXIcon,
|
|
10
7
|
ExternalLinkIcon,
|
|
11
8
|
} from "lucide-react";
|
|
12
9
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
10
|
import { Button } from "./ui/button";
|
|
14
|
-
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
15
11
|
import { cn } from "../lib/utils";
|
|
16
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
17
13
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
18
|
-
import {
|
|
14
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
19
15
|
|
|
20
16
|
export type ReviewChoice = "accept" | "reject";
|
|
21
17
|
|
|
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
|
|
|
31
27
|
reasonCode: ReferenceRejectionReasonCode | null;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
35
|
-
out_of_scope: "Out of scope",
|
|
36
|
-
policy_exclusion: "Policy exclusion",
|
|
37
|
-
duplicate: "Duplicate",
|
|
38
|
-
low_quality: "Low quality",
|
|
39
|
-
unavailable: "Unavailable",
|
|
40
|
-
provenance: "Provenance",
|
|
41
|
-
other: "Other",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
45
|
-
|
|
46
|
-
function percent(confidence: number | null): string | null {
|
|
47
|
-
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
52
|
-
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
53
|
-
*/
|
|
54
|
-
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
55
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
56
|
-
return (
|
|
57
|
-
<div className="space-y-2" data-review-row="cyclotron">
|
|
58
|
-
<div className="flex items-center gap-3">
|
|
59
|
-
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
60
|
-
{prediction.state === "ready" ? (
|
|
61
|
-
<div className="flex flex-1 gap-2">
|
|
62
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
63
|
-
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
64
|
-
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
65
|
-
return (
|
|
66
|
-
<span
|
|
67
|
-
className={cn(
|
|
68
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
69
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
70
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
71
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
72
|
-
)}
|
|
73
|
-
data-cyclotron-choice={choice}
|
|
74
|
-
data-predicted={predicted || undefined}
|
|
75
|
-
data-verdict={verdict ?? undefined}
|
|
76
|
-
key={choice}
|
|
77
|
-
>
|
|
78
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
79
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
80
|
-
{CHOICE_LABEL[choice]}
|
|
81
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
82
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
83
|
-
</span>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
86
|
-
</div>
|
|
87
|
-
) : (
|
|
88
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
89
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
90
|
-
</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
98
|
-
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
99
|
-
return (
|
|
100
|
-
<div className="flex flex-1 gap-2">
|
|
101
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
102
|
-
const on = chosen === choice;
|
|
103
|
-
return (
|
|
104
|
-
<Button
|
|
105
|
-
aria-disabled={disabled ? true : undefined}
|
|
106
|
-
aria-pressed={on}
|
|
107
|
-
className="min-h-14 flex-1 text-base"
|
|
108
|
-
data-review-choice={choice}
|
|
109
|
-
key={choice}
|
|
110
|
-
onClick={() => onChoose(choice)}
|
|
111
|
-
type="button"
|
|
112
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
113
|
-
>
|
|
114
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
115
|
-
{CHOICE_LABEL[choice]}
|
|
116
|
-
</Button>
|
|
117
|
-
);
|
|
118
|
-
})}
|
|
119
|
-
</div>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A reference's relevance decision in the card's own vocabulary. */
|
|
124
30
|
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
125
31
|
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
126
32
|
}
|
|
@@ -181,10 +87,8 @@ export function ReviewCard({
|
|
|
181
87
|
const store = ratings ?? local;
|
|
182
88
|
const item = store.get(itemId);
|
|
183
89
|
const note = item.note ?? initialNote;
|
|
184
|
-
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
185
90
|
const chosen = item.pending ?? decided;
|
|
186
91
|
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
187
|
-
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
188
92
|
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
189
93
|
|
|
190
94
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
@@ -222,53 +126,23 @@ export function ReviewCard({
|
|
|
222
126
|
) : null}
|
|
223
127
|
</header>
|
|
224
128
|
|
|
225
|
-
<
|
|
129
|
+
<DecisionControl
|
|
226
130
|
answer={chosen}
|
|
227
131
|
decided={decided}
|
|
228
132
|
disabled={Boolean(disabledReason)}
|
|
229
133
|
disabledReason={disabledReason}
|
|
230
134
|
feedback={item.feedback}
|
|
135
|
+
needsReason={item.askReason}
|
|
231
136
|
onChoose={choose}
|
|
137
|
+
onReasonChange={(text) => store.setNote(itemId, text)}
|
|
138
|
+
onReasonCommit={commitNote}
|
|
232
139
|
prediction={prediction}
|
|
140
|
+
reason={note}
|
|
141
|
+
reasonRequiredFor="reject"
|
|
233
142
|
size="large"
|
|
234
143
|
surface="card"
|
|
235
144
|
/>
|
|
236
145
|
|
|
237
|
-
<label className="block text-sm font-medium">
|
|
238
|
-
Why? (optional)
|
|
239
|
-
<AutoGrowTextarea
|
|
240
|
-
aria-label="Why? (optional)"
|
|
241
|
-
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
242
|
-
onBlur={commitNote}
|
|
243
|
-
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
244
|
-
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
245
|
-
value={note}
|
|
246
|
-
/>
|
|
247
|
-
</label>
|
|
248
|
-
|
|
249
|
-
{chosen === "reject" ? (
|
|
250
|
-
<div className="space-y-2" data-review-reasons>
|
|
251
|
-
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
252
|
-
<div className="flex flex-wrap gap-2">
|
|
253
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
254
|
-
<button
|
|
255
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
256
|
-
aria-pressed={reason === code}
|
|
257
|
-
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
258
|
-
data-review-reason={code}
|
|
259
|
-
key={code}
|
|
260
|
-
onClick={() => chooseReason(code)}
|
|
261
|
-
type="button"
|
|
262
|
-
>
|
|
263
|
-
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
264
|
-
{REASON_LABELS[code]}
|
|
265
|
-
</button>
|
|
266
|
-
))}
|
|
267
|
-
</div>
|
|
268
|
-
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
269
|
-
</div>
|
|
270
|
-
) : null}
|
|
271
|
-
|
|
272
146
|
{children}
|
|
273
147
|
|
|
274
148
|
{onArchive ? (
|
|
@@ -303,7 +177,7 @@ export function ReviewQueueBar({
|
|
|
303
177
|
return (
|
|
304
178
|
<nav
|
|
305
179
|
aria-label="Review queue"
|
|
306
|
-
className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "
|
|
180
|
+
className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "lg:fixed lg:bottom-0 lg:right-0 lg:w-[calc(24rem+1rem)] lg:pr-7 xl:w-[calc(28rem+1rem)]")}
|
|
307
181
|
data-review-queue-anchored={anchored || undefined}
|
|
308
182
|
data-review-queue-bar
|
|
309
183
|
>
|
|
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
4
4
|
import { LoaderIcon } from "lucide-react";
|
|
5
5
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
6
6
|
import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
7
|
-
import {
|
|
7
|
+
import { buildFeedItems, feedLeadId, visibleFeedItems } from "../lib/reference-feed-ranking";
|
|
8
8
|
import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
|
|
9
9
|
import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
|
|
10
|
+
import { useFeedPreferences } from "./use-feed-preferences";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* What the References page shows while the session and the first reads are still resolving: the real page header at once,
|
|
@@ -16,7 +17,7 @@ import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
|
|
|
16
17
|
export function ReferencesPageLoading() {
|
|
17
18
|
const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
|
|
18
19
|
const [checked, setChecked] = useState(false);
|
|
19
|
-
const
|
|
20
|
+
const { sort, setSort, show, setShow } = useFeedPreferences();
|
|
20
21
|
const [note, setNote] = useState<string | null>(null);
|
|
21
22
|
const [now] = useState(() => Date.now());
|
|
22
23
|
useEffect(() => {
|
|
@@ -26,8 +27,9 @@ export function ReferencesPageLoading() {
|
|
|
26
27
|
const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
|
|
27
28
|
const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
|
|
28
29
|
const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
|
|
29
|
-
const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
|
|
30
30
|
const noVisitRated = useMemo(() => new Set<string>(), []);
|
|
31
|
+
const allItems = useMemo(() => buildFeedItems(references, decisions), [references, decisions]);
|
|
32
|
+
const items = useMemo(() => visibleFeedItems(allItems, sort, show, noVisitRated), [allItems, sort, show, noVisitRated]);
|
|
31
33
|
|
|
32
34
|
return (
|
|
33
35
|
<div className="space-y-4" data-references-page-loading>
|
|
@@ -42,12 +44,16 @@ export function ReferencesPageLoading() {
|
|
|
42
44
|
now={now}
|
|
43
45
|
onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
|
|
44
46
|
onRate={() => undefined}
|
|
45
|
-
|
|
47
|
+
onShowChange={setShow}
|
|
48
|
+
onSortChange={setSort}
|
|
46
49
|
onVisitRated={() => undefined}
|
|
47
|
-
|
|
50
|
+
items={items}
|
|
51
|
+
leadId={feedLeadId(items)}
|
|
52
|
+
ratedCount={allItems.filter((item) => item.rated).length}
|
|
48
53
|
previews={previews}
|
|
54
|
+
show={show}
|
|
55
|
+
sort={sort}
|
|
49
56
|
stale
|
|
50
|
-
tab={tab}
|
|
51
57
|
visitRated={noVisitRated}
|
|
52
58
|
/>
|
|
53
59
|
) : (
|