@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
@@ -14,9 +14,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
14
14
  export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
- mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
19
- queries: ["listStoryDrafts"],
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
18
+ mutations: ["moderateReader"],
20
19
  guest: { mutations: [] },
21
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
22
21
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -119,7 +118,6 @@ export const readerSteeringPlugin = {
119
118
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
120
119
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
121
120
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
122
- ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
123
121
  ]) {
124
122
  tables[model].grantReadWriteData(lambda);
125
123
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -136,8 +134,10 @@ export const readerSteeringPlugin = {
136
134
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
137
135
  tables.ReaderSubmission.grantReadWriteData(contentActions);
138
136
  tables.ReaderComment.grantReadWriteData(contentActions);
139
- // The snapshot reads proposals and freezes the community result of one that reaches editor review.
137
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review. The Proposal model and the
138
+ // functions that file and move proposals are core (amplify/functions/proposal-actions); this plugin only reads the same table.
140
139
  tables.Proposal.grantReadWriteData(contentActions);
140
+ contentActions.addEnvironment("PAPYRUS_READER_PROPOSAL_TABLE", tables.Proposal.tableName);
141
141
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
142
142
  if (config.reservedConcurrency !== undefined) {
143
143
  lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
@@ -21,9 +21,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
21
21
  export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
- mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
26
- queries: ["listStoryDrafts"],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
+ mutations: ["moderateReader"],
27
26
  guest: { mutations: [] },
28
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
29
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -138,7 +137,6 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
138
137
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
139
138
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
140
139
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
141
- ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
142
140
  ] as const) {
143
141
  tables[model].grantReadWriteData(lambda);
144
142
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -155,8 +153,10 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
155
153
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
156
154
  tables.ReaderSubmission.grantReadWriteData(contentActions);
157
155
  tables.ReaderComment.grantReadWriteData(contentActions);
158
- // The snapshot reads proposals and freezes the community result of one that reaches editor review.
156
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review. The Proposal model and the
157
+ // functions that file and move proposals are core (amplify/functions/proposal-actions); this plugin only reads the same table.
159
158
  tables.Proposal.grantReadWriteData(contentActions);
159
+ contentActions.addEnvironment("PAPYRUS_READER_PROPOSAL_TABLE", tables.Proposal.tableName);
160
160
 
161
161
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
162
162
  if (config.reservedConcurrency !== undefined) {
@@ -135,101 +135,6 @@ export function readerSteeringSchemaDefinitions() {
135
135
  .disableOperations(["subscriptions"])
136
136
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
137
137
  .authorization(staffOnly),
138
- Proposal: a
139
- .model({
140
- id: a.id().required(),
141
- kind: a.string().required(),
142
- title: a.string().required(),
143
- summary: a.string(),
144
- body: a.string().required(),
145
- targets: a.json().required(),
146
- evidence: a.json(),
147
- proposer: a.string().required(),
148
- state: a.string().required(),
149
- history: a.json().required(),
150
- communityResult: a.json(),
151
- pinned: a.boolean(),
152
- held: a.boolean(),
153
- override: a.json(),
154
- exclusions: a.json(),
155
- editorLog: a.json(),
156
- needsLook: a.json(),
157
- createdAt: a.datetime().required(),
158
- })
159
- .disableOperations(["subscriptions"])
160
- .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
161
- .authorization(staffOnly),
162
- ProposalReceipt: a.customType({
163
- status: a.string().required(),
164
- id: a.string(),
165
- error: a.string(),
166
- message: a.string(),
167
- }),
168
- // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
169
- StoryDraftSummary: a.customType({
170
- id: a.string().required(),
171
- title: a.string().required(),
172
- summary: a.string(),
173
- targets: a.json(),
174
- state: a.string().required(),
175
- createdAt: a.string().required(),
176
- }),
177
- StoryDraftList: a.customType({
178
- status: a.string().required(),
179
- id: a.string(),
180
- error: a.string(),
181
- message: a.string(),
182
- drafts: a.ref("StoryDraftSummary").array(),
183
- }),
184
- // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
185
- // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
186
- // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
187
- fileProposal: a
188
- .mutation()
189
- .arguments({
190
- kind: a.string().required(),
191
- title: a.string().required(),
192
- body: a.string().required(),
193
- summary: a.string(),
194
- targets: a.json(),
195
- evidence: a.json(),
196
- })
197
- .returns(a.ref("ProposalReceipt"))
198
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
199
- .handler(functionHandler("readerActions", readerActions)),
200
- // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
201
- // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
202
- transitionProposal: a
203
- .mutation()
204
- .arguments({
205
- proposalId: a.string().required(),
206
- state: a.string().required(),
207
- reason: a.string(),
208
- result: a.string(),
209
- })
210
- .returns(a.ref("ProposalReceipt"))
211
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
212
- .handler(functionHandler("readerActions", readerActions)),
213
- // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
214
- // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
215
- listStoryDrafts: a
216
- .query()
217
- .returns(a.ref("StoryDraftList"))
218
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
219
- .handler(functionHandler("readerActions", readerActions)),
220
- // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
221
- // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
222
- editProposal: a
223
- .mutation()
224
- .arguments({
225
- proposalId: a.string().required(),
226
- action: a.string().required(),
227
- rule: a.string(),
228
- exclusionId: a.string(),
229
- })
230
- .returns(a.ref("ProposalReceipt"))
231
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
232
- .handler(functionHandler("readerActions", readerActions)),
233
138
  ModerationReceipt: a.customType({
234
139
  status: a.string().required(),
235
140
  id: a.string(),
@@ -137,101 +137,6 @@ export function readerSteeringSchemaDefinitions() {
137
137
  .disableOperations(["subscriptions"])
138
138
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
139
139
  .authorization(staffOnly),
140
- Proposal: a
141
- .model({
142
- id: a.id().required(),
143
- kind: a.string().required(),
144
- title: a.string().required(),
145
- summary: a.string(),
146
- body: a.string().required(),
147
- targets: a.json().required(),
148
- evidence: a.json(),
149
- proposer: a.string().required(),
150
- state: a.string().required(),
151
- history: a.json().required(),
152
- communityResult: a.json(),
153
- pinned: a.boolean(),
154
- held: a.boolean(),
155
- override: a.json(),
156
- exclusions: a.json(),
157
- editorLog: a.json(),
158
- needsLook: a.json(),
159
- createdAt: a.datetime().required(),
160
- })
161
- .disableOperations(["subscriptions"])
162
- .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
163
- .authorization(staffOnly),
164
- ProposalReceipt: a.customType({
165
- status: a.string().required(),
166
- id: a.string(),
167
- error: a.string(),
168
- message: a.string(),
169
- }),
170
- // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
171
- StoryDraftSummary: a.customType({
172
- id: a.string().required(),
173
- title: a.string().required(),
174
- summary: a.string(),
175
- targets: a.json(),
176
- state: a.string().required(),
177
- createdAt: a.string().required(),
178
- }),
179
- StoryDraftList: a.customType({
180
- status: a.string().required(),
181
- id: a.string(),
182
- error: a.string(),
183
- message: a.string(),
184
- drafts: a.ref("StoryDraftSummary").array(),
185
- }),
186
- // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
187
- // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
188
- // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
189
- fileProposal: a
190
- .mutation()
191
- .arguments({
192
- kind: a.string().required(),
193
- title: a.string().required(),
194
- body: a.string().required(),
195
- summary: a.string(),
196
- targets: a.json(),
197
- evidence: a.json(),
198
- })
199
- .returns(a.ref("ProposalReceipt"))
200
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
201
- .handler(functionHandler("readerActions", readerActions)),
202
- // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
203
- // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
204
- transitionProposal: a
205
- .mutation()
206
- .arguments({
207
- proposalId: a.string().required(),
208
- state: a.string().required(),
209
- reason: a.string(),
210
- result: a.string(),
211
- })
212
- .returns(a.ref("ProposalReceipt"))
213
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
214
- .handler(functionHandler("readerActions", readerActions)),
215
- // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
216
- // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
217
- listStoryDrafts: a
218
- .query()
219
- .returns(a.ref("StoryDraftList"))
220
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
221
- .handler(functionHandler("readerActions", readerActions)),
222
- // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
223
- // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
224
- editProposal: a
225
- .mutation()
226
- .arguments({
227
- proposalId: a.string().required(),
228
- action: a.string().required(),
229
- rule: a.string(),
230
- exclusionId: a.string(),
231
- })
232
- .returns(a.ref("ProposalReceipt"))
233
- .authorization((allow) => [allow.group("editor"), allow.group("admin")])
234
- .handler(functionHandler("readerActions", readerActions)),
235
140
  ModerationReceipt: a.customType({
236
141
  status: a.string().required(),
237
142
  id: a.string(),
@@ -24,6 +24,8 @@ import { defineSiteData } from "./data/resource.js";
24
24
  import { editorialChecks } from "./functions/editorial-checks/resource.js";
25
25
  import { automationView } from "./functions/automation-view/resource.js";
26
26
  import { wireAutomationView } from "./functions/automation-view/wire.js";
27
+ import { proposalActions } from "./functions/proposal-actions/resource.js";
28
+ import { wireProposalActions } from "./functions/proposal-actions/wire.js";
27
29
  import { monitoringView } from "./functions/monitoring-view/resource.js";
28
30
  import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire.js";
29
31
  import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
@@ -110,6 +112,7 @@ export function defineSiteBackend(papyrusSite) {
110
112
  modelAttachmentUpload,
111
113
  newsroomSummary,
112
114
  procedureAction,
115
+ proposalActions,
113
116
  publicForms,
114
117
  readerSettings,
115
118
  emailSubmissionProcessor,
@@ -631,6 +634,7 @@ export function defineSiteBackend(papyrusSite) {
631
634
  wireEditorialChecks(backend);
632
635
  wireMonitoringView(backend, emailMonitorId);
633
636
  wireAutomationView(backend);
637
+ wireProposalActions(backend);
634
638
  wirePlugins(backend, plugins, contentActionsLambda);
635
639
  return backend;
636
640
  }
@@ -31,6 +31,8 @@ import { defineSiteData } from "./data/resource";
31
31
  import { editorialChecks } from "./functions/editorial-checks/resource";
32
32
  import { automationView } from "./functions/automation-view/resource";
33
33
  import { wireAutomationView } from "./functions/automation-view/wire";
34
+ import { proposalActions } from "./functions/proposal-actions/resource";
35
+ import { wireProposalActions } from "./functions/proposal-actions/wire";
34
36
  import { monitoringView } from "./functions/monitoring-view/resource";
35
37
  import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire";
36
38
  import { wireEditorialChecks } from "./functions/editorial-checks/wire";
@@ -131,6 +133,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
131
133
  modelAttachmentUpload,
132
134
  newsroomSummary,
133
135
  procedureAction,
136
+ proposalActions,
134
137
  publicForms,
135
138
  readerSettings,
136
139
  emailSubmissionProcessor,
@@ -777,6 +780,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
777
780
  wireEditorialChecks(backend);
778
781
  wireMonitoringView(backend, emailMonitorId);
779
782
  wireAutomationView(backend);
783
+ wireProposalActions(backend);
780
784
  wirePlugins(backend, plugins, contentActionsLambda);
781
785
 
782
786
  return backend;
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCliLoginDiscovery } from "../../../lib/cli-login-discovery";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleCliLoginDiscovery(request);
9
+ }
package/app/globals.css CHANGED
@@ -1,7 +1,7 @@
1
1
  @import "./papyrus-theme.css";
2
2
 
3
- @import "tailwindcss";
4
- @import "tw-animate-css";
3
+ /* Tailwind (import, @source globs, @theme tokens) lives only in ./tailwind.css. Importing it again here
4
+ emitted a second full Tailwind build that loaded later and whose `.hidden` overrode `md:flex`. */
5
5
  @import "@radix-ui/colors/slate.css";
6
6
  @import "@radix-ui/colors/slate-alpha.css";
7
7
  @import "@radix-ui/colors/slate-dark.css";
@@ -15,39 +15,6 @@
15
15
  @import "@radix-ui/colors/tomato-dark.css";
16
16
  @import "@radix-ui/colors/tomato-dark-alpha.css";
17
17
 
18
- @theme inline {
19
- --radius-sm: calc(var(--radius) - 4px);
20
- --radius-md: calc(var(--radius) - 2px);
21
- --radius-lg: var(--radius);
22
- --radius-xl: calc(var(--radius) + 4px);
23
- --color-background: var(--background);
24
- --color-foreground: var(--foreground);
25
- --color-card: var(--card);
26
- --color-card-foreground: var(--card-foreground);
27
- --color-popover: var(--popover);
28
- --color-popover-foreground: var(--popover-foreground);
29
- --color-primary: var(--primary);
30
- --color-primary-foreground: var(--primary-foreground);
31
- --color-secondary: var(--secondary);
32
- --color-secondary-foreground: var(--secondary-foreground);
33
- --color-muted: var(--muted);
34
- --color-muted-foreground: var(--muted-foreground);
35
- --color-accent: var(--accent);
36
- --color-accent-foreground: var(--accent-foreground);
37
- --color-destructive: var(--destructive);
38
- --color-destructive-foreground: var(--destructive-foreground);
39
- --color-border: var(--border);
40
- --color-input: var(--input);
41
- --color-ring: var(--ring);
42
- --color-danger: var(--danger);
43
- --color-danger-foreground: var(--danger-foreground);
44
- --color-surface: var(--surface);
45
- --color-surface-foreground: var(--surface-foreground);
46
- --color-foreground-strong: var(--foreground-strong);
47
- --color-foreground-muted: var(--foreground-muted);
48
- --color-foreground-subtle: var(--foreground-subtle);
49
- }
50
-
51
18
  :root {
52
19
  --radius: 0.875rem;
53
20
  --background: var(--slate-2);
@@ -10170,3 +10137,32 @@ img {
10170
10137
  }
10171
10138
  }
10172
10139
 
10140
+
10141
+ @media (max-width: 900px) {
10142
+ .edition-progress__button--console { display: none; }
10143
+ .papyrus-console--open {
10144
+ --papyrus-console-width: 100vw;
10145
+ background: rgb(var(--paper-rgb));
10146
+ height: 100dvh;
10147
+ padding-bottom: env(safe-area-inset-bottom);
10148
+ box-sizing: border-box;
10149
+ }
10150
+ }
10151
+
10152
+ .papyrus-console__access {
10153
+ display: flex;
10154
+ flex-direction: column;
10155
+ align-items: flex-start;
10156
+ gap: 16px;
10157
+ padding: 24px 18px;
10158
+ overflow-y: auto;
10159
+ color: var(--ink);
10160
+ font-size: 0.95rem;
10161
+ line-height: 1.5;
10162
+ }
10163
+ .papyrus-console__access strong { font-size: 1.15rem; }
10164
+ .papyrus-console__access button { min-height: 44px; padding: 10px 14px; font: inherit; text-transform: none; letter-spacing: normal; }
10165
+
10166
+ @media (max-width: 900px) {
10167
+ .papyrus-console__header button { min-width: 44px; min-height: 44px; }
10168
+ }
package/app/tailwind.css CHANGED
@@ -24,6 +24,8 @@
24
24
  --color-accent: var(--accent);
25
25
  --color-accent-foreground: var(--accent-foreground);
26
26
  --color-destructive: var(--destructive);
27
+ --color-destructive-foreground: var(--destructive-foreground);
28
+ --color-foreground-subtle: var(--foreground-subtle);
27
29
  --color-danger: var(--danger);
28
30
  --color-danger-foreground: var(--danger-foreground);
29
31
  --color-surface: var(--surface);
@@ -97,8 +97,8 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
97
97
  );
98
98
  }
99
99
 
100
- function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
101
- items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
100
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
101
+ items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
102
102
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
103
103
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
104
104
  }) {
@@ -138,7 +138,10 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
138
138
  const result = await onResolveArticleLink(requestReceiptId, index);
139
139
  if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
140
140
  if (!("url" in result)) {
141
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved safely. The original link remains private and unchanged." }));
141
+ const reason = "reason" in result && typeof result.reason === "string"
142
+ && /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded)$/.test(result.reason)
143
+ ? ` (${result.reason})` : "";
144
+ setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
142
145
  return;
143
146
  }
144
147
  const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
@@ -181,7 +184,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
181
184
  if (result.error) {
182
185
  setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
183
186
  } else {
184
- setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
187
+ 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
188
  }
186
189
  } catch {
187
190
  if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
@@ -207,7 +210,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
207
210
  <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
211
  {item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
209
212
  {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>
213
+ {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
211
214
  </li>)}
212
215
  </ul> : <p>No email submissions are available for this publication.</p>}
213
216
  {detail && "submission" in detail ? (
@@ -238,7 +241,9 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
238
241
  {busyImport ? "Adding References…" : "Add selected links to References"}
239
242
  </button>
240
243
  {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}
244
+ {importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
245
+ <li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
246
+ ))}</ul> : null}
242
247
  </section>
243
248
  ) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
244
249
  : detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
@@ -297,7 +302,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
297
302
  <strong>{feed.id}</strong>
298
303
  <span>{feedHost(feed.sourceUrl)}</span>
299
304
  </div>
300
- <span>{feed.enabled ? "Enabled" : "Disabled"} &middot; {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
305
+ <span>{feed.enabled ? "Enabled" : "Disabled"} &middot; {everyText(feed.intervalSeconds)}</span>
301
306
  <span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} &middot; next {formatUtc(feed.nextDueAt)}</span>
302
307
  </li>
303
308
  ))}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
4
5
  import {
5
6
  importMonitoringEmailCandidates,
6
7
  loadMonitoringEmailSubmission,
@@ -9,12 +10,41 @@ import {
9
10
  } from "../lib/monitoring-client";
10
11
  import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
11
12
  import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
12
- import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
13
+ import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
13
14
 
14
15
  const REFRESH_MILLISECONDS = 60_000;
15
16
 
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 }) {
17
+ /** A receipt URL mounts only the single-submission reader, never the overview. */
18
+ export function MonitoringPanel(props: { isAdmin: boolean; isDemo: boolean }) {
19
+ const searchParams = useSearchParams();
20
+ if (searchParams.has("receipt")) {
21
+ return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
22
+ }
23
+ return <MonitoringOverviewPanel {...props} />;
24
+ }
25
+
26
+ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
27
+ const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
28
+ useEffect(() => {
29
+ if (!receiptId || isDemo) return;
30
+ let live = true;
31
+ void loadMonitoringEmailSubmission(receiptId).then(
32
+ (next) => { if (live) setDetail(next); },
33
+ () => { if (live) setDetail({ state: "unavailable" }); },
34
+ );
35
+ return () => { live = false; };
36
+ }, [receiptId, isDemo]);
37
+ if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
38
+ if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
39
+ return <div data-news-desk-admin-panel="monitoring">
40
+ {detail === null ? <p role="status">Loading email submission…</p> :
41
+ <EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
42
+ onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates} />}
43
+ </div>;
44
+ }
45
+
46
+ /** Overview refreshes apply only when no receipt was requested. */
47
+ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
18
48
  const [mode, setMode] = useState<MonitoringMode>("publication");
19
49
  const [result, setResult] = useState<MonitoringResult | null>(null);
20
50
  const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
@@ -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
+ }
@@ -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
 
@@ -33,6 +33,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
33
33
  .organizer-account-popup{min-width:250px;max-width:calc(100vw - 32px);border:1px solid var(--org-line);padding:6px;border-radius:4px}
34
34
  .organizer-account-popup [role=menuitem]{min-height:40px;font-size:12px}.organizer-account-popup .reader-auth{display:contents}.organizer-account-popup .reader-auth__error{display:block;white-space:normal;font-size:11px;padding:8px}
35
35
  .organizer-bottom-nav{display:none}
36
+ .organizer-bottom-nav button{appearance:none;border:0;background:transparent;color:inherit}
36
37
  .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
37
38
  .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:8px}
38
39
  .organizer-board-view-toggle{display:flex;gap:4px;flex-shrink:0}