@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
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
6
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
7
7
  import { CheckIcon, UserIcon } from "lucide-react";
8
8
  import { Md5 } from "@smithy/md5-js";
9
+ import { hasEditorRights } from "../lib/staff-groups";
9
10
  import {
10
11
  createConsoleMessage,
11
12
  createConsoleThread,
@@ -35,6 +36,8 @@ import {
35
36
  type ReaderMotionSetting,
36
37
  } from "./reader-settings";
37
38
  import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
39
+ import { PapyrusConsoleFrame } from "./papyrus-console-frame";
40
+ import { useConsoleVisibility } from "./use-console-visibility";
38
41
  import { PapyrusPrivateChatPanel } from "./papyrus-private-chat-panel";
39
42
  import { ModelSelector } from "./ai-elements/model-selector";
40
43
  import { Suggestion, Suggestions } from "./ai-elements/suggestion";
@@ -149,12 +152,12 @@ const PapyrusConsoleContext = createContext<PapyrusConsoleContextValue | null>(n
149
152
  export function PapyrusConsoleShell({ children, privateChatEnabled = false }: PapyrusConsoleShellProps) {
150
153
  const pathname = usePathname();
151
154
  const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
152
- const [open, setOpen] = useState(false);
153
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
154
156
  const internalPathname = browserPathToInternalNewsroomPath(pathname);
155
157
  const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
156
- const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
158
+ const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
157
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
160
+ const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
158
161
 
159
162
  useEffect(() => {
160
163
  let active = true;
@@ -165,37 +168,11 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
165
168
  .catch(() => {
166
169
  if (active) setSession({ auth: { status: "signedOut", label: "Signed out" }, groups: [], hasSession: false });
167
170
  });
168
- try {
169
- setOpen(window.localStorage.getItem("papyrus:console-open") === "true");
170
- } catch {
171
- setOpen(false);
172
- }
173
171
  return () => {
174
172
  active = false;
175
173
  };
176
174
  }, []);
177
175
 
178
- const toggleOpen = useCallback(() => {
179
- setOpen((current) => {
180
- const next = !current;
181
- try {
182
- window.localStorage.setItem("papyrus:console-open", String(next));
183
- } catch {
184
- // Ignore storage failures.
185
- }
186
- return next;
187
- });
188
- }, []);
189
-
190
- const openConsole = useCallback(() => {
191
- setOpen(true);
192
- try {
193
- window.localStorage.setItem("papyrus:console-open", "true");
194
- } catch {
195
- // Ignore storage failures.
196
- }
197
- }, []);
198
-
199
176
  const startReferenceChat = useCallback(async (target: NewsroomKnowledgeQueryTarget) => {
200
177
  if (!canUseConsole) {
201
178
  throw new Error("Chat is only available to editor/admin sessions.");
@@ -358,20 +335,15 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
358
335
 
359
336
  return (
360
337
  <PapyrusConsoleContext.Provider value={consoleContext}>
361
- <div className={canUseConsole && open ? "papyrus-console-page papyrus-console-page--open" : "papyrus-console-page"}>
362
- {children}
363
- </div>
364
- {shouldOfferConsole ? (
365
- <aside className={open ? "papyrus-console papyrus-console--open" : "papyrus-console"} aria-label="Papyrus console">
366
- {open ? (
367
- canUseConsole
368
- ? privateChatEnabled
369
- ? <PapyrusPrivateChatPanel onClose={toggleOpen} />
370
- : <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={toggleOpen} />
371
- : <ConsoleAccessPanel onClose={toggleOpen} session={session} />
372
- ) : null}
373
- </aside>
374
- ) : null}
338
+ <PapyrusConsoleFrame available={shouldOfferConsole} open={open} reserveSpace={canUseConsole && open} onClose={closeConsole} page={children}>
339
+ {open ? (
340
+ canUseConsole
341
+ ? privateChatEnabled
342
+ ? <PapyrusPrivateChatPanel onClose={closeConsole} />
343
+ : <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={closeConsole} />
344
+ : <ConsoleAccessPanel onClose={closeConsole} session={session} />
345
+ ) : null}
346
+ </PapyrusConsoleFrame>
375
347
  </PapyrusConsoleContext.Provider>
376
348
  );
377
349
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Hub } from "aws-amplify/utils";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
5
6
  import {
@@ -31,6 +32,7 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
31
32
  const [loading, setLoading] = useState(true);
32
33
  const [sending, setSending] = useState(false);
33
34
  const [error, setError] = useState<string | null>(null);
35
+ const identityGeneration = useRef(0);
34
36
  const retryAttempt = useRef<{ text: string; key: string } | null>(null);
35
37
  const isWaitingForReply = messages.length > 0 && messages[messages.length - 1].author_kind === "human" &&
36
38
  messages[messages.length - 1].addressed_to_bot_id !== null;
@@ -46,38 +48,67 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
46
48
  }];
47
49
  }, [isWaitingForReply, messages]);
48
50
 
49
- const refreshMessages = useCallback(async (channelId: string) => {
51
+ const handleFailure = useCallback(async (nextError: unknown) => {
52
+ const failure = failureMessage(nextError);
53
+ setError(failure);
54
+ if (["sign_in_required", "chat_account_access_denied"].includes(failure)) {
55
+ ++identityGeneration.current;
56
+ setChannel(null);
57
+ setMessages([]);
58
+ retryAttempt.current = null;
59
+ setSending(false);
60
+ setLoading(false);
61
+ }
62
+ }, []);
63
+
64
+ const refreshMessages = useCallback(async (channelId: string, generation: number) => {
50
65
  const next = await listPapyrusPrivateChatMessages(channelId);
51
- setMessages(next);
66
+ if (generation === identityGeneration.current) setMessages(next);
52
67
  }, []);
53
68
 
54
69
  const openConversation = useCallback(async () => {
70
+ const generation = ++identityGeneration.current;
55
71
  setLoading(true);
56
72
  setError(null);
73
+ setChannel(null);
74
+ setMessages([]);
75
+ retryAttempt.current = null;
76
+ setSending(false);
57
77
  try {
58
78
  const nextChannel = await openPapyrusPrivateChat();
79
+ if (generation !== identityGeneration.current) return;
59
80
  setChannel(nextChannel);
60
- await refreshMessages(nextChannel.channel_id);
81
+ await refreshMessages(nextChannel.channel_id, generation);
61
82
  } catch (nextError) {
62
- setError(failureMessage(nextError));
83
+ if (generation === identityGeneration.current) await handleFailure(nextError);
63
84
  } finally {
64
- setLoading(false);
85
+ if (generation === identityGeneration.current) setLoading(false);
65
86
  }
66
- }, [refreshMessages]);
87
+ }, [handleFailure, refreshMessages]);
67
88
 
68
89
  useEffect(() => {
69
90
  void openConversation();
91
+ return () => { identityGeneration.current += 1; };
70
92
  }, [openConversation]);
71
93
 
94
+ useEffect(() => Hub.listen("auth", ({ payload }) => {
95
+ if (payload.event === "tokenRefresh") {
96
+ void openConversation();
97
+ } else if (["signedOut", "signedIn", "tokenRefresh_failure"].includes(payload.event)) {
98
+ void handleFailure(new Error("sign_in_required"));
99
+ }
100
+ }), [handleFailure, openConversation]);
101
+
72
102
  useEffect(() => {
73
103
  if (!channel || !isWaitingForReply) return;
74
104
  let active = true;
105
+ const generation = identityGeneration.current;
75
106
  let timer: ReturnType<typeof setTimeout> | null = null;
76
107
  const poll = async () => {
77
108
  try {
78
- await refreshMessages(channel.channel_id);
109
+ await refreshMessages(channel.channel_id, generation);
79
110
  } catch (nextError) {
80
- if (active) setError(failureMessage(nextError));
111
+ if (active && generation === identityGeneration.current) await handleFailure(nextError);
81
112
  return;
82
113
  }
83
114
  if (active) timer = setTimeout(poll, REFRESH_MS);
@@ -87,10 +118,11 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
87
118
  active = false;
88
119
  if (timer) clearTimeout(timer);
89
120
  };
90
- }, [channel, isWaitingForReply, refreshMessages]);
121
+ }, [channel, handleFailure, isWaitingForReply, refreshMessages]);
91
122
 
92
123
  const sendMessage = useCallback(async (text: string) => {
93
124
  if (!channel || !text.trim() || sending) return;
125
+ const generation = identityGeneration.current;
94
126
  const attempt = retryAttempt.current?.text === text
95
127
  ? retryAttempt.current
96
128
  : { text, key: globalThis.crypto.randomUUID() };
@@ -99,27 +131,29 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
99
131
  setError(null);
100
132
  try {
101
133
  const result = await sendPapyrusPrivateChatMessage(channel.channel_id, text, attempt.key);
134
+ if (generation !== identityGeneration.current) return;
102
135
  setMessages((current) => current.some((message) => message.message_id === result.message.message_id)
103
136
  ? current
104
137
  : [...current, result.message]);
105
138
  retryAttempt.current = null;
106
139
  } catch (nextError) {
107
- setError(failureMessage(nextError));
140
+ if (generation === identityGeneration.current) await handleFailure(nextError);
108
141
  } finally {
109
- setSending(false);
142
+ if (generation === identityGeneration.current) setSending(false);
110
143
  }
111
- }, [channel, sending]);
144
+ }, [channel, handleFailure, sending]);
112
145
 
113
146
  return <div className="papyrus-console__panel">
114
147
  <header className="papyrus-console__header">
115
148
  <p className="papyrus-console__header-label">Private chat</p>
116
149
  <button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">×</button>
117
150
  </header>
118
- {channel ? <p className="papyrus-console__empty" role="status">Private conversation for your selected Account.</p> : null}
119
- {!channel && !loading ? <div className="papyrus-console__empty" role="alert">
151
+ {channel ? <p className="papyrus-console__empty" role="status">Private conversation for this newsroom.</p> : null}
152
+ {!channel && !loading ? <div className="papyrus-console__access" role="alert">
120
153
  <strong>Private chat could not be opened.</strong>
121
- <span>{error}</span>
154
+ <span>{error === "sign_in_required" ? "Your newsroom session has expired. Return to the newsroom and sign in from the account menu, then retry." : error === "chat_account_access_denied" ? "Your access to this conversation has changed. Retry to check your current access." : error}</span>
122
155
  <button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
156
+ <button className="papyrus-console__new-session" onClick={onClose} type="button">Back to newsroom</button>
123
157
  </div> : <PapyrusConsoleConversation
124
158
  messages={conversationMessages}
125
159
  isSending={sending || loading || !channel}
@@ -127,7 +161,7 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
127
161
  onSendMessage={sendMessage}
128
162
  error={error}
129
163
  emptyState={loading ? <p className="papyrus-console__empty">Opening private conversation…</p> : (
130
- <p className="papyrus-console__empty">This conversation is private to your selected Account and editor session.</p>
164
+ <p className="papyrus-console__empty">This conversation is private to you in this newsroom.</p>
131
165
  )}
132
166
  />}
133
167
  </div>;
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import amplifyOutputs from "papyrus-amplify-outputs";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { ProposalsView } from "./proposals-view";
7
+ import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
8
+ import {
9
+ LIST_PROPOSALS,
10
+ filterProposals,
11
+ proposalReceiptMessage,
12
+ type ProposalFilter,
13
+ type ProposalMove,
14
+ type ProposalRow,
15
+ } from "../lib/proposals-admin";
16
+ import { performProposalMove } from "../lib/proposals-client";
17
+
18
+ type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | null>; errors?: { message?: string }[] };
19
+ const MAXIMUM_PAGES = 20;
20
+
21
+ async function loadProposals(): Promise<ProposalRow[]> {
22
+ const client = generateClient({ authMode: "userPool" });
23
+ const rows: ProposalRow[] = [];
24
+ let nextToken: string | null | undefined;
25
+ for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
26
+ const result = (await client.graphql({ query: LIST_PROPOSALS, variables: { nextToken } })) as GraphResult;
27
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load proposals.");
28
+ const connection = result.data?.listProposals as { items?: ProposalRow[]; nextToken?: string | null } | undefined;
29
+ rows.push(...(connection?.items ?? []).filter(Boolean));
30
+ nextToken = connection?.nextToken;
31
+ if (!nextToken) break;
32
+ }
33
+ return rows;
34
+ }
35
+
36
+ /** Core to every site: it does not depend on the reader-steering plugin. */
37
+ export function ProposalsPanel() {
38
+ const [rows, setRows] = useState<ProposalRow[]>([]);
39
+ const [filter, setFilter] = useState<ProposalFilter>("open");
40
+ const [status, setStatus] = useState("Loading...");
41
+ const [busy, setBusy] = useState(false);
42
+ const [reason, setReason] = useState("");
43
+ const [focusId, setFocusId] = useState<string | null>(null);
44
+ const focused = useRef(false);
45
+ const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
46
+
47
+ const refresh = useCallback(async (quiet = false) => {
48
+ setBusy(true);
49
+ try {
50
+ setRows(await loadProposals());
51
+ if (!quiet) setStatus("");
52
+ } catch (error) {
53
+ setStatus(`Error: ${error instanceof Error ? error.message : "Could not load proposals."}`);
54
+ } finally {
55
+ setBusy(false);
56
+ }
57
+ }, []);
58
+
59
+ useEffect(() => {
60
+ void refresh();
61
+ }, [refresh]);
62
+
63
+ // Sent here by a link such as `?proposal=<id>`: show the view that holds it and scroll to it.
64
+ useEffect(() => {
65
+ setFocusId(new URLSearchParams(window.location.search).get("proposal"));
66
+ }, []);
67
+ useEffect(() => {
68
+ if (!focusId || focused.current) return;
69
+ const row = rows.find((candidate) => candidate.id === focusId);
70
+ if (!row) return;
71
+ focused.current = true;
72
+ setFilter(filterProposals([row], "open").length ? "open" : "decided");
73
+ window.requestAnimationFrame(() => document.getElementById(`proposal-${focusId}`)?.scrollIntoView({ block: "start" }));
74
+ }, [focusId, rows]);
75
+
76
+ const onMove = useCallback(
77
+ async (proposalId: string, move: ProposalMove) => {
78
+ setBusy(true);
79
+ setStatus("Working...");
80
+ try {
81
+ const receipt = await performProposalMove(proposalId, move, reason.trim());
82
+ setStatus(proposalReceiptMessage(receipt));
83
+ if (receipt.status === "ok") setReason("");
84
+ } catch (error) {
85
+ setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
86
+ }
87
+ await refresh(true);
88
+ },
89
+ [reason, refresh],
90
+ );
91
+
92
+ return (
93
+ <ProposalsView
94
+ rows={rows}
95
+ readers={readers}
96
+ filter={filter}
97
+ busy={busy}
98
+ status={status}
99
+ reason={reason}
100
+ onFilter={setFilter}
101
+ onReason={setReason}
102
+ onMove={(proposalId, move) => void onMove(proposalId, move)}
103
+ onRefresh={() => void refresh()}
104
+ focusId={focusId}
105
+ />
106
+ );
107
+ }
@@ -0,0 +1,95 @@
1
+ import { useMemo } from "react";
2
+ import {
3
+ countProposals,
4
+ filterProposals,
5
+ kindLabel,
6
+ proposalMoves,
7
+ proposerLabel,
8
+ sortProposals,
9
+ stateLabel,
10
+ type ProposalFilter,
11
+ type ProposalMove,
12
+ type ProposalRow,
13
+ } from "../lib/proposals-admin";
14
+
15
+ const FILTERS: Array<{ key: ProposalFilter; label: string }> = [
16
+ { key: "open", label: "Open" },
17
+ { key: "draft", label: "Drafts" },
18
+ { key: "in_steering", label: "In steering" },
19
+ { key: "editor_review", label: "Editor review" },
20
+ { key: "decided", label: "Decided" },
21
+ ];
22
+
23
+ export type ProposalsViewProps = {
24
+ rows: ProposalRow[];
25
+ /** True when the site has the reader-steering plugin; without it nothing reader-facing is shown. */
26
+ readers: boolean;
27
+ filter: ProposalFilter;
28
+ busy: boolean;
29
+ status: string;
30
+ reason: string;
31
+ onFilter: (filter: ProposalFilter) => void;
32
+ onReason: (reason: string) => void;
33
+ onMove: (proposalId: string, move: ProposalMove) => void;
34
+ onRefresh: () => void;
35
+ /** A proposal the editor was sent to (from the Notification Center): its draft opens and it is marked. */
36
+ focusId?: string | null;
37
+ };
38
+
39
+ export function ProposalsView(props: ProposalsViewProps) {
40
+ const sorted = useMemo(() => sortProposals(props.rows), [props.rows]);
41
+ const counts = useMemo(() => countProposals(sorted), [sorted]);
42
+ const shown = filterProposals(sorted, props.filter);
43
+ return (
44
+ <section aria-labelledby="newsroom-proposals-title" data-news-desk-admin-panel="proposals">
45
+ <h3 id="newsroom-proposals-title">Proposals</h3>
46
+ <p>Story drafts and other changes that AI proposes. Editors pin, hold and decide them. Only editors and admins can see this.</p>
47
+ <p role="tablist" aria-label="Proposal views">
48
+ {FILTERS.filter((entry) => props.readers || entry.key !== "in_steering").map((entry) => (
49
+ <span key={entry.key}>
50
+ <button type="button" role="tab" aria-selected={props.filter === entry.key} onClick={() => props.onFilter(entry.key)}>
51
+ {entry.label} ({counts[entry.key]})
52
+ </button>{" "}
53
+ </span>
54
+ ))}
55
+ <button type="button" onClick={props.onRefresh} disabled={props.busy}>Refresh</button>
56
+ </p>
57
+ <label>
58
+ Reason for the next move (optional){" "}
59
+ <input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
60
+ </label>
61
+ <p role="status" aria-live="polite">{props.status}</p>
62
+ {shown.length === 0 ? (
63
+ <p>Nothing here.</p>
64
+ ) : (
65
+ <ul>
66
+ {shown.map((row) => (
67
+ <li key={row.id} id={`proposal-${row.id}`} data-proposal-row={row.id} data-proposal-focused={props.focusId === row.id || undefined}>
68
+ <article aria-label={row.title}>
69
+ <p>
70
+ <strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
71
+ {row.held ? ", on hold" : ""}) from {proposerLabel(row.proposer)} <time dateTime={row.createdAt}>{row.createdAt}</time>
72
+ </p>
73
+ {row.summary ? <p>{row.summary}</p> : null}
74
+ {row.body ? (
75
+ <details open={props.focusId === row.id || undefined}>
76
+ <summary>Read the draft</summary>
77
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
78
+ </details>
79
+ ) : null}
80
+ <p>
81
+ {proposalMoves(row, props.readers).map((move) => (
82
+ <span key={move.label}>
83
+ <button type="button" disabled={props.busy} onClick={() => props.onMove(row.id, move)}>{move.label}</button>{" "}
84
+ </span>
85
+ ))}
86
+ </p>
87
+ </article>
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ )}
92
+ </section>
93
+ );
94
+ }
95
+