@anthusai/papyrus 5.43.0-next.37 → 5.43.0-next.39
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/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +173 -0
- package/components/decision-control.tsx +306 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +241 -25
- package/components/newsroom-organizer.css +24 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +22 -17
- package/components/reference-review-card.tsx +8 -134
- package/components/topic-steering-workspace.tsx +3 -2
- package/components/use-conversation.ts +84 -0
- package/components/use-reference-ratings.ts +1 -1
- package/lib/conversation.ts +154 -0
- package/package.json +8 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -2,20 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ArchiveIcon,
|
|
5
|
-
CheckIcon,
|
|
6
5
|
ChevronLeftIcon,
|
|
7
6
|
ChevronRightIcon,
|
|
8
|
-
CircleCheckIcon,
|
|
9
|
-
CircleXIcon,
|
|
10
7
|
ExternalLinkIcon,
|
|
11
8
|
} from "lucide-react";
|
|
12
9
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
10
|
import { Button } from "./ui/button";
|
|
14
|
-
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
15
11
|
import { cn } from "../lib/utils";
|
|
16
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
17
13
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
18
|
-
import {
|
|
14
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
19
15
|
|
|
20
16
|
export type ReviewChoice = "accept" | "reject";
|
|
21
17
|
|
|
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
|
|
|
31
27
|
reasonCode: ReferenceRejectionReasonCode | null;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
35
|
-
out_of_scope: "Out of scope",
|
|
36
|
-
policy_exclusion: "Policy exclusion",
|
|
37
|
-
duplicate: "Duplicate",
|
|
38
|
-
low_quality: "Low quality",
|
|
39
|
-
unavailable: "Unavailable",
|
|
40
|
-
provenance: "Provenance",
|
|
41
|
-
other: "Other",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
45
|
-
|
|
46
|
-
function percent(confidence: number | null): string | null {
|
|
47
|
-
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
52
|
-
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
53
|
-
*/
|
|
54
|
-
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
55
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
56
|
-
return (
|
|
57
|
-
<div className="space-y-2" data-review-row="cyclotron">
|
|
58
|
-
<div className="flex items-center gap-3">
|
|
59
|
-
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
60
|
-
{prediction.state === "ready" ? (
|
|
61
|
-
<div className="flex flex-1 gap-2">
|
|
62
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
63
|
-
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
64
|
-
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
65
|
-
return (
|
|
66
|
-
<span
|
|
67
|
-
className={cn(
|
|
68
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
69
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
70
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
71
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
72
|
-
)}
|
|
73
|
-
data-cyclotron-choice={choice}
|
|
74
|
-
data-predicted={predicted || undefined}
|
|
75
|
-
data-verdict={verdict ?? undefined}
|
|
76
|
-
key={choice}
|
|
77
|
-
>
|
|
78
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
79
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
80
|
-
{CHOICE_LABEL[choice]}
|
|
81
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
82
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
83
|
-
</span>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
86
|
-
</div>
|
|
87
|
-
) : (
|
|
88
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
89
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
90
|
-
</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
98
|
-
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
99
|
-
return (
|
|
100
|
-
<div className="flex flex-1 gap-2">
|
|
101
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
102
|
-
const on = chosen === choice;
|
|
103
|
-
return (
|
|
104
|
-
<Button
|
|
105
|
-
aria-disabled={disabled ? true : undefined}
|
|
106
|
-
aria-pressed={on}
|
|
107
|
-
className="min-h-14 flex-1 text-base"
|
|
108
|
-
data-review-choice={choice}
|
|
109
|
-
key={choice}
|
|
110
|
-
onClick={() => onChoose(choice)}
|
|
111
|
-
type="button"
|
|
112
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
113
|
-
>
|
|
114
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
115
|
-
{CHOICE_LABEL[choice]}
|
|
116
|
-
</Button>
|
|
117
|
-
);
|
|
118
|
-
})}
|
|
119
|
-
</div>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A reference's relevance decision in the card's own vocabulary. */
|
|
124
30
|
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
125
31
|
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
126
32
|
}
|
|
@@ -181,10 +87,8 @@ export function ReviewCard({
|
|
|
181
87
|
const store = ratings ?? local;
|
|
182
88
|
const item = store.get(itemId);
|
|
183
89
|
const note = item.note ?? initialNote;
|
|
184
|
-
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
185
90
|
const chosen = item.pending ?? decided;
|
|
186
91
|
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
187
|
-
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
188
92
|
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
189
93
|
|
|
190
94
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
@@ -222,53 +126,23 @@ export function ReviewCard({
|
|
|
222
126
|
) : null}
|
|
223
127
|
</header>
|
|
224
128
|
|
|
225
|
-
<
|
|
129
|
+
<DecisionControl
|
|
226
130
|
answer={chosen}
|
|
227
131
|
decided={decided}
|
|
228
132
|
disabled={Boolean(disabledReason)}
|
|
229
133
|
disabledReason={disabledReason}
|
|
230
134
|
feedback={item.feedback}
|
|
135
|
+
needsReason={item.askReason}
|
|
231
136
|
onChoose={choose}
|
|
137
|
+
onReasonChange={(text) => store.setNote(itemId, text)}
|
|
138
|
+
onReasonCommit={commitNote}
|
|
232
139
|
prediction={prediction}
|
|
140
|
+
reason={note}
|
|
141
|
+
reasonRequiredFor="reject"
|
|
233
142
|
size="large"
|
|
234
143
|
surface="card"
|
|
235
144
|
/>
|
|
236
145
|
|
|
237
|
-
<label className="block text-sm font-medium">
|
|
238
|
-
Why? (optional)
|
|
239
|
-
<AutoGrowTextarea
|
|
240
|
-
aria-label="Why? (optional)"
|
|
241
|
-
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
242
|
-
onBlur={commitNote}
|
|
243
|
-
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
244
|
-
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
245
|
-
value={note}
|
|
246
|
-
/>
|
|
247
|
-
</label>
|
|
248
|
-
|
|
249
|
-
{chosen === "reject" ? (
|
|
250
|
-
<div className="space-y-2" data-review-reasons>
|
|
251
|
-
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
252
|
-
<div className="flex flex-wrap gap-2">
|
|
253
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
254
|
-
<button
|
|
255
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
256
|
-
aria-pressed={reason === code}
|
|
257
|
-
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
258
|
-
data-review-reason={code}
|
|
259
|
-
key={code}
|
|
260
|
-
onClick={() => chooseReason(code)}
|
|
261
|
-
type="button"
|
|
262
|
-
>
|
|
263
|
-
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
264
|
-
{REASON_LABELS[code]}
|
|
265
|
-
</button>
|
|
266
|
-
))}
|
|
267
|
-
</div>
|
|
268
|
-
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
269
|
-
</div>
|
|
270
|
-
) : null}
|
|
271
|
-
|
|
272
146
|
{children}
|
|
273
147
|
|
|
274
148
|
{onArchive ? (
|
|
@@ -101,6 +101,7 @@ import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
|
101
101
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
102
102
|
import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
|
|
103
103
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
104
|
+
import { ConversationProvider } from "./conversation-provider";
|
|
104
105
|
import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
|
|
105
106
|
import { NewsroomTopicsView } from "./newsroom-topics-view";
|
|
106
107
|
import { MarkdownContext } from "./markdown-context";
|
|
@@ -3049,7 +3050,7 @@ function NewsDeskDashboard({
|
|
|
3049
3050
|
: formatDeskSectionHeadline(activeTab);
|
|
3050
3051
|
|
|
3051
3052
|
return (
|
|
3052
|
-
|
|
3053
|
+
<ConversationProvider authorLabel={authState.label} demo={Boolean(dashboard.isDemo)}>
|
|
3053
3054
|
<NewsroomOpsShell
|
|
3054
3055
|
activeTab={activeTab}
|
|
3055
3056
|
appTitle={SITE_BRAND.appTitle}
|
|
@@ -3286,7 +3287,7 @@ function NewsDeskDashboard({
|
|
|
3286
3287
|
</div>
|
|
3287
3288
|
</NewsroomOpsShell>
|
|
3288
3289
|
{topBarSearchControl.dialog}
|
|
3289
|
-
|
|
3290
|
+
</ConversationProvider>
|
|
3290
3291
|
);
|
|
3291
3292
|
}
|
|
3292
3293
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
|
|
5
|
+
import { useConversationStore } from "./conversation-context";
|
|
6
|
+
|
|
7
|
+
export type ConversationState = "loading" | "ready" | "error";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One conversation's Messages. Posting shows the Message at once (marked pending) and swaps in the saved one; a failed save removes
|
|
11
|
+
* it again and rejects, so the caller can give the typed text back.
|
|
12
|
+
*/
|
|
13
|
+
export function useConversation(anchor: ConversationAnchor) {
|
|
14
|
+
const store = useConversationStore();
|
|
15
|
+
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
|
16
|
+
const [state, setState] = useState<ConversationState>("loading");
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [attempt, setAttempt] = useState(0);
|
|
19
|
+
const pendingCount = useRef(0);
|
|
20
|
+
const anchorRef = useRef(anchor);
|
|
21
|
+
anchorRef.current = anchor;
|
|
22
|
+
const anchorKey = `${anchor.kind}#${anchor.lineageId ?? ""}`;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!store) return;
|
|
26
|
+
let active = true;
|
|
27
|
+
setState("loading");
|
|
28
|
+
setError(null);
|
|
29
|
+
setMessages([]);
|
|
30
|
+
store.load(anchorRef.current).then((loaded) => {
|
|
31
|
+
if (!active) return;
|
|
32
|
+
setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
|
|
33
|
+
setState("ready");
|
|
34
|
+
}).catch((failure) => {
|
|
35
|
+
if (!active) return;
|
|
36
|
+
setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
|
|
37
|
+
setState("error");
|
|
38
|
+
});
|
|
39
|
+
return () => { active = false; };
|
|
40
|
+
}, [store, anchorKey, attempt]);
|
|
41
|
+
|
|
42
|
+
const retry = useCallback(() => setAttempt((current) => current + 1), []);
|
|
43
|
+
|
|
44
|
+
const post = useCallback(async (body: string, parentMessageId: string | null) => {
|
|
45
|
+
if (!store) throw new Error("Messages are not available here.");
|
|
46
|
+
pendingCount.current += 1;
|
|
47
|
+
const pendingId = `pending-${pendingCount.current}`;
|
|
48
|
+
const pending: ConversationMessage = {
|
|
49
|
+
id: pendingId,
|
|
50
|
+
parentMessageId,
|
|
51
|
+
authorLabel: store.authorLabel,
|
|
52
|
+
content: body.trim(),
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + pendingCount.current,
|
|
55
|
+
pending: true,
|
|
56
|
+
};
|
|
57
|
+
setMessages((current) => [...current, pending]);
|
|
58
|
+
try {
|
|
59
|
+
const saved = await store.append({ anchor: anchorRef.current, body, parentMessageId });
|
|
60
|
+
setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
|
|
61
|
+
} catch (failure) {
|
|
62
|
+
setMessages((current) => current.filter((message) => message.id !== pendingId));
|
|
63
|
+
throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
|
|
64
|
+
}
|
|
65
|
+
}, [store]);
|
|
66
|
+
|
|
67
|
+
return { available: Boolean(store), messages, state, error, retry, post };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
|
|
71
|
+
export function useReferenceMessageCounts(enabled: boolean) {
|
|
72
|
+
const store = useConversationStore();
|
|
73
|
+
const [counts, setCounts] = useState<ReadonlyMap<string, number>>(() => new Map());
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!store || !enabled) return;
|
|
76
|
+
let active = true;
|
|
77
|
+
store.counts().then((loaded) => { if (active) setCounts((current) => new Map([...loaded, ...current])); }).catch(() => undefined);
|
|
78
|
+
return () => { active = false; };
|
|
79
|
+
}, [store, enabled]);
|
|
80
|
+
const setCount = useCallback((lineageId: string, count: number) => {
|
|
81
|
+
setCounts((current) => (current.get(lineageId) === count || (count === 0 && !current.has(lineageId)) ? current : new Map(current).set(lineageId, count)));
|
|
82
|
+
}, []);
|
|
83
|
+
return { counts, setCount };
|
|
84
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
5
|
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
-
import type { RatingFeedback } from "./
|
|
6
|
+
import type { RatingFeedback } from "./decision-control";
|
|
7
7
|
|
|
8
8
|
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
9
|
export const NOTE_AUTOSAVE_MS = 1200;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversations: Messages written about a Reference (later a Story, or nothing at all). One conversation per thing, held in one
|
|
3
|
+
* MessageThread found by its anchor key. Pure helpers only, so the browser and the tests share them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CONVERSATION_FEED_KEY = "conversations";
|
|
7
|
+
export const REFERENCE_CONVERSATION_KIND = "reference_conversation";
|
|
8
|
+
export const STORY_CONVERSATION_KIND = "story_conversation";
|
|
9
|
+
export const GENERAL_CONVERSATION_KIND = "conversation";
|
|
10
|
+
|
|
11
|
+
/** What a conversation is about. `none` is a conversation that is about nothing but itself. */
|
|
12
|
+
export type ConversationAnchor =
|
|
13
|
+
| { kind: "reference" | "item"; lineageId: string; /** The version id when it differs from the lineage id. */ id?: string; title?: string }
|
|
14
|
+
| { kind: "none"; lineageId?: undefined; title?: string };
|
|
15
|
+
|
|
16
|
+
/** A Message as the conversation shows it. */
|
|
17
|
+
export type ConversationMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
parentMessageId: string | null;
|
|
20
|
+
authorLabel: string;
|
|
21
|
+
content: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
sequenceNumber: number;
|
|
24
|
+
/** True while an optimistic Message waits for the save. */
|
|
25
|
+
pending?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ConversationReply = { message: ConversationMessage; answers: string | null };
|
|
29
|
+
export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
|
|
30
|
+
|
|
31
|
+
function slug(value: string): string {
|
|
32
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "thing";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `reference#<lineageId>`, `item#<lineageId>`, or null when the conversation is anchored to nothing. */
|
|
36
|
+
export function conversationAnchorKey(anchor: ConversationAnchor): string | null {
|
|
37
|
+
return anchor.kind === "none" || !anchor.lineageId?.trim() ? null : `${anchor.kind}#${anchor.lineageId.trim()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function conversationThreadKind(anchor: ConversationAnchor): string {
|
|
41
|
+
return anchor.kind === "reference" ? REFERENCE_CONVERSATION_KIND : anchor.kind === "item" ? STORY_CONVERSATION_KIND : GENERAL_CONVERSATION_KIND;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The thread id follows the anchor, so two editors starting the same conversation at once collide on create instead of splitting it. */
|
|
45
|
+
export function conversationThreadId(anchor: ConversationAnchor): string {
|
|
46
|
+
const key = conversationAnchorKey(anchor);
|
|
47
|
+
if (!key) throw new Error("A conversation without an anchor needs its own thread id.");
|
|
48
|
+
return `message-thread-conversation-${slug(key)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function conversationMessageId(threadId: string, sequenceNumber: number): string {
|
|
52
|
+
return `message-conversation-${slug(threadId)}-${String(sequenceNumber).padStart(4, "0")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One more than the highest sequence number in use. */
|
|
56
|
+
export function nextConversationSequence(existing: ReadonlyArray<{ sequenceNumber?: number | null }>): number {
|
|
57
|
+
return Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A create that lost a race for its sequence number (the id derives from the number, so the second writer collides). */
|
|
61
|
+
export function isSequenceConflict(error: unknown): boolean {
|
|
62
|
+
const text = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error ?? "");
|
|
63
|
+
return /conditional|already exists|duplicate|ConditionalCheckFailed/i.test(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "ryan@example.test" is shown as "ryan"; a name stays as it is. */
|
|
67
|
+
export function conversationAuthorName(label: string | null | undefined): string {
|
|
68
|
+
const text = String(label ?? "").trim();
|
|
69
|
+
if (!text) return "Someone";
|
|
70
|
+
const at = text.indexOf("@");
|
|
71
|
+
return at > 0 ? text.slice(0, at) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
|
|
76
|
+
* A reply whose parent is missing is shown as a top-level Message rather than lost.
|
|
77
|
+
*/
|
|
78
|
+
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>): ConversationRoot[] {
|
|
79
|
+
const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
|
|
80
|
+
const byId = new Map(sorted.map((message) => [message.id, message]));
|
|
81
|
+
const rootOf = (message: ConversationMessage): string => {
|
|
82
|
+
let current = message;
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
while (current.parentMessageId && byId.has(current.parentMessageId) && !seen.has(current.id)) {
|
|
85
|
+
seen.add(current.id);
|
|
86
|
+
current = byId.get(current.parentMessageId)!;
|
|
87
|
+
}
|
|
88
|
+
return current.id;
|
|
89
|
+
};
|
|
90
|
+
const roots = new Map<string, ConversationRoot>();
|
|
91
|
+
for (const message of sorted) {
|
|
92
|
+
if (rootOf(message) === message.id) roots.set(message.id, { message, replies: [] });
|
|
93
|
+
}
|
|
94
|
+
for (const message of sorted) {
|
|
95
|
+
const root = roots.get(rootOf(message));
|
|
96
|
+
if (!root || root.message.id === message.id) continue;
|
|
97
|
+
const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
|
|
98
|
+
root.replies.push({ message, answers: parent && parent.id !== root.message.id ? conversationAuthorName(parent.authorLabel) : null });
|
|
99
|
+
}
|
|
100
|
+
return [...roots.values()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Short relative time: "now", "5m", "3h", "2d". */
|
|
104
|
+
export function conversationAge(createdAt: string, now: number): string {
|
|
105
|
+
const time = Date.parse(createdAt);
|
|
106
|
+
if (!Number.isFinite(time)) return "";
|
|
107
|
+
const minutes = Math.max(0, Math.round((now - time) / 60000));
|
|
108
|
+
if (minutes < 1) return "now";
|
|
109
|
+
if (minutes < 60) return `${minutes}m`;
|
|
110
|
+
const hours = Math.round(minutes / 60);
|
|
111
|
+
if (hours < 24) return `${hours}h`;
|
|
112
|
+
return `${Math.round(hours / 24)}d`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function messageCountLabel(count: number): string {
|
|
116
|
+
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a Message with the next sequence number. The id derives from the number, so when another writer took it the create is
|
|
121
|
+
* refused and this tries the next number, up to `attempts` times. `list` returns the Messages already in the thread.
|
|
122
|
+
*/
|
|
123
|
+
export async function createWithSequenceRetry<Message>(input: {
|
|
124
|
+
list: () => Promise<ReadonlyArray<{ sequenceNumber?: number | null }>>;
|
|
125
|
+
create: (sequenceNumber: number) => Promise<Message>;
|
|
126
|
+
attempts?: number;
|
|
127
|
+
}): Promise<{ created: Message; sequenceNumber: number }> {
|
|
128
|
+
const attempts = input.attempts ?? 5;
|
|
129
|
+
let sequenceNumber = nextConversationSequence(await input.list());
|
|
130
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
return { created: await input.create(sequenceNumber), sequenceNumber };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!isSequenceConflict(error) || attempt >= attempts) throw error;
|
|
135
|
+
sequenceNumber = nextConversationSequence(await input.list());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Finds the conversation's thread, else creates it; a create that loses a race for the (anchor-derived) id uses the winner's thread. */
|
|
141
|
+
export async function findOrCreateThread<Thread>(input: {
|
|
142
|
+
find: () => Promise<Thread | null>;
|
|
143
|
+
create: () => Promise<Thread>;
|
|
144
|
+
}): Promise<Thread> {
|
|
145
|
+
const existing = await input.find();
|
|
146
|
+
if (existing) return existing;
|
|
147
|
+
try {
|
|
148
|
+
return await input.create();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const raced = isSequenceConflict(error) ? await input.find() : null;
|
|
151
|
+
if (raced) return raced;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -80,7 +80,11 @@
|
|
|
80
80
|
"./components/automation-run-now": "./components/automation-run-now.tsx",
|
|
81
81
|
"./components/automation-view": "./components/automation-view.tsx",
|
|
82
82
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
83
|
+
"./components/conversation-context": "./components/conversation-context.tsx",
|
|
84
|
+
"./components/conversation-provider": "./components/conversation-provider.tsx",
|
|
85
|
+
"./components/conversation-thread": "./components/conversation-thread.tsx",
|
|
83
86
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
87
|
+
"./components/decision-control": "./components/decision-control.tsx",
|
|
84
88
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
85
89
|
"./components/editorial-checks-admin-panel": "./components/editorial-checks-admin-panel.tsx",
|
|
86
90
|
"./components/editorial-finding-card": "./components/editorial-finding-card.tsx",
|
|
@@ -146,6 +150,7 @@
|
|
|
146
150
|
"./components/reference-add-entry": "./components/reference-add-entry.tsx",
|
|
147
151
|
"./components/reference-capture-client": "./components/reference-capture-client.ts",
|
|
148
152
|
"./components/reference-capture-panel": "./components/reference-capture-panel.tsx",
|
|
153
|
+
"./components/reference-conversation": "./components/reference-conversation.tsx",
|
|
149
154
|
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
150
155
|
"./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
|
|
151
156
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
@@ -160,7 +165,6 @@
|
|
|
160
165
|
"./components/reference-stories": "./components/reference-stories.tsx",
|
|
161
166
|
"./components/references-page-loading": "./components/references-page-loading.tsx",
|
|
162
167
|
"./components/references-prefetch": "./components/references-prefetch.ts",
|
|
163
|
-
"./components/review-rating-grid": "./components/review-rating-grid.tsx",
|
|
164
168
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
165
169
|
"./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
|
|
166
170
|
"./components/staging-banner": "./components/staging-banner.tsx",
|
|
@@ -189,6 +193,7 @@
|
|
|
189
193
|
"./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
|
|
190
194
|
"./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
|
|
191
195
|
"./components/use-console-visibility": "./components/use-console-visibility.ts",
|
|
196
|
+
"./components/use-conversation": "./components/use-conversation.ts",
|
|
192
197
|
"./components/use-editorial-checks": "./components/use-editorial-checks.ts",
|
|
193
198
|
"./components/use-media-query": "./components/use-media-query.ts",
|
|
194
199
|
"./components/use-near-viewport": "./components/use-near-viewport.ts",
|
|
@@ -229,6 +234,7 @@
|
|
|
229
234
|
"./lib/content-repository": "./lib/content-repository.ts",
|
|
230
235
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
231
236
|
"./lib/content-types": "./lib/content-types.ts",
|
|
237
|
+
"./lib/conversation": "./lib/conversation.ts",
|
|
232
238
|
"./lib/curation-client": "./lib/curation-client.ts",
|
|
233
239
|
"./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
|
|
234
240
|
"./lib/curation-routes": "./lib/curation-routes.ts",
|
|
@@ -504,7 +510,7 @@
|
|
|
504
510
|
"optional": true
|
|
505
511
|
}
|
|
506
512
|
},
|
|
507
|
-
"version": "5.43.0-next.
|
|
513
|
+
"version": "5.43.0-next.39",
|
|
508
514
|
"dependencies": {
|
|
509
515
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
510
516
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED