@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,108 @@
1
+ {
2
+ "_readme": "Generated by apps/api/tests/client-conversation-history-handlers.test.ts in runtypelabs/core. Do not hand-edit; regenerate with UPDATE_HISTORY_WIRE_FIXTURE=1. Pins the /v1/client/conversations wire shape a Persona message mapper must handle: divergent display vs model content, absent vs deliberately empty projections, withheld model-only content (displayAvailable false), media parts, and a terminal assistant turn finalized through the display-projections route. conversationRevision is normalized because it is a freshly minted opaque token.",
3
+ "list": [
4
+ {
5
+ "id": "record_fixture_0000000000",
6
+ "title": "record_fixture_0000000000",
7
+ "targetId": "flow_fixture_000000000000",
8
+ "flowId": "flow_fixture_000000000000",
9
+ "preview": "It is in transit and arrives Thursday.",
10
+ "messageCount": 9,
11
+ "createdAt": "2026-01-01T00:00:00.000Z",
12
+ "updatedAt": "2026-02-01T00:00:00.000Z"
13
+ }
14
+ ],
15
+ "detail": {
16
+ "id": "record_fixture_0000000000",
17
+ "title": "record_fixture_0000000000",
18
+ "targetId": "flow_fixture_000000000000",
19
+ "flowId": "flow_fixture_000000000000",
20
+ "preview": "It is in transit and arrives Thursday.",
21
+ "messageCount": 9,
22
+ "createdAt": "2026-01-01T00:00:00.000Z",
23
+ "updatedAt": "2026-02-01T00:00:00.000Z",
24
+ "conversationRevision": "<opaque>",
25
+ "messages": [
26
+ {
27
+ "id": "msg_string",
28
+ "role": "user",
29
+ "content": "Where is my order?",
30
+ "displayAvailable": true,
31
+ "timestamp": "2026-02-01T00:00:00.000Z"
32
+ },
33
+ {
34
+ "id": "msg_divergent",
35
+ "role": "assistant",
36
+ "content": "{\"directive\":\"order_card\",\"orderId\":\"A-1\"}",
37
+ "displayContent": "Order A-1 is on its way.",
38
+ "displayAvailable": true,
39
+ "timestamp": "2026-02-01T00:00:01.000Z"
40
+ },
41
+ {
42
+ "id": "msg_empty_projection",
43
+ "role": "assistant",
44
+ "content": "{\"directive\":\"open_modal\"}",
45
+ "displayContent": "",
46
+ "displayAvailable": true,
47
+ "timestamp": "2026-02-01T00:00:02.000Z"
48
+ },
49
+ {
50
+ "id": "msg_hidden_llm_content",
51
+ "role": "assistant",
52
+ "displayAvailable": false,
53
+ "timestamp": "2026-02-01T00:00:03.000Z"
54
+ },
55
+ {
56
+ "id": "msg_raw_directive",
57
+ "role": "assistant",
58
+ "displayAvailable": false,
59
+ "timestamp": "2026-02-01T00:00:04.000Z"
60
+ },
61
+ {
62
+ "id": "msg_image",
63
+ "role": "user",
64
+ "content": [
65
+ {
66
+ "text": "Looks like this:",
67
+ "type": "text"
68
+ },
69
+ {
70
+ "type": "image",
71
+ "image": "data:image/png;base64,AAAA"
72
+ }
73
+ ],
74
+ "displayAvailable": true,
75
+ "timestamp": "2026-02-01T00:00:05.000Z"
76
+ },
77
+ {
78
+ "id": "msg_file",
79
+ "role": "user",
80
+ "content": [
81
+ {
82
+ "data": "ZmFrZQ==",
83
+ "type": "file",
84
+ "filename": "receipt.pdf",
85
+ "mimeType": "application/pdf"
86
+ }
87
+ ],
88
+ "displayAvailable": true,
89
+ "timestamp": "2026-02-01T00:00:06.000Z"
90
+ },
91
+ {
92
+ "id": "msg_absent_content",
93
+ "role": "assistant",
94
+ "displayAvailable": false,
95
+ "timestamp": "2026-02-01T00:00:07.000Z"
96
+ },
97
+ {
98
+ "id": "msg_terminal_finalized",
99
+ "role": "assistant",
100
+ "content": "{\"tool\":\"track\",\"status\":\"in_transit\"}",
101
+ "displayContent": "It is in transit and arrives Thursday.",
102
+ "displayAvailable": true,
103
+ "timestamp": "2026-02-01T00:00:08.000Z"
104
+ }
105
+ ],
106
+ "nextMessageCursor": null
107
+ }
108
+ }
@@ -115,3 +115,18 @@ export function downloadInfoFor(record: ArtifactDownloadSource): {
115
115
  const title = record.title || "artifact";
116
116
  return { filename: `${title}.md`, mime: "text/markdown", content: raw };
117
117
  }
118
+
119
+ /** Save `content` as a browser download via a transient blob anchor. */
120
+ export function triggerArtifactDownload(info: {
121
+ filename: string;
122
+ mime: string;
123
+ content: string;
124
+ }): void {
125
+ const blob = new Blob([info.content], { type: info.mime });
126
+ const url = URL.createObjectURL(blob);
127
+ const a = document.createElement("a");
128
+ a.href = url;
129
+ a.download = info.filename;
130
+ a.click();
131
+ URL.revokeObjectURL(url);
132
+ }
@@ -371,6 +371,7 @@ describe("applyArtifactLayoutCssVars", () => {
371
371
  documentToolbarIconColor: "#60a5fa",
372
372
  documentToolbarToggleActiveBackground: "#262626",
373
373
  documentToolbarToggleActiveBorderColor: "#444444",
374
+ documentToolbarToggleActiveColor: "#e5e5e5",
374
375
  },
375
376
  },
376
377
  },
@@ -385,5 +386,8 @@ describe("applyArtifactLayoutCssVars", () => {
385
386
  expect(mount.style.getPropertyValue("--persona-artifact-doc-toggle-active-border").trim()).toBe(
386
387
  "#444444"
387
388
  );
389
+ expect(mount.style.getPropertyValue("--persona-artifact-doc-toggle-active-color").trim()).toBe(
390
+ "#e5e5e5"
391
+ );
388
392
  });
389
393
  });
@@ -107,23 +107,19 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
107
107
  mount.style.removeProperty("--persona-artifact-pane-padding");
108
108
  }
109
109
 
110
- const iconColor = l?.documentToolbarIconColor?.trim();
111
- if (iconColor) {
112
- mount.style.setProperty("--persona-artifact-doc-toolbar-icon-color", iconColor);
113
- } else {
114
- mount.style.removeProperty("--persona-artifact-doc-toolbar-icon-color");
115
- }
116
- const toggleBg = l?.documentToolbarToggleActiveBackground?.trim();
117
- if (toggleBg) {
118
- mount.style.setProperty("--persona-artifact-doc-toggle-active-bg", toggleBg);
119
- } else {
120
- mount.style.removeProperty("--persona-artifact-doc-toggle-active-bg");
121
- }
122
- const toggleBorder = l?.documentToolbarToggleActiveBorderColor?.trim();
123
- if (toggleBorder) {
124
- mount.style.setProperty("--persona-artifact-doc-toggle-active-border", toggleBorder);
125
- } else {
126
- mount.style.removeProperty("--persona-artifact-doc-toggle-active-border");
110
+ const docToolbarVars: Array<[string | undefined, string]> = [
111
+ [l?.documentToolbarIconColor, "--persona-artifact-doc-toolbar-icon-color"],
112
+ [l?.documentToolbarToggleActiveBackground, "--persona-artifact-doc-toggle-active-bg"],
113
+ [l?.documentToolbarToggleActiveBorderColor, "--persona-artifact-doc-toggle-active-border"],
114
+ [l?.documentToolbarToggleActiveColor, "--persona-artifact-doc-toggle-active-color"],
115
+ ];
116
+ for (const [raw, name] of docToolbarVars) {
117
+ const value = raw?.trim();
118
+ if (value) {
119
+ mount.style.setProperty(name, value);
120
+ } else {
121
+ mount.style.removeProperty(name);
122
+ }
127
123
  }
128
124
 
129
125
  applyArtifactPaneBorderTheme(mount, config);
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  ARTIFACT_RESIZE_CHAT_MIN_PX,
4
+ ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX,
4
5
  clampArtifactPaneWidth,
5
6
  maxArtifactWidthFromSplit,
6
7
  parseArtifactResizeMaxPxOptional,
@@ -61,4 +62,31 @@ describe("resolveArtifactPaneWidthPx", () => {
61
62
  const w = resolveArtifactPaneWidthPx(900, 1200, 8, 6, "200px", "320px");
62
63
  expect(w).toBeLessThanOrEqual(320);
63
64
  });
65
+
66
+ it("defaults the chat minimum to the flat reserve", () => {
67
+ const explicit = resolveArtifactPaneWidthPx(
68
+ 2000,
69
+ 1200,
70
+ 8,
71
+ 6,
72
+ "200px",
73
+ undefined,
74
+ ARTIFACT_RESIZE_CHAT_MIN_PX
75
+ );
76
+ expect(resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px")).toBe(explicit);
77
+ expect(explicit).toBe(1200 - ARTIFACT_RESIZE_CHAT_MIN_PX - 16 - 6);
78
+ });
79
+
80
+ it("shrinks the max by a larger chat minimum (docked rail + transcript)", () => {
81
+ const railed = 260 + ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX;
82
+ const w = resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px", undefined, railed);
83
+ expect(w).toBe(1200 - railed - 16 - 6);
84
+ expect(w).toBeLessThan(resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px"));
85
+ });
86
+
87
+ it("keeps min over max when the chat minimum eats the row", () => {
88
+ // 1200 - 1100 - gaps - handle < the 200px pane min: min still wins.
89
+ const w = resolveArtifactPaneWidthPx(2000, 1200, 8, 6, "200px", undefined, 1100);
90
+ expect(w).toBe(200);
91
+ });
64
92
  });
@@ -1,6 +1,12 @@
1
1
  /** Minimum width (px) reserved for the chat column while resizing the artifact pane. */
2
2
  export const ARTIFACT_RESIZE_CHAT_MIN_PX = 200;
3
3
 
4
+ /**
5
+ * Minimum transcript width (px) preserved beside a docked history rail while
6
+ * resizing the artifact pane. Added to the rail's own rendered width.
7
+ */
8
+ export const ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX = 360;
9
+
4
10
  /** Default minimum width (px) for the artifact column when `resizableMinWidth` is unset. */
5
11
  export const ARTIFACT_RESIZE_PANE_MIN_DEFAULT_PX = 200;
6
12
 
@@ -54,10 +60,11 @@ export function resolveArtifactPaneWidthPx(
54
60
  gapPx: number,
55
61
  handleWidthPx: number,
56
62
  resizableMinWidth?: string,
57
- resizableMaxWidth?: string
63
+ resizableMaxWidth?: string,
64
+ chatMinPx: number = ARTIFACT_RESIZE_CHAT_MIN_PX
58
65
  ): number {
59
66
  const minPx = parseArtifactResizePx(resizableMinWidth, ARTIFACT_RESIZE_PANE_MIN_DEFAULT_PX);
60
- let maxPx = maxArtifactWidthFromSplit(splitWidthPx, gapPx, handleWidthPx, ARTIFACT_RESIZE_CHAT_MIN_PX);
67
+ let maxPx = maxArtifactWidthFromSplit(splitWidthPx, gapPx, handleWidthPx, chatMinPx);
61
68
  maxPx = Math.max(minPx, maxPx);
62
69
  const cap = parseArtifactResizeMaxPxOptional(resizableMaxWidth);
63
70
  if (cap !== null) {
@@ -1,6 +1,10 @@
1
1
  import { createElement } from "./dom";
2
2
  import { renderLucideIcon } from "./icons";
3
- import { createDropdownMenu, type DropdownMenuItem } from "./dropdown";
3
+ import {
4
+ createDropdownMenu,
5
+ type DropdownMenuHandle,
6
+ type DropdownMenuItem,
7
+ } from "./dropdown";
4
8
 
5
9
  // ---------------------------------------------------------------------------
6
10
  // createIconButton
@@ -240,6 +244,182 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
240
244
  return { element: wrapper, setSelected };
241
245
  }
242
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // createSplitButton
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /** Options for {@link createSplitButton}. */
252
+ export interface CreateSplitButtonOptions {
253
+ /** Primary action text (always the accessible name; hidden visually with `iconOnly`). */
254
+ label: string;
255
+ /** Lucide icon for the primary half. */
256
+ icon?: string;
257
+ /** Primary icon size in pixels. Default: 14. */
258
+ iconSize?: number;
259
+ /** Render the primary half icon-only. */
260
+ iconOnly?: boolean;
261
+ /** Menu items behind the chevron half. */
262
+ menuItems: DropdownMenuItem[];
263
+ /** Called when a menu item is selected. */
264
+ onSelect: (id: string) => void;
265
+ /** Click handler for the primary half. */
266
+ onPrimary?: (e: MouseEvent) => void;
267
+ /** Menu alignment relative to the whole control. Default: "bottom-left". */
268
+ position?: "bottom-left" | "bottom-right";
269
+ /**
270
+ * Portal target for the menu (escapes `overflow: hidden` containers). A
271
+ * thunk is resolved when the menu is first built, so it may depend on the
272
+ * control being in the DOM.
273
+ */
274
+ portal?: HTMLElement | (() => HTMLElement | null | undefined);
275
+ /** Accessible label of the chevron half. Default: "More options". */
276
+ chevronLabel?: string;
277
+ /** Chevron icon size in pixels. Default: 14. */
278
+ chevronIconSize?: number;
279
+ /** Extra CSS class(es) on the wrapper. */
280
+ className?: string;
281
+ /** Extra CSS class(es) on the primary button. */
282
+ primaryClassName?: string;
283
+ /** Extra CSS class(es) on the chevron button. */
284
+ chevronClassName?: string;
285
+ }
286
+
287
+ /** Handle returned by {@link createSplitButton}. */
288
+ export interface SplitButtonHandle {
289
+ /** The bordered wrapper holding both halves. */
290
+ element: HTMLElement;
291
+ /** The primary-action half. */
292
+ primaryButton: HTMLButtonElement;
293
+ /** The menu-trigger half. */
294
+ chevronButton: HTMLButtonElement;
295
+ /** Open the menu. */
296
+ openMenu: () => void;
297
+ /** Close the menu. */
298
+ closeMenu: () => void;
299
+ /** Toggle the menu. */
300
+ toggleMenu: () => void;
301
+ /** Remove from DOM and clean up. */
302
+ destroy: () => void;
303
+ }
304
+
305
+ /**
306
+ * Creates a split button: one bordered control holding two separately
307
+ * invocable halves — a primary action button and an icon-only chevron that
308
+ * opens a dropdown menu — divided by an internal hairline. This is the
309
+ * Primer/Fluent/Material split-button anatomy; for a single button whose whole
310
+ * surface opens the menu, use {@link createComboButton} instead.
311
+ *
312
+ * Both halves are real `button`s (two tab stops). The chevron carries its own
313
+ * accessible label plus `aria-haspopup`/`aria-expanded`, kept accurate across
314
+ * outside-click dismissal.
315
+ *
316
+ * ```ts
317
+ * import { createSplitButton } from "@runtypelabs/persona";
318
+ *
319
+ * const split = createSplitButton({
320
+ * label: "Copy",
321
+ * icon: "copy",
322
+ * menuItems: [{ id: "download", label: "Download" }],
323
+ * onPrimary: () => copyToClipboard(),
324
+ * onSelect: (id) => handleMenuAction(id),
325
+ * });
326
+ * toolbar.appendChild(split.element);
327
+ * ```
328
+ */
329
+ export function createSplitButton(options: CreateSplitButtonOptions): SplitButtonHandle {
330
+ const {
331
+ label,
332
+ icon,
333
+ iconSize,
334
+ iconOnly,
335
+ menuItems,
336
+ onSelect,
337
+ onPrimary,
338
+ position = "bottom-left",
339
+ portal,
340
+ chevronLabel = "More options",
341
+ chevronIconSize,
342
+ className,
343
+ primaryClassName,
344
+ chevronClassName,
345
+ } = options;
346
+
347
+ const wrapper = createElement(
348
+ "div",
349
+ "persona-split-btn" + (className ? " " + className : ""),
350
+ );
351
+
352
+ const primaryBase = {
353
+ label,
354
+ className:
355
+ "persona-split-btn-primary" + (primaryClassName ? " " + primaryClassName : ""),
356
+ onClick: onPrimary,
357
+ };
358
+ const primaryButton =
359
+ iconOnly && icon
360
+ ? createIconButton({ ...primaryBase, icon, size: iconSize ?? 14 })
361
+ : createLabelButton({ ...primaryBase, icon, iconSize: iconSize ?? 14 });
362
+
363
+ const chevronButton = createIconButton({
364
+ icon: "chevron-down",
365
+ label: chevronLabel,
366
+ size: chevronIconSize ?? 14,
367
+ className:
368
+ "persona-split-btn-chevron" + (chevronClassName ? " " + chevronClassName : ""),
369
+ aria: { "aria-haspopup": "true", "aria-expanded": "false" },
370
+ });
371
+
372
+ wrapper.append(primaryButton, chevronButton);
373
+
374
+ let dropdown: DropdownMenuHandle | null = null;
375
+ const ensureMenu = (): DropdownMenuHandle => {
376
+ if (dropdown) return dropdown;
377
+ const target = (typeof portal === "function" ? portal() : portal) ?? undefined;
378
+ dropdown = createDropdownMenu({
379
+ items: menuItems,
380
+ onSelect,
381
+ anchor: wrapper,
382
+ position,
383
+ portal: target,
384
+ onOpenChange: (open) => {
385
+ chevronButton.setAttribute("aria-expanded", open ? "true" : "false");
386
+ },
387
+ });
388
+ // Non-portaled menus position absolutely inside the anchor.
389
+ if (!target) wrapper.appendChild(dropdown.element);
390
+ return dropdown;
391
+ };
392
+
393
+ // Build the (hidden) menu eagerly so it is queryable before first open, but
394
+ // wait a frame when the control is still detached: a portal thunk may need
395
+ // the mounted DOM to resolve its target.
396
+ if (wrapper.isConnected) {
397
+ ensureMenu();
398
+ } else {
399
+ requestAnimationFrame(() => {
400
+ if (!dropdown) ensureMenu();
401
+ });
402
+ }
403
+
404
+ chevronButton.addEventListener("click", (e) => {
405
+ e.stopPropagation();
406
+ ensureMenu().toggle();
407
+ });
408
+
409
+ return {
410
+ element: wrapper,
411
+ primaryButton,
412
+ chevronButton,
413
+ openMenu: () => ensureMenu().show(),
414
+ closeMenu: () => dropdown?.hide(),
415
+ toggleMenu: () => ensureMenu().toggle(),
416
+ destroy: () => {
417
+ dropdown?.destroy();
418
+ wrapper.remove();
419
+ },
420
+ };
421
+ }
422
+
243
423
  // ---------------------------------------------------------------------------
244
424
  // createComboButton
245
425
  // ---------------------------------------------------------------------------
@@ -380,9 +560,16 @@ export function createComboButton(options: CreateComboButtonOptions): ComboButto
380
560
  wrapper.appendChild(dropdown.element);
381
561
  }
382
562
 
563
+ // A shell may lock the menu while it has nothing to act on (e.g. no active
564
+ // conversation behind a conversation-title menu); locked, the combo reads
565
+ // as a plain title.
566
+ const locked = (): boolean =>
567
+ wrapper.getAttribute("data-persona-menu-locked") === "true";
568
+
383
569
  // Click toggles dropdown
384
570
  wrapper.addEventListener("click", (e) => {
385
571
  e.stopPropagation();
572
+ if (locked()) return;
386
573
  const isOpen = !dropdown.element.classList.contains("persona-hidden");
387
574
  wrapper.setAttribute("aria-expanded", isOpen ? "false" : "true");
388
575
  dropdown.toggle();
@@ -64,6 +64,97 @@ describe("mergeConfigUpdate", () => {
64
64
  expect(next.onSessionInit).toBe(second);
65
65
  });
66
66
 
67
+ it("replaces history identity/persistence callbacks wholesale", () => {
68
+ const firstProof = vi.fn();
69
+ const secondProof = vi.fn();
70
+ const firstGet = vi.fn();
71
+ const secondGet = vi.fn();
72
+ const firstSet = vi.fn();
73
+ const secondSet = vi.fn();
74
+ const firstClear = vi.fn();
75
+ const secondClear = vi.fn();
76
+ const firstClearSession = vi.fn();
77
+ const secondClearSession = vi.fn();
78
+
79
+ const prev = base({
80
+ getIdentityProof: firstProof,
81
+ getStoredConversationId: firstGet,
82
+ setStoredConversationId: firstSet,
83
+ clearStoredConversationId: firstClear,
84
+ clearStoredSessionId: firstClearSession,
85
+ });
86
+ const next = mergeConfigUpdate(prev, {
87
+ getIdentityProof: secondProof,
88
+ getStoredConversationId: secondGet,
89
+ setStoredConversationId: secondSet,
90
+ clearStoredConversationId: secondClear,
91
+ clearStoredSessionId: secondClearSession,
92
+ });
93
+
94
+ expect(next.getIdentityProof).toBe(secondProof);
95
+ expect(next.getStoredConversationId).toBe(secondGet);
96
+ expect(next.setStoredConversationId).toBe(secondSet);
97
+ expect(next.clearStoredConversationId).toBe(secondClear);
98
+ expect(next.clearStoredSessionId).toBe(secondClearSession);
99
+ });
100
+
101
+ it("preserves history identity/persistence callbacks absent from the patch", () => {
102
+ const getProof = vi.fn();
103
+ const getId = vi.fn();
104
+ const setId = vi.fn();
105
+ const clearId = vi.fn();
106
+ const clearSession = vi.fn();
107
+
108
+ const prev = base({
109
+ getIdentityProof: getProof,
110
+ getStoredConversationId: getId,
111
+ setStoredConversationId: setId,
112
+ clearStoredConversationId: clearId,
113
+ clearStoredSessionId: clearSession,
114
+ });
115
+ const next = mergeConfigUpdate(prev, { launcher: { title: "New" } });
116
+
117
+ expect(next.getIdentityProof).toBe(getProof);
118
+ expect(next.getStoredConversationId).toBe(getId);
119
+ expect(next.setStoredConversationId).toBe(setId);
120
+ expect(next.clearStoredConversationId).toBe(clearId);
121
+ expect(next.clearStoredSessionId).toBe(clearSession);
122
+ });
123
+
124
+ it("keeps features.history defaults when the host sets only enabled", () => {
125
+ const prev = base();
126
+ expect(prev.features?.history).toMatchObject({
127
+ enabled: false,
128
+ presentation: "panel",
129
+ showScopeStatus: true,
130
+ });
131
+
132
+ // Same guarantee through the defaults merge itself.
133
+ const withDefaults = mergeWithDefaults({ features: { history: { enabled: true } } });
134
+ expect(withDefaults.features?.history).toMatchObject({
135
+ enabled: true,
136
+ presentation: "panel",
137
+ showScopeStatus: true,
138
+ });
139
+
140
+ const next = mergeConfigUpdate(prev, { features: { history: { enabled: true } } });
141
+ expect(next.features?.history?.enabled).toBe(true);
142
+ expect(next.features?.history?.presentation).toBe("panel");
143
+ expect(next.features?.history?.showScopeStatus).toBe(true);
144
+ // Sibling feature blocks survive the history patch.
145
+ expect(next.features?.askUserQuestion?.enabled).toBe(true);
146
+ });
147
+
148
+ it("merges features.history.copy without dropping sibling copy overrides", () => {
149
+ const prev = base({ features: { history: { copy: { viewTitle: "Threads" } } } });
150
+ const next = mergeConfigUpdate(prev, {
151
+ features: { history: { copy: { emptyTitle: "Nothing yet" } } },
152
+ });
153
+ expect(next.features?.history?.copy?.viewTitle).toBe("Threads");
154
+ expect(next.features?.history?.copy?.emptyTitle).toBe("Nothing yet");
155
+ expect(next.features?.history?.presentation).toBe("panel");
156
+ });
157
+
67
158
  it("guards boolean|object unions in both directions", () => {
68
159
  const objFirst = base({ approval: { backgroundColor: "#ffffff" } });
69
160
  const toScalar = mergeConfigUpdate(objFirst, { approval: false });
@@ -28,6 +28,11 @@ export interface CreateDropdownOptions {
28
28
  * Use this to escape `overflow: hidden` containers.
29
29
  */
30
30
  portal?: HTMLElement;
31
+ /**
32
+ * Fired on every open/close transition, including outside-click dismissal.
33
+ * Use to keep a trigger's `aria-expanded` accurate.
34
+ */
35
+ onOpenChange?: (open: boolean) => void;
31
36
  }
32
37
 
33
38
  export interface DropdownMenuHandle {
@@ -65,7 +70,7 @@ export interface DropdownMenuHandle {
65
70
  * ```
66
71
  */
67
72
  export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenuHandle {
68
- const { items, onSelect, anchor, position = 'bottom-left', portal } = options;
73
+ const { items, onSelect, anchor, position = 'bottom-left', portal, onOpenChange } = options;
69
74
 
70
75
  const menu = createElement("div", "persona-dropdown-menu persona-hidden");
71
76
  menu.setAttribute("role", "menu");
@@ -137,8 +142,10 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
137
142
  }
138
143
 
139
144
  function show() {
145
+ const wasHidden = menu.classList.contains("persona-hidden");
140
146
  reposition();
141
147
  menu.classList.remove("persona-hidden");
148
+ if (wasHidden) onOpenChange?.(true);
142
149
  // Defer click-outside listener to avoid catching the triggering click
143
150
  requestAnimationFrame(() => {
144
151
  const handler = (e: MouseEvent) => {
@@ -152,9 +159,11 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
152
159
  }
153
160
 
154
161
  function hide() {
162
+ const wasOpen = !menu.classList.contains("persona-hidden");
155
163
  menu.classList.add("persona-hidden");
156
164
  cleanupClickOutside?.();
157
165
  cleanupClickOutside = null;
166
+ if (wasOpen) onOpenChange?.(false);
158
167
  }
159
168
 
160
169
  function toggle() {