@runtypelabs/persona 4.24.0 → 4.26.0

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 (237) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -0,0 +1,2362 @@
1
+ /**
2
+ * Visitor conversation history shell (docs/visitor-history-implementation-plan.md
3
+ * D6/D7/D8): placement, open/close, inertness of the obscured conversation,
4
+ * focus orchestration, confirmations, and every session mutation. The lazily
5
+ * loaded Messages view (`history-view` chunk) owns the list itself.
6
+ *
7
+ * Split out of `ui.ts` so the IIFE/CDN bundle can ship it as the lazy
8
+ * `history-shell.js` sibling chunk (see `history-shell-loader.ts`), fetched
9
+ * only when `features.history` is enabled or a history API is called. It runs
10
+ * inside the widget closure's state through `ctx`: live getters (and setters
11
+ * where the shell writes) over `createAgentExperience`'s locals, plus the
12
+ * stateful core modules (icon registry, tooltips, chunk loaders, provider
13
+ * registry) so the chunk never carries its own copies of them.
14
+ */
15
+ import { type ResolvedHistoryShellCopy, resolveHistoryShellCopy } from "./components/history-shell-copy";
16
+ import { type HistoryProvider, type HistoryOperationContext } from "./internal/history-provider";
17
+ import { type HistoryRenderSurface, createHistoryRenderSurface } from "./history-render";
18
+ import { type HistoryReturnSurface, type HistoryScope, type HistoryIdentityStatus, type WidgetHistoryInternals, type ResolvedHistoryPresentation, type LoadingIndicatorRenderContext, type AgentWidgetRenderHistoryOpenErrorContext, type HistoryConversationPatch, type HistoryConversationSummary } from "./types";
19
+ import { type HistoryViewHandle, type HistoryRailSection, type HistoryHeaderPlacement, type HistoryViewOptions } from "./history-view-entry";
20
+ import { createElement, createNode, cx } from "./utils/dom";
21
+ import { parseCombo, formatCombo, ariaCombo } from "./utils/shortcuts";
22
+ import { PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
23
+ import { type HistoryConfirmOptions } from "./components/history-confirm-dialog";
24
+ import type { getHistoryProviderFactory } from "./internal/history-provider-registry";
25
+ import { createRuntypeHistoryProvider, type HistoryErrorClasses } from "./internal/runtype-history-provider";
26
+ import type { isDockedMountMode } from "./utils/dock";
27
+ import type { renderLucideIcon } from "./utils/icons";
28
+ import type { loadHistoryView } from "./history-view-loader";
29
+ import type { createHeaderIconButton } from "./components/header-parts";
30
+ import type { attachTooltip } from "./utils/tooltip";
31
+ import type { createEventBus } from "./utils/events";
32
+ import type { AgentWidgetControllerEventMap } from "./types";
33
+
34
+ /** The widget closure state the shell reads and writes. */
35
+ export type HistoryShellContext = {
36
+ readonly actionManager: { syncFromMetadata: () => void };
37
+ readonly activeConversationTitle: string | null;
38
+ readonly announceHistory: (message: string) => void;
39
+ applyRailChrome: () => void;
40
+ readonly artifactSplitRoot: HTMLElement | null;
41
+ readonly body: HTMLElement;
42
+ readonly config: import("./types").AgentWidgetConfig;
43
+ readonly container: HTMLElement;
44
+ conversationOpenPendingEl: HTMLElement | null;
45
+ conversationOpenTakeover: boolean;
46
+ readonly currentKeyPrefix: () => string;
47
+ readonly destroyCallbacks: (() => void)[];
48
+ readonly eventBus: ReturnType<typeof createEventBus<AgentWidgetControllerEventMap>>;
49
+ readonly footer: HTMLElement;
50
+ readonly header: HTMLElement;
51
+ historyChromeSync: () => void;
52
+ readonly historyFeatureEnabled: () => boolean;
53
+ historyInternals: import("./types").WidgetHistoryInternals;
54
+ historyPresentation: import("./types").ResolvedHistoryPresentation | null;
55
+ readonly historySessionState: import("./session").SessionHistoryState;
56
+ historyVisible: boolean;
57
+ readonly isStreaming: boolean;
58
+ readonly jumpToBottomInstant: () => void;
59
+ lastAppliedMessages: import("./types").AgentWidgetMessage[] | null;
60
+ readonly launcherEnabled: boolean;
61
+ readonly launcherSurfaceInstance: import("./components/launcher").LauncherSurface | null;
62
+ readonly maybeFocusInput: () => void;
63
+ readonly messageCache: import("./utils/message-fingerprint").MessageCache;
64
+ readonly messagesWrapper: HTMLElement;
65
+ readonly mount: HTMLElement;
66
+ readonly open: boolean;
67
+ readonly panel: HTMLElement;
68
+ readonly panelElements: import("./components/panel").PanelElements;
69
+ persistentMetadata: Record<string, unknown>;
70
+ readonly plugins: import("./plugins/types").AgentWidgetPlugin[];
71
+ reapplyHistoryHostChrome: () => void;
72
+ readonly repinAnchoredMessage: () => void;
73
+ readonly resetAnchorState: () => void;
74
+ readonly restoreScrollPosition: () => boolean;
75
+ readonly resumeAutoScroll: () => void;
76
+ readonly runStorageMutation: (mutation: () => void | Promise<void>, errorLabel: string) => void;
77
+ readonly session: import("./session").AgentWidgetSession;
78
+ readonly setActiveConversationSummary: (summary: import("./types").HistoryConversationSummary | null) => void;
79
+ readonly storageAdapter: import("./types").AgentWidgetStorageAdapter | null;
80
+ suppressScrollSend: boolean;
81
+ readonly syncScrollToBottomButton: () => void;
82
+ readonly updateWelcome: (messages?: import("./types").AgentWidgetMessage[] | undefined) => void;
83
+ readonly getHistoryProviderFactory: typeof getHistoryProviderFactory;
84
+ /** Core's error classes, so the chunk's provider throws what core checks. */
85
+ readonly historyErrors: HistoryErrorClasses;
86
+ readonly isDockedMountMode: typeof isDockedMountMode;
87
+ readonly renderLucideIcon: typeof renderLucideIcon;
88
+ readonly loadHistoryView: typeof loadHistoryView;
89
+ readonly createHeaderIconButton: typeof createHeaderIconButton;
90
+ readonly attachTooltip: typeof attachTooltip;
91
+ };
92
+
93
+ export const createHistoryShell = (ctx: HistoryShellContext) => {
94
+ /** Rail needs this much HOST width; below it, rail collapses to panel. */
95
+ const RAIL_MIN_CONTAINER_WIDTH = 720;
96
+ /** Ceiling on the view's ~160ms exit before the close proceeds regardless. */
97
+ const HISTORY_EXIT_TIMEOUT_MS = 250;
98
+
99
+ let historyShellCopy: ResolvedHistoryShellCopy = resolveHistoryShellCopy(
100
+ ctx.config.features?.history?.copy
101
+ );
102
+ let historyProvider: HistoryProvider | null = null;
103
+ let historyUnavailable = false;
104
+ /** Default view + plugin render-hook arbitration. Null while closed. */
105
+ let historySurface: HistoryRenderSurface | null = null;
106
+ let historyOperationContext: HistoryOperationContext | null = null;
107
+ let historyReturnSurface: HistoryReturnSurface = "conversation";
108
+ let historyInvoker: HTMLElement | null = null;
109
+ let historyButton: HTMLButtonElement | null = null;
110
+ /** The inserted node: removing the button alone would orphan its wrapper. */
111
+ let historyButtonWrapper: HTMLElement | null = null;
112
+ let historyIdentityKey: string | null = null;
113
+ let historyOpenToken = 0;
114
+ let clearChatDefaultLabel: string | null = null;
115
+ let unsubscribeHistoryAvailability: (() => void) | null = null;
116
+ let unsubscribeHistoryIdentity: (() => void) | null = null;
117
+ const historyRegionId = `persona-history-${Math.random().toString(36).slice(2, 8)}`;
118
+
119
+ const historyAvailable = (): boolean =>
120
+ ctx.historyFeatureEnabled() && !!historyProvider && !historyUnavailable;
121
+ /** Switching conversations mid-turn would abandon a live answer. */
122
+ const historyTurnBusy = (): boolean => {
123
+ const status = ctx.session.getStatus();
124
+ return ctx.isStreaming || status === "paused" || status === "resuming";
125
+ };
126
+ const historyScope = (): HistoryScope => {
127
+ const configured = ctx.config.features?.history?.scope;
128
+ if (configured) return configured;
129
+ // Derived, not requested: narrow to what the provider advertises rather
130
+ // than asking it for a scope it will reject.
131
+ const derived: HistoryScope = ctx.config.getIdentityProof
132
+ ? "verified-user"
133
+ : "browser";
134
+ const scopes = historyProvider?.capabilities.scopes;
135
+ if (!scopes || scopes.includes(derived)) return derived;
136
+ return scopes[0] ?? derived;
137
+ };
138
+ const historyOperationScope = (): HistoryScope =>
139
+ historyOperationContext?.scope ?? historyScope();
140
+ const activeHistoryTargetId = (): string | null =>
141
+ ctx.session.getClientSession()?.targetId ?? null;
142
+
143
+ const identityStatusKey = (status: HistoryIdentityStatus): string =>
144
+ `${status.state}:${"reason" in status ? status.reason : ""}`;
145
+
146
+ /** Instance-scoped only: authentication state is never broadcast page-wide. */
147
+ const emitHistoryIdentityStatus = (status: HistoryIdentityStatus): void => {
148
+ const key = identityStatusKey(status);
149
+ if (key === historyIdentityKey) return;
150
+ historyIdentityKey = key;
151
+ ctx.eventBus.emit("history:identityStatusChanged", {
152
+ status,
153
+ timestamp: Date.now(),
154
+ });
155
+ };
156
+
157
+ // --- provider ------------------------------------------------------------
158
+
159
+ /** One provider build per widget instance; a new factory identity rebuilds. */
160
+ let configuredProviderFactory: (() => HistoryProvider) | null = null;
161
+ let configuredProviderInstance: HistoryProvider | null = null;
162
+
163
+ const buildHistoryProvider = (): HistoryProvider | null => {
164
+ if (!ctx.historyFeatureEnabled()) return null;
165
+ // Demo/test override first, then the host's own provider; production
166
+ // without one builds the Runtype provider from client-token config.
167
+ const override = ctx.getHistoryProviderFactory();
168
+ if (override) return override();
169
+ const configured = ctx.config.features?.history?.provider;
170
+ if (configured) {
171
+ if (typeof configured !== "function") return configured;
172
+ if (configuredProviderFactory !== configured) {
173
+ configuredProviderFactory = configured;
174
+ configuredProviderInstance = configured();
175
+ }
176
+ return configuredProviderInstance;
177
+ }
178
+ if (!ctx.session.isClientTokenMode()) return null;
179
+ return createRuntypeHistoryProvider({
180
+ errors: ctx.historyErrors,
181
+ client: ctx.session.getClient(),
182
+ getIdentityProofConfigured: () =>
183
+ typeof ctx.config.getIdentityProof === "function",
184
+ onActivationCommitted: (clientSession) =>
185
+ ctx.session.bindActivatedSession(clientSession),
186
+ // Connection-config rebuilds replace the client under the provider.
187
+ getClient: () => ctx.session.getClient(),
188
+ });
189
+ };
190
+
191
+ const installHistoryProvider = (): void => {
192
+ unsubscribeHistoryAvailability?.();
193
+ unsubscribeHistoryAvailability = null;
194
+ unsubscribeHistoryIdentity?.();
195
+ unsubscribeHistoryIdentity = null;
196
+ historyUnavailable = false;
197
+ historyProvider = buildHistoryProvider();
198
+ const next: WidgetHistoryInternals = { ...ctx.historyInternals };
199
+ if (historyProvider) next.historyProvider = historyProvider;
200
+ else delete next.historyProvider;
201
+ ctx.historyInternals = next;
202
+ ctx.session.setHistoryInternals(ctx.historyInternals);
203
+ if (!historyProvider) return;
204
+ unsubscribeHistoryAvailability =
205
+ historyProvider.subscribeAvailability?.((available) => {
206
+ historyUnavailable = !available;
207
+ // A degrade must remove an ALREADY-rendered surface, not just a flag.
208
+ if (!available) closeHistory({ restoreFocus: false });
209
+ syncHistoryChromeImpl();
210
+ }) ?? null;
211
+ unsubscribeHistoryIdentity = historyProvider.subscribeIdentityStatus(
212
+ (status) => emitHistoryIdentityStatus(status)
213
+ );
214
+ historyIdentityKey = identityStatusKey(historyProvider.getIdentityStatus());
215
+ };
216
+
217
+ // --- presentation hosts --------------------------------------------------
218
+
219
+ /**
220
+ * Resolved against the history HOST width, never the viewport. With the
221
+ * artifact split mounted the split root is that host, not the container:
222
+ * width the artifact pane borrows from the chat column is still the widget's,
223
+ * so a drag on the split must never read as a narrow host and re-mode the
224
+ * rail. A real window/host narrowing shrinks the split root too, and still
225
+ * flips.
226
+ */
227
+ const resolveHistoryPresentation = (): ResolvedHistoryPresentation => {
228
+ const configured = ctx.config.features?.history?.presentation ?? "panel";
229
+ if (configured === "panel") return "panel";
230
+ if (configured === "auto") {
231
+ // Floating launchers stay panel-based at every width.
232
+ const inlineOrDocked = !ctx.launcherEnabled || ctx.isDockedMountMode(ctx.config);
233
+ if (!inlineOrDocked) return "panel";
234
+ }
235
+ const host = ctx.artifactSplitRoot ?? ctx.container;
236
+ const width = host.getBoundingClientRect().width || host.clientWidth;
237
+ return width >= RAIL_MIN_CONTAINER_WIDTH ? "rail" : "panel";
238
+ };
239
+
240
+ const setHistoryHostInert = (element: HTMLElement, inert: boolean): void => {
241
+ if (inert) {
242
+ element.setAttribute("aria-hidden", "true");
243
+ element.setAttribute("inert", "");
244
+ } else {
245
+ element.removeAttribute("aria-hidden");
246
+ element.removeAttribute("inert");
247
+ }
248
+ };
249
+
250
+ /** Suppression of the shell header's own children while Messages owns the bar. */
251
+ const HISTORY_SUPPRESSED_ATTR = "data-persona-history-suppressed";
252
+ /** Shell-owned wrapper for the view's bar contents. Null while not hosting. */
253
+ let historyHeaderHost: HTMLElement | null = null;
254
+ /** Header carrying the swap-time min-height pin. Null while not hosting. */
255
+ let historyPinnedHeader: HTMLElement | null = null;
256
+ /** Handle + arbitrated element, tracked from before `historySurface` is assigned. */
257
+ let historyViewHandle: HistoryViewHandle | null = null;
258
+ let historyMountedElement: HTMLElement | null = null;
259
+ let capturedPanelHeader: HTMLElement | null = null;
260
+ let capturedPanelHeaderDisplay = "";
261
+
262
+ /** The display the shell header returns to; "none" means there is no bar. */
263
+ const shownHeaderDisplay = (): string =>
264
+ ctx.header === capturedPanelHeader
265
+ ? capturedPanelHeaderDisplay
266
+ : ctx.config.layout?.showHeader === false
267
+ ? "none"
268
+ : "";
269
+
270
+ /**
271
+ * One persistent bar: the shell header hosts the view's bar contents whenever
272
+ * the default view owns a panel and there is a visible header to host them in.
273
+ * A plugin's full custom surface owns everything, so it falls back to hiding.
274
+ */
275
+ const historyHeaderExternal = (): boolean =>
276
+ ctx.historyPresentation === "panel" &&
277
+ !!historyViewHandle &&
278
+ historyMountedElement === historyViewHandle.element &&
279
+ ctx.config.layout?.showHeader !== false &&
280
+ shownHeaderDisplay() !== "none";
281
+
282
+ const suppressHeaderChildren = (): void => {
283
+ for (const child of Array.from(ctx.header.children)) {
284
+ if (child === historyHeaderHost) continue;
285
+ child.setAttribute(HISTORY_SUPPRESSED_ATTR, "");
286
+ }
287
+ };
288
+
289
+ /** By attribute, not by captured list: the header may have been rebuilt. */
290
+ const unsuppressHeaderChildren = (): void => {
291
+ for (const node of Array.from(
292
+ ctx.container.querySelectorAll(`[${HISTORY_SUPPRESSED_ATTR}]`)
293
+ )) {
294
+ node.removeAttribute(HISTORY_SUPPRESSED_ATTR);
295
+ }
296
+ };
297
+
298
+ const hostHistoryHeaderContent = (): void => {
299
+ const view = historyViewHandle;
300
+ if (!view) return;
301
+ // Pin the pre-swap height so the contents swap never moves the chrome: the
302
+ // hosted bar is usually shorter than the title cluster it replaces, and
303
+ // constant-height chrome is what keeps the switch from reading as layout
304
+ // shift. Measurable only while the original contents are still visible; a
305
+ // re-entry on an already-hosted header keeps the standing pin, and a
306
+ // rebuilt header re-measures before its own suppression below.
307
+ const originalsVisible = Array.from(ctx.header.children).some(
308
+ (child) =>
309
+ child !== historyHeaderHost &&
310
+ !child.hasAttribute(HISTORY_SUPPRESSED_ATTR)
311
+ );
312
+ if (originalsVisible) {
313
+ if (historyPinnedHeader && historyPinnedHeader !== ctx.header) {
314
+ historyPinnedHeader.style.removeProperty("min-height");
315
+ historyPinnedHeader = null;
316
+ }
317
+ const measured = ctx.header.offsetHeight;
318
+ if (measured > 0) {
319
+ ctx.header.style.minHeight = `${measured}px`;
320
+ historyPinnedHeader = ctx.header;
321
+ }
322
+ }
323
+ if (!historyHeaderHost) {
324
+ historyHeaderHost = createElement("div", "persona-history-header-host");
325
+ }
326
+ view.setHeaderPlacement("external");
327
+ const content = view.getHeaderElement();
328
+ if (content.parentNode !== historyHeaderHost) {
329
+ historyHeaderHost.replaceChildren(content);
330
+ }
331
+ // The wrapper follows a rebuilt header binding; focus inside it survives.
332
+ if (historyHeaderHost.parentNode !== ctx.header) {
333
+ ctx.header.appendChild(historyHeaderHost);
334
+ }
335
+ suppressHeaderChildren();
336
+ };
337
+
338
+ const releaseHistoryHeaderContent = (): void => {
339
+ if (historyPinnedHeader) {
340
+ historyPinnedHeader.style.removeProperty("min-height");
341
+ historyPinnedHeader = null;
342
+ }
343
+ if (!historyHeaderHost) return;
344
+ // The view re-adopts its bar; the wrapper never owns the content's lifetime.
345
+ historyViewHandle?.setHeaderPlacement("inline");
346
+ historyHeaderHost.remove();
347
+ historyHeaderHost = null;
348
+ unsuppressHeaderChildren();
349
+ };
350
+
351
+ /**
352
+ * Panel presentation obscures the conversation, so the transcript AND the
353
+ * composer must be unreachable: a visitor must never send into a conversation
354
+ * they cannot see. The header bar itself stays: only its contents swap, which
355
+ * is also why it is never inert here (rail changes nothing at all: the
356
+ * conversation stays primary).
357
+ *
358
+ * The widget's close (×) usually lives inside that header, but no trap
359
+ * results: the view's back control is the initial focus target, Escape exits,
360
+ * and both restore the header contents before focus lands. `top-right` close
361
+ * placement parents the × to the container, so it stays reachable either way.
362
+ *
363
+ * Restores exactly what it captured.
364
+ */
365
+ let restorePanelHost: (() => void) | null = null;
366
+ const enforcePanelHost = (): void => {
367
+ if (!restorePanelHost) return;
368
+ ctx.body.style.display = "none";
369
+ setHistoryHostInert(ctx.body, true);
370
+ // Live `footer` / `header` bindings: a composer-plugin rebuild or a
371
+ // header-layout rebuild swaps them and re-enters here for the replacement.
372
+ ctx.footer.hidden = true;
373
+ setHistoryHostInert(ctx.footer, true);
374
+ if (historyHeaderExternal()) {
375
+ hostHistoryHeaderContent();
376
+ ctx.header.style.display = shownHeaderDisplay();
377
+ setHistoryHostInert(ctx.header, false);
378
+ return;
379
+ }
380
+ // No bar to host in: hide the header the way the surface used to.
381
+ releaseHistoryHeaderContent();
382
+ ctx.header.style.display = "none";
383
+ setHistoryHostInert(ctx.header, true);
384
+ };
385
+ ctx.reapplyHistoryHostChrome = enforcePanelHost;
386
+
387
+ const mountPanelHost = (element: HTMLElement): void => {
388
+ const previousDisplay = ctx.body.style.display;
389
+ const previousFooterHidden = ctx.footer.hidden;
390
+ const capturedFooter = ctx.footer;
391
+ const capturedHeader = ctx.header;
392
+ const previousHeaderDisplay = ctx.header.style.display;
393
+ capturedPanelHeader = capturedHeader;
394
+ capturedPanelHeaderDisplay = previousHeaderDisplay;
395
+ restorePanelHost = () => {
396
+ restorePanelHost = null;
397
+ releaseHistoryHeaderContent();
398
+ capturedPanelHeader = null;
399
+ ctx.body.style.display = previousDisplay;
400
+ setHistoryHostInert(ctx.body, false);
401
+ capturedFooter.hidden = previousFooterHidden;
402
+ setHistoryHostInert(capturedFooter, false);
403
+ if (ctx.footer !== capturedFooter) {
404
+ ctx.footer.hidden = false;
405
+ setHistoryHostInert(ctx.footer, false);
406
+ }
407
+ capturedHeader.style.display = previousHeaderDisplay;
408
+ setHistoryHostInert(capturedHeader, false);
409
+ if (ctx.header !== capturedHeader) {
410
+ // A replacement carries no captured state; only config decides it.
411
+ ctx.header.style.display = ctx.config.layout?.showHeader === false ? "none" : "";
412
+ setHistoryHostInert(ctx.header, false);
413
+ }
414
+ };
415
+ ctx.footer.parentNode?.insertBefore(element, ctx.footer);
416
+ enforcePanelHost();
417
+ };
418
+
419
+ let railShell: HTMLElement | null = null;
420
+ let railHost: HTMLElement | null = null;
421
+ let railColumn: HTMLElement | null = null;
422
+ /** Stand-in element holding the docked column while the chunk loads. */
423
+ let railPlaceholder: HTMLElement | null = null;
424
+ /** Container order the rail borrows from and hands back, header first. */
425
+ const railBorrowed = (): Array<HTMLElement | null> => [
426
+ ctx.header,
427
+ ctx.panelElements.closeButtonWrapper,
428
+ ctx.panelElements.clearChatButtonWrapper,
429
+ ctx.body,
430
+ // Composer-bar mode keeps the footer outside the container; the parent
431
+ // checks below then never adopt it.
432
+ ctx.footer,
433
+ ];
434
+
435
+ /** Collapsed icon rail, measured from the reference sidebar. */
436
+ const RAIL_COLLAPSED_WIDTH = 52;
437
+ const railCollapsible = (): boolean =>
438
+ ctx.config.features?.history?.rail?.collapsible !== false;
439
+ const railSide = (): "left" | "right" =>
440
+ ctx.config.features?.history?.rail?.side === "right" ? "right" : "left";
441
+ /** The band a rail width may take, by config or by drag. */
442
+ const RAIL_MIN_WIDTH = 200;
443
+ const RAIL_MAX_WIDTH = 400;
444
+ const clampRailWidth = (value: number): number =>
445
+ Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, Math.round(value)));
446
+
447
+ const railResizable = (): boolean =>
448
+ ctx.config.features?.history?.rail?.resizable === true;
449
+ /** Resolved once per widget: storage, else nothing. */
450
+ let railWidthChoice: number | null | undefined;
451
+
452
+ // Same teaser pattern as the collapsed state: blocked storage throws rather
453
+ // than fails, so the resolved value is also the in-memory fallback.
454
+ const storedRailWidth = (): number | null => {
455
+ if (railWidthChoice === undefined) {
456
+ railWidthChoice = null;
457
+ if (ctx.config.persistState !== false) {
458
+ try {
459
+ const stored = Number(
460
+ window.localStorage.getItem(`${ctx.currentKeyPrefix()}rail-width`)
461
+ );
462
+ if (stored) railWidthChoice = stored;
463
+ } catch {
464
+ /* blocked storage: the config width stands */
465
+ }
466
+ }
467
+ }
468
+ return railWidthChoice;
469
+ };
470
+
471
+ /**
472
+ * Expanded rail width, shared by the column and the floating overlay. A
473
+ * dragged width outranks config, the way the collapsed state does: a live
474
+ * `update()` must not undo what the visitor chose.
475
+ */
476
+ const railWidth = (): number =>
477
+ clampRailWidth(
478
+ storedRailWidth() ?? ctx.config.features?.history?.rail?.width ?? 260
479
+ );
480
+
481
+ const setStoredRailWidth = (next: number): void => {
482
+ railWidthChoice = next;
483
+ if (ctx.config.persistState === false) return;
484
+ try {
485
+ window.localStorage.setItem(`${ctx.currentKeyPrefix()}rail-width`, String(next));
486
+ } catch {
487
+ /* blocked storage: the in-memory value above is the fallback */
488
+ }
489
+ };
490
+ /**
491
+ * Overlay mode: the collapsed rail is a trigger in the conversation header
492
+ * plus a floating host, so there is no icon column and no mounted view at
493
+ * rest. `collapsed` then means "not pinned".
494
+ */
495
+ const railOverlayMode = (): boolean =>
496
+ railCollapsible() &&
497
+ ctx.config.features?.history?.rail?.collapsedBehavior === "overlay";
498
+ /** True while the view is mounted in the floating host, not a rail column. */
499
+ let railOverlayOpen = false;
500
+
501
+ /** Decorative image for a config-supplied rail icon or brand URL. */
502
+ const railIconImage = (src: string): HTMLImageElement => {
503
+ const image = createElement("img");
504
+ image.src = src;
505
+ image.alt = "";
506
+ image.setAttribute("aria-hidden", "true");
507
+ return image;
508
+ };
509
+
510
+ /**
511
+ * One brand declaration, resolved here where the lucide registry lives, into
512
+ * the callback both rail placements call: the expanded heading and the
513
+ * collapsed toggle's rest face. Precedence render > iconUrl > icon; the
514
+ * icon cases resolve once (so an unknown name warns once) and each caller
515
+ * gets its own copy, since both faces can be in the DOM at once.
516
+ */
517
+ const railBrandNode = ():
518
+ | ((collapsed: boolean) => Element | null)
519
+ | undefined => {
520
+ const brand = ctx.config.features?.history?.rail?.brand;
521
+ if (!brand) return undefined;
522
+ let warned = false;
523
+ let mark: Element | null | undefined;
524
+ return (collapsed) => {
525
+ if (brand.render) {
526
+ try {
527
+ return brand.render({ collapsed }) ?? null;
528
+ } catch (error) {
529
+ if (!warned) {
530
+ warned = true;
531
+ console.warn("[persona] history rail brand threw", error);
532
+ }
533
+ return null;
534
+ }
535
+ }
536
+ if (mark === undefined) {
537
+ if (brand.iconUrl) mark = railIconImage(brand.iconUrl);
538
+ else mark = brand.icon ? ctx.renderLucideIcon(brand.icon, 20) : null;
539
+ }
540
+ return mark ? (mark.cloneNode(true) as Element) : null;
541
+ };
542
+ };
543
+
544
+ /**
545
+ * Config nav sections, normalized for the size-capped chunk: icon precedence
546
+ * (renderIcon > iconUrl > icon) collapses to one memoized thunk resolved
547
+ * here, where the lucide registry already lives, and every host callback gets
548
+ * a warn-once-per-section guard.
549
+ */
550
+ const configNavSections = (): HistoryRailSection[] =>
551
+ ctx.config.features?.history?.rail?.sections?.map((section) => {
552
+ let warned = false;
553
+ const warn = (error: unknown): void => {
554
+ if (warned) return;
555
+ warned = true;
556
+ console.warn("[persona] history rail section threw", section.id, error);
557
+ };
558
+ return {
559
+ id: section.id,
560
+ title: section.title,
561
+ placement: section.placement ?? "above-conversations",
562
+ items: section.items.map((item) => {
563
+ // Built on first paint and cached: an unknown lucide name warns once,
564
+ // and a presentation flip reuses the node it made.
565
+ let node: Element | null | undefined;
566
+ return {
567
+ id: item.id,
568
+ label: item.label,
569
+ badge: item.badge,
570
+ iconNode: (): Element | null => {
571
+ if (node !== undefined) return node;
572
+ try {
573
+ if (item.renderIcon) node = item.renderIcon() ?? null;
574
+ else if (item.iconUrl) node = railIconImage(item.iconUrl);
575
+ else node = item.icon ? ctx.renderLucideIcon(item.icon, 20) : null;
576
+ } catch (error) {
577
+ warn(error);
578
+ node = null;
579
+ }
580
+ return node;
581
+ },
582
+ onSelect: () => {
583
+ try {
584
+ item.onSelect();
585
+ } catch (error) {
586
+ warn(error);
587
+ }
588
+ },
589
+ };
590
+ }),
591
+ };
592
+ }) ?? [];
593
+
594
+ /** One array for the chunk: config sections first in each placement bucket. */
595
+ const railNavSections = (
596
+ pluginSections: HistoryRailSection[]
597
+ ): HistoryRailSection[] | undefined => {
598
+ const sections = configNavSections();
599
+ for (const section of pluginSections) {
600
+ // Config owns the id space; a colliding plugin section is dropped.
601
+ if (sections.some((existing) => existing.id === section.id)) {
602
+ console.warn("[persona] duplicate history rail section id", section.id);
603
+ } else sections.push(section);
604
+ }
605
+ return sections.length ? sections : undefined;
606
+ };
607
+
608
+ /** Resolved once per widget: storage, else the configured default. */
609
+ let railCollapsed: boolean | null = null;
610
+
611
+ const railCollapseKey = (): string => `${ctx.currentKeyPrefix()}rail-collapsed`;
612
+
613
+ // localStorage access throws (not just fails) in Safari private mode and
614
+ // partitioned iframes, so the resolved value is also the in-memory fallback.
615
+ const isRailCollapsed = (): boolean => {
616
+ if (railCollapsed === null) {
617
+ railCollapsed = ctx.config.features?.history?.rail?.defaultCollapsed === true;
618
+ if (ctx.config.persistState !== false) {
619
+ try {
620
+ const stored = window.localStorage.getItem(railCollapseKey());
621
+ if (stored) railCollapsed = stored === "1";
622
+ } catch {
623
+ /* blocked storage: the resolved default stands */
624
+ }
625
+ }
626
+ }
627
+ return railCollapsed;
628
+ };
629
+
630
+ const setRailCollapsed = (next: boolean): void => {
631
+ railCollapsed = next;
632
+ if (ctx.config.persistState === false) return;
633
+ try {
634
+ window.localStorage.setItem(railCollapseKey(), next ? "1" : "0");
635
+ } catch {
636
+ /* blocked storage: the in-memory value above is the fallback */
637
+ }
638
+ };
639
+
640
+ /**
641
+ * Collapsed only ever applies to a collapsible rail presentation, and never
642
+ * in overlay mode, where a mounted rail is always the expanded one.
643
+ */
644
+ const railShowsCollapsed = (): boolean =>
645
+ ctx.historyPresentation === "rail" &&
646
+ railCollapsible() &&
647
+ !railOverlayMode() &&
648
+ isRailCollapsed();
649
+
650
+ /** Overlay-mode counterpart, assigned with the overlay controller below. */
651
+ let toggleRailPinned: () => void = () => {};
652
+
653
+ const toggleRailCollapsed = (): void => {
654
+ // The rail's own toggle sits where the trigger does, so in overlay mode it
655
+ // is that control: it pins a floating rail and unpins a docked one.
656
+ if (railOverlayMode()) {
657
+ toggleRailPinned();
658
+ return;
659
+ }
660
+ setRailCollapsed(!isRailCollapsed());
661
+ historySurface?.view.setCollapsed(railShowsCollapsed());
662
+ ctx.applyRailChrome();
663
+ // The transcript column resizes beside the anchor; a clamp would bounce it.
664
+ ctx.repinAnchoredMessage();
665
+ };
666
+
667
+ /**
668
+ * One declaration, three artifacts: the binding below, the toggle's tooltip
669
+ * hint chip, and its `aria-keyshortcuts`. Null when unset or unparseable.
670
+ */
671
+ const railCollapseShortcut = (): {
672
+ combo: string;
673
+ hint: string;
674
+ aria: string;
675
+ } | null => {
676
+ const combo = ctx.config.features?.history?.rail?.collapseShortcut;
677
+ if (!combo || !parseCombo(combo)) return null;
678
+ return { combo, hint: formatCombo(combo), aria: ariaCombo(combo) };
679
+ };
680
+
681
+ // --- collapsed rail as a floating overlay --------------------------------
682
+ //
683
+ // Rest state is a trigger at the leading edge of the conversation header and
684
+ // nothing else: no column, no mounted view, and the history chunk unloaded
685
+ // until a hover warms it. The pointer entering floats the expanded rail over
686
+ // the conversation; clicking pins it back into the full-height column.
687
+
688
+ /** Grace before a pointer that left both surfaces dismisses the rail. */
689
+ const RAIL_OVERLAY_GRACE_MS = 300;
690
+ /**
691
+ * Gap from the trigger, the docked edge and the bottom. A var reference, not
692
+ * a number, so a live theme update reaches an open overlay unaided.
693
+ */
694
+ const RAIL_OVERLAY_MARGIN = "var(--persona-history-overlay-margin,8px)";
695
+
696
+ let railTriggerButton: HTMLButtonElement | null = null;
697
+ let railTriggerWrapper: HTMLElement | null = null;
698
+ /** Docking opens awaiting the history chunk; the trigger hides for them. */
699
+ let railPinPendingOpens = 0;
700
+ let railOverlayHost: HTMLElement | null = null;
701
+ let railGraceTimer: ReturnType<typeof setTimeout> | null = null;
702
+ /** Pointer is over the trigger or over the floating rail. */
703
+ let railPointerInside = false;
704
+ /**
705
+ * Uncovering the trigger re-enters it with no pointer movement at all, which
706
+ * would undo the dismissal that just uncovered it. That synthetic enter
707
+ * arrives within a frame or two of the unmount.
708
+ */
709
+ let railUncoveredUntil = 0;
710
+
711
+ /** Touch has no hover to open with, so it taps the overlay open instead. */
712
+ const coarsePointer = (): boolean =>
713
+ window.matchMedia?.("(pointer: coarse)").matches === true;
714
+
715
+ /** The trigger stands in for the collapsed rail, so it needs a rail width. */
716
+ const railTriggerApplies = (): boolean =>
717
+ historyAvailable() &&
718
+ railOverlayMode() &&
719
+ (ctx.historyPresentation ?? resolveHistoryPresentation()) === "rail";
720
+
721
+ /** Docked in its own column rather than floating over the conversation. */
722
+ const railPinned = (): boolean => ctx.historyVisible && !railOverlayOpen;
723
+
724
+ const cancelRailGrace = (): void => {
725
+ if (railGraceTimer !== null) clearTimeout(railGraceTimer);
726
+ railGraceTimer = null;
727
+ };
728
+
729
+ /** Keyboard focus warms without opening; Enter and Space commit. */
730
+ const warmHistoryChunk = (): void => {
731
+ // A failed load just leaves the overlay closed; the loader retries later.
732
+ void ctx.loadHistoryView().catch(() => {});
733
+ };
734
+
735
+ /** Leaving both surfaces dismisses, but only after a grace to come back in. */
736
+ const scheduleRailOverlayClose = (): void => {
737
+ cancelRailGrace();
738
+ if (!railOverlayOpen) return;
739
+ railGraceTimer = setTimeout(() => {
740
+ railGraceTimer = null;
741
+ if (railPointerInside || !railOverlayOpen) return;
742
+ // A keyboard visitor inside the floating rail never loses it to a stray
743
+ // pointer leaving the widget.
744
+ if (railOverlayHost?.contains(document.activeElement)) return;
745
+ // A pointer dismissal moves focus no more than a pointer open does.
746
+ closeHistory({ restoreFocus: false });
747
+ }, RAIL_OVERLAY_GRACE_MS);
748
+ };
749
+
750
+ /**
751
+ * Hover keep-alive is geometric, not element-based: a pointer travelling
752
+ * from the trigger to the rail crosses the conversation header, which is
753
+ * neither. The safe zone is the trigger, the rail, and the bridge between
754
+ * them: the rail's own horizontal extent, from the trigger's row down to the
755
+ * rail's top edge. Derived from the live rects, so a right-docked rail needs
756
+ * no separate case.
757
+ */
758
+ const inRailSafeZone = (x: number, y: number): boolean => {
759
+ const rail = railOverlayHost?.getBoundingClientRect();
760
+ if (!rail) return false;
761
+ const row = railTriggerButton?.getBoundingClientRect();
762
+ // The rail and the bridge are one band: the rail's own width, from the
763
+ // trigger's row down to the rail's bottom. The trigger is its own rect.
764
+ const top = row ? Math.min(row.top, rail.top) : rail.top;
765
+ return (
766
+ (x >= rail.left && x <= rail.right && y >= top && y <= rail.bottom) ||
767
+ (!!row && x >= row.left && x <= row.right && y >= row.top && y <= row.bottom)
768
+ );
769
+ };
770
+
771
+ const handleRailPointerMove = (event: PointerEvent): void => {
772
+ if (!railOverlayOpen) return;
773
+ railPointerInside = inRailSafeZone(event.clientX, event.clientY);
774
+ if (railPointerInside) cancelRailGrace();
775
+ // Re-arming on every outside move would restart the countdown forever.
776
+ else if (railGraceTimer === null) scheduleRailOverlayClose();
777
+ };
778
+
779
+ /**
780
+ * No dwell: the rail answers the pointer the moment it arrives, as fast as
781
+ * the chunk allows (already loaded, that is the same frame). The 300ms leave
782
+ * grace is what an accidental pass over the trigger costs.
783
+ */
784
+ const openRailOverlay = (opts?: { keyboard?: boolean }): void => {
785
+ if (ctx.historyVisible || !railTriggerApplies()) return;
786
+ railOverlayOpen = true;
787
+ void openHistory({
788
+ invoker: railTriggerButton,
789
+ keyboard: opts?.keyboard === true,
790
+ }).then(() => {
791
+ // The chunk can resolve after the pointer left, or not resolve at all.
792
+ if (!ctx.historyVisible) railOverlayOpen = false;
793
+ else if (!railPointerInside && document.activeElement !== railTriggerButton) {
794
+ closeHistory();
795
+ }
796
+ });
797
+ };
798
+
799
+ /**
800
+ * Floating, the rail's own toggle pins instead of collapsing, so it wears
801
+ * the expand label; docked, it says collapse again.
802
+ */
803
+ const syncRailToggleLabel = (): void => {
804
+ // The mounted element, not the surface: the first ctx.mount happens inside the
805
+ // surface constructor, before `historySurface` is assigned.
806
+ const toggle = historyMountedElement?.querySelector(
807
+ '[data-persona-history-focus="collapse"]'
808
+ );
809
+ if (!toggle) return;
810
+ toggle.setAttribute(
811
+ "aria-label",
812
+ railOverlayOpen
813
+ ? historyShellCopy.expandLabel
814
+ : historyShellCopy.collapseLabel
815
+ );
816
+ toggle.setAttribute("aria-expanded", railOverlayOpen ? "false" : "true");
817
+ };
818
+
819
+ /**
820
+ * Pin: the floating rail gives way to the full-height column, moving the
821
+ * SAME view element when one is already open.
822
+ */
823
+ const pinRail = (): void => {
824
+ setRailCollapsed(false);
825
+ const surface = historySurface;
826
+ if (!railOverlayOpen || !surface) {
827
+ void openHistory({ invoker: railTriggerButton });
828
+ return;
829
+ }
830
+ const refocus = document.activeElement === railTriggerButton;
831
+ railOverlayOpen = false;
832
+ unmountHistoryHosts();
833
+ // Detach before re-hosting, exactly as the panel/rail move does.
834
+ surface.element.remove();
835
+ mountHistoryElement(surface.element);
836
+ syncRailToggleLabel();
837
+ // The trigger stands down beside the rail's own toggle, so keyboard focus
838
+ // has to follow the control there.
839
+ if (refocus) focusHistoryEntry();
840
+ ctx.repinAnchoredMessage();
841
+ syncHistoryChromeImpl();
842
+ };
843
+
844
+ /** Unpin: the column closes and the trigger takes the control back. */
845
+ const unpinRail = (): void => {
846
+ setRailCollapsed(true);
847
+ closeHistory();
848
+ };
849
+
850
+ toggleRailPinned = (): void => {
851
+ if (!ctx.historyVisible || railOverlayOpen) pinRail();
852
+ else unpinRail();
853
+ };
854
+
855
+ /**
856
+ * The sidebar glyph, plainly. `rail.brand` belongs to the icon column, which
857
+ * has no other identity, and to the rail's own header; this control sits in
858
+ * a conversation header that already carries the agent's.
859
+ */
860
+ const buildRailTrigger = (): void => {
861
+ const shortcut = railCollapseShortcut();
862
+ const parts = ctx.createHeaderIconButton({
863
+ ariaLabel: historyShellCopy.expandLabel,
864
+ iconName: "panel-left",
865
+ wrapperClassName:
866
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center",
867
+ extraClassName: "persona-rail-trigger",
868
+ // Hovering answers with the rail itself; a bubble would race the flyover.
869
+ // The combo stays discoverable on the floating rail's own toggle.
870
+ tooltip: false,
871
+ attrs: {
872
+ "data-persona-rail-trigger": "",
873
+ "aria-controls": historyRegionId,
874
+ ...(shortcut ? { "aria-keyshortcuts": shortcut.aria } : {}),
875
+ },
876
+ });
877
+ const button = parts.button;
878
+ button.addEventListener("mouseenter", () => {
879
+ railPointerInside = true;
880
+ cancelRailGrace();
881
+ if (coarsePointer() || Date.now() < railUncoveredUntil) return;
882
+ openRailOverlay();
883
+ });
884
+ button.addEventListener("mouseleave", () => {
885
+ railPointerInside = false;
886
+ // A real departure ends the hold-off: the next enter is intent.
887
+ railUncoveredUntil = 0;
888
+ scheduleRailOverlayClose();
889
+ });
890
+ // Focus only warms: Enter and Space are how a keyboard visitor commits.
891
+ button.addEventListener("focus", () => {
892
+ if (!ctx.historyVisible) warmHistoryChunk();
893
+ });
894
+ button.addEventListener("click", (event) => {
895
+ if (!historyAvailable()) return;
896
+ // Touch taps the overlay open first and pins on a second tap. A pointer
897
+ // that can hover is already looking at the rail, and Enter/Space (a click
898
+ // with detail 0) is a commitment either way, so both pin outright.
899
+ if (event.detail !== 0 && coarsePointer() && !ctx.historyVisible) {
900
+ openRailOverlay();
901
+ } else pinRail();
902
+ });
903
+ railTriggerButton = button;
904
+ railTriggerWrapper = parts.wrapper;
905
+ };
906
+
907
+ /**
908
+ * The trigger is chrome, not surface state: it exists whenever a collapsed
909
+ * overlay rail could be opened, and stands down only while the rail is
910
+ * docked, where the rail's own header toggle is the same control.
911
+ */
912
+ const syncRailOverlayTrigger = (): void => {
913
+ // A header rebuild detaches it; a stale ref must not block recreation.
914
+ if (railTriggerWrapper && !railTriggerWrapper.isConnected) {
915
+ railTriggerWrapper = null;
916
+ railTriggerButton = null;
917
+ }
918
+ if (!railTriggerApplies()) {
919
+ railTriggerWrapper?.remove();
920
+ railTriggerWrapper = null;
921
+ railTriggerButton = null;
922
+ return;
923
+ }
924
+ if (!railTriggerButton) buildRailTrigger();
925
+ const wrapper = railTriggerWrapper;
926
+ const button = railTriggerButton;
927
+ if (!wrapper || !button) return;
928
+ // Leading edge of the conversation header, mirrored for a right rail.
929
+ const lead = railSide() !== "right" ? ctx.header.firstChild : null;
930
+ if ((lead ?? ctx.header.lastChild) !== wrapper) ctx.header.insertBefore(wrapper, lead);
931
+ wrapper.style.display =
932
+ railPinned() || railPinPendingOpens > 0 ? "none" : "";
933
+ button.setAttribute("aria-label", historyShellCopy.expandLabel);
934
+ button.setAttribute("aria-expanded", ctx.historyVisible ? "true" : "false");
935
+ // The floating rail hangs from this control, so a rebuild or a resize that
936
+ // moved it re-anchors what is already open.
937
+ if (railOverlayHost) ctx.applyRailChrome();
938
+ };
939
+
940
+ /**
941
+ * Click outside dismisses the floating rail. Portaled surfaces it opened
942
+ * itself (row menus, confirmations) are not "outside" it.
943
+ */
944
+ const handleRailOverlayPointerDown = (event: Event): void => {
945
+ if (!railOverlayOpen) return;
946
+ const target = event.target;
947
+ if (!(target instanceof Node)) return;
948
+ if (railOverlayHost?.contains(target) || railTriggerWrapper?.contains(target)) {
949
+ return;
950
+ }
951
+ if (
952
+ target instanceof Element &&
953
+ target.closest('.persona-dropdown-menu,[role="alertdialog"]')
954
+ ) {
955
+ return;
956
+ }
957
+ closeHistory({ restoreFocus: false });
958
+ };
959
+ document.addEventListener("pointerdown", handleRailOverlayPointerDown, true);
960
+ ctx.destroyCallbacks.push(() => {
961
+ document.removeEventListener("pointerdown", handleRailOverlayPointerDown, true);
962
+ // Attached only while the rail floats; removing an unattached one is free.
963
+ document.removeEventListener("pointermove", handleRailPointerMove);
964
+ railResizeRelease?.();
965
+ cancelRailGrace();
966
+ });
967
+
968
+ // --- drag-resize of the docked rail --------------------------------------
969
+
970
+ let railResizeHandle: HTMLElement | null = null;
971
+ /** Ends an in-flight drag: its listeners are on the document, not the handle. */
972
+ let railResizeRelease: (() => void) | null = null;
973
+ /** Arrow-key step, matching the reference sidebar's coarse nudge. */
974
+ const RAIL_RESIZE_STEP = 16;
975
+
976
+ const commitRailWidth = (next: number): void => {
977
+ setStoredRailWidth(clampRailWidth(next));
978
+ ctx.applyRailChrome();
979
+ // The transcript resized beside the anchor; a clamp would bounce it.
980
+ ctx.repinAnchoredMessage();
981
+ };
982
+
983
+ /** Mirrors the artifact split handle: pointer capture, document-level drag. */
984
+ const buildRailResizeHandle = (): HTMLElement => {
985
+ const handle = createElement("div", "persona-rail-resizer");
986
+ handle.tabIndex = 0;
987
+ handle.setAttribute("role", "separator");
988
+ handle.setAttribute("aria-orientation", "vertical");
989
+ handle.setAttribute("aria-valuemin", String(RAIL_MIN_WIDTH));
990
+ handle.setAttribute("aria-valuemax", String(RAIL_MAX_WIDTH));
991
+
992
+ handle.addEventListener("pointerdown", (event) => {
993
+ const host = railHost;
994
+ if (!host || event.button !== 0) return;
995
+ event.preventDefault();
996
+ railResizeRelease?.();
997
+ const startX = event.clientX;
998
+ const startWidth = host.getBoundingClientRect().width || railWidth();
999
+ // A leading rail widens as the pointer travels right; a trailing one
1000
+ // mirrors that.
1001
+ const direction = railSide() === "right" ? -1 : 1;
1002
+ let width = startWidth;
1003
+ const doc = ctx.mount.ownerDocument;
1004
+ // The collapse animation is a transition on this very basis; left on, it
1005
+ // would trail the pointer for the whole drag.
1006
+ host.style.transition = "none";
1007
+ const onMove = (move: PointerEvent): void => {
1008
+ width = clampRailWidth(startWidth + direction * (move.clientX - startX));
1009
+ // Straight onto the basis: a chrome pass per pointer move is waste.
1010
+ host.style.flex = `0 0 ${width}px`;
1011
+ handle.setAttribute("aria-valuenow", String(width));
1012
+ };
1013
+ const onUp = (): void => {
1014
+ railResizeRelease = null;
1015
+ doc.removeEventListener("pointermove", onMove);
1016
+ doc.removeEventListener("pointerup", onUp);
1017
+ doc.removeEventListener("pointercancel", onUp);
1018
+ host.style.removeProperty("transition");
1019
+ try {
1020
+ handle.releasePointerCapture(event.pointerId);
1021
+ } catch {
1022
+ /* the capture may already be gone */
1023
+ }
1024
+ commitRailWidth(width);
1025
+ };
1026
+ railResizeRelease = onUp;
1027
+ doc.addEventListener("pointermove", onMove);
1028
+ doc.addEventListener("pointerup", onUp);
1029
+ doc.addEventListener("pointercancel", onUp);
1030
+ try {
1031
+ handle.setPointerCapture(event.pointerId);
1032
+ } catch {
1033
+ /* pointer capture is an enhancement, not the mechanism */
1034
+ }
1035
+ });
1036
+
1037
+ handle.addEventListener("keydown", (event) => {
1038
+ // Arrows track the visual direction, so a trailing rail inverts them.
1039
+ const step = railSide() === "right" ? -RAIL_RESIZE_STEP : RAIL_RESIZE_STEP;
1040
+ const width = railWidth();
1041
+ const next =
1042
+ event.key === "ArrowRight"
1043
+ ? width + step
1044
+ : event.key === "ArrowLeft"
1045
+ ? width - step
1046
+ : event.key === "Home"
1047
+ ? RAIL_MIN_WIDTH
1048
+ : event.key === "End"
1049
+ ? RAIL_MAX_WIDTH
1050
+ : null;
1051
+ if (next === null) return;
1052
+ event.preventDefault();
1053
+ commitRailWidth(next);
1054
+ });
1055
+ return handle;
1056
+ };
1057
+
1058
+ /** Docked and expanded only: the floating rail and the icon column never resize. */
1059
+ const syncRailResizeHandle = (
1060
+ shell: HTMLElement,
1061
+ before: HTMLElement
1062
+ ): void => {
1063
+ if (!railResizable() || railShowsCollapsed()) {
1064
+ railResizeRelease?.();
1065
+ railResizeHandle?.remove();
1066
+ return;
1067
+ }
1068
+ const handle = railResizeHandle ?? buildRailResizeHandle();
1069
+ railResizeHandle = handle;
1070
+ handle.setAttribute("aria-label", historyShellCopy.resizeLabel);
1071
+ handle.setAttribute("aria-valuenow", String(railWidth()));
1072
+ // Between the two, on the edge the divider already faces.
1073
+ if (handle.nextElementSibling !== before) shell.insertBefore(handle, before);
1074
+ };
1075
+
1076
+ /**
1077
+ * Rail geometry is config-derived, so it must be re-derivable: a live
1078
+ * `update()` of `rail.width` / `rail.side` lands here, not only at ctx.mount.
1079
+ */
1080
+ ctx.applyRailChrome = (): void => {
1081
+ // The bar mirrors the docked edge, so the view hears about a side flip even
1082
+ // while it is presenting as a panel.
1083
+ historySurface?.view.setRailSide(railSide());
1084
+ const trailing = railSide() === "right";
1085
+ const overlay = railOverlayHost;
1086
+ if (overlay) {
1087
+ // Hangs from the trigger's row rather than the widget's top edge, so the
1088
+ // trigger stays visible and clickable above it. Measured, since a header
1089
+ // rebuild or a resize can move the trigger.
1090
+ const below = railTriggerButton
1091
+ ? railTriggerButton.getBoundingClientRect().bottom -
1092
+ ctx.container.getBoundingClientRect().top
1093
+ : 0;
1094
+ overlay.style.top = `calc(${Math.max(0, Math.round(below))}px + ${RAIL_OVERLAY_MARGIN})`;
1095
+ overlay.style.width = `${railWidth()}px`;
1096
+ overlay.style.left = trailing ? "" : RAIL_OVERLAY_MARGIN;
1097
+ overlay.style.right = trailing ? RAIL_OVERLAY_MARGIN : "";
1098
+ }
1099
+ const host = railHost;
1100
+ const shell = railShell;
1101
+ const column = railColumn;
1102
+ if (!host || !shell || !column) return;
1103
+ host.style.flex = `0 0 ${
1104
+ railShowsCollapsed() ? RAIL_COLLAPSED_WIDTH : railWidth()
1105
+ }px`;
1106
+ // The divider always faces the conversation, whichever edge the rail took.
1107
+ const divider = "1px solid var(--persona-divider,#e5e7eb)";
1108
+ host.style.borderRight = trailing ? "" : divider;
1109
+ host.style.borderLeft = trailing ? divider : "";
1110
+ const leading = trailing ? column : host;
1111
+ // Reorder only on an actual side flip: re-parenting blurs what it moves.
1112
+ if (shell.firstElementChild !== leading) {
1113
+ shell.append(leading, trailing ? host : column);
1114
+ }
1115
+ syncRailResizeHandle(shell, trailing ? host : column);
1116
+ };
1117
+
1118
+ /**
1119
+ * Rail is a shell-owned navigation column running the FULL widget height
1120
+ * beside a still-operable conversation: the shell header, `body`, the
1121
+ * in-container composer and any top-right action wrappers move into a column
1122
+ * next to the host, and the shell takes the header's old container slot.
1123
+ */
1124
+ const mountRailHost = (element: HTMLElement): void => {
1125
+ // A pending docking open already built the shell around a placeholder;
1126
+ // the arriving view takes its slot with no reflow around it.
1127
+ if (railPlaceholder && railHost) {
1128
+ railPlaceholder.replaceWith(element);
1129
+ railPlaceholder = null;
1130
+ ctx.applyRailChrome();
1131
+ return;
1132
+ }
1133
+ const shell = createElement("div", "persona-history-rail-shell");
1134
+ shell.style.cssText = "display:flex;flex-direction:row;flex:1 1 auto;min-height:0";
1135
+ const column = createElement("div", "persona-history-rail-conversation");
1136
+ // position: top-right close/clear wrappers anchor here instead of the
1137
+ // container, or they would float over the rail.
1138
+ column.style.cssText =
1139
+ "display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;position:relative";
1140
+ // The collapse transition is a rule in the history chunk's stylesheet: an
1141
+ // inline one could not carry the reduced-motion query.
1142
+ const host = createElement("div", "persona-history-rail-host");
1143
+ host.style.cssText = "display:flex;min-height:0;overflow:hidden";
1144
+
1145
+ ctx.container.insertBefore(shell, ctx.header.parentNode === ctx.container ? ctx.header : ctx.body);
1146
+ for (const node of railBorrowed()) {
1147
+ if (node?.parentNode === ctx.container) column.appendChild(node);
1148
+ }
1149
+ shell.append(host, column);
1150
+ host.appendChild(element);
1151
+ railShell = shell;
1152
+ railHost = host;
1153
+ railColumn = column;
1154
+ ctx.applyRailChrome();
1155
+ };
1156
+
1157
+ /**
1158
+ * Space reservation for a docking open that still awaits the chunk: the full
1159
+ * rail shell mounts around an empty stand-in at the final width, so the
1160
+ * conversation and its header paint at their docked geometry from the first
1161
+ * frame instead of being pushed over when the view lands.
1162
+ */
1163
+ const mountRailPlaceholder = (): void => {
1164
+ if (railShell) return;
1165
+ const placeholder = createElement(
1166
+ "div",
1167
+ "persona-history-rail-placeholder"
1168
+ );
1169
+ placeholder.style.cssText = "flex:1 1 auto;min-height:0";
1170
+ railPlaceholder = placeholder;
1171
+ mountRailHost(placeholder);
1172
+ ctx.repinAnchoredMessage();
1173
+ };
1174
+
1175
+ /** Hands the reserved column back (failed or re-presented open). */
1176
+ const clearRailPlaceholder = (): void => {
1177
+ if (!railPlaceholder) return;
1178
+ unmountHistoryHosts();
1179
+ ctx.repinAnchoredMessage();
1180
+ };
1181
+
1182
+ /**
1183
+ * Floating host for the collapsed overlay rail: the expanded view elevated
1184
+ * over a conversation that keeps its own layout, so nothing is borrowed and
1185
+ * nothing reflows around it.
1186
+ */
1187
+ const mountRailOverlayHost = (element: HTMLElement): void => {
1188
+ const host = createElement("div", "persona-history-rail-overlay");
1189
+ // Every themeable value is a var() reference with its default in the
1190
+ // fallback, so an unset token costs nothing and a live one lands at once.
1191
+ host.style.cssText =
1192
+ `position:absolute;bottom:${RAIL_OVERLAY_MARGIN};` +
1193
+ `display:flex;overflow:hidden;z-index:${PORTALED_OVERLAY_Z_INDEX - 1};` +
1194
+ "border-radius:var(--persona-history-overlay-radius,16px);" +
1195
+ "background:var(--persona-history-overlay-bg,var(--persona-container,#f7f7f8));" +
1196
+ "box-shadow:var(--persona-history-overlay-shadow,0 12px 40px rgba(0,0,0,0.25))";
1197
+ // The conversation column already relies on a positioned container.
1198
+ ctx.container.style.position = "relative";
1199
+ ctx.container.appendChild(host);
1200
+ host.appendChild(element);
1201
+ // The safe zone spans nodes the rail does not own, so hover is tracked by
1202
+ // position for as long as it is open.
1203
+ document.addEventListener("pointermove", handleRailPointerMove);
1204
+ railOverlayHost = host;
1205
+ ctx.applyRailChrome();
1206
+ syncRailToggleLabel();
1207
+ };
1208
+
1209
+ const unmountHistoryHosts = (): void => {
1210
+ restorePanelHost?.();
1211
+ if (railOverlayHost) {
1212
+ // Dismissed under the pointer: hold the hover off until the synthetic
1213
+ // enter from uncovering the trigger has passed.
1214
+ if (railPointerInside) railUncoveredUntil = Date.now() + 150;
1215
+ document.removeEventListener("pointermove", handleRailPointerMove);
1216
+ railOverlayHost.remove();
1217
+ railOverlayHost = null;
1218
+ }
1219
+ const shell = railShell;
1220
+ if (shell) {
1221
+ // A drag in flight owns document listeners the host is about to lose.
1222
+ railResizeRelease?.();
1223
+ // Live bindings, in panel order, before the slot the shell took. A header
1224
+ // restored first also takes its inline action wrappers back with it, so
1225
+ // the `contains` check skips them.
1226
+ for (const node of railBorrowed()) {
1227
+ if (node && shell.contains(node)) ctx.container.insertBefore(node, shell);
1228
+ }
1229
+ shell.remove();
1230
+ railShell = null;
1231
+ railHost = null;
1232
+ railColumn = null;
1233
+ railPlaceholder = null;
1234
+ }
1235
+ };
1236
+
1237
+ /** Shell-owned chrome applied to whatever element arbitration produced. */
1238
+ const prepareHistoryElement = (element: HTMLElement): void => {
1239
+ historyMountedElement = element;
1240
+ element.id = historyRegionId;
1241
+ // Host-side flex sizing: the chunk sizes itself to 100%, the shell decides
1242
+ // how it participates in the column/row it was just dropped into.
1243
+ element.style.flex = "1 1 auto";
1244
+ element.style.minHeight = "0";
1245
+ };
1246
+
1247
+ /** The chunk owns its own classes/labels; the shell only picks the host. */
1248
+ const mountHistoryElement = (element: HTMLElement): void => {
1249
+ prepareHistoryElement(element);
1250
+ // The open that reserved the column can resolve to another host (a width
1251
+ // change mid-load re-presents as panel); the stand-in must not outlive it.
1252
+ if (ctx.historyPresentation !== "rail" || railOverlayOpen) {
1253
+ clearRailPlaceholder();
1254
+ }
1255
+ if (ctx.historyPresentation !== "rail") mountPanelHost(element);
1256
+ else if (railOverlayOpen) mountRailOverlayHost(element);
1257
+ else mountRailHost(element);
1258
+ };
1259
+
1260
+ /**
1261
+ * Live rail <-> panel transition. ONE view instance survives the move, so the
1262
+ * list, its fixed operation context, and any pending work are preserved. A
1263
+ * custom full view is re-invoked with the new presentation value.
1264
+ */
1265
+ const syncHistoryPresentation = (): void => {
1266
+ // The trigger belongs to a rail-capable width, so it resolves here too, and
1267
+ // the header toggle stands down beside whatever it resolved to.
1268
+ syncRailOverlayTrigger();
1269
+ syncHistoryButton();
1270
+ if (!ctx.historyVisible || !historySurface) return;
1271
+ const next = resolveHistoryPresentation();
1272
+ if (next === ctx.historyPresentation) return;
1273
+ const focusKey = document.activeElement;
1274
+ // The bar's contents may be focused inside the shell header, not the view.
1275
+ const refocus =
1276
+ focusKey instanceof HTMLElement &&
1277
+ (historySurface.element.contains(focusKey) ||
1278
+ historyHeaderHost?.contains(focusKey) === true)
1279
+ ? focusKey
1280
+ : null;
1281
+ unmountHistoryHosts();
1282
+ // Detach before re-hosting so a mid-move re-arbitration cannot re-insert
1283
+ // the surface into the host it is being moved out of.
1284
+ historySurface.element.remove();
1285
+ // Rail is always inline; the panel host re-externalizes on ctx.mount.
1286
+ historySurface.view.setHeaderPlacement("inline");
1287
+ ctx.historyPresentation = next;
1288
+ historySurface.view.setPresentation(next);
1289
+ // Panel always shows the whole list; returning to rail restores the state.
1290
+ historySurface.view.setCollapsed(railShowsCollapsed());
1291
+ historySurface.requestRender();
1292
+ if (!historySurface.element.isConnected) {
1293
+ mountHistoryElement(historySurface.element);
1294
+ }
1295
+ if (refocus?.isConnected) refocus.focus();
1296
+ else focusHistoryEntry();
1297
+ ctx.syncScrollToBottomButton();
1298
+ // A one-frame layout removal above the anchored message clamps scrollTop.
1299
+ ctx.repinAnchoredMessage();
1300
+ syncHistoryChromeImpl();
1301
+ };
1302
+
1303
+ // --- open / close --------------------------------------------------------
1304
+
1305
+ /** The bar lives in the shell header while it is hosted there. */
1306
+ const queryHistoryOwned = <T extends HTMLElement>(
1307
+ element: HTMLElement,
1308
+ selector: string
1309
+ ): T | null =>
1310
+ historyHeaderHost?.querySelector<T>(selector) ??
1311
+ element.querySelector<T>(selector);
1312
+
1313
+ const focusHistoryEntry = (): void => {
1314
+ const element = historySurface?.element ?? historyMountedElement;
1315
+ if (!element) return;
1316
+ const close = queryHistoryOwned(
1317
+ element,
1318
+ // The rail's leading control is a collapse toggle, not a close.
1319
+ '[data-persona-history-focus="close"],[data-persona-history-focus="collapse"]'
1320
+ );
1321
+ if (close) {
1322
+ close.focus();
1323
+ return;
1324
+ }
1325
+ const heading =
1326
+ queryHistoryOwned<HTMLElement>(element, ".persona-history-title") ??
1327
+ // Custom contents may expose neither: the region itself is the fallback.
1328
+ element;
1329
+ heading.tabIndex = -1;
1330
+ heading.focus();
1331
+ };
1332
+
1333
+ const openHistory = async (opts?: {
1334
+ returnSurface?: HistoryReturnSurface;
1335
+ invoker?: HTMLElement | null;
1336
+ /** Rail only: an open with no keyboard behind it must not move focus. */
1337
+ keyboard?: boolean;
1338
+ /** `false` skips the entry focus entirely (programmatic/preview opens). */
1339
+ focus?: boolean;
1340
+ }): Promise<void> => {
1341
+ if (!historyAvailable() || ctx.historyVisible) return;
1342
+ const provider = historyProvider;
1343
+ if (!provider) return;
1344
+ // An unpinned overlay rail IS its trigger, so restoring that state must
1345
+ // render chrome only: no surface, and no chunk fetched for it.
1346
+ if (!railOverlayOpen && railTriggerApplies() && isRailCollapsed()) {
1347
+ syncRailOverlayTrigger();
1348
+ return;
1349
+ }
1350
+ // A reopen mid-exit finishes the outgoing teardown first: never two
1351
+ // surfaces, never a restore that lands after this one mounts.
1352
+ settleHistoryExit();
1353
+ const token = ++historyOpenToken;
1354
+ historyReturnSurface = opts?.returnSurface ?? "conversation";
1355
+ historyInvoker = opts?.invoker ?? historyButton;
1356
+
1357
+ // An open that will dock the rail takes its final geometry for the chunk
1358
+ // load: the trigger hides (painted, the glyph flashes at the header's
1359
+ // leading edge and then jumps to the mounted rail's own toggle) and the
1360
+ // column is reserved (unreserved, the conversation header paints at the
1361
+ // widget edge and is pushed over when the rail lands).
1362
+ const pinPending = railTriggerApplies() && !railOverlayOpen;
1363
+ if (pinPending) {
1364
+ railPinPendingOpens += 1;
1365
+ syncRailOverlayTrigger();
1366
+ mountRailPlaceholder();
1367
+ }
1368
+ let module: Awaited<ReturnType<typeof loadHistoryView>>;
1369
+ try {
1370
+ module = await ctx.loadHistoryView();
1371
+ } catch {
1372
+ // Lazy-chunk failure keeps the invoking surface interactive and retryable.
1373
+ ctx.announceHistory(historyShellCopy.openHistoryLabel);
1374
+ if (pinPending) {
1375
+ railPinPendingOpens -= 1;
1376
+ // A superseded open leaves the shared chrome to its successor.
1377
+ if (token === historyOpenToken) {
1378
+ clearRailPlaceholder();
1379
+ syncRailOverlayTrigger();
1380
+ }
1381
+ }
1382
+ return;
1383
+ }
1384
+ if (pinPending) railPinPendingOpens -= 1;
1385
+ if (token !== historyOpenToken || ctx.historyVisible) return;
1386
+
1387
+ // One scope for the whole opened view; every operation reuses it.
1388
+ historyOperationContext = { scope: historyScope() };
1389
+ ctx.historyPresentation = resolveHistoryPresentation();
1390
+ ctx.historyVisible = true;
1391
+
1392
+ // Built externally when there is a shell header to host it in; the panel
1393
+ // host re-decides on ctx.mount, so this only avoids a needless first insert.
1394
+ const initialHeaderPlacement: HistoryHeaderPlacement =
1395
+ ctx.historyPresentation === "panel" &&
1396
+ ctx.config.layout?.showHeader !== false &&
1397
+ ctx.header.style.display !== "none"
1398
+ ? "external"
1399
+ : "inline";
1400
+
1401
+ const rowAvatar = ctx.config.features?.history?.rowAvatar;
1402
+ const shortcut = railCollapseShortcut();
1403
+ const collapseShortcutStrings = shortcut
1404
+ ? { hint: shortcut.hint, aria: shortcut.aria }
1405
+ : null;
1406
+
1407
+ const baseViewOptions: HistoryViewOptions = {
1408
+ provider,
1409
+ context: historyOperationContext,
1410
+ targetId: activeHistoryTargetId(),
1411
+ presentation: ctx.historyPresentation,
1412
+ collapsible: railCollapsible(),
1413
+ collapsed: railShowsCollapsed(),
1414
+ railSide: railSide(),
1415
+ renderRailHeader: ctx.config.features?.history?.rail?.renderHeader,
1416
+ railBrand: railBrandNode(),
1417
+ onToggleCollapse: toggleRailCollapsed,
1418
+ // Formatted strings only: the size-capped chunk never imports shortcuts.
1419
+ ...(collapseShortcutStrings
1420
+ ? { collapseShortcut: collapseShortcutStrings }
1421
+ : {}),
1422
+ headerPlacement: initialHeaderPlacement,
1423
+ showScopeStatus: ctx.config.features?.history?.showScopeStatus !== false,
1424
+ showDelete: ctx.config.features?.history?.showDelete !== false,
1425
+ showDeleteAll: ctx.config.features?.history?.showDeleteAll !== false,
1426
+ ...(ctx.config.features?.history?.listActions?.length
1427
+ ? { listActions: ctx.config.features.history.listActions }
1428
+ : {}),
1429
+ // Rows borrow the launcher's IMAGE mark only: agentIconText carries the
1430
+ // merged 💬 default, which would put a placeholder glyph on every row.
1431
+ // No mark at all means text-only rows, the assistant-list default.
1432
+ rowAvatar:
1433
+ rowAvatar === false
1434
+ ? false
1435
+ : typeof rowAvatar === "string"
1436
+ ? rowAvatar
1437
+ : ctx.config.launcher?.iconUrl,
1438
+ activeConversationId: ctx.session.getActiveConversationId(),
1439
+ ...(ctx.config.features?.history?.grouping
1440
+ ? { grouping: ctx.config.features.history.grouping }
1441
+ : {}),
1442
+ ...(ctx.config.features?.history?.copy
1443
+ ? { copy: ctx.config.features.history.copy }
1444
+ : {}),
1445
+ ...(ctx.config.features?.history?.pageSize !== undefined
1446
+ ? { pageSize: ctx.config.features.history.pageSize }
1447
+ : {}),
1448
+ // The pending/error surface owns failures (optimistic open), so the
1449
+ // row must not double-report them.
1450
+ onSelect: (conversationId) =>
1451
+ openHistoryConversation(conversationId, { focusComposer: true }).catch(
1452
+ () => {}
1453
+ ),
1454
+ onActiveConversationChange: (summary) =>
1455
+ ctx.setActiveConversationSummary(summary),
1456
+ // The chunk is size-capped, so it borrows the shell's tooltip module.
1457
+ attachTooltip: ctx.attachTooltip,
1458
+ onStartNew: () => startNewConversation({ focusComposer: true }),
1459
+ onClose: () => closeHistory(),
1460
+ onRequestDeleteConversation: (conversationId) =>
1461
+ requestDeleteConversation(conversationId),
1462
+ onRequestClearHistory: () => requestClearConversationHistory(),
1463
+ ...(provider.resetDevice
1464
+ ? { onRequestResetIdentity: () => requestResetHistoryIdentity() }
1465
+ : {}),
1466
+ };
1467
+
1468
+ // Plugin hooks arbitrate around the default view; the shell keeps placement,
1469
+ // open/close, Escape, confirmations, announcements, and focus.
1470
+ historySurface = createHistoryRenderSurface({
1471
+ plugins: ctx.plugins,
1472
+ config: ctx.config,
1473
+ getPresentation: () => ctx.historyPresentation ?? "panel",
1474
+ getReturnSurface: () => historyReturnSurface,
1475
+ close: () => closeHistory(),
1476
+ createView: ({ slots, renderDom, onModelChange, railSections }) => {
1477
+ // Tracked before `historySurface` is assigned: the first ctx.mount happens
1478
+ // inside this constructor and already needs the handle.
1479
+ historyViewHandle = module.createHistoryView({
1480
+ ...baseViewOptions,
1481
+ railSections: railNavSections(railSections),
1482
+ slots,
1483
+ renderDom,
1484
+ onModelChange,
1485
+ onAnnounce: ctx.announceHistory,
1486
+ });
1487
+ return historyViewHandle;
1488
+ },
1489
+ onElementChanged: (next, previous) => {
1490
+ if (!previous?.isConnected) return mountHistoryElement(next);
1491
+ prepareHistoryElement(next);
1492
+ previous.replaceWith(next);
1493
+ // Default <-> custom re-arbitration changes who owns the bar.
1494
+ enforcePanelHost();
1495
+ },
1496
+ });
1497
+ historySurface.view.setNewConversationRequired(
1498
+ ctx.historySessionState.recovery === "new_conversation_required"
1499
+ );
1500
+ // The panel makes the conversation inert behind it, so it always takes
1501
+ // focus. The rail leaves it operable: a pointer or programmatic open would
1502
+ // only leave a keyboard ring on the toggle, so it keeps focus where it is.
1503
+ // `focus: false` opts out entirely: the caller owns focus (preview replay).
1504
+ if (
1505
+ opts?.focus !== false &&
1506
+ (ctx.historyPresentation !== "rail" || opts?.keyboard === true)
1507
+ ) {
1508
+ focusHistoryEntry();
1509
+ }
1510
+ ctx.syncScrollToBottomButton();
1511
+ ctx.repinAnchoredMessage();
1512
+ syncHistoryChromeImpl();
1513
+ ctx.eventBus.emit("history:opened", {
1514
+ presentation: ctx.historyPresentation,
1515
+ returnSurface: historyReturnSurface,
1516
+ timestamp: Date.now(),
1517
+ });
1518
+ };
1519
+
1520
+ /**
1521
+ * Close is: exit animation -> unmount and restore the hidden chrome ->
1522
+ * restore focus. The teardown half is deferred behind the view's `playExit()`
1523
+ * promise, so it is exposed here for whoever preempts it (a reopen, a widget
1524
+ * teardown). Idempotent, and a no-op while nothing is leaving.
1525
+ */
1526
+ let settleHistoryExit: () => void = () => {};
1527
+
1528
+ const closeHistory = (opts?: { restoreFocus?: boolean }): void => {
1529
+ if (!ctx.historyVisible) return;
1530
+ historyOpenToken += 1;
1531
+ const returnSurface = historyReturnSurface;
1532
+ const invoker = historyInvoker;
1533
+ const surface = historySurface;
1534
+ const restoreInvokerFocus = opts?.restoreFocus !== false;
1535
+ // Flipped before the animation: a second close is a no-op and a reopen
1536
+ // mounts fresh rather than re-entering the surface that is leaving.
1537
+ ctx.historyVisible = false;
1538
+
1539
+ let done = false;
1540
+ let timer: ReturnType<typeof setTimeout> | null = null;
1541
+ const finish = (): void => {
1542
+ if (done) return;
1543
+ done = true;
1544
+ settleHistoryExit = () => {};
1545
+ if (timer !== null) clearTimeout(timer);
1546
+ // The floating rail is torn down with the surface it hosted.
1547
+ railOverlayOpen = false;
1548
+ cancelRailGrace();
1549
+ // Before focus restoration below: the invoker lives in the shell header,
1550
+ // which is inert until this restores it.
1551
+ unmountHistoryHosts();
1552
+ // Dispose before destroy: cleanups belong to the render being torn down.
1553
+ surface?.dispose();
1554
+ surface?.view.destroy();
1555
+ surface?.element.remove();
1556
+ // A reopen that preempted this exit already owns the shell state.
1557
+ if (historySurface === surface) {
1558
+ historySurface = null;
1559
+ historyViewHandle = null;
1560
+ historyMountedElement = null;
1561
+ ctx.historyPresentation = null;
1562
+ historyOperationContext = null;
1563
+ historyInvoker = null;
1564
+ }
1565
+ ctx.syncScrollToBottomButton();
1566
+ // Removing a full-height host above the anchored message clamps scrollTop.
1567
+ ctx.repinAnchoredMessage();
1568
+ syncHistoryChromeImpl();
1569
+ if (restoreInvokerFocus) {
1570
+ const target = invoker ?? historyButton;
1571
+ if (target?.isConnected) target.focus();
1572
+ else ctx.maybeFocusInput();
1573
+ }
1574
+ ctx.eventBus.emit("history:closed", { returnSurface, timestamp: Date.now() });
1575
+ };
1576
+
1577
+ // Only the arbitrated default view animates: a plugin full view owns its
1578
+ // own element and never got an entrance either.
1579
+ const exit =
1580
+ surface && surface.element === surface.view.element
1581
+ ? surface.view.playExit()
1582
+ : null;
1583
+ if (!exit) {
1584
+ finish();
1585
+ return;
1586
+ }
1587
+ settleHistoryExit = finish;
1588
+ // A cancelled or never-settling animation must never wedge the close.
1589
+ timer = setTimeout(finish, HISTORY_EXIT_TIMEOUT_MS);
1590
+ void exit.then(finish);
1591
+ };
1592
+
1593
+ /** Escape returns to the recorded invoking surface, like the back control. */
1594
+ const handleHistoryKeydown = (event: KeyboardEvent): void => {
1595
+ if (event.key !== "Escape" || !ctx.historyVisible || !historySurface) return;
1596
+ // Rail leaves the conversation operable: only its own focus closes it, and
1597
+ // the floating rail's scope includes the trigger it hangs from.
1598
+ if (ctx.historyPresentation === "rail") {
1599
+ const target = event.target;
1600
+ if (
1601
+ !(target instanceof Node) ||
1602
+ !(
1603
+ historySurface.element.contains(target) ||
1604
+ (railOverlayOpen && railTriggerWrapper?.contains(target) === true)
1605
+ )
1606
+ ) {
1607
+ return;
1608
+ }
1609
+ }
1610
+ event.preventDefault();
1611
+ event.stopPropagation();
1612
+ closeHistory();
1613
+ };
1614
+ ctx.container.addEventListener("keydown", handleHistoryKeydown);
1615
+ ctx.destroyCallbacks.push(() =>
1616
+ ctx.container.removeEventListener("keydown", handleHistoryKeydown)
1617
+ );
1618
+
1619
+ // --- session operations --------------------------------------------------
1620
+
1621
+ // --- optimistic conversation open ---------------------------------------
1622
+ // Selection acknowledges within a frame: the composer gates immediately, the
1623
+ // stand-in takes the surface only if the fetch outlasts the show delay, and
1624
+ // the transcript hydrates when the fetch lands. The token invalidates a stale
1625
+ // resolution after a newer open, a new conversation, or a failure the visitor
1626
+ // navigated away from.
1627
+ let conversationOpenToken = 0;
1628
+ // Show delay: opens that land inside it swap straight from the welcome or the
1629
+ // previous conversation to the loaded transcript, so no skeleton ever flashes.
1630
+ const CONVERSATION_OPEN_TAKEOVER_DELAY_MS = 250;
1631
+ let conversationOpenTakeoverTimer: ReturnType<typeof setTimeout> | null = null;
1632
+
1633
+ const cancelConversationOpenTakeover = (): void => {
1634
+ if (conversationOpenTakeoverTimer) clearTimeout(conversationOpenTakeoverTimer);
1635
+ conversationOpenTakeoverTimer = null;
1636
+ };
1637
+ ctx.destroyCallbacks.push(cancelConversationOpenTakeover);
1638
+
1639
+ /** Hands the surface to the mounted stand-in. Idempotent by re-application. */
1640
+ const engageConversationOpenTakeover = (): void => {
1641
+ cancelConversationOpenTakeover();
1642
+ const element = ctx.conversationOpenPendingEl;
1643
+ if (!element) return;
1644
+ ctx.conversationOpenTakeover = true;
1645
+ element.hidden = false;
1646
+ ctx.messagesWrapper.style.display = "none";
1647
+ // The welcome is a sibling of the transcript, so hiding the wrapper alone
1648
+ // would leave it painted above the stand-in.
1649
+ ctx.updateWelcome();
1650
+ };
1651
+
1652
+ const clearConversationOpenPending = (): void => {
1653
+ conversationOpenToken += 1;
1654
+ cancelConversationOpenTakeover();
1655
+ if (!ctx.conversationOpenPendingEl) return;
1656
+ ctx.conversationOpenPendingEl.remove();
1657
+ ctx.conversationOpenPendingEl = null;
1658
+ ctx.conversationOpenTakeover = false;
1659
+ ctx.messagesWrapper.style.removeProperty("display");
1660
+ setHistoryHostInert(ctx.footer, false);
1661
+ // Restores the welcome when navigation lands back on an empty transcript.
1662
+ ctx.updateWelcome();
1663
+ };
1664
+
1665
+ /**
1666
+ * Commit fade for the reopened transcript. WAAPI, not a CSS transition: the
1667
+ * wrapper is the morph target, and post-render inline state is stripped by
1668
+ * every render inside the completion window.
1669
+ */
1670
+ const fadeInTranscript = (): void => {
1671
+ try {
1672
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) {
1673
+ return;
1674
+ }
1675
+ ctx.messagesWrapper.animate([{ opacity: 0 }, { opacity: 1 }], {
1676
+ duration: 150,
1677
+ easing: "ease-out",
1678
+ });
1679
+ } catch {
1680
+ /* environments without WAAPI land the transcript without motion */
1681
+ }
1682
+ };
1683
+
1684
+ // The stand-in surfaces get the transcript's centered column from their
1685
+ // container classes, so the hydrated messages land exactly where they stood.
1686
+ const skeletonBubble = (width: string, trailing: boolean): HTMLElement =>
1687
+ createNode("div", {
1688
+ className: cx(
1689
+ "persona-conversation-loading-bubble",
1690
+ trailing && "persona-conversation-loading-bubble--trailing"
1691
+ ),
1692
+ attrs: { "aria-hidden": "true" },
1693
+ style: { width },
1694
+ });
1695
+
1696
+ const buildConversationOpenSkeleton = (): HTMLElement =>
1697
+ createNode(
1698
+ "div",
1699
+ { className: "persona-conversation-loading-body" },
1700
+ skeletonBubble("58%", false),
1701
+ skeletonBubble("72%", true),
1702
+ skeletonBubble("44%", false)
1703
+ );
1704
+
1705
+ const mountConversationOpenState = (element: HTMLElement): void => {
1706
+ ctx.conversationOpenPendingEl?.remove();
1707
+ ctx.conversationOpenPendingEl = element;
1708
+ // The takeover is deferred, so the stand-in mounts hidden; one that replaces
1709
+ // an already-engaged stand-in keeps the surface instead of blanking it.
1710
+ element.hidden = !ctx.conversationOpenTakeover;
1711
+ ctx.body.insertBefore(element, ctx.messagesWrapper);
1712
+ cancelConversationOpenTakeover();
1713
+ if (!ctx.conversationOpenTakeover) {
1714
+ conversationOpenTakeoverTimer = setTimeout(
1715
+ engageConversationOpenTakeover,
1716
+ CONVERSATION_OPEN_TAKEOVER_DELAY_MS
1717
+ );
1718
+ }
1719
+ // The composer must not send into the conversation being replaced. The
1720
+ // panel exit restores the footer after its animation, so the close path
1721
+ // re-asserts this gate (see the history:closed re-assert below).
1722
+ setHistoryHostInert(ctx.footer, true);
1723
+ };
1724
+
1725
+ const showConversationOpenPending = (): void => {
1726
+ // Same plugin -> config -> default chain as the streaming indicator. The
1727
+ // hook replaces the skeleton only; the container keeps the centered
1728
+ // column, the status role, and the composer gate.
1729
+ const context: LoadingIndicatorRenderContext = {
1730
+ config: ctx.config,
1731
+ streaming: false,
1732
+ location: "conversation-open",
1733
+ defaultRenderer: buildConversationOpenSkeleton,
1734
+ };
1735
+ let content: HTMLElement | null = null;
1736
+ const loadingPlugin = ctx.plugins.find((p) => p.renderLoadingIndicator);
1737
+ if (loadingPlugin?.renderLoadingIndicator) {
1738
+ content = loadingPlugin.renderLoadingIndicator(context);
1739
+ }
1740
+ if (content === null && ctx.config.loadingIndicator?.render) {
1741
+ content = ctx.config.loadingIndicator.render(context);
1742
+ }
1743
+ mountConversationOpenState(
1744
+ createNode(
1745
+ "div",
1746
+ {
1747
+ className: "persona-conversation-loading",
1748
+ attrs: {
1749
+ role: "status",
1750
+ "aria-label": historyShellCopy.openConversationLoadingLabel,
1751
+ },
1752
+ },
1753
+ content ?? buildConversationOpenSkeleton()
1754
+ )
1755
+ );
1756
+ };
1757
+
1758
+ const showConversationOpenError = (conversationId: string): void => {
1759
+ const retry = (): void => {
1760
+ void openHistoryConversation(conversationId, {
1761
+ focusComposer: true,
1762
+ }).catch(() => {});
1763
+ };
1764
+ const back = historyAvailable()
1765
+ ? (): void => {
1766
+ clearConversationOpenPending();
1767
+ void openHistory();
1768
+ }
1769
+ : undefined;
1770
+ const actionButton = (label: string, onClick: () => void): HTMLElement => {
1771
+ const button = createNode("button", {
1772
+ className: "persona-conversation-loading-error-action",
1773
+ attrs: { type: "button" },
1774
+ text: label,
1775
+ });
1776
+ button.addEventListener("click", onClick);
1777
+ return button;
1778
+ };
1779
+ const buildDefaultBlock = (): HTMLElement =>
1780
+ createNode(
1781
+ "div",
1782
+ { className: "persona-conversation-loading-error-body" },
1783
+ createNode("p", {
1784
+ className: "persona-conversation-loading-error-title",
1785
+ text: historyShellCopy.openConversationErrorTitle,
1786
+ }),
1787
+ createNode(
1788
+ "div",
1789
+ { className: "persona-conversation-loading-error-actions" },
1790
+ actionButton(historyShellCopy.openConversationRetryLabel, retry),
1791
+ back
1792
+ ? actionButton(historyShellCopy.openConversationBackLabel, back)
1793
+ : null
1794
+ )
1795
+ );
1796
+ // First non-null plugin hook wins (plugins are priority-sorted); a throw
1797
+ // is reported and skipped. The container keeps the centered column, the
1798
+ // alert role, and the composer gate around whatever the hook returns.
1799
+ const context: AgentWidgetRenderHistoryOpenErrorContext = Object.freeze({
1800
+ conversationId,
1801
+ config: ctx.config,
1802
+ copy: Object.freeze({
1803
+ title: historyShellCopy.openConversationErrorTitle,
1804
+ retryLabel: historyShellCopy.openConversationRetryLabel,
1805
+ backLabel: historyShellCopy.openConversationBackLabel,
1806
+ }),
1807
+ retry,
1808
+ ...(back ? { back } : {}),
1809
+ defaultRenderer: buildDefaultBlock,
1810
+ });
1811
+ let content: HTMLElement | null = null;
1812
+ for (const plugin of ctx.plugins) {
1813
+ if (!plugin.renderHistoryOpenError) continue;
1814
+ try {
1815
+ content = plugin.renderHistoryOpenError(context);
1816
+ } catch (error) {
1817
+ console.warn("[persona] renderHistoryOpenError threw", error);
1818
+ content = null;
1819
+ }
1820
+ if (content) break;
1821
+ }
1822
+ mountConversationOpenState(
1823
+ createNode(
1824
+ "div",
1825
+ {
1826
+ className: "persona-conversation-loading-error",
1827
+ attrs: { role: "alert" },
1828
+ },
1829
+ content ?? buildDefaultBlock()
1830
+ )
1831
+ );
1832
+ // A failure must never sit hidden behind the surface it replaces, so the
1833
+ // error skips the stand-in's show delay.
1834
+ engageConversationOpenTakeover();
1835
+ };
1836
+
1837
+ // The panel exit's deferred teardown restores the footer it captured, which
1838
+ // would lift the composer gate mid-fetch; re-assert it while an open is
1839
+ // still pending.
1840
+ ctx.eventBus.on("history:closed", () => {
1841
+ if (ctx.conversationOpenPendingEl) setHistoryHostInert(ctx.footer, true);
1842
+ });
1843
+
1844
+ /**
1845
+ * Optimistic reopen: navigate on the click, hydrate on the fetch. The
1846
+ * returned promise keeps the transactional contract (resolves once the
1847
+ * transcript is live, rejects on failure) for `controller.openConversation`.
1848
+ * `suppressScrollSend` covers the hydration: otherwise the last restored
1849
+ * user message triggers the anchor-top send scroll.
1850
+ */
1851
+ const openHistoryConversation = async (
1852
+ conversationId: string,
1853
+ { focusComposer = false }: { focusComposer?: boolean } = {}
1854
+ ): Promise<void> => {
1855
+ const scope = historyOperationScope();
1856
+ // Seeds the header title binding from the list row before the list leaves.
1857
+ // The surface is usually gone by hydration time (the exit outruns the
1858
+ // fetch), so this is the only moment the title can be read from the list.
1859
+ const seededFromList = !!historySurface;
1860
+ historySurface?.view.setActiveConversationId(conversationId);
1861
+ clearConversationOpenPending();
1862
+ const token = conversationOpenToken;
1863
+ showConversationOpenPending();
1864
+ if (ctx.historyVisible && (ctx.historyPresentation === "panel" || railOverlayOpen)) {
1865
+ closeHistory({ restoreFocus: false });
1866
+ }
1867
+ ctx.suppressScrollSend = true;
1868
+ try {
1869
+ await ctx.session.openConversation(conversationId, { scope });
1870
+ } catch (error) {
1871
+ if (token === conversationOpenToken) {
1872
+ showConversationOpenError(conversationId);
1873
+ }
1874
+ throw error;
1875
+ } finally {
1876
+ ctx.suppressScrollSend = false;
1877
+ }
1878
+ if (token !== conversationOpenToken) return;
1879
+ clearConversationOpenPending();
1880
+ ctx.messageCache.clear();
1881
+ ctx.resetAnchorState();
1882
+ ctx.resumeAutoScroll();
1883
+ if (!ctx.restoreScrollPosition()) ctx.jumpToBottomInstant();
1884
+ // Fades only after the scroll settles, so the motion covers the final frame.
1885
+ fadeInTranscript();
1886
+ syncEarlierMessagesPill();
1887
+ // Without a list at selection time there was no title to seed.
1888
+ if (!seededFromList) ctx.setActiveConversationSummary(null);
1889
+ ctx.eventBus.emit("history:conversationOpened", {
1890
+ conversationId,
1891
+ title: ctx.activeConversationTitle,
1892
+ scope,
1893
+ timestamp: Date.now(),
1894
+ });
1895
+ // Only interaction paths focus: focusing a programmatic open scrolls the
1896
+ // host page to the widget (same-origin iframes included).
1897
+ if (focusComposer) ctx.maybeFocusInput();
1898
+ };
1899
+
1900
+ /**
1901
+ * The single commit path behind the header action, the view's `onStartNew`,
1902
+ * and `controller.startNewConversation()`, so one emit covers all three.
1903
+ */
1904
+ const startNewConversation = async ({
1905
+ focusComposer = false,
1906
+ }: { focusComposer?: boolean } = {}): Promise<void> => {
1907
+ // A new conversation supersedes any transcript fetch still in flight.
1908
+ clearConversationOpenPending();
1909
+ await ctx.session.startNewConversation({ scope: historyOperationScope() });
1910
+ ctx.setActiveConversationSummary(null);
1911
+ ctx.messageCache.clear();
1912
+ ctx.resetAnchorState();
1913
+ ctx.resumeAutoScroll();
1914
+ ctx.jumpToBottomInstant();
1915
+ syncEarlierMessagesPill();
1916
+ const conversationId = ctx.session.getActiveConversationId();
1917
+ historySurface?.view.setActiveConversationId(conversationId);
1918
+ // Emitted before the close so a host can tell a commit from a plain close.
1919
+ ctx.eventBus.emit("history:conversationStarted", {
1920
+ conversationId,
1921
+ timestamp: Date.now(),
1922
+ });
1923
+ if (ctx.historyVisible && (ctx.historyPresentation === "panel" || railOverlayOpen)) {
1924
+ closeHistory({ restoreFocus: false });
1925
+ }
1926
+ if (focusComposer) ctx.maybeFocusInput();
1927
+ };
1928
+
1929
+ /** Shared rename/star path: provider update, then view + header binding. */
1930
+ const updateHistoryConversation = async (
1931
+ conversationId: string,
1932
+ patch: HistoryConversationPatch
1933
+ ): Promise<HistoryConversationSummary> => {
1934
+ const summary = await ctx.session.updateConversation(conversationId, {
1935
+ ...patch,
1936
+ ...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
1937
+ });
1938
+ // The view re-renders and reports the active title through the reporter;
1939
+ // without a mounted view, keep the header binding fresh directly.
1940
+ historySurface?.view.applyConversationSummary(summary);
1941
+ if (!historySurface && conversationId === ctx.session.getActiveConversationId()) {
1942
+ ctx.setActiveConversationSummary(summary);
1943
+ }
1944
+ return summary;
1945
+ };
1946
+
1947
+ const deleteHistoryConversation = async (
1948
+ conversationId: string
1949
+ ): Promise<void> => {
1950
+ const scope = historyOperationScope();
1951
+ const wasActive = ctx.session.getActiveConversationId() === conversationId;
1952
+ await ctx.session.deleteConversation(conversationId, { scope });
1953
+ // Prune the open list too: the view's own delete flow removes its row
1954
+ // after this resolves, but built-in/headless deletes have no view caller.
1955
+ historySurface?.view.removeConversationSummary(conversationId);
1956
+ if (wasActive) {
1957
+ ctx.setActiveConversationSummary(null);
1958
+ ctx.messageCache.clear();
1959
+ ctx.resetAnchorState();
1960
+ ctx.resumeAutoScroll();
1961
+ syncEarlierMessagesPill();
1962
+ }
1963
+ ctx.eventBus.emit("history:conversationDeleted", {
1964
+ conversationId,
1965
+ scope,
1966
+ wasActive,
1967
+ timestamp: Date.now(),
1968
+ });
1969
+ };
1970
+
1971
+ const clearConversationHistory = async (opts?: {
1972
+ targetId?: string;
1973
+ /** Deliberate headless opt-in to the whole authorized client-token scope. */
1974
+ allTargets?: boolean;
1975
+ scope?: HistoryScope;
1976
+ }): Promise<{ deleted: number }> => {
1977
+ const scope = opts?.scope ?? historyOperationScope();
1978
+ // Default to the same filter the visible list uses; only an explicit
1979
+ // headless opt-in deletes conversations the visitor was never shown.
1980
+ const targetId = opts?.allTargets
1981
+ ? null
1982
+ : (opts?.targetId ?? activeHistoryTargetId());
1983
+ const result = await ctx.session.clearConversationHistory({
1984
+ ...(targetId ? { targetId } : {}),
1985
+ scope,
1986
+ });
1987
+ ctx.setActiveConversationSummary(null);
1988
+ ctx.messageCache.clear();
1989
+ ctx.resetAnchorState();
1990
+ ctx.resumeAutoScroll();
1991
+ syncEarlierMessagesPill();
1992
+ ctx.eventBus.emit("history:cleared", {
1993
+ deleted: result.deleted,
1994
+ scope,
1995
+ targetId,
1996
+ timestamp: Date.now(),
1997
+ });
1998
+ return result;
1999
+ };
2000
+
2001
+ /**
2002
+ * D6: the local wipe is UNCONDITIONAL. Records survive on the server; this
2003
+ * browser is detached from them whether or not revocation was confirmed.
2004
+ */
2005
+ const resetHistoryIdentity = async (): Promise<{
2006
+ remoteRevocationConfirmed: boolean;
2007
+ }> => {
2008
+ // Rejection is reserved for misuse; remote failure resolves false.
2009
+ if (!historyProvider?.resetDevice) {
2010
+ throw new Error(
2011
+ "[Persona] resetHistoryIdentity() requires a history provider that can reset this device"
2012
+ );
2013
+ }
2014
+ let remoteRevocationConfirmed = false;
2015
+ try {
2016
+ const result = await ctx.session.resetHistoryDevice();
2017
+ remoteRevocationConfirmed = result.remoteRevocationConfirmed;
2018
+ } finally {
2019
+ ctx.setActiveConversationSummary(null);
2020
+ ctx.session.clearArtifacts();
2021
+ ctx.messageCache.clear();
2022
+ ctx.lastAppliedMessages = null;
2023
+ ctx.config.clearStoredSessionId?.();
2024
+ ctx.config.clearStoredConversationId?.();
2025
+ ctx.persistentMetadata = {};
2026
+ ctx.actionManager.syncFromMetadata();
2027
+ const storageAdapter = ctx.storageAdapter;
2028
+ if (storageAdapter?.clear) {
2029
+ ctx.runStorageMutation(
2030
+ () => storageAdapter.clear!(),
2031
+ "[AgentWidget] Failed to clear storage adapter:"
2032
+ );
2033
+ }
2034
+ ctx.resetAnchorState();
2035
+ ctx.resumeAutoScroll();
2036
+ syncEarlierMessagesPill();
2037
+ closeHistory({ restoreFocus: false });
2038
+ ctx.maybeFocusInput();
2039
+ if (!ctx.open) ctx.launcherSurfaceInstance?.launcher.element.focus();
2040
+ }
2041
+ ctx.announceHistory(
2042
+ remoteRevocationConfirmed
2043
+ ? historyShellCopy.identityResetNotice
2044
+ : historyShellCopy.identityResetUnconfirmedNotice
2045
+ );
2046
+ ctx.eventBus.emit("history:identityReset", {
2047
+ remoteRevocationConfirmed,
2048
+ timestamp: Date.now(),
2049
+ });
2050
+ return { remoteRevocationConfirmed };
2051
+ };
2052
+
2053
+ // --- confirmations (shell-owned alert dialogs) ---------------------------
2054
+
2055
+ // With the artifact split, `container` is only the chat column, so an
2056
+ // inset:0 overlay there would leave the artifact pane undimmed. The panel
2057
+ // wraps the whole split and is already position:relative on that path;
2058
+ // forcing `relative` onto it elsewhere would not survive the chrome
2059
+ // passes' cssText resets, so plain layouts keep the container host.
2060
+ const historyConfirmHost = (): HTMLElement =>
2061
+ ctx.artifactSplitRoot ? ctx.panel : ctx.container;
2062
+
2063
+ // The dialog ships in the lazy history-view chunk (usually already warm:
2064
+ // these actions start from history UI). A failed load, or a stale CDN chunk
2065
+ // that predates the export, degrades to the native confirm so the action
2066
+ // still works.
2067
+ const showHistoryConfirm = async (
2068
+ options: HistoryConfirmOptions
2069
+ ): Promise<boolean> => {
2070
+ const module = await ctx.loadHistoryView().catch(() => null);
2071
+ if (typeof module?.showHistoryConfirm === "function") {
2072
+ return module.showHistoryConfirm(options);
2073
+ }
2074
+ return window.confirm(`${options.title}\n\n${options.description}`);
2075
+ };
2076
+
2077
+ const requestDeleteConversation = async (
2078
+ conversationId: string
2079
+ ): Promise<"deleted" | "cancelled"> => {
2080
+ const confirmed = await showHistoryConfirm({
2081
+ host: historyConfirmHost(),
2082
+ title: historyShellCopy.deleteConversationConfirmTitle,
2083
+ description: historyShellCopy.deleteConversationConfirm,
2084
+ confirmLabel: historyShellCopy.deleteConversationConfirmLabel,
2085
+ cancelLabel: historyShellCopy.confirmCancelLabel,
2086
+ });
2087
+ if (!confirmed) return "cancelled";
2088
+ await deleteHistoryConversation(conversationId);
2089
+ return "deleted";
2090
+ };
2091
+
2092
+ const requestClearConversationHistory = async (): Promise<
2093
+ "cleared" | "cancelled"
2094
+ > => {
2095
+ // Scope-aware copy: never imply the delete is limited to the rendered page.
2096
+ const verified = historyOperationScope() === "verified-user";
2097
+ const confirmed = await showHistoryConfirm({
2098
+ host: historyConfirmHost(),
2099
+ title: historyShellCopy.clearHistoryConfirmTitle,
2100
+ description: verified
2101
+ ? historyShellCopy.clearHistoryVerifiedConfirm
2102
+ : historyShellCopy.clearHistoryConfirm,
2103
+ confirmLabel: historyShellCopy.clearHistoryConfirmLabel,
2104
+ cancelLabel: historyShellCopy.confirmCancelLabel,
2105
+ });
2106
+ if (!confirmed) return "cancelled";
2107
+ await clearConversationHistory();
2108
+ return "cleared";
2109
+ };
2110
+
2111
+ const requestResetHistoryIdentity = async (): Promise<
2112
+ { outcome: "cancelled" } | { outcome: "reset"; remoteRevocationConfirmed: boolean }
2113
+ > => {
2114
+ const confirmed = await showHistoryConfirm({
2115
+ host: historyConfirmHost(),
2116
+ title: historyShellCopy.resetIdentityConfirmTitle,
2117
+ description: historyShellCopy.resetIdentityConfirm,
2118
+ confirmLabel: historyShellCopy.resetIdentityConfirmLabel,
2119
+ cancelLabel: historyShellCopy.confirmCancelLabel,
2120
+ });
2121
+ if (!confirmed) return { outcome: "cancelled" };
2122
+ const { remoteRevocationConfirmed } = await resetHistoryIdentity();
2123
+ return { outcome: "reset", remoteRevocationConfirmed };
2124
+ };
2125
+
2126
+ // --- "show earlier messages" prepend -------------------------------------
2127
+
2128
+ const earlierMessagesButton = createElement(
2129
+ "button",
2130
+ "persona-history-earlier"
2131
+ ) as HTMLButtonElement;
2132
+ earlierMessagesButton.type = "button";
2133
+ earlierMessagesButton.textContent = historyShellCopy.showEarlierMessagesLabel;
2134
+ earlierMessagesButton.setAttribute("data-persona-history-earlier", "");
2135
+ Object.assign(earlierMessagesButton.style, {
2136
+ alignSelf: "center",
2137
+ minHeight: "44px",
2138
+ padding: "0 16px",
2139
+ borderRadius: "999px",
2140
+ border: "1px solid var(--persona-border, rgba(0,0,0,0.12))",
2141
+ background: "transparent",
2142
+ color: "inherit",
2143
+ font: "inherit",
2144
+ cursor: "pointer",
2145
+ flexShrink: "0",
2146
+ } satisfies Partial<CSSStyleDeclaration>);
2147
+
2148
+ const syncEarlierMessagesPill = (): void => {
2149
+ const show =
2150
+ historyAvailable() &&
2151
+ !!ctx.historySessionState.nextMessageCursor &&
2152
+ !!ctx.session.getActiveConversationId();
2153
+ if (!show) {
2154
+ earlierMessagesButton.remove();
2155
+ return;
2156
+ }
2157
+ earlierMessagesButton.textContent = historyShellCopy.showEarlierMessagesLabel;
2158
+ if (earlierMessagesButton.parentNode !== ctx.body) {
2159
+ ctx.body.insertBefore(earlierMessagesButton, ctx.body.firstChild);
2160
+ }
2161
+ };
2162
+
2163
+ earlierMessagesButton.addEventListener("click", () => {
2164
+ const conversationId = ctx.session.getActiveConversationId();
2165
+ const cursor = ctx.historySessionState.nextMessageCursor;
2166
+ if (!conversationId || !cursor || earlierMessagesButton.disabled) return;
2167
+ earlierMessagesButton.disabled = true;
2168
+ // Capture before the prepend so the reader's viewport stays put.
2169
+ const previousHeight = ctx.body.scrollHeight;
2170
+ const previousTop = ctx.body.scrollTop;
2171
+ void ctx.session
2172
+ .loadOlderMessages(conversationId, cursor, { scope: historyOperationScope() })
2173
+ .then(() => {
2174
+ ctx.body.scrollTop = previousTop + (ctx.body.scrollHeight - previousHeight);
2175
+ // Prepending shifts everything below the anchor.
2176
+ ctx.repinAnchoredMessage();
2177
+ })
2178
+ .catch(() => {})
2179
+ .finally(() => {
2180
+ earlierMessagesButton.disabled = false;
2181
+ syncEarlierMessagesPill();
2182
+ });
2183
+ });
2184
+
2185
+ // --- header chrome -------------------------------------------------------
2186
+
2187
+ // Shares the header control chrome (box + glyph from the header tokens) with
2188
+ // the close and clear-chat buttons beside it.
2189
+ const buildHistoryButton = (): {
2190
+ button: HTMLButtonElement;
2191
+ wrapper: HTMLElement;
2192
+ } => {
2193
+ const parts = ctx.createHeaderIconButton({
2194
+ ariaLabel: historyShellCopy.openHistoryLabel,
2195
+ iconName: "history",
2196
+ wrapperClassName:
2197
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center",
2198
+ extraClassName: "persona-history-toggle",
2199
+ attrs: { "data-persona-history-toggle": "" },
2200
+ });
2201
+ parts.button.addEventListener("click", (event) => {
2202
+ if (!historyAvailable() || historyTurnBusy()) return;
2203
+ if (ctx.historyVisible) {
2204
+ closeHistory();
2205
+ return;
2206
+ }
2207
+ // Enter/Space synthesize a click with detail 0; a pointer press reports > 0.
2208
+ void openHistory({ invoker: parts.button, keyboard: event.detail === 0 });
2209
+ });
2210
+ return parts;
2211
+ };
2212
+
2213
+ const syncHistoryButton = (): void => {
2214
+ if (!historyAvailable()) {
2215
+ historyButtonWrapper?.remove();
2216
+ historyButton = null;
2217
+ historyButtonWrapper = null;
2218
+ return;
2219
+ }
2220
+ // A header rebuild detaches the old button; a stale ref must not block
2221
+ // recreation into the replacement header.
2222
+ if (historyButtonWrapper && !historyButtonWrapper.isConnected) {
2223
+ historyButton = null;
2224
+ historyButtonWrapper = null;
2225
+ }
2226
+ if (!historyButton && ctx.header) {
2227
+ const parts = buildHistoryButton();
2228
+ historyButton = parts.button;
2229
+ historyButtonWrapper = parts.wrapper;
2230
+ const insertBefore =
2231
+ ctx.panelElements.clearChatButtonWrapper || ctx.panelElements.closeButtonWrapper;
2232
+ // Layouts may parent these wrappers in a trailing cluster rather than
2233
+ // the header itself; insert wherever they live so close stays outermost.
2234
+ if (insertBefore?.parentNode && ctx.header.contains(insertBefore)) {
2235
+ insertBefore.parentNode.insertBefore(historyButtonWrapper, insertBefore);
2236
+ } else {
2237
+ ctx.header.appendChild(historyButtonWrapper);
2238
+ }
2239
+ }
2240
+ if (!historyButton || !historyButtonWrapper) return;
2241
+ // Every rail surface carries its own toggle, and so does the trigger that
2242
+ // summons one: the header keeps a control only when neither is on screen.
2243
+ const railed =
2244
+ railTriggerApplies() || (ctx.historyVisible && ctx.historyPresentation === "rail");
2245
+ historyButtonWrapper.style.display = railed ? "none" : "";
2246
+ if (railed) {
2247
+ // Hiding the control under focus would drop it to the body; the rail's
2248
+ // own toggle is the same control, so focus follows it there.
2249
+ if (document.activeElement === historyButton) focusHistoryEntry();
2250
+ return;
2251
+ }
2252
+ const busy = historyTurnBusy();
2253
+ historyButton.disabled = busy;
2254
+ historyButton.setAttribute("aria-disabled", busy ? "true" : "false");
2255
+ const label = busy
2256
+ ? historyShellCopy.openHistoryBusyLabel
2257
+ : historyShellCopy.openHistoryLabel;
2258
+ // The factory tooltip reads the live aria-label; a title would double it.
2259
+ historyButton.setAttribute("aria-label", label);
2260
+ // Rail/auto toggles a navigation region; panel navigates to a surface.
2261
+ if ((ctx.config.features?.history?.presentation ?? "panel") === "panel") {
2262
+ historyButton.removeAttribute("aria-expanded");
2263
+ historyButton.removeAttribute("aria-controls");
2264
+ } else {
2265
+ historyButton.setAttribute("aria-expanded", ctx.historyVisible ? "true" : "false");
2266
+ historyButton.setAttribute("aria-controls", historyRegionId);
2267
+ }
2268
+ };
2269
+
2270
+ /**
2271
+ * With history available the visible start-over affordance becomes
2272
+ * "New conversation" (`clearChat()` stays programmatic-only).
2273
+ */
2274
+ let clearChatRelabelled = false;
2275
+ const syncClearChatAffordance = (): void => {
2276
+ const button = ctx.panelElements.clearChatButton;
2277
+ if (!button) return;
2278
+ if (historyAvailable()) {
2279
+ if (!clearChatRelabelled) {
2280
+ clearChatDefaultLabel = button.getAttribute("aria-label") ?? "";
2281
+ clearChatRelabelled = true;
2282
+ }
2283
+ // aria-label only: the styled tooltip reads it live, and a title would
2284
+ // render a second native tooltip on top.
2285
+ button.setAttribute("aria-label", historyShellCopy.newConversationLabel);
2286
+ button.removeAttribute("title");
2287
+ // No touch-target pin: the shared header control class owns the box, and
2288
+ // a 44px floor here would desync this control from its neighbours.
2289
+ return;
2290
+ }
2291
+ // Only undo a relabel we performed; never clobber host-configured copy.
2292
+ if (!clearChatRelabelled) return;
2293
+ clearChatRelabelled = false;
2294
+ if (clearChatDefaultLabel) {
2295
+ button.setAttribute("aria-label", clearChatDefaultLabel);
2296
+ }
2297
+ };
2298
+
2299
+ const syncHistoryChromeImpl = (): void => {
2300
+ historyShellCopy = resolveHistoryShellCopy(ctx.config.features?.history?.copy);
2301
+ syncHistoryButton();
2302
+ syncRailOverlayTrigger();
2303
+ syncClearChatAffordance();
2304
+ syncEarlierMessagesPill();
2305
+ };
2306
+ ctx.historyChromeSync = syncHistoryChromeImpl;
2307
+
2308
+ installHistoryProvider();
2309
+ syncHistoryChromeImpl();
2310
+ ctx.destroyCallbacks.push(() => {
2311
+ unsubscribeHistoryAvailability?.();
2312
+ unsubscribeHistoryIdentity?.();
2313
+ // An open in flight (a hover this teardown raced) must not ctx.mount after it.
2314
+ historyOpenToken += 1;
2315
+ // A pending exit still owns a mounted surface and a live timer.
2316
+ settleHistoryExit();
2317
+ historySurface?.dispose();
2318
+ historySurface?.view.destroy();
2319
+ historySurface = null;
2320
+ });
2321
+
2322
+ return {
2323
+ historyAvailable,
2324
+ historyScope,
2325
+ installHistoryProvider,
2326
+ syncHistoryChromeImpl,
2327
+ syncHistoryPresentation,
2328
+ syncEarlierMessagesPill,
2329
+ openHistory,
2330
+ closeHistory,
2331
+ openHistoryConversation,
2332
+ startNewConversation,
2333
+ updateHistoryConversation,
2334
+ deleteHistoryConversation,
2335
+ requestDeleteConversation,
2336
+ clearConversationHistory,
2337
+ resetHistoryIdentity,
2338
+ railCollapseShortcut,
2339
+ railCollapsible,
2340
+ railTriggerApplies,
2341
+ railOverlayMode,
2342
+ toggleRailCollapsed,
2343
+ toggleRailPinned: (): void => toggleRailPinned(),
2344
+ get historyProvider() {
2345
+ return historyProvider;
2346
+ },
2347
+ get historySurface() {
2348
+ return historySurface;
2349
+ },
2350
+ get railShell() {
2351
+ return railShell;
2352
+ },
2353
+ get railHost() {
2354
+ return railHost;
2355
+ },
2356
+ get railColumn() {
2357
+ return railColumn;
2358
+ },
2359
+ };
2360
+ };
2361
+
2362
+ export type HistoryShell = ReturnType<typeof createHistoryShell>;