@anthusai/papyrus 5.42.0 → 5.43.0-next.1

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 (58) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  22. package/app/globals.css +31 -35
  23. package/app/tailwind.css +2 -0
  24. package/components/monitoring-overview-view.tsx +12 -7
  25. package/components/monitoring-panel.tsx +33 -3
  26. package/components/newsroom-mobile-navigation.tsx +23 -0
  27. package/components/newsroom-ops-shell.tsx +8 -10
  28. package/components/newsroom-organizer.css +1 -0
  29. package/components/newsroom-references-view.tsx +141 -257
  30. package/components/notification-center.tsx +1 -1
  31. package/components/papyrus-console-frame.tsx +66 -0
  32. package/components/papyrus-console-shell.tsx +12 -41
  33. package/components/papyrus-private-chat-panel.tsx +50 -16
  34. package/components/proposals-panel.tsx +110 -0
  35. package/components/proposals-view.tsx +93 -0
  36. package/components/reference-review-card.tsx +383 -0
  37. package/components/topic-steering-workspace.tsx +13 -2
  38. package/components/use-console-visibility.ts +47 -0
  39. package/lib/cli-login-discovery.ts +78 -0
  40. package/lib/monitoring-client.ts +11 -2
  41. package/lib/monitoring-overview-fixture.ts +2 -2
  42. package/lib/monitoring-overview.ts +1 -2
  43. package/lib/newsroom-chat-client.ts +30 -0
  44. package/lib/newsroom-chat-identity.ts +39 -0
  45. package/lib/papyrus-hosted-chat.ts +62 -80
  46. package/lib/papyrus-private-chat-client.ts +7 -4
  47. package/lib/proposals-admin.ts +115 -0
  48. package/lib/prospectus-session.ts +2 -0
  49. package/lib/prospectus-sign-in-routes.ts +19 -2
  50. package/lib/prospectus-site-identity.ts +7 -0
  51. package/lib/relevance-decisions.ts +9 -0
  52. package/lib/review-requests.ts +4 -2
  53. package/package.json +12 -1
  54. package/routes.manifest.json +12 -1
  55. package/src/sync.mjs +11 -2
  56. package/src/with-papyrus.d.mts +1 -0
  57. package/src/with-papyrus.mjs +17 -1
  58. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -35,6 +35,8 @@ import {
35
35
  type ReaderMotionSetting,
36
36
  } from "./reader-settings";
37
37
  import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
38
+ import { PapyrusConsoleFrame } from "./papyrus-console-frame";
39
+ import { useConsoleVisibility } from "./use-console-visibility";
38
40
  import { PapyrusPrivateChatPanel } from "./papyrus-private-chat-panel";
39
41
  import { ModelSelector } from "./ai-elements/model-selector";
40
42
  import { Suggestion, Suggestions } from "./ai-elements/suggestion";
@@ -149,12 +151,12 @@ const PapyrusConsoleContext = createContext<PapyrusConsoleContextValue | null>(n
149
151
  export function PapyrusConsoleShell({ children, privateChatEnabled = false }: PapyrusConsoleShellProps) {
150
152
  const pathname = usePathname();
151
153
  const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
152
- const [open, setOpen] = useState(false);
153
154
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
154
155
  const internalPathname = browserPathToInternalNewsroomPath(pathname);
155
156
  const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
156
157
  const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
157
158
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
159
+ const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
158
160
 
159
161
  useEffect(() => {
160
162
  let active = true;
@@ -165,37 +167,11 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
165
167
  .catch(() => {
166
168
  if (active) setSession({ auth: { status: "signedOut", label: "Signed out" }, groups: [], hasSession: false });
167
169
  });
168
- try {
169
- setOpen(window.localStorage.getItem("papyrus:console-open") === "true");
170
- } catch {
171
- setOpen(false);
172
- }
173
170
  return () => {
174
171
  active = false;
175
172
  };
176
173
  }, []);
177
174
 
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
175
  const startReferenceChat = useCallback(async (target: NewsroomKnowledgeQueryTarget) => {
200
176
  if (!canUseConsole) {
201
177
  throw new Error("Chat is only available to editor/admin sessions.");
@@ -358,20 +334,15 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
358
334
 
359
335
  return (
360
336
  <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}
337
+ <PapyrusConsoleFrame available={shouldOfferConsole} open={open} reserveSpace={canUseConsole && open} onClose={closeConsole} page={children}>
338
+ {open ? (
339
+ canUseConsole
340
+ ? privateChatEnabled
341
+ ? <PapyrusPrivateChatPanel onClose={closeConsole} />
342
+ : <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={closeConsole} />
343
+ : <ConsoleAccessPanel onClose={closeConsole} session={session} />
344
+ ) : null}
345
+ </PapyrusConsoleFrame>
375
346
  </PapyrusConsoleContext.Provider>
376
347
  );
377
348
  }
@@ -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,110 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import amplifyOutputs from "papyrus-amplify-outputs";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { ProposalsView } from "./proposals-view";
7
+ import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
8
+ import {
9
+ EDIT_PROPOSAL,
10
+ LIST_PROPOSALS,
11
+ TRANSITION_PROPOSAL,
12
+ proposalReceiptMessage,
13
+ type ProposalFilter,
14
+ type ProposalMove,
15
+ type ProposalReceipt,
16
+ type ProposalRow,
17
+ } from "../lib/proposals-admin";
18
+
19
+ type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | ProposalReceipt | null>; errors?: { message?: string }[] };
20
+ const MAXIMUM_PAGES = 20;
21
+
22
+ async function loadProposals(): Promise<ProposalRow[]> {
23
+ const client = generateClient({ authMode: "userPool" });
24
+ const rows: ProposalRow[] = [];
25
+ let nextToken: string | null | undefined;
26
+ for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
27
+ const result = (await client.graphql({ query: LIST_PROPOSALS, variables: { nextToken } })) as GraphResult;
28
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load proposals.");
29
+ const connection = result.data?.listProposals as { items?: ProposalRow[]; nextToken?: string | null } | undefined;
30
+ rows.push(...(connection?.items ?? []).filter(Boolean));
31
+ nextToken = connection?.nextToken;
32
+ if (!nextToken) break;
33
+ }
34
+ return rows;
35
+ }
36
+
37
+ async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
38
+ const client = generateClient({ authMode: "userPool" });
39
+ const result = (await client.graphql({ query, variables })) as GraphResult;
40
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
41
+ return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
42
+ }
43
+
44
+ /** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
45
+ async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
46
+ if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
47
+ let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
48
+ for (const state of move.path ?? []) {
49
+ receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
50
+ if (receipt.status !== "ok") break;
51
+ }
52
+ return receipt;
53
+ }
54
+
55
+ /** Core to every site: it does not depend on the reader-steering plugin. */
56
+ export function ProposalsPanel() {
57
+ const [rows, setRows] = useState<ProposalRow[]>([]);
58
+ const [filter, setFilter] = useState<ProposalFilter>("open");
59
+ const [status, setStatus] = useState("Loading...");
60
+ const [busy, setBusy] = useState(false);
61
+ const [reason, setReason] = useState("");
62
+ const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
63
+
64
+ const refresh = useCallback(async (quiet = false) => {
65
+ setBusy(true);
66
+ try {
67
+ setRows(await loadProposals());
68
+ if (!quiet) setStatus("");
69
+ } catch (error) {
70
+ setStatus(`Error: ${error instanceof Error ? error.message : "Could not load proposals."}`);
71
+ } finally {
72
+ setBusy(false);
73
+ }
74
+ }, []);
75
+
76
+ useEffect(() => {
77
+ void refresh();
78
+ }, [refresh]);
79
+
80
+ const onMove = useCallback(
81
+ async (proposalId: string, move: ProposalMove) => {
82
+ setBusy(true);
83
+ setStatus("Working...");
84
+ try {
85
+ const receipt = await perform(proposalId, move, reason.trim());
86
+ setStatus(proposalReceiptMessage(receipt));
87
+ if (receipt.status === "ok") setReason("");
88
+ } catch (error) {
89
+ setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
90
+ }
91
+ await refresh(true);
92
+ },
93
+ [reason, refresh],
94
+ );
95
+
96
+ return (
97
+ <ProposalsView
98
+ rows={rows}
99
+ readers={readers}
100
+ filter={filter}
101
+ busy={busy}
102
+ status={status}
103
+ reason={reason}
104
+ onFilter={setFilter}
105
+ onReason={setReason}
106
+ onMove={(proposalId, move) => void onMove(proposalId, move)}
107
+ onRefresh={() => void refresh()}
108
+ />
109
+ );
110
+ }
@@ -0,0 +1,93 @@
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
+ };
36
+
37
+ export function ProposalsView(props: ProposalsViewProps) {
38
+ const sorted = useMemo(() => sortProposals(props.rows), [props.rows]);
39
+ const counts = useMemo(() => countProposals(sorted), [sorted]);
40
+ const shown = filterProposals(sorted, props.filter);
41
+ return (
42
+ <section aria-labelledby="newsroom-proposals-title" data-news-desk-admin-panel="proposals">
43
+ <h3 id="newsroom-proposals-title">Proposals</h3>
44
+ <p>Story drafts and other changes that AI proposes. Editors pin, hold and decide them. Only editors and admins can see this.</p>
45
+ <p role="tablist" aria-label="Proposal views">
46
+ {FILTERS.filter((entry) => props.readers || entry.key !== "in_steering").map((entry) => (
47
+ <span key={entry.key}>
48
+ <button type="button" role="tab" aria-selected={props.filter === entry.key} onClick={() => props.onFilter(entry.key)}>
49
+ {entry.label} ({counts[entry.key]})
50
+ </button>{" "}
51
+ </span>
52
+ ))}
53
+ <button type="button" onClick={props.onRefresh} disabled={props.busy}>Refresh</button>
54
+ </p>
55
+ <label>
56
+ Reason for the next move (optional){" "}
57
+ <input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
58
+ </label>
59
+ <p role="status" aria-live="polite">{props.status}</p>
60
+ {shown.length === 0 ? (
61
+ <p>Nothing here.</p>
62
+ ) : (
63
+ <ul>
64
+ {shown.map((row) => (
65
+ <li key={row.id} data-proposal-row={row.id}>
66
+ <article aria-label={row.title}>
67
+ <p>
68
+ <strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
69
+ {row.held ? ", on hold" : ""}) from {proposerLabel(row.proposer)} <time dateTime={row.createdAt}>{row.createdAt}</time>
70
+ </p>
71
+ {row.summary ? <p>{row.summary}</p> : null}
72
+ {row.body ? (
73
+ <details>
74
+ <summary>Read the draft</summary>
75
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
76
+ </details>
77
+ ) : null}
78
+ <p>
79
+ {proposalMoves(row, props.readers).map((move) => (
80
+ <span key={move.label}>
81
+ <button type="button" disabled={props.busy} onClick={() => props.onMove(row.id, move)}>{move.label}</button>{" "}
82
+ </span>
83
+ ))}
84
+ </p>
85
+ </article>
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ )}
90
+ </section>
91
+ );
92
+ }
93
+