@anthusai/papyrus 5.57.0 → 5.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +28 -3
- package/amplify/data/resource.ts +28 -3
- package/amplify/data/schema.js +34 -5
- package/amplify/data/schema.ts +34 -5
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +154 -25
- package/components/conversations-memory-source.ts +83 -0
- package/components/conversations-network-source.ts +52 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +36 -0
- package/components/conversations-view.tsx +680 -0
- package/components/decision-control.tsx +307 -0
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +18 -17
- package/components/needs-me-feed.tsx +16 -32
- package/components/news-desk-taxonomy-client.ts +216 -62
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +34 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +9 -8
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +129 -127
- package/components/reference-review-card.tsx +9 -135
- package/components/references-page-loading.tsx +12 -6
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +44 -786
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +14 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +73 -2
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +12 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowLeftIcon } from "lucide-react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The newsroom's list-and-detail layout, one rule for every page that has it (References, Messages). Below `lg` (1024px) the open
|
|
9
|
+
* item's detail replaces the list at the full width with a Back button; from `lg` the detail is a pane beside the list, 24rem wide and
|
|
10
|
+
* 28rem from `xl`. It is chosen by the viewport, never by the selection.
|
|
11
|
+
*/
|
|
12
|
+
export const DETAIL_PANE_WIDTH = "lg:w-[min(100%,24rem)] xl:w-[min(100%,28rem)]";
|
|
13
|
+
export const LIST_HIDDEN_WHEN_DETAIL_OPEN = "max-lg:hidden";
|
|
14
|
+
|
|
15
|
+
export function ListDetailLayout({ backLabel, detail, list, onBack, open }: {
|
|
16
|
+
backLabel: string;
|
|
17
|
+
detail: ReactNode;
|
|
18
|
+
list: ReactNode;
|
|
19
|
+
onBack: () => void;
|
|
20
|
+
open: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-start" data-list-detail-layout={open ? "detail" : "list"}>
|
|
24
|
+
<div className={cn("min-w-0 flex-1", open && LIST_HIDDEN_WHEN_DETAIL_OPEN)} data-list-detail-list>{list}</div>
|
|
25
|
+
{open ? (
|
|
26
|
+
<div
|
|
27
|
+
className={cn("flex w-full shrink-0 flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4 lg:sticky lg:top-4 lg:max-h-[calc(100dvh-2rem)] lg:overflow-y-auto", DETAIL_PANE_WIDTH)}
|
|
28
|
+
data-list-detail-pane
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
className="-ml-2 inline-flex min-h-9 items-center gap-1.5 self-start rounded-[var(--feed-radius-md,16px)] px-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring lg:hidden"
|
|
32
|
+
data-detail-back
|
|
33
|
+
onClick={onBack}
|
|
34
|
+
type="button"
|
|
35
|
+
>
|
|
36
|
+
<ArrowLeftIcon aria-hidden="true" className="size-4" />
|
|
37
|
+
{backLabel}
|
|
38
|
+
</button>
|
|
39
|
+
{detail}
|
|
40
|
+
</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -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
|
|
|
@@ -96,22 +96,12 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
96
96
|
|
|
97
97
|
return (
|
|
98
98
|
<section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
|
|
99
|
-
<AutoGrowTextarea
|
|
100
|
-
aria-label="Why? (optional)"
|
|
101
|
-
className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
-
data-needs-me-why
|
|
103
|
-
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
104
|
-
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
105
|
-
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
106
|
-
value={note}
|
|
107
|
-
/>
|
|
108
|
-
{locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
|
|
109
99
|
{locked ? (
|
|
110
100
|
<>
|
|
111
101
|
{item.decisionState === "acknowledged"
|
|
112
102
|
? <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
103
|
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} />}
|
|
114
|
-
{flag ? null : <
|
|
104
|
+
{flag ? null : <DecisionControl answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} showReason={false} size="large" surface="footer" />}
|
|
115
105
|
{lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
|
|
116
106
|
</>
|
|
117
107
|
) : (
|
|
@@ -134,7 +124,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
134
124
|
{flag ? (
|
|
135
125
|
<FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
136
126
|
) : (
|
|
137
|
-
<
|
|
127
|
+
<DecisionControl
|
|
138
128
|
answer={rating.pending}
|
|
139
129
|
disabled={Boolean(disabledReason)}
|
|
140
130
|
disabledReason={disabledReason}
|
|
@@ -142,12 +132,23 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
142
132
|
onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
|
|
143
133
|
prediction={{ state: "none" }}
|
|
144
134
|
showPrediction={false}
|
|
135
|
+
showReason={false}
|
|
145
136
|
size="large"
|
|
146
137
|
surface="footer"
|
|
147
138
|
/>
|
|
148
139
|
)}
|
|
149
140
|
</>
|
|
150
141
|
)}
|
|
142
|
+
<AutoGrowTextarea
|
|
143
|
+
aria-label="Why? (optional)"
|
|
144
|
+
className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
145
|
+
data-needs-me-why
|
|
146
|
+
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
147
|
+
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
148
|
+
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
149
|
+
value={note}
|
|
150
|
+
/>
|
|
151
|
+
{locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
|
|
151
152
|
</section>
|
|
152
153
|
);
|
|
153
154
|
}
|
|
@@ -163,7 +164,7 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
|
|
|
163
164
|
const { item: draft } = payload;
|
|
164
165
|
const view = draft.view;
|
|
165
166
|
if (!view) {
|
|
166
|
-
return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the
|
|
167
|
+
return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the Proposed Story…</p>;
|
|
167
168
|
}
|
|
168
169
|
return (
|
|
169
170
|
<div className="space-y-4" data-draft-detail={draft.draftId}>
|
|
@@ -195,16 +196,16 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
|
|
|
195
196
|
</li>
|
|
196
197
|
);
|
|
197
198
|
})}
|
|
198
|
-
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full
|
|
199
|
+
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full Proposed Story</li> : null}
|
|
199
200
|
</ul>
|
|
200
201
|
</section>
|
|
201
202
|
) : null}
|
|
202
203
|
<p className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
203
204
|
<span data-draft-caption>{requestCaption(draft.request)}</span>
|
|
204
|
-
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full
|
|
205
|
+
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full Proposed Story<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
|
|
205
206
|
</p>
|
|
206
207
|
<DefaultOutcome item={item} />
|
|
207
|
-
<DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the
|
|
208
|
+
<DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the Proposed Story in Proposals</Link>.</>} />
|
|
208
209
|
</div>
|
|
209
210
|
);
|
|
210
211
|
}
|
|
@@ -23,7 +23,6 @@ import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferen
|
|
|
23
23
|
import { formatFeedAge } from "../lib/reference-preview";
|
|
24
24
|
import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
|
|
25
25
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
26
|
-
import type { FeedFreshness } from "../lib/reference-feed-ranking";
|
|
27
26
|
import type { RecentDecision } from "../lib/notification-feed";
|
|
28
27
|
import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
|
|
29
28
|
import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
|
|
@@ -32,7 +31,7 @@ import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
|
32
31
|
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
|
|
33
32
|
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
34
33
|
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
35
|
-
import {
|
|
34
|
+
import { DecisionControl } from "./decision-control";
|
|
36
35
|
import { useMediaQuery } from "./use-media-query";
|
|
37
36
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
38
37
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
@@ -169,30 +168,24 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
169
168
|
{flag ? (
|
|
170
169
|
<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
170
|
) : (
|
|
172
|
-
<
|
|
171
|
+
<DecisionControl
|
|
173
172
|
answer={chosen}
|
|
174
173
|
decided={decided}
|
|
175
174
|
disabled={Boolean(disabledReason) || locked}
|
|
176
175
|
disabledReason={disabledReason}
|
|
177
176
|
feedback={feedback}
|
|
177
|
+
needsReason={refLike && rating.askReason}
|
|
178
178
|
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
|
|
179
179
|
onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
|
|
180
|
+
onReasonChange={(text) => ratings.setNote(item.id, text)}
|
|
181
|
+
onReasonCommit={() => { if (!disabledReason && !locked) ratings.commitNote(item.id); }}
|
|
180
182
|
prediction={cardPrediction(item, predictionLoading)}
|
|
183
|
+
reason={rating.note ?? ""}
|
|
184
|
+
reasonRequiredFor={refLike ? "reject" : null}
|
|
181
185
|
showPrediction={refLike}
|
|
186
|
+
showReason={refLike}
|
|
182
187
|
/>
|
|
183
188
|
)}
|
|
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
189
|
</div>
|
|
197
190
|
</article>
|
|
198
191
|
);
|
|
@@ -224,22 +217,14 @@ export function ShapingStrip({ stats }: { stats: ShapingStats }) {
|
|
|
224
217
|
);
|
|
225
218
|
}
|
|
226
219
|
|
|
227
|
-
function plainDays(days: number): string {
|
|
228
|
-
if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
|
|
229
|
-
return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
220
|
/** The plain-words recipe, with this site's learned numbers in it. */
|
|
233
|
-
export function NeedsMeRecipe({
|
|
221
|
+
export function NeedsMeRecipe({ threshold }: { threshold: ConfidenceThreshold }) {
|
|
234
222
|
return (
|
|
235
223
|
<div className="space-y-3 rounded-[20px] bg-[var(--feed-card)] p-5 text-sm" data-needs-me-recipe>
|
|
236
224
|
<p className="text-base font-bold">A feed run by algorithms you control</p>
|
|
237
225
|
<ol className="list-decimal space-y-2.5 pl-5">
|
|
238
226
|
<li><strong>Hard gates first.</strong> Nothing continues until you decide one.</li>
|
|
239
|
-
<li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters
|
|
240
|
-
<li><strong>Age fades the score.</strong> {freshness.tauDays !== null && freshness.medianAgeDays !== null
|
|
241
|
-
? <>It loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} References you accepted.</>
|
|
242
|
-
: <>Age does not change the order yet. The pace is learned from the References you accept, and so far there are {freshness.examples} of the 10 needed.</>}</li>
|
|
227
|
+
<li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters. Age does not change it. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
|
|
243
228
|
<li><strong>A mix, not a run.</strong> Never more than two of one kind in a row while something else is waiting.</li>
|
|
244
229
|
<li><strong>Confident items do not ask for you.</strong> {threshold.threshold !== null
|
|
245
230
|
? <>Items the Cyclotron is surer about than {Math.round((1 - threshold.threshold) * 100)}% (the median of the {threshold.examples} predictions you confirmed) wait under Decided automatically. You can overrule any of them.</>
|
|
@@ -259,7 +244,6 @@ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
|
259
244
|
export type NeedsMeViewProps = {
|
|
260
245
|
loadState: NeedsMeLoadState;
|
|
261
246
|
entries: NeedsMeEntry[];
|
|
262
|
-
freshness: FeedFreshness;
|
|
263
247
|
threshold: ConfidenceThreshold;
|
|
264
248
|
now: number;
|
|
265
249
|
shaping: ShapingStats;
|
|
@@ -297,7 +281,7 @@ function Note({ children, ...rest }: { children: ReactNode } & Record<string, un
|
|
|
297
281
|
|
|
298
282
|
/** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
|
|
299
283
|
export function NeedsMeView({
|
|
300
|
-
loadState, entries,
|
|
284
|
+
loadState, entries, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
|
|
301
285
|
referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
|
|
302
286
|
}: NeedsMeViewProps) {
|
|
303
287
|
const themeMode = useResolvedPapyrusTheme();
|
|
@@ -318,7 +302,7 @@ export function NeedsMeView({
|
|
|
318
302
|
|
|
319
303
|
// The order is taken ONCE per visit, when every source has been read, and then left alone: selecting, deciding, a refresh or a cache
|
|
320
304
|
// revalidation changes what the cards say, never where they are. Items that arrive later wait behind a "N new items: show" pill.
|
|
321
|
-
const live = useMemo(() => rankNeedsMe(entries, {
|
|
305
|
+
const live = useMemo(() => rankNeedsMe(entries, { threshold }), [entries, threshold]);
|
|
322
306
|
const snapshot = useRef<NeedsMeSnapshot | null>(null);
|
|
323
307
|
if (!snapshot.current && settled && entries.length > 0) snapshot.current = takeNeedsMeSnapshot(live);
|
|
324
308
|
const placed = snapshot.current ? applyNeedsMeSnapshot(snapshot.current, live) : { feed: live.feed, auto: live.auto, newCount: 0 };
|
|
@@ -465,7 +449,7 @@ export function NeedsMeView({
|
|
|
465
449
|
<InfoIcon aria-hidden="true" className="size-4" />How this is ordered
|
|
466
450
|
</button>
|
|
467
451
|
</div>
|
|
468
|
-
{recipeOpen ? <NeedsMeRecipe
|
|
452
|
+
{recipeOpen ? <NeedsMeRecipe threshold={threshold} /> : null}
|
|
469
453
|
<p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
|
|
470
454
|
Decide what you like and leave the rest. Each item says what happens if you do nothing, and only a hard gate waits for you.
|
|
471
455
|
</p>
|
|
@@ -492,17 +476,17 @@ export function NeedsMeView({
|
|
|
492
476
|
<div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
|
|
493
477
|
<CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
|
|
494
478
|
<h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
|
|
495
|
-
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New
|
|
479
|
+
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New Proposed Stories, references, emails and reviews will appear here as they arrive.</p>
|
|
496
480
|
</div>
|
|
497
481
|
) : null}
|
|
498
482
|
|
|
499
483
|
{draftsFailed ? (
|
|
500
484
|
<Note role="alert">
|
|
501
|
-
<p>The
|
|
485
|
+
<p>The Proposed Stories could not be loaded right now.</p>
|
|
502
486
|
<button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
|
|
503
487
|
</Note>
|
|
504
488
|
) : null}
|
|
505
|
-
{missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these
|
|
489
|
+
{missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these Proposed Stories {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
|
|
506
490
|
|
|
507
491
|
{referencesLoading && !placed.feed.length ? (
|
|
508
492
|
<NotificationSkeleton count={3} bare />
|