@runtypelabs/persona 4.33.0 → 4.35.1

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 (220) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-DW7Vg_l2.js → chunk-B1P8w9_X.js} +1 -1
  17. package/dist/{chunk-D6U0E82p.js → chunk-B30Jdgg6.js} +1 -1
  18. package/dist/{chunk-gu34lr-P.js → chunk-Brdx4NCz.js} +1 -1
  19. package/dist/{chunk-6CUrH_jm.js → chunk-Bzo9GaAi.js} +1 -1
  20. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  21. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  22. package/dist/chunk-CNBMBx-Y.js +2 -0
  23. package/dist/{chunk-ql5N2I_t.js → chunk-CWzHNuVK.js} +1 -1
  24. package/dist/{chunk-DbGLjU2P.js → chunk-CYpt7PsO.js} +1 -1
  25. package/dist/chunk-Cd8mp8Sv.js +1 -0
  26. package/dist/chunk-CsfE8WRG.js +1 -0
  27. package/dist/{chunk-DOZmcve8.js → chunk-Csr59G5V.js} +1 -1
  28. package/dist/{chunk-Co3VVwGe.js → chunk-D2pHRNEq.js} +0 -0
  29. package/dist/chunk-D3kZO8u-.js +1 -0
  30. package/dist/chunk-DAthf5H3.js +1 -0
  31. package/dist/{chunk-CXQJG7pb.js → chunk-DZ_aillY.js} +2 -2
  32. package/dist/{chunk-6C23QQdP.js → chunk-DgJV9zbK.js} +1 -1
  33. package/dist/chunk-Dv6KtIT1.js +1 -0
  34. package/dist/chunk-Dws8IrdR.js +1 -0
  35. package/dist/chunk-ThxxD8oK.js +1 -0
  36. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  37. package/dist/chunk-kizhspe5.js +1 -0
  38. package/dist/client-dispatch.js +1 -1
  39. package/dist/client-history.js +1 -1
  40. package/dist/client-stream.js +1 -1
  41. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  42. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  43. package/dist/codegen.cjs +1 -1
  44. package/dist/codegen.d.cts +1 -1
  45. package/dist/codegen.d.ts +1 -1
  46. package/dist/codegen.js +1 -1
  47. package/dist/composer-actions-runtime.cjs +1 -1
  48. package/dist/composer-actions-runtime.d.cts +4 -20
  49. package/dist/composer-actions-runtime.d.ts +4 -20
  50. package/dist/composer-actions-runtime.js +1 -1
  51. package/dist/{composer-input-DnqGCXZz.d.ts → composer-input-BSDNPOqV.d.cts} +1 -7
  52. package/dist/{composer-input-DnqGCXZz.d.cts → composer-input-BSDNPOqV.d.ts} +1 -7
  53. package/dist/{composer-store-6Iwy6WLI.d.ts → composer-store-DC-gAjtN.d.cts} +1 -3
  54. package/dist/{composer-store-6Iwy6WLI.d.cts → composer-store-DC-gAjtN.d.ts} +1 -3
  55. package/dist/config-diagnostics.d.cts +0 -2
  56. package/dist/config-diagnostics.d.ts +0 -2
  57. package/dist/{context-mention-manager-B-rHF98n.d.ts → context-mention-manager-CCpeoGL2.d.cts} +2 -6
  58. package/dist/{context-mention-manager-B-rHF98n.d.cts → context-mention-manager-CCpeoGL2.d.ts} +2 -6
  59. package/dist/context-mentions-inline.cjs +1 -1
  60. package/dist/context-mentions-inline.d.cts +2 -4
  61. package/dist/context-mentions-inline.d.ts +2 -4
  62. package/dist/context-mentions-inline.js +1 -1
  63. package/dist/context-mentions.cjs +2 -2
  64. package/dist/context-mentions.d.cts +3 -9
  65. package/dist/context-mentions.d.ts +3 -9
  66. package/dist/context-mentions.js +1 -1
  67. package/dist/{defaults-BuOjqUAG.d.ts → defaults-tj5RcN8r.d.cts} +1 -5
  68. package/dist/{defaults-BuOjqUAG.d.cts → defaults-tj5RcN8r.d.ts} +1 -5
  69. package/dist/dictation-runtime.cjs +1 -1
  70. package/dist/dictation-runtime.d.cts +4 -13
  71. package/dist/dictation-runtime.d.ts +4 -13
  72. package/dist/dictation-runtime.js +1 -1
  73. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.cts} +0 -2
  74. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.ts} +0 -2
  75. package/dist/event-stream-view.cjs +1 -1
  76. package/dist/event-stream-view.d.cts +3 -16
  77. package/dist/event-stream-view.d.ts +3 -16
  78. package/dist/{fallback-speech-engine-SqEfkGda.d.ts → fallback-speech-engine-gkhbjLiZ.d.cts} +1 -3
  79. package/dist/{fallback-speech-engine-SqEfkGda.d.cts → fallback-speech-engine-gkhbjLiZ.d.ts} +1 -3
  80. package/dist/{feature-preferences-DqAmje-2.d.ts → feature-preferences-CerDWGZ7.d.cts} +1 -5
  81. package/dist/{feature-preferences-DqAmje-2.d.cts → feature-preferences-CerDWGZ7.d.ts} +1 -5
  82. package/dist/forms-ui.d.cts +2 -4
  83. package/dist/forms-ui.d.ts +2 -4
  84. package/dist/{history-confirm-dialog-B3L7QNcO.d.cts → history-confirm-dialog-CrkSB3Ra.d.cts} +2 -14
  85. package/dist/{history-confirm-dialog-B3L7QNcO.d.ts → history-confirm-dialog-CrkSB3Ra.d.ts} +2 -14
  86. package/dist/history-shell.cjs +1 -1
  87. package/dist/history-shell.d.cts +8 -27
  88. package/dist/history-shell.d.ts +8 -27
  89. package/dist/history-shell.js +1 -1
  90. package/dist/history-view.d.cts +1 -1
  91. package/dist/history-view.d.ts +1 -1
  92. package/dist/icons-extra.cjs +1 -1
  93. package/dist/icons-extra.d.cts +0 -2
  94. package/dist/icons-extra.d.ts +0 -2
  95. package/dist/index.cjs +44 -44
  96. package/dist/index.cjs.map +1 -1
  97. package/dist/index.d.cts +10 -10
  98. package/dist/index.d.ts +10 -10
  99. package/dist/index.global.js +1 -1
  100. package/dist/index.global.js.map +1 -1
  101. package/dist/index.js +50 -50
  102. package/dist/index.js.map +1 -1
  103. package/dist/install.global.js +1 -1
  104. package/dist/install.global.js.map +1 -1
  105. package/dist/launcher.global.js +2 -2
  106. package/dist/launcher.global.js.map +1 -1
  107. package/dist/{loading-label-runtime-loader-JecjOfJS.d.ts → loading-label-runtime-loader-fWIRrhUF.d.cts} +1 -3
  108. package/dist/{loading-label-runtime-loader-JecjOfJS.d.cts → loading-label-runtime-loader-fWIRrhUF.d.ts} +1 -3
  109. package/dist/loading-label-runtime.cjs +1 -1
  110. package/dist/loading-label-runtime.d.cts +1 -1
  111. package/dist/loading-label-runtime.d.ts +1 -1
  112. package/dist/loading-label-runtime.js +1 -1
  113. package/dist/markdown-parsers.js +1 -1
  114. package/dist/{message-bubble-CBYtpDsT.d.ts → message-bubble-Czc0lOHr.d.cts} +20 -3
  115. package/dist/{message-bubble-CBYtpDsT.d.cts → message-bubble-Czc0lOHr.d.ts} +20 -3
  116. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-C30jE-cw.d.cts} +3 -3
  117. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-C30jE-cw.d.ts} +3 -3
  118. package/dist/plugin-kit.cjs +1 -1
  119. package/dist/plugin-kit.d.cts +1 -1
  120. package/dist/plugin-kit.d.ts +1 -1
  121. package/dist/plugin-kit.js +1 -1
  122. package/dist/preferences-runtime.cjs +1 -1
  123. package/dist/preferences-runtime.d.cts +1 -1
  124. package/dist/preferences-runtime.d.ts +1 -1
  125. package/dist/preferences-runtime.js +1 -1
  126. package/dist/{preview-_UJwwt2L.d.cts → preview-D-osV3FU.d.cts} +2 -6
  127. package/dist/{preview-_UJwwt2L.d.ts → preview-D-osV3FU.d.ts} +2 -6
  128. package/dist/{registry-B5ZV3K-h.d.ts → registry-B6w91tx0.d.cts} +1 -8
  129. package/dist/{registry-B5ZV3K-h.d.cts → registry-B6w91tx0.d.ts} +1 -8
  130. package/dist/{session-Bys8_XuB.d.cts → session-BUQrs3Of.d.cts} +10 -93
  131. package/dist/{session-Bys8_XuB.d.ts → session-BUQrs3Of.d.ts} +10 -93
  132. package/dist/session-reconnect.d.cts +2 -4
  133. package/dist/session-reconnect.d.ts +2 -4
  134. package/dist/smart-dom-reader.d.cts +2 -8
  135. package/dist/smart-dom-reader.d.ts +2 -8
  136. package/dist/testing.d.cts +0 -2
  137. package/dist/testing.d.ts +0 -2
  138. package/dist/theme-editor-preview.cjs +50 -50
  139. package/dist/theme-editor-preview.d.cts +1 -1
  140. package/dist/theme-editor-preview.d.ts +1 -1
  141. package/dist/theme-editor-preview.js +25 -25
  142. package/dist/theme-editor.cjs +25 -5
  143. package/dist/theme-editor.d.cts +2 -22
  144. package/dist/theme-editor.d.ts +2 -22
  145. package/dist/theme-editor.js +1 -1
  146. package/dist/theme-reference.cjs +1 -1
  147. package/dist/theme-reference.d.cts +2 -4
  148. package/dist/theme-reference.d.ts +2 -4
  149. package/dist/theme-reference.js +1 -1
  150. package/dist/{tool-loading-animation-d5qAQcwv.d.ts → tool-loading-animation-B7XUf8TN.d.cts} +1 -7
  151. package/dist/{tool-loading-animation-d5qAQcwv.d.cts → tool-loading-animation-B7XUf8TN.d.ts} +1 -7
  152. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  153. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  154. package/dist/{turn-activity-ui-q6fHVnx-.d.cts → turn-activity-ui-CohuNMHT.d.cts} +2 -12
  155. package/dist/{turn-activity-ui-q6fHVnx-.d.ts → turn-activity-ui-CohuNMHT.d.ts} +2 -12
  156. package/dist/turn-activity-ui.d.cts +1 -1
  157. package/dist/turn-activity-ui.d.ts +1 -1
  158. package/dist/{types-B_mj21jg.d.cts → types-9i0xcAMT.d.cts} +128 -29
  159. package/dist/{types-B_mj21jg.d.ts → types-9i0xcAMT.d.ts} +128 -29
  160. package/dist/ui-extras.js +3 -3
  161. package/dist/voice-client-tools.d.cts +1 -3
  162. package/dist/voice-client-tools.d.ts +1 -3
  163. package/dist/voice-runtime.cjs +1 -1
  164. package/dist/voice-runtime.d.cts +2 -12
  165. package/dist/voice-runtime.d.ts +2 -12
  166. package/dist/voice-runtime.js +2 -2
  167. package/dist/voice-worklet-player.d.cts +2 -6
  168. package/dist/voice-worklet-player.d.ts +2 -6
  169. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.ts → webmcp-runtime-entry-XNaXa-9h.d.cts} +9 -5
  170. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.cts → webmcp-runtime-entry-XNaXa-9h.d.ts} +9 -5
  171. package/dist/webmcp-runtime.cjs +1 -1
  172. package/dist/webmcp-runtime.d.cts +1 -1
  173. package/dist/webmcp-runtime.d.ts +1 -1
  174. package/dist/webmcp-runtime.js +1 -1
  175. package/dist/widget.css +1 -1
  176. package/package.json +5 -5
  177. package/src/client-dispatch.ts +13 -0
  178. package/src/client.ts +37 -2
  179. package/src/client.webmcp-require-confirm.test.ts +212 -0
  180. package/src/components/composer-model-picker.ts +0 -14
  181. package/src/components/composer-overflow-menu.ts +0 -15
  182. package/src/components/composer-tokens.ts +0 -20
  183. package/src/components/event-stream-view.test.ts +5 -0
  184. package/src/components/message-bubble.ts +26 -6
  185. package/src/components/scroll-to-bottom.test.ts +135 -0
  186. package/src/components/scroll-to-bottom.ts +50 -13
  187. package/src/components/user-message-collapse.ts +117 -0
  188. package/src/defaults.ts +6 -0
  189. package/src/generated/runtype-openapi-contract.ts +13 -0
  190. package/src/plugin-kit.test.ts +101 -0
  191. package/src/plugin-kit.ts +29 -1
  192. package/src/session.ts +6 -2
  193. package/src/session.webmcp.test.ts +32 -0
  194. package/src/styles/widget.css +56 -0
  195. package/src/theme-reference.ts +2 -2
  196. package/src/types/theme.ts +29 -0
  197. package/src/types.ts +84 -5
  198. package/src/ui.composer-models.test.ts +5 -7
  199. package/src/ui.composer-overflow.test.ts +14 -21
  200. package/src/ui.scroll-additive.test.ts +219 -117
  201. package/src/ui.scroll.test.ts +176 -13
  202. package/src/ui.ts +230 -107
  203. package/src/ui.turn-activity.test.ts +7 -7
  204. package/src/ui.user-message-collapse.test.ts +540 -0
  205. package/src/utils/auto-follow.test.ts +0 -38
  206. package/src/utils/auto-follow.ts +0 -35
  207. package/src/utils/context-mention-controller.test.ts +56 -0
  208. package/src/utils/theme.test.ts +23 -0
  209. package/src/utils/tokens.ts +16 -0
  210. package/src/webmcp-bridge.test.ts +102 -0
  211. package/src/webmcp-runtime-entry.ts +29 -1
  212. package/dist/chunk-BAkaTZpd.js +0 -1
  213. package/dist/chunk-BEDTp-dm.js +0 -1
  214. package/dist/chunk-C7-m0EmI.js +0 -1
  215. package/dist/chunk-CNhThrLX.js +0 -1
  216. package/dist/chunk-CVMIncEY.js +0 -1
  217. package/dist/chunk-CzLEhoB_.js +0 -2
  218. package/dist/chunk-DVgzoVCB.js +0 -1
  219. package/dist/chunk-d54BJdKd.js +0 -1
  220. package/dist/chunk-dLJWKfOb.js +0 -1
@@ -0,0 +1,540 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import { mergeWithDefaults } from "./defaults";
7
+ import {
8
+ DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT,
9
+ userMessageOverflows,
10
+ } from "./components/user-message-collapse";
11
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
12
+
13
+ type RafCallback = (time: number) => void;
14
+
15
+ const CREATED_AT = "2026-03-29T00:00:00.000Z";
16
+ const LONG_TEXT = Array.from({ length: 40 }, (_, i) => `Line ${i + 1} of a long pasted message.`).join("\n\n");
17
+
18
+ const installRafMock = () => {
19
+ let nextId = 1;
20
+ let now = performance.now();
21
+ const callbacks = new Map<number, RafCallback>();
22
+ vi.spyOn(performance, "now").mockImplementation(() => now);
23
+ vi.stubGlobal("requestAnimationFrame", (callback: RafCallback) => {
24
+ const id = nextId++;
25
+ callbacks.set(id, callback);
26
+ return id;
27
+ });
28
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => {
29
+ callbacks.delete(id);
30
+ });
31
+ return {
32
+ step(frameCount = 1) {
33
+ let frames = 0;
34
+ while (callbacks.size > 0 && frames < frameCount) {
35
+ const pending = [...callbacks.values()];
36
+ callbacks.clear();
37
+ frames += 1;
38
+ now += 16;
39
+ pending.forEach((callback) => callback(now));
40
+ }
41
+ },
42
+ flush(maxFrames = 80) {
43
+ let frames = 0;
44
+ while (callbacks.size > 0 && frames < maxFrames) {
45
+ const pending = [...callbacks.values()];
46
+ callbacks.clear();
47
+ frames += 1;
48
+ now += 16;
49
+ pending.forEach((callback) => callback(now));
50
+ }
51
+ },
52
+ };
53
+ };
54
+
55
+ const installResizeObserverMock = () => {
56
+ const triggers: Array<() => void> = [];
57
+ class ResizeObserverMock {
58
+ constructor(callback: (entries: unknown[], observer: unknown) => void) {
59
+ triggers.push(() => callback([], this));
60
+ }
61
+ observe() {}
62
+ unobserve() {}
63
+ disconnect() {}
64
+ }
65
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
66
+ return { trigger: () => triggers.forEach((fire) => fire()) };
67
+ };
68
+
69
+ // jsdom has no layout. Collapse regions report a per-message natural height
70
+ // (what the browser would measure for the rendered markdown and attachments);
71
+ // every other element keeps jsdom's 0, and the scroll container overrides it.
72
+ const naturalHeights = new Map<string, number>();
73
+ const originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, "scrollHeight")!;
74
+ beforeEach(() => {
75
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
76
+ configurable: true,
77
+ get(this: HTMLElement) {
78
+ if (this.hasAttribute("data-persona-user-collapse")) {
79
+ const id = this.closest("[data-message-id]")?.getAttribute("data-message-id") ?? "";
80
+ return naturalHeights.get(id) ?? 0;
81
+ }
82
+ return originalScrollHeight.get!.call(this);
83
+ },
84
+ });
85
+ });
86
+
87
+ afterEach(() => {
88
+ delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight;
89
+ naturalHeights.clear();
90
+ vi.unstubAllGlobals();
91
+ vi.restoreAllMocks();
92
+ document.body.replaceChildren();
93
+ });
94
+
95
+ const installScrollMetrics = (element: HTMLElement, initial: { scrollHeight: number; clientHeight: number }) => {
96
+ let scrollTop = 0;
97
+ let scrollHeight = initial.scrollHeight;
98
+ const clientHeight = initial.clientHeight;
99
+ Object.defineProperties(element, {
100
+ scrollTop: {
101
+ configurable: true,
102
+ get: () => scrollTop,
103
+ set: (value: number) => {
104
+ scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollHeight - clientHeight)));
105
+ },
106
+ },
107
+ scrollHeight: { configurable: true, get: () => scrollHeight },
108
+ clientHeight: { configurable: true, get: () => clientHeight },
109
+ });
110
+ return {
111
+ getScrollTop: () => scrollTop,
112
+ getBottomScrollTop: () => Math.max(0, scrollHeight - clientHeight),
113
+ setScrollTop: (value: number) => { element.scrollTop = value; },
114
+ setScrollHeight: (value: number) => {
115
+ scrollHeight = value;
116
+ if (scrollTop > scrollHeight - clientHeight) scrollTop = Math.max(0, scrollHeight - clientHeight);
117
+ },
118
+ };
119
+ };
120
+
121
+ const mountWidget = (config: Partial<AgentWidgetConfig> = {}) => {
122
+ const mount = document.createElement("div");
123
+ document.body.appendChild(mount);
124
+ const controller = createAgentExperience(mount, {
125
+ apiUrl: "https://api.example.com/chat",
126
+ launcher: { enabled: false },
127
+ persistState: false,
128
+ ...config,
129
+ features: { userMessageCollapse: { enabled: true }, ...config.features },
130
+ } as AgentWidgetConfig);
131
+ return { mount, controller };
132
+ };
133
+
134
+ const emit = (
135
+ controller: ReturnType<typeof createAgentExperience>,
136
+ message: Partial<AgentWidgetMessage> & Pick<AgentWidgetMessage, "id" | "role">
137
+ ) => {
138
+ controller.injectTestMessage({
139
+ type: "message",
140
+ message: { content: "", createdAt: CREATED_AT, ...message } as AgentWidgetMessage,
141
+ });
142
+ };
143
+
144
+ const emitUser = (
145
+ controller: ReturnType<typeof createAgentExperience>,
146
+ id: string,
147
+ height: number,
148
+ extra: Partial<AgentWidgetMessage> = {}
149
+ ) => {
150
+ naturalHeights.set(id, height);
151
+ emit(controller, { id, role: "user", content: LONG_TEXT, ...extra });
152
+ };
153
+
154
+ const region = (mount: HTMLElement, id: string) =>
155
+ mount.querySelector<HTMLElement>(`[data-message-id="${id}"] [data-persona-user-collapse]`);
156
+ const toggle = (mount: HTMLElement, id: string) =>
157
+ mount.querySelector<HTMLButtonElement>(`[data-message-id="${id}"] button[data-persona-user-collapse-toggle]`);
158
+
159
+ describe("userMessageOverflows", () => {
160
+ it("leaves content within two lines of the limit expanded", () => {
161
+ expect(userMessageOverflows(240, 240)).toBe(false);
162
+ expect(userMessageOverflows(288, 240)).toBe(false);
163
+ expect(userMessageOverflows(289, 240)).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe("long user message collapse", () => {
168
+ it("adds no toggle under the threshold", () => {
169
+ installRafMock();
170
+ const { mount, controller } = mountWidget();
171
+ emitUser(controller, "short", 200);
172
+ expect(region(mount, "short")?.getAttribute("data-persona-user-collapse")).toBe("fit");
173
+ expect(toggle(mount, "short")).toBeNull();
174
+ controller.destroy();
175
+ });
176
+
177
+ it("collapses over the threshold with a labelled, controlling toggle", () => {
178
+ installRafMock();
179
+ const { mount, controller } = mountWidget();
180
+ emitUser(controller, "long", 900);
181
+ const clip = region(mount, "long")!;
182
+ const button = toggle(mount, "long")!;
183
+ expect(clip.getAttribute("data-persona-user-collapse")).toBe("collapsed");
184
+ expect(clip.style.getPropertyValue("--persona-user-collapse-max-height")).toBe(`${DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT}px`);
185
+ expect(button.tagName).toBe("BUTTON");
186
+ expect(button.type).toBe("button");
187
+ expect(button.textContent).toBe("Show more");
188
+ expect(button.getAttribute("aria-expanded")).toBe("false");
189
+ expect(button.getAttribute("aria-controls")).toBe(clip.id);
190
+ expect(mount.querySelector(`#${clip.id}`)).toBe(clip);
191
+ // The whole message stays in the DOM: no truncation, no nested scroller.
192
+ expect(clip.textContent).toContain("Line 40 of a long pasted message.");
193
+ expect(clip.hasAttribute("tabindex")).toBe(false);
194
+ controller.destroy();
195
+ });
196
+
197
+ it("honors a custom maxHeight and localized labels", () => {
198
+ installRafMock();
199
+ const { mount, controller } = mountWidget({
200
+ features: { userMessageCollapse: { enabled: true, maxHeight: 120 } },
201
+ copy: { showMoreLabel: "Mehr anzeigen", showLessLabel: "Weniger anzeigen" },
202
+ });
203
+ emitUser(controller, "custom", 200);
204
+ expect(region(mount, "custom")!.style.getPropertyValue("--persona-user-collapse-max-height")).toBe("120px");
205
+ toggle(mount, "custom")!.click();
206
+ expect(toggle(mount, "custom")!.textContent).toBe("Weniger anzeigen");
207
+ toggle(mount, "custom")!.click();
208
+ expect(toggle(mount, "custom")!.textContent).toBe("Mehr anzeigen");
209
+ controller.destroy();
210
+ });
211
+
212
+ it("expands and collapses on toggle, keeping the same button", () => {
213
+ installRafMock();
214
+ const { mount, controller } = mountWidget();
215
+ emitUser(controller, "toggle", 900);
216
+ const button = toggle(mount, "toggle")!;
217
+ button.click();
218
+ expect(region(mount, "toggle")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
219
+ expect(button.getAttribute("aria-expanded")).toBe("true");
220
+ expect(button.textContent).toBe("Show less");
221
+ button.click();
222
+ expect(region(mount, "toggle")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
223
+ expect(button.getAttribute("aria-expanded")).toBe("false");
224
+ expect(button.textContent).toBe("Show more");
225
+ controller.destroy();
226
+ });
227
+
228
+ it("expands when keyboard focus lands inside the clipped content", () => {
229
+ installRafMock();
230
+ const { mount, controller } = mountWidget();
231
+ emitUser(controller, "focus", 900);
232
+ const content = region(mount, "focus")!.querySelector<HTMLElement>(".persona-message-content")!;
233
+ content.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
234
+ expect(region(mount, "focus")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
235
+ expect(toggle(mount, "focus")!.getAttribute("aria-expanded")).toBe("true");
236
+ // Focusing the toggle itself (outside the region) changes nothing.
237
+ toggle(mount, "focus")!.click();
238
+ toggle(mount, "focus")!.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
239
+ expect(region(mount, "focus")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
240
+ controller.destroy();
241
+ });
242
+
243
+ it("applies maxHeight and label changes from update() to rendered bubbles", () => {
244
+ installRafMock();
245
+ const { mount, controller } = mountWidget();
246
+ emitUser(controller, "updated", 900);
247
+ emit(controller, { id: "reply", role: "assistant", content: "Reply" });
248
+ controller.update({
249
+ features: { userMessageCollapse: { enabled: true, maxHeight: 300 } },
250
+ copy: { showMoreLabel: "Expand", showLessLabel: "Shrink" },
251
+ });
252
+ expect(region(mount, "updated")!.style.getPropertyValue("--persona-user-collapse-max-height")).toBe("300px");
253
+ expect(toggle(mount, "updated")!.textContent).toBe("Expand");
254
+ // Raised past the content height, the bubble no longer collapses at all.
255
+ controller.update({ features: { userMessageCollapse: { enabled: true, maxHeight: 1000 } } });
256
+ expect(region(mount, "updated")!.getAttribute("data-persona-user-collapse")).toBe("fit");
257
+ expect(toggle(mount, "updated")).toBeNull();
258
+ controller.destroy();
259
+ });
260
+
261
+ it("keeps the reader's choice across streaming re-renders and morphs", () => {
262
+ const raf = installRafMock();
263
+ const { mount, controller } = mountWidget();
264
+ controller.injectTestMessage({ type: "status", status: "connecting" });
265
+ emitUser(controller, "kept", 900);
266
+ toggle(mount, "kept")!.click();
267
+ for (const content of ["Streaming", "Streaming a longer reply", "Streaming a much longer reply"]) {
268
+ emit(controller, { id: "reply", role: "assistant", content, streaming: true });
269
+ raf.flush();
270
+ }
271
+ emit(controller, { id: "reply", role: "assistant", content: "Done" });
272
+ // A re-render of the user message itself (same id) must not re-collapse it.
273
+ emitUser(controller, "kept", 900, { content: `${LONG_TEXT}\n\nEdited.` });
274
+ raf.flush();
275
+ expect(region(mount, "kept")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
276
+ expect(toggle(mount, "kept")!.getAttribute("aria-expanded")).toBe("true");
277
+ expect(toggle(mount, "kept")!.textContent).toBe("Show less");
278
+
279
+ // And a collapsed choice is not re-expanded either.
280
+ toggle(mount, "kept")!.click();
281
+ emit(controller, { id: "reply-2", role: "assistant", content: "Another reply" });
282
+ raf.flush();
283
+ expect(region(mount, "kept")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
284
+ controller.destroy();
285
+ });
286
+
287
+ it("keeps an interim voice transcript expanded, then collapses it once final", () => {
288
+ const raf = installRafMock();
289
+ const { mount, controller } = mountWidget();
290
+ emitUser(controller, "voice", 400, { content: "So what I was thinking", voiceProcessing: true });
291
+ expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("live");
292
+ expect(toggle(mount, "voice")).toBeNull();
293
+ // The same id keeps growing while the speaker talks.
294
+ emitUser(controller, "voice", 900, { voiceProcessing: true });
295
+ raf.flush();
296
+ expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("live");
297
+ expect(toggle(mount, "voice")).toBeNull();
298
+ emitUser(controller, "voice", 900, { voiceProcessing: false });
299
+ raf.flush();
300
+ expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
301
+ expect(toggle(mount, "voice")!.getAttribute("aria-expanded")).toBe("false");
302
+ controller.destroy();
303
+ });
304
+
305
+ it("does not move an engaged reader's viewport when a final transcript collapses", () => {
306
+ const raf = installRafMock();
307
+ const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
308
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
309
+ const metrics = installScrollMetrics(body, { scrollHeight: 2000, clientHeight: 400 });
310
+ emitUser(controller, "voice-read", 900, { voiceProcessing: true });
311
+ raf.flush();
312
+ // The reader scrolls up into the transcript: follow pauses.
313
+ body.dispatchEvent(new WheelEvent("wheel", { deltaY: -200, bubbles: true }));
314
+ metrics.setScrollTop(500);
315
+ body.dispatchEvent(new Event("scroll"));
316
+ raf.flush();
317
+ const bubble = mount.querySelector<HTMLElement>('[data-message-id="voice-read"]')!;
318
+ // The bubble is in view at 100px; collapsing must hold its top there.
319
+ let top = 100;
320
+ vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() => ({ top, bottom: top + 300 } as DOMRect));
321
+ emitUser(controller, "voice-read", 900, { voiceProcessing: false });
322
+ raf.flush();
323
+ expect(region(mount, "voice-read")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
324
+ expect(metrics.getScrollTop()).toBe(500);
325
+ top = 0;
326
+ controller.destroy();
327
+ });
328
+
329
+ it("holds the content below when a final transcript above the viewport collapses", () => {
330
+ const raf = installRafMock();
331
+ const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
332
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
333
+ // The transcript bubble sits at the top of the content; the reader has
334
+ // scrolled 700px down, past most of it, into what follows.
335
+ let scrollTop = 0;
336
+ const bubbleHeight = () =>
337
+ region(mount, "voice-above")?.getAttribute("data-persona-user-collapse") === "collapsed" ? 270 : 900;
338
+ const scrollHeight = () => bubbleHeight() + 1100;
339
+ Object.defineProperties(body, {
340
+ scrollTop: {
341
+ configurable: true,
342
+ get: () => scrollTop,
343
+ set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
344
+ },
345
+ scrollHeight: { configurable: true, get: scrollHeight },
346
+ clientHeight: { configurable: true, get: () => 400 },
347
+ });
348
+ emitUser(controller, "voice-above", 900, { voiceProcessing: true });
349
+ raf.flush();
350
+ const bubble = mount.querySelector<HTMLElement>('[data-message-id="voice-above"]')!;
351
+ vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() =>
352
+ ({ top: -scrollTop, bottom: bubbleHeight() - scrollTop } as DOMRect));
353
+ body.dispatchEvent(new WheelEvent("wheel", { deltaY: -200, bubbles: true }));
354
+ body.scrollTop = 700;
355
+ body.dispatchEvent(new Event("scroll"));
356
+ raf.flush();
357
+ emitUser(controller, "voice-above", 900, { voiceProcessing: false });
358
+ raf.flush();
359
+ expect(region(mount, "voice-above")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
360
+ // The bubble's bottom edge (200px above the viewport) stays where it was:
361
+ // 900 - 700 = 200 before, 270 - 70 = 200 after.
362
+ expect(scrollTop).toBe(70);
363
+ controller.destroy();
364
+ });
365
+
366
+ it("treats a toggle as reader intent: follow pauses and later renders leave the viewport", () => {
367
+ const raf = installRafMock();
368
+ const resize = installResizeObserverMock();
369
+ const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
370
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
371
+ const metrics = installScrollMetrics(body, { scrollHeight: 1000, clientHeight: 400 });
372
+ controller.injectTestMessage({ type: "status", status: "connecting" });
373
+ emitUser(controller, "intent", 900);
374
+ emit(controller, { id: "reply", role: "assistant", content: "Streaming", streaming: true });
375
+ raf.flush();
376
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
377
+
378
+ metrics.setScrollTop(300);
379
+ toggle(mount, "intent")!.click();
380
+ for (const height of [1400, 1800]) {
381
+ metrics.setScrollHeight(height);
382
+ emit(controller, { id: "reply", role: "assistant", content: `Streaming ${height}`, streaming: true });
383
+ resize.trigger();
384
+ raf.flush();
385
+ expect(metrics.getScrollTop()).toBe(300);
386
+ }
387
+ expect(mount.querySelector("[data-persona-scroll-to-bottom]")?.getAttribute("data-persona-scroll-to-bottom-visible")).toBe("true");
388
+ controller.destroy();
389
+ });
390
+
391
+ it("brings a bubble whose top scrolled away back to the anchor offset on Show less", () => {
392
+ const raf = installRafMock();
393
+ const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
394
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
395
+ let scrollTop = 0;
396
+ const collapsed = () => region(mount, "far")?.getAttribute("data-persona-user-collapse") === "collapsed";
397
+ const scrollHeight = () => (collapsed() ? 270 : 900) + 1100;
398
+ Object.defineProperties(body, {
399
+ scrollTop: {
400
+ configurable: true,
401
+ get: () => scrollTop,
402
+ set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
403
+ },
404
+ scrollHeight: { configurable: true, get: scrollHeight },
405
+ clientHeight: { configurable: true, get: () => 400 },
406
+ });
407
+ // The bubble starts 100px into the content.
408
+ emitUser(controller, "far", 900);
409
+ raf.flush();
410
+ const bubble = mount.querySelector<HTMLElement>('[data-message-id="far"]')!;
411
+ vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() =>
412
+ ({ top: 100 - scrollTop, bottom: 100 - scrollTop + (collapsed() ? 270 : 900) } as DOMRect));
413
+ toggle(mount, "far")!.click();
414
+ // The reader reads down to the Show less button at the bubble's bottom.
415
+ body.scrollTop = 700;
416
+ toggle(mount, "far")!.click();
417
+ expect(collapsed()).toBe(true);
418
+ // Top lands 16px below the viewport top: 100 - 84 = 16.
419
+ expect(scrollTop).toBe(84);
420
+ controller.destroy();
421
+ });
422
+
423
+ it("leaves the anchor-top pin, its spacer, and jump-to-latest intact", () => {
424
+ const raf = installRafMock();
425
+ const resize = installResizeObserverMock();
426
+ const { mount, controller } = mountWidget({
427
+ features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
428
+ });
429
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
430
+ const spacer = body.querySelector<HTMLElement>("[data-persona-anchor-spacer]")!;
431
+ // As in a browser, the scroll height counts the spacer, and the content
432
+ // grows by 660px while the 900px message is expanded (300px collapsed).
433
+ let replyHeight = 0;
434
+ let scrollTop = 0;
435
+ const isExpanded = () => region(mount, "anchored")?.getAttribute("data-persona-user-collapse") === "expanded";
436
+ const bubbleHeight = () => (isExpanded() ? 960 : 300);
437
+ const scrollHeight = () =>
438
+ 1000 + (isExpanded() ? 660 : 0) + replyHeight +
439
+ (spacer.style.display === "none" ? 0 : parseFloat(spacer.style.height) || 0);
440
+ Object.defineProperties(body, {
441
+ scrollTop: {
442
+ configurable: true,
443
+ get: () => scrollTop,
444
+ set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
445
+ },
446
+ scrollHeight: { configurable: true, get: scrollHeight },
447
+ clientHeight: { configurable: true, get: () => 400 },
448
+ });
449
+ controller.injectTestMessage({ type: "status", status: "connecting" });
450
+ emitUser(controller, "anchored", 900);
451
+ // Collapsed on send, before the anchor frame measures the layout.
452
+ expect(region(mount, "anchored")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
453
+ const bubble = mount.querySelector<HTMLElement>('[data-message-id="anchored"]')!;
454
+ Object.defineProperty(bubble, "offsetTop", { value: 700 });
455
+ vi.spyOn(bubble, "offsetHeight", "get").mockImplementation(bubbleHeight);
456
+ // Pinned 16px below the viewport top once the anchor ease lands.
457
+ vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() => {
458
+ const top = 700 - scrollTop;
459
+ return { top, bottom: top + bubbleHeight() } as DOMRect;
460
+ });
461
+ raf.step(1);
462
+ // target = 700 - 16 = 684; spacer = 684 + 400 - 1000 + 24 slack = 108.
463
+ expect(spacer.style.height).toBe("108px");
464
+ raf.flush();
465
+ expect(scrollTop).toBe(684);
466
+
467
+ // Expanding holds the pinned bubble's top at the anchor offset; the
468
+ // grown content now covers the viewport, so the spacer gives its room back.
469
+ toggle(mount, "anchored")!.click();
470
+ raf.flush();
471
+ expect(scrollTop).toBe(684);
472
+ expect(spacer.style.height).toBe("0px");
473
+ // Collapsing re-reserves the spacer so the shrink can't clamp scrollTop.
474
+ toggle(mount, "anchored")!.click();
475
+ raf.flush();
476
+ expect(scrollTop).toBe(684);
477
+ expect(spacer.style.height).toBe("108px");
478
+
479
+ // The reply streams in under the pin, and jump-to-latest still drops the anchor.
480
+ emit(controller, { id: "reply", role: "assistant", content: "Streaming", streaming: true });
481
+ replyHeight = 600;
482
+ resize.trigger();
483
+ raf.flush();
484
+ expect(scrollTop).toBe(684);
485
+ expect(spacer.style.height).toBe("0px");
486
+ const jump = mount.querySelector<HTMLButtonElement>("[data-persona-scroll-to-bottom]")!;
487
+ expect(jump.getAttribute("data-persona-scroll-to-bottom-visible")).toBe("true");
488
+ jump.click();
489
+ raf.flush();
490
+ expect(spacer.style.display).toBe("none");
491
+ expect(scrollTop).toBe(scrollHeight() - 400);
492
+ controller.destroy();
493
+ });
494
+
495
+ it("leaves a custom renderUserMessage bubble untouched", () => {
496
+ installRafMock();
497
+ const { mount, controller } = mountWidget({
498
+ layout: {
499
+ messages: {
500
+ renderUserMessage: ({ message }) => {
501
+ const el = document.createElement("div");
502
+ el.className = "custom-user";
503
+ el.setAttribute("data-message-id", message.id);
504
+ el.textContent = message.content;
505
+ return el;
506
+ },
507
+ },
508
+ },
509
+ });
510
+ emitUser(controller, "custom-render", 900);
511
+ const custom = mount.querySelector<HTMLElement>(".custom-user")!;
512
+ expect(custom.textContent).toBe(LONG_TEXT);
513
+ expect(custom.querySelector("[data-persona-user-collapse]")).toBeNull();
514
+ expect(mount.querySelector("button[data-persona-user-collapse-toggle]")).toBeNull();
515
+ controller.destroy();
516
+ });
517
+
518
+ it("is off by default in V4 and on under future.v5Defaults", () => {
519
+ installRafMock();
520
+ expect(mergeWithDefaults({}).features?.userMessageCollapse).toBeUndefined();
521
+ expect(mergeWithDefaults({ future: { v5Defaults: true } }).features?.userMessageCollapse).toEqual({ enabled: true });
522
+ // A partial override keeps the V5 enable flag.
523
+ expect(mergeWithDefaults({
524
+ future: { v5Defaults: true }, features: { userMessageCollapse: { maxHeight: 300 } },
525
+ }).features?.userMessageCollapse).toEqual({ enabled: true, maxHeight: 300 });
526
+
527
+ for (const v5Defaults of [false, true]) {
528
+ const mount = document.createElement("div");
529
+ document.body.appendChild(mount);
530
+ const controller = createAgentExperience(mount, mergeWithDefaults({
531
+ apiUrl: "https://api.example.com/chat", launcher: { enabled: false }, persistState: false,
532
+ future: { v5Defaults },
533
+ }) as AgentWidgetConfig);
534
+ emitUser(controller, `default-${v5Defaults}`, 900);
535
+ expect(Boolean(toggle(mount, `default-${v5Defaults}`))).toBe(v5Defaults);
536
+ expect(Boolean(region(mount, `default-${v5Defaults}`))).toBe(v5Defaults);
537
+ controller.destroy();
538
+ }
539
+ });
540
+ });
@@ -6,12 +6,10 @@ import {
6
6
  createFollowStateController,
7
7
  getScrollBottomOffset,
8
8
  hasSelectionWithin,
9
- isAttentionWorthyMessage,
10
9
  isElementNearBottom,
11
10
  resolveFollowStateFromScroll,
12
11
  resolveFollowStateFromWheel
13
12
  } from "./auto-follow";
14
- import type { AgentWidgetMessage } from "../types";
15
13
 
16
14
  describe("auto-follow utilities", () => {
17
15
  it("tracks pause and resume state", () => {
@@ -224,39 +222,3 @@ describe("auto-follow utilities", () => {
224
222
  ).toBe("resume");
225
223
  });
226
224
  });
227
-
228
- describe("isAttentionWorthyMessage", () => {
229
- const msg = (over: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
230
- id: "m",
231
- role: "assistant",
232
- content: "",
233
- createdAt: "2026-07-28T00:00:00.000Z",
234
- ...over,
235
- });
236
-
237
- it("treats prose, artifacts/components, images and approvals as output", () => {
238
- expect(isAttentionWorthyMessage(msg({ content: "Here is the answer." }))).toBe(true);
239
- // Artifact / component blocks carry their payload in rawContent with an
240
- // empty `content` — the case a text-only test silently drops.
241
- expect(
242
- isAttentionWorthyMessage(msg({ rawContent: '{"component":"Chart","props":{}}' }))
243
- ).toBe(true);
244
- expect(
245
- isAttentionWorthyMessage(
246
- msg({ contentParts: [{ type: "image", image: "data:image/png;base64,x" }] as never })
247
- )
248
- ).toBe(true);
249
- // Blocked on the reader deciding: must never sit off-screen.
250
- expect(isAttentionWorthyMessage(msg({ variant: "approval" }))).toBe(true);
251
- });
252
-
253
- it("treats tool calls and reasoning as progress chrome", () => {
254
- expect(isAttentionWorthyMessage(msg({ variant: "tool", content: "searching" }))).toBe(false);
255
- expect(isAttentionWorthyMessage(msg({ variant: "reasoning", content: "thinking" }))).toBe(false);
256
- });
257
-
258
- it("skips the empty streaming placeholder and non-assistant roles", () => {
259
- expect(isAttentionWorthyMessage(msg({ content: " " }))).toBe(false);
260
- expect(isAttentionWorthyMessage(msg({ role: "user", content: "hi" }))).toBe(false);
261
- });
262
- });
@@ -1,38 +1,3 @@
1
- import type { AgentWidgetMessage } from "../types";
2
-
3
- /**
4
- * Is this message *output a human came for*, as opposed to progress chrome?
5
- *
6
- * This is the classifier behind anchor-top's "first unread" positioning: the
7
- * transcript anchors to the first attention-worthy block of a response and
8
- * leaves everything else to scroll past underneath. Two categories:
9
- *
10
- * - **Chrome** — tool calls and reasoning. Rendered so the reader *can* audit
11
- * the work, never positioned to. A response that opens with six tool calls
12
- * should not park the reader on the sixth one.
13
- * - **Attention-worthy** — anything that carries the answer: prose, an
14
- * artifact or component block (charts, forms, documents — these carry their
15
- * payload in `rawContent` with an EMPTY `content`, so testing text alone
16
- * silently drops them), multi-modal parts (images), and approval requests
17
- * (which are the strongest case of all: the turn is blocked on the reader
18
- * deciding, so it must never sit off-screen).
19
- *
20
- * The empty streaming placeholder — an assistant bubble created at
21
- * `text_start` before its first token — is deliberately NOT attention-worthy,
22
- * so the anchor waits for content rather than jumping to a blank bubble.
23
- */
24
- export function isAttentionWorthyMessage(message: AgentWidgetMessage): boolean {
25
- if (message.role !== "assistant") return false;
26
- if (message.variant === "tool" || message.variant === "reasoning") return false;
27
- // Blocked on the reader: attention-worthy even with no text of its own.
28
- if (message.variant === "approval") return true;
29
- return Boolean(
30
- message.content?.trim() ||
31
- message.rawContent?.trim() ||
32
- message.contentParts?.length
33
- );
34
- }
35
-
36
1
  export type FollowStateAction = "none" | "pause" | "resume";
37
2
 
38
3
  export type FollowStateController = {