@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
|
@@ -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,19 @@ 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
|
+
REFERENCE_CONVERSATION_KIND,
|
|
53
|
+
conversationAnchorKey,
|
|
54
|
+
conversationMessageId,
|
|
55
|
+
conversationThreadId,
|
|
56
|
+
conversationThreadKind,
|
|
57
|
+
createWithSequenceRetry,
|
|
58
|
+
findOrCreateThread,
|
|
59
|
+
type ConversationAnchor,
|
|
60
|
+
type ConversationMessage,
|
|
61
|
+
} from "../lib/conversation";
|
|
49
62
|
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
50
63
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
51
64
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
@@ -103,6 +116,23 @@ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
|
|
|
103
116
|
}
|
|
104
117
|
`;
|
|
105
118
|
|
|
119
|
+
const LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY = `
|
|
120
|
+
query ListMessageThreadsByAnchorAndUpdatedAt($primaryAnchorKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
121
|
+
listMessageThreadsByAnchorAndUpdatedAt(primaryAnchorKey: $primaryAnchorKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
122
|
+
items {
|
|
123
|
+
id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
|
|
124
|
+
}
|
|
125
|
+
nextToken
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const CREATE_SEMANTIC_RELATION_MUTATION = `
|
|
131
|
+
mutation CreateSemanticRelation($input: CreateSemanticRelationInput!) {
|
|
132
|
+
createSemanticRelation(input: $input) { id }
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
106
136
|
const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
107
137
|
query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
108
138
|
listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
@@ -1388,6 +1418,41 @@ export async function createSectionForumThreadRecord(input: {
|
|
|
1388
1418
|
return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
|
|
1389
1419
|
}
|
|
1390
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
|
|
1423
|
+
* derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
|
|
1424
|
+
*/
|
|
1425
|
+
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1426
|
+
const threadId = thread.id;
|
|
1427
|
+
let now = new Date().toISOString();
|
|
1428
|
+
let built: MessageRecord | null = null;
|
|
1429
|
+
const { created } = await createWithSequenceRetry({
|
|
1430
|
+
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1431
|
+
create: (sequenceNumber) => {
|
|
1432
|
+
now = new Date().toISOString();
|
|
1433
|
+
built = build(sequenceNumber, now);
|
|
1434
|
+
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
|
|
1438
|
+
const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
|
|
1439
|
+
UPDATE_MESSAGE_THREAD_MUTATION,
|
|
1440
|
+
{
|
|
1441
|
+
input: {
|
|
1442
|
+
id: threadId,
|
|
1443
|
+
messageCount: (thread.messageCount ?? 0) + 1,
|
|
1444
|
+
lastMessageId: createdMessage.id,
|
|
1445
|
+
lastMessageAt: createdMessage.createdAt,
|
|
1446
|
+
updatedAt: now,
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
);
|
|
1450
|
+
return {
|
|
1451
|
+
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
|
|
1452
|
+
message: createdMessage,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1391
1456
|
export async function appendForumThreadMessageRecord(input: {
|
|
1392
1457
|
threadId: string;
|
|
1393
1458
|
summary: string;
|
|
@@ -1404,11 +1469,8 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1404
1469
|
if (!summary) throw new Error("summary is required");
|
|
1405
1470
|
if (!content) throw new Error("content is required");
|
|
1406
1471
|
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")}`,
|
|
1472
|
+
return appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1473
|
+
id: `message-forum-${safeForumId(threadId)}-${String(sequenceNumber).padStart(4, "0")}`,
|
|
1412
1474
|
messageKind: "forum_post",
|
|
1413
1475
|
messageDomain: "edition_coordination",
|
|
1414
1476
|
status: "active",
|
|
@@ -1417,7 +1479,7 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1417
1479
|
authorLabel: input.authorLabel?.trim() || "editor",
|
|
1418
1480
|
threadId,
|
|
1419
1481
|
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1420
|
-
sequenceNumber
|
|
1482
|
+
sequenceNumber,
|
|
1421
1483
|
role: input.role || "human",
|
|
1422
1484
|
messageType: "forum_message",
|
|
1423
1485
|
content,
|
|
@@ -1425,30 +1487,184 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1425
1487
|
createdAt: now,
|
|
1426
1488
|
updatedAt: now,
|
|
1427
1489
|
newsroomFeedKey: "messages",
|
|
1428
|
-
};
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
type ConversationAuthor = { sub: string | null; label: string };
|
|
1494
|
+
|
|
1495
|
+
async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
1496
|
+
const snapshot = await loadReaderSessionSnapshot();
|
|
1497
|
+
let sub: string | null = null;
|
|
1498
|
+
try {
|
|
1499
|
+
const session = await fetchAuthSession();
|
|
1500
|
+
const claim = session.tokens?.idToken?.payload?.sub ?? session.tokens?.accessToken?.payload?.sub;
|
|
1501
|
+
sub = typeof claim === "string" && claim ? claim : null;
|
|
1502
|
+
} catch {
|
|
1503
|
+
sub = null;
|
|
1504
|
+
}
|
|
1505
|
+
return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
|
|
1509
|
+
type Response = { listMessageThreadsByAnchorAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null } | null };
|
|
1510
|
+
const response = await runGraphql<Response>(LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY, {
|
|
1511
|
+
primaryAnchorKey: anchorKey,
|
|
1512
|
+
sortDirection: "DESC",
|
|
1513
|
+
limit: 10,
|
|
1514
|
+
});
|
|
1515
|
+
const found = (response.listMessageThreadsByAnchorAndUpdatedAt?.items ?? []).filter(Boolean) as MessageThreadRecord[];
|
|
1516
|
+
const thread = found.find((entry) => entry.status !== "deleted") ?? null;
|
|
1517
|
+
return thread ? normalizeThreadRecord(thread) : null;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** The one conversation for an anchor: found by its anchor key, created when there is none. Concurrent first posts share the thread. */
|
|
1521
|
+
export async function ensureConversationThread(anchor: ConversationAnchor, author?: ConversationAuthor): Promise<MessageThreadRecord> {
|
|
1522
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1523
|
+
if (!anchorKey) throw new Error("An unattached conversation starts its own thread.");
|
|
1524
|
+
return findOrCreateThread({
|
|
1525
|
+
find: () => findConversationThread(anchorKey),
|
|
1526
|
+
create: async () => {
|
|
1527
|
+
const now = new Date().toISOString();
|
|
1528
|
+
const lineageId = anchor.lineageId!.trim();
|
|
1529
|
+
const thread: MessageThreadRecord = {
|
|
1530
|
+
id: conversationThreadId(anchor),
|
|
1531
|
+
threadKind: conversationThreadKind(anchor),
|
|
1532
|
+
status: "active",
|
|
1533
|
+
title: anchor.title?.trim() || lineageId,
|
|
1534
|
+
primaryAnchorKind: anchor.kind,
|
|
1535
|
+
primaryAnchorId: lineageId,
|
|
1536
|
+
primaryAnchorLineageId: lineageId,
|
|
1537
|
+
primaryAnchorKey: anchorKey,
|
|
1538
|
+
createdBySub: author?.sub ?? null,
|
|
1539
|
+
createdByLabel: author?.label ?? "editor",
|
|
1540
|
+
messageCount: 0,
|
|
1541
|
+
lastMessageId: null,
|
|
1542
|
+
lastMessageAt: null,
|
|
1543
|
+
metadata: JSON.stringify({ anchorKind: anchor.kind, anchorLineageId: lineageId }),
|
|
1544
|
+
createdAt: now,
|
|
1442
1545
|
updatedAt: now,
|
|
1443
|
-
|
|
1546
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1547
|
+
};
|
|
1548
|
+
const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1549
|
+
return normalizeThreadRecord(response.createMessageThread ?? thread);
|
|
1444
1550
|
},
|
|
1445
|
-
);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function toConversationMessage(record: MessageRecord): ConversationMessage {
|
|
1446
1555
|
return {
|
|
1447
|
-
|
|
1448
|
-
|
|
1556
|
+
id: record.id,
|
|
1557
|
+
parentMessageId: record.parentMessageId ?? null,
|
|
1558
|
+
authorLabel: record.authorLabel ?? "",
|
|
1559
|
+
content: record.content ?? record.body ?? "",
|
|
1560
|
+
createdAt: record.createdAt,
|
|
1561
|
+
sequenceNumber: Number(record.sequenceNumber ?? 0),
|
|
1449
1562
|
};
|
|
1450
1563
|
}
|
|
1451
1564
|
|
|
1565
|
+
/** Every Message in the anchor's conversation, oldest first. An anchor with no conversation yet has none. */
|
|
1566
|
+
export async function loadConversationMessages(anchor: ConversationAnchor): Promise<ConversationMessage[]> {
|
|
1567
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1568
|
+
if (!anchorKey) return [];
|
|
1569
|
+
const thread = await findConversationThread(anchorKey);
|
|
1570
|
+
if (!thread) return [];
|
|
1571
|
+
const records = await listMessagesByThreadId(thread.id, 500);
|
|
1572
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
|
|
1576
|
+
export async function loadReferenceConversationCounts(): Promise<Map<string, number>> {
|
|
1577
|
+
const threads = await listMessageThreadsByKind(REFERENCE_CONVERSATION_KIND, 500);
|
|
1578
|
+
const counts = new Map<string, number>();
|
|
1579
|
+
for (const thread of threads) {
|
|
1580
|
+
const lineageId = thread.primaryAnchorLineageId;
|
|
1581
|
+
if (lineageId && thread.status !== "deleted" && (thread.messageCount ?? 0) > 0) counts.set(lineageId, thread.messageCount ?? 0);
|
|
1582
|
+
}
|
|
1583
|
+
return counts;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Writes a Message into the anchor's conversation (finding or creating the thread), and a `comment` relation from the Message to
|
|
1588
|
+
* 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.
|
|
1589
|
+
*/
|
|
1590
|
+
export async function appendConversationMessage(input: {
|
|
1591
|
+
anchor: ConversationAnchor;
|
|
1592
|
+
body: string;
|
|
1593
|
+
parentMessageId?: string | null;
|
|
1594
|
+
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1595
|
+
const content = String(input.body || "").trim();
|
|
1596
|
+
if (!content) throw new Error("Write something to post.");
|
|
1597
|
+
const author = await currentConversationAuthor();
|
|
1598
|
+
const thread = await ensureConversationThread(input.anchor, author);
|
|
1599
|
+
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1600
|
+
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1601
|
+
messageKind: "comment",
|
|
1602
|
+
messageDomain: "commentary",
|
|
1603
|
+
status: "active",
|
|
1604
|
+
summary: content.slice(0, 120),
|
|
1605
|
+
source: "newsroom.conversation",
|
|
1606
|
+
authorSub: author.sub,
|
|
1607
|
+
authorLabel: author.label,
|
|
1608
|
+
threadId: thread.id,
|
|
1609
|
+
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1610
|
+
sequenceNumber,
|
|
1611
|
+
role: "human",
|
|
1612
|
+
messageType: "comment",
|
|
1613
|
+
content,
|
|
1614
|
+
metadata: JSON.stringify({ messageKind: "comment", anchorKey: conversationAnchorKey(input.anchor) }),
|
|
1615
|
+
createdAt: now,
|
|
1616
|
+
updatedAt: now,
|
|
1617
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1618
|
+
}));
|
|
1619
|
+
let relationError: string | null = null;
|
|
1620
|
+
if (input.anchor.kind !== "none") {
|
|
1621
|
+
try {
|
|
1622
|
+
await createCommentRelation(result.message, input.anchor);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
relationError = error instanceof Error ? error.message : "The link to the Reference could not be saved.";
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1631
|
+
if (anchor.kind === "none") return;
|
|
1632
|
+
const lineageId = anchor.lineageId.trim();
|
|
1633
|
+
const objectId = anchor.id?.trim() || lineageId;
|
|
1634
|
+
const now = message.createdAt;
|
|
1635
|
+
await runGraphql(CREATE_SEMANTIC_RELATION_MUTATION, {
|
|
1636
|
+
input: {
|
|
1637
|
+
id: `semantic-relation-${message.id}-comment`,
|
|
1638
|
+
relationState: "current",
|
|
1639
|
+
predicate: "comment",
|
|
1640
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1641
|
+
relationTypeKey: "comment",
|
|
1642
|
+
relationDomain: "commentary",
|
|
1643
|
+
subjectKind: "message",
|
|
1644
|
+
subjectId: message.id,
|
|
1645
|
+
subjectLineageId: message.id,
|
|
1646
|
+
subjectVersionNumber: 1,
|
|
1647
|
+
objectKind: anchor.kind,
|
|
1648
|
+
objectId,
|
|
1649
|
+
objectLineageId: lineageId,
|
|
1650
|
+
subjectStateKey: semanticStateKey("message", message.id),
|
|
1651
|
+
objectStateKey: semanticStateKey(anchor.kind, lineageId),
|
|
1652
|
+
objectSubjectStateKey: `${semanticStateKey(anchor.kind, lineageId)}#message`,
|
|
1653
|
+
predicateObjectStateKey: `comment#${semanticStateKey(anchor.kind, lineageId)}`,
|
|
1654
|
+
subjectVersionKey: semanticVersionKey("message", message.id),
|
|
1655
|
+
objectVersionKey: semanticVersionKey(anchor.kind, objectId),
|
|
1656
|
+
score: 1,
|
|
1657
|
+
rank: 1,
|
|
1658
|
+
reviewRecommended: false,
|
|
1659
|
+
importedAt: now,
|
|
1660
|
+
createdAt: now,
|
|
1661
|
+
updatedAt: now,
|
|
1662
|
+
newsroomFeedKey: "semanticRelations",
|
|
1663
|
+
metadata: JSON.stringify({ kind: "conversation.comment", messageKind: "comment" }),
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1452
1668
|
export async function deleteForumThreadMessageRecord(input: {
|
|
1453
1669
|
threadId: string;
|
|
1454
1670
|
messageId: string;
|
|
@@ -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}
|
|
@@ -9,7 +9,7 @@ import { cannotMoveReason, type DraftChoice } from "../lib/review-drafts";
|
|
|
9
9
|
import { answerWords, formatWhen, modeLabel, nextText, steeringText, subjectHref, subjectLabel, type ReviewRequest } from "../lib/review-requests";
|
|
10
10
|
import type { ReferencePreview } from "../lib/reference-preview";
|
|
11
11
|
import { FEED_SURFACE_PROPS, FeedImage, PILL, SourceMark } from "./reference-feed";
|
|
12
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
13
13
|
|
|
14
14
|
/** What the editor typed with an answer. */
|
|
15
15
|
export type DecisionExtras = { why: string; notThisKind: boolean };
|
|
@@ -109,9 +109,9 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
109
109
|
{locked ? (
|
|
110
110
|
<>
|
|
111
111
|
<DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
|
|
112
|
-
<
|
|
112
|
+
<DecisionControl
|
|
113
113
|
answer={decision.choice} disabled disabledReason={null} feedback={{ phase: "saved" }} onChoose={() => undefined}
|
|
114
|
-
prediction={{ state: "none" }} showPrediction={false} surface="footer"
|
|
114
|
+
prediction={{ state: "none" }} showPrediction={false} showReason={false} surface="footer"
|
|
115
115
|
/>
|
|
116
116
|
<p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p>
|
|
117
117
|
</>
|
|
@@ -132,7 +132,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
132
132
|
</button>
|
|
133
133
|
<span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
|
|
134
134
|
</div>
|
|
135
|
-
<
|
|
135
|
+
<DecisionControl
|
|
136
136
|
answer={busy ? pending : null}
|
|
137
137
|
disabled={Boolean(disabledReason)}
|
|
138
138
|
disabledReason={disabledReason}
|
|
@@ -140,6 +140,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
140
140
|
onChoose={choose}
|
|
141
141
|
prediction={{ state: "none" }}
|
|
142
142
|
showPrediction={false}
|
|
143
|
+
showReason={false}
|
|
143
144
|
surface="footer"
|
|
144
145
|
/>
|
|
145
146
|
</>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ConversationThread } from "./conversation-thread";
|
|
5
|
+
import { useConversation } from "./use-conversation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Messages about one Reference, under its review card. Shows nothing where no conversation store is provided.
|
|
9
|
+
* `onCount` tells the feed how many Messages there are, so its card count follows what is written here.
|
|
10
|
+
*/
|
|
11
|
+
export function ReferenceConversation({ disabledReason = null, lineageId, onCount, referenceId, title }: {
|
|
12
|
+
disabledReason?: string | null;
|
|
13
|
+
lineageId: string;
|
|
14
|
+
onCount?: (lineageId: string, count: number) => void;
|
|
15
|
+
/** The Reference version's id, when it differs from the lineage id. */
|
|
16
|
+
referenceId?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const conversation = useConversation({ kind: "reference", lineageId, title, ...(referenceId ? { id: referenceId } : {}) });
|
|
20
|
+
const [now, setNow] = useState(() => Date.now());
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const timer = window.setInterval(() => setNow(Date.now()), 60000);
|
|
23
|
+
return () => window.clearInterval(timer);
|
|
24
|
+
}, []);
|
|
25
|
+
const settled = conversation.messages.filter((message) => !message.pending).length;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (conversation.state === "ready") onCount?.(lineageId, settled);
|
|
28
|
+
}, [conversation.state, lineageId, onCount, settled]);
|
|
29
|
+
if (!conversation.available) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div data-reference-conversation={lineageId}>
|
|
32
|
+
<ConversationThread
|
|
33
|
+
disabledReason={disabledReason}
|
|
34
|
+
error={conversation.error}
|
|
35
|
+
messages={conversation.messages}
|
|
36
|
+
now={now}
|
|
37
|
+
onPost={conversation.post}
|
|
38
|
+
onRetry={conversation.retry}
|
|
39
|
+
state={conversation.state}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { InfoIcon, Loader2Icon } from "lucide-react";
|
|
3
|
+
import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
|
|
4
4
|
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
5
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
6
6
|
import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
@@ -20,11 +20,12 @@ import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
|
20
20
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
21
21
|
import { cn } from "../lib/utils";
|
|
22
22
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
|
-
import {
|
|
23
|
+
import { DecisionControl } from "./decision-control";
|
|
24
24
|
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
25
|
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
26
26
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
27
27
|
import type { CaptureStatus } from "../lib/reference-capture";
|
|
28
|
+
import { messageCountLabel } from "../lib/conversation";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
@@ -56,6 +57,8 @@ export type ReferenceFeedProps = {
|
|
|
56
57
|
disabledReason?: string | null;
|
|
57
58
|
/** True while the first read is in flight: cards are skeletons. */
|
|
58
59
|
loading?: boolean;
|
|
60
|
+
/** How many Messages each Reference has, by lineage id. A card with none shows no count. */
|
|
61
|
+
messageCounts?: ReadonlyMap<string, number>;
|
|
59
62
|
now: number;
|
|
60
63
|
onOpen: (lineageId: string) => void;
|
|
61
64
|
onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
@@ -201,6 +204,7 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
201
204
|
|
|
202
205
|
export function FeedCard({
|
|
203
206
|
capture,
|
|
207
|
+
messageCount = 0,
|
|
204
208
|
decision,
|
|
205
209
|
disabledReason,
|
|
206
210
|
item,
|
|
@@ -216,6 +220,8 @@ export function FeedCard({
|
|
|
216
220
|
}: {
|
|
217
221
|
/** Optional: the capture badge. */
|
|
218
222
|
capture?: CaptureStatus | undefined;
|
|
223
|
+
/** Optional: how many Messages this Reference has. */
|
|
224
|
+
messageCount?: number;
|
|
219
225
|
decision: RelevanceDecisionView | undefined;
|
|
220
226
|
disabledReason: string | null;
|
|
221
227
|
item: FeedItem;
|
|
@@ -294,37 +300,34 @@ export function FeedCard({
|
|
|
294
300
|
{host}
|
|
295
301
|
</span>
|
|
296
302
|
{capture ? <CaptureBadge status={capture} /> : null}
|
|
303
|
+
{messageCount > 0 ? (
|
|
304
|
+
<span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
|
|
305
|
+
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
|
|
306
|
+
{messageCount}
|
|
307
|
+
</span>
|
|
308
|
+
) : null}
|
|
297
309
|
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
298
310
|
</span>
|
|
299
311
|
</span>
|
|
300
312
|
</button>
|
|
301
313
|
|
|
302
314
|
<div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
|
|
303
|
-
<
|
|
315
|
+
<DecisionControl
|
|
304
316
|
answer={chosen}
|
|
305
317
|
decided={answer}
|
|
306
318
|
disabled={Boolean(disabledReason)}
|
|
307
319
|
disabledReason={disabledReason}
|
|
308
320
|
feedback={feedback}
|
|
321
|
+
needsReason={rating.askReason}
|
|
309
322
|
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
310
323
|
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
324
|
+
onReasonChange={(text) => store.setNote(item.lineageId, text)}
|
|
325
|
+
onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
|
|
311
326
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
327
|
+
reason={rating.note ?? ""}
|
|
328
|
+
reasonRequiredFor="reject"
|
|
312
329
|
statusOverlay
|
|
313
330
|
/>
|
|
314
|
-
{rating.askReason ? (
|
|
315
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
316
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
317
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
318
|
-
<div className="flex flex-wrap gap-1.5">
|
|
319
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
320
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
|
|
321
|
-
{REASON_LABELS[code]}
|
|
322
|
-
</button>
|
|
323
|
-
))}
|
|
324
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
327
|
-
) : null}
|
|
328
331
|
</div>
|
|
329
332
|
</article>
|
|
330
333
|
);
|
|
@@ -398,6 +401,7 @@ export function SectionHeading({ children, id }: { children: ReactNode; id: stri
|
|
|
398
401
|
export function ReferenceFeed({
|
|
399
402
|
disabledReason = null,
|
|
400
403
|
loading = false,
|
|
404
|
+
messageCounts,
|
|
401
405
|
now,
|
|
402
406
|
onOpen,
|
|
403
407
|
onRate,
|
|
@@ -426,6 +430,7 @@ export function ReferenceFeed({
|
|
|
426
430
|
item={item}
|
|
427
431
|
key={item.lineageId}
|
|
428
432
|
lead={lead}
|
|
433
|
+
messageCount={messageCounts?.get(item.lineageId)}
|
|
429
434
|
now={now}
|
|
430
435
|
onOpen={() => onOpen(item.lineageId)}
|
|
431
436
|
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|