@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21
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/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +64 -55
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +60 -2
- package/components/newsroom-references-view.tsx +301 -138
- package/components/notification-cards.tsx +369 -0
- package/components/notification-center.tsx +324 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +503 -0
- package/components/reference-review-card.tsx +110 -141
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +180 -0
- package/components/topic-steering-workspace.tsx +44 -9
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +134 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +55 -5
- package/lib/review-drafts.ts +252 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +64 -24
- package/lib/site-brand.ts +2 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +29 -1
- package/routes.manifest.json +1 -1
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
14
|
import { cn } from "../lib/utils";
|
|
15
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
16
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
15
17
|
import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
16
18
|
|
|
17
19
|
export type ReviewChoice = "accept" | "reject";
|
|
@@ -44,6 +46,84 @@ function percent(confidence: number | null): string | null {
|
|
|
44
46
|
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
51
|
+
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
52
|
+
*/
|
|
53
|
+
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
54
|
+
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
55
|
+
return (
|
|
56
|
+
<div className="space-y-2" data-review-row="cyclotron">
|
|
57
|
+
<div className="flex items-center gap-3">
|
|
58
|
+
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
59
|
+
{prediction.state === "ready" ? (
|
|
60
|
+
<div className="flex flex-1 gap-2">
|
|
61
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
62
|
+
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
63
|
+
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
className={cn(
|
|
67
|
+
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
68
|
+
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
69
|
+
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
70
|
+
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
71
|
+
)}
|
|
72
|
+
data-cyclotron-choice={choice}
|
|
73
|
+
data-predicted={predicted || undefined}
|
|
74
|
+
data-verdict={verdict ?? undefined}
|
|
75
|
+
key={choice}
|
|
76
|
+
>
|
|
77
|
+
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
78
|
+
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
79
|
+
{CHOICE_LABEL[choice]}
|
|
80
|
+
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
81
|
+
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
88
|
+
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
97
|
+
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex flex-1 gap-2">
|
|
100
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
101
|
+
const on = chosen === choice;
|
|
102
|
+
return (
|
|
103
|
+
<Button
|
|
104
|
+
aria-disabled={disabled ? true : undefined}
|
|
105
|
+
aria-pressed={on}
|
|
106
|
+
className="min-h-14 flex-1 text-base"
|
|
107
|
+
data-review-choice={choice}
|
|
108
|
+
key={choice}
|
|
109
|
+
onClick={() => onChoose(choice)}
|
|
110
|
+
type="button"
|
|
111
|
+
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
112
|
+
>
|
|
113
|
+
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
114
|
+
{CHOICE_LABEL[choice]}
|
|
115
|
+
</Button>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A reference's relevance decision in the card's own vocabulary. */
|
|
123
|
+
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
124
|
+
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
125
|
+
}
|
|
126
|
+
|
|
47
127
|
/**
|
|
48
128
|
* One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
|
|
49
129
|
* read-only; the human row is the real control and its answer is final. Saves on tap.
|
|
@@ -62,8 +142,11 @@ export function ReviewCard({
|
|
|
62
142
|
summary,
|
|
63
143
|
title,
|
|
64
144
|
titleHref,
|
|
145
|
+
ratings,
|
|
65
146
|
}: {
|
|
66
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;
|
|
67
150
|
/** The saved human answer, or null while the item is undecided. */
|
|
68
151
|
decided: ReviewChoice | null;
|
|
69
152
|
/** When set, the controls explain in words why they cannot change anything. */
|
|
@@ -78,70 +161,21 @@ export function ReviewCard({
|
|
|
78
161
|
title: string;
|
|
79
162
|
titleHref?: string | null;
|
|
80
163
|
}) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}, [initialNote]);
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
if (!reasonTouched.current) setReason(initialReason);
|
|
99
|
-
}, [initialReason]);
|
|
100
|
-
|
|
101
|
-
const chosen = draft ?? decided;
|
|
102
|
-
|
|
103
|
-
async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
|
|
104
|
-
if (disabledReason || inFlight.current) return;
|
|
105
|
-
if (choice === "reject" && !nextReason) return;
|
|
106
|
-
inFlight.current = true;
|
|
107
|
-
setSaving(true);
|
|
108
|
-
setSaved(false);
|
|
109
|
-
setError(null);
|
|
110
|
-
try {
|
|
111
|
-
await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
|
|
112
|
-
savedNote.current = nextNote.trim();
|
|
113
|
-
noteTouched.current = false;
|
|
114
|
-
reasonTouched.current = false;
|
|
115
|
-
setDraft(null);
|
|
116
|
-
setSaved(true);
|
|
117
|
-
} catch (failure) {
|
|
118
|
-
setDraft(null);
|
|
119
|
-
setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
|
|
120
|
-
} finally {
|
|
121
|
-
inFlight.current = false;
|
|
122
|
-
setSaving(false);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function choose(choice: ReviewChoice) {
|
|
127
|
-
if (disabledReason) return;
|
|
128
|
-
if (chosen === choice && draft === null) return; // the selected answer never unselects itself
|
|
129
|
-
setError(null);
|
|
130
|
-
setDraft(choice);
|
|
131
|
-
void save(choice, reason, note);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function chooseReason(code: ReferenceRejectionReasonCode) {
|
|
135
|
-
if (disabledReason) return;
|
|
136
|
-
setReason(code);
|
|
137
|
-
reasonTouched.current = true;
|
|
138
|
-
if (chosen === "reject") void save("reject", code, note);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function commitNote() {
|
|
142
|
-
if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
|
|
143
|
-
void save(decided, reason, note);
|
|
144
|
-
}
|
|
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); };
|
|
145
179
|
|
|
146
180
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
147
181
|
useEffect(() => {
|
|
@@ -156,12 +190,6 @@ export function ReviewCard({
|
|
|
156
190
|
return () => window.removeEventListener("keydown", onKey);
|
|
157
191
|
});
|
|
158
192
|
|
|
159
|
-
const predictedMarker = (choice: ReviewChoice) => {
|
|
160
|
-
if (prediction.state !== "ready" || prediction.choice !== choice) return null;
|
|
161
|
-
return prediction.choice;
|
|
162
|
-
};
|
|
163
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
164
|
-
|
|
165
193
|
return (
|
|
166
194
|
<section aria-label="Review" className="space-y-4" data-review-card={itemId}>
|
|
167
195
|
<header className="space-y-1">
|
|
@@ -175,76 +203,17 @@ export function ReviewCard({
|
|
|
175
203
|
{summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
|
|
176
204
|
</header>
|
|
177
205
|
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
190
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
191
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
192
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
193
|
-
)}
|
|
194
|
-
data-cyclotron-choice={choice}
|
|
195
|
-
data-predicted={predicted || undefined}
|
|
196
|
-
data-verdict={verdict ?? undefined}
|
|
197
|
-
key={choice}
|
|
198
|
-
>
|
|
199
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
200
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
201
|
-
{CHOICE_LABEL[choice]}
|
|
202
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
203
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
204
|
-
</span>
|
|
205
|
-
);
|
|
206
|
-
})}
|
|
207
|
-
</div>
|
|
208
|
-
) : (
|
|
209
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
210
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
211
|
-
</p>
|
|
212
|
-
)}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
<div className="space-y-2" data-review-row="you">
|
|
217
|
-
<div className="flex items-center gap-3">
|
|
218
|
-
<span className="w-20 shrink-0 text-sm font-semibold">You</span>
|
|
219
|
-
<div className="flex flex-1 gap-2">
|
|
220
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
221
|
-
const on = chosen === choice;
|
|
222
|
-
return (
|
|
223
|
-
<Button
|
|
224
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
225
|
-
aria-pressed={on}
|
|
226
|
-
className="min-h-14 flex-1 text-base"
|
|
227
|
-
data-review-choice={choice}
|
|
228
|
-
key={choice}
|
|
229
|
-
onClick={() => choose(choice)}
|
|
230
|
-
type="button"
|
|
231
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
232
|
-
>
|
|
233
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
234
|
-
{CHOICE_LABEL[choice]}
|
|
235
|
-
</Button>
|
|
236
|
-
);
|
|
237
|
-
})}
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
<div className="min-h-5 pl-[5.75rem] text-sm" role="status">
|
|
241
|
-
{saving ? "Saving…" : saved ? (
|
|
242
|
-
<span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
|
|
243
|
-
) : null}
|
|
244
|
-
</div>
|
|
245
|
-
{disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
|
|
246
|
-
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
|
247
|
-
</div>
|
|
206
|
+
<RatingGrid
|
|
207
|
+
answer={chosen}
|
|
208
|
+
decided={decided}
|
|
209
|
+
disabled={Boolean(disabledReason)}
|
|
210
|
+
disabledReason={disabledReason}
|
|
211
|
+
feedback={item.feedback}
|
|
212
|
+
onChoose={choose}
|
|
213
|
+
prediction={prediction}
|
|
214
|
+
size="large"
|
|
215
|
+
surface="card"
|
|
216
|
+
/>
|
|
248
217
|
|
|
249
218
|
<label className="block text-sm font-medium">
|
|
250
219
|
Why? (optional)
|
|
@@ -252,7 +221,7 @@ export function ReviewCard({
|
|
|
252
221
|
aria-label="Why? (optional)"
|
|
253
222
|
className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
|
|
254
223
|
onBlur={commitNote}
|
|
255
|
-
onChange={(event) =>
|
|
224
|
+
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
256
225
|
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
257
226
|
value={note}
|
|
258
227
|
/>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { LoaderIcon } from "lucide-react";
|
|
5
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
6
|
+
import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
7
|
+
import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
8
|
+
import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
|
|
9
|
+
import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the References page shows while the session and the first reads are still resolving: the real page header at once,
|
|
13
|
+
* then this browser's last saved view (read only, answering switches on when the session is confirmed), or card skeletons
|
|
14
|
+
* when there is none. Never a full-screen "checking" screen.
|
|
15
|
+
*/
|
|
16
|
+
export function ReferencesPageLoading() {
|
|
17
|
+
const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
|
|
18
|
+
const [checked, setChecked] = useState(false);
|
|
19
|
+
const [tab, setTab] = useState<ReferenceFeedTabKey>("feed");
|
|
20
|
+
const [note, setNote] = useState<string | null>(null);
|
|
21
|
+
const [now] = useState(() => Date.now());
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setSnapshot(loadFeedSnapshot(SITE_BRAND.id));
|
|
24
|
+
setChecked(true);
|
|
25
|
+
}, []);
|
|
26
|
+
const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
|
|
27
|
+
const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
|
|
28
|
+
const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
|
|
29
|
+
const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
|
|
30
|
+
const noVisitRated = useMemo(() => new Set<string>(), []);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="space-y-4" data-references-page-loading>
|
|
34
|
+
<div className="space-y-1">
|
|
35
|
+
<h2 className="text-2xl font-bold tracking-tight">References</h2>
|
|
36
|
+
<p className="text-sm text-muted-foreground">A feed ranked by what you are most likely to accept.</p>
|
|
37
|
+
</div>
|
|
38
|
+
{note ? <p className="text-sm text-muted-foreground" role="status">{note}</p> : null}
|
|
39
|
+
{checked && references.length ? (
|
|
40
|
+
<ReferenceFeed
|
|
41
|
+
disabledReason="Confirming your sign-in. You can answer in a moment."
|
|
42
|
+
now={now}
|
|
43
|
+
onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
|
|
44
|
+
onRate={() => undefined}
|
|
45
|
+
onTabChange={setTab}
|
|
46
|
+
onVisitRated={() => undefined}
|
|
47
|
+
partition={partition}
|
|
48
|
+
previews={previews}
|
|
49
|
+
stale
|
|
50
|
+
tab={tab}
|
|
51
|
+
visitRated={noVisitRated}
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<>
|
|
55
|
+
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><LoaderIcon aria-hidden="true" className="size-3.5 animate-spin" />Loading your references</p>
|
|
56
|
+
<ReferenceFeedSkeleton />
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts the References reads as soon as the editor session is confirmed, in parallel with the summary dashboard, instead of after it.
|
|
3
|
+
* The page that needs them takes the in-flight request once; anything later, or a retry, makes a fresh read. This is a head start
|
|
4
|
+
* on the same reads, not a cache: the backend authorizes each call exactly as before.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
loadEditorMessagesData,
|
|
8
|
+
loadEditorReferencesData,
|
|
9
|
+
loadEditorSemanticRelationsData,
|
|
10
|
+
} from "./news-desk-taxonomy-client";
|
|
11
|
+
|
|
12
|
+
const HEAD_START_MS = 60_000;
|
|
13
|
+
type Started = { startedAt: number; promise: Promise<unknown> };
|
|
14
|
+
const started = new Map<string, Started>();
|
|
15
|
+
|
|
16
|
+
const LOADERS = {
|
|
17
|
+
references: loadEditorReferencesData,
|
|
18
|
+
relations: loadEditorSemanticRelationsData,
|
|
19
|
+
messages: loadEditorMessagesData,
|
|
20
|
+
} as const;
|
|
21
|
+
type Key = keyof typeof LOADERS;
|
|
22
|
+
|
|
23
|
+
/** Begin all three reads. Safe to call twice; a failure is reported to whoever takes the read, never as an unhandled rejection. */
|
|
24
|
+
export function prefetchReferencesFeed(now = Date.now()) {
|
|
25
|
+
for (const key of Object.keys(LOADERS) as Key[]) {
|
|
26
|
+
if (started.has(key)) continue;
|
|
27
|
+
const promise = LOADERS[key]();
|
|
28
|
+
promise.catch(() => undefined);
|
|
29
|
+
started.set(key, { startedAt: now, promise });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function take<Key2 extends Key>(key: Key2, now = Date.now()): ReturnType<(typeof LOADERS)[Key2]> {
|
|
34
|
+
const head = started.get(key);
|
|
35
|
+
started.delete(key);
|
|
36
|
+
if (head && now - head.startedAt <= HEAD_START_MS) return head.promise as ReturnType<(typeof LOADERS)[Key2]>;
|
|
37
|
+
return LOADERS[key]() as ReturnType<(typeof LOADERS)[Key2]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const loadReferencesWithHeadStart = () => take("references");
|
|
41
|
+
export const loadRelationsWithHeadStart = () => take("relations");
|
|
42
|
+
export const loadMessagesWithHeadStart = () => take("messages");
|
|
43
|
+
|
|
44
|
+
/** Forget any unclaimed head start (sign-out). */
|
|
45
|
+
export function discardReferencesHeadStart() {
|
|
46
|
+
started.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The pages that need the References straight away: the feed itself and the home page, whose Notification Center shows References cards. */
|
|
50
|
+
export function wantsReferencesHeadStart(pathname: string): boolean {
|
|
51
|
+
return /\/newsroom\/references(\/|$)/.test(pathname) || /\/newsroom\/?$/.test(pathname);
|
|
52
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
|
|
4
|
+
import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import type { CardPrediction, ReviewChoice } from "./reference-review-card";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
|
|
10
|
+
* Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
|
|
11
|
+
* [====== confidence bar, centred at 50% ======]
|
|
12
|
+
* You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
|
|
13
|
+
* Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
|
|
14
|
+
* green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
|
|
15
|
+
* filled. Flat tones and rounded shapes only, no borders.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
19
|
+
|
|
20
|
+
/** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
|
|
21
|
+
export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
|
|
22
|
+
const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
|
|
23
|
+
return prediction.choice === "accept" ? confidence : 1 - confidence;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
|
|
27
|
+
export function confidenceFillPercent(probability: number): number {
|
|
28
|
+
return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
32
|
+
|
|
33
|
+
const TONE = {
|
|
34
|
+
match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
|
|
35
|
+
miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
|
|
36
|
+
} as const;
|
|
37
|
+
const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
|
|
38
|
+
|
|
39
|
+
export function RatingGrid({
|
|
40
|
+
answer,
|
|
41
|
+
decided = answer,
|
|
42
|
+
disabled = false,
|
|
43
|
+
disabledReason = null,
|
|
44
|
+
feedback = { phase: "idle" },
|
|
45
|
+
note = null,
|
|
46
|
+
onChoose,
|
|
47
|
+
prediction,
|
|
48
|
+
showPrediction = true,
|
|
49
|
+
size = "compact",
|
|
50
|
+
statusOverlay = false,
|
|
51
|
+
surface = "footer",
|
|
52
|
+
}: {
|
|
53
|
+
/** The side the buttons show as chosen (it may be a pending choice that is still saving). */
|
|
54
|
+
answer: ReviewChoice | null;
|
|
55
|
+
/** The saved human answer, which the Match / Miss verdict compares against. */
|
|
56
|
+
decided?: ReviewChoice | null;
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
disabledReason?: string | null;
|
|
59
|
+
feedback?: RatingFeedback;
|
|
60
|
+
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
61
|
+
note?: string | null;
|
|
62
|
+
onChoose: (choice: ReviewChoice) => void;
|
|
63
|
+
prediction: CardPrediction;
|
|
64
|
+
/** 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. */
|
|
65
|
+
showPrediction?: boolean;
|
|
66
|
+
size?: "compact" | "large";
|
|
67
|
+
/** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
|
|
68
|
+
statusOverlay?: boolean;
|
|
69
|
+
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
70
|
+
surface?: "footer" | "card";
|
|
71
|
+
}) {
|
|
72
|
+
const ready = prediction.state === "ready" ? prediction : null;
|
|
73
|
+
const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
|
|
74
|
+
const probability = ready ? predictionAcceptProbability(ready) : 0.5;
|
|
75
|
+
const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
|
|
76
|
+
const leansAccept = probability >= 0.5;
|
|
77
|
+
const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
|
|
78
|
+
const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
|
|
79
|
+
const label = size === "large" ? "text-sm" : "text-xs";
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={cn(
|
|
84
|
+
"grid gap-x-2 gap-y-2",
|
|
85
|
+
showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
|
|
86
|
+
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
87
|
+
)}
|
|
88
|
+
data-rating-grid
|
|
89
|
+
>
|
|
90
|
+
{showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
|
|
91
|
+
<span className={cn("self-center font-semibold", label)}>Cyclotron</span>
|
|
92
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
93
|
+
const predicted = ready?.choice === choice;
|
|
94
|
+
const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
|
|
95
|
+
return (
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
"flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
|
|
99
|
+
cell,
|
|
100
|
+
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
101
|
+
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
102
|
+
predicted && verdict && cn("font-semibold", TONE[verdict]),
|
|
103
|
+
)}
|
|
104
|
+
data-cyclotron-choice={choice}
|
|
105
|
+
data-predicted={predicted || undefined}
|
|
106
|
+
data-verdict={predicted && verdict ? verdict : undefined}
|
|
107
|
+
key={choice}
|
|
108
|
+
>
|
|
109
|
+
<span className="inline-flex items-center gap-1.5">
|
|
110
|
+
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
111
|
+
{WORD[choice]}
|
|
112
|
+
{predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
|
|
113
|
+
</span>
|
|
114
|
+
{predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
|
|
115
|
+
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
<div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
|
|
120
|
+
<div
|
|
121
|
+
aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
|
|
122
|
+
className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
123
|
+
data-confidence-bar
|
|
124
|
+
data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
|
|
125
|
+
role="img"
|
|
126
|
+
>
|
|
127
|
+
{ready ? (
|
|
128
|
+
<span
|
|
129
|
+
className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
|
|
130
|
+
data-confidence-fill
|
|
131
|
+
data-fill-percent={Math.round(half * 2)}
|
|
132
|
+
style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
|
|
133
|
+
/>
|
|
134
|
+
) : null}
|
|
135
|
+
<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)]")} />
|
|
136
|
+
</div>
|
|
137
|
+
{/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
|
|
138
|
+
<small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
139
|
+
{prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
140
|
+
</small>
|
|
141
|
+
</div>
|
|
142
|
+
</div> : null}
|
|
143
|
+
|
|
144
|
+
<div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
|
|
145
|
+
{showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
|
|
146
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
147
|
+
const on = answer === choice;
|
|
148
|
+
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
aria-disabled={disabled ? true : undefined}
|
|
152
|
+
aria-pressed={on}
|
|
153
|
+
className={cn(
|
|
154
|
+
"inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
155
|
+
cell,
|
|
156
|
+
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
157
|
+
disabled && !on && "opacity-60",
|
|
158
|
+
)}
|
|
159
|
+
data-review-choice={choice}
|
|
160
|
+
key={choice}
|
|
161
|
+
onClick={() => onChoose(choice)}
|
|
162
|
+
type="button"
|
|
163
|
+
>
|
|
164
|
+
<Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
|
|
165
|
+
{WORD[choice]}
|
|
166
|
+
</button>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
|
|
172
|
+
{note ? <span data-feed-reason>{note}</span> : null}
|
|
173
|
+
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
174
|
+
{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}
|
|
175
|
+
</div>
|
|
176
|
+
{disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
|
|
177
|
+
{feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|