@runtypelabs/persona 4.23.1 → 4.24.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 (158) 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-9Vxm-PPz.d.cts → types-Db61aI9J.d.cts} +25 -2
  4. package/dist/animations/{types-9Vxm-PPz.d.ts → types-Db61aI9J.d.ts} +25 -2
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +25 -2
  8. package/dist/animations-extra.d.ts +25 -2
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +261 -40
  12. package/dist/approval-ui.d.ts +261 -40
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +256 -28
  16. package/dist/artifacts-ui.d.ts +256 -28
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +256 -28
  21. package/dist/context-mentions.d.ts +256 -28
  22. package/dist/event-stream-view.d.cts +256 -28
  23. package/dist/event-stream-view.d.ts +256 -28
  24. package/dist/forms-ui.d.cts +291 -30
  25. package/dist/forms-ui.d.ts +291 -30
  26. package/dist/history-view.d.cts +25 -2
  27. package/dist/history-view.d.ts +25 -2
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +337 -51
  31. package/dist/index.d.ts +337 -51
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +256 -28
  41. package/dist/session-reconnect.d.ts +256 -28
  42. package/dist/smart-dom-reader.d.cts +256 -28
  43. package/dist/smart-dom-reader.d.ts +256 -28
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +263 -28
  46. package/dist/theme-editor-preview.d.ts +263 -28
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +276 -30
  50. package/dist/theme-editor.d.ts +276 -30
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/widget.css +1 -1
  57. package/package.json +3 -2
  58. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  59. package/src/approval-ui-entry.ts +0 -1
  60. package/src/approval-ui-loader.ts +5 -4
  61. package/src/approval-ui.ts +0 -1
  62. package/src/client-identity-proof.test.ts +193 -0
  63. package/src/client-session-warm.test.ts +216 -0
  64. package/src/client-visitor-history.test.ts +6 -0
  65. package/src/client.tool-pair-replay.test.ts +325 -0
  66. package/src/client.ts +154 -61
  67. package/src/codegen.test.ts +24 -0
  68. package/src/components/activity-row.test.ts +100 -0
  69. package/src/components/activity-row.ts +136 -0
  70. package/src/components/approval-actions.test.ts +8 -5
  71. package/src/components/approval-actions.ts +19 -6
  72. package/src/components/approval-bubble.test.ts +56 -9
  73. package/src/components/approval-bubble.ts +36 -41
  74. package/src/components/composer-builder.ts +5 -2
  75. package/src/components/composer-parts.ts +10 -2
  76. package/src/components/header-builder.test.ts +32 -0
  77. package/src/components/header-builder.ts +7 -4
  78. package/src/components/header-layouts.test.ts +12 -0
  79. package/src/components/header-layouts.ts +3 -2
  80. package/src/components/launcher.ts +11 -2
  81. package/src/components/message-bubble.test.ts +22 -0
  82. package/src/components/message-bubble.ts +7 -3
  83. package/src/components/panel.test.ts +52 -1
  84. package/src/components/panel.ts +47 -8
  85. package/src/components/pill-composer-builder.test.ts +2 -0
  86. package/src/components/pill-composer-builder.ts +0 -1
  87. package/src/components/reasoning-bubble.ts +29 -17
  88. package/src/components/suggestions.test.ts +1 -0
  89. package/src/components/suggestions.ts +0 -16
  90. package/src/components/tool-bubble.ts +131 -114
  91. package/src/components/tool-details.test.ts +87 -0
  92. package/src/components/tool-details.ts +102 -0
  93. package/src/components/welcome.test.ts +8 -0
  94. package/src/components/welcome.ts +4 -2
  95. package/src/defaults-parity.test.ts +158 -0
  96. package/src/defaults.test.ts +69 -0
  97. package/src/defaults.ts +128 -49
  98. package/src/generated/runtype-openapi-contract.ts +28 -13
  99. package/src/index-core.ts +2 -0
  100. package/src/install.test.ts +24 -0
  101. package/src/install.ts +3 -1
  102. package/src/launcher-global.ts +2 -1
  103. package/src/launcher-variants.test.ts +52 -0
  104. package/src/runtime/init-update-reset.test.ts +21 -0
  105. package/src/session.ts +61 -9
  106. package/src/styles/widget-styles.test.ts +19 -0
  107. package/src/styles/widget.css +295 -23
  108. package/src/theme-editor/index.ts +2 -0
  109. package/src/theme-editor/preview-utils.test.ts +8 -0
  110. package/src/theme-editor/preview-utils.ts +7 -3
  111. package/src/theme-editor/sections.test.ts +31 -0
  112. package/src/theme-editor/sections.ts +55 -1
  113. package/src/theme-editor/state-preferences.test.ts +49 -0
  114. package/src/theme-editor/state.ts +73 -15
  115. package/src/theme-reference.test.ts +20 -0
  116. package/src/theme-reference.ts +11 -5
  117. package/src/tool-call-display-defaults.test.ts +33 -1
  118. package/src/types/theme.ts +80 -3
  119. package/src/types.ts +179 -23
  120. package/src/ui.activity-row.test.ts +91 -0
  121. package/src/ui.approval-plugin.test.ts +63 -0
  122. package/src/ui.composer-compact.test.ts +24 -2
  123. package/src/ui.composer-placement.test.ts +23 -0
  124. package/src/ui.detached-panel.test.ts +33 -6
  125. package/src/ui.docked.test.ts +19 -0
  126. package/src/ui.header-update-stability.test.ts +25 -0
  127. package/src/ui.identity-proof.test.ts +58 -0
  128. package/src/ui.launcher-teaser.test.ts +25 -0
  129. package/src/ui.message-width.test.ts +17 -2
  130. package/src/ui.reasoning-display.test.ts +92 -0
  131. package/src/ui.rebuild-composer.test.ts +20 -3
  132. package/src/ui.scroll.test.ts +32 -0
  133. package/src/ui.session-init.test.ts +312 -0
  134. package/src/ui.status-indicator.test.ts +82 -0
  135. package/src/ui.suggest-replies.test.ts +11 -0
  136. package/src/ui.ts +363 -74
  137. package/src/ui.v5-defaults.test.ts +144 -0
  138. package/src/ui.welcome.test.ts +92 -3
  139. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  140. package/src/utils/code-generators.ts +14 -0
  141. package/src/utils/composer-compact.test.ts +0 -2
  142. package/src/utils/composer-compact.ts +2 -4
  143. package/src/utils/composer-placement.ts +2 -3
  144. package/src/utils/config-merge.test.ts +81 -5
  145. package/src/utils/config-merge.ts +13 -3
  146. package/src/utils/defaults-provenance.ts +67 -0
  147. package/src/utils/dock.ts +7 -1
  148. package/src/utils/launcher-variant.ts +16 -0
  149. package/src/utils/panel-config.ts +97 -0
  150. package/src/utils/status-indicator.ts +39 -0
  151. package/src/utils/theme.test.ts +182 -0
  152. package/src/utils/theme.ts +131 -18
  153. package/src/utils/tokens.test.ts +122 -1
  154. package/src/utils/tokens.ts +177 -17
  155. package/src/utils/tool-pair-replay.ts +100 -0
  156. package/src/welcome.test.ts +23 -0
  157. package/src/welcome.ts +16 -9
  158. package/dist/approval-ui-K7R5Q5OC.js +0 -1
@@ -82,10 +82,11 @@ describe("createAgentExperience detached panel", () => {
82
82
  setInnerWidth(originalInnerWidth);
83
83
  });
84
84
 
85
- it("keeps flush sidebar chrome when detachedPanel is unset", () => {
85
+ it.each([false, true])("keeps flush sidebar chrome when detachedPanel is unset (v5=%s)", (v5Defaults) => {
86
86
  const mount = createMount();
87
87
  const controller = createAgentExperience(mount, {
88
88
  apiUrl: "https://api.example.com/chat",
89
+ future: { v5Defaults },
89
90
  launcher: {
90
91
  sidebarMode: true,
91
92
  position: "bottom-right",
@@ -100,7 +101,9 @@ describe("createAgentExperience detached panel", () => {
100
101
  expect(wrapper.style.bottom).toBe("0px");
101
102
  expect(wrapper.style.right).toBe("0px");
102
103
  expect(wrapper.style.height).toBe("100vh");
103
- expect(panel?.style.borderRadius).toBe("0px");
104
+ expect(panel?.style.borderRadius).toBe(
105
+ "var(--persona-components-panel-modes-sidebar-borderRadius, 0)"
106
+ );
104
107
 
105
108
  controller.destroy();
106
109
  });
@@ -172,11 +175,12 @@ describe("createAgentExperience detached panel", () => {
172
175
  controller.destroy();
173
176
  });
174
177
 
175
- it("drops card chrome for docked + launcher off + detached on a mobile viewport", () => {
178
+ it.each([false, true])("drops card chrome for docked + launcher off + detached on a mobile viewport (v5=%s)", (v5Defaults) => {
176
179
  setInnerWidth(480);
177
180
  const mount = createMount();
178
181
  const controller = createAgentExperience(mount, {
179
182
  apiUrl: "https://api.example.com/chat",
183
+ future: { v5Defaults },
180
184
  launcher: {
181
185
  mountMode: "docked",
182
186
  enabled: false,
@@ -188,7 +192,9 @@ describe("createAgentExperience detached panel", () => {
188
192
 
189
193
  // Host-layout goes flush fullscreen: no detached attribute, no card shadow or inset.
190
194
  expect(mount.hasAttribute("data-persona-panel-detached")).toBe(false);
191
- expect(panel?.style.boxShadow).toBe("none");
195
+ expect(panel?.style.boxShadow).toBe(
196
+ "var(--persona-components-panel-modes-mobile-shadow, none)"
197
+ );
192
198
  expect(wrapper?.style.padding).toBe("");
193
199
 
194
200
  controller.destroy();
@@ -214,20 +220,24 @@ describe("createAgentExperience detached panel", () => {
214
220
  controller.destroy();
215
221
  });
216
222
 
217
- it("renders a flush inline embed with no shadow; detached opts elevation back in", () => {
223
+ it.each([false, true])("renders a flush inline embed with no shadow; detached opts elevation back in (v5=%s)", (v5Defaults) => {
218
224
  const mount = createMount();
219
225
  const controller = createAgentExperience(mount, {
220
226
  apiUrl: "https://api.example.com/chat",
227
+ future: { v5Defaults },
221
228
  launcher: { enabled: false },
222
229
  });
223
230
 
224
231
  const { panel } = queryEmbedRefs(mount);
225
- expect(panel?.style.boxShadow).toBe("none");
232
+ expect(panel?.style.boxShadow).toBe(
233
+ "var(--persona-components-panel-modes-inline-shadow, none)"
234
+ );
226
235
  controller.destroy();
227
236
 
228
237
  const detachedMount = createMount();
229
238
  const detachedController = createAgentExperience(detachedMount, {
230
239
  apiUrl: "https://api.example.com/chat",
240
+ future: { v5Defaults },
231
241
  launcher: { enabled: false, detachedPanel: true },
232
242
  });
233
243
 
@@ -235,6 +245,23 @@ describe("createAgentExperience detached panel", () => {
235
245
  expect(detachedPanel?.style.boxShadow).toContain("--persona-panel-shadow");
236
246
  detachedController.destroy();
237
247
  });
248
+
249
+ it.each([false, true])("emits mode chrome tokens in defaults v%s", (v5Defaults) => {
250
+ const mount = createMount();
251
+ const controller = createAgentExperience(mount, {
252
+ apiUrl: "https://api.example.com/chat",
253
+ future: { v5Defaults },
254
+ launcher: { enabled: false },
255
+ });
256
+
257
+ const { panel } = queryEmbedRefs(mount);
258
+ expect(mount.dataset.personaDefaults).toBe(v5Defaults ? "v5" : "v4");
259
+ expect(panel?.style.boxShadow).toBe(
260
+ "var(--persona-components-panel-modes-inline-shadow, none)"
261
+ );
262
+ expect(mount.style.getPropertyValue("--persona-components-panel-modes-inline-shadow")).toBe("none");
263
+ controller.destroy();
264
+ });
238
265
  });
239
266
 
240
267
  describe("createAgentExperience detached artifact split chrome", () => {
@@ -10,6 +10,25 @@ describe("createAgentExperience docked mode", () => {
10
10
  document.body.innerHTML = "";
11
11
  });
12
12
 
13
+ it.each([false, true])("lets an active dark mode token override global panel chrome (v5=%s)", (v5Defaults) => {
14
+ const mount = document.createElement("div");
15
+ document.body.appendChild(mount);
16
+ const controller = createAgentExperience(mount, {
17
+ apiUrl: "https://api.example.com/chat",
18
+ colorScheme: "dark",
19
+ future: { v5Defaults },
20
+ launcher: { mountMode: "docked", autoExpand: true },
21
+ theme: { components: { panel: { border: "2px solid red" } } },
22
+ darkTheme: {
23
+ components: { panel: { modes: { docked: { border: "3px solid blue" } } } },
24
+ },
25
+ });
26
+
27
+ const container = mount.querySelector<HTMLElement>(".persona-widget-container");
28
+ expect(container?.style.border).toBe("3px solid blue");
29
+ controller.destroy();
30
+ });
31
+
13
32
  it("toggles docked panel open/closed; built-in launcher stays hidden (open via controller.open)", () => {
14
33
  const mount = document.createElement("div");
15
34
  document.body.appendChild(mount);
@@ -94,6 +94,31 @@ describe("header stability across unrelated updates", () => {
94
94
 
95
95
  controller.destroy();
96
96
  });
97
+
98
+ it.each([false, true])("updates header padding through live theme variables without rebuilding the header (v5=%s)", (v5Defaults) => {
99
+ window.scrollTo = vi.fn();
100
+ const mount = createMount();
101
+ const controller = createAgentExperience(mount, { ...inlineConfig(), future: { v5Defaults } });
102
+ const header = mount.querySelector<HTMLElement>(".persona-widget-header")!;
103
+
104
+ expect(header.style.padding).toBe(
105
+ "var(--persona-components-header-padding, 20px 24px)"
106
+ );
107
+ expect(mount.style.getPropertyValue("--persona-components-header-padding")).toBe(
108
+ v5Defaults ? "8px 8px 8px 16px" : "20px 24px"
109
+ );
110
+
111
+ controller.update({ theme: { components: { header: { padding: "12px 16px" } } } });
112
+ expect(mount.querySelector(".persona-widget-header")).toBe(header);
113
+ expect(mount.style.getPropertyValue("--persona-components-header-padding")).toBe(
114
+ "12px 16px"
115
+ );
116
+ expect(mount.style.getPropertyValue("--persona-components-header-minimalPadding")).toBe(
117
+ "12px 16px"
118
+ );
119
+
120
+ controller.destroy();
121
+ });
97
122
  });
98
123
 
99
124
  describe("composer and header icon stability across unrelated updates", () => {
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, expect, it, vi } from 'vitest';
4
+ import { createAgentExperience } from './ui';
5
+
6
+ let controller: ReturnType<typeof createAgentExperience> | undefined;
7
+
8
+ afterEach(() => {
9
+ controller?.destroy();
10
+ controller = undefined;
11
+ document.body.replaceChildren();
12
+ vi.restoreAllMocks();
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ it('forwards verified identity from the embedded composer on the first turn with history disabled', async () => {
17
+ const chatBodies: Array<Record<string, unknown>> = [];
18
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
19
+ const body = JSON.parse(String(init.body));
20
+ if (url.endsWith('/init')) {
21
+ return Response.json({
22
+ sessionId: 'session-1',
23
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
24
+ flow: { id: 'agent-1', name: 'Agent', description: null },
25
+ config: { welcomeMessage: null, placeholder: 'Ask...', theme: null },
26
+ });
27
+ }
28
+ chatBodies.push(body);
29
+ if (!body.identityProof) {
30
+ return Response.json({ error: 'Tenant scope required' }, { status: 403 });
31
+ }
32
+ return new Response('data: {"type":"done"}\n\n', {
33
+ headers: { 'Content-Type': 'text/event-stream' },
34
+ });
35
+ }));
36
+ const mount = document.createElement('div');
37
+ document.body.appendChild(mount);
38
+ const getIdentityProof = vi.fn().mockResolvedValue('signed-organization-proof');
39
+ controller = createAgentExperience(mount, {
40
+ apiUrl: 'https://example.com',
41
+ clientToken: 'ct_test_identity',
42
+ agentId: 'agent-1',
43
+ identityProvider: 'clerk',
44
+ getIdentityProof,
45
+ features: { history: { enabled: false } },
46
+ launcher: { enabled: false },
47
+ persistState: false,
48
+ });
49
+ const textarea = mount.querySelector<HTMLTextAreaElement>('[data-persona-composer-input]')!;
50
+ textarea.value = 'Show my orders';
51
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
52
+ mount.querySelector<HTMLFormElement>('[data-persona-composer-form]')!
53
+ .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
54
+ await vi.waitFor(() => expect(chatBodies).toHaveLength(1));
55
+ expect(chatBodies[0].identityProof).toEqual({ provider: 'clerk', token: 'signed-organization-proof' });
56
+ expect(getIdentityProof).toHaveBeenCalledTimes(1);
57
+ expect(mount.querySelector('[data-persona-history-toggle]')).toBeNull();
58
+ });
@@ -60,6 +60,31 @@ describe("launcher teaser", () => {
60
60
  controller.destroy();
61
61
  });
62
62
 
63
+ it("keeps the defaults-version root attribute in parity through critical-launcher handoff", () => {
64
+ const prefix = nextPrefix();
65
+ const config = {
66
+ apiUrl: "https://api.example.com/chat",
67
+ persistState: { keyPrefix: prefix },
68
+ future: { v5Defaults: false },
69
+ };
70
+ const host = createMount();
71
+ const controller = createAgentExperience(host, config);
72
+ const criticalTarget = createMount();
73
+ const critical = mountCriticalLauncher({ target: criticalTarget, config, onOpen: () => {} });
74
+
75
+ expect(host.getAttribute("data-persona-defaults")).toBe("v4");
76
+ expect(critical.root.getAttribute("data-persona-defaults")).toBe("v4");
77
+
78
+ controller.update({ future: { v5Defaults: true } });
79
+ critical.update({ ...config, future: { v5Defaults: true } });
80
+
81
+ expect(host.getAttribute("data-persona-defaults")).toBe("v5");
82
+ expect(critical.root.getAttribute("data-persona-defaults")).toBe("v5");
83
+
84
+ critical.destroy();
85
+ controller.destroy();
86
+ });
87
+
63
88
  it("live-updates the teaser through controller.update()", () => {
64
89
  const prefix = nextPrefix();
65
90
  const host = createMount();
@@ -135,6 +135,21 @@ describe("createAgentExperience: role-specific message width", () => {
135
135
  controller.destroy();
136
136
  });
137
137
 
138
+ it.each([false, true])("allows explicit max-width tokens on full rows with v5Defaults=%s", (v5Defaults) => {
139
+ const mount = createMount();
140
+ const controller = createController(mount, {
141
+ future: { v5Defaults },
142
+ theme: { components: { message: { assistant: { maxWidth: "65ch" } } } },
143
+ layout: { messages: { assistant: { width: "full" } } },
144
+ });
145
+ injectMessage(controller, { id: "token-width", role: "assistant", content: "Response" });
146
+ expectRowLayout(getRow(mount, "token-width"), {
147
+ role: "assistant", width: "full", maxWidth: "var(--persona-message-assistant-max-width, 100%)",
148
+ });
149
+ expect(mount.style.getPropertyValue("--persona-message-assistant-max-width")).toBe("65ch");
150
+ controller.destroy();
151
+ });
152
+
138
153
  it("configures user and assistant geometry independently", () => {
139
154
  const mount = createMount();
140
155
  const controller = createController(mount, {
@@ -165,7 +180,7 @@ describe("createAgentExperience: role-specific message width", () => {
165
180
  expectRowLayout(getRow(mount, "assistant-full"), {
166
181
  role: "assistant",
167
182
  width: "full",
168
- maxWidth: "100%",
183
+ maxWidth: "var(--persona-message-assistant-max-width, 100%)",
169
184
  });
170
185
 
171
186
  controller.destroy();
@@ -316,7 +331,7 @@ describe("createAgentExperience: role-specific message width", () => {
316
331
  expectRowLayout(userRow, {
317
332
  role: "user",
318
333
  width: "full",
319
- maxWidth: "100%",
334
+ maxWidth: "var(--persona-message-user-max-width, 100%)",
320
335
  });
321
336
  expect(userRow.querySelector(":scope > [data-custom-user]")).not.toBeNull();
322
337
 
@@ -81,6 +81,22 @@ const injectText = (
81
81
  const rowOf = (mount: HTMLElement, id: string) =>
82
82
  mount.querySelector<HTMLElement>(`#wrapper-${id}`);
83
83
 
84
+ const toggleExpansion = (mount: HTMLElement, id: string, bubbleType: "reasoning" | "tool") => {
85
+ const header = rowOf(mount, id)?.querySelector<HTMLElement>(
86
+ `button[data-bubble-type="${bubbleType}"]`
87
+ );
88
+ expect(header).not.toBeNull();
89
+ header?.dispatchEvent(new Event("pointerdown", { bubbles: true }));
90
+ };
91
+
92
+ const expansionStateOf = (mount: HTMLElement, id: string, bubbleType: "reasoning" | "tool") => {
93
+ const header = rowOf(mount, id)?.querySelector<HTMLElement>(
94
+ `button[data-bubble-type="${bubbleType}"]`
95
+ );
96
+ expect(header).not.toBeNull();
97
+ return header!.getAttribute("aria-expanded");
98
+ };
99
+
84
100
  describe("features.reasoningDisplay.iconName", () => {
85
101
  beforeEach(() => {
86
102
  window.localStorage.clear();
@@ -296,3 +312,79 @@ describe("features.toolCallDisplay.completedVisibility", () => {
296
312
  ).toEqual("complete");
297
313
  });
298
314
  });
315
+
316
+ describe("expandable transcript state", () => {
317
+ beforeEach(() => {
318
+ window.localStorage.clear();
319
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
320
+ cb(0);
321
+ return 1;
322
+ });
323
+ vi.stubGlobal("cancelAnimationFrame", () => {});
324
+ window.scrollTo = vi.fn();
325
+ });
326
+
327
+ afterEach(() => {
328
+ controllers.splice(0).forEach((controller) => controller.destroy());
329
+ mounts.splice(0).forEach((mount) => mount.remove());
330
+ document.body.innerHTML = "";
331
+ vi.restoreAllMocks();
332
+ });
333
+
334
+ it("keeps tool and reasoning expansion separate for widgets with matching message ids", () => {
335
+ const first = makeController();
336
+ injectReasoning(first.controller, "shared-reasoning", "complete");
337
+ injectToolCall(first.controller, "shared-tool", "complete");
338
+ toggleExpansion(first.mount, "shared-reasoning", "reasoning");
339
+ toggleExpansion(first.mount, "shared-tool", "tool");
340
+ expect(expansionStateOf(first.mount, "shared-reasoning", "reasoning")).toBe("true");
341
+ expect(expansionStateOf(first.mount, "shared-tool", "tool")).toBe("true");
342
+
343
+ // The transcript derives DOM ids from the message id. Keep both widgets
344
+ // mounted while removing only the first rendered row ids so the fixture
345
+ // can model equal message ids without duplicate document ids.
346
+ first.mount.querySelectorAll("[id]").forEach((element) => element.removeAttribute("id"));
347
+
348
+ const second = makeController();
349
+ injectReasoning(second.controller, "shared-reasoning", "complete");
350
+ injectToolCall(second.controller, "shared-tool", "complete");
351
+
352
+ expect(expansionStateOf(second.mount, "shared-reasoning", "reasoning")).toBe("false");
353
+ expect(expansionStateOf(second.mount, "shared-tool", "tool")).toBe("false");
354
+ });
355
+
356
+ it("resets tool and reasoning expansion on clearChat", () => {
357
+ const { mount, controller } = makeController();
358
+ injectReasoning(controller, "reasoning-after-clear", "complete");
359
+ injectToolCall(controller, "tool-after-clear", "complete");
360
+ toggleExpansion(mount, "reasoning-after-clear", "reasoning");
361
+ toggleExpansion(mount, "tool-after-clear", "tool");
362
+ expect(expansionStateOf(mount, "reasoning-after-clear", "reasoning")).toBe("true");
363
+ expect(expansionStateOf(mount, "tool-after-clear", "tool")).toBe("true");
364
+
365
+ controller.clearChat();
366
+ injectReasoning(controller, "reasoning-after-clear", "complete");
367
+ injectToolCall(controller, "tool-after-clear", "complete");
368
+
369
+ expect(expansionStateOf(mount, "reasoning-after-clear", "reasoning")).toBe("false");
370
+ expect(expansionStateOf(mount, "tool-after-clear", "tool")).toBe("false");
371
+ });
372
+
373
+ it("releases expansion state when a widget is destroyed", () => {
374
+ const first = makeController();
375
+ injectReasoning(first.controller, "reasoning-after-destroy", "complete");
376
+ injectToolCall(first.controller, "tool-after-destroy", "complete");
377
+ toggleExpansion(first.mount, "reasoning-after-destroy", "reasoning");
378
+ toggleExpansion(first.mount, "tool-after-destroy", "tool");
379
+ expect(expansionStateOf(first.mount, "reasoning-after-destroy", "reasoning")).toBe("true");
380
+ expect(expansionStateOf(first.mount, "tool-after-destroy", "tool")).toBe("true");
381
+ first.controller.destroy();
382
+
383
+ const second = makeController();
384
+ injectReasoning(second.controller, "reasoning-after-destroy", "complete");
385
+ injectToolCall(second.controller, "tool-after-destroy", "complete");
386
+
387
+ expect(expansionStateOf(second.mount, "reasoning-after-destroy", "reasoning")).toBe("false");
388
+ expect(expansionStateOf(second.mount, "tool-after-destroy", "tool")).toBe("false");
389
+ });
390
+ });
@@ -366,23 +366,40 @@ describe("rebuildComposer (composer ctx requestRender)", () => {
366
366
  expect(footer.style.display).toBe("none");
367
367
  });
368
368
 
369
- it("keeps the floating panel's fixed height across a rebuild", () => {
369
+ it.each([false, true])("keeps the floating panel's fixed height across a rebuild (v5=%s)", (v5Defaults) => {
370
370
  // applyFullHeightStyles wipes panel.style.cssText and only restores width;
371
371
  // the fixed height belongs to recalcPanelHeight. Without a re-stamp in
372
372
  // syncPanelChrome, a rebuild leaves the floating panel height-less and it
373
373
  // grows to fit its content (pre-chat submit stretched to the viewport).
374
374
  const { plugin, state } = createGatePlugin();
375
375
  const { mount } = makeController({
376
+ future: { v5Defaults },
376
377
  plugins: [plugin],
377
378
  launcher: { enabled: true },
378
379
  });
379
380
 
381
+ expect(mount.style.getPropertyValue("--persona-components-panel-height")).toBe(v5Defaults ? "min(704px, calc(100dvh - 104px))" : "min(640px, max(200px, calc(100vh - 64px)))");
380
382
  const panel = mount.querySelector<HTMLElement>(".persona-widget-panel")!;
381
- expect(panel.style.height).toBe("640px");
383
+ expect(panel.style.height).toBe("max(200px, var(--persona-components-panel-height))");
382
384
 
383
385
  state.ctx!.requestRender();
384
386
 
385
- expect(panel.style.height).toBe("640px");
387
+ expect(panel.style.height).toBe("max(200px, var(--persona-components-panel-height))");
388
+ });
389
+
390
+ it.each([false, true])("retains transient connection status through a composer rebuild (v5=%s)", (v5Defaults) => {
391
+ const { plugin, state } = createGatePlugin();
392
+ state.gated = false;
393
+ const { mount, controller } = makeController({
394
+ plugins: [plugin], future: { v5Defaults },
395
+ statusIndicator: { mode: "transient", connectingText: "Connecting now" },
396
+ });
397
+ controller.injectTestMessage({ type: "status", status: "connecting" });
398
+ state.ctx!.requestRender();
399
+ const status = mount.querySelector<HTMLElement>("[data-persona-composer-status]")!;
400
+ expect(status.style.display).toBe("");
401
+ expect(status.textContent).toBe("Connecting now");
402
+ expect(status.nextElementSibling?.matches("[data-persona-composer-form]")).toBe(true);
386
403
  });
387
404
 
388
405
  it("does not stamp copy onto a plugin-owned composer's input", () => {
@@ -818,6 +818,38 @@ describe("createAgentExperience streaming scroll", () => {
818
818
  controller.destroy();
819
819
  });
820
820
 
821
+ describe.each([false, true])('manual activity disclosure (V5 %s)', v5Defaults => {
822
+ it.each(['pointer', 'keyboard'])('keeps the viewport after %s expansion and later content growth', activation => {
823
+ const raf = installRafMock();
824
+ const resize = installResizeObserverMock();
825
+ const mount = createMount();
826
+ const controller = createAgentExperience(mount, {
827
+ apiUrl: '/test', future: { v5Defaults }, launcher: { enabled: false },
828
+ features: { toolCallDisplay: { variant: 'row', grouped: false, autoExpand: false } }
829
+ });
830
+ const body = mount.querySelector<HTMLElement>('#persona-scroll-container')!;
831
+ const metrics = installScrollMetrics(body, { scrollHeight: 1000, clientHeight: 400 });
832
+ controller.injectTestMessage({ type: 'message', message: {
833
+ id: 'tool-scroll', role: 'assistant', content: '', createdAt: STREAM_CREATED_AT,
834
+ variant: 'tool', toolCall: { id: 'tool-scroll', name: 'Search', status: 'complete', result: 'Long result' }
835
+ } });
836
+ raf.flush();
837
+ metrics.setScrollTop(600);
838
+ const header = mount.querySelector<HTMLButtonElement>('[data-message-id="tool-scroll"] > button')!;
839
+ header.dispatchEvent(activation === 'pointer'
840
+ ? new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
841
+ : new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
842
+ expect(header.getAttribute('aria-expanded')).toBe('true');
843
+ metrics.setScrollHeight(1400);
844
+ resize.trigger(); raf.flush();
845
+ expect(metrics.getScrollTop()).toBe(600);
846
+ metrics.setScrollHeight(1600);
847
+ resize.trigger(); raf.flush();
848
+ expect(metrics.getScrollTop()).toBe(600);
849
+ controller.destroy();
850
+ });
851
+ });
852
+
821
853
  it("does not yank a paused reader when content grows without a render event", () => {
822
854
  const raf = installRafMock();
823
855
  const resize = installResizeObserverMock();