@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,1912 @@
1
+ "use client";
2
+
3
+ import type { BotAvatarState } from "anthus-vultus";
4
+ import dynamic from "next/dynamic";
5
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
+ import { CheckIcon, UserIcon } from "lucide-react";
7
+ import { Md5 } from "@smithy/md5-js";
8
+ import { gsap } from "gsap";
9
+ import {
10
+ createConsoleMessage,
11
+ createConsoleThread,
12
+ listConsoleMessagesByThread,
13
+ listConsoleThreads,
14
+ subscribeConsoleMessages,
15
+ updateConsoleMessage,
16
+ updateConsoleThread,
17
+ type ConsoleChatMessage,
18
+ type ConsoleChatThread,
19
+ } from "../lib/console-chat-client";
20
+ import { buildNewsroomKnowledgeQueryInput, type NewsroomKnowledgeQueryTarget } from "../lib/newsroom-knowledge-query-request";
21
+ import {
22
+ CONSOLE_WEB_CONTEXT_MESSAGE_KIND,
23
+ formatConsoleWebContextMessage,
24
+ readConsoleWebUiFromMessageMetadata,
25
+ webUiContextKey,
26
+ type ConsoleWebContextEvent,
27
+ } from "../lib/console-web-context";
28
+ import { buildWebUiContext, extractNavigationIntent, type PapyrusWebUiContext } from "../lib/papyrus-web-locations";
29
+ import { runNewsroomKnowledgeQuery } from "./news-desk-taxonomy-client";
30
+ import { loadReaderSessionSnapshot, type ReaderSessionSnapshot } from "./reader-auth-state";
31
+ import {
32
+ readLocalReaderSettings,
33
+ resolveReaderSettings,
34
+ subscribeReaderSettingsChanges,
35
+ type ReaderMotionSetting,
36
+ } from "./reader-settings";
37
+ import { Conversation, ConversationContent, ConversationScrollButton } from "./ai-elements/conversation";
38
+ import { ModelSelector } from "./ai-elements/model-selector";
39
+ import { PromptInput, PromptInputBody, PromptInputFooter, type PromptInputMessage, PromptInputSubmit, PromptInputTextarea, PromptInputTools } from "./ai-elements/prompt-input";
40
+ import { Shimmer } from "./ai-elements/shimmer";
41
+ import { Suggestion, Suggestions } from "./ai-elements/suggestion";
42
+ import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "./ai-elements/tool";
43
+ import { CONNECTION_STATE_CHANGE, ConnectionState } from "aws-amplify/data";
44
+ import { Hub } from "aws-amplify/utils";
45
+ import {
46
+ type ComponentType,
47
+ createContext,
48
+ type ReactNode,
49
+ useCallback,
50
+ useContext,
51
+ useEffect,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ } from "react";
56
+ import { useStickToBottomContext } from "use-stick-to-bottom";
57
+
58
+ const CONSOLE_STARTER_SUGGESTIONS = [
59
+ "Start a research assignment",
60
+ "Start a reporting assignment",
61
+ "Discuss a reference",
62
+ ] as const;
63
+
64
+ const CONSOLE_THREAD_ANCHOR_KEY = "site#papyrus";
65
+ const CONSOLE_NEWSROOM_FEED_KEY = "consoleChat";
66
+ const CONSOLE_MESSAGE_KIND = "console_chat_turn";
67
+ const CONSOLE_TOOL_CALL_KIND = "console_tool_call";
68
+ const CONSOLE_TOOL_RESULT_KIND = "console_tool_result";
69
+ const CONSOLE_MESSAGE_DOMAIN = "conversation";
70
+ const CONSOLE_SEMANTIC_LAYER = "chat_detail";
71
+ const CONSOLE_SEARCH_VISIBILITY = "explicit";
72
+ const CONSOLE_RESPONSE_TARGET = process.env.NEXT_PUBLIC_PAPYRUS_CONSOLE_RESPONSE_TARGET?.trim() || "cloud";
73
+ const DEFAULT_CONSOLE_MODEL = process.env.NEXT_PUBLIC_PAPYRUS_CONSOLE_MODEL?.trim() || "gpt-5.4-mini";
74
+ const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
75
+ const ASSISTANT_AVATAR_SIZE_PX = 32;
76
+ const ASSISTANT_AVATAR_DEFAULT_SHADOW_COLOR = "rgb(var(--ink-solid-rgb) / var(--ink-display-alpha))";
77
+ const ASSISTANT_AVATAR_DEFAULT_LIGHT_COLOR = "rgb(var(--paper-rgb) / 1)";
78
+ const ASSISTANT_AVATAR_ERROR_SHADOW_COLOR = ASSISTANT_AVATAR_DEFAULT_SHADOW_COLOR;
79
+ const ASSISTANT_AVATAR_ERROR_LIGHT_COLOR = ASSISTANT_AVATAR_DEFAULT_LIGHT_COLOR;
80
+ type VultusBotAvatarProps = {
81
+ ariaLabel?: string;
82
+ lightColor?: string;
83
+ neutralIdleMode?: "bored-random" | "static";
84
+ shadowColor?: string;
85
+ size?: number;
86
+ state?: BotAvatarState;
87
+ transitionDurationSeconds?: number;
88
+ };
89
+ const VULTUS_BOT_AVATAR_FALLBACK: ComponentType<VultusBotAvatarProps> = () => null;
90
+ const VultusBotAvatar = dynamic<VultusBotAvatarProps>(
91
+ () => (
92
+ import("anthus-vultus")
93
+ .then((module) => module.BotAvatar as ComponentType<VultusBotAvatarProps>)
94
+ .catch((error) => {
95
+ console.error("[PapyrusConsole] Failed to load anthus-vultus BotAvatar", error);
96
+ return VULTUS_BOT_AVATAR_FALLBACK;
97
+ })
98
+ ),
99
+ { ssr: false },
100
+ );
101
+ const CONSOLE_MODEL_OPTIONS = [
102
+ { value: "gpt-5.5", label: "GPT 5.5", provider: "openai" },
103
+ { value: "gpt-5.4", label: "GPT 5.4", provider: "openai" },
104
+ { value: "gpt-5.4-mini", label: "GPT 5.4 Mini", provider: "openai" },
105
+ { value: "gpt-5.4-nano", label: "GPT 5.4 Nano", provider: "openai" },
106
+ ] as const;
107
+
108
+ type PapyrusConsoleShellProps = {
109
+ children: ReactNode;
110
+ };
111
+
112
+ type PapyrusConsoleContextValue = {
113
+ open: boolean;
114
+ openingReferenceChat: boolean;
115
+ openConsole: () => void;
116
+ shouldOfferConsole: boolean;
117
+ startReferenceChat: (target: NewsroomKnowledgeQueryTarget) => Promise<void>;
118
+ toggleOpen: () => void;
119
+ };
120
+
121
+ type DisplayConsoleMessage = ConsoleChatMessage & {
122
+ pendingPlaceholder?: boolean;
123
+ };
124
+
125
+ type ConsoleToolTimelineEntry = {
126
+ id: string;
127
+ firstSequence: number;
128
+ firstCreatedAt: string;
129
+ messageKind: string;
130
+ role: "TOOL";
131
+ toolCallId: string;
132
+ toolName: string;
133
+ callMessage?: DisplayConsoleMessage;
134
+ resultMessage?: DisplayConsoleMessage;
135
+ };
136
+
137
+ type ConsoleTimelineEntry =
138
+ | { kind: "message"; id: string; message: DisplayConsoleMessage }
139
+ | { kind: "tool"; id: string; tool: ConsoleToolTimelineEntry };
140
+
141
+ type AssistantAvatarTone = "default" | "error";
142
+ type ConsoleAuthorIdentity = {
143
+ avatarHash: string | null;
144
+ email: string | null;
145
+ userProfileId: string | null;
146
+ };
147
+
148
+ const PapyrusConsoleContext = createContext<PapyrusConsoleContextValue | null>(null);
149
+
150
+ export function PapyrusConsoleShell({ children }: PapyrusConsoleShellProps) {
151
+ const pathname = usePathname();
152
+ const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
153
+ const [open, setOpen] = useState(false);
154
+ const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
+ const isNewsroomPath = pathname === "/newsroom" || pathname.startsWith("/newsroom/");
156
+ const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
157
+ const shouldOfferConsole = isNewsroomPath && canUseConsole;
158
+
159
+ useEffect(() => {
160
+ let active = true;
161
+ void loadReaderSessionSnapshot()
162
+ .then((snapshot) => {
163
+ if (active) setSession(snapshot);
164
+ })
165
+ .catch(() => {
166
+ if (active) setSession({ auth: { status: "signedOut", label: "Signed out" }, groups: [], hasSession: false });
167
+ });
168
+ try {
169
+ setOpen(window.localStorage.getItem("papyrus:console-open") === "true");
170
+ } catch {
171
+ setOpen(false);
172
+ }
173
+ return () => {
174
+ active = false;
175
+ };
176
+ }, []);
177
+
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
+ const startReferenceChat = useCallback(async (target: NewsroomKnowledgeQueryTarget) => {
200
+ if (!canUseConsole) {
201
+ throw new Error("Chat is only available to editor/admin sessions.");
202
+ }
203
+ if (openingReferenceChat) return;
204
+ setOpeningReferenceChat(true);
205
+ try {
206
+ openConsole();
207
+ const now = new Date().toISOString();
208
+ const title = target.title?.trim() || "Reference";
209
+ const summary = `Reference chat for ${title}`.slice(0, 180);
210
+ const lineageId = target.anchor.lineageId ?? target.anchor.id;
211
+ const thread = await createConsoleThread({
212
+ id: `message-thread-console-${crypto.randomUUID()}`,
213
+ threadKind: "console",
214
+ status: "active",
215
+ title: "Reference Chat",
216
+ summary,
217
+ primaryAnchorKind: "site",
218
+ primaryAnchorId: "papyrus",
219
+ primaryAnchorLineageId: "papyrus",
220
+ primaryAnchorKey: CONSOLE_THREAD_ANCHOR_KEY,
221
+ createdByLabel: session?.auth.label ?? "Papyrus editor",
222
+ messageCount: 0,
223
+ metadata: toAwsJson({
224
+ console: {
225
+ cache: "lambda-tmp-jit",
226
+ model: DEFAULT_CONSOLE_MODEL,
227
+ rawChatSearchVisibility: CONSOLE_SEARCH_VISIBILITY,
228
+ },
229
+ bootstrap: {
230
+ mode: "reference_chat",
231
+ anchor: target.anchor,
232
+ },
233
+ }),
234
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
235
+ createdAt: now,
236
+ updatedAt: now,
237
+ });
238
+ if (typeof window !== "undefined") {
239
+ window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }));
240
+ }
241
+ const pendingMessage = await createConsoleMessage({
242
+ id: `message-console-assistant-${crypto.randomUUID()}`,
243
+ threadId: thread.id,
244
+ parentMessageId: null,
245
+ sequenceNumber: 1,
246
+ role: "ASSISTANT",
247
+ messageKind: CONSOLE_MESSAGE_KIND,
248
+ messageType: "MESSAGE",
249
+ content: "",
250
+ summary: "Thinking...",
251
+ messageDomain: CONSOLE_MESSAGE_DOMAIN,
252
+ status: "active",
253
+ source: "papyrus-console",
254
+ authorLabel: "Papyrus",
255
+ semanticLayer: CONSOLE_SEMANTIC_LAYER,
256
+ searchVisibility: CONSOLE_SEARCH_VISIBILITY,
257
+ responseTarget: CONSOLE_RESPONSE_TARGET,
258
+ responseStatus: "RUNNING",
259
+ responseStartedAt: now,
260
+ metadata: toAwsJson({
261
+ model: DEFAULT_CONSOLE_MODEL,
262
+ bootstrap: "reference-chat",
263
+ anchor: target.anchor,
264
+ }),
265
+ createdAt: now,
266
+ updatedAt: now,
267
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
268
+ });
269
+ await updateConsoleThread({
270
+ id: thread.id,
271
+ messageCount: 1,
272
+ lastMessageId: pendingMessage.id,
273
+ lastMessageAt: now,
274
+ activeResponseMessageId: pendingMessage.id,
275
+ updatedAt: now,
276
+ });
277
+
278
+ const tokenBudget = 1600;
279
+ const request = buildNewsroomKnowledgeQueryInput(target, "", tokenBudget);
280
+ const completedAt = new Date().toISOString();
281
+ let markdown: string;
282
+ try {
283
+ const result = await withTimeout(
284
+ runNewsroomKnowledgeQuery(request),
285
+ 45_000,
286
+ "Reference context preload timed out.",
287
+ );
288
+ markdown = result.context?.text?.trim() ?? "";
289
+ if (!markdown) throw new Error("knowledgeQuery returned no markdown context.");
290
+ } catch (error) {
291
+ const message = error instanceof Error ? error.message : "Unknown error";
292
+ markdown = `Unable to preload reference context.\n\n${message}`;
293
+ }
294
+ let completedMessage: ConsoleChatMessage;
295
+ try {
296
+ completedMessage = await updateConsoleMessage({
297
+ id: pendingMessage.id,
298
+ content: markdown,
299
+ summary: truncateConsoleSummary(markdown),
300
+ responseStatus: "COMPLETED",
301
+ responseCompletedAt: completedAt,
302
+ updatedAt: completedAt,
303
+ });
304
+ } catch {
305
+ completedMessage = await createConsoleMessage({
306
+ id: `message-console-assistant-${crypto.randomUUID()}`,
307
+ threadId: thread.id,
308
+ parentMessageId: pendingMessage.id,
309
+ sequenceNumber: 2,
310
+ role: "ASSISTANT",
311
+ messageKind: CONSOLE_MESSAGE_KIND,
312
+ messageType: "MESSAGE",
313
+ content: markdown,
314
+ summary: truncateConsoleSummary(markdown),
315
+ messageDomain: CONSOLE_MESSAGE_DOMAIN,
316
+ status: "active",
317
+ source: "papyrus-console",
318
+ authorLabel: "Papyrus",
319
+ semanticLayer: CONSOLE_SEMANTIC_LAYER,
320
+ searchVisibility: CONSOLE_SEARCH_VISIBILITY,
321
+ responseTarget: CONSOLE_RESPONSE_TARGET,
322
+ responseStatus: "COMPLETED",
323
+ responseCompletedAt: completedAt,
324
+ metadata: toAwsJson({
325
+ model: DEFAULT_CONSOLE_MODEL,
326
+ bootstrap: "reference-chat",
327
+ anchor: target.anchor,
328
+ }),
329
+ createdAt: completedAt,
330
+ updatedAt: completedAt,
331
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
332
+ });
333
+ }
334
+ await updateConsoleThread({
335
+ id: thread.id,
336
+ messageCount: 1,
337
+ lastMessageId: completedMessage.id,
338
+ lastMessageAt: completedAt,
339
+ activeResponseMessageId: null,
340
+ updatedAt: completedAt,
341
+ });
342
+ if (typeof window !== "undefined") {
343
+ window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }));
344
+ }
345
+ } finally {
346
+ setOpeningReferenceChat(false);
347
+ }
348
+ }, [canUseConsole, openConsole, openingReferenceChat, session?.auth.label]);
349
+
350
+ const consoleContext = useMemo<PapyrusConsoleContextValue>(() => ({
351
+ open,
352
+ openingReferenceChat,
353
+ openConsole,
354
+ shouldOfferConsole,
355
+ startReferenceChat,
356
+ toggleOpen,
357
+ }), [open, openConsole, openingReferenceChat, shouldOfferConsole, startReferenceChat, toggleOpen]);
358
+
359
+ return (
360
+ <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
+ ? <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={toggleOpen} />
369
+ : <ConsoleAccessPanel onClose={toggleOpen} session={session} />
370
+ ) : null}
371
+ </aside>
372
+ ) : null}
373
+ </PapyrusConsoleContext.Provider>
374
+ );
375
+ }
376
+
377
+ export function NewsroomConsoleProgressToggle() {
378
+ const consoleContext = usePapyrusConsole();
379
+ if (!consoleContext?.shouldOfferConsole || consoleContext.open) return null;
380
+
381
+ return (
382
+ <button
383
+ type="button"
384
+ className="edition-progress__button edition-progress__button--next edition-progress__button--console"
385
+ aria-expanded={false}
386
+ aria-label="Open console"
387
+ onClick={consoleContext.toggleOpen}
388
+ title="Open console"
389
+ >
390
+ <PapyrusConsoleChatIcon />
391
+ </button>
392
+ );
393
+ }
394
+
395
+ export function usePapyrusConsole(): PapyrusConsoleContextValue | null {
396
+ return useContext(PapyrusConsoleContext);
397
+ }
398
+
399
+ function ConsoleAccessPanel({ onClose, session }: { onClose: () => void; session: ReaderSessionSnapshot | null }) {
400
+ return (
401
+ <div className="papyrus-console__panel papyrus-console__panel--access">
402
+ <ConsolePanelHeader onClose={onClose} />
403
+ <div className="papyrus-console__empty">
404
+ <strong>Checking Newsroom credentials.</strong>
405
+ <span>
406
+ The Newsroom is visible, but this console is waiting for the client session groups.
407
+ Refresh after the current deployment finishes if this does not resolve.
408
+ </span>
409
+ <span>Groups: {session?.groups.length ? session.groups.join(", ") : "none loaded"}</span>
410
+ </div>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ function ConsolePanel({ actorEmail, actorLabel, onClose }: { actorEmail: string | null; actorLabel: string; onClose: () => void }) {
416
+ const router = useRouter();
417
+ const pathname = usePathname();
418
+ const searchParams = useSearchParams();
419
+ const [thread, setThread] = useState<ConsoleChatThread | null>(null);
420
+ const [threads, setThreads] = useState<ConsoleChatThread[]>([]);
421
+ const [messages, setMessages] = useState<ConsoleChatMessage[]>([]);
422
+ const [draft, setDraft] = useState("");
423
+ const [loading, setLoading] = useState(false);
424
+ const [sending, setSending] = useState(false);
425
+ const [error, setError] = useState<string | null>(null);
426
+ const [selectedModel, setSelectedModel] = useState(DEFAULT_CONSOLE_MODEL);
427
+ const [motionSetting, setMotionSetting] = useState<ReaderMotionSetting>("standard");
428
+ const [authorIdentity, setAuthorIdentity] = useState<ConsoleAuthorIdentity>({ avatarHash: null, email: null, userProfileId: null });
429
+ const [fallbackAvatarHash, setFallbackAvatarHash] = useState<string | null>(null);
430
+ const hasLoadedRef = useRef(false);
431
+ const activeThreadIdRef = useRef<string | null>(null);
432
+ const threadRef = useRef<ConsoleChatThread | null>(null);
433
+ const handledNavigationMessageIdsRef = useRef<Set<string>>(new Set());
434
+
435
+ const currentWebPath = useMemo(() => {
436
+ const path = pathname ?? "/newsroom";
437
+ const query = searchParams.toString();
438
+ return query ? `${path}?${query}` : path;
439
+ }, [pathname, searchParams]);
440
+
441
+ const currentWebUi = useMemo(() => buildWebUiContext(currentWebPath), [currentWebPath]);
442
+ const currentWebUiKey = useMemo(() => webUiContextKey(currentWebUi), [currentWebUi]);
443
+ const recordingContextRef = useRef<string | null>(null);
444
+ const [hydratedThreadId, setHydratedThreadId] = useState<string | null>(null);
445
+
446
+ useEffect(() => {
447
+ for (const message of messages) {
448
+ if (message.messageKind !== CONSOLE_TOOL_RESULT_KIND) continue;
449
+ if (handledNavigationMessageIdsRef.current.has(message.id)) continue;
450
+ const metadata = readAwsJsonObject(message.metadata);
451
+ const navigation = extractNavigationIntent(readNestedValue(metadata, ["toolResultJson"]));
452
+ if (!navigation) continue;
453
+ handledNavigationMessageIdsRef.current.add(message.id);
454
+ if (currentWebPath !== navigation.webPath) {
455
+ router.push(navigation.webPath);
456
+ }
457
+ }
458
+ }, [currentWebPath, messages, router]);
459
+
460
+ useEffect(() => {
461
+ setMotionSetting(readLocalReaderSettings().motion);
462
+ return subscribeReaderSettingsChanges((settings) => {
463
+ setMotionSetting(settings.motion);
464
+ });
465
+ }, []);
466
+
467
+ useEffect(() => {
468
+ let active = true;
469
+ void resolveReaderSettings()
470
+ .then((resolution) => {
471
+ if (!active) return;
472
+ setAuthorIdentity({
473
+ avatarHash: resolution.avatarHash ?? null,
474
+ email: resolution.email ?? actorEmail ?? null,
475
+ userProfileId: resolution.userProfileId ?? null,
476
+ });
477
+ })
478
+ .catch(() => {
479
+ if (!active) return;
480
+ setAuthorIdentity({ avatarHash: null, email: actorEmail ?? null, userProfileId: null });
481
+ });
482
+ return () => {
483
+ active = false;
484
+ };
485
+ }, [actorEmail]);
486
+
487
+ useEffect(() => {
488
+ let active = true;
489
+ if (!actorEmail) {
490
+ setFallbackAvatarHash(null);
491
+ return () => {
492
+ active = false;
493
+ };
494
+ }
495
+ void computeAvatarHashFromEmail(actorEmail)
496
+ .then((hash) => {
497
+ if (active) setFallbackAvatarHash(hash);
498
+ })
499
+ .catch(() => {
500
+ if (active) setFallbackAvatarHash(null);
501
+ });
502
+ return () => {
503
+ active = false;
504
+ };
505
+ }, [actorEmail]);
506
+
507
+ const effectiveAuthorIdentity = useMemo<ConsoleAuthorIdentity>(() => ({
508
+ avatarHash: authorIdentity.avatarHash ?? fallbackAvatarHash,
509
+ email: authorIdentity.email ?? actorEmail ?? null,
510
+ userProfileId: authorIdentity.userProfileId,
511
+ }), [actorEmail, authorIdentity.avatarHash, authorIdentity.email, authorIdentity.userProfileId, fallbackAvatarHash]);
512
+
513
+ const sortedMessages = useMemo(() => (
514
+ [...messages].sort((left, right) => (left.sequenceNumber ?? 0) - (right.sequenceNumber ?? 0) || left.createdAt.localeCompare(right.createdAt))
515
+ ), [messages]);
516
+
517
+ const mergeMessage = useCallback((message: ConsoleChatMessage) => {
518
+ setMessages((current) => upsertConsoleMessage(current, message));
519
+ }, []);
520
+
521
+ const appendConsoleWebContextMessage = useCallback(async (
522
+ activeThread: ConsoleChatThread,
523
+ webUi: PapyrusWebUiContext,
524
+ event: ConsoleWebContextEvent,
525
+ ) => {
526
+ const { visibleContent, agentInstructions, summary } = formatConsoleWebContextMessage(webUi, event);
527
+ const sequenceNumber = (activeThread.messageCount ?? 0) + 1;
528
+ const now = new Date().toISOString();
529
+ const messageMetadata = {
530
+ console: {
531
+ webUi,
532
+ contextEvent: event,
533
+ agentInstructions,
534
+ },
535
+ };
536
+ const message: ConsoleChatMessage = {
537
+ id: `message-console-context-${crypto.randomUUID()}`,
538
+ threadId: activeThread.id,
539
+ parentMessageId: activeThread.lastMessageId ?? null,
540
+ sequenceNumber,
541
+ role: "SYSTEM",
542
+ messageKind: CONSOLE_WEB_CONTEXT_MESSAGE_KIND,
543
+ messageType: "MESSAGE",
544
+ content: visibleContent,
545
+ summary,
546
+ responseStatus: "COMPLETED",
547
+ responseCompletedAt: now,
548
+ metadata: messageMetadata,
549
+ createdAt: now,
550
+ };
551
+ mergeMessage(message);
552
+ const persisted = await createConsoleMessage({
553
+ ...message,
554
+ messageDomain: CONSOLE_MESSAGE_DOMAIN,
555
+ status: "active",
556
+ source: "papyrus-console",
557
+ authorLabel: actorLabel,
558
+ semanticLayer: CONSOLE_SEMANTIC_LAYER,
559
+ searchVisibility: CONSOLE_SEARCH_VISIBILITY,
560
+ responseTarget: CONSOLE_RESPONSE_TARGET,
561
+ metadata: toAwsJson(messageMetadata),
562
+ updatedAt: now,
563
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
564
+ });
565
+ mergeMessage({ ...message, ...persisted });
566
+ await updateConsoleThread({
567
+ id: activeThread.id,
568
+ messageCount: sequenceNumber,
569
+ lastMessageId: persisted.id,
570
+ lastMessageAt: now,
571
+ updatedAt: now,
572
+ });
573
+ setThread((current) => current?.id === activeThread.id ? {
574
+ ...current,
575
+ messageCount: sequenceNumber,
576
+ lastMessageId: persisted.id,
577
+ lastMessageAt: now,
578
+ updatedAt: now,
579
+ } : current);
580
+ recordingContextRef.current = webUiContextKey(webUi);
581
+ return persisted;
582
+ }, [actorLabel, mergeMessage]);
583
+
584
+ const recordWebContextIfNeeded = useCallback(async (
585
+ activeThread: ConsoleChatThread,
586
+ webUi: PapyrusWebUiContext,
587
+ event: ConsoleWebContextEvent,
588
+ ) => {
589
+ const nextKey = webUiContextKey(webUi);
590
+ if (!nextKey || recordingContextRef.current === nextKey) return;
591
+ const lastContextMessage = [...sortedMessages].reverse().find((entry) => entry.messageKind === CONSOLE_WEB_CONTEXT_MESSAGE_KIND);
592
+ const lastRecorded = lastContextMessage
593
+ ? readConsoleWebUiFromMessageMetadata(readAwsJsonObject(lastContextMessage.metadata))
594
+ : null;
595
+ if (lastRecorded && webUiContextKey(lastRecorded) === nextKey) {
596
+ recordingContextRef.current = nextKey;
597
+ return;
598
+ }
599
+ await appendConsoleWebContextMessage(activeThread, webUi, event);
600
+ }, [appendConsoleWebContextMessage, sortedMessages]);
601
+
602
+ useEffect(() => {
603
+ threadRef.current = thread;
604
+ }, [thread]);
605
+
606
+ useEffect(() => {
607
+ recordingContextRef.current = null;
608
+ }, [thread?.id]);
609
+
610
+ useEffect(() => {
611
+ if (!thread?.id || hydratedThreadId !== thread.id || !currentWebUi.papyrusLocationUri) return;
612
+ const hasPriorContext = sortedMessages.some((entry) => entry.messageKind === CONSOLE_WEB_CONTEXT_MESSAGE_KIND);
613
+ void recordWebContextIfNeeded(
614
+ thread,
615
+ currentWebUi,
616
+ hasPriorContext ? "navigation" : "session_start",
617
+ ).catch((error) => {
618
+ console.error("[PapyrusConsole] Unable to record web UI context", error);
619
+ });
620
+ }, [currentWebUi, currentWebUiKey, hydratedThreadId, recordWebContextIfNeeded, sortedMessages, thread]);
621
+
622
+ const loadMessages = useCallback(async (threadId: string) => {
623
+ setHydratedThreadId(null);
624
+ const nextMessages = await listConsoleMessagesByThread(threadId);
625
+ setMessages(nextMessages);
626
+ setHydratedThreadId(threadId);
627
+ }, []);
628
+
629
+ const loadThreads = useCallback(async () => {
630
+ return listConsoleThreads(CONSOLE_THREAD_ANCHOR_KEY, 25);
631
+ }, []);
632
+
633
+ const resolveActiveThread = useCallback((
634
+ recentThreads: ConsoleChatThread[],
635
+ currentThread: ConsoleChatThread | null,
636
+ ): ConsoleChatThread | null => {
637
+ const preferredId = activeThreadIdRef.current ?? currentThread?.id ?? null;
638
+ if (preferredId) {
639
+ const match = recentThreads.find((entry) => entry.id === preferredId);
640
+ if (match) return match;
641
+ if (currentThread?.id === preferredId) return currentThread;
642
+ }
643
+ const fallback = recentThreads[0] ?? null;
644
+ if (fallback) activeThreadIdRef.current = fallback.id;
645
+ return fallback;
646
+ }, []);
647
+
648
+ const loadThread = useCallback(async () => {
649
+ const initialLoad = !hasLoadedRef.current;
650
+ if (initialLoad) setLoading(true);
651
+ setError(null);
652
+ try {
653
+ const recentThreads = await loadThreads();
654
+ const nextThread = resolveActiveThread(recentThreads, threadRef.current);
655
+ setThreads(recentThreads);
656
+ setThread(nextThread);
657
+ setSelectedModel(resolveThreadModel(nextThread));
658
+ if (nextThread) {
659
+ await loadMessages(nextThread.id);
660
+ } else {
661
+ setMessages([]);
662
+ }
663
+ } catch (nextError) {
664
+ const normalizedError = formatUnknownConsoleError(nextError);
665
+ console.error("[PapyrusConsole] Unable to load console thread", normalizedError, nextError);
666
+ if (initialLoad) setError(normalizedError);
667
+ } finally {
668
+ hasLoadedRef.current = true;
669
+ if (initialLoad) setLoading(false);
670
+ }
671
+ }, [loadMessages, loadThreads, resolveActiveThread]);
672
+
673
+ useEffect(() => {
674
+ void loadThread();
675
+ }, [loadThread]);
676
+
677
+ useEffect(() => {
678
+ if (!thread?.id) return;
679
+ const activeThreadId = thread.id;
680
+ const unsubscribe = subscribeConsoleMessages(
681
+ activeThreadId,
682
+ (message) => {
683
+ if (message.threadId && message.threadId !== activeThreadIdRef.current) return;
684
+ mergeMessage(message);
685
+ },
686
+ (nextError) => {
687
+ console.warn("[PapyrusConsole] Console message subscription failed", nextError);
688
+ },
689
+ );
690
+ return unsubscribe;
691
+ }, [mergeMessage, thread?.id]);
692
+
693
+ useEffect(() => {
694
+ if (typeof window === "undefined") return;
695
+ const onSelectThread = (event: Event) => {
696
+ const detail = (event as CustomEvent<{ threadId?: string }>).detail;
697
+ const threadId = detail?.threadId?.trim();
698
+ if (!threadId) return;
699
+ activeThreadIdRef.current = threadId;
700
+ const currentMatch = threads.find((entry) => entry.id === threadId) ?? null;
701
+ if (currentMatch) {
702
+ setThread(currentMatch);
703
+ setSelectedModel(resolveThreadModel(currentMatch));
704
+ void loadMessages(threadId);
705
+ return;
706
+ }
707
+ void loadThread();
708
+ };
709
+ window.addEventListener(CONSOLE_SELECT_THREAD_EVENT, onSelectThread as EventListener);
710
+ return () => {
711
+ window.removeEventListener(CONSOLE_SELECT_THREAD_EVENT, onSelectThread as EventListener);
712
+ };
713
+ }, [loadMessages, loadThread, threads]);
714
+
715
+ useEffect(() => {
716
+ let previousState: ConnectionState | null = null;
717
+ const unsubscribe = Hub.listen("api", ({ payload }) => {
718
+ if (payload.event !== CONNECTION_STATE_CHANGE) return;
719
+ const data = payload.data as { connectionState?: ConnectionState } | undefined;
720
+ const nextState = data?.connectionState;
721
+ if (previousState && previousState !== ConnectionState.Connected && nextState === ConnectionState.Connected) {
722
+ const activeThreadId = activeThreadIdRef.current;
723
+ if (activeThreadId) void loadMessages(activeThreadId);
724
+ }
725
+ previousState = nextState ?? previousState;
726
+ });
727
+ return () => unsubscribe();
728
+ }, [loadMessages]);
729
+
730
+ const selectThread = useCallback((threadId: string) => {
731
+ activeThreadIdRef.current = threadId;
732
+ const nextThread = threads.find((entry) => entry.id === threadId) ?? null;
733
+ setThread(nextThread);
734
+ setSelectedModel(resolveThreadModel(nextThread));
735
+ setMessages([]);
736
+ setHydratedThreadId(null);
737
+ if (nextThread) void loadMessages(nextThread.id);
738
+ }, [loadMessages, threads]);
739
+
740
+ async function createNewThread(): Promise<ConsoleChatThread> {
741
+ const now = new Date().toISOString();
742
+ const nextThread: ConsoleChatThread = {
743
+ id: `message-thread-console-${crypto.randomUUID()}`,
744
+ threadKind: "console",
745
+ status: "active",
746
+ title: "Papyrus Console",
747
+ summary: "General editor console thread for Papyrus.",
748
+ primaryAnchorKind: "site",
749
+ primaryAnchorId: "papyrus",
750
+ primaryAnchorLineageId: "papyrus",
751
+ primaryAnchorKey: CONSOLE_THREAD_ANCHOR_KEY,
752
+ createdByLabel: actorLabel,
753
+ messageCount: 0,
754
+ metadata: toAwsJson({
755
+ console: {
756
+ cache: "lambda-tmp-jit",
757
+ model: selectedModel,
758
+ rawChatSearchVisibility: CONSOLE_SEARCH_VISIBILITY,
759
+ },
760
+ }),
761
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
762
+ createdAt: now,
763
+ updatedAt: now,
764
+ };
765
+ activeThreadIdRef.current = nextThread.id;
766
+ setThread(nextThread);
767
+ setThreads((current) => [nextThread, ...current]);
768
+ setMessages([]);
769
+ setHydratedThreadId(null);
770
+ const created = await createConsoleThread(nextThread);
771
+ activeThreadIdRef.current = created.id;
772
+ setThread(created);
773
+ setSelectedModel(resolveThreadModel(created));
774
+ setThreads((current) => [created, ...current.filter((entry) => entry.id !== created.id)]);
775
+ setHydratedThreadId(created.id);
776
+ if (currentWebUi.papyrusLocationUri) {
777
+ await recordWebContextIfNeeded(created, currentWebUi, "session_start");
778
+ }
779
+ return created;
780
+ }
781
+
782
+ async function ensureThread(): Promise<ConsoleChatThread> {
783
+ return thread ?? createNewThread();
784
+ }
785
+
786
+ async function submitMessage(content: string) {
787
+ if (!content || sending) return;
788
+ setSending(true);
789
+ setError(null);
790
+ try {
791
+ const activeThread = await ensureThread();
792
+ const previousMessage = sortedMessages[sortedMessages.length - 1] ?? null;
793
+ const previousSequenceNumber = previousMessage?.sequenceNumber ?? activeThread.messageCount ?? 0;
794
+ const sequenceNumber = previousSequenceNumber + 1;
795
+ const now = new Date().toISOString();
796
+ const messageMetadata = {
797
+ model: selectedModel,
798
+ previousSequenceNumber,
799
+ previousMessageId: previousMessage?.id ?? null,
800
+ previousContextDigest: activeThread.contextDigest ?? null,
801
+ cacheHint: "lambda-tmp-jit",
802
+ console: {
803
+ author: {
804
+ avatarHash: effectiveAuthorIdentity.avatarHash,
805
+ email: effectiveAuthorIdentity.email,
806
+ userProfileId: effectiveAuthorIdentity.userProfileId,
807
+ },
808
+ webUi: currentWebUi,
809
+ },
810
+ };
811
+ const message: ConsoleChatMessage = {
812
+ id: `message-console-user-${crypto.randomUUID()}`,
813
+ threadId: activeThread.id,
814
+ parentMessageId: previousMessage?.id ?? null,
815
+ sequenceNumber,
816
+ role: "USER",
817
+ messageKind: CONSOLE_MESSAGE_KIND,
818
+ messageType: "MESSAGE",
819
+ content,
820
+ summary: truncateConsoleSummary(content),
821
+ responseStatus: "PENDING",
822
+ authorUserProfileId: effectiveAuthorIdentity.userProfileId,
823
+ metadata: messageMetadata,
824
+ createdAt: now,
825
+ };
826
+ mergeMessage(message);
827
+ const persistedMessage = await createConsoleMessage({
828
+ ...message,
829
+ messageDomain: CONSOLE_MESSAGE_DOMAIN,
830
+ status: "active",
831
+ source: "papyrus-console",
832
+ authorLabel: actorLabel,
833
+ authorUserProfileId: effectiveAuthorIdentity.userProfileId,
834
+ semanticLayer: CONSOLE_SEMANTIC_LAYER,
835
+ searchVisibility: CONSOLE_SEARCH_VISIBILITY,
836
+ responseTarget: CONSOLE_RESPONSE_TARGET,
837
+ metadata: toAwsJson(messageMetadata),
838
+ updatedAt: now,
839
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
840
+ });
841
+ mergeMessage({ ...message, ...persistedMessage });
842
+ await updateConsoleThread({
843
+ id: activeThread.id,
844
+ messageCount: sequenceNumber,
845
+ lastMessageId: message.id,
846
+ lastMessageAt: now,
847
+ updatedAt: now,
848
+ });
849
+ setThread((current) => current ? {
850
+ ...current,
851
+ messageCount: sequenceNumber,
852
+ lastMessageId: message.id,
853
+ lastMessageAt: now,
854
+ updatedAt: now,
855
+ } : current);
856
+ } catch (nextError) {
857
+ console.error("[PapyrusConsole] Unable to send console message", nextError);
858
+ setError(nextError instanceof Error ? nextError.message : "Unable to send console message.");
859
+ } finally {
860
+ setSending(false);
861
+ }
862
+ }
863
+
864
+ async function sendMessage(message: PromptInputMessage) {
865
+ const content = message.text.trim();
866
+ if (!content) return;
867
+ setDraft("");
868
+ await submitMessage(content);
869
+ }
870
+
871
+ function handleSuggestionClick(suggestion: string) {
872
+ if (sending) return;
873
+ void submitMessage(suggestion);
874
+ }
875
+
876
+ const handleModelChange = useCallback((nextModel: string) => {
877
+ setSelectedModel(nextModel);
878
+ const activeThread = threadRef.current;
879
+ if (!activeThread) return;
880
+ const metadata = readAwsJsonObject(activeThread.metadata) ?? {};
881
+ const nextMetadata = {
882
+ ...metadata,
883
+ console: {
884
+ ...(readNestedObject(metadata, "console") ?? {}),
885
+ model: nextModel,
886
+ },
887
+ };
888
+ const now = new Date().toISOString();
889
+ void updateConsoleThread({
890
+ id: activeThread.id,
891
+ metadata: toAwsJson(nextMetadata),
892
+ updatedAt: now,
893
+ })
894
+ .then((updated) => {
895
+ setThread(updated);
896
+ setThreads((current) => current.map((entry) => (
897
+ entry.id === updated.id ? updated : entry
898
+ )));
899
+ })
900
+ .catch((nextError) => {
901
+ console.error("[PapyrusConsole] Unable to update console thread model", nextError);
902
+ setError(nextError instanceof Error ? nextError.message : "Unable to update console model.");
903
+ });
904
+ }, []);
905
+
906
+ const latestAwaitingAssistantUserMessage = [...sortedMessages]
907
+ .reverse()
908
+ .find((message) => (
909
+ message.role === "USER"
910
+ && isAwaitingAssistantStatus(message.responseStatus)
911
+ && !sortedMessages.some((candidate) => (
912
+ candidate.role === "ASSISTANT"
913
+ && isResponderMessageForPendingUser(candidate, message)
914
+ ))
915
+ ));
916
+ const pendingUserResponse = Boolean(latestAwaitingAssistantUserMessage);
917
+ const pendingAssistantResponse = sortedMessages.some((message, index) => {
918
+ if (message.role !== "ASSISTANT" || message.responseStatus !== "RUNNING") return false;
919
+ return !sortedMessages.slice(index + 1).some((candidate) => (
920
+ candidate.role === "ASSISTANT"
921
+ && candidate.threadId === message.threadId
922
+ && candidate.responseStatus === "COMPLETED"
923
+ && (candidate.parentMessageId === message.id || (candidate.sequenceNumber ?? 0) > (message.sequenceNumber ?? 0))
924
+ ));
925
+ });
926
+ const pending = pendingUserResponse || pendingAssistantResponse;
927
+ useEffect(() => {
928
+ if (!thread?.id || !pending) return;
929
+ let cancelled = false;
930
+ let timer: number | null = null;
931
+
932
+ const refresh = async () => {
933
+ if (cancelled) return;
934
+ try {
935
+ await loadMessages(thread.id);
936
+ } catch (nextError) {
937
+ console.warn("[PapyrusConsole] Pending message refresh failed", nextError);
938
+ } finally {
939
+ if (!cancelled) timer = window.setTimeout(refresh, 1200);
940
+ }
941
+ };
942
+
943
+ timer = window.setTimeout(refresh, 1200);
944
+ return () => {
945
+ cancelled = true;
946
+ if (timer !== null) window.clearTimeout(timer);
947
+ };
948
+ }, [loadMessages, pending, thread?.id]);
949
+
950
+ const displayMessages = useMemo<DisplayConsoleMessage[]>(() => {
951
+ if (!latestAwaitingAssistantUserMessage) return sortedMessages;
952
+ return [
953
+ ...sortedMessages,
954
+ {
955
+ id: `pending-assistant-placeholder-${latestAwaitingAssistantUserMessage.id}`,
956
+ threadId: latestAwaitingAssistantUserMessage.threadId,
957
+ parentMessageId: latestAwaitingAssistantUserMessage.id,
958
+ sequenceNumber: (latestAwaitingAssistantUserMessage.sequenceNumber ?? sortedMessages.length) + 1,
959
+ role: "ASSISTANT",
960
+ messageKind: CONSOLE_MESSAGE_KIND,
961
+ messageType: "MESSAGE",
962
+ content: "",
963
+ summary: "Thinking...",
964
+ responseStatus: "RUNNING",
965
+ createdAt: latestAwaitingAssistantUserMessage.createdAt,
966
+ pendingPlaceholder: true,
967
+ },
968
+ ];
969
+ }, [latestAwaitingAssistantUserMessage, sortedMessages]);
970
+ const timelineEntries = useMemo<ConsoleTimelineEntry[]>(() => {
971
+ const groupedTools = new Map<string, ConsoleToolTimelineEntry>();
972
+ const entries: ConsoleTimelineEntry[] = [];
973
+
974
+ for (const message of displayMessages) {
975
+ const isToolMessage = message.messageKind === CONSOLE_TOOL_CALL_KIND || message.messageKind === CONSOLE_TOOL_RESULT_KIND;
976
+ if (!isToolMessage) {
977
+ entries.push({ kind: "message", id: message.id, message });
978
+ continue;
979
+ }
980
+ const metadata = readAwsJsonObject(message.metadata);
981
+ const toolCallId = readNestedString(metadata, ["toolCallId"]);
982
+ const toolName = readNestedString(metadata, ["toolName"]);
983
+ if (!toolCallId) {
984
+ entries.push({ kind: "message", id: message.id, message });
985
+ continue;
986
+ }
987
+
988
+ const existing = groupedTools.get(toolCallId);
989
+ if (!existing) {
990
+ const firstSequence = typeof message.sequenceNumber === "number" ? message.sequenceNumber : Number.MAX_SAFE_INTEGER;
991
+ const next: ConsoleToolTimelineEntry = {
992
+ id: `tool-timeline-${toolCallId}`,
993
+ firstSequence,
994
+ firstCreatedAt: message.createdAt,
995
+ messageKind: message.messageKind ?? CONSOLE_TOOL_CALL_KIND,
996
+ role: "TOOL",
997
+ toolCallId,
998
+ toolName: toolName ?? "execute_tactus",
999
+ callMessage: message.messageKind === CONSOLE_TOOL_CALL_KIND ? message : undefined,
1000
+ resultMessage: message.messageKind === CONSOLE_TOOL_RESULT_KIND ? message : undefined,
1001
+ };
1002
+ groupedTools.set(toolCallId, next);
1003
+ entries.push({ kind: "tool", id: next.id, tool: next });
1004
+ continue;
1005
+ }
1006
+
1007
+ if (!existing.toolName && toolName) existing.toolName = toolName;
1008
+ if (message.messageKind === CONSOLE_TOOL_CALL_KIND) existing.callMessage = message;
1009
+ if (message.messageKind === CONSOLE_TOOL_RESULT_KIND) existing.resultMessage = message;
1010
+ const sequence = typeof message.sequenceNumber === "number" ? message.sequenceNumber : Number.MAX_SAFE_INTEGER;
1011
+ if (sequence < existing.firstSequence || (sequence === existing.firstSequence && message.createdAt < existing.firstCreatedAt)) {
1012
+ existing.firstSequence = sequence;
1013
+ existing.firstCreatedAt = message.createdAt;
1014
+ }
1015
+ }
1016
+
1017
+ return entries;
1018
+ }, [displayMessages]);
1019
+ const isSupersededRunningAssistantMessage = useCallback((
1020
+ message: DisplayConsoleMessage,
1021
+ _trimmedContent: string,
1022
+ ) => (
1023
+ message.role === "ASSISTANT"
1024
+ && message.responseStatus === "RUNNING"
1025
+ && sortedMessages.some((candidate) => (
1026
+ candidate.role === "ASSISTANT"
1027
+ && candidate.threadId === message.threadId
1028
+ && candidate.responseStatus === "COMPLETED"
1029
+ && (candidate.parentMessageId === message.id || (candidate.sequenceNumber ?? 0) > (message.sequenceNumber ?? 0))
1030
+ ))
1031
+ ), [sortedMessages]);
1032
+ const latestVisibleAvatarAnchorId = useMemo(() => {
1033
+ let latestAgentEntryId: string | null = null;
1034
+ for (const entry of timelineEntries) {
1035
+ if (entry.kind === "tool") {
1036
+ latestAgentEntryId = entry.id;
1037
+ continue;
1038
+ }
1039
+ const content = (entry.message.content ?? "").trim();
1040
+ if (isSupersededRunningAssistantMessage(entry.message, content)) continue;
1041
+ const isAgentRole = entry.message.role === "ASSISTANT" || entry.message.role === "TOOL";
1042
+ if (isAgentRole) latestAgentEntryId = entry.id;
1043
+ }
1044
+ return latestAgentEntryId;
1045
+ }, [isSupersededRunningAssistantMessage, timelineEntries]);
1046
+ const streamingAutoStickSignal = useMemo(() => {
1047
+ const activeStreamingAssistant = [...displayMessages]
1048
+ .reverse()
1049
+ .find((message) => message.role === "ASSISTANT" && message.responseStatus === "RUNNING");
1050
+ if (!activeStreamingAssistant) return null;
1051
+ return [
1052
+ activeStreamingAssistant.id,
1053
+ activeStreamingAssistant.pendingPlaceholder ? "placeholder" : "message",
1054
+ (activeStreamingAssistant.content ?? "").length,
1055
+ activeStreamingAssistant.updatedAt ?? activeStreamingAssistant.createdAt,
1056
+ ].join(":");
1057
+ }, [displayMessages]);
1058
+
1059
+ return (
1060
+ <div className="papyrus-console__panel">
1061
+ <ConsolePanelHeader onClose={onClose} />
1062
+ <div className="papyrus-console__sessions">
1063
+ <select
1064
+ aria-label="Console chat session"
1065
+ className="papyrus-console__session-select"
1066
+ onChange={(event) => selectThread(event.target.value)}
1067
+ value={thread?.id ?? ""}
1068
+ >
1069
+ {!thread ? <option value="">No session selected</option> : null}
1070
+ {threads.map((entry) => (
1071
+ <option key={entry.id} value={entry.id}>
1072
+ {formatConsoleThreadOption(entry)}
1073
+ </option>
1074
+ ))}
1075
+ </select>
1076
+ {sortedMessages.length > 0 ? (
1077
+ <button
1078
+ className="papyrus-console__new-session"
1079
+ disabled={sending}
1080
+ onClick={() => void createNewThread().catch((nextError) => {
1081
+ console.error("[PapyrusConsole] Unable to create console thread", nextError);
1082
+ setError(nextError instanceof Error ? nextError.message : "Unable to create console thread.");
1083
+ })}
1084
+ type="button"
1085
+ >
1086
+ New chat
1087
+ </button>
1088
+ ) : null}
1089
+ </div>
1090
+ <Conversation className="papyrus-console__conversation" resize={pending ? "instant" : "smooth"}>
1091
+ <ConsoleStreamingAutoStick active={pending} streamSignal={streamingAutoStickSignal} />
1092
+ <ConversationContent
1093
+ aria-live="polite"
1094
+ className="papyrus-console__body"
1095
+ scrollClassName="papyrus-console__scrollport"
1096
+ role="log"
1097
+ >
1098
+ {loading && !sortedMessages.length ? <p className="papyrus-console__empty">Loading conversation…</p> : null}
1099
+ {timelineEntries.map((entry) => {
1100
+ if (entry.kind === "tool") {
1101
+ const showAssistantAvatar = entry.id === latestVisibleAvatarAnchorId;
1102
+ const assistantAvatar = showAssistantAvatar
1103
+ ? resolveAssistantAvatarPresentation(entry.tool, motionSetting === "low", pending)
1104
+ : null;
1105
+ return (
1106
+ <ConsoleToolTimelineRow key={entry.id}>
1107
+ <ConsoleToolMessageContent tool={entry.tool} />
1108
+ {assistantAvatar ? (
1109
+ <span className="papyrus-console-message__assistant-avatar" data-tone={assistantAvatar.tone}>
1110
+ <VultusBotAvatar
1111
+ ariaLabel={assistantAvatar.ariaLabel}
1112
+ lightColor={assistantAvatar.tone === "error" ? ASSISTANT_AVATAR_ERROR_LIGHT_COLOR : ASSISTANT_AVATAR_DEFAULT_LIGHT_COLOR}
1113
+ neutralIdleMode={assistantAvatar.neutralIdleMode}
1114
+ shadowColor={assistantAvatar.tone === "error" ? ASSISTANT_AVATAR_ERROR_SHADOW_COLOR : ASSISTANT_AVATAR_DEFAULT_SHADOW_COLOR}
1115
+ size={ASSISTANT_AVATAR_SIZE_PX}
1116
+ state={assistantAvatar.state}
1117
+ transitionDurationSeconds={0.35}
1118
+ />
1119
+ </span>
1120
+ ) : null}
1121
+ </ConsoleToolTimelineRow>
1122
+ );
1123
+ }
1124
+
1125
+ const message = entry.message;
1126
+ const isWebContextMessage = message.messageKind === CONSOLE_WEB_CONTEXT_MESSAGE_KIND;
1127
+ const content = (message.content ?? "").trim();
1128
+ const summary = (message.summary ?? "").trim();
1129
+ const sanitizedSummary = sanitizeConsoleSummary(summary);
1130
+ const isSupersededRunningAssistant = isSupersededRunningAssistantMessage(message, content);
1131
+ const showAssistantAvatar = entry.id === latestVisibleAvatarAnchorId;
1132
+ const assistantAvatar = showAssistantAvatar
1133
+ ? resolveAssistantAvatarPresentation(message, motionSetting === "low", pending)
1134
+ : null;
1135
+ const userAvatarHash = message.role === "USER" ? readConsoleAuthorAvatarHash(message) : null;
1136
+ const userAuthorEmail = message.role === "USER" ? readConsoleAuthorEmail(message) : null;
1137
+ const shouldShowThinking = (
1138
+ message.pendingPlaceholder
1139
+ || (message.role === "ASSISTANT" && message.responseStatus === "RUNNING" && !content && !isSupersededRunningAssistant)
1140
+ );
1141
+ if (isSupersededRunningAssistant) return null;
1142
+
1143
+ return (
1144
+ <ConsoleChatMessageRow
1145
+ key={message.id}
1146
+ role={isWebContextMessage ? "CONTEXT" : message.role}
1147
+ >
1148
+ <div className={message.role === "ASSISTANT" ? "papyrus-console-message__markdown" : undefined}>
1149
+ {message.role === "USER" ? <ConsoleUserAvatar authorEmail={userAuthorEmail} avatarHash={userAvatarHash} /> : null}
1150
+ {isWebContextMessage ? (
1151
+ <p className="papyrus-console-message__context-label">Page context</p>
1152
+ ) : null}
1153
+ {shouldShowThinking ? (
1154
+ <Shimmer className="papyrus-console__thinking-shimmer">Thinking...</Shimmer>
1155
+ ) : <ConsoleChatMessageContent role={message.role} text={content || sanitizedSummary} />}
1156
+ </div>
1157
+ {message.responseStatus === "FAILED" ? <span className="papyrus-console-message__error">{message.responseError ?? "Responder failed"}</span> : null}
1158
+ {assistantAvatar ? (
1159
+ <span className="papyrus-console-message__assistant-avatar" data-tone={assistantAvatar.tone}>
1160
+ <VultusBotAvatar
1161
+ ariaLabel={assistantAvatar.ariaLabel}
1162
+ lightColor={assistantAvatar.tone === "error" ? ASSISTANT_AVATAR_ERROR_LIGHT_COLOR : ASSISTANT_AVATAR_DEFAULT_LIGHT_COLOR}
1163
+ neutralIdleMode={assistantAvatar.neutralIdleMode}
1164
+ shadowColor={assistantAvatar.tone === "error" ? ASSISTANT_AVATAR_ERROR_SHADOW_COLOR : ASSISTANT_AVATAR_DEFAULT_SHADOW_COLOR}
1165
+ size={ASSISTANT_AVATAR_SIZE_PX}
1166
+ state={assistantAvatar.state}
1167
+ transitionDurationSeconds={0.35}
1168
+ />
1169
+ </span>
1170
+ ) : null}
1171
+ </ConsoleChatMessageRow>
1172
+ );
1173
+ })}
1174
+ </ConversationContent>
1175
+ <ConversationScrollButton
1176
+ aria-label="Scroll to latest message"
1177
+ className="papyrus-console__scroll-bottom"
1178
+ />
1179
+ </Conversation>
1180
+ {!loading && !sortedMessages.length ? (
1181
+ <div className="papyrus-console__suggestions papyrus-console-ui">
1182
+ <Suggestions>
1183
+ {CONSOLE_STARTER_SUGGESTIONS.map((suggestion) => (
1184
+ <Suggestion
1185
+ disabled={sending}
1186
+ key={suggestion}
1187
+ onClick={handleSuggestionClick}
1188
+ suggestion={suggestion}
1189
+ />
1190
+ ))}
1191
+ </Suggestions>
1192
+ </div>
1193
+ ) : null}
1194
+ {error ? <p className="papyrus-console__error">{error}</p> : null}
1195
+ <PromptInput className="papyrus-console__composer" onSubmit={(message) => void sendMessage(message)}>
1196
+ <PromptInputBody>
1197
+ <PromptInputTextarea
1198
+ aria-label="Console message"
1199
+ disabled={sending}
1200
+ onChange={(event) => setDraft(event.target.value)}
1201
+ placeholder="Write a message..."
1202
+ rows={3}
1203
+ value={draft}
1204
+ />
1205
+ </PromptInputBody>
1206
+ <PromptInputFooter>
1207
+ <PromptInputTools>
1208
+ <ModelSelector
1209
+ aria-label="Console model"
1210
+ disabled={sending}
1211
+ onValueChange={handleModelChange}
1212
+ options={CONSOLE_MODEL_OPTIONS.map((option) => ({
1213
+ value: option.value,
1214
+ label: option.label,
1215
+ }))}
1216
+ value={selectedModel}
1217
+ />
1218
+ </PromptInputTools>
1219
+ <PromptInputSubmit
1220
+ disabled={!draft.trim() || sending}
1221
+ status={pending ? "streaming" : "ready"}
1222
+ />
1223
+ </PromptInputFooter>
1224
+ </PromptInput>
1225
+ </div>
1226
+ );
1227
+ }
1228
+
1229
+ function truncateConsoleSummary(value: string): string {
1230
+ const normalized = value.replace(/\s+/g, " ").trim();
1231
+ return normalized.length > 180 ? `${normalized.slice(0, 179)}…` : normalized;
1232
+ }
1233
+
1234
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
1235
+ return new Promise<T>((resolve, reject) => {
1236
+ const timeout = window.setTimeout(() => {
1237
+ reject(new Error(message));
1238
+ }, timeoutMs);
1239
+ promise.then(
1240
+ (value) => {
1241
+ window.clearTimeout(timeout);
1242
+ resolve(value);
1243
+ },
1244
+ (error) => {
1245
+ window.clearTimeout(timeout);
1246
+ reject(error);
1247
+ },
1248
+ );
1249
+ });
1250
+ }
1251
+
1252
+ function upsertConsoleMessage(messages: ConsoleChatMessage[], message: ConsoleChatMessage): ConsoleChatMessage[] {
1253
+ const index = messages.findIndex((entry) => entry.id === message.id);
1254
+ const next = index >= 0
1255
+ ? messages.map((entry, currentIndex) => (
1256
+ currentIndex === index
1257
+ ? mergeConsoleMessage(entry, message)
1258
+ : entry
1259
+ ))
1260
+ : [...messages, message];
1261
+ return next.sort((left, right) => (left.sequenceNumber ?? 0) - (right.sequenceNumber ?? 0) || left.createdAt.localeCompare(right.createdAt));
1262
+ }
1263
+
1264
+ function mergeConsoleMessage(previous: ConsoleChatMessage, incoming: ConsoleChatMessage): ConsoleChatMessage {
1265
+ const merged = { ...previous } as Record<string, unknown>;
1266
+ for (const [key, value] of Object.entries(incoming)) {
1267
+ if (value !== undefined && value !== null) merged[key] = value;
1268
+ }
1269
+ return merged as ConsoleChatMessage;
1270
+ }
1271
+
1272
+ function isConsoleMessageAfter(message: ConsoleChatMessage, anchor: ConsoleChatMessage): boolean {
1273
+ if (message.id === anchor.id) return false;
1274
+ if (typeof message.sequenceNumber === "number" && typeof anchor.sequenceNumber === "number") {
1275
+ return message.sequenceNumber > anchor.sequenceNumber;
1276
+ }
1277
+ return message.createdAt >= anchor.createdAt;
1278
+ }
1279
+
1280
+ function isResponderMessageForPendingUser(message: ConsoleChatMessage, pendingUser: ConsoleChatMessage): boolean {
1281
+ if (message.id === pendingUser.id) return false;
1282
+ if (message.parentMessageId && message.parentMessageId === pendingUser.id) return true;
1283
+ if (isConsoleMessageAfter(message, pendingUser)) return true;
1284
+ if (
1285
+ typeof message.sequenceNumber === "number"
1286
+ && typeof pendingUser.sequenceNumber === "number"
1287
+ && message.sequenceNumber === pendingUser.sequenceNumber
1288
+ ) {
1289
+ return message.createdAt >= pendingUser.createdAt;
1290
+ }
1291
+ return false;
1292
+ }
1293
+
1294
+ function isAwaitingAssistantStatus(status: string | null | undefined): boolean {
1295
+ return status === "PENDING" || status === "RUNNING";
1296
+ }
1297
+
1298
+ function sanitizeConsoleSummary(summary: string): string {
1299
+ const lowered = summary.trim().toLowerCase();
1300
+ if (!lowered) return "";
1301
+ if (lowered === "thinking...") return "";
1302
+ if (lowered.endsWith("responding.")) return "";
1303
+ return summary;
1304
+ }
1305
+
1306
+ function readConsoleAuthorAvatarHash(message: ConsoleChatMessage): string | null {
1307
+ const metadata = readAwsJsonObject(message.metadata);
1308
+ return (
1309
+ readNestedString(metadata, ["console", "author", "avatarHash"])
1310
+ ?? readNestedString(metadata, ["author", "avatarHash"])
1311
+ );
1312
+ }
1313
+
1314
+ function readConsoleAuthorEmail(message: ConsoleChatMessage): string | null {
1315
+ const metadata = readAwsJsonObject(message.metadata);
1316
+ return (
1317
+ readNestedString(metadata, ["console", "author", "email"])
1318
+ ?? readNestedString(metadata, ["author", "email"])
1319
+ );
1320
+ }
1321
+
1322
+ function gravatarUrlFromHash(hash: string, size: number): string {
1323
+ const normalized = hash.trim().toLowerCase();
1324
+ return `https://www.gravatar.com/avatar/${encodeURIComponent(normalized)}?s=${size}&d=404`;
1325
+ }
1326
+
1327
+ async function computeAvatarHashFromEmail(email: string): Promise<string | null> {
1328
+ const normalized = email.trim().toLowerCase();
1329
+ if (!normalized) return null;
1330
+ return computeMd5Hex(normalized);
1331
+ }
1332
+
1333
+ async function computeMd5Hex(value: string): Promise<string> {
1334
+ const md5 = new Md5();
1335
+ md5.update(new TextEncoder().encode(value));
1336
+ const digest = await md5.digest();
1337
+ return Array.from(digest)
1338
+ .map((byte) => byte.toString(16).padStart(2, "0"))
1339
+ .join("");
1340
+ }
1341
+
1342
+ function ConsoleUserAvatar({ authorEmail, avatarHash }: { authorEmail: string | null; avatarHash: string | null }) {
1343
+ const [imageFailed, setImageFailed] = useState(false);
1344
+ useEffect(() => {
1345
+ setImageFailed(false);
1346
+ }, [avatarHash]);
1347
+ const imageUrl = avatarHash ? gravatarUrlFromHash(avatarHash, 64) : null;
1348
+ const showImage = Boolean(imageUrl && !imageFailed);
1349
+ const tooltip = authorEmail ?? "Email unavailable";
1350
+
1351
+ return (
1352
+ <span
1353
+ aria-label={`Message author email: ${tooltip}`}
1354
+ className="papyrus-console-message__user-avatar"
1355
+ role="img"
1356
+ title={tooltip}
1357
+ >
1358
+ {showImage ? (
1359
+ // eslint-disable-next-line @next/next/no-img-element
1360
+ <img
1361
+ alt=""
1362
+ className="papyrus-console-message__user-avatar-image"
1363
+ onError={() => setImageFailed(true)}
1364
+ src={imageUrl ?? undefined}
1365
+ />
1366
+ ) : (
1367
+ <UserIcon className="papyrus-console-message__user-avatar-icon" size={14} strokeWidth={2} />
1368
+ )}
1369
+ </span>
1370
+ );
1371
+ }
1372
+
1373
+ function resolveAssistantAvatarPresentation(
1374
+ entry: DisplayConsoleMessage | ConsoleToolTimelineEntry,
1375
+ forceLowMotion: boolean,
1376
+ hasActiveResponse: boolean,
1377
+ ): { ariaLabel: string; neutralIdleMode?: "bored-random" | "static"; state: BotAvatarState; tone: AssistantAvatarTone } {
1378
+ const failedState = resolveConsoleEntryFailedState(entry);
1379
+ if (forceLowMotion) {
1380
+ return {
1381
+ ariaLabel: "Assistant avatar in low-motion mode",
1382
+ neutralIdleMode: "static",
1383
+ state: "neutral",
1384
+ tone: failedState ? "error" : "default",
1385
+ };
1386
+ }
1387
+
1388
+ if (failedState) {
1389
+ return {
1390
+ ariaLabel: "Assistant avatar in error state",
1391
+ neutralIdleMode: "static",
1392
+ state: "neutral",
1393
+ tone: "error",
1394
+ };
1395
+ }
1396
+ if (isToolTimelineEntry(entry) && hasActiveResponse) {
1397
+ const toolHasResult = isToolTimelineResultAvailable(entry);
1398
+ if (toolHasResult) {
1399
+ return {
1400
+ ariaLabel: "Assistant avatar while handling tool results",
1401
+ state: "toolResponse",
1402
+ tone: "default",
1403
+ };
1404
+ }
1405
+ return {
1406
+ ariaLabel: "Assistant avatar while calling tools",
1407
+ state: "toolCalling",
1408
+ tone: "default",
1409
+ };
1410
+ }
1411
+ if (isToolTimelineEntry(entry)) return {
1412
+ ariaLabel: "Assistant avatar in neutral state",
1413
+ state: "neutral",
1414
+ tone: "default",
1415
+ };
1416
+
1417
+ const trimmedContent = (entry.content ?? "").trim();
1418
+ if (entry.responseStatus === "RUNNING" && !hasActiveResponse) {
1419
+ return {
1420
+ ariaLabel: "Assistant avatar in neutral state",
1421
+ state: "neutral",
1422
+ tone: "default",
1423
+ };
1424
+ }
1425
+ if (entry.pendingPlaceholder || (entry.responseStatus === "RUNNING" && !trimmedContent)) {
1426
+ return {
1427
+ ariaLabel: "Assistant avatar while thinking",
1428
+ state: "thinking",
1429
+ tone: "default",
1430
+ };
1431
+ }
1432
+ if (entry.responseStatus === "RUNNING" && trimmedContent) {
1433
+ return {
1434
+ ariaLabel: "Assistant avatar while speaking",
1435
+ state: "speakingOpen",
1436
+ tone: "default",
1437
+ };
1438
+ }
1439
+ return {
1440
+ ariaLabel: "Assistant avatar in neutral state",
1441
+ state: "neutral",
1442
+ tone: "default",
1443
+ };
1444
+ }
1445
+
1446
+ function resolveConsoleEntryFailedState(entry: DisplayConsoleMessage | ConsoleToolTimelineEntry): boolean {
1447
+ if (!isToolTimelineEntry(entry)) return entry.responseStatus === "FAILED";
1448
+ if (!entry.resultMessage) return false;
1449
+ const metadata = readAwsJsonObject(entry.resultMessage.metadata);
1450
+ const toolResult = readNestedValue(metadata, ["toolResultJson"]);
1451
+ if (toolResult && typeof toolResult === "object" && !Array.isArray(toolResult)) {
1452
+ const toolResultObject = toolResult as Record<string, unknown>;
1453
+ if (toolResultObject.ok === false) return true;
1454
+ if (toolResultObject.error) return true;
1455
+ }
1456
+ const markdown = entry.resultMessage.content?.toLowerCase() ?? "";
1457
+ return /(^|\n)-\s*ok:\s*false\b/.test(markdown);
1458
+ }
1459
+
1460
+ function isToolTimelineEntry(entry: DisplayConsoleMessage | ConsoleToolTimelineEntry): entry is ConsoleToolTimelineEntry {
1461
+ return typeof (entry as ConsoleToolTimelineEntry).toolCallId === "string";
1462
+ }
1463
+
1464
+ function isToolTimelineResultAvailable(entry: ConsoleToolTimelineEntry): boolean {
1465
+ return Boolean(entry.resultMessage);
1466
+ }
1467
+
1468
+ function ConsoleToolMessageContent({ tool }: { tool: ConsoleToolTimelineEntry }) {
1469
+ const callMessage = tool.callMessage;
1470
+ const resultMessage = tool.resultMessage;
1471
+ const callMetadata = readAwsJsonObject(callMessage?.metadata);
1472
+ const resultMetadata = readAwsJsonObject(resultMessage?.metadata);
1473
+ const toolState: "input-available" | "output-available" | "output-error" = resultMessage
1474
+ ? (resolveConsoleEntryFailedState(tool) ? "output-error" : "output-available")
1475
+ : "input-available";
1476
+ const toolInput = (
1477
+ readNestedValue(callMetadata, ["arguments"])
1478
+ ?? callMessage?.content
1479
+ ?? null
1480
+ );
1481
+ const toolResultOutput = resultMessage
1482
+ ? (
1483
+ readNestedValue(resultMetadata, ["toolResultJson"])
1484
+ ?? resultMessage.content
1485
+ ?? null
1486
+ )
1487
+ : null;
1488
+ const toolErrorText = toolState === "output-error" && resultMessage
1489
+ ? (
1490
+ readNestedString(resultMetadata, ["toolResultJson", "error", "message"])
1491
+ ?? readNestedString(resultMetadata, ["toolResultJson", "error"])
1492
+ ?? resultMessage.content
1493
+ ?? "Tool error"
1494
+ )
1495
+ : undefined;
1496
+
1497
+ return (
1498
+ <div className="papyrus-console-tool-row__content">
1499
+ <Tool>
1500
+ <ToolHeader
1501
+ state={toolState}
1502
+ toolName={tool.toolName}
1503
+ type={`tool-${tool.toolName}`}
1504
+ />
1505
+ <ToolContent>
1506
+ <ToolInput input={toolInput} />
1507
+ <ToolOutput
1508
+ errorText={toolErrorText}
1509
+ output={toolState === "output-error" ? undefined : formatToolOutputText(toolResultOutput)}
1510
+ />
1511
+ </ToolContent>
1512
+ </Tool>
1513
+ </div>
1514
+ );
1515
+ }
1516
+
1517
+ function ConsoleToolTimelineRow({ children }: { children: ReactNode }) {
1518
+ const rowRef = useRef<HTMLElement | null>(null);
1519
+
1520
+ useEffect(() => {
1521
+ const row = rowRef.current;
1522
+ if (!row || prefersReducedMotion()) return;
1523
+ const tween = gsap.fromTo(
1524
+ row,
1525
+ { autoAlpha: 0, y: 8 },
1526
+ { autoAlpha: 1, y: 0, duration: 0.16, ease: "power2.out", overwrite: "auto" },
1527
+ );
1528
+ return () => {
1529
+ tween.kill();
1530
+ gsap.killTweensOf(row);
1531
+ };
1532
+ }, []);
1533
+
1534
+ return (
1535
+ <article className="papyrus-console-tool-row" ref={rowRef}>
1536
+ {children}
1537
+ </article>
1538
+ );
1539
+ }
1540
+
1541
+ function formatToolOutputText(value: unknown): string {
1542
+ if (typeof value === "string") return value;
1543
+ if (value === undefined || value === null) return "";
1544
+ try {
1545
+ return JSON.stringify(value, null, 2);
1546
+ } catch {
1547
+ return String(value);
1548
+ }
1549
+ }
1550
+
1551
+ function ConsoleChatMessageContent({ role, text }: { role: string | null | undefined; text: string }) {
1552
+ const blocks = useMemo(() => parseConsoleMarkdownBlocks(text), [text]);
1553
+ if (role !== "ASSISTANT") return text;
1554
+ return (
1555
+ <>
1556
+ {blocks.map((block, index) => renderConsoleMarkdownBlock(block, index))}
1557
+ </>
1558
+ );
1559
+ }
1560
+
1561
+ type ConsoleMarkdownBlock =
1562
+ | { type: "code"; text: string }
1563
+ | { type: "heading"; depth: number; text: string }
1564
+ | { type: "list"; ordered: boolean; items: string[] }
1565
+ | { type: "paragraph"; text: string };
1566
+
1567
+ function parseConsoleMarkdownBlocks(text: string): ConsoleMarkdownBlock[] {
1568
+ const blocks: ConsoleMarkdownBlock[] = [];
1569
+ const lines = text.split(/\r?\n/);
1570
+ let paragraph: string[] = [];
1571
+ let list: string[] = [];
1572
+ let listOrdered = false;
1573
+ let code: string[] = [];
1574
+ let inCode = false;
1575
+
1576
+ const flushParagraph = () => {
1577
+ if (!paragraph.length) return;
1578
+ blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
1579
+ paragraph = [];
1580
+ };
1581
+ const flushList = () => {
1582
+ if (!list.length) return;
1583
+ blocks.push({ type: "list", ordered: listOrdered, items: list });
1584
+ list = [];
1585
+ };
1586
+
1587
+ for (const line of lines) {
1588
+ if (line.trim().startsWith("```")) {
1589
+ if (inCode) {
1590
+ blocks.push({ type: "code", text: code.join("\n") });
1591
+ code = [];
1592
+ inCode = false;
1593
+ } else {
1594
+ flushParagraph();
1595
+ flushList();
1596
+ inCode = true;
1597
+ }
1598
+ continue;
1599
+ }
1600
+ if (inCode) {
1601
+ code.push(line);
1602
+ continue;
1603
+ }
1604
+ const trimmed = line.trim();
1605
+ if (!trimmed) {
1606
+ flushParagraph();
1607
+ flushList();
1608
+ continue;
1609
+ }
1610
+ const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
1611
+ if (heading) {
1612
+ flushParagraph();
1613
+ flushList();
1614
+ blocks.push({ type: "heading", depth: heading[1].length, text: heading[2].trim() });
1615
+ continue;
1616
+ }
1617
+ const bullet = /^([-*+])\s+(.+)$/.exec(trimmed);
1618
+ if (bullet) {
1619
+ flushParagraph();
1620
+ if (!list.length) listOrdered = false;
1621
+ list.push(bullet[2].trim());
1622
+ continue;
1623
+ }
1624
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
1625
+ if (ordered) {
1626
+ flushParagraph();
1627
+ if (!list.length) listOrdered = true;
1628
+ list.push(ordered[1].trim());
1629
+ continue;
1630
+ }
1631
+ flushList();
1632
+ paragraph.push(trimmed);
1633
+ }
1634
+
1635
+ flushParagraph();
1636
+ flushList();
1637
+ if (code.length) blocks.push({ type: "code", text: code.join("\n") });
1638
+ return blocks;
1639
+ }
1640
+
1641
+ function renderConsoleMarkdownBlock(block: ConsoleMarkdownBlock, index: number): ReactNode {
1642
+ const key = `${block.type}-${index}`;
1643
+ if (block.type === "code") return <pre className="papyrus-console-message__code" key={key}>{block.text}</pre>;
1644
+ if (block.type === "list") {
1645
+ if (block.ordered) {
1646
+ return (
1647
+ <ol key={key}>
1648
+ {block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderConsoleInlineMarkdown(item)}</li>)}
1649
+ </ol>
1650
+ );
1651
+ }
1652
+ return (
1653
+ <ul key={key}>
1654
+ {block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderConsoleInlineMarkdown(item)}</li>)}
1655
+ </ul>
1656
+ );
1657
+ }
1658
+ if (block.type === "heading") {
1659
+ if (block.depth <= 1) return <h3 key={key}>{renderConsoleInlineMarkdown(block.text)}</h3>;
1660
+ if (block.depth === 2) return <h4 key={key}>{renderConsoleInlineMarkdown(block.text)}</h4>;
1661
+ return <h5 key={key}>{renderConsoleInlineMarkdown(block.text)}</h5>;
1662
+ }
1663
+ return <p key={key}>{renderConsoleInlineMarkdown(block.text)}</p>;
1664
+ }
1665
+
1666
+ function renderConsoleInlineMarkdown(text: string): ReactNode[] {
1667
+ const nodes: ReactNode[] = [];
1668
+ const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g;
1669
+ let lastIndex = 0;
1670
+ let match: RegExpExecArray | null;
1671
+ while ((match = pattern.exec(text)) !== null) {
1672
+ if (match.index > lastIndex) nodes.push(text.slice(lastIndex, match.index));
1673
+ const token = match[0];
1674
+ if (token.startsWith("`")) {
1675
+ nodes.push(<code key={`${match.index}-code`}>{token.slice(1, -1)}</code>);
1676
+ } else if (token.startsWith("**")) {
1677
+ nodes.push(<strong key={`${match.index}-strong`}>{token.slice(2, -2)}</strong>);
1678
+ } else {
1679
+ const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
1680
+ if (linkMatch) {
1681
+ nodes.push(
1682
+ <a href={linkMatch[2]} key={`${match.index}-link`} rel="noreferrer noopener" target="_blank">
1683
+ {linkMatch[1]}
1684
+ </a>,
1685
+ );
1686
+ } else {
1687
+ nodes.push(token);
1688
+ }
1689
+ }
1690
+ lastIndex = match.index + token.length;
1691
+ }
1692
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
1693
+ return nodes;
1694
+ }
1695
+
1696
+ function ConsoleChatMessageRow({
1697
+ role,
1698
+ children,
1699
+ }: {
1700
+ role: string | null | undefined;
1701
+ children: ReactNode;
1702
+ }) {
1703
+ const rowRef = useRef<HTMLElement | null>(null);
1704
+
1705
+ useEffect(() => {
1706
+ const row = rowRef.current;
1707
+ if (!row || prefersReducedMotion()) return;
1708
+ const tween = gsap.fromTo(
1709
+ row,
1710
+ { autoAlpha: 0, y: 8 },
1711
+ { autoAlpha: 1, y: 0, duration: 0.16, ease: "power2.out", overwrite: "auto" },
1712
+ );
1713
+ return () => {
1714
+ tween.kill();
1715
+ gsap.killTweensOf(row);
1716
+ };
1717
+ }, []);
1718
+
1719
+ return (
1720
+ <article
1721
+ className={
1722
+ role === "USER"
1723
+ ? "papyrus-console-message papyrus-console-message--user"
1724
+ : role === "CONTEXT"
1725
+ ? "papyrus-console-message papyrus-console-message--context"
1726
+ : "papyrus-console-message papyrus-console-message--assistant"
1727
+ }
1728
+ ref={rowRef}
1729
+ >
1730
+ {children}
1731
+ </article>
1732
+ );
1733
+ }
1734
+
1735
+ function ConsoleStreamingAutoStick({ active, streamSignal }: { active: boolean; streamSignal: string | null }) {
1736
+ const { scrollRef, scrollToBottom } = useStickToBottomContext();
1737
+ const previousSignalRef = useRef<string | null>(null);
1738
+
1739
+ const snapToBottom = useCallback(() => {
1740
+ void scrollToBottom({ animation: "instant" });
1741
+ const scrollElement = scrollRef.current;
1742
+ if (!scrollElement) return;
1743
+ scrollElement.scrollTop = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight);
1744
+ }, [scrollRef, scrollToBottom]);
1745
+
1746
+ useEffect(() => {
1747
+ if (!active || !streamSignal) return;
1748
+ if (previousSignalRef.current === streamSignal) return;
1749
+ previousSignalRef.current = streamSignal;
1750
+
1751
+ snapToBottom();
1752
+ const frame = window.requestAnimationFrame(() => {
1753
+ snapToBottom();
1754
+ });
1755
+ return () => {
1756
+ window.cancelAnimationFrame(frame);
1757
+ };
1758
+ }, [active, snapToBottom, streamSignal]);
1759
+
1760
+ useEffect(() => {
1761
+ if (!active) return;
1762
+ let frame = 0;
1763
+ const pin = () => {
1764
+ snapToBottom();
1765
+ frame = window.requestAnimationFrame(pin);
1766
+ };
1767
+ pin();
1768
+ return () => {
1769
+ window.cancelAnimationFrame(frame);
1770
+ };
1771
+ }, [active, snapToBottom]);
1772
+
1773
+ useEffect(() => {
1774
+ if (!active) previousSignalRef.current = null;
1775
+ }, [active]);
1776
+
1777
+ return null;
1778
+ }
1779
+
1780
+ function prefersReducedMotion(): boolean {
1781
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
1782
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1783
+ }
1784
+
1785
+ function formatConsoleThreadOption(thread: ConsoleChatThread): string {
1786
+ const stamp = thread.lastMessageAt ?? thread.updatedAt ?? thread.createdAt;
1787
+ const date = stamp ? new Date(stamp) : null;
1788
+ const suffix = date && Number.isFinite(date.getTime())
1789
+ ? ` — ${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`
1790
+ : "";
1791
+ return `${thread.title || "Papyrus Console"}${suffix}`;
1792
+ }
1793
+
1794
+ function resolveThreadModel(thread: ConsoleChatThread | null): string {
1795
+ const metadata = readAwsJsonObject(thread?.metadata);
1796
+ const model =
1797
+ readNestedString(metadata, ["console", "model"])
1798
+ ?? readNestedString(metadata, ["model"]);
1799
+ return isSupportedConsoleModel(model) ? model : DEFAULT_CONSOLE_MODEL;
1800
+ }
1801
+
1802
+ function isSupportedConsoleModel(model: unknown): model is string {
1803
+ return typeof model === "string" && CONSOLE_MODEL_OPTIONS.some((option) => option.value === model);
1804
+ }
1805
+
1806
+ function readNestedObject(value: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
1807
+ if (!value) return null;
1808
+ const candidate = value[key];
1809
+ return candidate && typeof candidate === "object" && !Array.isArray(candidate)
1810
+ ? candidate as Record<string, unknown>
1811
+ : null;
1812
+ }
1813
+
1814
+ function readNestedString(value: Record<string, unknown> | null, path: string[]): string | null {
1815
+ let cursor: unknown = value;
1816
+ for (const key of path) {
1817
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return null;
1818
+ cursor = (cursor as Record<string, unknown>)[key];
1819
+ }
1820
+ return typeof cursor === "string" && cursor.trim() ? cursor.trim() : null;
1821
+ }
1822
+
1823
+ function readNestedValue(value: Record<string, unknown> | null, path: string[]): unknown {
1824
+ let cursor: unknown = value;
1825
+ for (const key of path) {
1826
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return null;
1827
+ cursor = (cursor as Record<string, unknown>)[key];
1828
+ }
1829
+ return cursor ?? null;
1830
+ }
1831
+
1832
+ function readAwsJsonObject(value: unknown): Record<string, unknown> | null {
1833
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
1834
+ if (typeof value === "string") {
1835
+ try {
1836
+ const parsed = JSON.parse(value) as unknown;
1837
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
1838
+ } catch {
1839
+ return null;
1840
+ }
1841
+ }
1842
+ return null;
1843
+ }
1844
+
1845
+ function ConsolePanelHeader({ onClose }: { onClose: () => void }) {
1846
+ return (
1847
+ <header className="papyrus-console__header">
1848
+ <p className="papyrus-console__header-label">Chat</p>
1849
+ <button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">
1850
+ <LucideXIcon />
1851
+ </button>
1852
+ </header>
1853
+ );
1854
+ }
1855
+
1856
+ function LucideXIcon() {
1857
+ return (
1858
+ <svg
1859
+ aria-hidden="true"
1860
+ className="papyrus-console__close-icon news-desk-search-mark__icon"
1861
+ fill="none"
1862
+ focusable="false"
1863
+ height="18"
1864
+ stroke="currentColor"
1865
+ strokeLinecap="round"
1866
+ strokeLinejoin="round"
1867
+ strokeWidth="2"
1868
+ viewBox="0 0 24 24"
1869
+ width="18"
1870
+ >
1871
+ <path d="M18 6 6 18" />
1872
+ <path d="m6 6 12 12" />
1873
+ </svg>
1874
+ );
1875
+ }
1876
+
1877
+ export function PapyrusConsoleChatIcon() {
1878
+ return (
1879
+ <svg
1880
+ aria-hidden="true"
1881
+ className="papyrus-console__icon news-desk-search-mark__icon"
1882
+ fill="none"
1883
+ focusable="false"
1884
+ height="18"
1885
+ stroke="currentColor"
1886
+ strokeLinecap="round"
1887
+ strokeLinejoin="round"
1888
+ strokeWidth="2"
1889
+ viewBox="0 0 24 24"
1890
+ width="18"
1891
+ >
1892
+ <path d="M14 9a2 2 0 0 1-2 2H6.5L3 14V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2Z" />
1893
+ <path d="M18 9h1a2 2 0 0 1 2 2v9l-3.5-3H12a2 2 0 0 1-2-2v-1" />
1894
+ </svg>
1895
+ );
1896
+ }
1897
+
1898
+ function toAwsJson(value: unknown): string {
1899
+ return JSON.stringify(value ?? {});
1900
+ }
1901
+
1902
+ function formatUnknownConsoleError(error: unknown): string {
1903
+ if (error instanceof Error && error.message.trim()) return error.message;
1904
+ if (typeof error === "string" && error.trim()) return error;
1905
+ try {
1906
+ const text = JSON.stringify(error);
1907
+ if (text && text !== "{}") return text;
1908
+ } catch {
1909
+ // Ignore serialization failures.
1910
+ }
1911
+ return "Unable to load console thread.";
1912
+ }