@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
@@ -0,0 +1,1891 @@
1
+ /**
2
+ * History ("Messages") navigation view — the default visitor-facing surface
3
+ * described in `docs/visitor-history-implementation-plan.md` D7.
4
+ *
5
+ * Scope split: this file owns the VIEW (list, grouping, paging, list-region
6
+ * states, row/menu affordances, its own announcements and styles). The shell
7
+ * owns placement, open/close, inertness of the obscured conversation, focus
8
+ * orchestration between surfaces, confirmations, and every session mutation —
9
+ * the view only invokes the callbacks below and awaits their promises.
10
+ *
11
+ * Never statically imported on the core path: the IIFE build marks
12
+ * `@runtypelabs/persona/history-view` external and `build:client` marks the same
13
+ * subpath external for ESM/CJS. The core reaches it through
14
+ * `history-view-loader.ts`. Guarded by `history-view-bundle.test.ts`.
15
+ */
16
+
17
+ import { injectStyles } from "../plugin-kit";
18
+ import { createNode, cx } from "../utils/dom";
19
+ import type { TooltipHandle, TooltipOptions } from "../utils/tooltip";
20
+ import { createHistoryAnnouncer } from "./history-view/announcer";
21
+ import { HISTORY_VIEW_CSS } from "./history-view/css";
22
+ import {
23
+ resolveHistoryViewCopy,
24
+ type HistoryViewCopyInput,
25
+ type ResolvedHistoryViewCopy,
26
+ } from "./history-view/copy";
27
+ import {
28
+ groupConversations,
29
+ type HistoryGroupingMode,
30
+ } from "./history-view/grouping";
31
+ import { historyIcon } from "./history-view/icons";
32
+ import {
33
+ buildConversationRow,
34
+ buildMenuTrigger,
35
+ buildOverflowMenu,
36
+ menuItemFocusKey,
37
+ type HistoryMenuItemSpec,
38
+ type RowPending,
39
+ } from "./history-view/rows";
40
+ import {
41
+ buildStateBlock,
42
+ toListState,
43
+ type HistoryListState,
44
+ } from "./history-view/state";
45
+ import {
46
+ isHistoryProviderError,
47
+ type HistoryConversationSummary,
48
+ type HistoryOperationContext,
49
+ type HistoryProvider,
50
+ } from "../internal/history-provider";
51
+ import type {
52
+ AgentWidgetHistoryListAction,
53
+ AgentWidgetHistoryRenderActions,
54
+ HistoryIdentityStatus,
55
+ } from "../types";
56
+
57
+ export type HistoryViewPresentation = "panel" | "rail";
58
+
59
+ /**
60
+ * Where the top bar's contents live. `"external"` hands them to the shell,
61
+ * which mounts them inside its own persistent header; the view element then
62
+ * starts at the body.
63
+ */
64
+ export type HistoryHeaderPlacement = "inline" | "external";
65
+
66
+ export type HistoryViewPendingAction =
67
+ | { kind: "refresh" | "load-more" | "start-new" | "clear" | "reset" }
68
+ | { kind: "open" | "delete"; conversationId: string }
69
+ | null;
70
+
71
+ /**
72
+ * Slot seams for the core arbitration layer (`history-render.ts`). The public
73
+ * plugin contexts are built there: the view only supplies the slot's data and
74
+ * its own default DOM, and never sees a plugin.
75
+ */
76
+ export interface HistoryHeaderSlotContext {
77
+ identityStatus: HistoryIdentityStatus;
78
+ pendingAction: HistoryViewPendingAction;
79
+ copy: ResolvedHistoryViewCopy;
80
+ defaultRenderer: () => HTMLElement;
81
+ }
82
+
83
+ export interface HistoryConversationSlotContext {
84
+ conversation: HistoryConversationSummary;
85
+ active: boolean;
86
+ pending: RowPending;
87
+ open: () => Promise<void>;
88
+ requestDelete: () => Promise<"deleted" | "cancelled">;
89
+ defaultRenderer: () => HTMLElement;
90
+ }
91
+
92
+ export interface HistoryStateSlotContext {
93
+ state: Exclude<HistoryListState, { kind: "ready" }>;
94
+ identityStatus: HistoryIdentityStatus;
95
+ copy: ResolvedHistoryViewCopy;
96
+ retry?: () => Promise<void>;
97
+ startNewConversation?: () => Promise<void>;
98
+ defaultRenderer: () => HTMLElement;
99
+ }
100
+
101
+ export interface HistoryViewSlotRenderers {
102
+ header?: (context: HistoryHeaderSlotContext) => HTMLElement | null;
103
+ conversation?: (
104
+ context: HistoryConversationSlotContext
105
+ ) => HTMLElement | null;
106
+ state?: (context: HistoryStateSlotContext) => HTMLElement | null;
107
+ }
108
+
109
+ /** Snapshot source for the core arbitration layer. Read-only by contract. */
110
+ export interface HistoryViewModel {
111
+ conversations: readonly HistoryConversationSummary[];
112
+ activeConversationId: string | null;
113
+ state: HistoryListState;
114
+ pendingAction: HistoryViewPendingAction;
115
+ identityStatus: HistoryIdentityStatus;
116
+ nextCursor: string | null;
117
+ }
118
+
119
+ /**
120
+ * The operations the default DOM invokes. Shaped as the public render actions
121
+ * minus `close` (the shell owns that) so the core can forward them verbatim.
122
+ */
123
+ export type HistoryViewOperations = Omit<
124
+ AgentWidgetHistoryRenderActions,
125
+ "close"
126
+ >;
127
+
128
+ /** Where a rail section sits relative to the built-in conversation list. */
129
+ export type HistoryRailSectionPlacement =
130
+ | "above-conversations"
131
+ | "below-conversations"
132
+ | "footer";
133
+
134
+ /** Core-normalized rail nav item: one label, one pre-resolved icon, one action. */
135
+ export interface HistoryRailSectionItem {
136
+ id: string;
137
+ label: string;
138
+ badge?: string;
139
+ /** Memoized by the core, so a presentation flip reuses the same node. */
140
+ iconNode?: () => Element | null;
141
+ /** Already guarded by the core; this chunk calls it bare. */
142
+ onSelect: () => void;
143
+ }
144
+
145
+ export interface HistoryRailSection {
146
+ id: string;
147
+ title?: string;
148
+ placement: HistoryRailSectionPlacement;
149
+ items: HistoryRailSectionItem[];
150
+ /**
151
+ * Plugin-backed body, in place of `items`. Re-invoked with each new collapsed
152
+ * value; null empties and hides the section, as does a throw after one warn.
153
+ */
154
+ render?: (collapsed: boolean) => Element | null;
155
+ }
156
+
157
+ export interface HistoryViewOptions {
158
+ /** Internal seam (D9). The view never touches `AgentWidgetClient`. */
159
+ provider: HistoryProvider;
160
+ /** Resolved once when history opens; reused by every operation. */
161
+ context: HistoryOperationContext;
162
+ /** Canonical active target filter. `null` lists the whole authorized scope. */
163
+ targetId: string | null;
164
+ /** Host overrides; defaults are applied here, so passing a partial is fine. */
165
+ copy?: HistoryViewCopyInput;
166
+ /** Already resolved against the host container width by the shell, not here. */
167
+ presentation: HistoryViewPresentation;
168
+ /**
169
+ * Rail only. Default true: the bar's leading control is a collapse toggle
170
+ * instead of a close (x), and `onToggleCollapse` drives it.
171
+ */
172
+ collapsible?: boolean;
173
+ /** Rail only. Initial collapsed state; the shell owns and persists it. */
174
+ collapsed?: boolean;
175
+ /**
176
+ * Rail only. The edge the rail docks to, so the bar can put its collapse
177
+ * toggle on the inner edge facing the conversation. Default `"left"`.
178
+ */
179
+ railSide?: "left" | "right";
180
+ /**
181
+ * Rail only. Host-rendered identity for the bar's heading area. The `h2`
182
+ * stays in the DOM as sr-only, so the region keeps its accessible name.
183
+ */
184
+ renderRailHeader?: (context: {
185
+ collapsed: boolean;
186
+ defaultTitle: string;
187
+ }) => Element | null;
188
+ /**
189
+ * Rail only. Core-resolved brand mark for both identity spots: the heading
190
+ * default composition (`false`) and the collapsed toggle's rest face
191
+ * (`true`). Null means no brand for that spot. Outranked by
192
+ * `renderRailHeader` in the heading.
193
+ */
194
+ railBrand?: (collapsed: boolean) => Element | null;
195
+ /**
196
+ * Rail only. Host navigation sections stacked around the conversation list.
197
+ * Normalized by the core, which also resolves each item's icon precedence
198
+ * into `iconNode` so this chunk never imports the lucide registry.
199
+ */
200
+ railSections?: HistoryRailSection[];
201
+ /**
202
+ * Rail only. Pre-formatted collapse shortcut: `hint` is the tooltip chip,
203
+ * `aria` the `aria-keyshortcuts` value. The shell owns the binding and both
204
+ * strings, so this chunk never bundles the shortcut module.
205
+ */
206
+ collapseShortcut?: { hint: string; aria: string };
207
+ /** Default `"inline"`. Rail is always inline; the shell enforces that. */
208
+ headerPlacement?: HistoryHeaderPlacement;
209
+ showScopeStatus: boolean;
210
+ /**
211
+ * Per-row delete via the row overflow menu. Default true; false drops the
212
+ * trigger (delete is its only item). Hides the control only — custom row
213
+ * slots still receive `requestDelete`.
214
+ */
215
+ showDelete?: boolean;
216
+ /** The list overflow menu's clear-history item. Default true. */
217
+ showDeleteAll?: boolean;
218
+ /** Host items in the list overflow menu, above the built-in destructive ones. */
219
+ listActions?: AgentWidgetHistoryListAction[];
220
+ /**
221
+ * Leading row avatar: an image URL, a glyph, or `false` to omit the block.
222
+ * Absent falls back to the same glyph the header uses.
223
+ */
224
+ rowAvatar?: string | false;
225
+ activeConversationId: string | null;
226
+ /**
227
+ * Date bucketing of the list. Default `"time"`; `"none"` folds the time
228
+ * buckets into one flat group. Starred rows keep their pinned group either
229
+ * way.
230
+ */
231
+ grouping?: HistoryGroupingMode;
232
+ /** List page size. Default 25. */
233
+ pageSize?: number;
234
+ /** Clock seam for grouping and relative time. */
235
+ now?: () => number;
236
+
237
+ /** Row selection. The shell commits the open; the view shows row pending. */
238
+ onSelect: (conversationId: string) => void | Promise<void>;
239
+ onStartNew: () => void | Promise<void>;
240
+ /** Back (panel) or close (rail without a collapse toggle). */
241
+ onClose: () => void;
242
+ /** Collapse toggle. The shell decides, then calls `setCollapsed`. */
243
+ onToggleCollapse?: () => void;
244
+ /** Shell owns the confirmation; resolves with the outcome. */
245
+ onRequestDeleteConversation: (
246
+ conversationId: string
247
+ ) => Promise<"deleted" | "cancelled">;
248
+ onRequestClearHistory: () => Promise<"cleared" | "cancelled">;
249
+ /** Rendered only when the provider exposes `resetDevice`. */
250
+ onRequestResetIdentity?: () => Promise<
251
+ { outcome: "cancelled" } | { outcome: "reset"; remoteRevocationConfirmed: boolean }
252
+ >;
253
+
254
+ /** Core-owned plugin slots. Absent means the default DOM everywhere. */
255
+ slots?: HistoryViewSlotRenderers;
256
+ /**
257
+ * Start with DOM rendering suspended so the core can arbitrate a full-view
258
+ * hook before any slot runs. Default true.
259
+ */
260
+ renderDom?: boolean;
261
+ /** Model-change signal, emitted only while DOM rendering is suspended. */
262
+ onModelChange?: () => void;
263
+ /**
264
+ * Fired whenever the ACTIVE conversation's list summary changes: on
265
+ * selection, on a list refresh that delivers a server-generated title, on a
266
+ * rename/star patch, and with `null` when no active conversation is listed.
267
+ * Feeds the shell's `layout.header.titleSource: "conversation"` binding and
268
+ * its built-in title-menu star toggle.
269
+ */
270
+ onActiveConversationChange?: (
271
+ summary: HistoryConversationSummary | null
272
+ ) => void;
273
+ /** Shell live region, used while this view's own one is detached. */
274
+ onAnnounce?: (message: string) => void;
275
+ /**
276
+ * The shell's tooltip attacher, passed in so the size-capped chunk never
277
+ * bundles a second copy of the tooltip module. Absent means no tooltips.
278
+ */
279
+ attachTooltip?: (options: TooltipOptions) => TooltipHandle;
280
+ }
281
+
282
+ export interface HistoryViewHandle {
283
+ element: HTMLElement;
284
+ /** Resolved copy, defaults included. The defaults live in this chunk. */
285
+ copy: ResolvedHistoryViewCopy;
286
+ /** Immutable-by-contract model snapshot for the core arbitration layer. */
287
+ getModel(): HistoryViewModel;
288
+ /** The same operations the default DOM invokes, for public render hooks. */
289
+ operations: HistoryViewOperations;
290
+ /** Suspend/resume this view's DOM while a plugin owns the full surface. */
291
+ setDomRenderEnabled(enabled: boolean): void;
292
+ /** Re-fetch and re-render the list in place, preserving list/focus state. */
293
+ refresh(): void;
294
+ /**
295
+ * Retarget the view when the shell moves it between hosts (rail <-> panel).
296
+ * The instance and all its state survive the move; only chrome changes.
297
+ */
298
+ setPresentation(presentation: HistoryViewPresentation): void;
299
+ /**
300
+ * Collapse the rail to its icon column. The view only styles itself and
301
+ * relabels the toggle; the shell owns the host width and the persisted state.
302
+ */
303
+ setCollapsed(collapsed: boolean): void;
304
+ /** Re-mirror the bar for a live `rail.side` change. */
305
+ setRailSide(side: "left" | "right"): void;
306
+ /**
307
+ * The top bar's CURRENT content node (the default bar or a slot replacement).
308
+ * A slot re-render swaps it in place, so an external host stays its parent.
309
+ */
310
+ getHeaderElement(): HTMLElement;
311
+ /**
312
+ * Hand the bar's contents to the shell (`"external"`, detached for the shell
313
+ * to re-home) or take them back (`"inline"`). Idempotent; never replays the
314
+ * entrance.
315
+ */
316
+ setHeaderPlacement(placement: HistoryHeaderPlacement): void;
317
+ /** Keep the active-row indicator in sync when the shell changes conversation. */
318
+ setActiveConversationId(conversationId: string | null): void;
319
+ /** Patch one listed summary in place after a shell-side rename/star. */
320
+ applyConversationSummary(summary: HistoryConversationSummary): void;
321
+ /** Drop one listed row after a shell-side delete (headless or built-in). */
322
+ removeConversationSummary(conversationId: string): void;
323
+ /** Enter/leave the post-deletion replacement-init recovery state. */
324
+ setNewConversationRequired(required: boolean): void;
325
+ /**
326
+ * Mirrored exit for the shell's close sequence. Resolves once the surface has
327
+ * visually left; never rejects, and repeat calls join the running exit.
328
+ * `null` means nothing to wait for (motion off, or no WAAPI): the caller must
329
+ * tear down synchronously rather than deferring by a microtask.
330
+ */
331
+ playExit(): Promise<void> | null;
332
+ destroy(): void;
333
+ }
334
+
335
+ const DEFAULT_PAGE_SIZE = 25;
336
+
337
+ /**
338
+ * Open-menu id for the list-level overflow, so it shares the row menu's
339
+ * open/close, outside-click and Escape machinery. Never a conversation id.
340
+ */
341
+ const LIST_MENU_ID = "persona:list-options";
342
+
343
+ /** Motion. The entrance is CSS (`css.ts`); the exit below mirrors it. */
344
+ const ENTRANCE_MS = 180;
345
+ /** Arrival of the shell-hosted bar; the bar chrome around it never moves. */
346
+ const SHELL_FADE_MS = 120;
347
+ const EXIT_MS = 160;
348
+ const EXIT_EASING = "cubic-bezier(0.4, 0, 1, 1)";
349
+ const EXIT_SLIDE_PX: Record<HistoryViewPresentation, number> = {
350
+ panel: 20,
351
+ rail: 12,
352
+ };
353
+
354
+ const prefersReducedMotion = (): boolean =>
355
+ typeof window !== "undefined" &&
356
+ typeof window.matchMedia === "function" &&
357
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
358
+
359
+ /** A cancelled animation resolves like a finished one: the shell must proceed. */
360
+ const settled = (animation: Animation): Promise<void> =>
361
+ animation.finished
362
+ ? animation.finished.then(
363
+ () => undefined,
364
+ () => undefined
365
+ )
366
+ : Promise.resolve();
367
+
368
+ function identityKey(status: HistoryIdentityStatus): string {
369
+ return `${status.state}:${"reason" in status ? status.reason : ""}`;
370
+ }
371
+
372
+ function scopeCopy(
373
+ status: HistoryIdentityStatus,
374
+ copy: ResolvedHistoryViewCopy
375
+ ): { title: string; description: string; pending: boolean } {
376
+ switch (status.state) {
377
+ case "verified":
378
+ return {
379
+ title: copy.verifiedTitle,
380
+ description: copy.verifiedDescription,
381
+ pending: false,
382
+ };
383
+ case "verifying":
384
+ case "resetting":
385
+ return {
386
+ title: copy.verifyingTitle,
387
+ description: copy.verifyingDescription,
388
+ pending: true,
389
+ };
390
+ case "authentication_required":
391
+ return {
392
+ title: copy.authenticationRequiredTitle,
393
+ description: copy.authenticationRequiredDescription,
394
+ pending: false,
395
+ };
396
+ case "identity_provider_failed":
397
+ return {
398
+ title: copy.identityProviderFailedTitle,
399
+ description: copy.identityProviderFailedDescription,
400
+ pending: false,
401
+ };
402
+ case "configuration_error":
403
+ return {
404
+ title: copy.proofNotAdmittedTitle,
405
+ description: copy.proofNotAdmittedDescription,
406
+ pending: false,
407
+ };
408
+ case "unavailable":
409
+ return {
410
+ title: copy.unavailableTitle,
411
+ description: copy.unavailableDescription,
412
+ pending: false,
413
+ };
414
+ default:
415
+ return {
416
+ title: copy.browserOnlyTitle,
417
+ description: copy.browserOnlyDescription,
418
+ pending: false,
419
+ };
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Ambient states are stable information, not a call to action: the title alone
425
+ * shows as a quiet subtitle and the sentence attaches via `aria-describedby`.
426
+ * Everything else is actionable and keeps the visible block.
427
+ */
428
+ function scopeAmbient(status: HistoryIdentityStatus): boolean {
429
+ return status.state === "verified" || status.state === "browser_only";
430
+ }
431
+
432
+ /** Identity states whose failure copy offers a retry. */
433
+ function identityRetryable(status: HistoryIdentityStatus): boolean {
434
+ return (
435
+ status.state === "authentication_required" ||
436
+ status.state === "identity_provider_failed" ||
437
+ status.state === "configuration_error"
438
+ );
439
+ }
440
+
441
+ export function createHistoryView(
442
+ options: HistoryViewOptions
443
+ ): HistoryViewHandle {
444
+ const copy = resolveHistoryViewCopy(options.copy);
445
+ const now = options.now ?? (() => Date.now());
446
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
447
+ const headingId = `persona-history-title-${Math.random().toString(36).slice(2, 8)}`;
448
+
449
+ let items: HistoryConversationSummary[] = [];
450
+ let nextCursor: string | null = null;
451
+ let listState: HistoryListState = { kind: "loading", phase: "initial" };
452
+ let pending: HistoryViewPendingAction = null;
453
+ let activeConversationId = options.activeConversationId;
454
+ let identityStatus = options.provider.getIdentityStatus();
455
+ let renderedIdentityKey = identityKey(identityStatus);
456
+ let openMenuId: string | null = null;
457
+ let focusMenuOnRender = false;
458
+ let destroyed = false;
459
+ let listEpoch = 0;
460
+ let presentation = options.presentation;
461
+ const collapsible = options.collapsible !== false;
462
+ let collapsed = options.collapsed === true;
463
+ let railRight = options.railSide === "right";
464
+ let domRenderEnabled = options.renderDom !== false;
465
+ const rowErrors = new Map<string, { message: string; retry: () => void }>();
466
+ let actionError: { message: string; retry: () => void } | null = null;
467
+
468
+ const announcer = createHistoryAnnouncer();
469
+
470
+ /** A suspended view is detached: its live region cannot speak, the shell's can. */
471
+ const announce = (message: string): void => {
472
+ if (!domRenderEnabled && options.onAnnounce) options.onAnnounce(message);
473
+ else announcer.announce(message);
474
+ };
475
+
476
+ const busy = (): boolean => pending !== null;
477
+
478
+ // --- static chrome (built once; never moves between list states) ---------
479
+
480
+ const bodyId = `${headingId}-body`;
481
+
482
+ const backButton = createNode("button", {
483
+ className: "persona-history-icon-button persona-history-back",
484
+ attrs: { type: "button" },
485
+ });
486
+
487
+ /** Only a collapsible rail turns the leading control into a toggle. */
488
+ const collapseToggle = (): boolean => presentation === "rail" && collapsible;
489
+
490
+ /** Built once: the ctx of this face is always the collapsed rail. */
491
+ let toggleFace: Element | null | undefined;
492
+
493
+ /**
494
+ * Collapsed rest face for the toggle, stacked with the glyph the CSS reveals
495
+ * on hover and keyboard focus. Panel drops it; a move back re-appends it.
496
+ */
497
+ const syncToggleBrand = (): void => {
498
+ if (!options.railBrand) return;
499
+ if (!collapseToggle()) {
500
+ backButton.classList.remove("persona-history-back--branded");
501
+ return;
502
+ }
503
+ if (toggleFace === undefined) {
504
+ const mark = options.railBrand(true);
505
+ toggleFace = mark
506
+ ? createNode(
507
+ "span",
508
+ {
509
+ className:
510
+ "persona-history-brand-mark persona-history-toggle-brand",
511
+ attrs: { "aria-hidden": "true" },
512
+ },
513
+ mark
514
+ )
515
+ : null;
516
+ }
517
+ if (!toggleFace) return;
518
+ backButton.classList.add("persona-history-back--branded");
519
+ backButton.appendChild(toggleFace);
520
+ };
521
+
522
+ const syncLeadingControl = (): void => {
523
+ const toggle = collapseToggle();
524
+ const rail = presentation === "rail";
525
+ backButton.setAttribute(
526
+ "data-persona-history-focus",
527
+ toggle ? "collapse" : "close"
528
+ );
529
+ backButton.setAttribute(
530
+ "aria-label",
531
+ toggle
532
+ ? collapsed
533
+ ? copy.expandLabel
534
+ : copy.collapseLabel
535
+ : rail
536
+ ? copy.closeLabel
537
+ : copy.backLabel
538
+ );
539
+ if (toggle) {
540
+ backButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
541
+ backButton.setAttribute("aria-controls", bodyId);
542
+ // Only the collapse toggle answers to the shortcut; a back/close does not.
543
+ if (options.collapseShortcut?.aria) {
544
+ backButton.setAttribute(
545
+ "aria-keyshortcuts",
546
+ options.collapseShortcut.aria
547
+ );
548
+ }
549
+ } else {
550
+ backButton.removeAttribute("aria-expanded");
551
+ backButton.removeAttribute("aria-controls");
552
+ backButton.removeAttribute("aria-keyshortcuts");
553
+ }
554
+ backButton.replaceChildren(
555
+ historyIcon(toggle ? "panel-left" : rail ? "x" : "arrow-left")
556
+ );
557
+ syncToggleBrand();
558
+ };
559
+ syncLeadingControl();
560
+ backButton.addEventListener("click", () => {
561
+ if (collapseToggle()) options.onToggleCollapse?.();
562
+ else options.onClose();
563
+ });
564
+
565
+ const title = createNode("h2", {
566
+ className: "persona-history-title",
567
+ text: copy.viewTitle,
568
+ attrs: { id: headingId },
569
+ });
570
+
571
+ const scopeTitle = createNode("span", {
572
+ className: "persona-history-scope-title",
573
+ });
574
+ // The icon is rail-only chrome, revealed by CSS; panel keeps the plain
575
+ // sentence. Decorative: the title text carries the meaning.
576
+ const scopeLine = createNode(
577
+ "p",
578
+ { className: "persona-history-scope" },
579
+ createNode(
580
+ "span",
581
+ {
582
+ className: "persona-history-scope-icon",
583
+ attrs: { "aria-hidden": "true" },
584
+ },
585
+ historyIcon("monitor", 14)
586
+ ),
587
+ scopeTitle
588
+ );
589
+
590
+ const headingGroup = createNode(
591
+ "div",
592
+ { className: "persona-history-heading-group" },
593
+ title
594
+ );
595
+
596
+ let brandWarned = false;
597
+ /** Built once: the ctx of this face is always the expanded rail. */
598
+ let headingBrand: Element | null | undefined;
599
+
600
+ /**
601
+ * Rail-only identity slot. A host mark replaces the visible title; the `h2`
602
+ * stays sr-only so `aria-labelledby` still resolves to a name.
603
+ */
604
+ const syncRailBrand = (): void => {
605
+ const rail = presentation === "rail";
606
+ const slot = rail ? options.renderRailHeader : undefined;
607
+ let mark: Element | null = null;
608
+ let slotted = slot !== undefined;
609
+ if (slot) {
610
+ try {
611
+ mark = slot({ collapsed, defaultTitle: copy.viewTitle });
612
+ } catch (error) {
613
+ slotted = false;
614
+ if (!brandWarned) {
615
+ brandWarned = true;
616
+ console.warn("[persona] history rail renderHeader threw", error);
617
+ }
618
+ }
619
+ }
620
+ // Default composition for a brand declaration: mark leading, the view
621
+ // title beside it as the wordmark. Built once, since this face is only
622
+ // ever the expanded one.
623
+ if (!slotted && rail && options.railBrand) {
624
+ if (headingBrand === undefined) {
625
+ const brand = options.railBrand(false);
626
+ headingBrand = brand
627
+ ? createNode(
628
+ "span",
629
+ {
630
+ className: "persona-history-heading-brand",
631
+ attrs: { "aria-hidden": "true" },
632
+ },
633
+ createNode(
634
+ "span",
635
+ { className: "persona-history-brand-mark" },
636
+ brand
637
+ ),
638
+ createNode("span", {
639
+ className: "persona-history-wordmark",
640
+ text: copy.viewTitle,
641
+ })
642
+ )
643
+ : null;
644
+ }
645
+ if (headingBrand) {
646
+ slotted = true;
647
+ mark = headingBrand;
648
+ }
649
+ }
650
+ title.classList.toggle("persona-history-sr-only", slotted);
651
+ headingGroup.replaceChildren(title);
652
+ if (mark) headingGroup.appendChild(mark);
653
+ };
654
+ syncRailBrand();
655
+
656
+ const newIconButton = createNode("button", {
657
+ className: "persona-history-icon-button persona-history-new-icon",
658
+ attrs: {
659
+ type: "button",
660
+ "data-persona-history-focus": "new-icon",
661
+ "aria-label": copy.newConversationLabel,
662
+ },
663
+ });
664
+ newIconButton.appendChild(historyIcon("plus"));
665
+ newIconButton.addEventListener("click", () => void startNew());
666
+
667
+ // Same styled tooltip as the shell header controls beside this bar. Live
668
+ // aria-label getters keep the rail close relabel accurate.
669
+ const tooltipHandles = [backButton, newIconButton].map((control) =>
670
+ options.attachTooltip?.({
671
+ anchor: control,
672
+ text: () => control.getAttribute("aria-label") ?? "",
673
+ // Hint chip only while this control is the collapse toggle.
674
+ ...(control === backButton && options.collapseShortcut
675
+ ? { hint: () => (collapseToggle() ? options.collapseShortcut!.hint : "") }
676
+ : {}),
677
+ })
678
+ );
679
+
680
+ const topbar = createNode("div", { className: "persona-history-topbar" });
681
+
682
+ let barOrderKey: string | null = null;
683
+
684
+ /**
685
+ * The rail puts its collapse toggle on the inner edge facing the conversation
686
+ * (trailing on the left, leading on the right); the panel keeps the back
687
+ * arrow leading. DOM order, so tab order follows the visual one.
688
+ */
689
+ const syncBarOrder = (): void => {
690
+ const rail = presentation === "rail";
691
+ const key = rail ? (railRight ? "rail-right" : "rail") : "panel";
692
+ if (key === barOrderKey) return;
693
+ barOrderKey = key;
694
+ // Re-parenting blurs what it moves, so only a real order change re-appends.
695
+ if (key === "rail") topbar.append(headingGroup, backButton);
696
+ else topbar.append(backButton, headingGroup);
697
+ // The rail already carries a new-conversation row in its body.
698
+ if (rail) newIconButton.remove();
699
+ else topbar.appendChild(newIconButton);
700
+ // Only reachable after `element` exists: the first call is below it.
701
+ element.classList.toggle(
702
+ "persona-history-view--rail-right",
703
+ key === "rail-right"
704
+ );
705
+ };
706
+
707
+ /**
708
+ * Explanatory scope text + identity retry, at the top of the body. Visible
709
+ * only for actionable states; ambient states keep it sr-only behind the
710
+ * caption above the list (see `scopeAmbient`).
711
+ */
712
+ const scopeBlock = createNode("div", {
713
+ className: "persona-history-scope-alert",
714
+ });
715
+ const scopeDescriptionId = `${headingId}-scope`;
716
+
717
+ // Both presentations lead with the compose plus, Intercom-style.
718
+ const newIcon = historyIcon("plus", 18);
719
+ const newConversationButton = createNode(
720
+ "button",
721
+ {
722
+ className: "persona-history-new",
723
+ attrs: { type: "button", "data-persona-history-focus": "new" },
724
+ },
725
+ newIcon,
726
+ createNode("span", { text: copy.newConversationLabel })
727
+ );
728
+ newConversationButton.addEventListener("click", () => void startNew());
729
+
730
+ const listRegion = createNode("div", {
731
+ className: "persona-history-list-region",
732
+ });
733
+
734
+ // Destructive actions are never ambient furniture: both live behind this one
735
+ // quiet trigger, which is chrome and therefore renders through the load.
736
+ const clearAllowed = options.showDeleteAll !== false;
737
+ const resetAllowed = !!options.provider.resetDevice;
738
+ const listActions = options.listActions ?? [];
739
+
740
+ const optionsButton =
741
+ clearAllowed || resetAllowed || listActions.length > 0
742
+ ? buildMenuTrigger({
743
+ className: "persona-history-list-options",
744
+ label: copy.listOptionsLabel,
745
+ focusKey: `menu:${LIST_MENU_ID}`,
746
+ open: false,
747
+ inert: true,
748
+ iconSize: 16,
749
+ onToggle: () => toggleMenu(LIST_MENU_ID),
750
+ })
751
+ : null;
752
+
753
+ /**
754
+ * Heading over the conversation list. Visible in the rail, where it anchors
755
+ * the block the way the nav section titles do; sr-only in the panel, which
756
+ * already carries the view title directly above.
757
+ */
758
+ const conversationsHeading = createNode("h3", {
759
+ className: "persona-history-conversations-title",
760
+ text: copy.conversationsTitle,
761
+ });
762
+
763
+ /**
764
+ * List header block: heading and overflow trigger on the first line, the
765
+ * scope caption under them. Present even when the scope caption is off or
766
+ * hidden, so the trigger never depends on identity state. The item attribute
767
+ * is what the row menus use, so the outside-click guard covers this menu too.
768
+ */
769
+ const captionRow = createNode(
770
+ "div",
771
+ {
772
+ className: "persona-history-caption",
773
+ attrs: { "data-persona-history-item": LIST_MENU_ID },
774
+ },
775
+ conversationsHeading,
776
+ options.showScopeStatus ? scopeLine : null,
777
+ optionsButton
778
+ );
779
+
780
+ // One sliding region below the bar: the scope alert and the ambient caption
781
+ // are body content, not chrome.
782
+ const body = createNode(
783
+ "div",
784
+ { className: "persona-history-body", attrs: { id: bodyId } },
785
+ options.showScopeStatus ? scopeBlock : null,
786
+ newConversationButton,
787
+ // Always present: the rail shows its heading even with the scope caption
788
+ // off and no overflow trigger; the panel hides the empty row (renderChrome).
789
+ captionRow,
790
+ listRegion
791
+ );
792
+
793
+ let headerPlacement: HistoryHeaderPlacement =
794
+ options.headerPlacement ?? "inline";
795
+ if (headerPlacement === "external") {
796
+ topbar.classList.add(
797
+ "persona-history-topbar--shell",
798
+ "persona-history-topbar--shell-enter"
799
+ );
800
+ }
801
+
802
+ const element = createNode(
803
+ "div",
804
+ {
805
+ className: cx(
806
+ "persona-history-view",
807
+ `persona-history-view--${options.presentation}`,
808
+ "persona-history-view--enter"
809
+ ),
810
+ attrs: {
811
+ role: "region",
812
+ "aria-labelledby": headingId,
813
+ "data-persona-history-presentation": options.presentation,
814
+ },
815
+ },
816
+ announcer.element,
817
+ headerPlacement === "inline" ? topbar : null,
818
+ body
819
+ );
820
+
821
+ /** Collapsed is a rail-only treatment: panel always shows the whole list. */
822
+ const syncCollapsedClass = (): void => {
823
+ element.classList.toggle(
824
+ "persona-history-view--rail-collapsed",
825
+ collapsed && collapseToggle()
826
+ );
827
+ };
828
+ syncCollapsedClass();
829
+ syncBarOrder();
830
+
831
+ // --- rail nav sections --------------------------------------------------
832
+
833
+ const buildNavSection = (
834
+ section: HistoryRailSection,
835
+ index: number
836
+ ): HTMLElement => {
837
+ const navHeadingId = `${headingId}-s${index}`;
838
+ const node = createNode("div", {
839
+ className: cx(
840
+ "persona-history-nav",
841
+ section.placement === "footer" && "persona-history-nav--footer"
842
+ ),
843
+ attrs: {
844
+ role: "group",
845
+ "data-persona-rail-section": section.id,
846
+ ...(section.title
847
+ ? { "aria-labelledby": navHeadingId }
848
+ : { "aria-label": section.id }),
849
+ },
850
+ });
851
+ if (section.title) {
852
+ node.appendChild(
853
+ createNode("h3", {
854
+ className: "persona-history-group-heading",
855
+ text: section.title,
856
+ attrs: { id: navHeadingId },
857
+ })
858
+ );
859
+ }
860
+ for (const item of section.items) {
861
+ const icon = item.iconNode?.() ?? null;
862
+ const button = createNode(
863
+ "button",
864
+ {
865
+ // Collapsed keeps only the rows that have something to show as a square.
866
+ className: cx(
867
+ "persona-history-nav-item",
868
+ icon && "persona-history-nav-item--icon"
869
+ ),
870
+ attrs: {
871
+ type: "button",
872
+ "aria-label": item.label,
873
+ "data-persona-rail-item": item.id,
874
+ },
875
+ },
876
+ icon ? createNode("span", { className: "persona-history-nav-icon" }, icon) : null,
877
+ createNode("span", {
878
+ className: "persona-history-nav-label persona-history-truncate",
879
+ text: item.label,
880
+ }),
881
+ item.badge
882
+ ? createNode("span", {
883
+ className: "persona-history-nav-badge",
884
+ text: item.badge,
885
+ })
886
+ : null
887
+ );
888
+ button.addEventListener("click", () => item.onSelect());
889
+ node.appendChild(button);
890
+ }
891
+ return node;
892
+ };
893
+
894
+ /** Built once; a presentation flip only detaches and re-attaches them. */
895
+ let navNodes: HTMLElement[] | null = null;
896
+ /** Collapsed value the render-backed sections were last built for. */
897
+ let navCollapsed: boolean | null = null;
898
+
899
+ /**
900
+ * Render-backed sections only, and only while this view owns the DOM: a
901
+ * plugin holding the whole surface never invokes them.
902
+ */
903
+ const syncNavContent = (): void => {
904
+ if (
905
+ !navNodes ||
906
+ !domRenderEnabled ||
907
+ presentation !== "rail" ||
908
+ collapsed === navCollapsed
909
+ )
910
+ return;
911
+ navCollapsed = collapsed;
912
+ options.railSections!.forEach((section, index) => {
913
+ if (!section.render) return;
914
+ const node = navNodes![index];
915
+ // The heading outlives a content swap; it is always the first child.
916
+ const heading = section.title ? node.firstElementChild : null;
917
+ let content: Element | null = null;
918
+ try {
919
+ content = section.render(collapsed);
920
+ } catch (error) {
921
+ // Warn once, then drop the section: this array is the view's own copy.
922
+ section.render = undefined;
923
+ console.warn("[persona] history rail section threw", section.id, error);
924
+ }
925
+ node.replaceChildren(...(heading ? [heading] : []), ...(content ? [content] : []));
926
+ node.hidden = !content;
927
+ });
928
+ };
929
+
930
+ const syncNavSections = (): void => {
931
+ const sections = options.railSections;
932
+ // The list header draws its divider only with a nav block above to close off.
933
+ element.classList.toggle(
934
+ "persona-history-view--has-nav",
935
+ presentation === "rail" &&
936
+ !!sections?.some((section) => section.placement === "above-conversations")
937
+ );
938
+ if (!sections?.length) return;
939
+ if (presentation !== "rail") {
940
+ navNodes?.forEach((node) => node.remove());
941
+ return;
942
+ }
943
+ navNodes ??= sections.map(buildNavSection);
944
+ // Bucket order, then array order within a bucket: re-parenting an already
945
+ // placed node is a no-op only because nothing inside it holds focus.
946
+ for (const placement of [
947
+ "above-conversations",
948
+ "below-conversations",
949
+ "footer",
950
+ ] as HistoryRailSectionPlacement[]) {
951
+ // Nothing follows the list any more, so the trailing buckets append in
952
+ // placement order; `insertBefore(node, null)` is that append.
953
+ const anchor =
954
+ placement === "above-conversations"
955
+ ? captionRow.parentNode === body
956
+ ? captionRow
957
+ : listRegion
958
+ : null;
959
+ sections.forEach((section, index) => {
960
+ if (section.placement === placement) {
961
+ body.insertBefore(navNodes![index], anchor);
962
+ }
963
+ });
964
+ }
965
+ syncNavContent();
966
+ };
967
+ syncNavSections();
968
+
969
+ injectStyles(element, "persona-history-view", HISTORY_VIEW_CSS);
970
+
971
+ // --- entrance / exit ----------------------------------------------------
972
+
973
+ // components.history.motion overrides ride in as inherited CSS vars; the
974
+ // chunk never defines them on its own elements, so a theme emission at the
975
+ // widget root is never shadowed. Read lazily: the values must match what
976
+ // the CSS animation actually uses at that moment.
977
+ const motionMs = (name: string, fallback: number): number => {
978
+ const raw = element.ownerDocument.defaultView
979
+ ?.getComputedStyle(element)
980
+ .getPropertyValue(name);
981
+ const parsed = Number.parseFloat(raw ?? "");
982
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
983
+ };
984
+ const motionEasing = (name: string, fallback: string): string =>
985
+ element.ownerDocument.defaultView
986
+ ?.getComputedStyle(element)
987
+ .getPropertyValue(name)
988
+ .trim() || fallback;
989
+
990
+ /**
991
+ * The entrance is a one-shot mount animation. Re-parenting a node restarts a
992
+ * CSS animation, so the class is dropped the moment it can no longer be
993
+ * needed: the shell's live rail <-> panel move must not replay it. The timer
994
+ * is only the fallback for a missing animationend, so it follows the themed
995
+ * duration rather than cutting a longer one short.
996
+ */
997
+ let entranceTimer: ReturnType<typeof setTimeout> | null = null;
998
+ const endEntrance = (): void => {
999
+ if (entranceTimer !== null) {
1000
+ clearTimeout(entranceTimer);
1001
+ entranceTimer = null;
1002
+ }
1003
+ element.removeEventListener("animationend", endEntrance);
1004
+ element.classList.remove("persona-history-view--enter");
1005
+ };
1006
+ element.addEventListener("animationend", endEntrance);
1007
+ // Two-step arm: at creation the element is detached and inherited vars read
1008
+ // empty, so the themed duration resolves one macrotask later, after the
1009
+ // shell has mounted the view.
1010
+ entranceTimer = setTimeout(() => {
1011
+ entranceTimer = setTimeout(
1012
+ endEntrance,
1013
+ motionMs("--persona-history-enter-ms", ENTRANCE_MS) + 60
1014
+ );
1015
+ }, 0);
1016
+
1017
+ /**
1018
+ * The shell-hosted bar fades in once. Same one-shot discipline as the
1019
+ * entrance: the class is dropped so a later re-parent cannot replay it.
1020
+ */
1021
+ let shellFadeTimer: ReturnType<typeof setTimeout> | null = null;
1022
+ const endShellFade = (): void => {
1023
+ if (shellFadeTimer !== null) {
1024
+ clearTimeout(shellFadeTimer);
1025
+ shellFadeTimer = null;
1026
+ }
1027
+ topbar.classList.remove("persona-history-topbar--shell-enter");
1028
+ };
1029
+ const startShellFade = (): void => {
1030
+ endShellFade();
1031
+ topbar.classList.add("persona-history-topbar--shell-enter");
1032
+ shellFadeTimer = setTimeout(endShellFade, SHELL_FADE_MS + 60);
1033
+ };
1034
+ if (headerPlacement === "external") {
1035
+ shellFadeTimer = setTimeout(endShellFade, SHELL_FADE_MS + 60);
1036
+ }
1037
+
1038
+ let exitAnimations: Animation[] = [];
1039
+ let exitPromise: Promise<void> | null = null;
1040
+
1041
+ const playExit = (): Promise<void> | null => {
1042
+ if (exitPromise) return exitPromise;
1043
+ if (destroyed || prefersReducedMotion() || typeof element.animate !== "function") {
1044
+ endEntrance();
1045
+ return null;
1046
+ }
1047
+ // Closing mid-entrance: start from wherever the entrance got to rather than
1048
+ // popping back to full opacity first.
1049
+ const from =
1050
+ element.ownerDocument.defaultView?.getComputedStyle(body).opacity || "1";
1051
+ endEntrance();
1052
+ // A leaving surface is no longer a target, but focus stays put until the
1053
+ // shell restores it. The bar's contents go with it wherever they are hosted.
1054
+ element.style.pointerEvents = "none";
1055
+ headerContent.style.pointerEvents = "none";
1056
+ const timing = {
1057
+ duration: motionMs("--persona-history-exit-ms", EXIT_MS),
1058
+ easing: motionEasing("--persona-history-exit-easing", EXIT_EASING),
1059
+ fill: "forwards",
1060
+ } as const;
1061
+ const distance = EXIT_SLIDE_PX[presentation];
1062
+ // Only the body leaves: the bar is furniture and switches back instantly.
1063
+ exitAnimations = [
1064
+ body.animate(
1065
+ [
1066
+ { opacity: from, transform: "none" },
1067
+ { opacity: 0, transform: `translateX(${distance}px)` },
1068
+ ],
1069
+ timing
1070
+ ),
1071
+ ];
1072
+ exitPromise = Promise.all(exitAnimations.map(settled)).then(() => undefined);
1073
+ return exitPromise;
1074
+ };
1075
+
1076
+ // --- rendering ----------------------------------------------------------
1077
+
1078
+ /** `aria-disabled` rather than `disabled`: a busy control keeps its focus. */
1079
+ const setInert = (target: HTMLElement | null, inert: boolean): void => {
1080
+ if (!target) return;
1081
+ if (inert) target.setAttribute("aria-disabled", "true");
1082
+ else target.removeAttribute("aria-disabled");
1083
+ };
1084
+
1085
+ // --- header slot --------------------------------------------------------
1086
+
1087
+ let headerContent: HTMLElement = topbar;
1088
+ let headerRenderedKey: string | null = null;
1089
+
1090
+ /**
1091
+ * Re-run only when the header's own inputs change: a custom header must not
1092
+ * lose focus every time the list below it re-renders.
1093
+ */
1094
+ const renderHeader = (): void => {
1095
+ const slot = options.slots?.header;
1096
+ if (!slot) return;
1097
+ const key = `${presentation}|${identityKey(identityStatus)}|${
1098
+ pending ? `${pending.kind}:${"conversationId" in pending ? pending.conversationId : ""}` : ""
1099
+ }`;
1100
+ if (key === headerRenderedKey) return;
1101
+ headerRenderedKey = key;
1102
+ const next =
1103
+ slot({
1104
+ identityStatus,
1105
+ pendingAction: pending,
1106
+ copy,
1107
+ defaultRenderer: () => topbar,
1108
+ }) ?? topbar;
1109
+ if (next === headerContent) return;
1110
+ // Swaps in place wherever the content lives, including an external host.
1111
+ // Detached (external, pre-mount) it is bookkeeping only: the shell then
1112
+ // mounts whatever `getHeaderElement()` reports.
1113
+ headerContent.replaceWith(next);
1114
+ headerContent = next;
1115
+ };
1116
+
1117
+ let scopeRenderedKey: string | null = null;
1118
+
1119
+ const renderScope = (): void => {
1120
+ if (!options.showScopeStatus) return;
1121
+ // Rebuild only on a real identity change: a `verifying` block is live, and
1122
+ // re-rendering it on every list render would re-announce.
1123
+ const key = identityKey(identityStatus);
1124
+ if (key === scopeRenderedKey) return;
1125
+ scopeRenderedKey = key;
1126
+ const resolved = scopeCopy(identityStatus, copy);
1127
+ // Text is patched in place so a stable caption never re-announces.
1128
+ scopeTitle.textContent = resolved.title;
1129
+ const ambient = scopeAmbient(identityStatus);
1130
+ // The visible block carries the whole message; the caption would repeat it.
1131
+ scopeLine.hidden = !ambient;
1132
+ scopeBlock.replaceChildren(
1133
+ ...(ambient
1134
+ ? []
1135
+ : [
1136
+ createNode("span", {
1137
+ className: "persona-history-scope-alert-title",
1138
+ text: resolved.title,
1139
+ }),
1140
+ ]),
1141
+ createNode("span", {
1142
+ className: "persona-history-scope-description",
1143
+ text: resolved.description,
1144
+ attrs: { id: scopeDescriptionId },
1145
+ })
1146
+ );
1147
+ scopeBlock.setAttribute(
1148
+ "data-persona-history-scope-tone",
1149
+ ambient ? "ambient" : "attention"
1150
+ );
1151
+ // Described-by only while the block is sr-only; a visible one would repeat.
1152
+ if (ambient) scopeLine.setAttribute("aria-describedby", scopeDescriptionId);
1153
+ else scopeLine.removeAttribute("aria-describedby");
1154
+ if (resolved.pending) scopeBlock.setAttribute("role", "status");
1155
+ else scopeBlock.removeAttribute("role");
1156
+ scopeBlock.setAttribute("data-persona-history-identity", identityStatus.state);
1157
+ // Mirrored on the caption so the rail's icon and dot can key off it.
1158
+ scopeLine.setAttribute("data-persona-history-identity", identityStatus.state);
1159
+ if (identityRetryable(identityStatus)) {
1160
+ const retry = createNode("button", {
1161
+ className: "persona-history-secondary persona-history-state-action",
1162
+ text: copy.retryIdentityLabel,
1163
+ attrs: { type: "button", "data-persona-history-focus": "identity-retry" },
1164
+ });
1165
+ retry.addEventListener("click", () => {
1166
+ if (busy()) return;
1167
+ void loadList("refresh");
1168
+ });
1169
+ scopeBlock.appendChild(retry);
1170
+ }
1171
+ };
1172
+
1173
+ const rowPendingFor = (id: string): RowPending => {
1174
+ if (!pending) return null;
1175
+ if (pending.kind === "open" && pending.conversationId === id) return "opening";
1176
+ if (pending.kind === "delete" && pending.conversationId === id)
1177
+ return "deleting";
1178
+ return null;
1179
+ };
1180
+
1181
+ const captureFocusKey = (): string | null => {
1182
+ const active = document.activeElement;
1183
+ if (!(active instanceof HTMLElement) || !element.contains(active)) return null;
1184
+ return active.getAttribute("data-persona-history-focus");
1185
+ };
1186
+
1187
+ // Menu keys are looked up on the whole view: the list menu lives in the
1188
+ // caption row, outside the list region.
1189
+ const restoreFocus = (key: string | null): void => {
1190
+ if (focusMenuOnRender && openMenuId) {
1191
+ focusMenuOnRender = false;
1192
+ const item = element.querySelector<HTMLElement>(
1193
+ `[data-persona-history-focus="${menuItemFocusKey(openMenuId)}"]`
1194
+ );
1195
+ if (item) {
1196
+ item.focus();
1197
+ return;
1198
+ }
1199
+ }
1200
+ if (!key) return;
1201
+ element
1202
+ .querySelector<HTMLElement>(`[data-persona-history-focus="${key}"]`)
1203
+ ?.focus();
1204
+ };
1205
+
1206
+ const buildActionError = (): HTMLElement | null => {
1207
+ if (!actionError) return null;
1208
+ const retry = createNode("button", {
1209
+ className: "persona-history-secondary persona-history-state-action",
1210
+ text: copy.retryLabel,
1211
+ attrs: { type: "button", "data-persona-history-focus": "action-retry" },
1212
+ });
1213
+ const failed = actionError;
1214
+ retry.addEventListener("click", () => {
1215
+ if (busy()) return;
1216
+ failed.retry();
1217
+ });
1218
+ return createNode(
1219
+ "div",
1220
+ { className: "persona-history-row-error", attrs: { role: "alert" } },
1221
+ createNode("span", { text: failed.message }),
1222
+ retry
1223
+ );
1224
+ };
1225
+
1226
+ const buildGroups = (): HTMLElement[] =>
1227
+ groupConversations(items, now(), copy, options.grouping).map((group, index) => {
1228
+ const groupHeadingId = `${headingId}-g${index}`;
1229
+ const list = createNode("ul", {
1230
+ className: "persona-history-list",
1231
+ attrs: { "aria-labelledby": groupHeadingId },
1232
+ });
1233
+ for (const conversation of group.items) {
1234
+ const defaultRow = (): HTMLElement =>
1235
+ buildConversationRow({
1236
+ conversation,
1237
+ active: conversation.id === activeConversationId,
1238
+ pending: rowPendingFor(conversation.id),
1239
+ busy: busy(),
1240
+ menuOpen: openMenuId === conversation.id,
1241
+ error: rowErrors.get(conversation.id) ?? null,
1242
+ avatar: options.rowAvatar,
1243
+ showDelete: options.showDelete !== false,
1244
+ nowMs: now(),
1245
+ copy,
1246
+ onOpen: () => void openConversation(conversation.id),
1247
+ onToggleMenu: () => toggleMenu(conversation.id),
1248
+ onCloseMenu: (opts) => closeMenu(opts),
1249
+ onDelete: () => void deleteConversation(conversation.id),
1250
+ });
1251
+ const custom = options.slots?.conversation?.({
1252
+ conversation,
1253
+ active: conversation.id === activeConversationId,
1254
+ pending: rowPendingFor(conversation.id),
1255
+ open: () => openConversation(conversation.id),
1256
+ requestDelete: () => deleteConversation(conversation.id),
1257
+ defaultRenderer: defaultRow,
1258
+ });
1259
+ const row = custom ?? defaultRow();
1260
+ // The list stays a real list even when a plugin returns loose markup.
1261
+ list.appendChild(
1262
+ row instanceof HTMLLIElement
1263
+ ? row
1264
+ : createNode("li", { className: "persona-history-item" }, row)
1265
+ );
1266
+ }
1267
+ return createNode(
1268
+ "div",
1269
+ {
1270
+ className: "persona-history-group",
1271
+ attrs: { "data-persona-history-group": group.key },
1272
+ },
1273
+ createNode("h3", {
1274
+ // The flat group's heading would repeat the list heading above it,
1275
+ // so it labels the list from the accessibility tree only.
1276
+ className: cx(
1277
+ "persona-history-group-heading",
1278
+ group.key === "recent" && "persona-history-sr-only"
1279
+ ),
1280
+ text: group.label,
1281
+ attrs: { id: groupHeadingId },
1282
+ }),
1283
+ list
1284
+ );
1285
+ });
1286
+
1287
+ const buildLoadMore = (): HTMLElement | null => {
1288
+ if (!nextCursor) return null;
1289
+ const loadingMore =
1290
+ pending?.kind === "load-more" ||
1291
+ (listState.kind === "loading" && listState.phase === "load-more");
1292
+ const button = createNode("button", {
1293
+ className: "persona-history-secondary persona-history-load-more",
1294
+ text: loadingMore ? copy.loadingMoreLabel : copy.loadMoreLabel,
1295
+ attrs: {
1296
+ type: "button",
1297
+ "data-persona-history-focus": "load-more",
1298
+ ...(loadingMore
1299
+ ? { "aria-busy": "true", "aria-disabled": "true" }
1300
+ : {}),
1301
+ ...(busy() && !loadingMore ? { "aria-disabled": "true" } : {}),
1302
+ },
1303
+ });
1304
+ button.addEventListener("click", () => {
1305
+ if (busy()) return;
1306
+ void loadList("load-more");
1307
+ });
1308
+ return button;
1309
+ };
1310
+
1311
+ const renderList = (): void => {
1312
+ const focusKey = captureFocusKey();
1313
+ const children: Array<Node | null> = [buildActionError()];
1314
+
1315
+ const hasRows = items.length > 0;
1316
+ if (hasRows) {
1317
+ children.push(...buildGroups());
1318
+ children.push(buildLoadMore());
1319
+ }
1320
+
1321
+ const stateNeedsBlock =
1322
+ listState.kind !== "ready" &&
1323
+ !(listState.kind === "loading" && listState.phase !== "initial" && hasRows);
1324
+ if (stateNeedsBlock) {
1325
+ const state = listState as Exclude<HistoryListState, { kind: "ready" }>;
1326
+ // Recovery affordances exist only where they are safe for this state.
1327
+ const retry = state.kind === "loading" ? undefined : () => loadList("refresh");
1328
+ const startNewConversation =
1329
+ state.kind === "new_conversation_required" ? () => startNew() : undefined;
1330
+ const defaultState = (): HTMLElement =>
1331
+ buildStateBlock({
1332
+ state,
1333
+ copy,
1334
+ identityStatus,
1335
+ busy: busy(),
1336
+ ...(retry ? { onRetry: () => void retry() } : {}),
1337
+ ...(startNewConversation
1338
+ ? { onStartNew: () => void startNewConversation() }
1339
+ : {}),
1340
+ });
1341
+ const custom = options.slots?.state?.({
1342
+ state,
1343
+ identityStatus,
1344
+ copy,
1345
+ ...(retry ? { retry } : {}),
1346
+ ...(startNewConversation ? { startNewConversation } : {}),
1347
+ defaultRenderer: defaultState,
1348
+ });
1349
+ children.push(custom ?? defaultState());
1350
+ }
1351
+
1352
+ listRegion.replaceChildren(...children.filter((node): node is Node => !!node));
1353
+ restoreFocus(focusKey);
1354
+ };
1355
+
1356
+ /**
1357
+ * Menu items, computed fresh at open time. Destructive actions reveal with
1358
+ * the data they act on: neither the empty state nor an unresolved first load
1359
+ * has anything to delete yet.
1360
+ */
1361
+ const listMenuItems = (): HistoryMenuItemSpec[] => {
1362
+ const listUnresolved = listState.kind === "loading" && items.length === 0;
1363
+ const specs: HistoryMenuItemSpec[] = [];
1364
+ // Opening lands on the first item, whichever action that turns out to be.
1365
+ const nextKey = (): string =>
1366
+ specs.length === 0
1367
+ ? menuItemFocusKey(LIST_MENU_ID)
1368
+ : `${menuItemFocusKey(LIST_MENU_ID)}-${specs.length}`;
1369
+ // Host actions lead so the menu never opens straight onto a destructive
1370
+ // built-in when neutral options exist.
1371
+ if (!listUnresolved) {
1372
+ for (const action of listActions) {
1373
+ specs.push({
1374
+ label: action.label,
1375
+ focusKey: nextKey(),
1376
+ danger: action.danger === true,
1377
+ onSelect: () => void action.onSelect({ conversations: [...items] }),
1378
+ });
1379
+ }
1380
+ }
1381
+ if (
1382
+ clearAllowed &&
1383
+ !(items.length === 0 && (listState.kind === "empty" || listUnresolved))
1384
+ ) {
1385
+ specs.push({
1386
+ label: copy.clearHistoryLabel,
1387
+ focusKey: nextKey(),
1388
+ danger: true,
1389
+ onSelect: () => void clearHistory(),
1390
+ });
1391
+ }
1392
+ if (resetAllowed && !listUnresolved) {
1393
+ specs.push({
1394
+ label: copy.resetIdentityLabel,
1395
+ focusKey: nextKey(),
1396
+ danger: true,
1397
+ onSelect: () => void resetIdentity(),
1398
+ });
1399
+ }
1400
+ return specs;
1401
+ };
1402
+
1403
+ const renderChrome = (): void => {
1404
+ setInert(newConversationButton, busy());
1405
+ setInert(newIconButton, busy());
1406
+ if (optionsButton) {
1407
+ const specs = listMenuItems();
1408
+ // The trigger is chrome: it holds its slot through the first load rather
1409
+ // than popping in behind the rows, and only drops out once the resolved
1410
+ // state has nothing to offer.
1411
+ const listUnresolved = listState.kind === "loading" && items.length === 0;
1412
+ optionsButton.hidden = specs.length === 0 && !listUnresolved;
1413
+ setInert(optionsButton, busy() || specs.length === 0);
1414
+ optionsButton.setAttribute(
1415
+ "aria-expanded",
1416
+ openMenuId === LIST_MENU_ID ? "true" : "false"
1417
+ );
1418
+ const menu = captionRow.querySelector(".persona-history-menu");
1419
+ if (openMenuId !== LIST_MENU_ID || specs.length === 0) {
1420
+ menu?.remove();
1421
+ } else {
1422
+ const next = buildOverflowMenu({
1423
+ label: copy.listOptionsLabel,
1424
+ items: specs,
1425
+ onCloseMenu: (opts) => closeMenu(opts),
1426
+ });
1427
+ if (menu) menu.replaceWith(next);
1428
+ else captionRow.appendChild(next);
1429
+ }
1430
+ }
1431
+ // The rail always shows the block for its heading; in the panel the
1432
+ // heading is sr-only, so an otherwise empty row would still take a body gap.
1433
+ captionRow.hidden =
1434
+ presentation !== "rail" &&
1435
+ ((!optionsButton || optionsButton.hidden) &&
1436
+ (!options.showScopeStatus || scopeLine.hidden));
1437
+ };
1438
+
1439
+ // The shell's active-conversation binding. Reported from render() so every
1440
+ // path that can change the answer (selection, list refresh delivering a
1441
+ // server title, rename/star, deletion, clear) funnels through one
1442
+ // change-detecting emit; runs even while DOM rendering is suspended, since
1443
+ // the data still moved.
1444
+ let reportedActiveKey: string | undefined;
1445
+ const reportActiveConversation = (): void => {
1446
+ const callback = options.onActiveConversationChange;
1447
+ if (!callback) return;
1448
+ const active = items.find((c) => c.id === activeConversationId) ?? null;
1449
+ const key = active
1450
+ ? `${active.id}\0${active.title}\0${active.starred ? 1 : 0}`
1451
+ : "";
1452
+ if (key === reportedActiveKey) return;
1453
+ reportedActiveKey = key;
1454
+ callback(active);
1455
+ };
1456
+
1457
+ const render = (): void => {
1458
+ if (destroyed) return;
1459
+ reportActiveConversation();
1460
+ // Suspended means a plugin owns the whole surface: build nothing, just tell
1461
+ // the arbitration layer the model moved.
1462
+ if (!domRenderEnabled) {
1463
+ options.onModelChange?.();
1464
+ return;
1465
+ }
1466
+ renderHeader();
1467
+ renderScope();
1468
+ renderChrome();
1469
+ renderList();
1470
+ // First paint for render-backed sections: nothing runs while suspended.
1471
+ syncNavContent();
1472
+ };
1473
+
1474
+ // --- menu ---------------------------------------------------------------
1475
+
1476
+ const closeMenu = (opts?: { restoreFocus?: boolean }): void => {
1477
+ if (!openMenuId) return;
1478
+ const previous = openMenuId;
1479
+ openMenuId = null;
1480
+ focusMenuOnRender = false;
1481
+ render();
1482
+ if (opts?.restoreFocus) {
1483
+ element
1484
+ .querySelector<HTMLElement>(
1485
+ `[data-persona-history-focus="menu:${previous}"]`
1486
+ )
1487
+ ?.focus();
1488
+ }
1489
+ };
1490
+
1491
+ const toggleMenu = (conversationId: string): void => {
1492
+ if (openMenuId === conversationId) {
1493
+ closeMenu({ restoreFocus: true });
1494
+ return;
1495
+ }
1496
+ openMenuId = conversationId;
1497
+ focusMenuOnRender = true;
1498
+ render();
1499
+ };
1500
+
1501
+ const onDocumentPointerDown = (event: Event): void => {
1502
+ if (!openMenuId) return;
1503
+ const target = event.target;
1504
+ if (target instanceof Node && element.contains(target)) {
1505
+ const item = (target as HTMLElement).closest?.(
1506
+ `[data-persona-history-item="${openMenuId}"]`
1507
+ );
1508
+ if (item) return;
1509
+ }
1510
+ closeMenu();
1511
+ };
1512
+ document.addEventListener("pointerdown", onDocumentPointerDown, true);
1513
+
1514
+ const onRootKeydown = (event: KeyboardEvent): void => {
1515
+ if (event.key !== "Escape" || !openMenuId) return;
1516
+ // Do not let the shell's Escape close the whole surface behind the menu.
1517
+ event.stopPropagation();
1518
+ closeMenu({ restoreFocus: true });
1519
+ };
1520
+ element.addEventListener("keydown", onRootKeydown);
1521
+
1522
+ // --- data operations ----------------------------------------------------
1523
+
1524
+ async function loadList(
1525
+ phase: "initial" | "refresh" | "load-more"
1526
+ ): Promise<void> {
1527
+ if (destroyed) return;
1528
+ const epoch = ++listEpoch;
1529
+ const cursor = phase === "load-more" ? nextCursor : null;
1530
+ if (phase === "load-more" && !cursor) return;
1531
+ pending = { kind: phase === "load-more" ? "load-more" : "refresh" };
1532
+ listState = { kind: "loading", phase };
1533
+ if (phase !== "load-more") {
1534
+ rowErrors.clear();
1535
+ actionError = null;
1536
+ }
1537
+ closeMenu();
1538
+ render();
1539
+ try {
1540
+ const result = await options.provider.list({
1541
+ limit: pageSize,
1542
+ context: options.context,
1543
+ ...(cursor ? { cursor } : {}),
1544
+ ...(options.targetId ? { targetId: options.targetId } : {}),
1545
+ });
1546
+ if (destroyed || epoch !== listEpoch) return;
1547
+ items = cursor ? [...items, ...result.items] : result.items;
1548
+ nextCursor = result.nextCursor;
1549
+ listState = items.length === 0 ? { kind: "empty" } : { kind: "ready" };
1550
+ } catch (error) {
1551
+ if (destroyed || epoch !== listEpoch) return;
1552
+ listState = toListState(error);
1553
+ } finally {
1554
+ if (!destroyed && epoch === listEpoch) {
1555
+ pending = null;
1556
+ render();
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ const removeConversation = (conversationId: string): void => {
1562
+ items = items.filter((item) => item.id !== conversationId);
1563
+ rowErrors.delete(conversationId);
1564
+ if (activeConversationId === conversationId) activeConversationId = null;
1565
+ if (items.length === 0 && !nextCursor) listState = { kind: "empty" };
1566
+ };
1567
+
1568
+ async function openConversation(conversationId: string): Promise<void> {
1569
+ if (busy() || destroyed) return;
1570
+ rowErrors.delete(conversationId);
1571
+ closeMenu();
1572
+ pending = { kind: "open", conversationId };
1573
+ render();
1574
+ try {
1575
+ await options.onSelect(conversationId);
1576
+ if (destroyed) return;
1577
+ activeConversationId = conversationId;
1578
+ } catch {
1579
+ if (destroyed) return;
1580
+ rowErrors.set(conversationId, {
1581
+ message: copy.openFailedLabel,
1582
+ retry: () => void openConversation(conversationId),
1583
+ });
1584
+ } finally {
1585
+ if (!destroyed) {
1586
+ pending = null;
1587
+ render();
1588
+ }
1589
+ }
1590
+ }
1591
+
1592
+ /** A failed delete resolves "cancelled"; the failure surfaces as a row error. */
1593
+ async function deleteConversation(
1594
+ conversationId: string
1595
+ ): Promise<"deleted" | "cancelled"> {
1596
+ if (busy() || destroyed) return "cancelled";
1597
+ rowErrors.delete(conversationId);
1598
+ pending = { kind: "delete", conversationId };
1599
+ render();
1600
+ try {
1601
+ const outcome = await options.onRequestDeleteConversation(conversationId);
1602
+ if (destroyed) return outcome;
1603
+ if (outcome === "deleted") {
1604
+ removeConversation(conversationId);
1605
+ announce(copy.conversationRemovedNotice);
1606
+ }
1607
+ return outcome;
1608
+ } catch (error) {
1609
+ if (destroyed) return "cancelled";
1610
+ // A 404 means the row is already gone: remove it without an error.
1611
+ if (isHistoryProviderError(error) && error.code === "not_found") {
1612
+ removeConversation(conversationId);
1613
+ } else {
1614
+ rowErrors.set(conversationId, {
1615
+ message: copy.deleteFailedLabel,
1616
+ retry: () => void deleteConversation(conversationId),
1617
+ });
1618
+ }
1619
+ return "cancelled";
1620
+ } finally {
1621
+ if (!destroyed) {
1622
+ pending = null;
1623
+ render();
1624
+ }
1625
+ }
1626
+ }
1627
+
1628
+ async function startNew(): Promise<void> {
1629
+ if (busy() || destroyed) return;
1630
+ actionError = null;
1631
+ closeMenu();
1632
+ pending = { kind: "start-new" };
1633
+ render();
1634
+ try {
1635
+ await options.onStartNew();
1636
+ if (destroyed) return;
1637
+ if (listState.kind === "new_conversation_required") {
1638
+ listState = items.length === 0 ? { kind: "empty" } : { kind: "ready" };
1639
+ }
1640
+ } catch {
1641
+ if (destroyed) return;
1642
+ actionError = {
1643
+ message: copy.errorDescription,
1644
+ retry: () => void startNew(),
1645
+ };
1646
+ } finally {
1647
+ if (!destroyed) {
1648
+ pending = null;
1649
+ render();
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ /** A failed clear resolves "cancelled"; the failure surfaces as an action error. */
1655
+ async function clearHistory(): Promise<"cleared" | "cancelled"> {
1656
+ if (busy() || destroyed) return "cancelled";
1657
+ actionError = null;
1658
+ closeMenu();
1659
+ pending = { kind: "clear" };
1660
+ render();
1661
+ try {
1662
+ const outcome = await options.onRequestClearHistory();
1663
+ if (destroyed) return outcome;
1664
+ if (outcome === "cleared") {
1665
+ items = [];
1666
+ nextCursor = null;
1667
+ activeConversationId = null;
1668
+ listState = { kind: "empty" };
1669
+ announce(copy.historyClearedNotice);
1670
+ }
1671
+ return outcome;
1672
+ } catch {
1673
+ if (destroyed) return "cancelled";
1674
+ actionError = {
1675
+ message: copy.errorDescription,
1676
+ retry: () => void clearHistory(),
1677
+ };
1678
+ return "cancelled";
1679
+ } finally {
1680
+ if (!destroyed) {
1681
+ pending = null;
1682
+ render();
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ type ResetOutcome =
1688
+ | { outcome: "cancelled" }
1689
+ | { outcome: "reset"; remoteRevocationConfirmed: boolean };
1690
+
1691
+ const CANCELLED: ResetOutcome = { outcome: "cancelled" };
1692
+
1693
+ async function resetIdentity(): Promise<ResetOutcome> {
1694
+ if (busy() || destroyed || !options.onRequestResetIdentity) return CANCELLED;
1695
+ actionError = null;
1696
+ closeMenu();
1697
+ pending = { kind: "reset" };
1698
+ render();
1699
+ try {
1700
+ const result = await options.onRequestResetIdentity();
1701
+ if (destroyed) return result;
1702
+ if (result.outcome === "reset") {
1703
+ announce(
1704
+ result.remoteRevocationConfirmed
1705
+ ? copy.identityResetNotice
1706
+ : copy.identityResetUnconfirmedNotice
1707
+ );
1708
+ pending = null;
1709
+ await loadList("refresh");
1710
+ return result;
1711
+ }
1712
+ return result;
1713
+ } catch {
1714
+ if (destroyed) return CANCELLED;
1715
+ actionError = {
1716
+ message: copy.errorDescription,
1717
+ retry: () => void resetIdentity(),
1718
+ };
1719
+ return CANCELLED;
1720
+ } finally {
1721
+ if (!destroyed && pending?.kind === "reset") {
1722
+ pending = null;
1723
+ render();
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ // --- subscriptions ------------------------------------------------------
1729
+
1730
+ const unsubscribeIdentity = options.provider.subscribeIdentityStatus(
1731
+ (status) => {
1732
+ if (destroyed) return;
1733
+ const key = identityKey(status);
1734
+ const changed = key !== renderedIdentityKey;
1735
+ identityStatus = status;
1736
+ renderedIdentityKey = key;
1737
+ render();
1738
+ // Announce only real transitions, and let `verifying` speak through its
1739
+ // own role="status" block instead of announcing twice.
1740
+ if (changed && status.state !== "verifying") {
1741
+ announce(scopeCopy(status, copy).title);
1742
+ }
1743
+ }
1744
+ );
1745
+
1746
+ const unsubscribeAvailability = options.provider.subscribeAvailability?.(
1747
+ (available) => {
1748
+ if (destroyed) return;
1749
+ if (available) {
1750
+ void loadList("refresh");
1751
+ return;
1752
+ }
1753
+ items = [];
1754
+ nextCursor = null;
1755
+ listState = { kind: "error", reason: "unavailable", retryable: false };
1756
+ render();
1757
+ }
1758
+ );
1759
+
1760
+ render();
1761
+ void loadList("initial");
1762
+
1763
+ return {
1764
+ element,
1765
+ copy,
1766
+ getModel: () => ({
1767
+ conversations: items,
1768
+ activeConversationId,
1769
+ state: listState,
1770
+ pendingAction: pending,
1771
+ identityStatus,
1772
+ nextCursor,
1773
+ }),
1774
+ // Busy gating matches the default controls, which are inert while pending.
1775
+ operations: {
1776
+ refresh: async () => {
1777
+ if (busy()) return;
1778
+ await loadList("refresh");
1779
+ },
1780
+ loadMore: async () => {
1781
+ if (busy()) return;
1782
+ await loadList("load-more");
1783
+ },
1784
+ openConversation: (conversationId) => openConversation(conversationId),
1785
+ startNewConversation: () => startNew(),
1786
+ requestDeleteConversation: (conversationId) =>
1787
+ deleteConversation(conversationId),
1788
+ requestClearConversationHistory: () => clearHistory(),
1789
+ requestResetHistoryIdentity: () => resetIdentity(),
1790
+ },
1791
+ setDomRenderEnabled: (enabled) => {
1792
+ if (enabled === domRenderEnabled) return;
1793
+ domRenderEnabled = enabled;
1794
+ if (enabled) render();
1795
+ },
1796
+ refresh: () => {
1797
+ void loadList("refresh");
1798
+ },
1799
+ setPresentation: (next) => {
1800
+ if (next === presentation) return;
1801
+ // The shell re-parents the element around this call; a live entrance
1802
+ // would replay on re-insertion.
1803
+ endEntrance();
1804
+ presentation = next;
1805
+ headerRenderedKey = null;
1806
+ const rail = next === "rail";
1807
+ element.classList.toggle("persona-history-view--panel", !rail);
1808
+ element.classList.toggle("persona-history-view--rail", rail);
1809
+ element.setAttribute("data-persona-history-presentation", next);
1810
+ syncLeadingControl();
1811
+ syncCollapsedClass();
1812
+ syncBarOrder();
1813
+ syncRailBrand();
1814
+ syncNavSections();
1815
+ // A header slot is presentation-aware, so it re-arbitrates on the move.
1816
+ render();
1817
+ },
1818
+ setCollapsed: (next) => {
1819
+ if (next === collapsed) return;
1820
+ collapsed = next;
1821
+ syncCollapsedClass();
1822
+ syncLeadingControl();
1823
+ syncRailBrand();
1824
+ syncNavContent();
1825
+ },
1826
+ setRailSide: (side) => {
1827
+ railRight = side === "right";
1828
+ syncBarOrder();
1829
+ },
1830
+ getHeaderElement: () => headerContent,
1831
+ setHeaderPlacement: (next) => {
1832
+ if (next === headerPlacement) return;
1833
+ headerPlacement = next;
1834
+ const external = next === "external";
1835
+ topbar.classList.toggle("persona-history-topbar--shell", external);
1836
+ if (external) {
1837
+ startShellFade();
1838
+ // The shell re-homes it; leaving it here would show it twice.
1839
+ headerContent.remove();
1840
+ return;
1841
+ }
1842
+ endShellFade();
1843
+ element.insertBefore(headerContent, body);
1844
+ },
1845
+ setActiveConversationId: (conversationId) => {
1846
+ if (activeConversationId === conversationId) return;
1847
+ activeConversationId = conversationId;
1848
+ render();
1849
+ },
1850
+ applyConversationSummary: (summary) => {
1851
+ const index = items.findIndex((item) => item.id === summary.id);
1852
+ if (index === -1) return;
1853
+ items[index] = summary;
1854
+ render();
1855
+ },
1856
+ removeConversationSummary: (conversationId) => {
1857
+ if (!items.some((item) => item.id === conversationId)) return;
1858
+ removeConversation(conversationId);
1859
+ render();
1860
+ },
1861
+ setNewConversationRequired: (required) => {
1862
+ if (required) {
1863
+ listState = { kind: "new_conversation_required" };
1864
+ } else if (listState.kind === "new_conversation_required") {
1865
+ listState = items.length === 0 ? { kind: "empty" } : { kind: "ready" };
1866
+ }
1867
+ render();
1868
+ },
1869
+ playExit,
1870
+ destroy: () => {
1871
+ destroyed = true;
1872
+ listEpoch += 1;
1873
+ endEntrance();
1874
+ endShellFade();
1875
+ // An open tooltip holds document-level listeners; the buttons may also
1876
+ // outlive this element in the shell header.
1877
+ tooltipHandles.forEach((handle) => handle?.destroy());
1878
+ // The bar's contents may be hosted outside this element.
1879
+ headerContent.remove();
1880
+ exitAnimations.forEach((animation) => animation.cancel());
1881
+ exitAnimations = [];
1882
+ unsubscribeIdentity();
1883
+ unsubscribeAvailability?.();
1884
+ document.removeEventListener("pointerdown", onDocumentPointerDown, true);
1885
+ element.removeEventListener("keydown", onRootKeydown);
1886
+ announcer.destroy();
1887
+ element.remove();
1888
+ element.replaceChildren();
1889
+ },
1890
+ };
1891
+ }