@anthusai/papyrus 5.57.0 → 5.58.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/resource.js +25 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +25 -3
- package/amplify/data/schema.ts +25 -3
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-thread.tsx +27 -12
- package/components/conversations-memory-source.ts +46 -0
- package/components/conversations-network-source.ts +40 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +24 -0
- package/components/conversations-view.tsx +319 -0
- package/components/decision-control.tsx +307 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/news-desk-taxonomy-client.ts +83 -14
- package/components/newsroom-organizer.css +24 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-feed.tsx +60 -42
- package/components/reference-review-card.tsx +8 -134
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +40 -782
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +9 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useState } from "react";
|
|
4
|
+
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
|
5
|
+
import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
8
|
+
import type { CardPrediction, ReviewChoice } from "./reference-review-card";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One classification, in two stages: a model predicts it, a person decides it.
|
|
12
|
+
*
|
|
13
|
+
* PREDICTED ACCEPT
|
|
14
|
+
* [=========== 96% ==========| ] the prediction's own confidence, in its own bar
|
|
15
|
+
* [ ✓ Accept PREDICTED ] [ ✕ Reject ] one pair of segments; the predicted one is pre-filled, in its own tone
|
|
16
|
+
* REASON · OPTIONAL
|
|
17
|
+
* [ Why? In your own words… ] written, always in view, full width
|
|
18
|
+
* ✓ Recorded · confirms the prediction one status line for the whole block
|
|
19
|
+
*
|
|
20
|
+
* The prediction and the answer share one row of segments rather than a row each: they are not two
|
|
21
|
+
* voters, they are one decision before and after a person touched it. Once decided, a small marker
|
|
22
|
+
* stays on the segment the model picked — on the losing one if it was overturned — so the comparison
|
|
23
|
+
* survives the answer without a second row. The bar belongs to the model, so it never moves when the
|
|
24
|
+
* person answers; only its tone changes.
|
|
25
|
+
*
|
|
26
|
+
* The verdict never tints the person's own choice: a decided segment is always `--primary`, and
|
|
27
|
+
* `--feed-verdict-*` colours the marker and the bar. An overturned Reject painted red reads as "your
|
|
28
|
+
* answer is wrong" rather than "the model's was".
|
|
29
|
+
*
|
|
30
|
+
* No engine or product name appears in the copy: the component knows about predictions, not about
|
|
31
|
+
* what made them (AGENTS.md: a deployment publishes about any subject with any classifier behind it).
|
|
32
|
+
*
|
|
33
|
+
* Every tone, corner and opacity is a feed token from newsroom-organizer.css; the values after the
|
|
34
|
+
* comma are the last resort for a surface that declares none.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
38
|
+
|
|
39
|
+
/** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
|
|
40
|
+
export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
|
|
41
|
+
const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
|
|
42
|
+
return prediction.choice === "accept" ? confidence : 1 - confidence;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
|
|
46
|
+
export function confidenceFillPercent(probability: number): number {
|
|
47
|
+
return Math.min(100, Math.abs(probability - 0.5) * 100 * 2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
51
|
+
const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
|
|
52
|
+
/** A fill shorter than this cannot hold its own percentage, so the number sits beside it. */
|
|
53
|
+
const NARROW_FILL = 14;
|
|
54
|
+
|
|
55
|
+
export function DecisionControl({
|
|
56
|
+
answer,
|
|
57
|
+
decided = null,
|
|
58
|
+
disabled = false,
|
|
59
|
+
disabledReason = null,
|
|
60
|
+
feedback = { phase: "idle" },
|
|
61
|
+
needsReason = false,
|
|
62
|
+
note = null,
|
|
63
|
+
onChoose,
|
|
64
|
+
onReasonChange,
|
|
65
|
+
onReasonCommit,
|
|
66
|
+
prediction,
|
|
67
|
+
reason = "",
|
|
68
|
+
reasonRequiredFor = null,
|
|
69
|
+
showPrediction = true,
|
|
70
|
+
showReason = true,
|
|
71
|
+
size = "compact",
|
|
72
|
+
statusOverlay = false,
|
|
73
|
+
surface = "footer",
|
|
74
|
+
}: {
|
|
75
|
+
/** The side shown as chosen. It may be an answer that is still saving. */
|
|
76
|
+
answer: ReviewChoice | null;
|
|
77
|
+
/** The saved answer, which the Confirmed / Overturned verdict compares against. */
|
|
78
|
+
decided?: ReviewChoice | null;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
disabledReason?: string | null;
|
|
81
|
+
feedback?: RatingFeedback;
|
|
82
|
+
/** The answer is staged and waiting for a written reason before it can be recorded. */
|
|
83
|
+
needsReason?: boolean;
|
|
84
|
+
/** A short line shown in the status row beside the saved tick. It never adds a row. */
|
|
85
|
+
note?: string | null;
|
|
86
|
+
onChoose: (choice: ReviewChoice) => void;
|
|
87
|
+
onReasonChange?: (text: string) => void;
|
|
88
|
+
/** Save the reason on its own (on blur, or from the status row). */
|
|
89
|
+
onReasonCommit?: () => void;
|
|
90
|
+
prediction: CardPrediction;
|
|
91
|
+
/** The written reason, owned by the caller. */
|
|
92
|
+
reason?: string;
|
|
93
|
+
/** The side that cannot be recorded without a written reason. */
|
|
94
|
+
reasonRequiredFor?: ReviewChoice | null;
|
|
95
|
+
/** False for an item with nothing to predict: the bar and the caption are left out. */
|
|
96
|
+
showPrediction?: boolean;
|
|
97
|
+
/** False where the surrounding card owns the reason field. */
|
|
98
|
+
showReason?: boolean;
|
|
99
|
+
size?: "compact" | "large";
|
|
100
|
+
/** Lay the status line over the bottom padding of the nearest positioned ancestor, so it adds no height. */
|
|
101
|
+
statusOverlay?: boolean;
|
|
102
|
+
/** The tone the control sits on, so the bar's centre notch matches it. */
|
|
103
|
+
surface?: "footer" | "card";
|
|
104
|
+
}) {
|
|
105
|
+
const fieldId = useId();
|
|
106
|
+
const ready = prediction.state === "ready" ? prediction : null;
|
|
107
|
+
const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
|
|
108
|
+
const saving = feedback.phase === "saving";
|
|
109
|
+
const [reasonDirty, setReasonDirty] = useState(false);
|
|
110
|
+
useEffect(() => { if (feedback.phase === "saved") setReasonDirty(false); }, [feedback.phase]);
|
|
111
|
+
|
|
112
|
+
const probability = ready ? predictionAcceptProbability(ready) : 0.5;
|
|
113
|
+
const half = confidenceFillPercent(probability) / 2; // percent of the full bar, one side of the centre
|
|
114
|
+
const leansAccept = probability >= 0.5;
|
|
115
|
+
const confidenceText = ready ? (ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : "unrecorded") : null;
|
|
116
|
+
const narrow = half < NARROW_FILL;
|
|
117
|
+
const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
|
|
118
|
+
const field = size === "large" ? "min-h-16" : "min-h-9";
|
|
119
|
+
|
|
120
|
+
const caption = ready
|
|
121
|
+
? `Predicted ${WORD[ready.choice]}`
|
|
122
|
+
: prediction.state === "loading" ? "Reading the prediction…"
|
|
123
|
+
: prediction.state === "error" ? "Prediction unavailable" : "No prediction";
|
|
124
|
+
|
|
125
|
+
/* What the chosen segment says about itself while the answer is in flight or held back. A segment
|
|
126
|
+
never claims to be unrecorded while it is being recorded. */
|
|
127
|
+
const stagedWord = saving ? (decided ? "Updating…" : "Sending…") : needsReason ? "Needs a reason" : null;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="@container/decision grid gap-y-2" data-decision-control data-rating-grid>
|
|
131
|
+
{showPrediction ? (
|
|
132
|
+
<div className="grid gap-y-0.5" data-prediction>
|
|
133
|
+
<span className={cn(LABEL, "text-muted-foreground")} data-prediction-caption>{caption}</span>
|
|
134
|
+
<div
|
|
135
|
+
aria-label={ready ? `Prediction: ${WORD[ready.choice]}, ${confidenceText} confident` : caption}
|
|
136
|
+
className="relative h-4 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
137
|
+
data-confidence-bar
|
|
138
|
+
data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
|
|
139
|
+
data-prediction-state={ready ? undefined : prediction.state}
|
|
140
|
+
role="img"
|
|
141
|
+
>
|
|
142
|
+
{ready ? (
|
|
143
|
+
<span
|
|
144
|
+
className={cn(
|
|
145
|
+
"absolute inset-y-0 flex items-center rounded-full",
|
|
146
|
+
leansAccept ? "justify-end" : "justify-start",
|
|
147
|
+
verdict === "match" ? "bg-[var(--feed-verdict-match-fill,#059669)]"
|
|
148
|
+
: verdict === "miss" ? "bg-[var(--feed-verdict-miss-fill,#dc2626)]"
|
|
149
|
+
: "bg-[var(--foreground-muted)]",
|
|
150
|
+
)}
|
|
151
|
+
data-confidence-fill
|
|
152
|
+
data-fill-percent={Math.round(half * 2)}
|
|
153
|
+
style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
|
|
154
|
+
>
|
|
155
|
+
{/* The confidence is printed in the bar and nowhere else, so no button has to carry a number. */}
|
|
156
|
+
{narrow ? null : <small className={cn(LABEL, "px-1.5 text-[var(--feed-card)] whitespace-nowrap")} data-confidence-value>{confidenceText}</small>}
|
|
157
|
+
</span>
|
|
158
|
+
) : null}
|
|
159
|
+
{ready && narrow ? (
|
|
160
|
+
<small
|
|
161
|
+
className={cn(LABEL, "absolute top-0.5 text-muted-foreground whitespace-nowrap")}
|
|
162
|
+
data-confidence-value
|
|
163
|
+
style={leansAccept ? { left: `${50 - half}%`, transform: "translateX(-100%)" } : { left: `${50 + half}%` }}
|
|
164
|
+
>
|
|
165
|
+
{confidenceText}
|
|
166
|
+
</small>
|
|
167
|
+
) : null}
|
|
168
|
+
<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)]")} />
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
) : null}
|
|
172
|
+
|
|
173
|
+
<div aria-label="Classification" className="grid grid-cols-2 gap-x-1.5" data-review-row="you" role="group">
|
|
174
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
175
|
+
const on = answer === choice;
|
|
176
|
+
const predicted = ready?.choice === choice;
|
|
177
|
+
const proposed = predicted && !answer;
|
|
178
|
+
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
179
|
+
const sub = proposed ? "Predicted"
|
|
180
|
+
: on ? (stagedWord ?? (verdict === "miss" ? "Overturned" : verdict === "match" ? "Confirmed" : decided === choice ? "Recorded" : null))
|
|
181
|
+
: null;
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
aria-disabled={disabled ? true : undefined}
|
|
185
|
+
aria-pressed={on}
|
|
186
|
+
className={cn(
|
|
187
|
+
"relative inline-flex min-w-0 flex-col items-center justify-center gap-px rounded-[var(--feed-radius-md,16px)] px-2 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring @max-[17rem]/decision:px-1",
|
|
188
|
+
cell,
|
|
189
|
+
// Three degrees of finish, in tone alone: the feed register has no borders to spend
|
|
190
|
+
// (newsroom-organizer.css strips them), so a chosen answer is the solid fill, the
|
|
191
|
+
// predicted side is the control tone, and an untouched side is quieter still.
|
|
192
|
+
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]"
|
|
193
|
+
: proposed ? "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70"
|
|
194
|
+
: "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70",
|
|
195
|
+
disabled && !on && "opacity-[var(--feed-quiet,0.6)]",
|
|
196
|
+
)}
|
|
197
|
+
data-predicted={predicted || undefined}
|
|
198
|
+
data-review-choice={choice}
|
|
199
|
+
data-state={on ? (saving || needsReason ? "staged" : "decided") : proposed ? "predicted" : "idle"}
|
|
200
|
+
key={choice}
|
|
201
|
+
onClick={() => { if (!disabled) onChoose(choice); }}
|
|
202
|
+
type="button"
|
|
203
|
+
>
|
|
204
|
+
{/* The model's opinion survives the answer, on the segment it picked. */}
|
|
205
|
+
{predicted && answer ? (
|
|
206
|
+
<span
|
|
207
|
+
className={cn(
|
|
208
|
+
"absolute top-1.5 right-2 opacity-55",
|
|
209
|
+
verdict === "match" && "text-[var(--feed-verdict-match-fill,#059669)] opacity-100",
|
|
210
|
+
verdict === "miss" && "text-[var(--feed-verdict-miss-fill,#dc2626)] opacity-100",
|
|
211
|
+
)}
|
|
212
|
+
data-prediction-mark
|
|
213
|
+
data-verdict={verdict ?? undefined}
|
|
214
|
+
title="this is what was predicted"
|
|
215
|
+
>
|
|
216
|
+
<svg aria-hidden="true" className="size-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
217
|
+
<circle cx="12" cy="12" r="8" />
|
|
218
|
+
<circle cx="12" cy="12" fill="currentColor" r="2.6" stroke="none" />
|
|
219
|
+
</svg>
|
|
220
|
+
</span>
|
|
221
|
+
) : null}
|
|
222
|
+
<span className="flex items-center gap-1.5">
|
|
223
|
+
<Icon aria-hidden="true" className={cn("size-4", !on && "opacity-[var(--feed-quiet,0.6)]")} />
|
|
224
|
+
{WORD[choice]}
|
|
225
|
+
</span>
|
|
226
|
+
{sub ? <span className={cn(LABEL, "opacity-75")} data-choice-state>{sub}</span> : null}
|
|
227
|
+
{verdict && predicted ? <span className="sr-only">{verdict === "match" ? ", this is what was predicted" : ", the prediction was the other way"}</span> : null}
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
})}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{showReason ? (
|
|
234
|
+
<div className="grid gap-y-1.5" data-review-reason>
|
|
235
|
+
<label className={cn(LABEL, "text-muted-foreground")} htmlFor={fieldId}>
|
|
236
|
+
Reason{reasonRequiredFor ? ` · required to ${WORD[reasonRequiredFor].toLowerCase()}` : " · optional"}
|
|
237
|
+
</label>
|
|
238
|
+
{/* Written reasons only. A row of canned answers collects a label the model mostly has already;
|
|
239
|
+
what trains the next prediction is a sentence. */}
|
|
240
|
+
<AutoGrowTextarea
|
|
241
|
+
className={cn(
|
|
242
|
+
"w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm leading-5 text-foreground outline-none transition-[min-height] placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring",
|
|
243
|
+
field,
|
|
244
|
+
)}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
id={fieldId}
|
|
247
|
+
onBlur={() => { if (!disabled) onReasonCommit?.(); }}
|
|
248
|
+
onChange={(event) => { setReasonDirty(true); onReasonChange?.(event.target.value); }}
|
|
249
|
+
placeholder="Why? In your own words…"
|
|
250
|
+
value={reason}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
) : null}
|
|
254
|
+
|
|
255
|
+
<div
|
|
256
|
+
className={cn(
|
|
257
|
+
"flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground",
|
|
258
|
+
// The overlay strip is 16px tall: a question with a button, or an error, needs a row of its own.
|
|
259
|
+
statusOverlay && !needsReason && feedback.phase !== "error" ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : "min-h-5",
|
|
260
|
+
)}
|
|
261
|
+
data-feed-saved
|
|
262
|
+
role="status"
|
|
263
|
+
>
|
|
264
|
+
{note ? <span data-feed-reason>{note}</span> : null}
|
|
265
|
+
{needsReason ? (
|
|
266
|
+
<>
|
|
267
|
+
<span className="font-semibold text-foreground">A reason is needed to record {WORD[answer ?? "reject"]}</span>
|
|
268
|
+
<button
|
|
269
|
+
className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring disabled:opacity-[var(--feed-quiet,0.6)]"
|
|
270
|
+
disabled={!reason.trim()}
|
|
271
|
+
data-review-record
|
|
272
|
+
onClick={() => { if (answer) onChoose(answer); }}
|
|
273
|
+
type="button"
|
|
274
|
+
>
|
|
275
|
+
Record
|
|
276
|
+
</button>
|
|
277
|
+
</>
|
|
278
|
+
) : null}
|
|
279
|
+
{saving ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Recording…</span> : null}
|
|
280
|
+
{feedback.phase === "saved" && !needsReason ? (
|
|
281
|
+
<span className="inline-flex items-center gap-1 text-foreground" data-review-saved>
|
|
282
|
+
<CheckIcon aria-hidden="true" className="size-3.5" />
|
|
283
|
+
Recorded
|
|
284
|
+
{verdict ? <span className="text-muted-foreground">· {verdict === "match" ? "confirms" : "overturns"} the prediction</span> : null}
|
|
285
|
+
{showReason ? (
|
|
286
|
+
reasonDirty ? <span className="text-muted-foreground">· reason not saved yet</span>
|
|
287
|
+
: reason.trim() ? <span className="text-muted-foreground">· reason saved</span>
|
|
288
|
+
: <span className="text-muted-foreground">· no reason given</span>
|
|
289
|
+
) : null}
|
|
290
|
+
</span>
|
|
291
|
+
) : null}
|
|
292
|
+
{feedback.phase === "saved" && reasonDirty && showReason ? (
|
|
293
|
+
<button
|
|
294
|
+
className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
|
295
|
+
data-review-save-reason
|
|
296
|
+
onClick={() => onReasonCommit?.()}
|
|
297
|
+
type="button"
|
|
298
|
+
>
|
|
299
|
+
Save reason
|
|
300
|
+
</button>
|
|
301
|
+
) : null}
|
|
302
|
+
</div>
|
|
303
|
+
{disabledReason ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
|
|
304
|
+
{feedback.phase === "error" ? <p className="text-xs text-destructive" role="alert">{feedback.message}</p> : null}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -13,7 +13,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
|
13
13
|
import { answerWords, formatWhen, nextText, steeringText, subjectHref, subjectLabel } from "../lib/review-requests";
|
|
14
14
|
import { DecidedLine } from "./notification-cards";
|
|
15
15
|
import { SourceMark } from "./reference-feed";
|
|
16
|
-
import {
|
|
16
|
+
import { DecisionControl } from "./decision-control";
|
|
17
17
|
import type { RatingController } from "./use-reference-ratings";
|
|
18
18
|
import { FlagFooter, decidedChoiceOf, recordedAnswer } from "./needs-me-shared";
|
|
19
19
|
|
|
@@ -111,7 +111,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
111
111
|
{item.decisionState === "acknowledged"
|
|
112
112
|
? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
|
|
113
113
|
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} />}
|
|
114
|
-
{flag ? null : <
|
|
114
|
+
{flag ? null : <DecisionControl answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} showReason={false} size="large" surface="footer" />}
|
|
115
115
|
{lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
|
|
116
116
|
</>
|
|
117
117
|
) : (
|
|
@@ -134,7 +134,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
134
134
|
{flag ? (
|
|
135
135
|
<FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
136
136
|
) : (
|
|
137
|
-
<
|
|
137
|
+
<DecisionControl
|
|
138
138
|
answer={rating.pending}
|
|
139
139
|
disabled={Boolean(disabledReason)}
|
|
140
140
|
disabledReason={disabledReason}
|
|
@@ -142,6 +142,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
142
142
|
onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
|
|
143
143
|
prediction={{ state: "none" }}
|
|
144
144
|
showPrediction={false}
|
|
145
|
+
showReason={false}
|
|
145
146
|
size="large"
|
|
146
147
|
surface="footer"
|
|
147
148
|
/>
|
|
@@ -32,7 +32,7 @@ import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
|
32
32
|
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
|
|
33
33
|
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
34
34
|
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
35
|
-
import {
|
|
35
|
+
import { DecisionControl } from "./decision-control";
|
|
36
36
|
import { useMediaQuery } from "./use-media-query";
|
|
37
37
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
38
38
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
@@ -169,30 +169,24 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
169
169
|
{flag ? (
|
|
170
170
|
<FlagFooter busy={rating.feedback.phase === "saving"} disabledReason={disabledReason} done={item.decisionState === "acknowledged"} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
171
171
|
) : (
|
|
172
|
-
<
|
|
172
|
+
<DecisionControl
|
|
173
173
|
answer={chosen}
|
|
174
174
|
decided={decided}
|
|
175
175
|
disabled={Boolean(disabledReason) || locked}
|
|
176
176
|
disabledReason={disabledReason}
|
|
177
177
|
feedback={feedback}
|
|
178
|
+
needsReason={refLike && rating.askReason}
|
|
178
179
|
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
|
|
179
180
|
onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
|
|
181
|
+
onReasonChange={(text) => ratings.setNote(item.id, text)}
|
|
182
|
+
onReasonCommit={() => { if (!disabledReason && !locked) ratings.commitNote(item.id); }}
|
|
180
183
|
prediction={cardPrediction(item, predictionLoading)}
|
|
184
|
+
reason={rating.note ?? ""}
|
|
185
|
+
reasonRequiredFor={refLike ? "reject" : null}
|
|
181
186
|
showPrediction={refLike}
|
|
187
|
+
showReason={refLike}
|
|
182
188
|
/>
|
|
183
189
|
)}
|
|
184
|
-
{refLike && rating.askReason ? (
|
|
185
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
186
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
187
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
188
|
-
<div className="flex flex-wrap gap-1.5">
|
|
189
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
190
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => ratings.setReason(item.id, code)} type="button">{REASON_LABELS[code]}</button>
|
|
191
|
-
))}
|
|
192
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => ratings.cancelReason(item.id)} type="button">Cancel</button>
|
|
193
|
-
</div>
|
|
194
|
-
</div>
|
|
195
|
-
) : null}
|
|
196
190
|
</div>
|
|
197
191
|
</article>
|
|
198
192
|
);
|
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
|
|
50
50
|
import {
|
|
51
51
|
CONVERSATION_FEED_KEY,
|
|
52
|
+
GENERAL_CONVERSATION_KIND,
|
|
52
53
|
REFERENCE_CONVERSATION_KIND,
|
|
53
54
|
conversationAnchorKey,
|
|
54
55
|
conversationMessageId,
|
|
@@ -301,7 +302,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
|
|
|
301
302
|
const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
302
303
|
query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
303
304
|
listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
304
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
|
|
305
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
305
306
|
nextToken
|
|
306
307
|
}
|
|
307
308
|
}
|
|
@@ -310,7 +311,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
|
310
311
|
const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
311
312
|
query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
312
313
|
listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
313
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
|
|
314
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
314
315
|
nextToken
|
|
315
316
|
}
|
|
316
317
|
}
|
|
@@ -319,7 +320,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
|
319
320
|
const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
320
321
|
query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
321
322
|
listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
322
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
|
|
323
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
323
324
|
nextToken
|
|
324
325
|
}
|
|
325
326
|
}
|
|
@@ -328,7 +329,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
|
328
329
|
const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
329
330
|
query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
330
331
|
listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
331
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
|
|
332
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
332
333
|
nextToken
|
|
333
334
|
}
|
|
334
335
|
}
|
|
@@ -337,7 +338,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
|
337
338
|
const LIST_REFERENCES_FALLBACK_QUERY = `
|
|
338
339
|
query ListReferencesFallback($limit: Int, $nextToken: String) {
|
|
339
340
|
listReferences(limit: $limit, nextToken: $nextToken) {
|
|
340
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
|
|
341
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
341
342
|
nextToken
|
|
342
343
|
}
|
|
343
344
|
}
|
|
@@ -470,7 +471,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
|
|
|
470
471
|
|
|
471
472
|
/** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
|
|
472
473
|
const REFERENCE_RECORD_GRAPHQL_FIELDS =
|
|
473
|
-
"id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt";
|
|
474
|
+
"id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt";
|
|
474
475
|
|
|
475
476
|
const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
|
|
476
477
|
query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
|
|
@@ -1430,7 +1431,9 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
|
|
|
1430
1431
|
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1431
1432
|
create: (sequenceNumber) => {
|
|
1432
1433
|
now = new Date().toISOString();
|
|
1433
|
-
|
|
1434
|
+
// `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
|
|
1435
|
+
// sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
|
|
1436
|
+
built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
|
|
1434
1437
|
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1435
1438
|
},
|
|
1436
1439
|
});
|
|
@@ -1591,11 +1594,13 @@ export async function appendConversationMessage(input: {
|
|
|
1591
1594
|
anchor: ConversationAnchor;
|
|
1592
1595
|
body: string;
|
|
1593
1596
|
parentMessageId?: string | null;
|
|
1597
|
+
/** The conversation to write into, when the caller already holds it (the Messages page). Without it the anchor finds or creates it. */
|
|
1598
|
+
thread?: MessageThreadRecord;
|
|
1594
1599
|
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1595
1600
|
const content = String(input.body || "").trim();
|
|
1596
1601
|
if (!content) throw new Error("Write something to post.");
|
|
1597
1602
|
const author = await currentConversationAuthor();
|
|
1598
|
-
const thread = await ensureConversationThread(input.anchor, author);
|
|
1603
|
+
const thread = input.thread ?? await ensureConversationThread(input.anchor, author);
|
|
1599
1604
|
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1600
1605
|
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1601
1606
|
messageKind: "comment",
|
|
@@ -1627,6 +1632,65 @@ export async function appendConversationMessage(input: {
|
|
|
1627
1632
|
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1628
1633
|
}
|
|
1629
1634
|
|
|
1635
|
+
/** One page of conversation threads of a kind, newest activity first. */
|
|
1636
|
+
export async function loadConversationThreadPage(threadKind: string, nextToken: string | null, limit = 20): Promise<{ items: MessageThreadRecord[]; nextToken: string | null }> {
|
|
1637
|
+
type ThreadConnectionResponse = { listMessageThreadsByKindAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null; nextToken?: string | null } | null };
|
|
1638
|
+
const response = await runGraphql<ThreadConnectionResponse>(LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY, { threadKind, sortDirection: "DESC", limit, nextToken });
|
|
1639
|
+
const connection = response.listMessageThreadsByKindAndUpdatedAt;
|
|
1640
|
+
const items = ((connection?.items ?? []).filter(Boolean) as MessageThreadRecord[]).map(normalizeThreadRecord).filter((thread) => thread.status !== "deleted");
|
|
1641
|
+
return { items, nextToken: connection?.nextToken ?? null };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Every Message in a conversation thread, oldest first. */
|
|
1645
|
+
export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
|
|
1646
|
+
const records = await listMessagesByThreadId(threadId, 500);
|
|
1647
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** Starts a conversation about nothing but itself: its own thread, and its first Message. */
|
|
1651
|
+
export async function startUnattachedConversation(body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
|
|
1652
|
+
const content = String(body || "").trim();
|
|
1653
|
+
if (!content) throw new Error("Write something to post.");
|
|
1654
|
+
const author = await currentConversationAuthor();
|
|
1655
|
+
const now = new Date().toISOString();
|
|
1656
|
+
const id = `message-thread-conversation-new-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1657
|
+
const thread: MessageThreadRecord = {
|
|
1658
|
+
id,
|
|
1659
|
+
threadKind: GENERAL_CONVERSATION_KIND,
|
|
1660
|
+
status: "active",
|
|
1661
|
+
title: content.split("\n")[0].slice(0, 80),
|
|
1662
|
+
createdBySub: author.sub,
|
|
1663
|
+
createdByLabel: author.label,
|
|
1664
|
+
messageCount: 0,
|
|
1665
|
+
lastMessageId: null,
|
|
1666
|
+
lastMessageAt: null,
|
|
1667
|
+
metadata: JSON.stringify({ anchorKind: "none" }),
|
|
1668
|
+
createdAt: now,
|
|
1669
|
+
updatedAt: now,
|
|
1670
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1671
|
+
};
|
|
1672
|
+
const created = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1673
|
+
const saved = normalizeThreadRecord(created.createMessageThread ?? thread);
|
|
1674
|
+
const result = await appendConversationMessage({ anchor: { kind: "none" }, body: content, thread: saved });
|
|
1675
|
+
return { message: result.message, thread: result.thread };
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
|
|
1679
|
+
export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
|
|
1680
|
+
const headers = new Map<string, { title: string; stage: string | null }>();
|
|
1681
|
+
await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
|
|
1682
|
+
try {
|
|
1683
|
+
type Response = { listItemsByLineageAndVersion?: { items?: Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null } | null> | null } | null };
|
|
1684
|
+
const response = await runGraphql<Response>(`query StoryHeader($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title stage } } }`, { lineageId });
|
|
1685
|
+
const items = (response.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null }>;
|
|
1686
|
+
const newest = items.sort((left, right) => (right.versionNumber ?? 0) - (left.versionNumber ?? 0))[0];
|
|
1687
|
+
const title = newest?.headline || newest?.title;
|
|
1688
|
+
if (title) headers.set(lineageId, { title, stage: newest?.stage ?? null });
|
|
1689
|
+
} catch { /* a Story that cannot be read keeps the title the thread was started with */ }
|
|
1690
|
+
}));
|
|
1691
|
+
return headers;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1630
1694
|
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1631
1695
|
if (anchor.kind === "none") return;
|
|
1632
1696
|
const lineageId = anchor.lineageId.trim();
|
|
@@ -1843,10 +1907,17 @@ async function runGraphql<T extends Record<string, unknown>>(
|
|
|
1843
1907
|
const client = generateClient<Schema>() as unknown as {
|
|
1844
1908
|
graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
|
|
1845
1909
|
};
|
|
1910
|
+
// The Amplify client rejects with the raw `{ data, errors }` response when the API returns errors; turn that into an
|
|
1911
|
+
// Error carrying the API's own words, so a refusal is said in words instead of as an unknown failure.
|
|
1846
1912
|
const response = await client.graphql({
|
|
1847
1913
|
query,
|
|
1848
1914
|
variables,
|
|
1849
1915
|
authMode: USER_POOL_AUTH_MODE,
|
|
1916
|
+
}).catch((failure: unknown) => {
|
|
1917
|
+
if (failure && typeof failure === "object" && Array.isArray((failure as { errors?: unknown }).errors)) {
|
|
1918
|
+
assertNoGraphQLErrors((failure as { errors: unknown[] }).errors);
|
|
1919
|
+
}
|
|
1920
|
+
throw failure;
|
|
1850
1921
|
});
|
|
1851
1922
|
assertNoGraphQLErrors(response.errors);
|
|
1852
1923
|
return (response.data ?? {}) as T;
|
|
@@ -2082,17 +2153,15 @@ export async function loadEditorReferenceReviewDetails(lineageId: string) {
|
|
|
2082
2153
|
configureAmplifyClient();
|
|
2083
2154
|
const testMock = getTestEditorNewsroomMock();
|
|
2084
2155
|
return loadReferenceReviewDetails(lineageId, {
|
|
2085
|
-
relations: async (
|
|
2156
|
+
relations: async () => {
|
|
2086
2157
|
if (testMock?.semanticRelations) return testMock.semanticRelations;
|
|
2087
2158
|
return loadSemanticRelationsByState({
|
|
2088
|
-
field:
|
|
2089
|
-
keyName:
|
|
2159
|
+
field: "listSemanticRelationsBySubjectState",
|
|
2160
|
+
keyName: "subjectStateKey",
|
|
2090
2161
|
keyValue: `reference#${lineageId}#current`,
|
|
2091
|
-
query:
|
|
2162
|
+
query: LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY,
|
|
2092
2163
|
});
|
|
2093
2164
|
},
|
|
2094
|
-
message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
|
|
2095
|
-
payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
|
|
2096
2165
|
});
|
|
2097
2166
|
}
|
|
2098
2167
|
|
|
@@ -401,11 +401,29 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
401
401
|
--feed-radius-lg: 10px;
|
|
402
402
|
--feed-radius-md: 8px;
|
|
403
403
|
--feed-radius-pill: 8px;
|
|
404
|
+
/* Verdict tones for the rating grid. Hue is never the only signal: the cell also carries a CircleCheck or
|
|
405
|
+
CircleX and the word Match or Miss. Named here, in the feed layer, because the component must not hold
|
|
406
|
+
literal palette values and because Tailwind's dark: variant (.dark *) never fires in this app. */
|
|
407
|
+
--feed-verdict-match-tint: #05966933;
|
|
408
|
+
--feed-verdict-match-ink: #064e3b;
|
|
409
|
+
--feed-verdict-match-fill: #059669;
|
|
410
|
+
--feed-verdict-miss-tint: #dc262633;
|
|
411
|
+
--feed-verdict-miss-ink: #7f1d1d;
|
|
412
|
+
--feed-verdict-miss-fill: #dc2626;
|
|
413
|
+
/* The quiet step for an unpredicted cell and a switched-off button. */
|
|
414
|
+
--feed-quiet: 0.6;
|
|
404
415
|
}
|
|
405
416
|
[data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
|
|
406
417
|
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
|
|
407
418
|
--feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
|
|
408
419
|
--feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
|
|
420
|
+
/* Dark verdicts lift toward the tint rather than down into it, so the ink reads on a dark card. */
|
|
421
|
+
--feed-verdict-match-tint: #34d39933;
|
|
422
|
+
--feed-verdict-match-ink: #d1fae5;
|
|
423
|
+
--feed-verdict-match-fill: #34d399;
|
|
424
|
+
--feed-verdict-miss-tint: #f8717133;
|
|
425
|
+
--feed-verdict-miss-ink: #fee2e2;
|
|
426
|
+
--feed-verdict-miss-fill: #f87171;
|
|
409
427
|
}
|
|
410
428
|
|
|
411
429
|
/* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
|
|
@@ -426,6 +444,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
426
444
|
}
|
|
427
445
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: var(--feed-radius-pill) !important; }
|
|
428
446
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
|
|
447
|
+
/* The decision control keeps one corner across its segments and its reason field, and its predicted segment
|
|
448
|
+
keeps its tones apart: the flat rules above give every unpressed button the same control fill, which would
|
|
449
|
+
leave the predicted side and an untouched side looking identical. */
|
|
450
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] :is(button, textarea) { border-radius: var(--feed-radius-md) !important; }
|
|
451
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] [data-review-choice][data-state="idle"] { background: color-mix(in oklab, var(--feed-control) 45%, transparent) !important; }
|
|
452
|
+
|
|
429
453
|
/* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
|
|
430
454
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
|
|
431
455
|
|