@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
@@ -206,6 +206,12 @@ export type ArtifactPreviewBodyHandle = {
206
206
  el: HTMLElement;
207
207
  /** Apply streaming deltas / status changes (may be a different record). */
208
208
  update(record: PersonaArtifactRecord): void;
209
+ /**
210
+ * Release the file-preview loading state machine: pending overlay/settle
211
+ * timers and the window `message` listener, which otherwise outlive a
212
+ * discarded preview. Idempotent; call whenever the handle is dropped.
213
+ */
214
+ destroy(): void;
209
215
  };
210
216
 
211
217
  const PRE_CLASS =
@@ -990,5 +996,8 @@ export function renderArtifactPreviewBody(
990
996
  update(rec: PersonaArtifactRecord) {
991
997
  render(rec);
992
998
  },
999
+ destroy() {
1000
+ resetFilePreview();
1001
+ },
993
1002
  };
994
1003
  }
@@ -11,6 +11,8 @@ export const HEADER_THEME_CSS = {
11
11
  "var(--persona-header-subtitle-fg, var(--persona-text-muted, var(--persona-muted, #9ca3af)))",
12
12
  actionIconColor:
13
13
  "var(--persona-header-action-icon-fg, var(--persona-muted, #9ca3af))",
14
+ /** One knob for both header builders; the Messages rail header shares it. */
15
+ minHeight: "var(--persona-header-min-height, auto)",
14
16
  } as const;
15
17
 
16
18
  /**
@@ -65,6 +67,7 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
65
67
  "persona-widget-header persona-flex persona-items-center persona-gap-3 persona-px-6 persona-py-5",
66
68
  attrs: { "data-persona-theme-zone": "header" },
67
69
  style: {
70
+ minHeight: HEADER_THEME_CSS.minHeight,
68
71
  backgroundColor: "var(--persona-header-bg, var(--persona-surface, #ffffff))",
69
72
  borderBottomColor: "var(--persona-header-border, var(--persona-divider, #f1f5f9))",
70
73
  boxShadow: "var(--persona-header-shadow, none)",
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
 
5
- import { buildMinimalHeader } from "./header-layouts";
5
+ import { buildHeaderWithLayout, buildMinimalHeader } from "./header-layouts";
6
6
  import { HEADER_THEME_CSS } from "./header-builder";
7
+ import type { AgentWidgetConfig } from "../types";
8
+ import { setMacPlatformOverride } from "../utils/shortcuts";
7
9
 
8
10
  const build = (layoutHeaderConfig: Parameters<typeof buildMinimalHeader>[0]["layoutHeaderConfig"]) =>
9
11
  buildMinimalHeader({
@@ -27,21 +29,105 @@ describe("minimal header trailing actions", () => {
27
29
  );
28
30
  expect(button).not.toBeNull();
29
31
 
30
- // Trailing cluster, not the title row: the button shares a parent with
31
- // the close button wrapper and does not precede the title.
32
- expect(button!.parentElement).toBe(elements.closeButtonWrapper.parentElement);
32
+ // Trailing cluster, not the title row: the action's wrapper shares a
33
+ // parent with the close button wrapper and does not precede the title.
34
+ expect(button!.parentElement!.parentElement).toBe(
35
+ elements.closeButtonWrapper.parentElement
36
+ );
33
37
  expect(elements.headerTitle.parentElement!.contains(button!)).toBe(false);
34
38
 
35
- // Close-button chrome: 32px round hit area, header action-icon color.
36
- expect(button!.style.height).toBe("32px");
37
- expect(button!.style.width).toBe("32px");
39
+ // Shared header control chrome: token-sized box, no inline size, header
40
+ // action-icon color.
41
+ expect(button!.classList.contains("persona-header-control")).toBe(true);
42
+ expect(button!.classList.contains("persona-header-control--glyph")).toBe(true);
43
+ expect(button!.style.width).toBe("");
44
+ expect(button!.style.height).toBe("");
38
45
  expect(button!.style.color).toBe(HEADER_THEME_CSS.actionIconColor);
39
46
  expect(button!.className).toContain("persona-rounded-full");
47
+ // Flex wrapper: an inline-flex button in a block wrapper reserves baseline
48
+ // slack and rides high.
49
+ expect(button!.parentElement!.className).toContain("persona-inline-flex");
40
50
 
41
51
  button!.click();
42
52
  expect(onAction).toHaveBeenCalledWith("home");
43
53
  });
44
54
 
55
+ it("stamps a declared shortcut on the button and hints it in the tooltip", () => {
56
+ setMacPlatformOverride(false);
57
+ const elements = build({
58
+ layout: "minimal",
59
+ trailingActions: [
60
+ {
61
+ id: "home",
62
+ icon: "house",
63
+ ariaLabel: "Back to home",
64
+ shortcut: "mod+shift+h",
65
+ },
66
+ ],
67
+ });
68
+ const button = elements.header.querySelector<HTMLButtonElement>(
69
+ 'button[aria-label="Back to home"]'
70
+ )!;
71
+ // The shell finds the button by this id to run the binding.
72
+ expect(button.getAttribute("data-persona-header-action")).toBe("home");
73
+ expect(button.getAttribute("aria-keyshortcuts")).toBe("Control+Shift+H");
74
+
75
+ document.body.appendChild(elements.header);
76
+ button.parentElement!.dispatchEvent(new MouseEvent("mouseenter"));
77
+ expect(
78
+ document.querySelector(".persona-control-tooltip__hint")!.textContent
79
+ ).toBe("Ctrl+Shift+H");
80
+ document.body.innerHTML = "";
81
+ setMacPlatformOverride(null);
82
+ });
83
+
84
+ it("leaves aria-keyshortcuts off an action with no shortcut", () => {
85
+ const elements = build({
86
+ layout: "minimal",
87
+ trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
88
+ });
89
+ const button = elements.header.querySelector<HTMLButtonElement>(
90
+ 'button[aria-label="Back to home"]'
91
+ )!;
92
+ expect(button.hasAttribute("aria-keyshortcuts")).toBe(false);
93
+ });
94
+
95
+ it("keeps the close button transparent so the UA button fill never shows", () => {
96
+ const elements = build({ layout: "minimal" });
97
+ expect(elements.closeButton.className).toContain("persona-bg-transparent");
98
+ expect(elements.closeButton.className).toContain("hover:persona-bg-gray-100");
99
+ expect(elements.closeButton.style.backgroundColor).toBe("");
100
+ });
101
+
102
+ it("shares the header control chrome and a flex wrapper with the default layout", () => {
103
+ const elements = build({ layout: "minimal" });
104
+ expect(elements.closeButton.classList.contains("persona-header-control")).toBe(true);
105
+ // Sparse X glyph: the stylesheet scales it up from the icon-size token.
106
+ expect(
107
+ elements.closeButton.classList.contains("persona-header-control--sparse")
108
+ ).toBe(true);
109
+ expect(elements.closeButton.style.width).toBe("");
110
+ // The centering bug: a block wrapper reserves inline baseline slack, so
111
+ // the button rides high inside it.
112
+ expect(elements.closeButtonWrapper.className).toContain("persona-inline-flex");
113
+ expect(elements.closeButtonWrapper.className).toContain("persona-items-center");
114
+ });
115
+
116
+ it("applies closeButtonBackgroundColor instead of the transparent default", () => {
117
+ const elements = buildMinimalHeader({
118
+ config: {
119
+ apiUrl: "https://example.com/api",
120
+ launcher: { closeButtonBackgroundColor: "red" },
121
+ },
122
+ showClose: true,
123
+ onClose: () => {},
124
+ layoutHeaderConfig: { layout: "minimal" },
125
+ });
126
+ expect(elements.closeButton.style.backgroundColor).toBe("red");
127
+ expect(elements.closeButton.className).not.toContain("persona-bg-transparent");
128
+ expect(elements.closeButton.className).not.toContain("hover:persona-bg-gray-100");
129
+ });
130
+
45
131
  it("still ignores trailingActions when titleMenu is configured", () => {
46
132
  const elements = build({
47
133
  layout: "minimal",
@@ -54,3 +140,81 @@ describe("minimal header trailing actions", () => {
54
140
  ).toBeNull();
55
141
  });
56
142
  });
143
+
144
+ describe("minimal header clear chat", () => {
145
+ it("renders clear chat in the cluster between trailing actions and close", () => {
146
+ const elements = build({
147
+ layout: "minimal",
148
+ trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
149
+ });
150
+
151
+ const clear = elements.clearChatButton;
152
+ expect(clear).not.toBeNull();
153
+ expect(clear!.classList.contains("persona-header-control")).toBe(true);
154
+ expect(elements.clearChatButtonWrapper!.parentElement).toBe(
155
+ elements.closeButtonWrapper.parentElement
156
+ );
157
+ const action = elements.header.querySelector('button[aria-label="Back to home"]')!;
158
+ expect(
159
+ action.compareDocumentPosition(clear!) & Node.DOCUMENT_POSITION_FOLLOWING
160
+ ).toBeTruthy();
161
+ expect(
162
+ clear!.compareDocumentPosition(elements.closeButton) &
163
+ Node.DOCUMENT_POSITION_FOLLOWING
164
+ ).toBeTruthy();
165
+ });
166
+
167
+ it("renders nothing when launcher.clearChat.enabled is false", () => {
168
+ const elements = buildMinimalHeader({
169
+ config: {
170
+ apiUrl: "https://example.com/api",
171
+ launcher: { clearChat: { enabled: false } },
172
+ },
173
+ showClose: true,
174
+ onClose: () => {},
175
+ layoutHeaderConfig: { layout: "minimal" },
176
+ });
177
+ expect(elements.clearChatButton).toBeNull();
178
+ expect(elements.clearChatButtonWrapper).toBeNull();
179
+ });
180
+
181
+ it("hides the wrapper under layout.header.showClearChat false", () => {
182
+ const config = {
183
+ apiUrl: "https://example.com/api",
184
+ layout: { header: { layout: "minimal", showClearChat: false } },
185
+ } as unknown as AgentWidgetConfig;
186
+ const elements = buildHeaderWithLayout(config, config.layout!.header!, {
187
+ config,
188
+ showClose: true,
189
+ onClose: () => {},
190
+ });
191
+ expect(elements.clearChatButtonWrapper).not.toBeNull();
192
+ expect(elements.clearChatButtonWrapper!.style.display).toBe("none");
193
+ });
194
+
195
+ it("keeps a top-right placement out of the header for the container mount", () => {
196
+ const elements = buildMinimalHeader({
197
+ config: {
198
+ apiUrl: "https://example.com/api",
199
+ launcher: { clearChat: { placement: "top-right" } },
200
+ },
201
+ showClose: true,
202
+ onClose: () => {},
203
+ layoutHeaderConfig: { layout: "minimal" },
204
+ });
205
+ expect(elements.clearChatButtonWrapper).not.toBeNull();
206
+ expect(elements.header.contains(elements.clearChatButtonWrapper!)).toBe(false);
207
+ expect(elements.clearChatButtonWrapper!.style.right).toBe("48px");
208
+ });
209
+
210
+ it("still renders clear chat and close when titleMenu is configured", () => {
211
+ const elements = build({
212
+ layout: "minimal",
213
+ titleMenu: { menuItems: [{ id: "a", label: "A" }], onSelect: () => {} },
214
+ });
215
+ expect(elements.clearChatButton).not.toBeNull();
216
+ expect(elements.clearChatButtonWrapper!.parentElement).toBe(
217
+ elements.closeButtonWrapper.parentElement
218
+ );
219
+ });
220
+ });
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "../utils/dom";
2
- import { renderLucideIcon } from "../utils/icons";
3
2
  import { createDropdownMenu } from "../utils/dropdown";
4
3
  import { createComboButton } from "../utils/buttons";
4
+ import { ariaCombo, formatCombo } from "../utils/shortcuts";
5
5
  import {
6
6
  AgentWidgetConfig,
7
7
  AgentWidgetHeaderLayoutConfig,
@@ -14,6 +14,11 @@ import {
14
14
  HeaderElements,
15
15
  attachHeaderToContainer as _attachHeaderToContainer,
16
16
  } from "./header-builder";
17
+ import {
18
+ createClearChatButton,
19
+ createCloseButton,
20
+ createHeaderIconButton,
21
+ } from "./header-parts";
17
22
 
18
23
  export interface HeaderLayoutContext {
19
24
  config: AgentWidgetConfig;
@@ -71,29 +76,25 @@ function appendTrailingHeaderActions(
71
76
  ): void {
72
77
  if (!actions?.length) return;
73
78
  for (const a of actions) {
74
- // Same chrome as the close button beside them: 32px round hit area,
75
- // hover fill, and the header zone's action-icon color (the muted body
76
- // token disappears on themed headers).
77
- const btn = createElement(
78
- "button",
79
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-0"
80
- ) as HTMLButtonElement;
81
- btn.type = "button";
82
- btn.style.height = "32px";
83
- btn.style.width = "32px";
84
- btn.style.color = HEADER_THEME_CSS.actionIconColor;
85
- btn.setAttribute("aria-label", a.ariaLabel ?? a.label ?? a.id);
86
- if (a.icon) {
87
- const ic = renderLucideIcon(a.icon, 16, "currentColor", 2);
88
- if (ic) btn.appendChild(ic);
89
- } else if (a.label) {
90
- btn.textContent = a.label;
79
+ // Same shared chrome as the close button beside them. The wrapper stays
80
+ // relative so a dropdown can anchor to it.
81
+ const { button: btn, wrapper } = createHeaderIconButton({
82
+ ariaLabel: a.ariaLabel ?? a.label ?? a.id,
83
+ iconName: a.icon,
84
+ iconText: a.icon ? undefined : a.label,
85
+ tooltipHint: a.shortcut ? formatCombo(a.shortcut) : undefined,
86
+ wrapperClassName:
87
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center",
88
+ });
89
+ // The keydown binding is registered by the shell, which finds the button
90
+ // by this id; the shortcut's other two artifacts are stamped here.
91
+ btn.setAttribute("data-persona-header-action", a.id);
92
+ if (a.shortcut) {
93
+ const aria = ariaCombo(a.shortcut);
94
+ if (aria) btn.setAttribute("aria-keyshortcuts", aria);
91
95
  }
92
96
 
93
97
  if (a.menuItems?.length) {
94
- // Wrap in a relative container for dropdown positioning
95
- const wrapper = createElement("div", "persona-relative");
96
- wrapper.appendChild(btn);
97
98
  const dropdown = createDropdownMenu({
98
99
  items: a.menuItems,
99
100
  onSelect: (itemId) => onAction?.(itemId),
@@ -107,11 +108,10 @@ function appendTrailingHeaderActions(
107
108
  e.stopPropagation();
108
109
  dropdown.toggle();
109
110
  });
110
- container.appendChild(wrapper);
111
111
  } else {
112
112
  btn.addEventListener("click", () => onAction?.(a.id));
113
- container.appendChild(btn);
114
113
  }
114
+ container.appendChild(wrapper);
115
115
  }
116
116
  }
117
117
 
@@ -119,11 +119,13 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
119
119
  const { config, showClose = true, onClose, layoutHeaderConfig, onHeaderAction } = context;
120
120
  const launcher = config?.launcher ?? {};
121
121
 
122
+ // max(title 24px, cluster 32px) + py-4 + border = the strip's 65px.
122
123
  const header = createElement(
123
124
  "div",
124
125
  "persona-flex persona-items-center persona-justify-between persona-px-6 persona-py-4"
125
126
  );
126
127
  header.setAttribute("data-persona-theme-zone", "header");
128
+ header.style.minHeight = HEADER_THEME_CSS.minHeight;
127
129
  header.style.backgroundColor = 'var(--persona-header-bg, var(--persona-surface, #ffffff))';
128
130
  header.style.borderBottomColor = 'var(--persona-header-border, var(--persona-divider, #f1f5f9))';
129
131
  header.style.boxShadow = 'var(--persona-header-shadow, none)';
@@ -140,7 +142,28 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
140
142
  const combo = createComboButton({
141
143
  label: launcher.title ?? "Chat Assistant",
142
144
  menuItems: titleMenuConfig.menuItems,
143
- onSelect: titleMenuConfig.onSelect,
145
+ // `false` = not handled: bubble the id to the shell's built-ins (star,
146
+ // delete on the active conversation). A DOM event, not a context
147
+ // callback, because the initial header is built by the pure panel
148
+ // builder with no shell in reach.
149
+ onSelect: (itemId) => {
150
+ const finish = (handled: unknown): void => {
151
+ if (handled !== false) return;
152
+ combo.element.dispatchEvent(
153
+ new CustomEvent("persona:title-menu-builtin", {
154
+ bubbles: true,
155
+ composed: true,
156
+ detail: { actionId: itemId },
157
+ })
158
+ );
159
+ };
160
+ const result = titleMenuConfig.onSelect(itemId);
161
+ if (result instanceof Promise) {
162
+ void result.then(finish, () => {});
163
+ } else {
164
+ finish(result);
165
+ }
166
+ },
144
167
  hover: titleMenuConfig.hover,
145
168
  className: "",
146
169
  });
@@ -203,38 +226,20 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
203
226
 
204
227
  header.appendChild(titleRow);
205
228
 
206
- // Close button
207
- const closeButtonSize = launcher.closeButtonSize ?? "32px";
208
- const closeButtonWrapper = createElement("div", "");
209
-
210
- const closeButton = createElement(
211
- "button",
212
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
213
- ) as HTMLButtonElement;
214
- closeButton.style.height = closeButtonSize;
215
- closeButton.style.width = closeButtonSize;
216
- closeButton.type = "button";
217
- closeButton.setAttribute("aria-label", "Close chat");
218
- closeButton.style.display = showClose ? "" : "none";
219
- closeButton.style.color =
220
- launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
221
-
222
- const closeButtonIconName = launcher.closeButtonIconName ?? "x";
223
- // Larger intrinsic size compensates for the X glyph's sparse viewBox
224
- // (paths only occupy the middle 50%). Matches header-builder.ts.
225
- const closeIconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
226
- if (closeIconSvg) {
227
- closeButton.appendChild(closeIconSvg);
228
- } else {
229
- closeButton.textContent = "×";
230
- }
229
+ // Close button: same shared factory the default layout uses. The wrapper is
230
+ // flex so an inline-flex button can never reserve baseline slack and ride
231
+ // high inside it.
232
+ const trailingWrapperClass =
233
+ "persona-relative persona-inline-flex persona-items-center persona-justify-center";
234
+ const { button: closeButton, wrapper: closeButtonWrapper } = createCloseButton(
235
+ config,
236
+ { showClose, wrapperClassName: trailingWrapperClass }
237
+ );
231
238
 
232
239
  if (onClose) {
233
240
  closeButton.addEventListener("click", onClose);
234
241
  }
235
242
 
236
- closeButtonWrapper.appendChild(closeButton);
237
-
238
243
  // Trailing edge: action buttons cluster with the close button, matching
239
244
  // its chrome. `titleMenu` still ignores `trailingActions` (documented).
240
245
  const trailingCluster = createElement(
@@ -248,6 +253,31 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
248
253
  layoutHeaderConfig?.onAction ?? onHeaderAction
249
254
  );
250
255
  }
256
+
257
+ // Clear chat honors the same config surface as the default layout. Click
258
+ // wiring is owned by setupClearChatButton() in ui.ts via the returned ref;
259
+ // top-right placement is mounted by attachHeaderToContainer, also via ref.
260
+ const clearChatConfig = launcher.clearChat ?? {};
261
+ let clearChatButton: HTMLButtonElement | null = null;
262
+ let clearChatButtonWrapper: HTMLElement | null = null;
263
+ if (clearChatConfig.enabled ?? true) {
264
+ const clearChatPlacement = clearChatConfig.placement ?? "inline";
265
+ const parts = createClearChatButton(config, {
266
+ wrapperClassName:
267
+ clearChatPlacement === "top-right"
268
+ ? "persona-absolute persona-top-4 persona-z-50"
269
+ : trailingWrapperClass,
270
+ });
271
+ clearChatButton = parts.button;
272
+ clearChatButtonWrapper = parts.wrapper;
273
+ if (clearChatPlacement === "top-right") {
274
+ clearChatButtonWrapper.style.right = "48px";
275
+ } else {
276
+ // Close stays outermost.
277
+ trailingCluster.appendChild(clearChatButtonWrapper);
278
+ }
279
+ }
280
+
251
281
  trailingCluster.appendChild(closeButtonWrapper);
252
282
  header.appendChild(trailingCluster);
253
283
 
@@ -266,8 +296,8 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
266
296
  headerSubtitle,
267
297
  closeButton,
268
298
  closeButtonWrapper,
269
- clearChatButton: null,
270
- clearChatButtonWrapper: null
299
+ clearChatButton,
300
+ clearChatButtonWrapper
271
301
  };
272
302
  };
273
303
 
@@ -1,11 +1,152 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
- import { createCloseButton, createClearChatButton } from "./header-parts";
4
+ import {
5
+ createCloseButton,
6
+ createClearChatButton,
7
+ createHeaderIconButton,
8
+ HEADER_CONTROL_CLASS,
9
+ HEADER_CONTROL_GLYPH_CLASS,
10
+ HEADER_CONTROL_SPARSE_CLASS,
11
+ } from "./header-parts";
12
+ import { HEADER_THEME_CSS } from "./header-builder";
5
13
  import type { AgentWidgetConfig } from "../types";
6
14
 
7
15
  const baseConfig: AgentWidgetConfig = { apiUrl: "/api" };
8
16
 
17
+ describe("createHeaderIconButton", () => {
18
+ it("leaves the box and glyph to the tokens when nothing is configured", () => {
19
+ const { button } = createHeaderIconButton({
20
+ ariaLabel: "Do it",
21
+ iconName: "refresh-cw",
22
+ });
23
+ expect(button.classList.contains(HEADER_CONTROL_CLASS)).toBe(true);
24
+ expect(button.classList.contains(HEADER_CONTROL_GLYPH_CLASS)).toBe(true);
25
+ expect(button.classList.contains(HEADER_CONTROL_SPARSE_CLASS)).toBe(false);
26
+ expect(button.style.width).toBe("");
27
+ expect(button.style.height).toBe("");
28
+ expect(button.style.minWidth).toBe("");
29
+ expect(button.type).toBe("button");
30
+ expect(button.getAttribute("aria-label")).toBe("Do it");
31
+ expect(button.style.color).toBe(HEADER_THEME_CSS.actionIconColor);
32
+ });
33
+
34
+ it("writes an inline box and drops the glyph class for explicit sizes", () => {
35
+ const { button } = createHeaderIconButton({
36
+ ariaLabel: "Do it",
37
+ iconName: "refresh-cw",
38
+ size: "16px",
39
+ iconSize: "14px",
40
+ });
41
+ expect(button.classList.contains(HEADER_CONTROL_CLASS)).toBe(true);
42
+ expect(button.classList.contains(HEADER_CONTROL_GLYPH_CLASS)).toBe(false);
43
+ expect(button.style.width).toBe("16px");
44
+ expect(button.style.minHeight).toBe("16px");
45
+ const svg = button.querySelector("svg")!;
46
+ expect(svg.getAttribute("width")).toBe("14px");
47
+ expect(svg.getAttribute("stroke-width")).toBe("1.5");
48
+ expect(svg.style.strokeWidth).toBe(
49
+ "calc(var(--persona-components-header-controlStrokeWidth, 1.5) * 1)"
50
+ );
51
+ });
52
+
53
+ it("routes the stroke token inline for explicitly sized glyphs", () => {
54
+ const { button } = createHeaderIconButton({
55
+ ariaLabel: "Close",
56
+ iconName: "x",
57
+ iconSize: "12px",
58
+ });
59
+ // No class hooks, so the stylesheet rules can't reach this control: the
60
+ // inline style carries the stroke token, with the attribute as the
61
+ // no-token fallback and the sparse 0.7 factor preserved.
62
+ expect(button.classList.contains(HEADER_CONTROL_GLYPH_CLASS)).toBe(false);
63
+ expect(button.classList.contains(HEADER_CONTROL_SPARSE_CLASS)).toBe(false);
64
+ const svg = button.querySelector("svg")!;
65
+ expect(svg.getAttribute("stroke-width")).toBe("1.05");
66
+ expect(svg.style.strokeWidth).toBe(
67
+ "calc(var(--persona-components-header-controlStrokeWidth, 1.5) * 0.7)"
68
+ );
69
+ });
70
+
71
+ it("compensates the sparse X glyph in both the class hook and the attributes", () => {
72
+ const token = createHeaderIconButton({ ariaLabel: "Close", iconName: "x" });
73
+ expect(token.button.classList.contains(HEADER_CONTROL_SPARSE_CLASS)).toBe(true);
74
+
75
+ const explicit = createHeaderIconButton({
76
+ ariaLabel: "Close",
77
+ iconName: "x",
78
+ iconSize: "10px",
79
+ });
80
+ // Nominal 10px scaled by 1.4, stroke thinned by the same factor so the
81
+ // rendered weight still matches a dense sibling glyph.
82
+ const svg = explicit.button.querySelector("svg")!;
83
+ expect(svg.getAttribute("width")).toBe("14px");
84
+ expect(svg.getAttribute("stroke-width")).toBe("1.05");
85
+ expect(explicit.button.classList.contains(HEADER_CONTROL_SPARSE_CLASS)).toBe(false);
86
+ });
87
+
88
+ it("swaps the transparent default classes for an explicit background", () => {
89
+ const { button } = createHeaderIconButton({
90
+ ariaLabel: "Do it",
91
+ iconName: "refresh-cw",
92
+ backgroundColor: "rgb(1, 2, 3)",
93
+ color: "rgb(9, 9, 9)",
94
+ });
95
+ expect(button.className).not.toContain("persona-bg-transparent");
96
+ expect(button.className).not.toContain("hover:persona-bg-gray-100");
97
+ expect(button.style.backgroundColor).toBe("rgb(1, 2, 3)");
98
+ expect(button.style.color).toBe("rgb(9, 9, 9)");
99
+ });
100
+
101
+ it("falls back to the text glyph when the icon is not in the registry", () => {
102
+ const { button } = createHeaderIconButton({
103
+ ariaLabel: "Close",
104
+ iconName: "not-a-real-icon",
105
+ iconText: "×",
106
+ });
107
+ expect(button.querySelector("svg")).toBeNull();
108
+ expect(button.textContent).toBe("×");
109
+ });
110
+
111
+ it("wraps the button in a flex box so it can never ride off-center", () => {
112
+ const { button, wrapper } = createHeaderIconButton({
113
+ ariaLabel: "Do it",
114
+ iconName: "refresh-cw",
115
+ });
116
+ expect(button.parentElement).toBe(wrapper);
117
+ expect(wrapper.className).toContain("persona-inline-flex");
118
+ expect(wrapper.className).toContain("persona-items-center");
119
+ expect(wrapper.className).toContain("persona-justify-center");
120
+ });
121
+
122
+ it("attaches the styled tooltip by default, reading the live aria-label", () => {
123
+ const { button, wrapper } = createHeaderIconButton({
124
+ ariaLabel: "Messages",
125
+ iconName: "history",
126
+ });
127
+ document.body.appendChild(wrapper);
128
+ button.setAttribute("aria-label", "Opening conversation");
129
+ wrapper.dispatchEvent(new MouseEvent("mouseenter"));
130
+ const tooltip = document.body.querySelector(".persona-control-tooltip");
131
+ expect(tooltip).not.toBeNull();
132
+ expect(tooltip!.textContent).toContain("Opening conversation");
133
+ wrapper.remove();
134
+ tooltip!.remove();
135
+ });
136
+
137
+ it("attaches no tooltip when tooltip is false", () => {
138
+ const { wrapper } = createHeaderIconButton({
139
+ ariaLabel: "Silent",
140
+ iconName: "x",
141
+ tooltip: false,
142
+ });
143
+ document.body.appendChild(wrapper);
144
+ wrapper.dispatchEvent(new MouseEvent("mouseenter"));
145
+ expect(document.body.querySelector(".persona-control-tooltip")).toBeNull();
146
+ wrapper.remove();
147
+ });
148
+ });
149
+
9
150
  describe("createCloseButton", () => {
10
151
  it("keeps the default utility classes when no style overrides are set", () => {
11
152
  const { button } = createCloseButton(baseConfig, {});
@@ -15,6 +156,18 @@ describe("createCloseButton", () => {
15
156
  expect(button.classList.contains("persona-rounded-full")).toBe(true);
16
157
  expect(button.classList.contains("persona-border-none")).toBe(true);
17
158
  expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(true);
159
+ // Unset `launcher.closeButtonSize` leaves the control-size token in charge.
160
+ expect(button.classList.contains(HEADER_CONTROL_CLASS)).toBe(true);
161
+ expect(button.style.width).toBe("");
162
+ });
163
+
164
+ it("still lets launcher.closeButtonSize pin the box past the token", () => {
165
+ const { button } = createCloseButton(
166
+ { ...baseConfig, launcher: { closeButtonSize: "28px" } },
167
+ {}
168
+ );
169
+ expect(button.style.width).toBe("28px");
170
+ expect(button.style.minWidth).toBe("28px");
18
171
  });
19
172
 
20
173
  it("hides the button when showClose is false", () => {