@anthusai/papyrus 5.56.1 → 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-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +188 -0
- 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/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +322 -37
- 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 +69 -37
- 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 +43 -784
- package/components/use-conversation.ts +84 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +154 -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 +15 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -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
|
);
|
|
@@ -11,6 +11,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
|
11
11
|
import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
|
|
12
12
|
import { decidedChoiceOf } from "./needs-me-shared";
|
|
13
13
|
import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
|
|
14
|
+
import { ReferenceConversation } from "./reference-conversation";
|
|
14
15
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
15
16
|
import { useReferenceCapture } from "./reference-capture-client";
|
|
16
17
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
@@ -57,6 +58,7 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
57
58
|
titleHref={url}
|
|
58
59
|
>
|
|
59
60
|
{payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
|
|
61
|
+
<ReferenceConversation disabledReason={disabledReason ? "Editor access is required to write messages." : null} lineageId={lineageId} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
|
|
60
62
|
</ReviewCard>
|
|
61
63
|
<DefaultOutcome item={item} />
|
|
62
64
|
{incomplete ? <p className="text-sm text-muted-foreground" data-needs-me-capture-note>The captured text looks cut off or missing. Read it below. If it is incomplete, choose Paste or correct the text and say whether the capture was complete, or rate the Reference as it is.</p> : null}
|
|
@@ -10,6 +10,7 @@ import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments,
|
|
|
10
10
|
import { generateClient } from "aws-amplify/data";
|
|
11
11
|
import { testEditorOverrideAllowed } from "../lib/test-editor-override";
|
|
12
12
|
import { downloadData, getUrl } from "aws-amplify/storage";
|
|
13
|
+
import { fetchAuthSession } from "aws-amplify/auth";
|
|
13
14
|
import type { Schema } from "../amplify/data/resource";
|
|
14
15
|
import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
|
|
15
16
|
import { createEmptyCategorySteeringDashboard } from "../lib/category-dashboard";
|
|
@@ -45,7 +46,20 @@ import type {
|
|
|
45
46
|
SemanticRelationRecord,
|
|
46
47
|
UserDirectoryEntry,
|
|
47
48
|
} from "../lib/category-repository";
|
|
48
|
-
import { relationTypeKey } from "../lib/semantic-graph";
|
|
49
|
+
import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
|
|
50
|
+
import {
|
|
51
|
+
CONVERSATION_FEED_KEY,
|
|
52
|
+
GENERAL_CONVERSATION_KIND,
|
|
53
|
+
REFERENCE_CONVERSATION_KIND,
|
|
54
|
+
conversationAnchorKey,
|
|
55
|
+
conversationMessageId,
|
|
56
|
+
conversationThreadId,
|
|
57
|
+
conversationThreadKind,
|
|
58
|
+
createWithSequenceRetry,
|
|
59
|
+
findOrCreateThread,
|
|
60
|
+
type ConversationAnchor,
|
|
61
|
+
type ConversationMessage,
|
|
62
|
+
} from "../lib/conversation";
|
|
49
63
|
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
50
64
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
51
65
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
@@ -103,6 +117,23 @@ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
|
|
|
103
117
|
}
|
|
104
118
|
`;
|
|
105
119
|
|
|
120
|
+
const LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY = `
|
|
121
|
+
query ListMessageThreadsByAnchorAndUpdatedAt($primaryAnchorKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
122
|
+
listMessageThreadsByAnchorAndUpdatedAt(primaryAnchorKey: $primaryAnchorKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
123
|
+
items {
|
|
124
|
+
id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
|
|
125
|
+
}
|
|
126
|
+
nextToken
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const CREATE_SEMANTIC_RELATION_MUTATION = `
|
|
132
|
+
mutation CreateSemanticRelation($input: CreateSemanticRelationInput!) {
|
|
133
|
+
createSemanticRelation(input: $input) { id }
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
|
|
106
137
|
const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
107
138
|
query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
108
139
|
listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
@@ -271,7 +302,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
|
|
|
271
302
|
const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
272
303
|
query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
273
304
|
listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
274
|
-
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 }
|
|
275
306
|
nextToken
|
|
276
307
|
}
|
|
277
308
|
}
|
|
@@ -280,7 +311,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
|
280
311
|
const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
281
312
|
query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
282
313
|
listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
283
|
-
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 }
|
|
284
315
|
nextToken
|
|
285
316
|
}
|
|
286
317
|
}
|
|
@@ -289,7 +320,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
|
289
320
|
const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
290
321
|
query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
291
322
|
listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
292
|
-
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 }
|
|
293
324
|
nextToken
|
|
294
325
|
}
|
|
295
326
|
}
|
|
@@ -298,7 +329,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
|
298
329
|
const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
299
330
|
query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
300
331
|
listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
301
|
-
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 }
|
|
302
333
|
nextToken
|
|
303
334
|
}
|
|
304
335
|
}
|
|
@@ -307,7 +338,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
|
307
338
|
const LIST_REFERENCES_FALLBACK_QUERY = `
|
|
308
339
|
query ListReferencesFallback($limit: Int, $nextToken: String) {
|
|
309
340
|
listReferences(limit: $limit, nextToken: $nextToken) {
|
|
310
|
-
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 }
|
|
311
342
|
nextToken
|
|
312
343
|
}
|
|
313
344
|
}
|
|
@@ -440,7 +471,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
|
|
|
440
471
|
|
|
441
472
|
/** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
|
|
442
473
|
const REFERENCE_RECORD_GRAPHQL_FIELDS =
|
|
443
|
-
"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";
|
|
444
475
|
|
|
445
476
|
const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
|
|
446
477
|
query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
|
|
@@ -1388,6 +1419,43 @@ export async function createSectionForumThreadRecord(input: {
|
|
|
1388
1419
|
return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
|
|
1389
1420
|
}
|
|
1390
1421
|
|
|
1422
|
+
/**
|
|
1423
|
+
* Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
|
|
1424
|
+
* derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
|
|
1425
|
+
*/
|
|
1426
|
+
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1427
|
+
const threadId = thread.id;
|
|
1428
|
+
let now = new Date().toISOString();
|
|
1429
|
+
let built: MessageRecord | null = null;
|
|
1430
|
+
const { created } = await createWithSequenceRetry({
|
|
1431
|
+
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1432
|
+
create: (sequenceNumber) => {
|
|
1433
|
+
now = new Date().toISOString();
|
|
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) };
|
|
1437
|
+
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
|
|
1441
|
+
const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
|
|
1442
|
+
UPDATE_MESSAGE_THREAD_MUTATION,
|
|
1443
|
+
{
|
|
1444
|
+
input: {
|
|
1445
|
+
id: threadId,
|
|
1446
|
+
messageCount: (thread.messageCount ?? 0) + 1,
|
|
1447
|
+
lastMessageId: createdMessage.id,
|
|
1448
|
+
lastMessageAt: createdMessage.createdAt,
|
|
1449
|
+
updatedAt: now,
|
|
1450
|
+
},
|
|
1451
|
+
},
|
|
1452
|
+
);
|
|
1453
|
+
return {
|
|
1454
|
+
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
|
|
1455
|
+
message: createdMessage,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1391
1459
|
export async function appendForumThreadMessageRecord(input: {
|
|
1392
1460
|
threadId: string;
|
|
1393
1461
|
summary: string;
|
|
@@ -1404,11 +1472,8 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1404
1472
|
if (!summary) throw new Error("summary is required");
|
|
1405
1473
|
if (!content) throw new Error("content is required");
|
|
1406
1474
|
const thread = await getMessageThreadRecord(threadId);
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const now = new Date().toISOString();
|
|
1410
|
-
const message: MessageRecord = {
|
|
1411
|
-
id: `message-forum-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
|
|
1475
|
+
return appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1476
|
+
id: `message-forum-${safeForumId(threadId)}-${String(sequenceNumber).padStart(4, "0")}`,
|
|
1412
1477
|
messageKind: "forum_post",
|
|
1413
1478
|
messageDomain: "edition_coordination",
|
|
1414
1479
|
status: "active",
|
|
@@ -1417,7 +1482,7 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1417
1482
|
authorLabel: input.authorLabel?.trim() || "editor",
|
|
1418
1483
|
threadId,
|
|
1419
1484
|
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1420
|
-
sequenceNumber
|
|
1485
|
+
sequenceNumber,
|
|
1421
1486
|
role: input.role || "human",
|
|
1422
1487
|
messageType: "forum_message",
|
|
1423
1488
|
content,
|
|
@@ -1425,28 +1490,243 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1425
1490
|
createdAt: now,
|
|
1426
1491
|
updatedAt: now,
|
|
1427
1492
|
newsroomFeedKey: "messages",
|
|
1428
|
-
};
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1493
|
+
}));
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
type ConversationAuthor = { sub: string | null; label: string };
|
|
1497
|
+
|
|
1498
|
+
async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
1499
|
+
const snapshot = await loadReaderSessionSnapshot();
|
|
1500
|
+
let sub: string | null = null;
|
|
1501
|
+
try {
|
|
1502
|
+
const session = await fetchAuthSession();
|
|
1503
|
+
const claim = session.tokens?.idToken?.payload?.sub ?? session.tokens?.accessToken?.payload?.sub;
|
|
1504
|
+
sub = typeof claim === "string" && claim ? claim : null;
|
|
1505
|
+
} catch {
|
|
1506
|
+
sub = null;
|
|
1507
|
+
}
|
|
1508
|
+
return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
|
|
1512
|
+
type Response = { listMessageThreadsByAnchorAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null } | null };
|
|
1513
|
+
const response = await runGraphql<Response>(LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY, {
|
|
1514
|
+
primaryAnchorKey: anchorKey,
|
|
1515
|
+
sortDirection: "DESC",
|
|
1516
|
+
limit: 10,
|
|
1517
|
+
});
|
|
1518
|
+
const found = (response.listMessageThreadsByAnchorAndUpdatedAt?.items ?? []).filter(Boolean) as MessageThreadRecord[];
|
|
1519
|
+
const thread = found.find((entry) => entry.status !== "deleted") ?? null;
|
|
1520
|
+
return thread ? normalizeThreadRecord(thread) : null;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/** The one conversation for an anchor: found by its anchor key, created when there is none. Concurrent first posts share the thread. */
|
|
1524
|
+
export async function ensureConversationThread(anchor: ConversationAnchor, author?: ConversationAuthor): Promise<MessageThreadRecord> {
|
|
1525
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1526
|
+
if (!anchorKey) throw new Error("An unattached conversation starts its own thread.");
|
|
1527
|
+
return findOrCreateThread({
|
|
1528
|
+
find: () => findConversationThread(anchorKey),
|
|
1529
|
+
create: async () => {
|
|
1530
|
+
const now = new Date().toISOString();
|
|
1531
|
+
const lineageId = anchor.lineageId!.trim();
|
|
1532
|
+
const thread: MessageThreadRecord = {
|
|
1533
|
+
id: conversationThreadId(anchor),
|
|
1534
|
+
threadKind: conversationThreadKind(anchor),
|
|
1535
|
+
status: "active",
|
|
1536
|
+
title: anchor.title?.trim() || lineageId,
|
|
1537
|
+
primaryAnchorKind: anchor.kind,
|
|
1538
|
+
primaryAnchorId: lineageId,
|
|
1539
|
+
primaryAnchorLineageId: lineageId,
|
|
1540
|
+
primaryAnchorKey: anchorKey,
|
|
1541
|
+
createdBySub: author?.sub ?? null,
|
|
1542
|
+
createdByLabel: author?.label ?? "editor",
|
|
1543
|
+
messageCount: 0,
|
|
1544
|
+
lastMessageId: null,
|
|
1545
|
+
lastMessageAt: null,
|
|
1546
|
+
metadata: JSON.stringify({ anchorKind: anchor.kind, anchorLineageId: lineageId }),
|
|
1547
|
+
createdAt: now,
|
|
1442
1548
|
updatedAt: now,
|
|
1443
|
-
|
|
1549
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1550
|
+
};
|
|
1551
|
+
const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1552
|
+
return normalizeThreadRecord(response.createMessageThread ?? thread);
|
|
1444
1553
|
},
|
|
1445
|
-
);
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function toConversationMessage(record: MessageRecord): ConversationMessage {
|
|
1446
1558
|
return {
|
|
1447
|
-
|
|
1448
|
-
|
|
1559
|
+
id: record.id,
|
|
1560
|
+
parentMessageId: record.parentMessageId ?? null,
|
|
1561
|
+
authorLabel: record.authorLabel ?? "",
|
|
1562
|
+
content: record.content ?? record.body ?? "",
|
|
1563
|
+
createdAt: record.createdAt,
|
|
1564
|
+
sequenceNumber: Number(record.sequenceNumber ?? 0),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Every Message in the anchor's conversation, oldest first. An anchor with no conversation yet has none. */
|
|
1569
|
+
export async function loadConversationMessages(anchor: ConversationAnchor): Promise<ConversationMessage[]> {
|
|
1570
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1571
|
+
if (!anchorKey) return [];
|
|
1572
|
+
const thread = await findConversationThread(anchorKey);
|
|
1573
|
+
if (!thread) return [];
|
|
1574
|
+
const records = await listMessagesByThreadId(thread.id, 500);
|
|
1575
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
|
|
1579
|
+
export async function loadReferenceConversationCounts(): Promise<Map<string, number>> {
|
|
1580
|
+
const threads = await listMessageThreadsByKind(REFERENCE_CONVERSATION_KIND, 500);
|
|
1581
|
+
const counts = new Map<string, number>();
|
|
1582
|
+
for (const thread of threads) {
|
|
1583
|
+
const lineageId = thread.primaryAnchorLineageId;
|
|
1584
|
+
if (lineageId && thread.status !== "deleted" && (thread.messageCount ?? 0) > 0) counts.set(lineageId, thread.messageCount ?? 0);
|
|
1585
|
+
}
|
|
1586
|
+
return counts;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Writes a Message into the anchor's conversation (finding or creating the thread), and a `comment` relation from the Message to
|
|
1591
|
+
* the Reference or Item so the thing's own detail can find it. The relation is secondary: if it fails the Message is still saved.
|
|
1592
|
+
*/
|
|
1593
|
+
export async function appendConversationMessage(input: {
|
|
1594
|
+
anchor: ConversationAnchor;
|
|
1595
|
+
body: string;
|
|
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;
|
|
1599
|
+
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1600
|
+
const content = String(input.body || "").trim();
|
|
1601
|
+
if (!content) throw new Error("Write something to post.");
|
|
1602
|
+
const author = await currentConversationAuthor();
|
|
1603
|
+
const thread = input.thread ?? await ensureConversationThread(input.anchor, author);
|
|
1604
|
+
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1605
|
+
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1606
|
+
messageKind: "comment",
|
|
1607
|
+
messageDomain: "commentary",
|
|
1608
|
+
status: "active",
|
|
1609
|
+
summary: content.slice(0, 120),
|
|
1610
|
+
source: "newsroom.conversation",
|
|
1611
|
+
authorSub: author.sub,
|
|
1612
|
+
authorLabel: author.label,
|
|
1613
|
+
threadId: thread.id,
|
|
1614
|
+
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1615
|
+
sequenceNumber,
|
|
1616
|
+
role: "human",
|
|
1617
|
+
messageType: "comment",
|
|
1618
|
+
content,
|
|
1619
|
+
metadata: JSON.stringify({ messageKind: "comment", anchorKey: conversationAnchorKey(input.anchor) }),
|
|
1620
|
+
createdAt: now,
|
|
1621
|
+
updatedAt: now,
|
|
1622
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1623
|
+
}));
|
|
1624
|
+
let relationError: string | null = null;
|
|
1625
|
+
if (input.anchor.kind !== "none") {
|
|
1626
|
+
try {
|
|
1627
|
+
await createCommentRelation(result.message, input.anchor);
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
relationError = error instanceof Error ? error.message : "The link to the Reference could not be saved.";
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1633
|
+
}
|
|
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,
|
|
1449
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
|
+
|
|
1694
|
+
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1695
|
+
if (anchor.kind === "none") return;
|
|
1696
|
+
const lineageId = anchor.lineageId.trim();
|
|
1697
|
+
const objectId = anchor.id?.trim() || lineageId;
|
|
1698
|
+
const now = message.createdAt;
|
|
1699
|
+
await runGraphql(CREATE_SEMANTIC_RELATION_MUTATION, {
|
|
1700
|
+
input: {
|
|
1701
|
+
id: `semantic-relation-${message.id}-comment`,
|
|
1702
|
+
relationState: "current",
|
|
1703
|
+
predicate: "comment",
|
|
1704
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1705
|
+
relationTypeKey: "comment",
|
|
1706
|
+
relationDomain: "commentary",
|
|
1707
|
+
subjectKind: "message",
|
|
1708
|
+
subjectId: message.id,
|
|
1709
|
+
subjectLineageId: message.id,
|
|
1710
|
+
subjectVersionNumber: 1,
|
|
1711
|
+
objectKind: anchor.kind,
|
|
1712
|
+
objectId,
|
|
1713
|
+
objectLineageId: lineageId,
|
|
1714
|
+
subjectStateKey: semanticStateKey("message", message.id),
|
|
1715
|
+
objectStateKey: semanticStateKey(anchor.kind, lineageId),
|
|
1716
|
+
objectSubjectStateKey: `${semanticStateKey(anchor.kind, lineageId)}#message`,
|
|
1717
|
+
predicateObjectStateKey: `comment#${semanticStateKey(anchor.kind, lineageId)}`,
|
|
1718
|
+
subjectVersionKey: semanticVersionKey("message", message.id),
|
|
1719
|
+
objectVersionKey: semanticVersionKey(anchor.kind, objectId),
|
|
1720
|
+
score: 1,
|
|
1721
|
+
rank: 1,
|
|
1722
|
+
reviewRecommended: false,
|
|
1723
|
+
importedAt: now,
|
|
1724
|
+
createdAt: now,
|
|
1725
|
+
updatedAt: now,
|
|
1726
|
+
newsroomFeedKey: "semanticRelations",
|
|
1727
|
+
metadata: JSON.stringify({ kind: "conversation.comment", messageKind: "comment" }),
|
|
1728
|
+
},
|
|
1729
|
+
});
|
|
1450
1730
|
}
|
|
1451
1731
|
|
|
1452
1732
|
export async function deleteForumThreadMessageRecord(input: {
|
|
@@ -1627,10 +1907,17 @@ async function runGraphql<T extends Record<string, unknown>>(
|
|
|
1627
1907
|
const client = generateClient<Schema>() as unknown as {
|
|
1628
1908
|
graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
|
|
1629
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.
|
|
1630
1912
|
const response = await client.graphql({
|
|
1631
1913
|
query,
|
|
1632
1914
|
variables,
|
|
1633
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;
|
|
1634
1921
|
});
|
|
1635
1922
|
assertNoGraphQLErrors(response.errors);
|
|
1636
1923
|
return (response.data ?? {}) as T;
|
|
@@ -1866,17 +2153,15 @@ export async function loadEditorReferenceReviewDetails(lineageId: string) {
|
|
|
1866
2153
|
configureAmplifyClient();
|
|
1867
2154
|
const testMock = getTestEditorNewsroomMock();
|
|
1868
2155
|
return loadReferenceReviewDetails(lineageId, {
|
|
1869
|
-
relations: async (
|
|
2156
|
+
relations: async () => {
|
|
1870
2157
|
if (testMock?.semanticRelations) return testMock.semanticRelations;
|
|
1871
2158
|
return loadSemanticRelationsByState({
|
|
1872
|
-
field:
|
|
1873
|
-
keyName:
|
|
2159
|
+
field: "listSemanticRelationsBySubjectState",
|
|
2160
|
+
keyName: "subjectStateKey",
|
|
1874
2161
|
keyValue: `reference#${lineageId}#current`,
|
|
1875
|
-
query:
|
|
2162
|
+
query: LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY,
|
|
1876
2163
|
});
|
|
1877
2164
|
},
|
|
1878
|
-
message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
|
|
1879
|
-
payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
|
|
1880
2165
|
});
|
|
1881
2166
|
}
|
|
1882
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
|
|
|
@@ -50,6 +50,8 @@ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./referen
|
|
|
50
50
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
51
51
|
import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
|
|
52
52
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
53
|
+
import { ReferenceConversation } from "./reference-conversation";
|
|
54
|
+
import { useReferenceMessageCounts } from "./use-conversation";
|
|
53
55
|
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
54
56
|
import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
55
57
|
import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
@@ -200,6 +202,7 @@ function ReferenceDetailPanel({
|
|
|
200
202
|
decision,
|
|
201
203
|
demo = false,
|
|
202
204
|
disabled,
|
|
205
|
+
onMessageCount,
|
|
203
206
|
onReview,
|
|
204
207
|
predictionState = "ready",
|
|
205
208
|
reviewReason,
|
|
@@ -223,6 +226,7 @@ function ReferenceDetailPanel({
|
|
|
223
226
|
decision?: RelevanceDecisionView;
|
|
224
227
|
demo?: boolean;
|
|
225
228
|
disabled?: boolean;
|
|
229
|
+
onMessageCount?: (lineageId: string, count: number) => void;
|
|
226
230
|
onRelevanceReview?: (curation: RelevanceCuration) => Promise<void> | void;
|
|
227
231
|
onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
|
|
228
232
|
predictionState?: PredictionState;
|
|
@@ -308,6 +312,7 @@ function ReferenceDetailPanel({
|
|
|
308
312
|
</details>
|
|
309
313
|
) : null}
|
|
310
314
|
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
315
|
+
<ReferenceConversation disabledReason={disabled ? "Editor access is required to write messages." : null} lineageId={lineageId} onCount={onMessageCount} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
|
|
311
316
|
</ReviewCard>
|
|
312
317
|
|
|
313
318
|
<Separator />
|
|
@@ -445,6 +450,7 @@ export function NewsroomReferencesView({
|
|
|
445
450
|
const [view, setView] = useState<ReferenceView>("feed");
|
|
446
451
|
const [addOpen, setAddOpen] = useState(false);
|
|
447
452
|
const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
|
|
453
|
+
const { counts: messageCounts, setCount: setMessageCount } = useReferenceMessageCounts(!loading);
|
|
448
454
|
const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
|
|
449
455
|
const [mountedAt] = useState(() => Date.now());
|
|
450
456
|
const markVisitRated = useCallback((lineageId: string) => {
|
|
@@ -774,6 +780,7 @@ export function NewsroomReferencesView({
|
|
|
774
780
|
<ReferenceFeed
|
|
775
781
|
disabledReason={feedDisabledReason}
|
|
776
782
|
loading={previewSkeleton || (loading && !references.length)}
|
|
783
|
+
messageCounts={messageCounts}
|
|
777
784
|
now={mountedAt}
|
|
778
785
|
onOpen={selectReference}
|
|
779
786
|
onRate={rateFromFeed}
|
|
@@ -909,6 +916,7 @@ export function NewsroomReferencesView({
|
|
|
909
916
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
910
917
|
demo={demo}
|
|
911
918
|
disabled={disabled}
|
|
919
|
+
onMessageCount={setMessageCount}
|
|
912
920
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
913
921
|
onReview={runReview}
|
|
914
922
|
sectionAgreement={sectionAgreement}
|
|
@@ -952,6 +960,7 @@ export function NewsroomReferencesView({
|
|
|
952
960
|
demo={demo}
|
|
953
961
|
disabled={disabled}
|
|
954
962
|
onClose={closeDetail}
|
|
963
|
+
onMessageCount={setMessageCount}
|
|
955
964
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
956
965
|
onReview={runReview}
|
|
957
966
|
sectionAgreement={sectionAgreement}
|