@anthusai/papyrus 5.42.1 → 5.43.0-next.10

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.
Files changed (83) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/category-action/handler.ts +2 -1
  4. package/amplify/functions/monitoring-view/schema.js +22 -0
  5. package/amplify/functions/monitoring-view/schema.ts +22 -0
  6. package/amplify/functions/monitoring-view/wire.js +1 -0
  7. package/amplify/functions/monitoring-view/wire.ts +1 -0
  8. package/amplify/functions/procedure-action/handler.ts +2 -10
  9. package/amplify/functions/proposal-actions/handler.py +137 -0
  10. package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
  11. package/amplify/functions/proposal-actions/resource.js +15 -0
  12. package/amplify/functions/proposal-actions/resource.ts +22 -0
  13. package/amplify/functions/proposal-actions/schema.js +106 -0
  14. package/amplify/functions/proposal-actions/schema.ts +108 -0
  15. package/amplify/functions/proposal-actions/wire.js +10 -0
  16. package/amplify/functions/proposal-actions/wire.ts +13 -0
  17. package/amplify/plugins/reader-steering/function/handler.py +13 -72
  18. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  19. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  20. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  21. package/amplify/plugins/reader-steering/schema.js +0 -95
  22. package/amplify/plugins/reader-steering/schema.ts +0 -95
  23. package/amplify/site-backend.js +4 -0
  24. package/amplify/site-backend.ts +4 -0
  25. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  26. package/app/globals.css +31 -35
  27. package/app/tailwind.css +2 -0
  28. package/bin/papyrus-infra.mjs +21 -1
  29. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  30. package/components/monitoring-overview-view.tsx +140 -23
  31. package/components/monitoring-panel.tsx +96 -6
  32. package/components/news-desk-client-provider.tsx +8 -2
  33. package/components/news-desk-taxonomy-client.ts +2 -1
  34. package/components/newsroom-mobile-navigation.tsx +23 -0
  35. package/components/newsroom-ops-overview.tsx +6 -1
  36. package/components/newsroom-ops-shell.tsx +8 -10
  37. package/components/newsroom-organizer.css +1 -0
  38. package/components/newsroom-references-view.tsx +139 -257
  39. package/components/notification-center.tsx +345 -63
  40. package/components/papyrus-console-frame.tsx +66 -0
  41. package/components/papyrus-console-shell.tsx +14 -42
  42. package/components/papyrus-private-chat-panel.tsx +50 -16
  43. package/components/proposals-panel.tsx +107 -0
  44. package/components/proposals-view.tsx +95 -0
  45. package/components/reference-review-card.tsx +400 -0
  46. package/components/topic-steering-workspace.tsx +13 -2
  47. package/components/use-console-visibility.ts +47 -0
  48. package/infra/amplify-app-shell.js +18 -2
  49. package/infra/amplify-app-shell.ts +18 -2
  50. package/infra/chat-stack.js +222 -0
  51. package/infra/chat-stack.ts +250 -0
  52. package/infra/chat.test.js +142 -0
  53. package/infra/chat.test.ts +151 -0
  54. package/infra/index.js +1 -0
  55. package/infra/site-config.js +58 -1
  56. package/infra/site-config.ts +78 -1
  57. package/lib/cli-login-discovery.ts +78 -0
  58. package/lib/monitoring-client.ts +39 -3
  59. package/lib/monitoring-overview-fixture.ts +2 -2
  60. package/lib/monitoring-overview.ts +47 -3
  61. package/lib/monitoring-paging.ts +58 -0
  62. package/lib/newsroom-chat-client.ts +30 -0
  63. package/lib/newsroom-chat-identity.ts +40 -0
  64. package/lib/newsroom-organizer-nav.ts +1 -0
  65. package/lib/papyrus-hosted-chat.ts +62 -80
  66. package/lib/papyrus-private-chat-client.ts +7 -4
  67. package/lib/proposals-admin.ts +115 -0
  68. package/lib/proposals-client.ts +29 -0
  69. package/lib/prospectus-session.ts +2 -0
  70. package/lib/prospectus-sign-in-routes.ts +19 -2
  71. package/lib/prospectus-site-identity.ts +7 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/relevance-decisions.ts +9 -0
  74. package/lib/review-client.ts +38 -1
  75. package/lib/review-drafts.ts +231 -0
  76. package/lib/review-fixture.ts +36 -2
  77. package/lib/review-requests.ts +66 -24
  78. package/lib/staff-groups.ts +23 -0
  79. package/lib/staging-gate.ts +2 -2
  80. package/package.json +16 -1
  81. package/routes.manifest.json +12 -1
  82. package/src/sync.mjs +11 -2
  83. package/src/with-papyrus.mjs +1 -1
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
4
4
  import { configureAmplifyClient } from "./amplify-client-provider";
5
+ import { hasEditorRights } from "../lib/staff-groups";
5
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
7
 
7
- const EDITOR_GROUPS = new Set(["editor", "admin"]);
8
8
 
9
9
  function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
10
10
  const accessPayload = session.tokens?.accessToken?.payload ?? {};
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
29
29
 
30
30
  function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
31
31
  if (!session.tokens?.accessToken) return false;
32
- return readSessionGroups(session).some((group) => EDITOR_GROUPS.has(group));
32
+ return hasEditorRights(readSessionGroups(session));
33
33
  }
34
34
 
35
35
  export async function ensureDevSandboxEditorSession(
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import Link from "next/link";
3
+ import { CheckIcon, XIcon } from "lucide-react";
3
4
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
4
5
  import {
5
6
  confidenceText,
@@ -12,7 +13,12 @@ import {
12
13
  beginEmailSubmissionAction,
13
14
  endEmailSubmissionAction,
14
15
  isCurrentEmailSubmissionAction,
16
+ emailSubmissionAccepted,
17
+ QUARANTINE_LABELS,
18
+ type EmailDecisionResult,
19
+ type EmailQuarantine,
15
20
  type MonitoringItem,
21
+ type MonitoringListName,
16
22
  type MonitoringEmailDetails,
17
23
  type MonitoringEmailArticleResolution,
18
24
  type EmailReferenceImportItem,
@@ -25,6 +31,27 @@ import {
25
31
 
26
32
  export type MonitoringMode = "publication" | "all";
27
33
 
34
+ /** How a paged list loads more: the page owns the cursors and the requests, the view only shows the button. */
35
+ export type MonitoringPaging = {
36
+ /** Loads the next page of a list; the page appends it. */
37
+ onLoadMore?: (list: MonitoringListName) => void;
38
+ /** The list whose next page is being fetched. */
39
+ loadingList?: MonitoringListName | null;
40
+ /** Show only email submissions held for review (restarts the email list from its newest held message). */
41
+ heldOnly?: boolean;
42
+ onHeldOnlyChange?: (heldOnly: boolean) => void;
43
+ };
44
+
45
+ function LoadMore({ list, cursor, paging }: { list: MonitoringListName; cursor: string | null | undefined; paging?: MonitoringPaging }) {
46
+ if (!cursor || !paging?.onLoadMore) return null;
47
+ const loading = paging.loadingList === list;
48
+ return (
49
+ <button type="button" data-monitoring-load-more={list} disabled={loading} onClick={() => paging.onLoadMore?.(list)}>
50
+ {loading ? "Loading…" : "Load more"}
51
+ </button>
52
+ );
53
+ }
54
+
28
55
  const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
29
56
  const status = (value: string) => STATUS_WORDS[value] ?? value;
30
57
  const STOP_WORDS: Record<string, string> = {
@@ -97,8 +124,10 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
97
124
  );
98
125
  }
99
126
 
100
- function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
101
- items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
127
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
128
+ items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
129
+ cursor?: string | null; paging?: MonitoringPaging;
130
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
102
131
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
103
132
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
104
133
  }) {
@@ -108,8 +137,15 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
108
137
  const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
109
138
  const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
110
139
  const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
140
+ const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
141
+ const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
142
+ const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
111
143
  const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
112
144
  const receiptKey = receiptId ?? "";
145
+ const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
146
+ const accepted = emailSubmissionAccepted(quarantine);
147
+ const reason = reasonByReceipt[receiptKey] ?? "";
148
+ const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
113
149
  const selected = selectedByReceipt[receiptKey] ?? {};
114
150
  const resolved = resolvedByReceipt[receiptKey] ?? {};
115
151
  const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
@@ -138,13 +174,18 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
138
174
  const result = await onResolveArticleLink(requestReceiptId, index);
139
175
  if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
140
176
  if (!("url" in result)) {
141
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved safely. The original link remains private and unchanged." }));
177
+ const reason = "reason" in result && typeof result.reason === "string"
178
+ && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded|invalid_url|not_public|dns|timeout|connection|tls|loop|http_4xx|http_5xx|blocked|too_large|unreadable|unsafe_destination|no_query|search_unavailable|search_error|no_match)$/.test(result.reason)
179
+ ? ` (${result.reason})` : "";
180
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
142
181
  return;
143
182
  }
144
183
  const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
145
184
  setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
146
185
  setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
147
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
186
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
187
+ ? `The newsletter link could not be followed, so this article was found by searching the headline (match ${Math.round((result.search?.matchScore ?? 0) * 100)}%). Check that it is the right article before importing it.`
188
+ : "Resolved. This request may be recorded as a click by the newsletter sender." }));
148
189
  } catch {
149
190
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
150
191
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
@@ -155,6 +196,34 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
155
196
  }
156
197
  };
157
198
 
199
+ const decide = async (decision: "accept" | "reject") => {
200
+ if (!receiptId || !onDecide) return;
201
+ const requestReceiptId = receiptId;
202
+ const actionKey = `decide:${requestReceiptId}`;
203
+ if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
204
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
205
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
206
+ try {
207
+ const result = await onDecide(requestReceiptId, decision, reason.trim());
208
+ if ("ok" in result) {
209
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
210
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
211
+ ? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
212
+ : "Rejected. The message is closed and kept privately until it expires." }));
213
+ } else if ("error" in result && result.quarantine) {
214
+ setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
215
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
216
+ } else {
217
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
218
+ }
219
+ } catch {
220
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
221
+ } finally {
222
+ endEmailSubmissionAction(inFlightActions.current, actionKey);
223
+ setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
224
+ }
225
+ };
226
+
158
227
  const importSelected = async () => {
159
228
  if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
160
229
  const requestReceiptId = receiptId;
@@ -181,7 +250,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
181
250
  if (result.error) {
182
251
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
183
252
  } else {
184
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
253
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Predictions are queued in the cloud; review them when ready. Import does not approve or publish them.` }));
185
254
  }
186
255
  } catch {
187
256
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
@@ -196,6 +265,12 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
196
265
  return (
197
266
  <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
198
267
  <div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
268
+ {paging?.onHeldOnlyChange ? (
269
+ <div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
270
+ <button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
271
+ <button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
272
+ </div>
273
+ ) : null}
199
274
  {items.length ? <ul className="news-desk-monitoring__list">
200
275
  {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
201
276
  <strong>{item.subject || "(no subject)"}</strong>
@@ -207,38 +282,70 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
207
282
  <span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
208
283
  {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
209
284
  {item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
210
- <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
285
+ {item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
286
+ {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
211
287
  </li>)}
212
- </ul> : <p>No email submissions are available for this publication.</p>}
288
+ </ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
289
+ <LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
213
290
  {detail && "submission" in detail ? (
214
291
  <section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
215
292
  <h4>{detail.submission.subject || "Submitted email"}</h4>
216
293
  <p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
217
294
  <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
295
+ {quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
296
+ <h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
297
+ {quarantine.state === "quarantined" ? <>
298
+ <p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
299
+ <label>Why? (optional)
300
+ <textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
301
+ onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
302
+ </label>
303
+ <button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
304
+ <CheckIcon aria-hidden="true" size={16} /> Accept
305
+ </button>
306
+ <button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
307
+ <XIcon aria-hidden="true" size={16} /> Reject
308
+ </button>
309
+ </> : null}
310
+ {quarantine.decision ? <p data-email-decision-record>
311
+ {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
312
+ {quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
313
+ {quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
314
+ </p> : null}
315
+ {quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
316
+ {quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
317
+ {notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
318
+ </section> : null}
218
319
  <h5>Candidate references ({detail.candidateLinks.length})</h5>
219
320
  <ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
220
- {link.resolutionStatus === "unresolved" && !resolved[index]
321
+ {!accepted
322
+ ? <span>{link.title || "Source link"} <code>{link.url}</code></span>
323
+ : link.resolutionStatus === "unresolved" && !resolved[index]
221
324
  ? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
222
325
  : <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
223
- <label>
326
+ {accepted ? <label>
224
327
  <input type="checkbox" checked={selected[index] ?? false}
225
328
  disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
226
329
  onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
227
330
  Add as pending Reference
228
- </label>
229
- {link.resolutionStatus === "unresolved" && !resolved[index] ? (
331
+ </label> : null}
332
+ {accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
230
333
  <button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
231
334
  {busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
232
335
  </button>
233
336
  ) : null}
234
337
  </li>)}</ul>
235
- <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
236
- <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
237
- onClick={() => void importSelected()}>
238
- {busyImport ? "Adding References…" : "Add selected links to References"}
239
- </button>
240
- {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
241
- {importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
338
+ {accepted ? <>
339
+ <p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
340
+ <button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
341
+ onClick={() => void importSelected()}>
342
+ {busyImport ? "Adding References…" : "Add selected links to References"}
343
+ </button>
344
+ {notice ? <p role="status" data-email-import-result>{notice}</p> : null}
345
+ {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
346
+ <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
347
+ ))}</ul> : null}
348
+ </> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
242
349
  </section>
243
350
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
244
351
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -246,8 +353,9 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
246
353
  );
247
354
  }
248
355
 
249
- function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
250
- data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
356
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
357
+ data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
358
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
251
359
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
252
360
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
253
361
  }) {
@@ -259,8 +367,10 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
259
367
  ) : null}
260
368
 
261
369
  <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
370
+ cursor={data.cursors?.emailSubmissions} paging={paging}
262
371
  onResolveArticleLink={demo ? undefined : onResolveArticleLink}
263
- onImportCandidates={demo ? undefined : onImportCandidates} />
372
+ onImportCandidates={demo ? undefined : onImportCandidates}
373
+ onDecide={demo ? undefined : onDecide} />
264
374
 
265
375
  <article className="news-desk-doctrine-card" data-monitoring-section="router">
266
376
  <div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
@@ -297,7 +407,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
297
407
  <strong>{feed.id}</strong>
298
408
  <span>{feedHost(feed.sourceUrl)}</span>
299
409
  </div>
300
- <span>{feed.enabled ? "Enabled" : "Disabled"} &middot; {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
410
+ <span>{feed.enabled ? "Enabled" : "Disabled"} &middot; {everyText(feed.intervalSeconds)}</span>
301
411
  <span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} &middot; next {formatUtc(feed.nextDueAt)}</span>
302
412
  </li>
303
413
  ))}
@@ -318,6 +428,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
318
428
  ))}
319
429
  </ul>
320
430
  ) : <p>No collector runs recorded yet.</p>}
431
+ <LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
321
432
  {data.routerRuns.length ? (
322
433
  <ul className="news-desk-monitoring__list" data-monitoring-router-runs>
323
434
  {data.routerRuns.map((run) => (
@@ -330,6 +441,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
330
441
  ))}
331
442
  </ul>
332
443
  ) : null}
444
+ <LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
333
445
  </article>
334
446
 
335
447
  <article className="news-desk-doctrine-card" data-monitoring-section="queue">
@@ -339,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
339
451
  {data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
340
452
  </ul>
341
453
  ) : <p>No items in the queue.</p>}
454
+ <LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
342
455
  {data.hiddenNotRelevant > 0 ? (
343
456
  <p className="news-desk-delegation-note" data-monitoring-hidden>
344
457
  {data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
@@ -368,7 +481,10 @@ export function MonitoringOverviewView({
368
481
  onEmailOpen = () => {},
369
482
  onResolveArticleLink,
370
483
  onImportCandidates,
484
+ onDecide,
485
+ paging,
371
486
  }: {
487
+ paging?: MonitoringPaging;
372
488
  result: MonitoringResult | null;
373
489
  canViewAll: boolean;
374
490
  mode: MonitoringMode;
@@ -378,6 +494,7 @@ export function MonitoringOverviewView({
378
494
  onEmailOpen?: (receiptId: string) => void;
379
495
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
380
496
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
497
+ onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
381
498
  }) {
382
499
  let body: React.ReactNode;
383
500
  if (result === null) {
@@ -394,7 +511,7 @@ export function MonitoringOverviewView({
394
511
  body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector&apos;s first run.</Message>;
395
512
  } else {
396
513
  body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
397
- onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
514
+ onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
398
515
  }
399
516
  const scopeLine = result && "scope" in result
400
517
  ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
@@ -1,23 +1,75 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
4
5
  import {
6
+ decideMonitoringEmailSubmission,
5
7
  importMonitoringEmailCandidates,
6
8
  loadMonitoringEmailSubmission,
7
9
  loadMonitoringOverview,
10
+ loadMonitoringPage,
8
11
  resolveMonitoringEmailArticleLink,
9
12
  } from "../lib/monitoring-client";
10
13
  import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
11
- import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
12
- import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
14
+ import { withLoadedPages } from "../lib/monitoring-paging";
15
+ import type {
16
+ EmailReferenceImportItem,
17
+ MonitoringEmailDetails,
18
+ MonitoringListName,
19
+ MonitoringResult,
20
+ } from "../lib/monitoring-overview";
21
+ import type { MonitoringLoadedPages } from "../lib/monitoring-paging";
22
+ import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
13
23
 
14
24
  const REFRESH_MILLISECONDS = 60_000;
15
25
 
16
- /** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
17
- export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
26
+ /** A receipt URL mounts only the single-submission reader, never the overview. */
27
+ export function MonitoringPanel(props: { isAdmin: boolean; isDemo: boolean }) {
28
+ const searchParams = useSearchParams();
29
+ if (searchParams.has("receipt")) {
30
+ return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
31
+ }
32
+ return <MonitoringOverviewPanel {...props} />;
33
+ }
34
+
35
+ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
36
+ const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
37
+ useEffect(() => {
38
+ if (!receiptId || isDemo) return;
39
+ let live = true;
40
+ void loadMonitoringEmailSubmission(receiptId).then(
41
+ (next) => { if (live) setDetail(next); },
42
+ () => { if (live) setDetail({ state: "unavailable" }); },
43
+ );
44
+ return () => { live = false; };
45
+ }, [receiptId, isDemo]);
46
+ if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
47
+ if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
48
+ return <div data-news-desk-admin-panel="monitoring">
49
+ {detail === null ? <p role="status">Loading email submission…</p> :
50
+ <EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
51
+ onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
52
+ onDecide={decideMonitoringEmailSubmission} />}
53
+ </div>;
54
+ }
55
+
56
+ /** Overview refreshes apply only when no receipt was requested. */
57
+ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
18
58
  const [mode, setMode] = useState<MonitoringMode>("publication");
19
59
  const [result, setResult] = useState<MonitoringResult | null>(null);
20
60
  const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
61
+ // Pages the editor loaded beyond the newest page of each list. The 60 second refresh re-reads only the newest
62
+ // page, so what was loaded stays on screen.
63
+ const [loaded, setLoaded] = useState<MonitoringLoadedPages>({});
64
+ const [heldOnly, setHeldOnly] = useState(false);
65
+ const [held, setHeld] = useState<MonitoringLoadedPages["emailSubmissions"] | null>(null);
66
+ const [loadingList, setLoadingList] = useState<MonitoringListName | null>(null);
67
+
68
+ useEffect(() => {
69
+ setLoaded({});
70
+ setHeldOnly(false);
71
+ setHeld(null);
72
+ }, [mode]);
21
73
 
22
74
  useEffect(() => {
23
75
  if (isDemo) {
@@ -48,13 +100,51 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
48
100
  setEmailDetail(next);
49
101
  };
50
102
 
103
+ // Appends the next page of a list, or (held filter) the next page of held messages.
104
+ const loadMore = async (list: MonitoringListName) => {
105
+ if (isDemo || !result || !("scope" in result) || loadingList) return;
106
+ const current = withLoadedPages(result as never, loaded, heldOnly ? held : null);
107
+ const cursor = current.cursors?.[list];
108
+ if (!cursor) return;
109
+ setLoadingList(list);
110
+ try {
111
+ const page = await loadMonitoringPage(list, cursor, mode, heldOnly && list === "emailSubmissions");
112
+ if (!("rows" in page)) return;
113
+ if (heldOnly && list === "emailSubmissions") {
114
+ setHeld((existing) => ({ rows: [...(existing?.rows ?? []), ...page.rows] as never, cursor: page.cursor }));
115
+ } else {
116
+ setLoaded((existing) => ({ ...existing, [list]: { rows: [...(existing[list]?.rows ?? []), ...page.rows], cursor: page.cursor } }) as MonitoringLoadedPages);
117
+ }
118
+ } finally {
119
+ setLoadingList(null);
120
+ }
121
+ };
122
+
123
+ // Held for review restarts the email list at its newest held message, however old.
124
+ const changeHeldOnly = async (next: boolean) => {
125
+ setHeldOnly(next);
126
+ setHeld(null);
127
+ if (!next || isDemo) return;
128
+ setLoadingList("emailSubmissions");
129
+ try {
130
+ const page = await loadMonitoringPage("emailSubmissions", null, mode, true);
131
+ if ("rows" in page) setHeld({ rows: page.rows as never, cursor: page.cursor });
132
+ else setHeldOnly(false);
133
+ } finally {
134
+ setLoadingList(null);
135
+ }
136
+ };
137
+
138
+ const shown = result && "scope" in result ? withLoadedPages(result as never, loaded, heldOnly ? held ?? { rows: [], cursor: null } : null) : result;
139
+
51
140
  const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
52
141
  resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
53
142
 
54
143
  const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
55
144
  importMonitoringEmailCandidates(receiptId, candidates);
56
145
 
57
- return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
146
+ return <MonitoringOverviewView result={shown} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
58
147
  emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
59
- onImportCandidates={importEmailCandidates} />;
148
+ onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
149
+ paging={isDemo ? undefined : { onLoadMore: (list) => void loadMore(list), loadingList, heldOnly, onHeldOnlyChange: (next) => void changeHeldOnly(next) }} />;
60
150
  }
@@ -113,7 +113,8 @@ export function NewsDeskClientProvider({
113
113
  return;
114
114
  }
115
115
  if (isAuthorizationError(error)) {
116
- setShell((current) => resolveForbidden(current, access.auth));
116
+ // The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
117
+ setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
117
118
  return;
118
119
  }
119
120
  setShell((current) => resolveDashboardFailure(
@@ -215,9 +216,14 @@ function isAuthorizationError(error: unknown): boolean {
215
216
  return /not authorized|unauthorized|access denied|forbidden/i.test(message);
216
217
  }
217
218
 
219
+ function refusedReadMessage(error: unknown): string {
220
+ const detail = error instanceof Error ? error.message : String(error ?? "");
221
+ return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
222
+ }
223
+
218
224
  function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
219
225
  if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
220
- if (isAuthorizationError(error)) return resolveForbidden(state, auth);
226
+ if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
221
227
  return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
222
228
  }
223
229
 
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { loadReferenceReviewDetails } from "../lib/reference-review-details";
4
+ import { hasEditorRights } from "../lib/staff-groups";
4
5
 
5
6
  import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
6
7
  import { isLocalPreviewEnabled } from "../lib/local-preview";
@@ -838,7 +839,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
838
839
  if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
839
840
  return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
840
841
  }
841
- const isEditor = refreshed.groups.some((group) => group === "editor" || group === "admin");
842
+ const isEditor = hasEditorRights(refreshed.groups);
842
843
  if (!isEditor) {
843
844
  return {
844
845
  status: "forbidden",
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import Link from "next/link";
5
+ import { BookOpen, ClipboardList, House, MessageCircle, MessagesSquare } from "lucide-react";
6
+
7
+ const ICONS = { overview: House, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
8
+
9
+ export function NewsroomMobileNavigation({ items, activeTab, chat, more }: {
10
+ items: readonly { key: string; label: string; description: string; href: string }[];
11
+ activeTab: string;
12
+ chat?: { open: boolean; onOpen: () => void };
13
+ more: ReactNode;
14
+ }) {
15
+ return <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
16
+ {items.map(item => {
17
+ if (item.key === "messages" && chat) return <li key="chat"><button className="organizer-chat" type="button" aria-label="Open private chat" aria-expanded={chat.open} aria-controls="papyrus-console-panel" onClick={chat.onOpen}><MessageCircle size={18} aria-hidden="true" /><span>Chat</span></button></li>;
18
+ const Icon = ICONS[item.key as keyof typeof ICONS];
19
+ return <li key={item.key}><Link href={item.href} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}><Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>;
20
+ })}
21
+ <li>{more}</li>
22
+ </ul></nav>;
23
+ }
@@ -24,6 +24,7 @@ import {
24
24
  import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
25
25
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
26
26
  import { NotificationCenter } from "./notification-center";
27
+ import { CyclotronRow, predictionFromDecision } from "./reference-review-card";
27
28
 
28
29
  type NewsroomOpsOverviewProps = {
29
30
  assignments: AssignmentRecord[];
@@ -105,9 +106,13 @@ export function NewsroomOpsOverview({
105
106
  href={href}
106
107
  key={lineageId}
107
108
  >
108
- <div className="min-w-0 flex-1">
109
+ <div className="min-w-0 flex-1 space-y-1">
109
110
  <p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
110
111
  <p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
112
+ <div data-needs-attention-suggestion={lineageId}>
113
+ <CyclotronRow prediction={decisions.get(lineageId) ? predictionFromDecision(decisions.get(lineageId)!) : { state: "none" }} />
114
+ </div>
115
+ <p className="text-xs font-medium text-foreground">Open it to Accept or Reject in the review queue</p>
111
116
  </div>
112
117
  <Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
113
118
  </Link>
@@ -8,7 +8,7 @@ import { cn } from "../lib/utils";
8
8
  import { useEffect, useId, useState, type ReactNode } from "react";
9
9
  import Link from "next/link";
10
10
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
11
- import { BookOpen, ClipboardList, House, Menu, Search, MessagesSquare, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
11
+ import { Menu, Search, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
12
12
  import { ORGANIZER_NAV_GROUPS, ORGANIZER_PRIMARY_NAV, ORGANIZER_SETTINGS_DESTINATION } from "../lib/newsroom-organizer-nav";
13
13
  import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
14
14
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
@@ -20,6 +20,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTr
20
20
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
21
21
  import "./newsroom-organizer.css";
22
22
  import { NewsroomThemeFonts, useNewsroomThemeAttributes } from "./newsroom-theme";
23
+ import { NewsroomMobileNavigation } from "./newsroom-mobile-navigation";
24
+ import { usePapyrusConsole } from "./papyrus-console-shell";
23
25
 
24
26
  export type NewsroomNavCount = {
25
27
  count: number | null;
@@ -55,8 +57,6 @@ function OrganizerSearch({ demo }: { demo: boolean }) {
55
57
  </form>;
56
58
  }
57
59
 
58
- const ICONS = { overview: House, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
59
-
60
60
  /** Navigation is a presentation layer; existing route and server authorization still own access. */
61
61
  function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
62
62
  const id = useId();
@@ -105,6 +105,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
105
105
  }
106
106
 
107
107
  export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabel, children, demo = false, headerActions, pageTitle, showNavigation = true }: NewsroomOpsShellProps) {
108
+ const consoleState = usePapyrusConsole();
108
109
  const pathname = usePathname();
109
110
  const themeAttributes = useNewsroomThemeAttributes();
110
111
  const session = useOptionalNewsDeskClient();
@@ -166,16 +167,13 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
166
167
  </header>
167
168
  <main className="organizer-main">{demo && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
168
169
  </div>
169
- {showNavigation && <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
170
- {ORGANIZER_PRIMARY_NAV.map(item => { const Icon = ICONS[item.key as keyof typeof ICONS]; return <li key={item.key}><Link
171
- href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}>
172
- <Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
173
- <li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
170
+ {showNavigation && <NewsroomMobileNavigation activeTab={activeTab} items={ORGANIZER_PRIMARY_NAV.map(item => ({ ...item, href: getNewsroomNavHref(item.path, demo) }))}
171
+ chat={consoleState?.shouldOfferConsole ? { open: consoleState.open, onOpen: consoleState.openConsole } : undefined}
172
+ more={<Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
174
173
  <SheetContent side="bottom" className="organizer-menu" {...themeAttributes}><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
175
174
  <Navigation activePath={activePath} demo={demo} admin={admin} surface="menu" onNavigate={() => setMenuOpen(false)} />
176
175
  </SheetContent>
177
- </Sheet></li>
178
- </ul></nav>}
176
+ </Sheet>} />}
179
177
  </div>;
180
178
  }
181
179