@runtypelabs/persona 4.17.0 → 4.18.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 (165) 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-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/ui.ts CHANGED
@@ -3,7 +3,12 @@ import { resolveSanitizer } from "./utils/sanitize";
3
3
  import { stabilizeStreamingTables } from "./utils/streaming-table";
4
4
  import { wrapScrollableTables, refreshTableScrollFades } from "./utils/table-scroll-fade";
5
5
  import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parsers-loader";
6
- import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
6
+ import {
7
+ AgentWidgetSession,
8
+ AgentWidgetSessionStatus,
9
+ type SessionHistoryNotice,
10
+ type SessionHistoryState
11
+ } from "./session";
7
12
  import {
8
13
  AgentWidgetConfig,
9
14
  AgentWidgetConfigPatch,
@@ -12,6 +17,7 @@ import {
12
17
  AgentWidgetEvent,
13
18
  AgentWidgetStorageAdapter,
14
19
  AgentWidgetStoredState,
20
+ WidgetHistoryInternals,
15
21
  AgentWidgetControllerEventMap,
16
22
  AgentWidgetVoiceStateEvent,
17
23
  AgentWidgetStateEvent,
@@ -26,6 +32,7 @@ import {
26
32
  InjectSystemMessageOptions,
27
33
  InjectComponentDirectiveOptions,
28
34
  LoadingIndicatorRenderContext,
35
+ AgentWidgetRenderHistoryOpenErrorContext,
29
36
  IdleIndicatorRenderContext,
30
37
  VoiceStatus,
31
38
  ReadAloudState,
@@ -37,7 +44,14 @@ import {
37
44
  AgentWidgetContextMentionRef,
38
45
  AgentWidgetSuggestion,
39
46
  AgentWidgetSuggestionSource,
40
- AgentWidgetWelcomeIcon
47
+ AgentWidgetWelcomeIcon,
48
+ HistoryConversationPatch,
49
+ HistoryConversationSummary,
50
+ HistoryIdentityStatus,
51
+ HistoryReturnSurface,
52
+ HistoryScope,
53
+ PendingDisplayProjections,
54
+ ResolvedHistoryPresentation
41
55
  } from "./types";
42
56
  import { AttachmentManager } from "./utils/attachment-manager";
43
57
  import {
@@ -49,10 +63,20 @@ import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
49
63
  import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
50
64
  import { resolveTokenValue } from "./utils/tokens";
51
65
  import { renderLucideIcon } from "./utils/icons";
52
- import { createElement } from "./utils/dom";
66
+ import { createElement, createNode, cx } from "./utils/dom";
53
67
  import { resolveContentMaxWidth } from "./utils/content-width";
54
- import { attachTooltip } from "./utils/tooltip";
55
- import { downloadInfoFor } from "./utils/artifact-file";
68
+ import { attachTooltip, configureTooltipTiming } from "./utils/tooltip";
69
+ import {
70
+ DEFAULT_TOOLTIP_DELAY_MS,
71
+ DEFAULT_TOOLTIP_SKIP_DELAY_MS,
72
+ } from "./utils/tooltip-timing";
73
+ import {
74
+ ariaCombo,
75
+ createShortcutRegistry,
76
+ formatCombo,
77
+ parseCombo,
78
+ } from "./utils/shortcuts";
79
+ import { downloadInfoFor, triggerArtifactDownload } from "./utils/artifact-file";
56
80
  import { artifactCopyText } from "./components/artifact-preview";
57
81
  import { morphMessages } from "./utils/morph";
58
82
  import { normalizeCopiedSelectionText } from "./utils/copy-selection";
@@ -73,7 +97,11 @@ import {
73
97
  resolveFollowStateFromScroll,
74
98
  resolveFollowStateFromWheel
75
99
  } from "./utils/auto-follow";
76
- import { statusCopy, DEFAULT_OVERLAY_Z_INDEX } from "./utils/constants";
100
+ import {
101
+ statusCopy,
102
+ DEFAULT_OVERLAY_Z_INDEX,
103
+ PORTALED_OVERLAY_Z_INDEX,
104
+ } from "./utils/constants";
77
105
  import {
78
106
  applyStreamBuffer,
79
107
  createSkeletonPlaceholder,
@@ -88,7 +116,13 @@ import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
88
116
  import { acquireScrollLock } from "./utils/scroll-lock";
89
117
  import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
90
118
  import { LauncherSurface } from "./components/launcher";
91
- import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
119
+ import {
120
+ buildHeader,
121
+ buildComposer,
122
+ attachHeaderToContainer,
123
+ COMPOSER_BAR_CLEAR_CHAT_ICON_SIZE,
124
+ COMPOSER_BAR_CLOSE_ICON_SIZE,
125
+ } from "./components/panel";
92
126
  import { buildPillComposer } from "./components/pill-composer-builder";
93
127
  import { createWidgetView, resolveLauncher } from "./components/widget-view";
94
128
  import {
@@ -102,6 +136,10 @@ import {
102
136
  import { createPluginStorageFactory } from "./utils/plugin-storage";
103
137
  import { isWelcomeVisible, resolveWelcomeConfig } from "./welcome";
104
138
  import { HEADER_THEME_CSS } from "./components/header-builder";
139
+ import {
140
+ applyHeaderControlGlyph,
141
+ createHeaderIconButton,
142
+ } from "./components/header-parts";
105
143
  import { buildHeaderWithLayout } from "./components/header-layouts";
106
144
  import { positionMap } from "./utils/positioning";
107
145
  import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
@@ -139,7 +177,8 @@ import {
139
177
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
140
178
  import { EventStreamStore } from "./utils/event-stream-store";
141
179
  import { ThroughputTracker } from "./utils/throughput-tracker";
142
- import { createEventStreamView } from "./components/event-stream-view";
180
+ import { loadEventStreamView } from "./event-stream-view-loader";
181
+ import type { EventStreamViewHandle } from "./event-stream-view-entry";
143
182
  import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
144
183
  import {
145
184
  hasLiveInlineArtifactBlock,
@@ -154,7 +193,12 @@ import {
154
193
  } from "./utils/artifact-gate";
155
194
  import { resolveArtifactDisplayMode } from "./utils/artifact-display";
156
195
  import { resolveConfigPreferences } from "./utils/feature-preferences";
157
- import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
196
+ import {
197
+ ARTIFACT_RESIZE_CHAT_MIN_PX,
198
+ ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX,
199
+ readFlexGapPx,
200
+ resolveArtifactPaneWidthPx,
201
+ } from "./utils/artifact-resize";
158
202
  import { enhanceWithForms } from "./components/forms";
159
203
  import { pluginRegistry } from "./plugins/registry";
160
204
  import { mergeWithDefaults, DEFAULT_FLOATING_LAUNCHER_WIDTH } from "./defaults";
@@ -166,6 +210,29 @@ import {
166
210
  defaultJsonActionParser
167
211
  } from "./utils/actions";
168
212
  import { createLocalStorageAdapter } from "./utils/storage";
213
+ import { createVisitorStore, type VisitorStore } from "./utils/visitor-store";
214
+ import { loadHistoryView } from "./history-view-loader";
215
+ import type {
216
+ HistoryHeaderPlacement,
217
+ HistoryRailSection,
218
+ HistoryViewHandle,
219
+ HistoryViewOptions,
220
+ } from "./history-view-entry";
221
+ import {
222
+ createHistoryRenderSurface,
223
+ type HistoryRenderSurface,
224
+ } from "./history-render";
225
+ import { getHistoryProviderFactory } from "./internal/history-provider-registry";
226
+ import type {
227
+ HistoryOperationContext,
228
+ HistoryProvider,
229
+ } from "./internal/history-provider";
230
+ import { createRuntypeHistoryProvider } from "./internal/runtype-history-provider";
231
+ import {
232
+ resolveHistoryShellCopy,
233
+ type ResolvedHistoryShellCopy,
234
+ } from "./components/history-shell-copy";
235
+ import { showHistoryConfirm } from "./components/history-confirm-dialog";
169
236
  import { componentRegistry } from "./components/registry";
170
237
  import {
171
238
  renderComponentDirective,
@@ -467,6 +534,73 @@ type Controller = {
467
534
  decision: 'approved' | 'denied',
468
535
  options?: AgentWidgetApprovalDecisionOptions
469
536
  ) => Promise<void>;
537
+
538
+ // --- Conversation history (client-token mode; see D8) ---------------------
539
+
540
+ /** Pass-through list, for headless hosts rendering their own navigation. */
541
+ listConversations: (opts?: {
542
+ cursor?: string;
543
+ limit?: number;
544
+ targetId?: string;
545
+ scope?: HistoryScope;
546
+ }) => Promise<{
547
+ items: HistoryConversationSummary[];
548
+ nextCursor: string | null;
549
+ }>;
550
+ /** The open conversation's record id; null for a fresh unsaved chat. */
551
+ getActiveConversationId: () => string | null;
552
+ /**
553
+ * Full open flow: transactional resume, hydration, and scroll restore.
554
+ * `focus: true` moves focus to the composer; the programmatic default
555
+ * leaves focus alone (an embedded widget must not scroll the host page
556
+ * on a boot-time reopen).
557
+ */
558
+ openConversation: (
559
+ conversationId: string,
560
+ opts?: { focus?: boolean }
561
+ ) => Promise<void>;
562
+ /** Clean local state plus a new owned server conversation. `focus` as on `openConversation`. */
563
+ startNewConversation: (opts?: { focus?: boolean }) => Promise<void>;
564
+ /** Permanently delete one server record; the active one is replaced. */
565
+ deleteConversation: (conversationId: string) => Promise<void>;
566
+ /**
567
+ * Visitor-scoped rename. Requires a provider with the update capability;
568
+ * a user-set title pins, so a later auto-generated title never overwrites it.
569
+ */
570
+ renameConversation: (
571
+ conversationId: string,
572
+ title: string
573
+ ) => Promise<HistoryConversationSummary>;
574
+ /** Visitor-scoped star/unstar. Requires a provider with the update capability. */
575
+ setConversationStarred: (
576
+ conversationId: string,
577
+ starred: boolean
578
+ ) => Promise<HistoryConversationSummary>;
579
+ /**
580
+ * Permanently delete every record in the resolved scope. Defaults to the
581
+ * active target filter; `allTargets` is a deliberate client-token-wide delete.
582
+ */
583
+ clearConversationHistory: (opts?: {
584
+ targetId?: string;
585
+ allTargets?: boolean;
586
+ scope?: HistoryScope;
587
+ }) => Promise<{ deleted: number }>;
588
+ /**
589
+ * Revoke this browser's history credential and wipe it locally. Resolves
590
+ * (never rejects) on remote failure; rejection is reserved for misuse.
591
+ * Required host logout wiring.
592
+ */
593
+ resetHistoryIdentity: () => Promise<{ remoteRevocationConfirmed: boolean }>;
594
+ /** Latest sanitized identity state; carries no token, proof, or identity id. */
595
+ getHistoryIdentityStatus: () => HistoryIdentityStatus;
596
+ /** `focus: false` keeps focus where it is (programmatic/preview opens). */
597
+ showHistory: (opts?: {
598
+ returnSurface?: HistoryReturnSurface;
599
+ focus?: boolean;
600
+ }) => Promise<void>;
601
+ /** `restoreFocus: false` leaves focus untouched on close. */
602
+ hideHistory: (opts?: { restoreFocus?: boolean }) => void;
603
+ isHistoryVisible: () => boolean;
470
604
  };
471
605
 
472
606
  export const buildPostprocessor = (
@@ -630,6 +764,13 @@ export const createAgentExperience = (
630
764
  }
631
765
 
632
766
  let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
767
+ const applyTooltipTiming = (): void => {
768
+ configureTooltipTiming({
769
+ delayMs: config.tooltip?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS,
770
+ skipDelayMs: config.tooltip?.skipDelayMs ?? DEFAULT_TOOLTIP_SKIP_DELAY_MS,
771
+ });
772
+ };
773
+ applyTooltipTiming();
633
774
  // `config.features` holds the preference-resolved view every read site uses;
634
775
  // `baseFeatures` keeps the pre-preference base so update() re-resolves from
635
776
  // it instead of stacking new preferences onto already-overlaid values.
@@ -641,6 +782,11 @@ export const createAgentExperience = (
641
782
  // Get plugins for this instance
642
783
  const plugins = pluginRegistry.getForInstance(config.plugins);
643
784
 
785
+ // One keydown listener per instance. Widget-scoped bindings resolve against
786
+ // the mount; Escape is reserved and never routed through here.
787
+ const shortcuts = createShortcutRegistry(mount);
788
+ let releaseShortcuts: Array<() => void> = [];
789
+
644
790
  // Guards the composer/welcome hooks, which can run before `session` exists
645
791
  // (its `let` is declared far below and is in the temporal dead zone here).
646
792
  let sessionInitialized = false;
@@ -671,6 +817,13 @@ export const createAgentExperience = (
671
817
  : (config.storageAdapter ?? createLocalStorageAdapter());
672
818
  let persistentMetadata: Record<string, unknown> = {};
673
819
  let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
820
+ // Resolves once stored state has been applied (or its load failed). Every
821
+ // history-capable init path waits on it, so a fast network can never race an
822
+ // async storage adapter. Always resolves, never rejects.
823
+ let resolveHistoryBootstrap!: () => void;
824
+ const historyBootstrapReady = new Promise<void>((resolve) => {
825
+ resolveHistoryBootstrap = resolve;
826
+ });
674
827
 
675
828
  let shouldOpenAfterStateLoaded = false;
676
829
 
@@ -746,6 +899,10 @@ export const createAgentExperience = (
746
899
  }
747
900
  }
748
901
 
902
+ // Only the async adapter path defers the gate (resolved with hydration
903
+ // below); every synchronous exit above, including a load throw, is done here.
904
+ if (!pendingStoredState) resolveHistoryBootstrap();
905
+
749
906
  const getSessionMetadata = () => persistentMetadata;
750
907
  const updateSessionMetadata = (
751
908
  updater: (prev: Record<string, unknown>) => Record<string, unknown>
@@ -822,18 +979,39 @@ export const createAgentExperience = (
822
979
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
823
980
  let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
824
981
  let scrollBehaviorFeature = config.features?.scrollBehavior ?? {};
825
- const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
982
+ // Live-read: `update()` may change the persistState shape, which re-keys the
983
+ // visitor store.
984
+ const currentKeyPrefix = () =>
985
+ (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
986
+ const persistKeyPrefix = currentKeyPrefix();
826
987
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
827
988
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
828
989
  const eventStreamMaxEvents = config.features?.eventStream?.maxEvents ?? 2000;
829
990
  let eventStreamBuffer = showEventStreamToggle ? new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore) : null;
830
991
  // Passive output-throughput tracker, fed from the same SSE tap as the buffer.
831
992
  let throughputTracker = showEventStreamToggle ? new ThroughputTracker() : null;
832
- let eventStreamView: ReturnType<typeof createEventStreamView> | null = null;
993
+ let eventStreamView: EventStreamViewHandle | null = null;
833
994
  let eventStreamVisible = false;
834
995
  let eventStreamRAF: number | null = null;
835
996
  let eventStreamLastUpdate = 0;
836
997
 
998
+ // --- history (Messages) shell state ---------------------------------------
999
+ // Declared here because the scroll-affordance gate, `syncPanelChrome`, and the
1000
+ // session callbacks all run before the history block installs its behavior.
1001
+ let historyVisible = false;
1002
+ let historyPresentation: ResolvedHistoryPresentation | null = null;
1003
+ /** Re-asserts panel-host inert state after a chrome pass. */
1004
+ let reapplyHistoryHostChrome: () => void = () => {};
1005
+ /** Re-derives rail geometry from config; no-op unless a rail is mounted. */
1006
+ let applyRailChrome: () => void = () => {};
1007
+ let historyStateHandler: (state: SessionHistoryState) => void = () => {};
1008
+ let historyNoticeHandler: (notice: SessionHistoryNotice) => void = () => {};
1009
+ /** Header button/affordance refresh; a turn's busy state gates the button. */
1010
+ let historyChromeSync: () => void = () => {};
1011
+ /** A surface that replaces the whole panel body: no scroll affordance over it. */
1012
+ const fullPanelOverlayVisible = (): boolean =>
1013
+ eventStreamVisible || (historyVisible && historyPresentation === "panel");
1014
+
837
1015
  // Open IndexedDB store and restore persisted events into the buffer
838
1016
  eventStreamStore?.open().then(() => {
839
1017
  return eventStreamBuffer?.restore();
@@ -1102,8 +1280,9 @@ export const createAgentExperience = (
1102
1280
  onClose: () => setOpenState(false, "user")
1103
1281
  });
1104
1282
  if (customHeader) {
1105
- // Replace the default header with custom header
1106
- const existingHeader = container.querySelector('.persona-border-b-persona-divider');
1283
+ // Replace the default header with custom header. By the live binding, not
1284
+ // a container lookup: while a rail is mounted the header sits inside it.
1285
+ const existingHeader = header;
1107
1286
  if (existingHeader) {
1108
1287
  existingHeader.replaceWith(customHeader);
1109
1288
  header = customHeader;
@@ -1115,24 +1294,39 @@ export const createAgentExperience = (
1115
1294
  }
1116
1295
 
1117
1296
  // Event stream toggle functions (lifted to outer scope for controller access)
1297
+ const mountEventStreamView = () => {
1298
+ if (!eventStreamView) return;
1299
+ body.style.display = "none";
1300
+ footer.parentNode?.insertBefore(eventStreamView.element, footer);
1301
+ eventStreamView.update();
1302
+ };
1303
+
1118
1304
  const toggleEventStreamOn = () => {
1119
1305
  if (!eventStreamBuffer) return;
1120
1306
  eventStreamVisible = true;
1121
- if (!eventStreamView && eventStreamBuffer) {
1122
- eventStreamView = createEventStreamView({
1123
- buffer: eventStreamBuffer,
1124
- getFullHistory: () => eventStreamBuffer!.getAllFromStore(),
1125
- onClose: () => toggleEventStreamOff(),
1126
- config,
1127
- plugins,
1128
- getThroughput: () =>
1129
- throughputTracker?.getMetric() ?? { status: "idle" },
1130
- });
1131
- }
1132
- if (eventStreamView) {
1133
- body.style.display = "none";
1134
- footer.parentNode?.insertBefore(eventStreamView.element, footer);
1135
- eventStreamView.update();
1307
+ if (!eventStreamView) {
1308
+ // The panel lives in a lazy chunk; mount when it resolves, unless the
1309
+ // toggle was flipped back off (or the widget destroyed) meanwhile.
1310
+ void loadEventStreamView()
1311
+ .then((mod) => {
1312
+ if (eventStreamView || !eventStreamVisible || !eventStreamBuffer) return;
1313
+ eventStreamView = mod.createEventStreamView({
1314
+ buffer: eventStreamBuffer,
1315
+ getFullHistory: () => eventStreamBuffer!.getAllFromStore(),
1316
+ onClose: () => toggleEventStreamOff(),
1317
+ config,
1318
+ plugins,
1319
+ getThroughput: () =>
1320
+ throughputTracker?.getMetric() ?? { status: "idle" },
1321
+ });
1322
+ mountEventStreamView();
1323
+ })
1324
+ .catch(() => {
1325
+ // Chunk failure resets the toggle; the next click retries the load.
1326
+ toggleEventStreamOff();
1327
+ });
1328
+ } else {
1329
+ mountEventStreamView();
1136
1330
  }
1137
1331
  if (eventStreamToggleBtn) {
1138
1332
  eventStreamToggleBtn.style.boxShadow = `inset 0 0 0 1.5px ${HEADER_THEME_CSS.actionIconColor}`;
@@ -1410,6 +1604,9 @@ export const createAgentExperience = (
1410
1604
  element.style.maxWidth = max;
1411
1605
  element.style.marginLeft = "auto";
1412
1606
  element.style.marginRight = "auto";
1607
+ // Auto cross-axis margins defeat flex stretch: the previews container
1608
+ // sits inside the flex-column form and would shrink-center without this.
1609
+ element.style.width = "100%";
1413
1610
  }
1414
1611
  };
1415
1612
  applyComposerContentMaxWidth();
@@ -1969,14 +2166,7 @@ export const createAgentExperience = (
1969
2166
  if (!markdown) return;
1970
2167
  // File artifacts download the raw unfenced source under their real name/MIME;
1971
2168
  // non-file markdown artifacts keep the legacy `<title>.md` / text/markdown path.
1972
- const { filename, mime, content } = downloadInfoFor({ title, markdown, file });
1973
- const blob = new Blob([content], { type: mime });
1974
- const url = URL.createObjectURL(blob);
1975
- const a = document.createElement('a');
1976
- a.href = url;
1977
- a.download = filename;
1978
- a.click();
1979
- URL.revokeObjectURL(url);
2169
+ triggerArtifactDownload(downloadInfoFor({ title, markdown, file }));
1980
2170
  });
1981
2171
 
1982
2172
  // Click delegation for integrator-supplied card action buttons. Actions are
@@ -2521,6 +2711,21 @@ export const createAgentExperience = (
2521
2711
  artifactResizeDocEnd = null;
2522
2712
  }
2523
2713
 
2714
+ /**
2715
+ * Chat-column minimum for an artifact drag. A docked in-flow rail lives
2716
+ * inside the chat column, so the flat chat minimum would let a wide drag
2717
+ * squeeze the column under the rail's own responsive floor and re-mode the
2718
+ * rail into the panel mid-drag. The floating overlay rail (`railOverlayHost`)
2719
+ * takes no flex width, so only the in-flow `railHost` counts, measured live
2720
+ * to cover expanded, collapsed, and dragged widths alike.
2721
+ */
2722
+ const railedChatMinPx = (): number => {
2723
+ const host = railShell && railHost?.isConnected ? railHost : null;
2724
+ const railPx = host ? host.getBoundingClientRect().width : 0;
2725
+ if (railPx <= 0) return ARTIFACT_RESIZE_CHAT_MIN_PX;
2726
+ return railPx + ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX;
2727
+ };
2728
+
2524
2729
  /** Flush split: overlay handle on the seam so it does not consume flex gap (extension + resizable). */
2525
2730
  const positionExtensionArtifactResizeHandle = () => {
2526
2731
  if (!artifactSplitRoot || !artifactResizeHandle) return;
@@ -2636,6 +2841,9 @@ export const createAgentExperience = (
2636
2841
  artifactPaneApi.setCopyButtonVisible(
2637
2842
  config.features?.artifacts?.layout?.showCopyButton === true
2638
2843
  );
2844
+ artifactPaneApi.setRefreshButtonVisible(
2845
+ typeof config.features?.artifacts?.layout?.onDocumentToolbarRefresh === "function"
2846
+ );
2639
2847
  artifactPaneApi.setCustomActions(config.features?.artifacts?.toolbarActions ?? []);
2640
2848
  artifactPaneApi.setTabFade(config.features?.artifacts?.layout?.tabFade);
2641
2849
  artifactPaneApi.setRenderTabBar(config.features?.artifacts?.renderTabBar);
@@ -2768,7 +2976,8 @@ export const createAgentExperience = (
2768
2976
  gapPx,
2769
2977
  handleW,
2770
2978
  layout?.resizableMinWidth,
2771
- layout?.resizableMaxWidth
2979
+ layout?.resizableMaxWidth,
2980
+ railedChatMinPx()
2772
2981
  );
2773
2982
  artifactPaneApi!.element.style.width = `${clamped}px`;
2774
2983
  artifactPaneApi!.element.style.maxWidth = "none";
@@ -3050,7 +3259,9 @@ export const createAgentExperience = (
3050
3259
  panel.style.cssText = '';
3051
3260
  container.style.cssText = '';
3052
3261
  body.style.cssText = '';
3053
- footer.style.cssText = '';
3262
+ // A plugin-owned footer is the plugin's DOM: its inline state (the home
3263
+ // screen's `display: none` placeholder) is not ours to reset.
3264
+ if (!composerIsPluginOwned) footer.style.cssText = '';
3054
3265
 
3055
3266
  // Preserve the event-stream takeover across a layout-mode change. The
3056
3267
  // cssText reset above wiped the `display: none` that toggleEventStreamOn
@@ -3387,8 +3598,9 @@ export const createAgentExperience = (
3387
3598
  ${chatFlush ? 'background: transparent !important;' : ''}
3388
3599
  `;
3389
3600
 
3390
- // Remove footer border in sidebar mode
3391
- footer.style.cssText = `
3601
+ // Remove footer border in sidebar mode (never on a plugin footer: see
3602
+ // the cssText reset above)
3603
+ if (!composerIsPluginOwned) footer.style.cssText = `
3392
3604
  flex-shrink: 0 !important;
3393
3605
  border-top: none !important;
3394
3606
  padding: 8px 16px 12px 16px !important;
@@ -3464,9 +3676,17 @@ export const createAgentExperience = (
3464
3676
  } else {
3465
3677
  mount.style.removeProperty("--persona-artifact-welded-outer-radius");
3466
3678
  }
3679
+ applyRailChrome();
3680
+ // A chrome pass can restyle the panel out from under an open history host.
3681
+ reapplyHistoryHostChrome();
3467
3682
  };
3468
3683
 
3469
3684
  const destroyCallbacks: Array<() => void> = [];
3685
+ // Drops every binding (config and plugin) with the document listener.
3686
+ destroyCallbacks.push(() => {
3687
+ releaseShortcuts.forEach((release) => release());
3688
+ shortcuts.destroy();
3689
+ });
3470
3690
  // Clean up the document-level digit-key shortcut listener registered earlier.
3471
3691
  destroyCallbacks.push(() => {
3472
3692
  document.removeEventListener("keydown", handleAskUserDigitKey);
@@ -3503,6 +3723,8 @@ export const createAgentExperience = (
3503
3723
  }
3504
3724
  artifactPaneApi?.element.style.removeProperty("width");
3505
3725
  artifactPaneApi?.element.style.removeProperty("maxWidth");
3726
+ // Release the pane preview's pending file-preview timers/listeners.
3727
+ artifactPaneApi?.destroy();
3506
3728
  });
3507
3729
 
3508
3730
  // Event stream cleanup
@@ -4144,7 +4366,7 @@ export const createAgentExperience = (
4144
4366
  !!session && session.getMessages().length > 0;
4145
4367
 
4146
4368
  const syncScrollToBottomButton = () => {
4147
- if (!isScrollToBottomEnabled() || eventStreamVisible) {
4369
+ if (!isScrollToBottomEnabled() || fullPanelOverlayVisible()) {
4148
4370
  if (scrollToBottomButton.parentNode) {
4149
4371
  scrollToBottomButton.remove();
4150
4372
  }
@@ -6445,6 +6667,13 @@ export const createAgentExperience = (
6445
6667
  if (config.autoFocusInput) setTimeout(() => maybeFocusInput(), 200);
6446
6668
  });
6447
6669
 
6670
+ // Declared here, not beside the rest of the conversation-open state below:
6671
+ // `updateWelcome` reads them during the session's first render.
6672
+ let conversationOpenPendingEl: HTMLElement | null = null;
6673
+ // False during the stand-in's show delay, when the surface it replaces
6674
+ // (welcome or previous transcript) is still the one on screen.
6675
+ let conversationOpenTakeover = false;
6676
+
6448
6677
  // Welcome visibility is derived, never stored: `resolveWelcomeConfig` owns
6449
6678
  // the config and the session's user messages own the dismissal.
6450
6679
  let welcomeShown = !welcomeHost.hidden;
@@ -6461,6 +6690,9 @@ export const createAgentExperience = (
6461
6690
  // Element returned by a `renderWelcome` plugin, or null when the default
6462
6691
  // renderer owns the host. A plugin element ignores derived visibility.
6463
6692
  let welcomePluginContent: HTMLElement | null = null;
6693
+ // True while plugin content stays mounted but unmounted from the overlay,
6694
+ // so the pending stand-in the overlay body class would hide stays visible.
6695
+ let welcomePluginSuppressed = false;
6464
6696
  const updateWelcome = (messages?: AgentWidgetMessage[]) => {
6465
6697
  const resolved = resolveWelcomeConfig(config);
6466
6698
  const welcomeKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}`;
@@ -6489,7 +6721,11 @@ export const createAgentExperience = (
6489
6721
 
6490
6722
  const current =
6491
6723
  messages ?? (session ? session.getMessages() : config.initialMessages);
6492
- const visible = isWelcomeVisible(resolved, current);
6724
+ // An engaged conversation-open stand-in owns the surface: the transcript is
6725
+ // replaced only at commit, so derived visibility would hold the welcome
6726
+ // above the stand-in for the rest of the fetch.
6727
+ const openTakeover = conversationOpenTakeover;
6728
+ const visible = !openTakeover && isWelcomeVisible(resolved, current);
6493
6729
  const flipped = visible !== welcomeShown;
6494
6730
  welcomeShown = visible;
6495
6731
  // Plugin content renders regardless of derived visibility and owns the
@@ -6497,12 +6733,22 @@ export const createAgentExperience = (
6497
6733
  if (welcomePluginContent) {
6498
6734
  welcomeDismissAnimation?.cancel();
6499
6735
  welcomeDismissAnimation = null;
6500
- applyWelcomeVisibility(body, welcomeHost, true);
6736
+ if (openTakeover !== welcomePluginSuppressed) {
6737
+ welcomePluginSuppressed = openTakeover;
6738
+ // The overlay body class hides every transcript sibling, the engaged
6739
+ // stand-in included, so navigation drops it and the return restores it.
6740
+ if (openTakeover) clearWelcomePluginContent(body, welcomeHost, null);
6741
+ else mountWelcomePluginContent(body, welcomeHost, welcomePluginContent);
6742
+ }
6743
+ // Plugin content is an absolute overlay, so hiding it collapses no layout.
6744
+ applyWelcomeVisibility(body, welcomeHost, !openTakeover);
6501
6745
  return;
6502
6746
  }
6503
6747
  if (
6504
6748
  flipped &&
6505
6749
  !visible &&
6750
+ // Navigation is not a first message: that hide is instant.
6751
+ !openTakeover &&
6506
6752
  resolved.dismiss === "on-first-message" &&
6507
6753
  welcomeHydrated
6508
6754
  ) {
@@ -6522,13 +6768,17 @@ export const createAgentExperience = (
6522
6768
  return;
6523
6769
  }
6524
6770
  if (welcomeDismissAnimation) {
6525
- // Later renders must not hide the host out from under the animation.
6526
- if (!visible) return;
6771
+ // An engaged open takes the host instantly; every other render must not
6772
+ // hide it out from under the animation.
6773
+ if (!visible && !openTakeover) return;
6527
6774
  welcomeDismissAnimation.cancel();
6528
6775
  welcomeDismissAnimation = null;
6529
6776
  }
6777
+ // The host still holds layout while a cancelled dismiss was running, so the
6778
+ // repin keys off the applied state rather than the derived flip.
6779
+ const wasLaidOut = !welcomeHost.hidden;
6530
6780
  applyWelcomeVisibility(body, welcomeHost, visible);
6531
- if (flipped && !visible) repinAnchoredMessage();
6781
+ if (!visible && wasLaidOut) repinAnchoredMessage();
6532
6782
  };
6533
6783
 
6534
6784
  // `renderWelcome` arbitration. The core owns the host; a re-render runs the
@@ -6601,6 +6851,7 @@ export const createAgentExperience = (
6601
6851
  runWelcomeCleanups();
6602
6852
  clearWelcomePluginContent(body, welcomeHost, welcomePluginContent);
6603
6853
  welcomePluginContent = null;
6854
+ welcomePluginSuppressed = false;
6604
6855
 
6605
6856
  const current = session ? session.getMessages() : config.initialMessages;
6606
6857
  const visible = isWelcomeVisible(resolved, current);
@@ -6677,22 +6928,131 @@ export const createAgentExperience = (
6677
6928
 
6678
6929
  // Add session ID persistence callbacks for client token mode
6679
6930
  // These allow the widget to resume conversations by passing session_id to /client/init
6931
+ // Conversation-id callbacks defer to host-supplied ones; only the internal
6932
+ // revision writer is synthesized alongside a synthesized id writer.
6933
+ const hostOwnsConversationId = Boolean(config.setStoredConversationId);
6934
+ const dropMetadataKey = (key: string) =>
6935
+ updateSessionMetadata((prev) => {
6936
+ const next = { ...prev };
6937
+ delete next[key];
6938
+ return next;
6939
+ });
6940
+ const readMetadataString = (key: string): string | null => {
6941
+ const stored = persistentMetadata[key];
6942
+ return typeof stored === 'string' ? stored : null;
6943
+ };
6680
6944
  if (config.clientToken) {
6681
6945
  config = {
6682
6946
  ...config,
6683
- getStoredSessionId: () => {
6684
- const storedId = persistentMetadata['sessionId'];
6685
- return typeof storedId === 'string' ? storedId : null;
6686
- },
6947
+ getStoredSessionId: () => readMetadataString('sessionId'),
6687
6948
  setStoredSessionId: (sessionId: string) => {
6688
6949
  updateSessionMetadata((prev) => ({
6689
6950
  ...prev,
6690
6951
  sessionId: sessionId,
6691
6952
  }));
6692
6953
  },
6954
+ clearStoredSessionId:
6955
+ config.clearStoredSessionId ?? (() => dropMetadataKey('sessionId')),
6956
+ getStoredConversationId:
6957
+ config.getStoredConversationId ?? (() => readMetadataString('conversationId')),
6958
+ setStoredConversationId:
6959
+ config.setStoredConversationId ??
6960
+ ((conversationId: string) => {
6961
+ updateSessionMetadata((prev) => ({ ...prev, conversationId }));
6962
+ }),
6963
+ clearStoredConversationId:
6964
+ config.clearStoredConversationId ?? (() => dropMetadataKey('conversationId')),
6693
6965
  };
6694
6966
  }
6695
6967
 
6968
+ // Visitor-history credential store, keyed on (clientToken, keyPrefix,
6969
+ // persistence-disabled). Owned here and injected into every client the
6970
+ // session builds; `update()` rebuilds it when that tuple changes.
6971
+ const historyStoreToken = (): string | null =>
6972
+ config.features?.history?.enabled === true ? (config.clientToken ?? null) : null;
6973
+ const buildVisitorStore = (token: string) =>
6974
+ createVisitorStore(token, currentKeyPrefix(), config.persistState === false);
6975
+ const initialStoreToken = historyStoreToken();
6976
+ let visitorStore: VisitorStore | null = initialStoreToken
6977
+ ? buildVisitorStore(initialStoreToken)
6978
+ : null;
6979
+ // Internal history metadata rides the same serialized persistence path as the
6980
+ // transcript, so a reload reconstructs revision, prepend cursor, and any
6981
+ // display projection still owed to the server.
6982
+ const readPendingProjections = (): PendingDisplayProjections | null => {
6983
+ const stored = persistentMetadata.pendingDisplayProjections;
6984
+ if (!stored || typeof stored !== 'object') return null;
6985
+ const candidate = stored as Partial<PendingDisplayProjections>;
6986
+ if (typeof candidate.conversationId !== 'string') return null;
6987
+ if (!Array.isArray(candidate.messageIds)) return null;
6988
+ return {
6989
+ conversationId: candidate.conversationId,
6990
+ messageIds: candidate.messageIds.filter(
6991
+ (id): id is string => typeof id === 'string'
6992
+ ),
6993
+ };
6994
+ };
6995
+ let historyInternals: WidgetHistoryInternals = {
6996
+ ...(visitorStore ? { visitorStore } : {}),
6997
+ historyBootstrapReady,
6998
+ getStoredMessageCursor: () => readMetadataString('historyMessageCursor'),
6999
+ setStoredMessageCursor: (cursor: string | null) => {
7000
+ if (cursor === null) {
7001
+ dropMetadataKey('historyMessageCursor');
7002
+ return;
7003
+ }
7004
+ updateSessionMetadata((prev) => ({
7005
+ ...prev,
7006
+ historyMessageCursor: cursor,
7007
+ }));
7008
+ },
7009
+ getPendingDisplayProjections: readPendingProjections,
7010
+ setPendingDisplayProjections: (pending) => {
7011
+ if (!pending) {
7012
+ dropMetadataKey('pendingDisplayProjections');
7013
+ return;
7014
+ }
7015
+ updateSessionMetadata((prev) => ({
7016
+ ...prev,
7017
+ pendingDisplayProjections: pending,
7018
+ }));
7019
+ },
7020
+ ...(hostOwnsConversationId
7021
+ ? {}
7022
+ : {
7023
+ getStoredConversationRevision: () =>
7024
+ readMetadataString('conversationRevision'),
7025
+ setStoredConversationRevision: (revision: string | null) => {
7026
+ if (revision === null) {
7027
+ dropMetadataKey('conversationRevision');
7028
+ return;
7029
+ }
7030
+ updateSessionMetadata((prev) => ({
7031
+ ...prev,
7032
+ conversationRevision: revision,
7033
+ }));
7034
+ },
7035
+ }),
7036
+ };
7037
+ const syncVisitorStore = () => {
7038
+ const token = historyStoreToken();
7039
+ if (
7040
+ token &&
7041
+ visitorStore?.matches(token, currentKeyPrefix(), config.persistState === false)
7042
+ ) {
7043
+ return;
7044
+ }
7045
+ if (!token && !visitorStore) return;
7046
+ // Never clear the old namespace: it belongs to the prior surface/config.
7047
+ visitorStore?.destroy();
7048
+ visitorStore = token ? buildVisitorStore(token) : null;
7049
+ const nextInternals: WidgetHistoryInternals = { ...historyInternals };
7050
+ if (visitorStore) nextInternals.visitorStore = visitorStore;
7051
+ else delete nextInternals.visitorStore;
7052
+ historyInternals = nextInternals;
7053
+ session.setHistoryInternals(historyInternals);
7054
+ };
7055
+
6696
7056
  // Global timer for live-updating tool elapsed time spans.
6697
7057
  // Runs at 100ms while any [data-tool-elapsed] span exists in the message area,
6698
7058
  // auto-stops when none remain. Operates on real DOM after morph, not temp elements.
@@ -6912,6 +7272,8 @@ export const createAgentExperience = (
6912
7272
  return statusCopy[s];
6913
7273
  };
6914
7274
  applyStatusToElement(statusText, getCurrentStatusText(status), currentStatusConfig, status);
7275
+ // A paused/resuming durable turn gates the history button too.
7276
+ historyChromeSync();
6915
7277
  },
6916
7278
  onStreamingChanged(streaming) {
6917
7279
  if (!streaming) {
@@ -6923,6 +7285,7 @@ export const createAgentExperience = (
6923
7285
  }
6924
7286
  isStreaming = streaming;
6925
7287
  setComposerDisabled(streaming);
7288
+ historyChromeSync();
6926
7289
  // Re-render messages to show/hide typing indicator
6927
7290
  if (session) {
6928
7291
  renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
@@ -7009,6 +7372,13 @@ export const createAgentExperience = (
7009
7372
  syncArtifactPane();
7010
7373
  persistState();
7011
7374
  },
7375
+ // Late-bound: the history block installs the real handlers below.
7376
+ onHistoryStateChanged(state) {
7377
+ historyStateHandler(state);
7378
+ },
7379
+ onHistoryNotice(notice) {
7380
+ historyNoticeHandler(notice);
7381
+ },
7012
7382
  onReconnect(event) {
7013
7383
  // Map the durable-reconnect lifecycle to public controller events.
7014
7384
  const { executionId, lastEventId } = event.handle;
@@ -7024,7 +7394,7 @@ export const createAgentExperience = (
7024
7394
  eventBus.emit("stream:resumed", { executionId, after: lastEventId });
7025
7395
  }
7026
7396
  }
7027
- });
7397
+ }, historyInternals);
7028
7398
 
7029
7399
  sessionRef.current = session;
7030
7400
 
@@ -7032,6 +7402,12 @@ export const createAgentExperience = (
7032
7402
  // reconnect's backoff timer and focus/online listeners don't outlive the
7033
7403
  // widget (cancel() → teardownReconnect()).
7034
7404
  destroyCallbacks.push(() => session.cancel());
7405
+ // Drop the visitor store's `storage` listener and the session's subscription
7406
+ // to it; a client rebuild must not leak either.
7407
+ destroyCallbacks.push(() => {
7408
+ session.destroy();
7409
+ visitorStore?.destroy();
7410
+ });
7035
7411
 
7036
7412
  // Mirror read-aloud playback state into the action buttons, and surface it as
7037
7413
  // a controller event (parallel to message:copy / message:feedback).
@@ -7075,161 +7451,2489 @@ export const createAgentExperience = (
7075
7451
  }
7076
7452
  }
7077
7453
 
7078
- // Pre-initialize client session when in client token mode so feedback works
7079
- // before the user sends their first message (e.g. on restored/persisted messages)
7080
- if (config.clientToken) {
7081
- session.initClientSession().catch((err) => {
7082
- if (config.debug) {
7083
- // eslint-disable-next-line no-console
7084
- console.warn("[AgentWidget] Pre-init client session failed:", err);
7085
- }
7454
+ // ==========================================================================
7455
+ // Visitor conversation history shell
7456
+ // (docs/visitor-history-implementation-plan.md D6/D7/D8)
7457
+ //
7458
+ // The shell owns placement, open/close, inertness of the obscured
7459
+ // conversation, focus orchestration, confirmations, and every session
7460
+ // mutation. The lazily loaded Messages view owns the list itself.
7461
+ // ==========================================================================
7462
+
7463
+ /** Rail needs this much HOST width; below it, rail collapses to panel. */
7464
+ const RAIL_MIN_CONTAINER_WIDTH = 720;
7465
+ /** Ceiling on the view's ~160ms exit before the close proceeds regardless. */
7466
+ const HISTORY_EXIT_TIMEOUT_MS = 250;
7467
+
7468
+ let historyShellCopy: ResolvedHistoryShellCopy = resolveHistoryShellCopy(
7469
+ config.features?.history?.copy
7470
+ );
7471
+ let historyProvider: HistoryProvider | null = null;
7472
+ let historyUnavailable = false;
7473
+ /** Default view + plugin render-hook arbitration. Null while closed. */
7474
+ let historySurface: HistoryRenderSurface | null = null;
7475
+ let historyOperationContext: HistoryOperationContext | null = null;
7476
+ let historyReturnSurface: HistoryReturnSurface = "conversation";
7477
+ let historyInvoker: HTMLElement | null = null;
7478
+ let historyButton: HTMLButtonElement | null = null;
7479
+ /** The inserted node: removing the button alone would orphan its wrapper. */
7480
+ let historyButtonWrapper: HTMLElement | null = null;
7481
+ let historySessionState: SessionHistoryState = session.getHistoryState();
7482
+ let historyIdentityKey: string | null = null;
7483
+ let historyOpenToken = 0;
7484
+ let clearChatDefaultLabel: string | null = null;
7485
+ let unsubscribeHistoryAvailability: (() => void) | null = null;
7486
+ let unsubscribeHistoryIdentity: (() => void) | null = null;
7487
+ const historyRegionId = `persona-history-${Math.random().toString(36).slice(2, 8)}`;
7488
+
7489
+ const historyFeatureEnabled = (): boolean =>
7490
+ config.features?.history?.enabled === true;
7491
+ const historyAvailable = (): boolean =>
7492
+ historyFeatureEnabled() && !!historyProvider && !historyUnavailable;
7493
+ /** Switching conversations mid-turn would abandon a live answer. */
7494
+ const historyTurnBusy = (): boolean => {
7495
+ const status = session.getStatus();
7496
+ return isStreaming || status === "paused" || status === "resuming";
7497
+ };
7498
+ const historyScope = (): HistoryScope => {
7499
+ const configured = config.features?.history?.scope;
7500
+ if (configured) return configured;
7501
+ // Derived, not requested: narrow to what the provider advertises rather
7502
+ // than asking it for a scope it will reject.
7503
+ const derived: HistoryScope = config.getIdentityProof
7504
+ ? "verified-user"
7505
+ : "browser";
7506
+ const scopes = historyProvider?.capabilities.scopes;
7507
+ if (!scopes || scopes.includes(derived)) return derived;
7508
+ return scopes[0] ?? derived;
7509
+ };
7510
+ const historyOperationScope = (): HistoryScope =>
7511
+ historyOperationContext?.scope ?? historyScope();
7512
+ const activeHistoryTargetId = (): string | null =>
7513
+ session.getClientSession()?.targetId ?? null;
7514
+
7515
+ const identityStatusKey = (status: HistoryIdentityStatus): string =>
7516
+ `${status.state}:${"reason" in status ? status.reason : ""}`;
7517
+
7518
+ /** Instance-scoped only: authentication state is never broadcast page-wide. */
7519
+ const emitHistoryIdentityStatus = (status: HistoryIdentityStatus): void => {
7520
+ const key = identityStatusKey(status);
7521
+ if (key === historyIdentityKey) return;
7522
+ historyIdentityKey = key;
7523
+ eventBus.emit("history:identityStatusChanged", {
7524
+ status,
7525
+ timestamp: Date.now(),
7086
7526
  });
7087
- }
7527
+ };
7088
7528
 
7089
- // Wire up optional SSE tap (host) + event stream buffer to capture SSE events
7090
- if (eventStreamBuffer || config.onSSEEvent) {
7091
- session.setSSEEventCallback((type: string, payload: unknown) => {
7092
- config.onSSEEvent?.(type, payload);
7093
- throughputTracker?.processEvent(type, payload);
7094
- eventStreamBuffer?.push({
7095
- id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
7096
- type,
7097
- timestamp: Date.now(),
7098
- payload: JSON.stringify(payload)
7099
- });
7529
+ /** Unconditional: `announce()` is gated on an unrelated scroll opt-in. */
7530
+ const announceHistory = (message: string): void => {
7531
+ if (!message) return;
7532
+ liveRegion.textContent = "";
7533
+ liveRegion.textContent = message;
7534
+ };
7535
+
7536
+ // --- provider ------------------------------------------------------------
7537
+
7538
+ /** One provider build per widget instance; a new factory identity rebuilds. */
7539
+ let configuredProviderFactory: (() => HistoryProvider) | null = null;
7540
+ let configuredProviderInstance: HistoryProvider | null = null;
7541
+
7542
+ const buildHistoryProvider = (): HistoryProvider | null => {
7543
+ if (!historyFeatureEnabled()) return null;
7544
+ // Demo/test override first, then the host's own provider; production
7545
+ // without one builds the Runtype provider from client-token config.
7546
+ const override = getHistoryProviderFactory();
7547
+ if (override) return override();
7548
+ const configured = config.features?.history?.provider;
7549
+ if (configured) {
7550
+ if (typeof configured !== "function") return configured;
7551
+ if (configuredProviderFactory !== configured) {
7552
+ configuredProviderFactory = configured;
7553
+ configuredProviderInstance = configured();
7554
+ }
7555
+ return configuredProviderInstance;
7556
+ }
7557
+ if (!session.isClientTokenMode()) return null;
7558
+ return createRuntypeHistoryProvider({
7559
+ client: session.getClient(),
7560
+ getIdentityProofConfigured: () =>
7561
+ typeof config.getIdentityProof === "function",
7562
+ onActivationCommitted: (clientSession) =>
7563
+ session.bindActivatedSession(clientSession),
7564
+ // Connection-config rebuilds replace the client under the provider.
7565
+ getClient: () => session.getClient(),
7100
7566
  });
7101
- }
7567
+ };
7102
7568
 
7103
- // Durable-session reconnect boot path: once history is in place,
7104
- // if the host passed a non-terminal resume handle (+ a reconnect transport),
7105
- // immediately re-enter `resuming` and replay everything past the cursor into
7106
- // the restored conversation. Fires AFTER hydration so the trailing partial
7107
- // assistant bubble exists for the replay to append to.
7108
- const maybeBootResume = () => {
7109
- if (config.resume && typeof config.reconnectStream === "function") {
7110
- session.resumeFromHandle(config.resume);
7111
- }
7569
+ const installHistoryProvider = (): void => {
7570
+ unsubscribeHistoryAvailability?.();
7571
+ unsubscribeHistoryAvailability = null;
7572
+ unsubscribeHistoryIdentity?.();
7573
+ unsubscribeHistoryIdentity = null;
7574
+ historyUnavailable = false;
7575
+ historyProvider = buildHistoryProvider();
7576
+ const next: WidgetHistoryInternals = { ...historyInternals };
7577
+ if (historyProvider) next.historyProvider = historyProvider;
7578
+ else delete next.historyProvider;
7579
+ historyInternals = next;
7580
+ session.setHistoryInternals(historyInternals);
7581
+ if (!historyProvider) return;
7582
+ unsubscribeHistoryAvailability =
7583
+ historyProvider.subscribeAvailability?.((available) => {
7584
+ historyUnavailable = !available;
7585
+ // A degrade must remove an ALREADY-rendered surface, not just a flag.
7586
+ if (!available) closeHistory({ restoreFocus: false });
7587
+ syncHistoryChromeImpl();
7588
+ }) ?? null;
7589
+ unsubscribeHistoryIdentity = historyProvider.subscribeIdentityStatus(
7590
+ (status) => emitHistoryIdentityStatus(status)
7591
+ );
7592
+ historyIdentityKey = identityStatusKey(historyProvider.getIdentityStatus());
7112
7593
  };
7113
7594
 
7114
- if (pendingStoredState) {
7115
- pendingStoredState
7116
- .then((state) => {
7117
- if (!state) return;
7118
- if (state.metadata) {
7119
- persistentMetadata = ensureRecord(state.metadata);
7120
- actionManager.syncFromMetadata();
7121
- }
7122
- if (state.messages?.length) {
7123
- // Restored history must not read as a fresh send (scroll-on-send /
7124
- // anchor-top would fire for the last restored user message).
7125
- suppressScrollSend = true;
7126
- try {
7127
- session.hydrateMessages(state.messages);
7128
- } finally {
7129
- suppressScrollSend = false;
7130
- }
7131
- }
7132
- if (state.artifacts?.length) {
7133
- session.hydrateArtifacts(
7134
- state.artifacts,
7135
- state.selectedArtifactId ?? null
7136
- );
7137
- }
7138
- })
7139
- .catch((error) => {
7140
- if (typeof console !== "undefined") {
7141
- // eslint-disable-next-line no-console
7142
- console.error("[AgentWidget] Failed to hydrate stored state:", error);
7143
- }
7144
- })
7145
- .finally(() => {
7146
- welcomeHydrated = true;
7147
- maybeBootResume();
7148
- });
7149
- } else {
7150
- maybeBootResume();
7151
- }
7595
+ // --- presentation hosts --------------------------------------------------
7152
7596
 
7153
- // Centralized so both the default composer (`handleSubmit`) and the plugin
7154
- // composer (`renderComposer.onSubmit`) auto-expand the composer-bar wrapper
7155
- // when a message is sent while the panel is collapsed. Without a single
7156
- // helper the two submit paths drift over time.
7157
- const maybeExpandComposerBar = () => {
7158
- if (!isComposerBar()) return;
7159
- if (open) return;
7160
- const expandOnSubmit = config.launcher?.composerBar?.expandOnSubmit ?? true;
7161
- if (!expandOnSubmit) return;
7162
- setOpenState(true, "auto");
7597
+ /**
7598
+ * Resolved against the history HOST width, never the viewport. With the
7599
+ * artifact split mounted the split root is that host, not the container:
7600
+ * width the artifact pane borrows from the chat column is still the widget's,
7601
+ * so a drag on the split must never read as a narrow host and re-mode the
7602
+ * rail. A real window/host narrowing shrinks the split root too, and still
7603
+ * flips.
7604
+ */
7605
+ const resolveHistoryPresentation = (): ResolvedHistoryPresentation => {
7606
+ const configured = config.features?.history?.presentation ?? "panel";
7607
+ if (configured === "panel") return "panel";
7608
+ if (configured === "auto") {
7609
+ // Floating launchers stay panel-based at every width.
7610
+ const inlineOrDocked = !launcherEnabled || isDockedMountMode(config);
7611
+ if (!inlineOrDocked) return "panel";
7612
+ }
7613
+ const host = artifactSplitRoot ?? container;
7614
+ const width = host.getBoundingClientRect().width || host.clientWidth;
7615
+ return width >= RAIL_MIN_CONTAINER_WIDTH ? "rail" : "panel";
7616
+ };
7617
+
7618
+ const setHistoryHostInert = (element: HTMLElement, inert: boolean): void => {
7619
+ if (inert) {
7620
+ element.setAttribute("aria-hidden", "true");
7621
+ element.setAttribute("inert", "");
7622
+ } else {
7623
+ element.removeAttribute("aria-hidden");
7624
+ element.removeAttribute("inert");
7625
+ }
7626
+ };
7627
+
7628
+ /** Suppression of the shell header's own children while Messages owns the bar. */
7629
+ const HISTORY_SUPPRESSED_ATTR = "data-persona-history-suppressed";
7630
+ /** Shell-owned wrapper for the view's bar contents. Null while not hosting. */
7631
+ let historyHeaderHost: HTMLElement | null = null;
7632
+ /** Header carrying the swap-time min-height pin. Null while not hosting. */
7633
+ let historyPinnedHeader: HTMLElement | null = null;
7634
+ /** Handle + arbitrated element, tracked from before `historySurface` is assigned. */
7635
+ let historyViewHandle: HistoryViewHandle | null = null;
7636
+ let historyMountedElement: HTMLElement | null = null;
7637
+ let capturedPanelHeader: HTMLElement | null = null;
7638
+ let capturedPanelHeaderDisplay = "";
7639
+
7640
+ /** The display the shell header returns to; "none" means there is no bar. */
7641
+ const shownHeaderDisplay = (): string =>
7642
+ header === capturedPanelHeader
7643
+ ? capturedPanelHeaderDisplay
7644
+ : config.layout?.showHeader === false
7645
+ ? "none"
7646
+ : "";
7647
+
7648
+ /**
7649
+ * One persistent bar: the shell header hosts the view's bar contents whenever
7650
+ * the default view owns a panel and there is a visible header to host them in.
7651
+ * A plugin's full custom surface owns everything, so it falls back to hiding.
7652
+ */
7653
+ const historyHeaderExternal = (): boolean =>
7654
+ historyPresentation === "panel" &&
7655
+ !!historyViewHandle &&
7656
+ historyMountedElement === historyViewHandle.element &&
7657
+ config.layout?.showHeader !== false &&
7658
+ shownHeaderDisplay() !== "none";
7659
+
7660
+ const suppressHeaderChildren = (): void => {
7661
+ for (const child of Array.from(header.children)) {
7662
+ if (child === historyHeaderHost) continue;
7663
+ child.setAttribute(HISTORY_SUPPRESSED_ATTR, "");
7664
+ }
7665
+ };
7666
+
7667
+ /** By attribute, not by captured list: the header may have been rebuilt. */
7668
+ const unsuppressHeaderChildren = (): void => {
7669
+ for (const node of Array.from(
7670
+ container.querySelectorAll(`[${HISTORY_SUPPRESSED_ATTR}]`)
7671
+ )) {
7672
+ node.removeAttribute(HISTORY_SUPPRESSED_ATTR);
7673
+ }
7674
+ };
7675
+
7676
+ const hostHistoryHeaderContent = (): void => {
7677
+ const view = historyViewHandle;
7678
+ if (!view) return;
7679
+ // Pin the pre-swap height so the contents swap never moves the chrome: the
7680
+ // hosted bar is usually shorter than the title cluster it replaces, and
7681
+ // constant-height chrome is what keeps the switch from reading as layout
7682
+ // shift. Measurable only while the original contents are still visible; a
7683
+ // re-entry on an already-hosted header keeps the standing pin, and a
7684
+ // rebuilt header re-measures before its own suppression below.
7685
+ const originalsVisible = Array.from(header.children).some(
7686
+ (child) =>
7687
+ child !== historyHeaderHost &&
7688
+ !child.hasAttribute(HISTORY_SUPPRESSED_ATTR)
7689
+ );
7690
+ if (originalsVisible) {
7691
+ if (historyPinnedHeader && historyPinnedHeader !== header) {
7692
+ historyPinnedHeader.style.removeProperty("min-height");
7693
+ historyPinnedHeader = null;
7694
+ }
7695
+ const measured = header.offsetHeight;
7696
+ if (measured > 0) {
7697
+ header.style.minHeight = `${measured}px`;
7698
+ historyPinnedHeader = header;
7699
+ }
7700
+ }
7701
+ if (!historyHeaderHost) {
7702
+ historyHeaderHost = createElement("div", "persona-history-header-host");
7703
+ }
7704
+ view.setHeaderPlacement("external");
7705
+ const content = view.getHeaderElement();
7706
+ if (content.parentNode !== historyHeaderHost) {
7707
+ historyHeaderHost.replaceChildren(content);
7708
+ }
7709
+ // The wrapper follows a rebuilt header binding; focus inside it survives.
7710
+ if (historyHeaderHost.parentNode !== header) {
7711
+ header.appendChild(historyHeaderHost);
7712
+ }
7713
+ suppressHeaderChildren();
7163
7714
  };
7164
7715
 
7165
- // Combine `@`-chip mentions with an inline server-command's context bundle so
7166
- // both reach the message in one `mentions` payload. Either side may be null.
7167
- type SubmitMentions = NonNullable<
7168
- ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
7169
- >;
7170
- const mergeSubmitMentions = (
7171
- a: SubmitMentions | null,
7172
- b: SubmitMentions | null
7173
- ): SubmitMentions | null => {
7174
- if (!a) return b;
7175
- if (!b) return a;
7176
- return {
7177
- refs: [...a.refs, ...b.refs],
7178
- // allSettled, not all: one side rejecting must not discard the other
7179
- // side's already-resolved context (the bubble already echoed its chips).
7180
- finalize: async () => {
7181
- const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
7182
- const fulfilled: MentionSubmitBundle[] = [];
7183
- if (ra.status === "fulfilled") fulfilled.push(ra.value);
7184
- else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
7185
- if (rb.status === "fulfilled") fulfilled.push(rb.value);
7186
- else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
7187
- return mergeFinalizedMentions(fulfilled);
7188
- },
7189
- };
7716
+ const releaseHistoryHeaderContent = (): void => {
7717
+ if (historyPinnedHeader) {
7718
+ historyPinnedHeader.style.removeProperty("min-height");
7719
+ historyPinnedHeader = null;
7720
+ }
7721
+ if (!historyHeaderHost) return;
7722
+ // The view re-adopts its bar; the wrapper never owns the content's lifetime.
7723
+ historyViewHandle?.setHeaderPlacement("inline");
7724
+ historyHeaderHost.remove();
7725
+ historyHeaderHost = null;
7726
+ unsuppressHeaderChildren();
7190
7727
  };
7191
7728
 
7192
- // Inline-mode composer element: after the contenteditable swap, `textarea`
7193
- // additionally exposes `getInlineMessageFields()` (built in the inline chunk,
7194
- // which owns the document model — the core never imports it). Structural shape
7195
- // only, so no runtime import crosses the bundle boundary.
7196
- type InlineComposerFieldsHost = {
7197
- getInlineMessageFields?: () => {
7198
- content: string;
7199
- contextMentions: AgentWidgetContextMentionRef[];
7200
- contentSegments: AgentWidgetContentSegment[];
7729
+ /**
7730
+ * Panel presentation obscures the conversation, so the transcript AND the
7731
+ * composer must be unreachable: a visitor must never send into a conversation
7732
+ * they cannot see. The header bar itself stays: only its contents swap, which
7733
+ * is also why it is never inert here (rail changes nothing at all: the
7734
+ * conversation stays primary).
7735
+ *
7736
+ * The widget's close (×) usually lives inside that header, but no trap
7737
+ * results: the view's back control is the initial focus target, Escape exits,
7738
+ * and both restore the header contents before focus lands. `top-right` close
7739
+ * placement parents the × to the container, so it stays reachable either way.
7740
+ *
7741
+ * Restores exactly what it captured.
7742
+ */
7743
+ let restorePanelHost: (() => void) | null = null;
7744
+ const enforcePanelHost = (): void => {
7745
+ if (!restorePanelHost) return;
7746
+ body.style.display = "none";
7747
+ setHistoryHostInert(body, true);
7748
+ // Live `footer` / `header` bindings: a composer-plugin rebuild or a
7749
+ // header-layout rebuild swaps them and re-enters here for the replacement.
7750
+ footer.hidden = true;
7751
+ setHistoryHostInert(footer, true);
7752
+ if (historyHeaderExternal()) {
7753
+ hostHistoryHeaderContent();
7754
+ header.style.display = shownHeaderDisplay();
7755
+ setHistoryHostInert(header, false);
7756
+ return;
7757
+ }
7758
+ // No bar to host in: hide the header the way the surface used to.
7759
+ releaseHistoryHeaderContent();
7760
+ header.style.display = "none";
7761
+ setHistoryHostInert(header, true);
7762
+ };
7763
+ reapplyHistoryHostChrome = enforcePanelHost;
7764
+
7765
+ const mountPanelHost = (element: HTMLElement): void => {
7766
+ const previousDisplay = body.style.display;
7767
+ const previousFooterHidden = footer.hidden;
7768
+ const capturedFooter = footer;
7769
+ const capturedHeader = header;
7770
+ const previousHeaderDisplay = header.style.display;
7771
+ capturedPanelHeader = capturedHeader;
7772
+ capturedPanelHeaderDisplay = previousHeaderDisplay;
7773
+ restorePanelHost = () => {
7774
+ restorePanelHost = null;
7775
+ releaseHistoryHeaderContent();
7776
+ capturedPanelHeader = null;
7777
+ body.style.display = previousDisplay;
7778
+ setHistoryHostInert(body, false);
7779
+ capturedFooter.hidden = previousFooterHidden;
7780
+ setHistoryHostInert(capturedFooter, false);
7781
+ if (footer !== capturedFooter) {
7782
+ footer.hidden = false;
7783
+ setHistoryHostInert(footer, false);
7784
+ }
7785
+ capturedHeader.style.display = previousHeaderDisplay;
7786
+ setHistoryHostInert(capturedHeader, false);
7787
+ if (header !== capturedHeader) {
7788
+ // A replacement carries no captured state; only config decides it.
7789
+ header.style.display = config.layout?.showHeader === false ? "none" : "";
7790
+ setHistoryHostInert(header, false);
7791
+ }
7201
7792
  };
7793
+ footer.parentNode?.insertBefore(element, footer);
7794
+ enforcePanelHost();
7202
7795
  };
7203
- // Read the ordered display segments from the live inline composer, but only
7204
- // when at least one mention is present: a plain-text inline message must keep
7205
- // the normal markdown-rendered bubble (segments bypass that path), so we return
7206
- // undefined for pure prose and let the bubble render `content` as usual.
7207
- const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
7208
- const host = textarea as unknown as InlineComposerFieldsHost;
7209
- const fields = host.getInlineMessageFields?.();
7210
- if (!fields) return undefined;
7211
- const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
7212
- return hasMention ? fields.contentSegments : undefined;
7796
+
7797
+ let railShell: HTMLElement | null = null;
7798
+ let railHost: HTMLElement | null = null;
7799
+ let railColumn: HTMLElement | null = null;
7800
+ /** Container order the rail borrows from and hands back, header first. */
7801
+ const railBorrowed = (): Array<HTMLElement | null> => [
7802
+ header,
7803
+ panelElements.closeButtonWrapper,
7804
+ panelElements.clearChatButtonWrapper,
7805
+ body,
7806
+ // Composer-bar mode keeps the footer outside the container; the parent
7807
+ // checks below then never adopt it.
7808
+ footer,
7809
+ ];
7810
+
7811
+ /** Collapsed icon rail, measured from the reference sidebar. */
7812
+ const RAIL_COLLAPSED_WIDTH = 52;
7813
+ const railCollapsible = (): boolean =>
7814
+ config.features?.history?.rail?.collapsible !== false;
7815
+ const railSide = (): "left" | "right" =>
7816
+ config.features?.history?.rail?.side === "right" ? "right" : "left";
7817
+ /** The band a rail width may take, by config or by drag. */
7818
+ const RAIL_MIN_WIDTH = 200;
7819
+ const RAIL_MAX_WIDTH = 400;
7820
+ const clampRailWidth = (value: number): number =>
7821
+ Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, Math.round(value)));
7822
+
7823
+ const railResizable = (): boolean =>
7824
+ config.features?.history?.rail?.resizable === true;
7825
+ /** Resolved once per widget: storage, else nothing. */
7826
+ let railWidthChoice: number | null | undefined;
7827
+
7828
+ // Same teaser pattern as the collapsed state: blocked storage throws rather
7829
+ // than fails, so the resolved value is also the in-memory fallback.
7830
+ const storedRailWidth = (): number | null => {
7831
+ if (railWidthChoice === undefined) {
7832
+ railWidthChoice = null;
7833
+ if (config.persistState !== false) {
7834
+ try {
7835
+ const stored = Number(
7836
+ window.localStorage.getItem(`${currentKeyPrefix()}rail-width`)
7837
+ );
7838
+ if (stored) railWidthChoice = stored;
7839
+ } catch {
7840
+ /* blocked storage: the config width stands */
7841
+ }
7842
+ }
7843
+ }
7844
+ return railWidthChoice;
7213
7845
  };
7214
7846
 
7215
- // Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
7216
- // lazy chunk load + host resolve) before the composer clears, and
7217
- // `isStreaming()` is still false in that window, so a second Enter would
7218
- // otherwise dispatch the same text/command twice.
7219
- let submitInFlight = false;
7847
+ /**
7848
+ * Expanded rail width, shared by the column and the floating overlay. A
7849
+ * dragged width outranks config, the way the collapsed state does: a live
7850
+ * `update()` must not undo what the visitor chose.
7851
+ */
7852
+ const railWidth = (): number =>
7853
+ clampRailWidth(
7854
+ storedRailWidth() ?? config.features?.history?.rail?.width ?? 260
7855
+ );
7220
7856
 
7221
- const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
7222
- const value = textarea.value.trim();
7223
- const hasAttachments = attachmentManager?.hasAttachments() ?? false;
7857
+ const setStoredRailWidth = (next: number): void => {
7858
+ railWidthChoice = next;
7859
+ if (config.persistState === false) return;
7860
+ try {
7861
+ window.localStorage.setItem(`${currentKeyPrefix()}rail-width`, String(next));
7862
+ } catch {
7863
+ /* blocked storage: the in-memory value above is the fallback */
7864
+ }
7865
+ };
7866
+ /**
7867
+ * Overlay mode: the collapsed rail is a trigger in the conversation header
7868
+ * plus a floating host, so there is no icon column and no mounted view at
7869
+ * rest. `collapsed` then means "not pinned".
7870
+ */
7871
+ const railOverlayMode = (): boolean =>
7872
+ railCollapsible() &&
7873
+ config.features?.history?.rail?.collapsedBehavior === "overlay";
7874
+ /** True while the view is mounted in the floating host, not a rail column. */
7875
+ let railOverlayOpen = false;
7224
7876
 
7225
- // Inline slash command (Slack-style): every `command:"server"` plus any
7226
- // arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
7227
- // text to send, and an action sends nothing at all.
7228
- const inline = value
7229
- ? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
7230
- : null;
7877
+ /** Decorative image for a config-supplied rail icon or brand URL. */
7878
+ const railIconImage = (src: string): HTMLImageElement => {
7879
+ const image = createElement("img");
7880
+ image.src = src;
7881
+ image.alt = "";
7882
+ image.setAttribute("aria-hidden", "true");
7883
+ return image;
7884
+ };
7231
7885
 
7232
- if (inline?.kind === "action") {
7886
+ /**
7887
+ * One brand declaration, resolved here where the lucide registry lives, into
7888
+ * the callback both rail placements call: the expanded heading and the
7889
+ * collapsed toggle's rest face. Precedence render > iconUrl > icon; the
7890
+ * icon cases resolve once (so an unknown name warns once) and each caller
7891
+ * gets its own copy, since both faces can be in the DOM at once.
7892
+ */
7893
+ const railBrandNode = ():
7894
+ | ((collapsed: boolean) => Element | null)
7895
+ | undefined => {
7896
+ const brand = config.features?.history?.rail?.brand;
7897
+ if (!brand) return undefined;
7898
+ let warned = false;
7899
+ let mark: Element | null | undefined;
7900
+ return (collapsed) => {
7901
+ if (brand.render) {
7902
+ try {
7903
+ return brand.render({ collapsed }) ?? null;
7904
+ } catch (error) {
7905
+ if (!warned) {
7906
+ warned = true;
7907
+ console.warn("[persona] history rail brand threw", error);
7908
+ }
7909
+ return null;
7910
+ }
7911
+ }
7912
+ if (mark === undefined) {
7913
+ if (brand.iconUrl) mark = railIconImage(brand.iconUrl);
7914
+ else mark = brand.icon ? renderLucideIcon(brand.icon, 20) : null;
7915
+ }
7916
+ return mark ? (mark.cloneNode(true) as Element) : null;
7917
+ };
7918
+ };
7919
+
7920
+ /**
7921
+ * Config nav sections, normalized for the size-capped chunk: icon precedence
7922
+ * (renderIcon > iconUrl > icon) collapses to one memoized thunk resolved
7923
+ * here, where the lucide registry already lives, and every host callback gets
7924
+ * a warn-once-per-section guard.
7925
+ */
7926
+ const configNavSections = (): HistoryRailSection[] =>
7927
+ config.features?.history?.rail?.sections?.map((section) => {
7928
+ let warned = false;
7929
+ const warn = (error: unknown): void => {
7930
+ if (warned) return;
7931
+ warned = true;
7932
+ console.warn("[persona] history rail section threw", section.id, error);
7933
+ };
7934
+ return {
7935
+ id: section.id,
7936
+ title: section.title,
7937
+ placement: section.placement ?? "above-conversations",
7938
+ items: section.items.map((item) => {
7939
+ // Built on first paint and cached: an unknown lucide name warns once,
7940
+ // and a presentation flip reuses the node it made.
7941
+ let node: Element | null | undefined;
7942
+ return {
7943
+ id: item.id,
7944
+ label: item.label,
7945
+ badge: item.badge,
7946
+ iconNode: (): Element | null => {
7947
+ if (node !== undefined) return node;
7948
+ try {
7949
+ if (item.renderIcon) node = item.renderIcon() ?? null;
7950
+ else if (item.iconUrl) node = railIconImage(item.iconUrl);
7951
+ else node = item.icon ? renderLucideIcon(item.icon, 20) : null;
7952
+ } catch (error) {
7953
+ warn(error);
7954
+ node = null;
7955
+ }
7956
+ return node;
7957
+ },
7958
+ onSelect: () => {
7959
+ try {
7960
+ item.onSelect();
7961
+ } catch (error) {
7962
+ warn(error);
7963
+ }
7964
+ },
7965
+ };
7966
+ }),
7967
+ };
7968
+ }) ?? [];
7969
+
7970
+ /** One array for the chunk: config sections first in each placement bucket. */
7971
+ const railNavSections = (
7972
+ pluginSections: HistoryRailSection[]
7973
+ ): HistoryRailSection[] | undefined => {
7974
+ const sections = configNavSections();
7975
+ for (const section of pluginSections) {
7976
+ // Config owns the id space; a colliding plugin section is dropped.
7977
+ if (sections.some((existing) => existing.id === section.id)) {
7978
+ console.warn("[persona] duplicate history rail section id", section.id);
7979
+ } else sections.push(section);
7980
+ }
7981
+ return sections.length ? sections : undefined;
7982
+ };
7983
+
7984
+ /** Resolved once per widget: storage, else the configured default. */
7985
+ let railCollapsed: boolean | null = null;
7986
+
7987
+ const railCollapseKey = (): string => `${currentKeyPrefix()}rail-collapsed`;
7988
+
7989
+ // localStorage access throws (not just fails) in Safari private mode and
7990
+ // partitioned iframes, so the resolved value is also the in-memory fallback.
7991
+ const isRailCollapsed = (): boolean => {
7992
+ if (railCollapsed === null) {
7993
+ railCollapsed = config.features?.history?.rail?.defaultCollapsed === true;
7994
+ if (config.persistState !== false) {
7995
+ try {
7996
+ const stored = window.localStorage.getItem(railCollapseKey());
7997
+ if (stored) railCollapsed = stored === "1";
7998
+ } catch {
7999
+ /* blocked storage: the resolved default stands */
8000
+ }
8001
+ }
8002
+ }
8003
+ return railCollapsed;
8004
+ };
8005
+
8006
+ const setRailCollapsed = (next: boolean): void => {
8007
+ railCollapsed = next;
8008
+ if (config.persistState === false) return;
8009
+ try {
8010
+ window.localStorage.setItem(railCollapseKey(), next ? "1" : "0");
8011
+ } catch {
8012
+ /* blocked storage: the in-memory value above is the fallback */
8013
+ }
8014
+ };
8015
+
8016
+ /**
8017
+ * Collapsed only ever applies to a collapsible rail presentation, and never
8018
+ * in overlay mode, where a mounted rail is always the expanded one.
8019
+ */
8020
+ const railShowsCollapsed = (): boolean =>
8021
+ historyPresentation === "rail" &&
8022
+ railCollapsible() &&
8023
+ !railOverlayMode() &&
8024
+ isRailCollapsed();
8025
+
8026
+ /** Overlay-mode counterpart, assigned with the overlay controller below. */
8027
+ let toggleRailPinned: () => void = () => {};
8028
+
8029
+ const toggleRailCollapsed = (): void => {
8030
+ // The rail's own toggle sits where the trigger does, so in overlay mode it
8031
+ // is that control: it pins a floating rail and unpins a docked one.
8032
+ if (railOverlayMode()) {
8033
+ toggleRailPinned();
8034
+ return;
8035
+ }
8036
+ setRailCollapsed(!isRailCollapsed());
8037
+ historySurface?.view.setCollapsed(railShowsCollapsed());
8038
+ applyRailChrome();
8039
+ // The transcript column resizes beside the anchor; a clamp would bounce it.
8040
+ repinAnchoredMessage();
8041
+ };
8042
+
8043
+ /**
8044
+ * One declaration, three artifacts: the binding below, the toggle's tooltip
8045
+ * hint chip, and its `aria-keyshortcuts`. Null when unset or unparseable.
8046
+ */
8047
+ const railCollapseShortcut = (): {
8048
+ combo: string;
8049
+ hint: string;
8050
+ aria: string;
8051
+ } | null => {
8052
+ const combo = config.features?.history?.rail?.collapseShortcut;
8053
+ if (!combo || !parseCombo(combo)) return null;
8054
+ return { combo, hint: formatCombo(combo), aria: ariaCombo(combo) };
8055
+ };
8056
+
8057
+ /**
8058
+ * Every binding this instance owns, re-derived so a live `update()` of the
8059
+ * config or the plugin list lands. Config registers before plugins, so a
8060
+ * collision leaves the config combo standing.
8061
+ */
8062
+ const syncShortcuts = (): void => {
8063
+ releaseShortcuts.forEach((release) => release());
8064
+ releaseShortcuts = [];
8065
+ const shortcut = railCollapseShortcut();
8066
+ if (shortcut) {
8067
+ releaseShortcuts.push(
8068
+ shortcuts.register({
8069
+ id: "history-rail-collapse",
8070
+ combo: shortcut.combo,
8071
+ scope:
8072
+ config.features?.history?.rail?.collapseShortcutScope === "page"
8073
+ ? "page"
8074
+ : "widget",
8075
+ // Overlay mode answers at rest too: the combo is how a keyboard
8076
+ // visitor pins a rail that is currently only a trigger.
8077
+ when: () =>
8078
+ (historyVisible &&
8079
+ historyPresentation === "rail" &&
8080
+ railCollapsible()) ||
8081
+ railTriggerApplies(),
8082
+ run: () =>
8083
+ railOverlayMode() ? toggleRailPinned() : toggleRailCollapsed(),
8084
+ })
8085
+ );
8086
+ }
8087
+ for (const action of config.layout?.header?.trailingActions ?? []) {
8088
+ if (!action.shortcut) continue;
8089
+ releaseShortcuts.push(
8090
+ shortcuts.register({
8091
+ id: `header-action-${action.id}`,
8092
+ combo: action.shortcut,
8093
+ // The click path, so a menu action still opens its dropdown; a
8094
+ // header rebuild swaps the button, so it is found per keypress.
8095
+ run: () => {
8096
+ const button = mount.querySelector<HTMLButtonElement>(
8097
+ `[data-persona-header-action="${action.id}"]`
8098
+ );
8099
+ if (button) button.click();
8100
+ else config.layout?.header?.onAction?.(action.id);
8101
+ },
8102
+ })
8103
+ );
8104
+ }
8105
+ for (const plugin of plugins) {
8106
+ for (const shortcut of plugin.shortcuts ?? []) {
8107
+ releaseShortcuts.push(
8108
+ shortcuts.register({ ...shortcut, id: `${plugin.id}:${shortcut.id}` })
8109
+ );
8110
+ }
8111
+ }
8112
+ };
8113
+ syncShortcuts();
8114
+
8115
+ // --- collapsed rail as a floating overlay --------------------------------
8116
+ //
8117
+ // Rest state is a trigger at the leading edge of the conversation header and
8118
+ // nothing else: no column, no mounted view, and the history chunk unloaded
8119
+ // until a hover warms it. The pointer entering floats the expanded rail over
8120
+ // the conversation; clicking pins it back into the full-height column.
8121
+
8122
+ /** Grace before a pointer that left both surfaces dismisses the rail. */
8123
+ const RAIL_OVERLAY_GRACE_MS = 300;
8124
+ /**
8125
+ * Gap from the trigger, the docked edge and the bottom. A var reference, not
8126
+ * a number, so a live theme update reaches an open overlay unaided.
8127
+ */
8128
+ const RAIL_OVERLAY_MARGIN = "var(--persona-history-overlay-margin,8px)";
8129
+
8130
+ let railTriggerButton: HTMLButtonElement | null = null;
8131
+ let railTriggerWrapper: HTMLElement | null = null;
8132
+ let railOverlayHost: HTMLElement | null = null;
8133
+ let railGraceTimer: ReturnType<typeof setTimeout> | null = null;
8134
+ /** Pointer is over the trigger or over the floating rail. */
8135
+ let railPointerInside = false;
8136
+ /**
8137
+ * Uncovering the trigger re-enters it with no pointer movement at all, which
8138
+ * would undo the dismissal that just uncovered it. That synthetic enter
8139
+ * arrives within a frame or two of the unmount.
8140
+ */
8141
+ let railUncoveredUntil = 0;
8142
+
8143
+ /** Touch has no hover to open with, so it taps the overlay open instead. */
8144
+ const coarsePointer = (): boolean =>
8145
+ window.matchMedia?.("(pointer: coarse)").matches === true;
8146
+
8147
+ /** The trigger stands in for the collapsed rail, so it needs a rail width. */
8148
+ const railTriggerApplies = (): boolean =>
8149
+ historyAvailable() &&
8150
+ railOverlayMode() &&
8151
+ (historyPresentation ?? resolveHistoryPresentation()) === "rail";
8152
+
8153
+ /** Docked in its own column rather than floating over the conversation. */
8154
+ const railPinned = (): boolean => historyVisible && !railOverlayOpen;
8155
+
8156
+ const cancelRailGrace = (): void => {
8157
+ if (railGraceTimer !== null) clearTimeout(railGraceTimer);
8158
+ railGraceTimer = null;
8159
+ };
8160
+
8161
+ /** Keyboard focus warms without opening; Enter and Space commit. */
8162
+ const warmHistoryChunk = (): void => {
8163
+ // A failed load just leaves the overlay closed; the loader retries later.
8164
+ void loadHistoryView().catch(() => {});
8165
+ };
8166
+
8167
+ /** Leaving both surfaces dismisses, but only after a grace to come back in. */
8168
+ const scheduleRailOverlayClose = (): void => {
8169
+ cancelRailGrace();
8170
+ if (!railOverlayOpen) return;
8171
+ railGraceTimer = setTimeout(() => {
8172
+ railGraceTimer = null;
8173
+ if (railPointerInside || !railOverlayOpen) return;
8174
+ // A keyboard visitor inside the floating rail never loses it to a stray
8175
+ // pointer leaving the widget.
8176
+ if (railOverlayHost?.contains(document.activeElement)) return;
8177
+ // A pointer dismissal moves focus no more than a pointer open does.
8178
+ closeHistory({ restoreFocus: false });
8179
+ }, RAIL_OVERLAY_GRACE_MS);
8180
+ };
8181
+
8182
+ /**
8183
+ * Hover keep-alive is geometric, not element-based: a pointer travelling
8184
+ * from the trigger to the rail crosses the conversation header, which is
8185
+ * neither. The safe zone is the trigger, the rail, and the bridge between
8186
+ * them: the rail's own horizontal extent, from the trigger's row down to the
8187
+ * rail's top edge. Derived from the live rects, so a right-docked rail needs
8188
+ * no separate case.
8189
+ */
8190
+ const inRailSafeZone = (x: number, y: number): boolean => {
8191
+ const rail = railOverlayHost?.getBoundingClientRect();
8192
+ if (!rail) return false;
8193
+ const row = railTriggerButton?.getBoundingClientRect();
8194
+ // The rail and the bridge are one band: the rail's own width, from the
8195
+ // trigger's row down to the rail's bottom. The trigger is its own rect.
8196
+ const top = row ? Math.min(row.top, rail.top) : rail.top;
8197
+ return (
8198
+ (x >= rail.left && x <= rail.right && y >= top && y <= rail.bottom) ||
8199
+ (!!row && x >= row.left && x <= row.right && y >= row.top && y <= row.bottom)
8200
+ );
8201
+ };
8202
+
8203
+ const handleRailPointerMove = (event: PointerEvent): void => {
8204
+ if (!railOverlayOpen) return;
8205
+ railPointerInside = inRailSafeZone(event.clientX, event.clientY);
8206
+ if (railPointerInside) cancelRailGrace();
8207
+ // Re-arming on every outside move would restart the countdown forever.
8208
+ else if (railGraceTimer === null) scheduleRailOverlayClose();
8209
+ };
8210
+
8211
+ /**
8212
+ * No dwell: the rail answers the pointer the moment it arrives, as fast as
8213
+ * the chunk allows (already loaded, that is the same frame). The 300ms leave
8214
+ * grace is what an accidental pass over the trigger costs.
8215
+ */
8216
+ const openRailOverlay = (opts?: { keyboard?: boolean }): void => {
8217
+ if (historyVisible || !railTriggerApplies()) return;
8218
+ railOverlayOpen = true;
8219
+ void openHistory({
8220
+ invoker: railTriggerButton,
8221
+ keyboard: opts?.keyboard === true,
8222
+ }).then(() => {
8223
+ // The chunk can resolve after the pointer left, or not resolve at all.
8224
+ if (!historyVisible) railOverlayOpen = false;
8225
+ else if (!railPointerInside && document.activeElement !== railTriggerButton) {
8226
+ closeHistory();
8227
+ }
8228
+ });
8229
+ };
8230
+
8231
+ /**
8232
+ * Floating, the rail's own toggle pins instead of collapsing, so it wears
8233
+ * the expand label; docked, it says collapse again.
8234
+ */
8235
+ const syncRailToggleLabel = (): void => {
8236
+ // The mounted element, not the surface: the first mount happens inside the
8237
+ // surface constructor, before `historySurface` is assigned.
8238
+ const toggle = historyMountedElement?.querySelector(
8239
+ '[data-persona-history-focus="collapse"]'
8240
+ );
8241
+ if (!toggle) return;
8242
+ toggle.setAttribute(
8243
+ "aria-label",
8244
+ railOverlayOpen
8245
+ ? historyShellCopy.expandLabel
8246
+ : historyShellCopy.collapseLabel
8247
+ );
8248
+ toggle.setAttribute("aria-expanded", railOverlayOpen ? "false" : "true");
8249
+ };
8250
+
8251
+ /**
8252
+ * Pin: the floating rail gives way to the full-height column, moving the
8253
+ * SAME view element when one is already open.
8254
+ */
8255
+ const pinRail = (): void => {
8256
+ setRailCollapsed(false);
8257
+ const surface = historySurface;
8258
+ if (!railOverlayOpen || !surface) {
8259
+ void openHistory({ invoker: railTriggerButton });
8260
+ return;
8261
+ }
8262
+ const refocus = document.activeElement === railTriggerButton;
8263
+ railOverlayOpen = false;
8264
+ unmountHistoryHosts();
8265
+ // Detach before re-hosting, exactly as the panel/rail move does.
8266
+ surface.element.remove();
8267
+ mountHistoryElement(surface.element);
8268
+ syncRailToggleLabel();
8269
+ // The trigger stands down beside the rail's own toggle, so keyboard focus
8270
+ // has to follow the control there.
8271
+ if (refocus) focusHistoryEntry();
8272
+ repinAnchoredMessage();
8273
+ syncHistoryChromeImpl();
8274
+ };
8275
+
8276
+ /** Unpin: the column closes and the trigger takes the control back. */
8277
+ const unpinRail = (): void => {
8278
+ setRailCollapsed(true);
8279
+ closeHistory();
8280
+ };
8281
+
8282
+ toggleRailPinned = (): void => {
8283
+ if (!historyVisible || railOverlayOpen) pinRail();
8284
+ else unpinRail();
8285
+ };
8286
+
8287
+ /**
8288
+ * The sidebar glyph, plainly. `rail.brand` belongs to the icon column, which
8289
+ * has no other identity, and to the rail's own header; this control sits in
8290
+ * a conversation header that already carries the agent's.
8291
+ */
8292
+ const buildRailTrigger = (): void => {
8293
+ const shortcut = railCollapseShortcut();
8294
+ const parts = createHeaderIconButton({
8295
+ ariaLabel: historyShellCopy.expandLabel,
8296
+ iconName: "panel-left",
8297
+ wrapperClassName:
8298
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center",
8299
+ extraClassName: "persona-rail-trigger",
8300
+ // Hovering answers with the rail itself; a bubble would race the flyover.
8301
+ // The combo stays discoverable on the floating rail's own toggle.
8302
+ tooltip: false,
8303
+ attrs: {
8304
+ "data-persona-rail-trigger": "",
8305
+ "aria-controls": historyRegionId,
8306
+ ...(shortcut ? { "aria-keyshortcuts": shortcut.aria } : {}),
8307
+ },
8308
+ });
8309
+ const button = parts.button;
8310
+ button.addEventListener("mouseenter", () => {
8311
+ railPointerInside = true;
8312
+ cancelRailGrace();
8313
+ if (coarsePointer() || Date.now() < railUncoveredUntil) return;
8314
+ openRailOverlay();
8315
+ });
8316
+ button.addEventListener("mouseleave", () => {
8317
+ railPointerInside = false;
8318
+ // A real departure ends the hold-off: the next enter is intent.
8319
+ railUncoveredUntil = 0;
8320
+ scheduleRailOverlayClose();
8321
+ });
8322
+ // Focus only warms: Enter and Space are how a keyboard visitor commits.
8323
+ button.addEventListener("focus", () => {
8324
+ if (!historyVisible) warmHistoryChunk();
8325
+ });
8326
+ button.addEventListener("click", (event) => {
8327
+ if (!historyAvailable()) return;
8328
+ // Touch taps the overlay open first and pins on a second tap. A pointer
8329
+ // that can hover is already looking at the rail, and Enter/Space (a click
8330
+ // with detail 0) is a commitment either way, so both pin outright.
8331
+ if (event.detail !== 0 && coarsePointer() && !historyVisible) {
8332
+ openRailOverlay();
8333
+ } else pinRail();
8334
+ });
8335
+ railTriggerButton = button;
8336
+ railTriggerWrapper = parts.wrapper;
8337
+ };
8338
+
8339
+ /**
8340
+ * The trigger is chrome, not surface state: it exists whenever a collapsed
8341
+ * overlay rail could be opened, and stands down only while the rail is
8342
+ * docked, where the rail's own header toggle is the same control.
8343
+ */
8344
+ const syncRailOverlayTrigger = (): void => {
8345
+ // A header rebuild detaches it; a stale ref must not block recreation.
8346
+ if (railTriggerWrapper && !railTriggerWrapper.isConnected) {
8347
+ railTriggerWrapper = null;
8348
+ railTriggerButton = null;
8349
+ }
8350
+ if (!railTriggerApplies()) {
8351
+ railTriggerWrapper?.remove();
8352
+ railTriggerWrapper = null;
8353
+ railTriggerButton = null;
8354
+ return;
8355
+ }
8356
+ if (!railTriggerButton) buildRailTrigger();
8357
+ const wrapper = railTriggerWrapper;
8358
+ const button = railTriggerButton;
8359
+ if (!wrapper || !button) return;
8360
+ // Leading edge of the conversation header, mirrored for a right rail.
8361
+ const lead = railSide() !== "right" ? header.firstChild : null;
8362
+ if ((lead ?? header.lastChild) !== wrapper) header.insertBefore(wrapper, lead);
8363
+ wrapper.style.display = railPinned() ? "none" : "";
8364
+ button.setAttribute("aria-label", historyShellCopy.expandLabel);
8365
+ button.setAttribute("aria-expanded", historyVisible ? "true" : "false");
8366
+ // The floating rail hangs from this control, so a rebuild or a resize that
8367
+ // moved it re-anchors what is already open.
8368
+ if (railOverlayHost) applyRailChrome();
8369
+ };
8370
+
8371
+ /**
8372
+ * Click outside dismisses the floating rail. Portaled surfaces it opened
8373
+ * itself (row menus, confirmations) are not "outside" it.
8374
+ */
8375
+ const handleRailOverlayPointerDown = (event: Event): void => {
8376
+ if (!railOverlayOpen) return;
8377
+ const target = event.target;
8378
+ if (!(target instanceof Node)) return;
8379
+ if (railOverlayHost?.contains(target) || railTriggerWrapper?.contains(target)) {
8380
+ return;
8381
+ }
8382
+ if (
8383
+ target instanceof Element &&
8384
+ target.closest('.persona-dropdown-menu,[role="alertdialog"]')
8385
+ ) {
8386
+ return;
8387
+ }
8388
+ closeHistory({ restoreFocus: false });
8389
+ };
8390
+ document.addEventListener("pointerdown", handleRailOverlayPointerDown, true);
8391
+ destroyCallbacks.push(() => {
8392
+ document.removeEventListener("pointerdown", handleRailOverlayPointerDown, true);
8393
+ // Attached only while the rail floats; removing an unattached one is free.
8394
+ document.removeEventListener("pointermove", handleRailPointerMove);
8395
+ railResizeRelease?.();
8396
+ cancelRailGrace();
8397
+ });
8398
+
8399
+ // --- drag-resize of the docked rail --------------------------------------
8400
+
8401
+ let railResizeHandle: HTMLElement | null = null;
8402
+ /** Ends an in-flight drag: its listeners are on the document, not the handle. */
8403
+ let railResizeRelease: (() => void) | null = null;
8404
+ /** Arrow-key step, matching the reference sidebar's coarse nudge. */
8405
+ const RAIL_RESIZE_STEP = 16;
8406
+
8407
+ const commitRailWidth = (next: number): void => {
8408
+ setStoredRailWidth(clampRailWidth(next));
8409
+ applyRailChrome();
8410
+ // The transcript resized beside the anchor; a clamp would bounce it.
8411
+ repinAnchoredMessage();
8412
+ };
8413
+
8414
+ /** Mirrors the artifact split handle: pointer capture, document-level drag. */
8415
+ const buildRailResizeHandle = (): HTMLElement => {
8416
+ const handle = createElement("div", "persona-rail-resizer");
8417
+ handle.tabIndex = 0;
8418
+ handle.setAttribute("role", "separator");
8419
+ handle.setAttribute("aria-orientation", "vertical");
8420
+ handle.setAttribute("aria-valuemin", String(RAIL_MIN_WIDTH));
8421
+ handle.setAttribute("aria-valuemax", String(RAIL_MAX_WIDTH));
8422
+
8423
+ handle.addEventListener("pointerdown", (event) => {
8424
+ const host = railHost;
8425
+ if (!host || event.button !== 0) return;
8426
+ event.preventDefault();
8427
+ railResizeRelease?.();
8428
+ const startX = event.clientX;
8429
+ const startWidth = host.getBoundingClientRect().width || railWidth();
8430
+ // A leading rail widens as the pointer travels right; a trailing one
8431
+ // mirrors that.
8432
+ const direction = railSide() === "right" ? -1 : 1;
8433
+ let width = startWidth;
8434
+ const doc = mount.ownerDocument;
8435
+ // The collapse animation is a transition on this very basis; left on, it
8436
+ // would trail the pointer for the whole drag.
8437
+ host.style.transition = "none";
8438
+ const onMove = (move: PointerEvent): void => {
8439
+ width = clampRailWidth(startWidth + direction * (move.clientX - startX));
8440
+ // Straight onto the basis: a chrome pass per pointer move is waste.
8441
+ host.style.flex = `0 0 ${width}px`;
8442
+ handle.setAttribute("aria-valuenow", String(width));
8443
+ };
8444
+ const onUp = (): void => {
8445
+ railResizeRelease = null;
8446
+ doc.removeEventListener("pointermove", onMove);
8447
+ doc.removeEventListener("pointerup", onUp);
8448
+ doc.removeEventListener("pointercancel", onUp);
8449
+ host.style.removeProperty("transition");
8450
+ try {
8451
+ handle.releasePointerCapture(event.pointerId);
8452
+ } catch {
8453
+ /* the capture may already be gone */
8454
+ }
8455
+ commitRailWidth(width);
8456
+ };
8457
+ railResizeRelease = onUp;
8458
+ doc.addEventListener("pointermove", onMove);
8459
+ doc.addEventListener("pointerup", onUp);
8460
+ doc.addEventListener("pointercancel", onUp);
8461
+ try {
8462
+ handle.setPointerCapture(event.pointerId);
8463
+ } catch {
8464
+ /* pointer capture is an enhancement, not the mechanism */
8465
+ }
8466
+ });
8467
+
8468
+ handle.addEventListener("keydown", (event) => {
8469
+ // Arrows track the visual direction, so a trailing rail inverts them.
8470
+ const step = railSide() === "right" ? -RAIL_RESIZE_STEP : RAIL_RESIZE_STEP;
8471
+ const width = railWidth();
8472
+ const next =
8473
+ event.key === "ArrowRight"
8474
+ ? width + step
8475
+ : event.key === "ArrowLeft"
8476
+ ? width - step
8477
+ : event.key === "Home"
8478
+ ? RAIL_MIN_WIDTH
8479
+ : event.key === "End"
8480
+ ? RAIL_MAX_WIDTH
8481
+ : null;
8482
+ if (next === null) return;
8483
+ event.preventDefault();
8484
+ commitRailWidth(next);
8485
+ });
8486
+ return handle;
8487
+ };
8488
+
8489
+ /** Docked and expanded only: the floating rail and the icon column never resize. */
8490
+ const syncRailResizeHandle = (
8491
+ shell: HTMLElement,
8492
+ before: HTMLElement
8493
+ ): void => {
8494
+ if (!railResizable() || railShowsCollapsed()) {
8495
+ railResizeRelease?.();
8496
+ railResizeHandle?.remove();
8497
+ return;
8498
+ }
8499
+ const handle = railResizeHandle ?? buildRailResizeHandle();
8500
+ railResizeHandle = handle;
8501
+ handle.setAttribute("aria-label", historyShellCopy.resizeLabel);
8502
+ handle.setAttribute("aria-valuenow", String(railWidth()));
8503
+ // Between the two, on the edge the divider already faces.
8504
+ if (handle.nextElementSibling !== before) shell.insertBefore(handle, before);
8505
+ };
8506
+
8507
+ /**
8508
+ * Rail geometry is config-derived, so it must be re-derivable: a live
8509
+ * `update()` of `rail.width` / `rail.side` lands here, not only at mount.
8510
+ */
8511
+ applyRailChrome = (): void => {
8512
+ // The bar mirrors the docked edge, so the view hears about a side flip even
8513
+ // while it is presenting as a panel.
8514
+ historySurface?.view.setRailSide(railSide());
8515
+ const trailing = railSide() === "right";
8516
+ const overlay = railOverlayHost;
8517
+ if (overlay) {
8518
+ // Hangs from the trigger's row rather than the widget's top edge, so the
8519
+ // trigger stays visible and clickable above it. Measured, since a header
8520
+ // rebuild or a resize can move the trigger.
8521
+ const below = railTriggerButton
8522
+ ? railTriggerButton.getBoundingClientRect().bottom -
8523
+ container.getBoundingClientRect().top
8524
+ : 0;
8525
+ overlay.style.top = `calc(${Math.max(0, Math.round(below))}px + ${RAIL_OVERLAY_MARGIN})`;
8526
+ overlay.style.width = `${railWidth()}px`;
8527
+ overlay.style.left = trailing ? "" : RAIL_OVERLAY_MARGIN;
8528
+ overlay.style.right = trailing ? RAIL_OVERLAY_MARGIN : "";
8529
+ }
8530
+ const host = railHost;
8531
+ const shell = railShell;
8532
+ const column = railColumn;
8533
+ if (!host || !shell || !column) return;
8534
+ host.style.flex = `0 0 ${
8535
+ railShowsCollapsed() ? RAIL_COLLAPSED_WIDTH : railWidth()
8536
+ }px`;
8537
+ // The divider always faces the conversation, whichever edge the rail took.
8538
+ const divider = "1px solid var(--persona-divider,#e5e7eb)";
8539
+ host.style.borderRight = trailing ? "" : divider;
8540
+ host.style.borderLeft = trailing ? divider : "";
8541
+ const leading = trailing ? column : host;
8542
+ // Reorder only on an actual side flip: re-parenting blurs what it moves.
8543
+ if (shell.firstElementChild !== leading) {
8544
+ shell.append(leading, trailing ? host : column);
8545
+ }
8546
+ syncRailResizeHandle(shell, trailing ? host : column);
8547
+ };
8548
+
8549
+ /**
8550
+ * Rail is a shell-owned navigation column running the FULL widget height
8551
+ * beside a still-operable conversation: the shell header, `body`, the
8552
+ * in-container composer and any top-right action wrappers move into a column
8553
+ * next to the host, and the shell takes the header's old container slot.
8554
+ */
8555
+ const mountRailHost = (element: HTMLElement): void => {
8556
+ const shell = createElement("div", "persona-history-rail-shell");
8557
+ shell.style.cssText = "display:flex;flex-direction:row;flex:1 1 auto;min-height:0";
8558
+ const column = createElement("div", "persona-history-rail-conversation");
8559
+ // position: top-right close/clear wrappers anchor here instead of the
8560
+ // container, or they would float over the rail.
8561
+ column.style.cssText =
8562
+ "display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;position:relative";
8563
+ // The collapse transition is a rule in the history chunk's stylesheet: an
8564
+ // inline one could not carry the reduced-motion query.
8565
+ const host = createElement("div", "persona-history-rail-host");
8566
+ host.style.cssText = "display:flex;min-height:0;overflow:hidden";
8567
+
8568
+ container.insertBefore(shell, header.parentNode === container ? header : body);
8569
+ for (const node of railBorrowed()) {
8570
+ if (node?.parentNode === container) column.appendChild(node);
8571
+ }
8572
+ shell.append(host, column);
8573
+ host.appendChild(element);
8574
+ railShell = shell;
8575
+ railHost = host;
8576
+ railColumn = column;
8577
+ applyRailChrome();
8578
+ };
8579
+
8580
+ /**
8581
+ * Floating host for the collapsed overlay rail: the expanded view elevated
8582
+ * over a conversation that keeps its own layout, so nothing is borrowed and
8583
+ * nothing reflows around it.
8584
+ */
8585
+ const mountRailOverlayHost = (element: HTMLElement): void => {
8586
+ const host = createElement("div", "persona-history-rail-overlay");
8587
+ // Every themeable value is a var() reference with its default in the
8588
+ // fallback, so an unset token costs nothing and a live one lands at once.
8589
+ host.style.cssText =
8590
+ `position:absolute;bottom:${RAIL_OVERLAY_MARGIN};` +
8591
+ `display:flex;overflow:hidden;z-index:${PORTALED_OVERLAY_Z_INDEX - 1};` +
8592
+ "border-radius:var(--persona-history-overlay-radius,16px);" +
8593
+ "background:var(--persona-history-overlay-bg,var(--persona-container,#f7f7f8));" +
8594
+ "box-shadow:var(--persona-history-overlay-shadow,0 12px 40px rgba(0,0,0,0.25))";
8595
+ // The conversation column already relies on a positioned container.
8596
+ container.style.position = "relative";
8597
+ container.appendChild(host);
8598
+ host.appendChild(element);
8599
+ // The safe zone spans nodes the rail does not own, so hover is tracked by
8600
+ // position for as long as it is open.
8601
+ document.addEventListener("pointermove", handleRailPointerMove);
8602
+ railOverlayHost = host;
8603
+ applyRailChrome();
8604
+ syncRailToggleLabel();
8605
+ };
8606
+
8607
+ const unmountHistoryHosts = (): void => {
8608
+ restorePanelHost?.();
8609
+ if (railOverlayHost) {
8610
+ // Dismissed under the pointer: hold the hover off until the synthetic
8611
+ // enter from uncovering the trigger has passed.
8612
+ if (railPointerInside) railUncoveredUntil = Date.now() + 150;
8613
+ document.removeEventListener("pointermove", handleRailPointerMove);
8614
+ railOverlayHost.remove();
8615
+ railOverlayHost = null;
8616
+ }
8617
+ const shell = railShell;
8618
+ if (shell) {
8619
+ // A drag in flight owns document listeners the host is about to lose.
8620
+ railResizeRelease?.();
8621
+ // Live bindings, in panel order, before the slot the shell took. A header
8622
+ // restored first also takes its inline action wrappers back with it, so
8623
+ // the `contains` check skips them.
8624
+ for (const node of railBorrowed()) {
8625
+ if (node && shell.contains(node)) container.insertBefore(node, shell);
8626
+ }
8627
+ shell.remove();
8628
+ railShell = null;
8629
+ railHost = null;
8630
+ railColumn = null;
8631
+ }
8632
+ };
8633
+
8634
+ /** Shell-owned chrome applied to whatever element arbitration produced. */
8635
+ const prepareHistoryElement = (element: HTMLElement): void => {
8636
+ historyMountedElement = element;
8637
+ element.id = historyRegionId;
8638
+ // Host-side flex sizing: the chunk sizes itself to 100%, the shell decides
8639
+ // how it participates in the column/row it was just dropped into.
8640
+ element.style.flex = "1 1 auto";
8641
+ element.style.minHeight = "0";
8642
+ };
8643
+
8644
+ /** The chunk owns its own classes/labels; the shell only picks the host. */
8645
+ const mountHistoryElement = (element: HTMLElement): void => {
8646
+ prepareHistoryElement(element);
8647
+ if (historyPresentation !== "rail") mountPanelHost(element);
8648
+ else if (railOverlayOpen) mountRailOverlayHost(element);
8649
+ else mountRailHost(element);
8650
+ };
8651
+
8652
+ /**
8653
+ * Live rail <-> panel transition. ONE view instance survives the move, so the
8654
+ * list, its fixed operation context, and any pending work are preserved. A
8655
+ * custom full view is re-invoked with the new presentation value.
8656
+ */
8657
+ const syncHistoryPresentation = (): void => {
8658
+ // The trigger belongs to a rail-capable width, so it resolves here too, and
8659
+ // the header toggle stands down beside whatever it resolved to.
8660
+ syncRailOverlayTrigger();
8661
+ syncHistoryButton();
8662
+ if (!historyVisible || !historySurface) return;
8663
+ const next = resolveHistoryPresentation();
8664
+ if (next === historyPresentation) return;
8665
+ const focusKey = document.activeElement;
8666
+ // The bar's contents may be focused inside the shell header, not the view.
8667
+ const refocus =
8668
+ focusKey instanceof HTMLElement &&
8669
+ (historySurface.element.contains(focusKey) ||
8670
+ historyHeaderHost?.contains(focusKey) === true)
8671
+ ? focusKey
8672
+ : null;
8673
+ unmountHistoryHosts();
8674
+ // Detach before re-hosting so a mid-move re-arbitration cannot re-insert
8675
+ // the surface into the host it is being moved out of.
8676
+ historySurface.element.remove();
8677
+ // Rail is always inline; the panel host re-externalizes on mount.
8678
+ historySurface.view.setHeaderPlacement("inline");
8679
+ historyPresentation = next;
8680
+ historySurface.view.setPresentation(next);
8681
+ // Panel always shows the whole list; returning to rail restores the state.
8682
+ historySurface.view.setCollapsed(railShowsCollapsed());
8683
+ historySurface.requestRender();
8684
+ if (!historySurface.element.isConnected) {
8685
+ mountHistoryElement(historySurface.element);
8686
+ }
8687
+ if (refocus?.isConnected) refocus.focus();
8688
+ else focusHistoryEntry();
8689
+ syncScrollToBottomButton();
8690
+ // A one-frame layout removal above the anchored message clamps scrollTop.
8691
+ repinAnchoredMessage();
8692
+ syncHistoryChromeImpl();
8693
+ };
8694
+
8695
+ // --- open / close --------------------------------------------------------
8696
+
8697
+ /** The bar lives in the shell header while it is hosted there. */
8698
+ const queryHistoryOwned = <T extends HTMLElement>(
8699
+ element: HTMLElement,
8700
+ selector: string
8701
+ ): T | null =>
8702
+ historyHeaderHost?.querySelector<T>(selector) ??
8703
+ element.querySelector<T>(selector);
8704
+
8705
+ const focusHistoryEntry = (): void => {
8706
+ const element = historySurface?.element ?? historyMountedElement;
8707
+ if (!element) return;
8708
+ const close = queryHistoryOwned(
8709
+ element,
8710
+ // The rail's leading control is a collapse toggle, not a close.
8711
+ '[data-persona-history-focus="close"],[data-persona-history-focus="collapse"]'
8712
+ );
8713
+ if (close) {
8714
+ close.focus();
8715
+ return;
8716
+ }
8717
+ const heading =
8718
+ queryHistoryOwned<HTMLElement>(element, ".persona-history-title") ??
8719
+ // Custom contents may expose neither: the region itself is the fallback.
8720
+ element;
8721
+ heading.tabIndex = -1;
8722
+ heading.focus();
8723
+ };
8724
+
8725
+ const openHistory = async (opts?: {
8726
+ returnSurface?: HistoryReturnSurface;
8727
+ invoker?: HTMLElement | null;
8728
+ /** Rail only: an open with no keyboard behind it must not move focus. */
8729
+ keyboard?: boolean;
8730
+ /** `false` skips the entry focus entirely (programmatic/preview opens). */
8731
+ focus?: boolean;
8732
+ }): Promise<void> => {
8733
+ if (!historyAvailable() || historyVisible) return;
8734
+ const provider = historyProvider;
8735
+ if (!provider) return;
8736
+ // An unpinned overlay rail IS its trigger, so restoring that state must
8737
+ // render chrome only: no surface, and no chunk fetched for it.
8738
+ if (!railOverlayOpen && railTriggerApplies() && isRailCollapsed()) {
8739
+ syncRailOverlayTrigger();
8740
+ return;
8741
+ }
8742
+ // A reopen mid-exit finishes the outgoing teardown first: never two
8743
+ // surfaces, never a restore that lands after this one mounts.
8744
+ settleHistoryExit();
8745
+ const token = ++historyOpenToken;
8746
+ historyReturnSurface = opts?.returnSurface ?? "conversation";
8747
+ historyInvoker = opts?.invoker ?? historyButton;
8748
+
8749
+ let module: Awaited<ReturnType<typeof loadHistoryView>>;
8750
+ try {
8751
+ module = await loadHistoryView();
8752
+ } catch {
8753
+ // Lazy-chunk failure keeps the invoking surface interactive and retryable.
8754
+ announceHistory(historyShellCopy.openHistoryLabel);
8755
+ return;
8756
+ }
8757
+ if (token !== historyOpenToken || historyVisible) return;
8758
+
8759
+ // One scope for the whole opened view; every operation reuses it.
8760
+ historyOperationContext = { scope: historyScope() };
8761
+ historyPresentation = resolveHistoryPresentation();
8762
+ historyVisible = true;
8763
+
8764
+ // Built externally when there is a shell header to host it in; the panel
8765
+ // host re-decides on mount, so this only avoids a needless first insert.
8766
+ const initialHeaderPlacement: HistoryHeaderPlacement =
8767
+ historyPresentation === "panel" &&
8768
+ config.layout?.showHeader !== false &&
8769
+ header.style.display !== "none"
8770
+ ? "external"
8771
+ : "inline";
8772
+
8773
+ const rowAvatar = config.features?.history?.rowAvatar;
8774
+ const shortcut = railCollapseShortcut();
8775
+ const collapseShortcutStrings = shortcut
8776
+ ? { hint: shortcut.hint, aria: shortcut.aria }
8777
+ : null;
8778
+
8779
+ const baseViewOptions: HistoryViewOptions = {
8780
+ provider,
8781
+ context: historyOperationContext,
8782
+ targetId: activeHistoryTargetId(),
8783
+ presentation: historyPresentation,
8784
+ collapsible: railCollapsible(),
8785
+ collapsed: railShowsCollapsed(),
8786
+ railSide: railSide(),
8787
+ renderRailHeader: config.features?.history?.rail?.renderHeader,
8788
+ railBrand: railBrandNode(),
8789
+ onToggleCollapse: toggleRailCollapsed,
8790
+ // Formatted strings only: the size-capped chunk never imports shortcuts.
8791
+ ...(collapseShortcutStrings
8792
+ ? { collapseShortcut: collapseShortcutStrings }
8793
+ : {}),
8794
+ headerPlacement: initialHeaderPlacement,
8795
+ showScopeStatus: config.features?.history?.showScopeStatus !== false,
8796
+ showDelete: config.features?.history?.showDelete !== false,
8797
+ showDeleteAll: config.features?.history?.showDeleteAll !== false,
8798
+ ...(config.features?.history?.listActions?.length
8799
+ ? { listActions: config.features.history.listActions }
8800
+ : {}),
8801
+ // Rows borrow the launcher's IMAGE mark only: agentIconText carries the
8802
+ // merged 💬 default, which would put a placeholder glyph on every row.
8803
+ // No mark at all means text-only rows, the assistant-list default.
8804
+ rowAvatar:
8805
+ rowAvatar === false
8806
+ ? false
8807
+ : typeof rowAvatar === "string"
8808
+ ? rowAvatar
8809
+ : config.launcher?.iconUrl,
8810
+ activeConversationId: session.getActiveConversationId(),
8811
+ ...(config.features?.history?.grouping
8812
+ ? { grouping: config.features.history.grouping }
8813
+ : {}),
8814
+ ...(config.features?.history?.copy
8815
+ ? { copy: config.features.history.copy }
8816
+ : {}),
8817
+ ...(config.features?.history?.pageSize !== undefined
8818
+ ? { pageSize: config.features.history.pageSize }
8819
+ : {}),
8820
+ // The pending/error surface owns failures (optimistic open), so the
8821
+ // row must not double-report them.
8822
+ onSelect: (conversationId) =>
8823
+ openHistoryConversation(conversationId, { focusComposer: true }).catch(
8824
+ () => {}
8825
+ ),
8826
+ onActiveConversationChange: (summary) =>
8827
+ setActiveConversationSummary(summary),
8828
+ // The chunk is size-capped, so it borrows the shell's tooltip module.
8829
+ attachTooltip,
8830
+ onStartNew: () => startNewConversation({ focusComposer: true }),
8831
+ onClose: () => closeHistory(),
8832
+ onRequestDeleteConversation: (conversationId) =>
8833
+ requestDeleteConversation(conversationId),
8834
+ onRequestClearHistory: () => requestClearConversationHistory(),
8835
+ ...(provider.resetDevice
8836
+ ? { onRequestResetIdentity: () => requestResetHistoryIdentity() }
8837
+ : {}),
8838
+ };
8839
+
8840
+ // Plugin hooks arbitrate around the default view; the shell keeps placement,
8841
+ // open/close, Escape, confirmations, announcements, and focus.
8842
+ historySurface = createHistoryRenderSurface({
8843
+ plugins,
8844
+ config,
8845
+ getPresentation: () => historyPresentation ?? "panel",
8846
+ getReturnSurface: () => historyReturnSurface,
8847
+ close: () => closeHistory(),
8848
+ createView: ({ slots, renderDom, onModelChange, railSections }) => {
8849
+ // Tracked before `historySurface` is assigned: the first mount happens
8850
+ // inside this constructor and already needs the handle.
8851
+ historyViewHandle = module.createHistoryView({
8852
+ ...baseViewOptions,
8853
+ railSections: railNavSections(railSections),
8854
+ slots,
8855
+ renderDom,
8856
+ onModelChange,
8857
+ onAnnounce: announceHistory,
8858
+ });
8859
+ return historyViewHandle;
8860
+ },
8861
+ onElementChanged: (next, previous) => {
8862
+ if (!previous?.isConnected) return mountHistoryElement(next);
8863
+ prepareHistoryElement(next);
8864
+ previous.replaceWith(next);
8865
+ // Default <-> custom re-arbitration changes who owns the bar.
8866
+ enforcePanelHost();
8867
+ },
8868
+ });
8869
+ historySurface.view.setNewConversationRequired(
8870
+ historySessionState.recovery === "new_conversation_required"
8871
+ );
8872
+ // The panel makes the conversation inert behind it, so it always takes
8873
+ // focus. The rail leaves it operable: a pointer or programmatic open would
8874
+ // only leave a keyboard ring on the toggle, so it keeps focus where it is.
8875
+ // `focus: false` opts out entirely: the caller owns focus (preview replay).
8876
+ if (
8877
+ opts?.focus !== false &&
8878
+ (historyPresentation !== "rail" || opts?.keyboard === true)
8879
+ ) {
8880
+ focusHistoryEntry();
8881
+ }
8882
+ syncScrollToBottomButton();
8883
+ repinAnchoredMessage();
8884
+ syncHistoryChromeImpl();
8885
+ eventBus.emit("history:opened", {
8886
+ presentation: historyPresentation,
8887
+ returnSurface: historyReturnSurface,
8888
+ timestamp: Date.now(),
8889
+ });
8890
+ };
8891
+
8892
+ /**
8893
+ * Close is: exit animation -> unmount and restore the hidden chrome ->
8894
+ * restore focus. The teardown half is deferred behind the view's `playExit()`
8895
+ * promise, so it is exposed here for whoever preempts it (a reopen, a widget
8896
+ * teardown). Idempotent, and a no-op while nothing is leaving.
8897
+ */
8898
+ let settleHistoryExit: () => void = () => {};
8899
+
8900
+ const closeHistory = (opts?: { restoreFocus?: boolean }): void => {
8901
+ if (!historyVisible) return;
8902
+ historyOpenToken += 1;
8903
+ const returnSurface = historyReturnSurface;
8904
+ const invoker = historyInvoker;
8905
+ const surface = historySurface;
8906
+ const restoreInvokerFocus = opts?.restoreFocus !== false;
8907
+ // Flipped before the animation: a second close is a no-op and a reopen
8908
+ // mounts fresh rather than re-entering the surface that is leaving.
8909
+ historyVisible = false;
8910
+
8911
+ let done = false;
8912
+ let timer: ReturnType<typeof setTimeout> | null = null;
8913
+ const finish = (): void => {
8914
+ if (done) return;
8915
+ done = true;
8916
+ settleHistoryExit = () => {};
8917
+ if (timer !== null) clearTimeout(timer);
8918
+ // The floating rail is torn down with the surface it hosted.
8919
+ railOverlayOpen = false;
8920
+ cancelRailGrace();
8921
+ // Before focus restoration below: the invoker lives in the shell header,
8922
+ // which is inert until this restores it.
8923
+ unmountHistoryHosts();
8924
+ // Dispose before destroy: cleanups belong to the render being torn down.
8925
+ surface?.dispose();
8926
+ surface?.view.destroy();
8927
+ surface?.element.remove();
8928
+ // A reopen that preempted this exit already owns the shell state.
8929
+ if (historySurface === surface) {
8930
+ historySurface = null;
8931
+ historyViewHandle = null;
8932
+ historyMountedElement = null;
8933
+ historyPresentation = null;
8934
+ historyOperationContext = null;
8935
+ historyInvoker = null;
8936
+ }
8937
+ syncScrollToBottomButton();
8938
+ // Removing a full-height host above the anchored message clamps scrollTop.
8939
+ repinAnchoredMessage();
8940
+ syncHistoryChromeImpl();
8941
+ if (restoreInvokerFocus) {
8942
+ const target = invoker ?? historyButton;
8943
+ if (target?.isConnected) target.focus();
8944
+ else maybeFocusInput();
8945
+ }
8946
+ eventBus.emit("history:closed", { returnSurface, timestamp: Date.now() });
8947
+ };
8948
+
8949
+ // Only the arbitrated default view animates: a plugin full view owns its
8950
+ // own element and never got an entrance either.
8951
+ const exit =
8952
+ surface && surface.element === surface.view.element
8953
+ ? surface.view.playExit()
8954
+ : null;
8955
+ if (!exit) {
8956
+ finish();
8957
+ return;
8958
+ }
8959
+ settleHistoryExit = finish;
8960
+ // A cancelled or never-settling animation must never wedge the close.
8961
+ timer = setTimeout(finish, HISTORY_EXIT_TIMEOUT_MS);
8962
+ void exit.then(finish);
8963
+ };
8964
+
8965
+ /** Escape returns to the recorded invoking surface, like the back control. */
8966
+ const handleHistoryKeydown = (event: KeyboardEvent): void => {
8967
+ if (event.key !== "Escape" || !historyVisible || !historySurface) return;
8968
+ // Rail leaves the conversation operable: only its own focus closes it, and
8969
+ // the floating rail's scope includes the trigger it hangs from.
8970
+ if (historyPresentation === "rail") {
8971
+ const target = event.target;
8972
+ if (
8973
+ !(target instanceof Node) ||
8974
+ !(
8975
+ historySurface.element.contains(target) ||
8976
+ (railOverlayOpen && railTriggerWrapper?.contains(target) === true)
8977
+ )
8978
+ ) {
8979
+ return;
8980
+ }
8981
+ }
8982
+ event.preventDefault();
8983
+ event.stopPropagation();
8984
+ closeHistory();
8985
+ };
8986
+ container.addEventListener("keydown", handleHistoryKeydown);
8987
+ destroyCallbacks.push(() =>
8988
+ container.removeEventListener("keydown", handleHistoryKeydown)
8989
+ );
8990
+
8991
+ // --- session operations --------------------------------------------------
8992
+
8993
+ // ---- header title binding (layout.header.titleSource: "conversation") ----
8994
+ // The history view reports the active conversation's list summary through
8995
+ // onActiveConversationChange; the shell owns the fallback and the write, so
8996
+ // the binding works for both the plain title span and the titleMenu label.
8997
+ // `activeConversationStarred` additionally feeds the built-in title-menu
8998
+ // star toggle.
8999
+ let activeConversationTitle: string | null = null;
9000
+ let activeConversationStarred = false;
9001
+ const applyHeaderTitle = (): void => {
9002
+ if (!headerTitle) return;
9003
+ const conversationMode = config.layout?.header?.titleSource === "conversation";
9004
+ const bound = conversationMode
9005
+ ? activeConversationTitle?.trim() || null
9006
+ : null;
9007
+ headerTitle.textContent = bound ?? config.launcher?.title ?? "Chat Assistant";
9008
+ // A conversation-bound titleMenu acts on the ACTIVE conversation, so with
9009
+ // none open the combo locks into a plain title (no chevron, no menu)
9010
+ // instead of a menu of no-ops. Static titles keep their menu untouched.
9011
+ const combo = headerTitle.closest<HTMLElement>(".persona-combo-btn");
9012
+ if (!combo) return;
9013
+ const menuAvailable =
9014
+ !conversationMode || session.getActiveConversationId() !== null;
9015
+ combo.setAttribute("data-persona-menu-locked", menuAvailable ? "false" : "true");
9016
+ combo.setAttribute("aria-haspopup", menuAvailable ? "true" : "false");
9017
+ if (!menuAvailable) combo.setAttribute("aria-expanded", "false");
9018
+ combo.style.cursor = menuAvailable ? "pointer" : "default";
9019
+ const chevron = combo.querySelector("svg");
9020
+ if (chevron) chevron.style.display = menuAvailable ? "" : "none";
9021
+ };
9022
+ const setActiveConversationTitle = (title: string | null): void => {
9023
+ if (title === activeConversationTitle) return;
9024
+ activeConversationTitle = title;
9025
+ applyHeaderTitle();
9026
+ };
9027
+ const setActiveConversationSummary = (
9028
+ summary: HistoryConversationSummary | null
9029
+ ): void => {
9030
+ activeConversationStarred = summary?.starred === true;
9031
+ setActiveConversationTitle(summary ? summary.title.trim() || null : null);
9032
+ // The title can be unchanged (null to null) while the ACTIVE id flipped,
9033
+ // e.g. start-new; the menu lock keys on the id, so always resync.
9034
+ applyHeaderTitle();
9035
+ };
9036
+
9037
+ // Built-in title-menu actions, raised as a bubbling DOM event by the header
9038
+ // combo (the initial header is built by the pure panel builder, out of the
9039
+ // shell's reach). One listener on the stable container survives header
9040
+ // rebuilds and rail re-homing.
9041
+ // Initial stamp: with no active conversation the conversation-bound menu
9042
+ // starts locked (and a restored session starts unlocked).
9043
+ applyHeaderTitle();
9044
+
9045
+ container.addEventListener("persona:title-menu-builtin", (event) => {
9046
+ const actionId = (event as CustomEvent<{ actionId?: string }>).detail
9047
+ ?.actionId;
9048
+ const activeId = session.getActiveConversationId();
9049
+ if (!activeId) return;
9050
+ if (actionId === "delete") {
9051
+ void requestDeleteConversation(activeId);
9052
+ } else if (actionId === "star" && session.canUpdateConversations()) {
9053
+ void updateHistoryConversation(activeId, {
9054
+ starred: !activeConversationStarred,
9055
+ }).catch(() => {});
9056
+ }
9057
+ });
9058
+
9059
+ // --- optimistic conversation open ---------------------------------------
9060
+ // Selection acknowledges within a frame: the composer gates immediately, the
9061
+ // stand-in takes the surface only if the fetch outlasts the show delay, and
9062
+ // the transcript hydrates when the fetch lands. The token invalidates a stale
9063
+ // resolution after a newer open, a new conversation, or a failure the visitor
9064
+ // navigated away from.
9065
+ let conversationOpenToken = 0;
9066
+ // Show delay: opens that land inside it swap straight from the welcome or the
9067
+ // previous conversation to the loaded transcript, so no skeleton ever flashes.
9068
+ const CONVERSATION_OPEN_TAKEOVER_DELAY_MS = 250;
9069
+ let conversationOpenTakeoverTimer: ReturnType<typeof setTimeout> | null = null;
9070
+
9071
+ const cancelConversationOpenTakeover = (): void => {
9072
+ if (conversationOpenTakeoverTimer) clearTimeout(conversationOpenTakeoverTimer);
9073
+ conversationOpenTakeoverTimer = null;
9074
+ };
9075
+ destroyCallbacks.push(cancelConversationOpenTakeover);
9076
+
9077
+ /** Hands the surface to the mounted stand-in. Idempotent by re-application. */
9078
+ const engageConversationOpenTakeover = (): void => {
9079
+ cancelConversationOpenTakeover();
9080
+ const element = conversationOpenPendingEl;
9081
+ if (!element) return;
9082
+ conversationOpenTakeover = true;
9083
+ element.hidden = false;
9084
+ messagesWrapper.style.display = "none";
9085
+ // The welcome is a sibling of the transcript, so hiding the wrapper alone
9086
+ // would leave it painted above the stand-in.
9087
+ updateWelcome();
9088
+ };
9089
+
9090
+ const clearConversationOpenPending = (): void => {
9091
+ conversationOpenToken += 1;
9092
+ cancelConversationOpenTakeover();
9093
+ if (!conversationOpenPendingEl) return;
9094
+ conversationOpenPendingEl.remove();
9095
+ conversationOpenPendingEl = null;
9096
+ conversationOpenTakeover = false;
9097
+ messagesWrapper.style.removeProperty("display");
9098
+ setHistoryHostInert(footer, false);
9099
+ // Restores the welcome when navigation lands back on an empty transcript.
9100
+ updateWelcome();
9101
+ };
9102
+
9103
+ /**
9104
+ * Commit fade for the reopened transcript. WAAPI, not a CSS transition: the
9105
+ * wrapper is the morph target, and post-render inline state is stripped by
9106
+ * every render inside the completion window.
9107
+ */
9108
+ const fadeInTranscript = (): void => {
9109
+ try {
9110
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) {
9111
+ return;
9112
+ }
9113
+ messagesWrapper.animate([{ opacity: 0 }, { opacity: 1 }], {
9114
+ duration: 150,
9115
+ easing: "ease-out",
9116
+ });
9117
+ } catch {
9118
+ /* environments without WAAPI land the transcript without motion */
9119
+ }
9120
+ };
9121
+
9122
+ // The stand-in surfaces get the transcript's centered column from their
9123
+ // container classes, so the hydrated messages land exactly where they stood.
9124
+ const skeletonBubble = (width: string, trailing: boolean): HTMLElement =>
9125
+ createNode("div", {
9126
+ className: cx(
9127
+ "persona-conversation-loading-bubble",
9128
+ trailing && "persona-conversation-loading-bubble--trailing"
9129
+ ),
9130
+ attrs: { "aria-hidden": "true" },
9131
+ style: { width },
9132
+ });
9133
+
9134
+ const buildConversationOpenSkeleton = (): HTMLElement =>
9135
+ createNode(
9136
+ "div",
9137
+ { className: "persona-conversation-loading-body" },
9138
+ skeletonBubble("58%", false),
9139
+ skeletonBubble("72%", true),
9140
+ skeletonBubble("44%", false)
9141
+ );
9142
+
9143
+ const mountConversationOpenState = (element: HTMLElement): void => {
9144
+ conversationOpenPendingEl?.remove();
9145
+ conversationOpenPendingEl = element;
9146
+ // The takeover is deferred, so the stand-in mounts hidden; one that replaces
9147
+ // an already-engaged stand-in keeps the surface instead of blanking it.
9148
+ element.hidden = !conversationOpenTakeover;
9149
+ body.insertBefore(element, messagesWrapper);
9150
+ cancelConversationOpenTakeover();
9151
+ if (!conversationOpenTakeover) {
9152
+ conversationOpenTakeoverTimer = setTimeout(
9153
+ engageConversationOpenTakeover,
9154
+ CONVERSATION_OPEN_TAKEOVER_DELAY_MS
9155
+ );
9156
+ }
9157
+ // The composer must not send into the conversation being replaced. The
9158
+ // panel exit restores the footer after its animation, so the close path
9159
+ // re-asserts this gate (see the history:closed re-assert below).
9160
+ setHistoryHostInert(footer, true);
9161
+ };
9162
+
9163
+ const showConversationOpenPending = (): void => {
9164
+ // Same plugin -> config -> default chain as the streaming indicator. The
9165
+ // hook replaces the skeleton only; the container keeps the centered
9166
+ // column, the status role, and the composer gate.
9167
+ const context: LoadingIndicatorRenderContext = {
9168
+ config,
9169
+ streaming: false,
9170
+ location: "conversation-open",
9171
+ defaultRenderer: buildConversationOpenSkeleton,
9172
+ };
9173
+ let content: HTMLElement | null = null;
9174
+ const loadingPlugin = plugins.find((p) => p.renderLoadingIndicator);
9175
+ if (loadingPlugin?.renderLoadingIndicator) {
9176
+ content = loadingPlugin.renderLoadingIndicator(context);
9177
+ }
9178
+ if (content === null && config.loadingIndicator?.render) {
9179
+ content = config.loadingIndicator.render(context);
9180
+ }
9181
+ mountConversationOpenState(
9182
+ createNode(
9183
+ "div",
9184
+ {
9185
+ className: "persona-conversation-loading",
9186
+ attrs: {
9187
+ role: "status",
9188
+ "aria-label": historyShellCopy.openConversationLoadingLabel,
9189
+ },
9190
+ },
9191
+ content ?? buildConversationOpenSkeleton()
9192
+ )
9193
+ );
9194
+ };
9195
+
9196
+ const showConversationOpenError = (conversationId: string): void => {
9197
+ const retry = (): void => {
9198
+ void openHistoryConversation(conversationId, {
9199
+ focusComposer: true,
9200
+ }).catch(() => {});
9201
+ };
9202
+ const back = historyAvailable()
9203
+ ? (): void => {
9204
+ clearConversationOpenPending();
9205
+ void openHistory();
9206
+ }
9207
+ : undefined;
9208
+ const actionButton = (label: string, onClick: () => void): HTMLElement => {
9209
+ const button = createNode("button", {
9210
+ className: "persona-conversation-loading-error-action",
9211
+ attrs: { type: "button" },
9212
+ text: label,
9213
+ });
9214
+ button.addEventListener("click", onClick);
9215
+ return button;
9216
+ };
9217
+ const buildDefaultBlock = (): HTMLElement =>
9218
+ createNode(
9219
+ "div",
9220
+ { className: "persona-conversation-loading-error-body" },
9221
+ createNode("p", {
9222
+ className: "persona-conversation-loading-error-title",
9223
+ text: historyShellCopy.openConversationErrorTitle,
9224
+ }),
9225
+ createNode(
9226
+ "div",
9227
+ { className: "persona-conversation-loading-error-actions" },
9228
+ actionButton(historyShellCopy.openConversationRetryLabel, retry),
9229
+ back
9230
+ ? actionButton(historyShellCopy.openConversationBackLabel, back)
9231
+ : null
9232
+ )
9233
+ );
9234
+ // First non-null plugin hook wins (plugins are priority-sorted); a throw
9235
+ // is reported and skipped. The container keeps the centered column, the
9236
+ // alert role, and the composer gate around whatever the hook returns.
9237
+ const context: AgentWidgetRenderHistoryOpenErrorContext = Object.freeze({
9238
+ conversationId,
9239
+ config,
9240
+ copy: Object.freeze({
9241
+ title: historyShellCopy.openConversationErrorTitle,
9242
+ retryLabel: historyShellCopy.openConversationRetryLabel,
9243
+ backLabel: historyShellCopy.openConversationBackLabel,
9244
+ }),
9245
+ retry,
9246
+ ...(back ? { back } : {}),
9247
+ defaultRenderer: buildDefaultBlock,
9248
+ });
9249
+ let content: HTMLElement | null = null;
9250
+ for (const plugin of plugins) {
9251
+ if (!plugin.renderHistoryOpenError) continue;
9252
+ try {
9253
+ content = plugin.renderHistoryOpenError(context);
9254
+ } catch (error) {
9255
+ console.warn("[persona] renderHistoryOpenError threw", error);
9256
+ content = null;
9257
+ }
9258
+ if (content) break;
9259
+ }
9260
+ mountConversationOpenState(
9261
+ createNode(
9262
+ "div",
9263
+ {
9264
+ className: "persona-conversation-loading-error",
9265
+ attrs: { role: "alert" },
9266
+ },
9267
+ content ?? buildDefaultBlock()
9268
+ )
9269
+ );
9270
+ // A failure must never sit hidden behind the surface it replaces, so the
9271
+ // error skips the stand-in's show delay.
9272
+ engageConversationOpenTakeover();
9273
+ };
9274
+
9275
+ // The panel exit's deferred teardown restores the footer it captured, which
9276
+ // would lift the composer gate mid-fetch; re-assert it while an open is
9277
+ // still pending.
9278
+ eventBus.on("history:closed", () => {
9279
+ if (conversationOpenPendingEl) setHistoryHostInert(footer, true);
9280
+ });
9281
+
9282
+ /**
9283
+ * Optimistic reopen: navigate on the click, hydrate on the fetch. The
9284
+ * returned promise keeps the transactional contract (resolves once the
9285
+ * transcript is live, rejects on failure) for `controller.openConversation`.
9286
+ * `suppressScrollSend` covers the hydration: otherwise the last restored
9287
+ * user message triggers the anchor-top send scroll.
9288
+ */
9289
+ const openHistoryConversation = async (
9290
+ conversationId: string,
9291
+ { focusComposer = false }: { focusComposer?: boolean } = {}
9292
+ ): Promise<void> => {
9293
+ const scope = historyOperationScope();
9294
+ // Seeds the header title binding from the list row before the list leaves.
9295
+ // The surface is usually gone by hydration time (the exit outruns the
9296
+ // fetch), so this is the only moment the title can be read from the list.
9297
+ const seededFromList = !!historySurface;
9298
+ historySurface?.view.setActiveConversationId(conversationId);
9299
+ clearConversationOpenPending();
9300
+ const token = conversationOpenToken;
9301
+ showConversationOpenPending();
9302
+ if (historyVisible && (historyPresentation === "panel" || railOverlayOpen)) {
9303
+ closeHistory({ restoreFocus: false });
9304
+ }
9305
+ suppressScrollSend = true;
9306
+ try {
9307
+ await session.openConversation(conversationId, { scope });
9308
+ } catch (error) {
9309
+ if (token === conversationOpenToken) {
9310
+ showConversationOpenError(conversationId);
9311
+ }
9312
+ throw error;
9313
+ } finally {
9314
+ suppressScrollSend = false;
9315
+ }
9316
+ if (token !== conversationOpenToken) return;
9317
+ clearConversationOpenPending();
9318
+ messageCache.clear();
9319
+ resetAnchorState();
9320
+ resumeAutoScroll();
9321
+ if (!restoreScrollPosition()) jumpToBottomInstant();
9322
+ // Fades only after the scroll settles, so the motion covers the final frame.
9323
+ fadeInTranscript();
9324
+ syncEarlierMessagesPill();
9325
+ // Without a list at selection time there was no title to seed.
9326
+ if (!seededFromList) setActiveConversationSummary(null);
9327
+ eventBus.emit("history:conversationOpened", {
9328
+ conversationId,
9329
+ title: activeConversationTitle,
9330
+ scope,
9331
+ timestamp: Date.now(),
9332
+ });
9333
+ // Only interaction paths focus: focusing a programmatic open scrolls the
9334
+ // host page to the widget (same-origin iframes included).
9335
+ if (focusComposer) maybeFocusInput();
9336
+ };
9337
+
9338
+ /**
9339
+ * The single commit path behind the header action, the view's `onStartNew`,
9340
+ * and `controller.startNewConversation()`, so one emit covers all three.
9341
+ */
9342
+ const startNewConversation = async ({
9343
+ focusComposer = false,
9344
+ }: { focusComposer?: boolean } = {}): Promise<void> => {
9345
+ // A new conversation supersedes any transcript fetch still in flight.
9346
+ clearConversationOpenPending();
9347
+ await session.startNewConversation({ scope: historyOperationScope() });
9348
+ setActiveConversationSummary(null);
9349
+ messageCache.clear();
9350
+ resetAnchorState();
9351
+ resumeAutoScroll();
9352
+ jumpToBottomInstant();
9353
+ syncEarlierMessagesPill();
9354
+ const conversationId = session.getActiveConversationId();
9355
+ historySurface?.view.setActiveConversationId(conversationId);
9356
+ // Emitted before the close so a host can tell a commit from a plain close.
9357
+ eventBus.emit("history:conversationStarted", {
9358
+ conversationId,
9359
+ timestamp: Date.now(),
9360
+ });
9361
+ if (historyVisible && (historyPresentation === "panel" || railOverlayOpen)) {
9362
+ closeHistory({ restoreFocus: false });
9363
+ }
9364
+ if (focusComposer) maybeFocusInput();
9365
+ };
9366
+
9367
+ /** Shared rename/star path: provider update, then view + header binding. */
9368
+ const updateHistoryConversation = async (
9369
+ conversationId: string,
9370
+ patch: HistoryConversationPatch
9371
+ ): Promise<HistoryConversationSummary> => {
9372
+ const summary = await session.updateConversation(conversationId, {
9373
+ ...patch,
9374
+ ...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
9375
+ });
9376
+ // The view re-renders and reports the active title through the reporter;
9377
+ // without a mounted view, keep the header binding fresh directly.
9378
+ historySurface?.view.applyConversationSummary(summary);
9379
+ if (!historySurface && conversationId === session.getActiveConversationId()) {
9380
+ setActiveConversationSummary(summary);
9381
+ }
9382
+ return summary;
9383
+ };
9384
+
9385
+ const deleteHistoryConversation = async (
9386
+ conversationId: string
9387
+ ): Promise<void> => {
9388
+ const scope = historyOperationScope();
9389
+ const wasActive = session.getActiveConversationId() === conversationId;
9390
+ await session.deleteConversation(conversationId, { scope });
9391
+ // Prune the open list too: the view's own delete flow removes its row
9392
+ // after this resolves, but built-in/headless deletes have no view caller.
9393
+ historySurface?.view.removeConversationSummary(conversationId);
9394
+ if (wasActive) {
9395
+ setActiveConversationSummary(null);
9396
+ messageCache.clear();
9397
+ resetAnchorState();
9398
+ resumeAutoScroll();
9399
+ syncEarlierMessagesPill();
9400
+ }
9401
+ eventBus.emit("history:conversationDeleted", {
9402
+ conversationId,
9403
+ scope,
9404
+ wasActive,
9405
+ timestamp: Date.now(),
9406
+ });
9407
+ };
9408
+
9409
+ const clearConversationHistory = async (opts?: {
9410
+ targetId?: string;
9411
+ /** Deliberate headless opt-in to the whole authorized client-token scope. */
9412
+ allTargets?: boolean;
9413
+ scope?: HistoryScope;
9414
+ }): Promise<{ deleted: number }> => {
9415
+ const scope = opts?.scope ?? historyOperationScope();
9416
+ // Default to the same filter the visible list uses; only an explicit
9417
+ // headless opt-in deletes conversations the visitor was never shown.
9418
+ const targetId = opts?.allTargets
9419
+ ? null
9420
+ : (opts?.targetId ?? activeHistoryTargetId());
9421
+ const result = await session.clearConversationHistory({
9422
+ ...(targetId ? { targetId } : {}),
9423
+ scope,
9424
+ });
9425
+ setActiveConversationSummary(null);
9426
+ messageCache.clear();
9427
+ resetAnchorState();
9428
+ resumeAutoScroll();
9429
+ syncEarlierMessagesPill();
9430
+ eventBus.emit("history:cleared", {
9431
+ deleted: result.deleted,
9432
+ scope,
9433
+ targetId,
9434
+ timestamp: Date.now(),
9435
+ });
9436
+ return result;
9437
+ };
9438
+
9439
+ /**
9440
+ * D6: the local wipe is UNCONDITIONAL. Records survive on the server; this
9441
+ * browser is detached from them whether or not revocation was confirmed.
9442
+ */
9443
+ const resetHistoryIdentity = async (): Promise<{
9444
+ remoteRevocationConfirmed: boolean;
9445
+ }> => {
9446
+ // Rejection is reserved for misuse; remote failure resolves false.
9447
+ if (!historyProvider?.resetDevice) {
9448
+ throw new Error(
9449
+ "[Persona] resetHistoryIdentity() requires a history provider that can reset this device"
9450
+ );
9451
+ }
9452
+ let remoteRevocationConfirmed = false;
9453
+ try {
9454
+ const result = await session.resetHistoryDevice();
9455
+ remoteRevocationConfirmed = result.remoteRevocationConfirmed;
9456
+ } finally {
9457
+ setActiveConversationSummary(null);
9458
+ session.clearArtifacts();
9459
+ messageCache.clear();
9460
+ lastAppliedMessages = null;
9461
+ config.clearStoredSessionId?.();
9462
+ config.clearStoredConversationId?.();
9463
+ persistentMetadata = {};
9464
+ actionManager.syncFromMetadata();
9465
+ if (storageAdapter?.clear) {
9466
+ runStorageMutation(
9467
+ () => storageAdapter.clear!(),
9468
+ "[AgentWidget] Failed to clear storage adapter:"
9469
+ );
9470
+ }
9471
+ resetAnchorState();
9472
+ resumeAutoScroll();
9473
+ syncEarlierMessagesPill();
9474
+ closeHistory({ restoreFocus: false });
9475
+ maybeFocusInput();
9476
+ if (!open) launcherSurfaceInstance?.launcher.element.focus();
9477
+ }
9478
+ announceHistory(
9479
+ remoteRevocationConfirmed
9480
+ ? historyShellCopy.identityResetNotice
9481
+ : historyShellCopy.identityResetUnconfirmedNotice
9482
+ );
9483
+ eventBus.emit("history:identityReset", {
9484
+ remoteRevocationConfirmed,
9485
+ timestamp: Date.now(),
9486
+ });
9487
+ return { remoteRevocationConfirmed };
9488
+ };
9489
+
9490
+ // --- confirmations (shell-owned alert dialogs) ---------------------------
9491
+
9492
+ // With the artifact split, `container` is only the chat column, so an
9493
+ // inset:0 overlay there would leave the artifact pane undimmed. The panel
9494
+ // wraps the whole split and is already position:relative on that path;
9495
+ // forcing `relative` onto it elsewhere would not survive the chrome
9496
+ // passes' cssText resets, so plain layouts keep the container host.
9497
+ const historyConfirmHost = (): HTMLElement =>
9498
+ artifactSplitRoot ? panel : container;
9499
+
9500
+ const requestDeleteConversation = async (
9501
+ conversationId: string
9502
+ ): Promise<"deleted" | "cancelled"> => {
9503
+ const confirmed = await showHistoryConfirm({
9504
+ host: historyConfirmHost(),
9505
+ title: historyShellCopy.deleteConversationConfirmTitle,
9506
+ description: historyShellCopy.deleteConversationConfirm,
9507
+ confirmLabel: historyShellCopy.deleteConversationConfirmLabel,
9508
+ cancelLabel: historyShellCopy.confirmCancelLabel,
9509
+ });
9510
+ if (!confirmed) return "cancelled";
9511
+ await deleteHistoryConversation(conversationId);
9512
+ return "deleted";
9513
+ };
9514
+
9515
+ const requestClearConversationHistory = async (): Promise<
9516
+ "cleared" | "cancelled"
9517
+ > => {
9518
+ // Scope-aware copy: never imply the delete is limited to the rendered page.
9519
+ const verified = historyOperationScope() === "verified-user";
9520
+ const confirmed = await showHistoryConfirm({
9521
+ host: historyConfirmHost(),
9522
+ title: historyShellCopy.clearHistoryConfirmTitle,
9523
+ description: verified
9524
+ ? historyShellCopy.clearHistoryVerifiedConfirm
9525
+ : historyShellCopy.clearHistoryConfirm,
9526
+ confirmLabel: historyShellCopy.clearHistoryConfirmLabel,
9527
+ cancelLabel: historyShellCopy.confirmCancelLabel,
9528
+ });
9529
+ if (!confirmed) return "cancelled";
9530
+ await clearConversationHistory();
9531
+ return "cleared";
9532
+ };
9533
+
9534
+ const requestResetHistoryIdentity = async (): Promise<
9535
+ { outcome: "cancelled" } | { outcome: "reset"; remoteRevocationConfirmed: boolean }
9536
+ > => {
9537
+ const confirmed = await showHistoryConfirm({
9538
+ host: historyConfirmHost(),
9539
+ title: historyShellCopy.resetIdentityConfirmTitle,
9540
+ description: historyShellCopy.resetIdentityConfirm,
9541
+ confirmLabel: historyShellCopy.resetIdentityConfirmLabel,
9542
+ cancelLabel: historyShellCopy.confirmCancelLabel,
9543
+ });
9544
+ if (!confirmed) return { outcome: "cancelled" };
9545
+ const { remoteRevocationConfirmed } = await resetHistoryIdentity();
9546
+ return { outcome: "reset", remoteRevocationConfirmed };
9547
+ };
9548
+
9549
+ // --- "show earlier messages" prepend -------------------------------------
9550
+
9551
+ const earlierMessagesButton = createElement(
9552
+ "button",
9553
+ "persona-history-earlier"
9554
+ ) as HTMLButtonElement;
9555
+ earlierMessagesButton.type = "button";
9556
+ earlierMessagesButton.textContent = historyShellCopy.showEarlierMessagesLabel;
9557
+ earlierMessagesButton.setAttribute("data-persona-history-earlier", "");
9558
+ Object.assign(earlierMessagesButton.style, {
9559
+ alignSelf: "center",
9560
+ minHeight: "44px",
9561
+ padding: "0 16px",
9562
+ borderRadius: "999px",
9563
+ border: "1px solid var(--persona-border, rgba(0,0,0,0.12))",
9564
+ background: "transparent",
9565
+ color: "inherit",
9566
+ font: "inherit",
9567
+ cursor: "pointer",
9568
+ flexShrink: "0",
9569
+ } satisfies Partial<CSSStyleDeclaration>);
9570
+
9571
+ const syncEarlierMessagesPill = (): void => {
9572
+ const show =
9573
+ historyAvailable() &&
9574
+ !!historySessionState.nextMessageCursor &&
9575
+ !!session.getActiveConversationId();
9576
+ if (!show) {
9577
+ earlierMessagesButton.remove();
9578
+ return;
9579
+ }
9580
+ earlierMessagesButton.textContent = historyShellCopy.showEarlierMessagesLabel;
9581
+ if (earlierMessagesButton.parentNode !== body) {
9582
+ body.insertBefore(earlierMessagesButton, body.firstChild);
9583
+ }
9584
+ };
9585
+
9586
+ earlierMessagesButton.addEventListener("click", () => {
9587
+ const conversationId = session.getActiveConversationId();
9588
+ const cursor = historySessionState.nextMessageCursor;
9589
+ if (!conversationId || !cursor || earlierMessagesButton.disabled) return;
9590
+ earlierMessagesButton.disabled = true;
9591
+ // Capture before the prepend so the reader's viewport stays put.
9592
+ const previousHeight = body.scrollHeight;
9593
+ const previousTop = body.scrollTop;
9594
+ void session
9595
+ .loadOlderMessages(conversationId, cursor, { scope: historyOperationScope() })
9596
+ .then(() => {
9597
+ body.scrollTop = previousTop + (body.scrollHeight - previousHeight);
9598
+ // Prepending shifts everything below the anchor.
9599
+ repinAnchoredMessage();
9600
+ })
9601
+ .catch(() => {})
9602
+ .finally(() => {
9603
+ earlierMessagesButton.disabled = false;
9604
+ syncEarlierMessagesPill();
9605
+ });
9606
+ });
9607
+
9608
+ // --- header chrome -------------------------------------------------------
9609
+
9610
+ // Shares the header control chrome (box + glyph from the header tokens) with
9611
+ // the close and clear-chat buttons beside it.
9612
+ const buildHistoryButton = (): {
9613
+ button: HTMLButtonElement;
9614
+ wrapper: HTMLElement;
9615
+ } => {
9616
+ const parts = createHeaderIconButton({
9617
+ ariaLabel: historyShellCopy.openHistoryLabel,
9618
+ iconName: "history",
9619
+ wrapperClassName:
9620
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center",
9621
+ extraClassName: "persona-history-toggle",
9622
+ attrs: { "data-persona-history-toggle": "" },
9623
+ });
9624
+ parts.button.addEventListener("click", (event) => {
9625
+ if (!historyAvailable() || historyTurnBusy()) return;
9626
+ if (historyVisible) {
9627
+ closeHistory();
9628
+ return;
9629
+ }
9630
+ // Enter/Space synthesize a click with detail 0; a pointer press reports > 0.
9631
+ void openHistory({ invoker: parts.button, keyboard: event.detail === 0 });
9632
+ });
9633
+ return parts;
9634
+ };
9635
+
9636
+ const syncHistoryButton = (): void => {
9637
+ if (!historyAvailable()) {
9638
+ historyButtonWrapper?.remove();
9639
+ historyButton = null;
9640
+ historyButtonWrapper = null;
9641
+ return;
9642
+ }
9643
+ // A header rebuild detaches the old button; a stale ref must not block
9644
+ // recreation into the replacement header.
9645
+ if (historyButtonWrapper && !historyButtonWrapper.isConnected) {
9646
+ historyButton = null;
9647
+ historyButtonWrapper = null;
9648
+ }
9649
+ if (!historyButton && header) {
9650
+ const parts = buildHistoryButton();
9651
+ historyButton = parts.button;
9652
+ historyButtonWrapper = parts.wrapper;
9653
+ const insertBefore =
9654
+ panelElements.clearChatButtonWrapper || panelElements.closeButtonWrapper;
9655
+ // Layouts may parent these wrappers in a trailing cluster rather than
9656
+ // the header itself; insert wherever they live so close stays outermost.
9657
+ if (insertBefore?.parentNode && header.contains(insertBefore)) {
9658
+ insertBefore.parentNode.insertBefore(historyButtonWrapper, insertBefore);
9659
+ } else {
9660
+ header.appendChild(historyButtonWrapper);
9661
+ }
9662
+ }
9663
+ if (!historyButton || !historyButtonWrapper) return;
9664
+ // Every rail surface carries its own toggle, and so does the trigger that
9665
+ // summons one: the header keeps a control only when neither is on screen.
9666
+ const railed =
9667
+ railTriggerApplies() || (historyVisible && historyPresentation === "rail");
9668
+ historyButtonWrapper.style.display = railed ? "none" : "";
9669
+ if (railed) {
9670
+ // Hiding the control under focus would drop it to the body; the rail's
9671
+ // own toggle is the same control, so focus follows it there.
9672
+ if (document.activeElement === historyButton) focusHistoryEntry();
9673
+ return;
9674
+ }
9675
+ const busy = historyTurnBusy();
9676
+ historyButton.disabled = busy;
9677
+ historyButton.setAttribute("aria-disabled", busy ? "true" : "false");
9678
+ const label = busy
9679
+ ? historyShellCopy.openHistoryBusyLabel
9680
+ : historyShellCopy.openHistoryLabel;
9681
+ // The factory tooltip reads the live aria-label; a title would double it.
9682
+ historyButton.setAttribute("aria-label", label);
9683
+ // Rail/auto toggles a navigation region; panel navigates to a surface.
9684
+ if ((config.features?.history?.presentation ?? "panel") === "panel") {
9685
+ historyButton.removeAttribute("aria-expanded");
9686
+ historyButton.removeAttribute("aria-controls");
9687
+ } else {
9688
+ historyButton.setAttribute("aria-expanded", historyVisible ? "true" : "false");
9689
+ historyButton.setAttribute("aria-controls", historyRegionId);
9690
+ }
9691
+ };
9692
+
9693
+ /**
9694
+ * With history available the visible start-over affordance becomes
9695
+ * "New conversation" (`clearChat()` stays programmatic-only).
9696
+ */
9697
+ let clearChatRelabelled = false;
9698
+ const syncClearChatAffordance = (): void => {
9699
+ const button = panelElements.clearChatButton;
9700
+ if (!button) return;
9701
+ if (historyAvailable()) {
9702
+ if (!clearChatRelabelled) {
9703
+ clearChatDefaultLabel = button.getAttribute("aria-label") ?? "";
9704
+ clearChatRelabelled = true;
9705
+ }
9706
+ // aria-label only: the styled tooltip reads it live, and a title would
9707
+ // render a second native tooltip on top.
9708
+ button.setAttribute("aria-label", historyShellCopy.newConversationLabel);
9709
+ button.removeAttribute("title");
9710
+ // No touch-target pin: the shared header control class owns the box, and
9711
+ // a 44px floor here would desync this control from its neighbours.
9712
+ return;
9713
+ }
9714
+ // Only undo a relabel we performed; never clobber host-configured copy.
9715
+ if (!clearChatRelabelled) return;
9716
+ clearChatRelabelled = false;
9717
+ if (clearChatDefaultLabel) {
9718
+ button.setAttribute("aria-label", clearChatDefaultLabel);
9719
+ }
9720
+ };
9721
+
9722
+ const syncHistoryChromeImpl = (): void => {
9723
+ historyShellCopy = resolveHistoryShellCopy(config.features?.history?.copy);
9724
+ syncHistoryButton();
9725
+ syncRailOverlayTrigger();
9726
+ syncClearChatAffordance();
9727
+ syncEarlierMessagesPill();
9728
+ };
9729
+ historyChromeSync = syncHistoryChromeImpl;
9730
+
9731
+ // --- session-driven state ------------------------------------------------
9732
+
9733
+ historyStateHandler = (state) => {
9734
+ historySessionState = state;
9735
+ historySurface?.view.setNewConversationRequired(
9736
+ state.recovery === "new_conversation_required"
9737
+ );
9738
+ // Sending stays blocked through a destructive/continuity transition.
9739
+ setComposerDisabled(state.sendBlocked || isStreaming);
9740
+ syncEarlierMessagesPill();
9741
+ };
9742
+
9743
+ historyNoticeHandler = (notice) => {
9744
+ if (
9745
+ notice.code === "history_continuity_reset" ||
9746
+ notice.code === "conversation_deleted_recovered"
9747
+ ) {
9748
+ messageCache.clear();
9749
+ resetAnchorState();
9750
+ resumeAutoScroll();
9751
+ }
9752
+ announceHistory(notice.message);
9753
+ historySurface?.view.refresh();
9754
+ };
9755
+
9756
+ installHistoryProvider();
9757
+ syncHistoryChromeImpl();
9758
+ destroyCallbacks.push(() => {
9759
+ unsubscribeHistoryAvailability?.();
9760
+ unsubscribeHistoryIdentity?.();
9761
+ // An open in flight (a hover this teardown raced) must not mount after it.
9762
+ historyOpenToken += 1;
9763
+ // A pending exit still owns a mounted surface and a live timer.
9764
+ settleHistoryExit();
9765
+ historySurface?.dispose();
9766
+ historySurface?.view.destroy();
9767
+ historySurface = null;
9768
+ });
9769
+
9770
+ // Pre-initialize client session when in client token mode so feedback works
9771
+ // before the user sends their first message (e.g. on restored/persisted messages)
9772
+ if (config.clientToken) {
9773
+ session
9774
+ .initClientSession()
9775
+ .then(() => {
9776
+ // Boot resume is authoritative only after reconciliation; the client
9777
+ // already gated its init on `historyBootstrapReady`.
9778
+ if (!historyAvailable()) return undefined;
9779
+ return session.reconcileBootConversation({ scope: historyScope() });
9780
+ })
9781
+ .then(() => {
9782
+ syncEarlierMessagesPill();
9783
+ })
9784
+ .catch((err) => {
9785
+ if (config.debug) {
9786
+ // eslint-disable-next-line no-console
9787
+ console.warn("[AgentWidget] Pre-init client session failed:", err);
9788
+ }
9789
+ });
9790
+ }
9791
+
9792
+ // Wire up optional SSE tap (host) + event stream buffer to capture SSE events
9793
+ if (eventStreamBuffer || config.onSSEEvent) {
9794
+ session.setSSEEventCallback((type: string, payload: unknown) => {
9795
+ config.onSSEEvent?.(type, payload);
9796
+ throughputTracker?.processEvent(type, payload);
9797
+ eventStreamBuffer?.push({
9798
+ id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
9799
+ type,
9800
+ timestamp: Date.now(),
9801
+ payload: JSON.stringify(payload)
9802
+ });
9803
+ });
9804
+ }
9805
+
9806
+ // Durable-session reconnect boot path: once history is in place,
9807
+ // if the host passed a non-terminal resume handle (+ a reconnect transport),
9808
+ // immediately re-enter `resuming` and replay everything past the cursor into
9809
+ // the restored conversation. Fires AFTER hydration so the trailing partial
9810
+ // assistant bubble exists for the replay to append to.
9811
+ const maybeBootResume = () => {
9812
+ if (config.resume && typeof config.reconnectStream === "function") {
9813
+ session.resumeFromHandle(config.resume);
9814
+ }
9815
+ };
9816
+
9817
+ if (pendingStoredState) {
9818
+ pendingStoredState
9819
+ .then((state) => {
9820
+ if (!state) return;
9821
+ if (state.metadata) {
9822
+ persistentMetadata = ensureRecord(state.metadata);
9823
+ actionManager.syncFromMetadata();
9824
+ }
9825
+ if (state.messages?.length) {
9826
+ // Restored history must not read as a fresh send (scroll-on-send /
9827
+ // anchor-top would fire for the last restored user message).
9828
+ suppressScrollSend = true;
9829
+ try {
9830
+ session.hydrateMessages(state.messages);
9831
+ } finally {
9832
+ suppressScrollSend = false;
9833
+ }
9834
+ }
9835
+ if (state.artifacts?.length) {
9836
+ session.hydrateArtifacts(
9837
+ state.artifacts,
9838
+ state.selectedArtifactId ?? null
9839
+ );
9840
+ }
9841
+ })
9842
+ .catch((error) => {
9843
+ if (typeof console !== "undefined") {
9844
+ // eslint-disable-next-line no-console
9845
+ console.error("[AgentWidget] Failed to hydrate stored state:", error);
9846
+ }
9847
+ })
9848
+ .finally(() => {
9849
+ welcomeHydrated = true;
9850
+ resolveHistoryBootstrap();
9851
+ maybeBootResume();
9852
+ });
9853
+ } else {
9854
+ maybeBootResume();
9855
+ }
9856
+
9857
+ // Centralized so both the default composer (`handleSubmit`) and the plugin
9858
+ // composer (`renderComposer.onSubmit`) auto-expand the composer-bar wrapper
9859
+ // when a message is sent while the panel is collapsed. Without a single
9860
+ // helper the two submit paths drift over time.
9861
+ const maybeExpandComposerBar = () => {
9862
+ if (!isComposerBar()) return;
9863
+ if (open) return;
9864
+ const expandOnSubmit = config.launcher?.composerBar?.expandOnSubmit ?? true;
9865
+ if (!expandOnSubmit) return;
9866
+ setOpenState(true, "auto");
9867
+ };
9868
+
9869
+ // Combine `@`-chip mentions with an inline server-command's context bundle so
9870
+ // both reach the message in one `mentions` payload. Either side may be null.
9871
+ type SubmitMentions = NonNullable<
9872
+ ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
9873
+ >;
9874
+ const mergeSubmitMentions = (
9875
+ a: SubmitMentions | null,
9876
+ b: SubmitMentions | null
9877
+ ): SubmitMentions | null => {
9878
+ if (!a) return b;
9879
+ if (!b) return a;
9880
+ return {
9881
+ refs: [...a.refs, ...b.refs],
9882
+ // allSettled, not all: one side rejecting must not discard the other
9883
+ // side's already-resolved context (the bubble already echoed its chips).
9884
+ finalize: async () => {
9885
+ const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
9886
+ const fulfilled: MentionSubmitBundle[] = [];
9887
+ if (ra.status === "fulfilled") fulfilled.push(ra.value);
9888
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
9889
+ if (rb.status === "fulfilled") fulfilled.push(rb.value);
9890
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
9891
+ return mergeFinalizedMentions(fulfilled);
9892
+ },
9893
+ };
9894
+ };
9895
+
9896
+ // Inline-mode composer element: after the contenteditable swap, `textarea`
9897
+ // additionally exposes `getInlineMessageFields()` (built in the inline chunk,
9898
+ // which owns the document model — the core never imports it). Structural shape
9899
+ // only, so no runtime import crosses the bundle boundary.
9900
+ type InlineComposerFieldsHost = {
9901
+ getInlineMessageFields?: () => {
9902
+ content: string;
9903
+ contextMentions: AgentWidgetContextMentionRef[];
9904
+ contentSegments: AgentWidgetContentSegment[];
9905
+ };
9906
+ };
9907
+ // Read the ordered display segments from the live inline composer, but only
9908
+ // when at least one mention is present: a plain-text inline message must keep
9909
+ // the normal markdown-rendered bubble (segments bypass that path), so we return
9910
+ // undefined for pure prose and let the bubble render `content` as usual.
9911
+ const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
9912
+ const host = textarea as unknown as InlineComposerFieldsHost;
9913
+ const fields = host.getInlineMessageFields?.();
9914
+ if (!fields) return undefined;
9915
+ const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
9916
+ return hasMention ? fields.contentSegments : undefined;
9917
+ };
9918
+
9919
+ // Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
9920
+ // lazy chunk load + host resolve) before the composer clears, and
9921
+ // `isStreaming()` is still false in that window, so a second Enter would
9922
+ // otherwise dispatch the same text/command twice.
9923
+ let submitInFlight = false;
9924
+
9925
+ const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
9926
+ const value = textarea.value.trim();
9927
+ const hasAttachments = attachmentManager?.hasAttachments() ?? false;
9928
+
9929
+ // Inline slash command (Slack-style): every `command:"server"` plus any
9930
+ // arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
9931
+ // text to send, and an action sends nothing at all.
9932
+ const inline = value
9933
+ ? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
9934
+ : null;
9935
+
9936
+ if (inline?.kind === "action") {
7233
9937
  // Ran in the browser; nothing to send. Clear the composer + any chips.
7234
9938
  textarea.value = "";
7235
9939
  textarea.style.height = "auto";
@@ -8166,7 +10870,17 @@ export const createAgentExperience = (
8166
10870
  });
8167
10871
  footerResizeObserver.observe(footer);
8168
10872
  destroyCallbacks.push(() => footerResizeObserver.disconnect());
10873
+ // Rail/panel resolves against the host container, not the viewport.
10874
+ const historyHostObserver = new ResizeObserver(() => {
10875
+ syncHistoryPresentation();
10876
+ });
10877
+ historyHostObserver.observe(container);
10878
+ destroyCallbacks.push(() => historyHostObserver.disconnect());
8169
10879
  }
10880
+ ownerWindow.addEventListener("resize", syncHistoryPresentation);
10881
+ destroyCallbacks.push(() =>
10882
+ ownerWindow.removeEventListener("resize", syncHistoryPresentation)
10883
+ );
8170
10884
 
8171
10885
  lastScrollTop = body.scrollTop;
8172
10886
  let lastBottomOffset = getScrollBottomOffset(body);
@@ -8377,6 +11091,17 @@ export const createAgentExperience = (
8377
11091
  if (!clearChatButton) return;
8378
11092
 
8379
11093
  clearChatButton.addEventListener("click", () => {
11094
+ // With history available this affordance is "New conversation": a local
11095
+ // view clear would leave the server record open and unreachable.
11096
+ if (historyAvailable()) {
11097
+ void startNewConversation({ focusComposer: true }).catch((error) => {
11098
+ if (config.debug) {
11099
+ // eslint-disable-next-line no-console
11100
+ console.warn("[AgentWidget] New conversation failed:", error);
11101
+ }
11102
+ });
11103
+ return;
11104
+ }
8380
11105
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
8381
11106
  session.clearMessages();
8382
11107
  messageCache.clear();
@@ -8673,6 +11398,12 @@ export const createAgentExperience = (
8673
11398
  const previousToolCallDisplay = config.features?.toolCallDisplay;
8674
11399
  const previousReasoningDisplay = config.features?.reasoningDisplay;
8675
11400
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
11401
+ // History provider identity: enablement, eligibility, and advertised
11402
+ // scopes are all resolved at construction, so a change rebuilds it.
11403
+ const previousHistoryEnabled = config.features?.history?.enabled === true;
11404
+ const previousHistoryProvider = config.features?.history?.provider;
11405
+ const previousIdentityProof = config.getIdentityProof;
11406
+ const previousClientToken = config.clientToken;
8676
11407
  // One consistent recursive patch policy across the live controller and the
8677
11408
  // init handle. See utils/config-merge.ts for the replace-leaf list and
8678
11409
  // explicit-undefined reset semantics. The patch merges over the
@@ -8680,11 +11411,14 @@ export const createAgentExperience = (
8680
11411
  config = mergeConfigUpdate({ ...config, features: baseFeatures }, nextConfig);
8681
11412
  baseFeatures = config.features;
8682
11413
  config = resolveConfigPreferences(config);
11414
+ applyTooltipTiming();
8683
11415
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
8684
11416
  applyFullHeightStyles();
8685
11417
  applyThemeVariables(mount, config);
8686
11418
  applyArtifactLayoutCssVars(mount, config);
8687
11419
  applyArtifactPaneAppearance(mount, config);
11420
+ // Rail width/side are config-derived, so an open rail re-derives here too.
11421
+ applyRailChrome();
8688
11422
  syncArtifactPane();
8689
11423
 
8690
11424
  // Re-setup theme observer if colorScheme changed
@@ -8696,6 +11430,8 @@ export const createAgentExperience = (
8696
11430
  const newPlugins = pluginRegistry.getForInstance(config.plugins);
8697
11431
  plugins.length = 0;
8698
11432
  plugins.push(...newPlugins);
11433
+ // Bindings are config- and plugin-derived, so they re-derive after both.
11434
+ syncShortcuts();
8699
11435
 
8700
11436
  launcherEnabled = config.launcher?.enabled ?? true;
8701
11437
  autoExpand = config.launcher?.autoExpand ?? false;
@@ -8802,10 +11538,10 @@ export const createAgentExperience = (
8802
11538
  }
8803
11539
  // Note: Custom launcher updates are handled by the plugin's own logic
8804
11540
 
8805
- // Update panel header title and subtitle
8806
- if (headerTitle && config.launcher?.title !== undefined) {
8807
- headerTitle.textContent = config.launcher.title;
8808
- }
11541
+ // Update panel header title and subtitle. applyHeaderTitle respects the
11542
+ // titleSource binding, so a static launcher.title update never clobbers
11543
+ // a bound conversation title (and a titleSource flip applies live).
11544
+ applyHeaderTitle();
8809
11545
  if (headerSubtitle && config.launcher?.subtitle !== undefined) {
8810
11546
  headerSubtitle.textContent = config.launcher.subtitle;
8811
11547
  }
@@ -8838,6 +11574,8 @@ export const createAgentExperience = (
8838
11574
  closeButton = view.header.closeButton;
8839
11575
 
8840
11576
  prevHeaderLayout = headerLayoutConfig?.layout;
11577
+ // The rebuilt title span starts static; restamp the bound title.
11578
+ applyHeaderTitle();
8841
11579
  } else if (headerLayoutConfig) {
8842
11580
  // Apply visibility settings without rebuilding
8843
11581
  if (iconHolder) {
@@ -8965,7 +11703,7 @@ export const createAgentExperience = (
8965
11703
  const headerIconSize = launcher.headerIconSize ?? "48px";
8966
11704
 
8967
11705
  if (iconHolder) {
8968
- const headerEl = container.querySelector(".persona-border-b-persona-divider");
11706
+ const headerEl = header;
8969
11707
  const headerCopy = headerEl?.querySelector(".persona-flex-col");
8970
11708
 
8971
11709
  // Handle hide/show
@@ -9061,10 +11799,17 @@ export const createAgentExperience = (
9061
11799
  isPanelToggleable() && config.layout?.header?.showCloseButton !== false;
9062
11800
  closeButton.style.display = layoutShowCloseButton ? "" : "none";
9063
11801
 
9064
- const closeButtonSize = launcher.closeButtonSize ?? "32px";
11802
+ // Composer-bar mode owns its close sizing, same as clear chat below.
11803
+ // Elsewhere, unset clears the inline box so the header control-size
11804
+ // token wins; writing a fallback here would pin it past the token.
11805
+ if (!isComposerBar()) {
11806
+ const closeButtonSize = launcher.closeButtonSize ?? "";
11807
+ closeButton.style.height = closeButtonSize;
11808
+ closeButton.style.width = closeButtonSize;
11809
+ closeButton.style.minWidth = closeButtonSize;
11810
+ closeButton.style.minHeight = closeButtonSize;
11811
+ }
9065
11812
  const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
9066
- closeButton.style.height = closeButtonSize;
9067
- closeButton.style.width = closeButtonSize;
9068
11813
 
9069
11814
  // Update placement if changed - move the wrapper (not just the button) to preserve tooltip
9070
11815
  const { closeButtonWrapper } = panelElements;
@@ -9078,15 +11823,16 @@ export const createAgentExperience = (
9078
11823
  // Update wrapper classes
9079
11824
  if (isTopRight) {
9080
11825
  closeButtonWrapper.className = "persona-absolute persona-top-4 persona-right-4 persona-z-50";
9081
- container.style.position = "relative";
9082
- container.appendChild(closeButtonWrapper);
11826
+ // A mounted rail owns the conversation column; anchoring to the
11827
+ // container would float the button over the rail.
11828
+ const anchor = railColumn ?? container;
11829
+ anchor.style.position = "relative";
11830
+ anchor.appendChild(closeButtonWrapper);
9083
11831
  } else {
9084
11832
  // Check if clear chat is inline to determine if we need ml-auto
9085
11833
  const clearChatPlacement = launcher.clearChat?.placement ?? "inline";
9086
11834
  const clearChatEnabled = launcher.clearChat?.enabled ?? true;
9087
11835
  closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "persona-ml-auto";
9088
- // Find header element
9089
- const header = container.querySelector(".persona-border-b-persona-divider");
9090
11836
  if (header) {
9091
11837
  header.appendChild(closeButtonWrapper);
9092
11838
  }
@@ -9140,23 +11886,16 @@ export const createAgentExperience = (
9140
11886
  closeButton.style.paddingBottom = "";
9141
11887
  }
9142
11888
 
9143
- // Update icon
9144
- const closeButtonIconName = launcher.closeButtonIconName ?? "x";
9145
- const closeButtonIconText = launcher.closeButtonIconText ?? "×";
9146
-
9147
- // Clear existing content and render new icon.
9148
- // Larger intrinsic size compensates for the X glyph's sparse
9149
- // viewBox so the close button visually matches sibling icons.
9150
- closeButton.innerHTML = "";
9151
- const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
9152
- if (iconSvg) {
9153
- // display:block matches the builder; inline SVG baseline spacing
9154
- // shifts the icon off-center.
9155
- iconSvg.style.display = "block";
9156
- closeButton.appendChild(iconSvg);
9157
- } else {
9158
- closeButton.textContent = closeButtonIconText;
9159
- }
11889
+ // Update icon through the shared renderer so a restyle can never
11890
+ // repaint the glyph at a different size or stroke than the builder.
11891
+ applyHeaderControlGlyph(
11892
+ closeButton,
11893
+ launcher.closeButtonIconName ?? "x",
11894
+ {
11895
+ iconSize: isComposerBar() ? COMPOSER_BAR_CLOSE_ICON_SIZE : undefined,
11896
+ iconText: launcher.closeButtonIconText ?? "×",
11897
+ }
11898
+ );
9160
11899
 
9161
11900
  // Update tooltip
9162
11901
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
@@ -9225,14 +11964,14 @@ export const createAgentExperience = (
9225
11964
  // Position to the left of the close button (which is at right: 1rem/16px)
9226
11965
  // Close button is ~32px wide, plus small gap = 48px from right
9227
11966
  clearChatButtonWrapper.style.right = "48px";
9228
- container.style.position = "relative";
9229
- container.appendChild(clearChatButtonWrapper);
11967
+ const anchor = railColumn ?? container;
11968
+ anchor.style.position = "relative";
11969
+ anchor.appendChild(clearChatButtonWrapper);
9230
11970
  } else {
9231
11971
  clearChatButtonWrapper.className = "persona-relative persona-ml-auto persona-clear-chat-button-wrapper";
9232
11972
  // Clear the inline right style when switching back to inline mode
9233
11973
  clearChatButtonWrapper.style.right = "";
9234
11974
  // Find header and insert before close button
9235
- const header = container.querySelector(".persona-border-b-persona-divider");
9236
11975
  const closeButtonWrapperEl = panelElements.closeButtonWrapper;
9237
11976
  if (header && closeButtonWrapperEl && closeButtonWrapperEl.parentElement === header) {
9238
11977
  header.insertBefore(clearChatButtonWrapper, closeButtonWrapperEl);
@@ -9256,35 +11995,32 @@ export const createAgentExperience = (
9256
11995
  }
9257
11996
 
9258
11997
  if (shouldShowClearChat) {
9259
- // Update size: composer-bar mode owns its sizing (16px to match
9260
- // the close icon), so leave size alone there. Floating-launcher
9261
- // and other modes still honor `launcher.clearChat.size`.
11998
+ // Update size: composer-bar mode owns its sizing, so leave size
11999
+ // alone there. Elsewhere, unset clears the inline box so the header
12000
+ // control-size token wins.
9262
12001
  if (!isComposerBar()) {
9263
- const clearChatSize = clearChatConfig.size ?? "32px";
12002
+ const clearChatSize = clearChatConfig.size ?? "";
9264
12003
  clearChatButton.style.height = clearChatSize;
9265
12004
  clearChatButton.style.width = clearChatSize;
12005
+ clearChatButton.style.minWidth = clearChatSize;
12006
+ clearChatButton.style.minHeight = clearChatSize;
9266
12007
  }
9267
12008
 
9268
- // Update icon
9269
- const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
9270
12009
  const clearChatIconColor = clearChatConfig.iconColor ?? "";
9271
-
9272
12010
  clearChatButton.style.color =
9273
12011
  clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
9274
12012
 
9275
- // Clear existing icon and render new one. Composer-bar shrinks
9276
- // the icon to match its 16px button.
9277
- clearChatButton.innerHTML = "";
9278
- const clearChatIconSize = isComposerBar() ? "14px" : "20px";
9279
- // Stroke 1 matches the mount-time builder (header-parts.ts); a
9280
- // different weight here makes the icon visibly bolden on update.
9281
- const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 1);
9282
- if (iconSvg) {
9283
- // display:block matches the builder; inline SVG baseline spacing
9284
- // shifts the icon off-center.
9285
- iconSvg.style.display = "block";
9286
- clearChatButton.appendChild(iconSvg);
9287
- }
12013
+ // Same shared renderer as the builder, so a restyle can never
12014
+ // repaint the glyph at a different size or stroke.
12015
+ applyHeaderControlGlyph(
12016
+ clearChatButton,
12017
+ clearChatConfig.iconName ?? "refresh-cw",
12018
+ {
12019
+ iconSize: isComposerBar()
12020
+ ? COMPOSER_BAR_CLEAR_CHAT_ICON_SIZE
12021
+ : undefined,
12022
+ }
12023
+ );
9288
12024
 
9289
12025
  // Update background color
9290
12026
  if (clearChatConfig.backgroundColor) {
@@ -9368,7 +12104,22 @@ export const createAgentExperience = (
9368
12104
  });
9369
12105
 
9370
12106
  postprocess = buildPostprocessor(config, actionManager, handleResubmitRequested);
12107
+ // Re-key the visitor store before any client rebuild sees the new config.
12108
+ syncVisitorStore();
9371
12109
  session.updateConfig(config);
12110
+ if (
12111
+ (config.features?.history?.enabled === true) !== previousHistoryEnabled ||
12112
+ config.features?.history?.provider !== previousHistoryProvider ||
12113
+ config.getIdentityProof !== previousIdentityProof ||
12114
+ config.clientToken !== previousClientToken
12115
+ ) {
12116
+ closeHistory({ restoreFocus: false });
12117
+ installHistoryProvider();
12118
+ }
12119
+ syncHistoryChromeImpl();
12120
+ // Presentation may have flipped between panel and rail; the open view
12121
+ // instance moves hosts rather than being recreated.
12122
+ syncHistoryPresentation();
9372
12123
  renderMessagesWithPlugins(
9373
12124
  messagesWrapper,
9374
12125
  session.getMessages(),
@@ -9880,6 +12631,9 @@ export const createAgentExperience = (
9880
12631
  : statusIndicatorConfig.align === "center" ? "persona-text-center"
9881
12632
  : "persona-text-right";
9882
12633
  statusText.classList.add(alignClass);
12634
+ // Last: this pass rebuilds the header and rewrites header/footer
12635
+ // visibility, any of which would expose an open panel host's chrome.
12636
+ reapplyHistoryHostChrome();
9883
12637
  },
9884
12638
  open() {
9885
12639
  if (!isPanelToggleable()) return;
@@ -10116,6 +12870,62 @@ export const createAgentExperience = (
10116
12870
  isEventStreamVisible(): boolean {
10117
12871
  return eventStreamVisible;
10118
12872
  },
12873
+ listConversations(opts) {
12874
+ return session.listConversations(opts);
12875
+ },
12876
+ getActiveConversationId(): string | null {
12877
+ return session.getActiveConversationId();
12878
+ },
12879
+ openConversation(
12880
+ conversationId: string,
12881
+ opts?: { focus?: boolean }
12882
+ ): Promise<void> {
12883
+ return openHistoryConversation(conversationId, {
12884
+ focusComposer: opts?.focus === true,
12885
+ });
12886
+ },
12887
+ startNewConversation(opts?: { focus?: boolean }): Promise<void> {
12888
+ return startNewConversation({ focusComposer: opts?.focus === true });
12889
+ },
12890
+ deleteConversation(conversationId: string): Promise<void> {
12891
+ return deleteHistoryConversation(conversationId);
12892
+ },
12893
+ renameConversation(conversationId, title) {
12894
+ return updateHistoryConversation(conversationId, { title });
12895
+ },
12896
+ setConversationStarred(conversationId, starred) {
12897
+ return updateHistoryConversation(conversationId, { starred });
12898
+ },
12899
+ clearConversationHistory(opts) {
12900
+ return clearConversationHistory(opts);
12901
+ },
12902
+ resetHistoryIdentity(): Promise<{ remoteRevocationConfirmed: boolean }> {
12903
+ return resetHistoryIdentity();
12904
+ },
12905
+ getHistoryIdentityStatus(): HistoryIdentityStatus {
12906
+ if (!historyProvider) {
12907
+ return historyFeatureEnabled()
12908
+ ? { state: "unavailable", reason: "ineligible_mode" }
12909
+ : { state: "unavailable", reason: "history_disabled" };
12910
+ }
12911
+ return historyProvider.getIdentityStatus();
12912
+ },
12913
+ showHistory(opts): Promise<void> {
12914
+ return openHistory({
12915
+ ...(opts?.returnSurface ? { returnSurface: opts.returnSurface } : {}),
12916
+ ...(opts?.focus !== undefined ? { focus: opts.focus } : {}),
12917
+ });
12918
+ },
12919
+ hideHistory(opts): void {
12920
+ closeHistory(
12921
+ opts?.restoreFocus !== undefined
12922
+ ? { restoreFocus: opts.restoreFocus }
12923
+ : undefined
12924
+ );
12925
+ },
12926
+ isHistoryVisible(): boolean {
12927
+ return historyVisible;
12928
+ },
10119
12929
  showArtifacts(): void {
10120
12930
  if (!artifactsSidebarEnabled(config)) return;
10121
12931
  artifactsPaneUserHidden = false;
@@ -10413,6 +13223,27 @@ export const createAgentExperience = (
10413
13223
  });
10414
13224
  }
10415
13225
 
13226
+ // Show/hide only. Identity status stays on the instance-scoped controller
13227
+ // bus so authentication state is never broadcast page-wide.
13228
+ const handleShowHistory = (e: Event) => {
13229
+ const detail = (e as CustomEvent).detail;
13230
+ if (!detail?.instanceId || detail.instanceId === instanceId) {
13231
+ void controller.showHistory();
13232
+ }
13233
+ };
13234
+ const handleHideHistory = (e: Event) => {
13235
+ const detail = (e as CustomEvent).detail;
13236
+ if (!detail?.instanceId || detail.instanceId === instanceId) {
13237
+ controller.hideHistory();
13238
+ }
13239
+ };
13240
+ window.addEventListener("persona:showHistory", handleShowHistory);
13241
+ window.addEventListener("persona:hideHistory", handleHideHistory);
13242
+ destroyCallbacks.push(() => {
13243
+ window.removeEventListener("persona:showHistory", handleShowHistory);
13244
+ window.removeEventListener("persona:hideHistory", handleHideHistory);
13245
+ });
13246
+
10416
13247
  const handleShowArtifacts = (e: Event) => {
10417
13248
  const detail = (e as CustomEvent).detail;
10418
13249
  if (!detail?.instanceId || detail.instanceId === instanceId) {