@anthusai/papyrus 5.42.0 → 5.43.0-next.1
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/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +12 -69
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/components/monitoring-overview-view.tsx +12 -7
- package/components/monitoring-panel.tsx +33 -3
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +141 -257
- package/components/notification-center.tsx +1 -1
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +12 -41
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +110 -0
- package/components/proposals-view.tsx +93 -0
- package/components/reference-review-card.tsx +383 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +11 -2
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +1 -2
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +39 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-requests.ts +4 -2
- package/package.json +12 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
- /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
|
@@ -4,14 +4,7 @@ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
|
4
4
|
import { ReferenceAddEntry } from "./reference-add-entry";
|
|
5
5
|
import { ReferenceCurationControls, useReferenceImport } from "./reference-curation-controls";
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
ArchiveIcon,
|
|
9
|
-
ChevronLeftIcon,
|
|
10
|
-
ChevronRightIcon,
|
|
11
|
-
ExternalLinkIcon,
|
|
12
|
-
ThumbsDownIcon,
|
|
13
|
-
ThumbsUpIcon,
|
|
14
|
-
} from "lucide-react";
|
|
7
|
+
import { ExternalLinkIcon } from "lucide-react";
|
|
15
8
|
import { usePathname } from "next/navigation";
|
|
16
9
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
10
|
import { Badge } from "./ui/badge";
|
|
@@ -46,14 +39,20 @@ import {
|
|
|
46
39
|
type ReferenceCurationAction,
|
|
47
40
|
type ReferenceStatusFilter,
|
|
48
41
|
} from "../lib/newsroom-references";
|
|
49
|
-
import { normalizeReferenceCurationStatus, referenceReasonCode,
|
|
42
|
+
import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
50
43
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
51
44
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
52
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
ReviewCard,
|
|
47
|
+
ReviewQueueBar,
|
|
48
|
+
useQueueGestures,
|
|
49
|
+
type CardPrediction,
|
|
50
|
+
type ReviewCardDecision,
|
|
51
|
+
type ReviewChoice,
|
|
52
|
+
} from "./reference-review-card";
|
|
53
53
|
import {
|
|
54
54
|
CountsAsTrainingLabelNote,
|
|
55
55
|
DelegatedCurationNote,
|
|
56
|
-
ReferenceSuggestionBlock,
|
|
57
56
|
ReferenceSuggestionProvenance,
|
|
58
57
|
ReferenceSuggestionRowLine,
|
|
59
58
|
} from "./reference-cyclotron-suggestion";
|
|
@@ -75,7 +74,7 @@ import type { PrimingFormInput } from "./reference-cyclotron-priming";
|
|
|
75
74
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
76
75
|
import type { PrimingRequestView } from "../lib/relevance-status";
|
|
77
76
|
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
78
|
-
import type
|
|
77
|
+
import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
79
78
|
import {
|
|
80
79
|
loadReferenceAttachmentsForLineageId,
|
|
81
80
|
loadStoragePathUrl,
|
|
@@ -252,17 +251,38 @@ function useReferencePreviewAttachments(
|
|
|
252
251
|
}
|
|
253
252
|
|
|
254
253
|
function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
|
|
255
|
-
return
|
|
256
|
-
|
|
254
|
+
return awaitsEditorReview(reference.curationStatus, decision);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function summaryOf(reference: ReferenceRecord): string | null {
|
|
258
|
+
const raw = reference.metadata;
|
|
259
|
+
let metadata: Record<string, unknown> = {};
|
|
260
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) metadata = raw as Record<string, unknown>;
|
|
261
|
+
else if (typeof raw === "string") {
|
|
262
|
+
try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) metadata = parsed; } catch { /* no summary */ }
|
|
263
|
+
}
|
|
264
|
+
for (const key of ["summary", "abstract", "description"]) {
|
|
265
|
+
const value = metadata[key];
|
|
266
|
+
if (typeof value === "string" && value.trim()) return value.trim().length > 280 ? `${value.trim().slice(0, 277)}…` : value.trim();
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cardPrediction(decision: RelevanceDecisionView | undefined, predictionState: PredictionState): CardPrediction {
|
|
272
|
+
if (decision) {
|
|
273
|
+
const positive = decision.positiveLabel ?? "include";
|
|
274
|
+
return { state: "ready", choice: decision.label === positive ? "accept" : "reject", confidence: decision.confidence };
|
|
275
|
+
}
|
|
276
|
+
if (predictionState === "loading") return { state: "loading" };
|
|
277
|
+
if (predictionState === "error") return { state: "error" };
|
|
278
|
+
return { state: "none" };
|
|
257
279
|
}
|
|
258
280
|
|
|
259
281
|
function ReferenceDetailPanel({
|
|
260
282
|
countsAsTrainingLabel,
|
|
261
283
|
delegated,
|
|
262
284
|
decision,
|
|
263
|
-
demo,
|
|
264
285
|
disabled,
|
|
265
|
-
onRelevanceReview,
|
|
266
286
|
onReview,
|
|
267
287
|
predictionState = "ready",
|
|
268
288
|
reviewReason,
|
|
@@ -292,76 +312,25 @@ function ReferenceDetailPanel({
|
|
|
292
312
|
onClose?: () => void;
|
|
293
313
|
}) {
|
|
294
314
|
const lineageId = referenceLineageId(reference);
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const [reasonCode, setReasonCode] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
|
|
299
|
-
const [savedNote, setSavedNote] = useState(sourceNote);
|
|
300
|
-
const [savedReason, setSavedReason] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
|
|
301
|
-
const [busy, setBusy] = useState(false);
|
|
302
|
-
const [reviewError, setReviewError] = useState<string | null>(null);
|
|
303
|
-
const [saved, setSaved] = useState(false);
|
|
304
|
-
const saving = useRef(false);
|
|
305
|
-
const noteEdited = useRef(false);
|
|
306
|
-
const reasonEdited = useRef(false);
|
|
307
|
-
useEffect(() => {
|
|
308
|
-
setSavedNote(sourceNote);
|
|
309
|
-
if (!noteEdited.current) setNote(sourceNote);
|
|
310
|
-
}, [sourceNote]);
|
|
311
|
-
useEffect(() => {
|
|
312
|
-
setSavedReason(sourceReason);
|
|
313
|
-
if (!reasonEdited.current) setReasonCode(sourceReason);
|
|
314
|
-
}, [sourceReason]);
|
|
315
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
316
|
+
const sourceNote = status === "pending" ? "" : reference.curationStatusReason ?? "";
|
|
317
|
+
const sourceReason = reviewReason ?? referenceReasonCode(reference) ?? null;
|
|
315
318
|
const [chosenSectionId, setChosenSectionId] = useState<string | null>(reviewSectionId ?? null);
|
|
316
|
-
const [savedSectionId, setSavedSectionId] = useState<string | null>(reviewSectionId ?? null);
|
|
317
319
|
const sectionEdited = useRef(false);
|
|
318
320
|
useEffect(() => {
|
|
319
|
-
setSavedSectionId(reviewSectionId ?? null);
|
|
320
321
|
if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
|
|
321
322
|
}, [reviewSectionId]);
|
|
322
|
-
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
323
323
|
const url = referenceUrl(reference);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
saving.current = true;
|
|
337
|
-
setBusy(true);
|
|
338
|
-
setSaved(false);
|
|
339
|
-
setReviewError(null);
|
|
340
|
-
try {
|
|
341
|
-
if (review && onRelevanceReview) await onRelevanceReview({ ...review, ...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}) });
|
|
342
|
-
else await onReview(action, {
|
|
343
|
-
note: note.trim(),
|
|
344
|
-
reasonCode: action === "reject" ? reasonCode || null : null,
|
|
345
|
-
...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
|
|
346
|
-
...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
|
|
347
|
-
});
|
|
348
|
-
if (review) {
|
|
349
|
-
setNote(review.note ?? "");
|
|
350
|
-
setReasonCode((review.reasonCode as ReferenceRejectionReasonCode) ?? "");
|
|
351
|
-
}
|
|
352
|
-
setSavedNote(review ? review.note ?? "" : note.trim());
|
|
353
|
-
setSavedReason(action === "reject" ? rejectionReason as ReferenceRejectionReasonCode : "");
|
|
354
|
-
setSavedSectionId(action === "accept" ? chosenSectionId : null);
|
|
355
|
-
noteEdited.current = false;
|
|
356
|
-
reasonEdited.current = false;
|
|
357
|
-
sectionEdited.current = false;
|
|
358
|
-
setSaved(true);
|
|
359
|
-
} catch (error) {
|
|
360
|
-
setReviewError(error instanceof Error ? error.message : "Review could not be saved. Try again.");
|
|
361
|
-
} finally {
|
|
362
|
-
saving.current = false;
|
|
363
|
-
setBusy(false);
|
|
364
|
-
}
|
|
324
|
+
|
|
325
|
+
async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
|
|
326
|
+
const action: ReferenceCurationAction = choice;
|
|
327
|
+
await onReview(action, {
|
|
328
|
+
note: review.note,
|
|
329
|
+
reasonCode: review.reasonCode,
|
|
330
|
+
...(choice === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
|
|
331
|
+
...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
|
|
332
|
+
});
|
|
333
|
+
sectionEdited.current = false;
|
|
365
334
|
}
|
|
366
335
|
|
|
367
336
|
return (
|
|
@@ -369,135 +338,45 @@ function ReferenceDetailPanel({
|
|
|
369
338
|
className="flex h-full flex-col font-sans"
|
|
370
339
|
data-news-desk-reference-detail={lineageId}
|
|
371
340
|
>
|
|
372
|
-
<div className="space-y-4 p-4">
|
|
373
|
-
<div className="
|
|
374
|
-
<
|
|
375
|
-
|
|
376
|
-
{reference.corpusId ? (
|
|
377
|
-
<Badge variant="outline">{reference.corpusId}</Badge>
|
|
378
|
-
) : null}
|
|
379
|
-
</div>
|
|
380
|
-
<h2 className="text-lg font-semibold leading-snug text-foreground">{referenceDisplayTitle(reference)}</h2>
|
|
381
|
-
{reference.externalItemId ? (
|
|
382
|
-
<p className="text-sm text-muted-foreground">{reference.externalItemId}</p>
|
|
383
|
-
) : null}
|
|
341
|
+
<div className="space-y-4 p-4" data-reference-curation-status={status}>
|
|
342
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
343
|
+
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
344
|
+
{reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
|
|
384
345
|
</div>
|
|
385
346
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
<details className="text-sm text-foreground">
|
|
399
|
-
<summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">Review with a reason</summary>
|
|
400
|
-
<div className="mt-2">
|
|
401
|
-
<ReferenceRelevanceReview
|
|
402
|
-
decision={decision}
|
|
403
|
-
disabled={locked}
|
|
404
|
-
onCurate={(review) => save(review.action, review)}
|
|
405
|
-
referenceLineageId={lineageId}
|
|
406
|
-
/>
|
|
407
|
-
</div>
|
|
408
|
-
</details>
|
|
409
|
-
) : null}
|
|
410
|
-
</div>
|
|
411
|
-
) : predictionState === "ready" ? (
|
|
412
|
-
<section aria-label="Publication prediction" className="space-y-1 rounded-md border p-3">
|
|
413
|
-
<h3 className="text-sm font-semibold">No prediction recorded</h3>
|
|
414
|
-
<p className="text-sm text-muted-foreground">No publication relevance prediction was returned for this reference. You can still review it. Pending is its editorial status, not a prediction status.</p>
|
|
415
|
-
</section>
|
|
416
|
-
) : null}
|
|
417
|
-
{sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
|
|
418
|
-
<details className="rounded-md border p-3 text-sm">
|
|
419
|
-
<summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
|
|
420
|
-
<div className="mt-3 space-y-3">
|
|
421
|
-
{sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
|
|
422
|
-
{(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
|
|
423
|
-
agreement={sectionAgreement}
|
|
424
|
-
chosenSectionId={chosenSectionId}
|
|
425
|
-
disabled={locked}
|
|
426
|
-
onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; setSaved(false); }}
|
|
427
|
-
referenceLineageId={lineageId}
|
|
428
|
-
sections={sections}
|
|
429
|
-
suggestedSectionId={sectionSuggestion?.sectionId ?? null}
|
|
430
|
-
/> : null}
|
|
431
|
-
</div>
|
|
432
|
-
</details>
|
|
433
|
-
) : null}
|
|
434
|
-
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
435
|
-
|
|
436
|
-
<section aria-label="Editor feedback" className="space-y-3">
|
|
437
|
-
<h3 className="text-sm font-semibold">Editor feedback</h3>
|
|
438
|
-
{disabled ? <p className="text-sm text-muted-foreground">Editor access is required to change this reference.</p> : null}
|
|
439
|
-
<label className="block text-sm">
|
|
440
|
-
Review explanation (optional)
|
|
441
|
-
<textarea aria-label="Review explanation (optional)" className="mt-1 block min-h-20 w-full rounded-md border bg-background p-2" disabled={locked} value={note} onChange={(event) => { setNote(event.target.value); noteEdited.current = true; setSaved(false); }} />
|
|
442
|
-
</label>
|
|
443
|
-
<label className="block text-sm">
|
|
444
|
-
Rejection reason
|
|
445
|
-
<select aria-label="Rejection reason" className="mt-1 block w-full rounded-md border bg-background p-2" disabled={locked} value={reasonCode} onChange={(event) => { setReasonCode(event.target.value as ReferenceRejectionReasonCode | ""); reasonEdited.current = true; setSaved(false); }}>
|
|
446
|
-
<option value="">Choose a reason when rejecting</option>
|
|
447
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => <option key={code} value={code}>{code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " ")}</option>)}
|
|
448
|
-
</select>
|
|
449
|
-
</label>
|
|
450
|
-
<p className="text-xs text-muted-foreground">Only out of scope and policy exclusion are negative relevance labels. Other rejection reasons do not teach relevance.</p>
|
|
451
|
-
<div
|
|
452
|
-
className="flex flex-wrap items-center gap-2"
|
|
453
|
-
data-news-desk-reference-curation-cluster
|
|
454
|
-
data-reference-curation-status={status}
|
|
347
|
+
<ReviewCard
|
|
348
|
+
decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
|
|
349
|
+
disabledReason={disabled ? "Editor access is required to change this reference." : null}
|
|
350
|
+
initialNote={sourceNote}
|
|
351
|
+
initialReason={sourceReason}
|
|
352
|
+
itemId={lineageId}
|
|
353
|
+
onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
|
|
354
|
+
onDecide={decide}
|
|
355
|
+
prediction={cardPrediction(decision, predictionState)}
|
|
356
|
+
summary={summaryOf(reference)}
|
|
357
|
+
title={referenceDisplayTitle(reference)}
|
|
358
|
+
titleHref={url}
|
|
455
359
|
>
|
|
456
|
-
<
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
disabled={rejectDisabled}
|
|
474
|
-
onClick={() => { void save("reject"); }}
|
|
475
|
-
size="sm"
|
|
476
|
-
type="button"
|
|
477
|
-
variant={status === "rejected" ? "destructive" : "outline"}
|
|
478
|
-
>
|
|
479
|
-
<ThumbsDownIcon className="size-4" />
|
|
480
|
-
Reject
|
|
481
|
-
</Button>
|
|
482
|
-
<Button
|
|
483
|
-
aria-label="Archive reference"
|
|
484
|
-
disabled={archiveDisabled}
|
|
485
|
-
onClick={() => { void save("archive"); }}
|
|
486
|
-
size="sm"
|
|
487
|
-
type="button"
|
|
488
|
-
variant="outline"
|
|
489
|
-
>
|
|
490
|
-
<ArchiveIcon className="size-4" />
|
|
491
|
-
Archive
|
|
492
|
-
</Button>
|
|
493
|
-
{status === "accepted" || status === "rejected" ? (
|
|
494
|
-
<Button disabled={locked || predictionState !== "ready" || (note.trim() === savedNote.trim() && (status !== "rejected" || reasonCode === savedReason) && (status !== "accepted" || chosenSectionId === savedSectionId))} onClick={() => { void save(status === "accepted" ? "accept" : "reject"); }} size="sm" type="button" variant="outline">Save feedback</Button>
|
|
360
|
+
{decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
|
|
361
|
+
{sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
|
|
362
|
+
<details className="rounded-md border p-3 text-sm">
|
|
363
|
+
<summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
|
|
364
|
+
<div className="mt-3 space-y-3">
|
|
365
|
+
{sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
|
|
366
|
+
{(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
|
|
367
|
+
agreement={sectionAgreement}
|
|
368
|
+
chosenSectionId={chosenSectionId}
|
|
369
|
+
disabled={Boolean(disabled)}
|
|
370
|
+
onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; }}
|
|
371
|
+
referenceLineageId={lineageId}
|
|
372
|
+
sections={sections}
|
|
373
|
+
suggestedSectionId={sectionSuggestion?.sectionId ?? null}
|
|
374
|
+
/> : null}
|
|
375
|
+
</div>
|
|
376
|
+
</details>
|
|
495
377
|
) : null}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
{busy ? <p role="status" className="text-sm">Saving review…</p> : saved ? <p role="status" className="text-sm">Review saved.</p> : null}
|
|
499
|
-
{reviewError ? <p role="alert" className="text-sm text-destructive">{reviewError}</p> : null}
|
|
500
|
-
</section>
|
|
378
|
+
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
379
|
+
</ReviewCard>
|
|
501
380
|
|
|
502
381
|
<Separator />
|
|
503
382
|
|
|
@@ -514,22 +393,6 @@ function ReferenceDetailPanel({
|
|
|
514
393
|
|| "No explanation recorded."}
|
|
515
394
|
</dd>
|
|
516
395
|
</div>
|
|
517
|
-
{url ? (
|
|
518
|
-
<div>
|
|
519
|
-
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Source</dt>
|
|
520
|
-
<dd className="mt-1">
|
|
521
|
-
<a
|
|
522
|
-
className="inline-flex items-center gap-1 text-primary underline-offset-4 hover:underline"
|
|
523
|
-
href={url}
|
|
524
|
-
rel="noreferrer"
|
|
525
|
-
target="_blank"
|
|
526
|
-
>
|
|
527
|
-
{url}
|
|
528
|
-
<ExternalLinkIcon className="size-3.5" aria-hidden="true" />
|
|
529
|
-
</a>
|
|
530
|
-
</dd>
|
|
531
|
-
</div>
|
|
532
|
-
) : null}
|
|
533
396
|
<div className="grid grid-cols-2 gap-3">
|
|
534
397
|
<div>
|
|
535
398
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
|
|
@@ -615,7 +478,7 @@ export function NewsroomReferencesView({
|
|
|
615
478
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
616
479
|
const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
617
480
|
|
|
618
|
-
const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
|
|
481
|
+
const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
|
|
619
482
|
const needsReviewCount = useMemo(
|
|
620
483
|
() => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
|
|
621
484
|
[canonicalReferences, decisions],
|
|
@@ -639,13 +502,25 @@ export function NewsroomReferencesView({
|
|
|
639
502
|
|
|
640
503
|
const previewAttachments = useReferencePreviewAttachments(selectedReference, referenceAttachments);
|
|
641
504
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
?
|
|
648
|
-
|
|
505
|
+
// The queue is the current list filter. A reference the editor has just decided leaves the filtered
|
|
506
|
+
// list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
|
|
507
|
+
const queueRef = useRef<string[]>([]);
|
|
508
|
+
const queue = useMemo(() => {
|
|
509
|
+
const ids = filteredReferences.map(referenceLineageId);
|
|
510
|
+
const open = selectedReference ? referenceLineageId(selectedReference) : "";
|
|
511
|
+
if (open && !ids.includes(open)) {
|
|
512
|
+
const before = queueRef.current.indexOf(open);
|
|
513
|
+
if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
|
|
514
|
+
}
|
|
515
|
+
return ids;
|
|
516
|
+
}, [filteredReferences, selectedReference]);
|
|
517
|
+
useEffect(() => { queueRef.current = queue; }, [queue]);
|
|
518
|
+
const [caughtUp, setCaughtUp] = useState(false);
|
|
519
|
+
const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
|
|
520
|
+
const queuePosition = selectedIndex >= 0 ? selectedIndex + 1 : null;
|
|
521
|
+
const previousLineageId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
|
|
522
|
+
const nextLineageId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
|
|
523
|
+
const nextReference = nextLineageId ? selectedReferenceRecordByLineage(canonicalReferences, nextLineageId) : null;
|
|
649
524
|
|
|
650
525
|
const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
|
|
651
526
|
if (demo) return;
|
|
@@ -672,6 +547,7 @@ export function NewsroomReferencesView({
|
|
|
672
547
|
}, [isMobileDetail]);
|
|
673
548
|
|
|
674
549
|
const selectReference = (lineageId: string) => {
|
|
550
|
+
setCaughtUp(false);
|
|
675
551
|
setSelectedLineageId(lineageId);
|
|
676
552
|
if (isMobileDetail) setMobileDetailOpen(true);
|
|
677
553
|
if (typeof window !== "undefined") {
|
|
@@ -682,7 +558,22 @@ export function NewsroomReferencesView({
|
|
|
682
558
|
}
|
|
683
559
|
};
|
|
684
560
|
|
|
561
|
+
const goNext = () => {
|
|
562
|
+
if (nextLineageId) selectReference(nextLineageId);
|
|
563
|
+
else if (queue.length && !caughtUp) {
|
|
564
|
+
// Past the last item: say so, rather than showing an empty page.
|
|
565
|
+
setCaughtUp(true);
|
|
566
|
+
setSelectedLineageId("");
|
|
567
|
+
if (typeof window !== "undefined") window.history.pushState(null, "", getNewsroomNavHref("/newsroom/references", demo));
|
|
568
|
+
} else if (!queue.length) setCaughtUp(true);
|
|
569
|
+
};
|
|
570
|
+
const goPrevious = () => {
|
|
571
|
+
if (previousLineageId) selectReference(previousLineageId);
|
|
572
|
+
};
|
|
573
|
+
const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
|
|
574
|
+
|
|
685
575
|
const closeDetail = () => {
|
|
576
|
+
setCaughtUp(false);
|
|
686
577
|
setMobileDetailOpen(false);
|
|
687
578
|
setSelectedLineageId("");
|
|
688
579
|
if (typeof window !== "undefined") {
|
|
@@ -696,6 +587,16 @@ export function NewsroomReferencesView({
|
|
|
696
587
|
return onReview(selectedReference, action, options);
|
|
697
588
|
};
|
|
698
589
|
|
|
590
|
+
const caughtUpPanel = (
|
|
591
|
+
<div className="space-y-2 p-6 text-center" data-review-caught-up>
|
|
592
|
+
<h3 className="text-lg font-semibold">All caught up</h3>
|
|
593
|
+
<p className="text-sm text-muted-foreground">There is nothing more in this list. Use Previous to go back.</p>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
const queueBar = queue.length || caughtUp ? (
|
|
597
|
+
<ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
|
|
598
|
+
) : null;
|
|
599
|
+
|
|
699
600
|
const emptyLabel = canonicalReferences.length
|
|
700
601
|
? "No references match this filter."
|
|
701
602
|
: "No private references imported yet.";
|
|
@@ -744,10 +645,11 @@ export function NewsroomReferencesView({
|
|
|
744
645
|
|
|
745
646
|
<Tabs
|
|
746
647
|
className="shrink-0"
|
|
747
|
-
defaultValue="
|
|
648
|
+
defaultValue="pending"
|
|
748
649
|
onValueChange={(value) => {
|
|
749
650
|
const next = value as ReferenceListFilter;
|
|
750
651
|
setStatusFilter(next);
|
|
652
|
+
setCaughtUp(false);
|
|
751
653
|
syncStatusUrl(next);
|
|
752
654
|
}}
|
|
753
655
|
value={statusFilter}
|
|
@@ -826,29 +728,8 @@ export function NewsroomReferencesView({
|
|
|
826
728
|
>
|
|
827
729
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
|
|
828
730
|
<span className="text-sm font-medium text-foreground">Detail</span>
|
|
829
|
-
<div className="newsroom-list-detail-shell__detail-toolbar-trailing flex items-center gap-1">
|
|
830
|
-
<Button
|
|
831
|
-
aria-label="Previous"
|
|
832
|
-
disabled={!previousReference}
|
|
833
|
-
onClick={() => previousReference && selectReference(referenceLineageId(previousReference))}
|
|
834
|
-
size="icon-sm"
|
|
835
|
-
type="button"
|
|
836
|
-
variant="ghost"
|
|
837
|
-
>
|
|
838
|
-
<ChevronLeftIcon className="size-4" />
|
|
839
|
-
</Button>
|
|
840
|
-
<Button
|
|
841
|
-
aria-label="Next"
|
|
842
|
-
disabled={!nextReference}
|
|
843
|
-
onClick={() => nextReference && selectReference(referenceLineageId(nextReference))}
|
|
844
|
-
size="icon-sm"
|
|
845
|
-
type="button"
|
|
846
|
-
variant="ghost"
|
|
847
|
-
>
|
|
848
|
-
<ChevronRightIcon className="size-4" />
|
|
849
|
-
</Button>
|
|
850
|
-
</div>
|
|
851
731
|
</div>
|
|
732
|
+
<div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
|
|
852
733
|
<ScrollArea className="min-h-0 flex-1">
|
|
853
734
|
{selectedReference ? (
|
|
854
735
|
<ReferenceDetailPanel
|
|
@@ -869,12 +750,14 @@ export function NewsroomReferencesView({
|
|
|
869
750
|
previewAttachments={previewAttachments}
|
|
870
751
|
reference={selectedReference}
|
|
871
752
|
/>
|
|
872
|
-
) : (
|
|
753
|
+
) : caughtUp ? caughtUpPanel : (
|
|
873
754
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
|
874
755
|
Select a reference to inspect curation details.
|
|
875
756
|
</CardContent>
|
|
876
757
|
)}
|
|
877
758
|
</ScrollArea>
|
|
759
|
+
</div>
|
|
760
|
+
{queueBar}
|
|
878
761
|
</div>
|
|
879
762
|
|
|
880
763
|
{isMobileDetail ? (
|
|
@@ -883,14 +766,14 @@ export function NewsroomReferencesView({
|
|
|
883
766
|
if (!open) closeDetail();
|
|
884
767
|
else setMobileDetailOpen(true);
|
|
885
768
|
}}
|
|
886
|
-
open={mobileDetailOpen && Boolean(selectedReference)}
|
|
769
|
+
open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
|
|
887
770
|
>
|
|
888
771
|
<SheetContent className="gap-0 p-0" side="bottom">
|
|
889
772
|
<SheetHeader className="border-b border-border px-4 py-3">
|
|
890
773
|
<SheetTitle className="text-left text-base">Reference</SheetTitle>
|
|
891
774
|
</SheetHeader>
|
|
892
775
|
{selectedReference ? (
|
|
893
|
-
<ScrollArea className="max-h-[
|
|
776
|
+
<div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
|
|
894
777
|
<ReferenceDetailPanel
|
|
895
778
|
key={referenceLineageId(selectedReference)}
|
|
896
779
|
predictionState={predictionState}
|
|
@@ -910,8 +793,9 @@ export function NewsroomReferencesView({
|
|
|
910
793
|
previewAttachments={previewAttachments}
|
|
911
794
|
reference={selectedReference}
|
|
912
795
|
/>
|
|
913
|
-
</ScrollArea>
|
|
914
|
-
) : null}
|
|
796
|
+
</ScrollArea></div>
|
|
797
|
+
) : caughtUp ? caughtUpPanel : null}
|
|
798
|
+
{queueBar}
|
|
915
799
|
</SheetContent>
|
|
916
800
|
</Sheet>
|
|
917
801
|
) : null}
|
|
@@ -153,7 +153,7 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
|
|
|
153
153
|
<h1 className="text-lg font-semibold">Notification Center</h1>
|
|
154
154
|
<p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
|
|
155
155
|
<p className="text-xs text-muted-foreground">
|
|
156
|
-
Look, or steer. Anything you ignore keeps going: soft
|
|
156
|
+
Look, or steer. Anything you ignore keeps going: a soft review with a deadline proceeds at it, one without stays open until handled, and flags never wait. Only a hard gate waits for you.
|
|
157
157
|
</p>
|
|
158
158
|
<p className="text-xs text-muted-foreground" data-notification-unread>
|
|
159
159
|
{unreadCount ? `${unreadCount} unread` : "Nothing unread"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export function PapyrusConsoleFrame({ available, open, reserveSpace, onClose, page, children }: {
|
|
6
|
+
available: boolean;
|
|
7
|
+
open: boolean;
|
|
8
|
+
reserveSpace: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
page: ReactNode;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
const [mobile, setMobile] = useState(false);
|
|
14
|
+
const [viewport, setViewport] = useState<{ top: number; height: number } | null>(null);
|
|
15
|
+
const panel = useRef<HTMLElement>(null);
|
|
16
|
+
const modal = available && open && mobile;
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const media = window.matchMedia("(max-width: 900px)");
|
|
20
|
+
const update = () => setMobile(media.matches);
|
|
21
|
+
update();
|
|
22
|
+
media.addEventListener("change", update);
|
|
23
|
+
return () => media.removeEventListener("change", update);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!modal) return;
|
|
28
|
+
const visual = window.visualViewport;
|
|
29
|
+
const update = () => setViewport({ top: visual?.offsetTop ?? 0, height: visual?.height ?? window.innerHeight });
|
|
30
|
+
update();
|
|
31
|
+
visual?.addEventListener("resize", update);
|
|
32
|
+
visual?.addEventListener("scroll", update, { passive: true });
|
|
33
|
+
return () => {
|
|
34
|
+
visual?.removeEventListener("resize", update);
|
|
35
|
+
visual?.removeEventListener("scroll", update);
|
|
36
|
+
};
|
|
37
|
+
}, [modal]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!modal) return;
|
|
41
|
+
const previous = document.activeElement;
|
|
42
|
+
const frame = requestAnimationFrame(() => panel.current?.querySelector<HTMLElement>("button")?.focus());
|
|
43
|
+
return () => {
|
|
44
|
+
cancelAnimationFrame(frame);
|
|
45
|
+
requestAnimationFrame(() => {
|
|
46
|
+
if (previous instanceof HTMLElement && previous !== document.body && previous.isConnected && !previous.closest("[inert]")) previous.focus();
|
|
47
|
+
else Array.from(document.querySelectorAll<HTMLElement>(".organizer-chat, .edition-progress__button--console, .papyrus-console__header button")).find(element => element.getClientRects().length > 0)?.focus();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}, [modal]);
|
|
51
|
+
|
|
52
|
+
return <>
|
|
53
|
+
<div className={reserveSpace ? "papyrus-console-page papyrus-console-page--open" : "papyrus-console-page"} inert={modal || undefined}>{page}</div>
|
|
54
|
+
{available ? <aside ref={panel} id="papyrus-console-panel" className={open ? "papyrus-console papyrus-console--open" : "papyrus-console"} aria-label="Papyrus console" role={modal ? "dialog" : undefined} aria-modal={modal || undefined}
|
|
55
|
+
style={modal && viewport ? { top: viewport.top, height: viewport.height, bottom: "auto" } : undefined}
|
|
56
|
+
onKeyDown={event => {
|
|
57
|
+
if (!modal) return;
|
|
58
|
+
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
|
|
59
|
+
if (event.key !== "Tab") return;
|
|
60
|
+
const focusable = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []).filter(element => element.getClientRects().length > 0);
|
|
61
|
+
const first = focusable[0], last = focusable.at(-1);
|
|
62
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
|
63
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
64
|
+
}}>{children}</aside> : null}
|
|
65
|
+
</>;
|
|
66
|
+
}
|