@runtypelabs/persona 4.30.0 → 4.31.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 (128) hide show
  1. package/README.md +22 -1
  2. package/dist/activity-ui.d.cts +3 -3
  3. package/dist/activity-ui.d.ts +3 -3
  4. package/dist/animations/glyph-cycle.d.cts +1 -1
  5. package/dist/animations/glyph-cycle.d.ts +1 -1
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/approval-ui.d.cts +2 -2
  9. package/dist/approval-ui.d.ts +2 -2
  10. package/dist/artifacts-ui.d.cts +3 -3
  11. package/dist/artifacts-ui.d.ts +3 -3
  12. package/dist/background-runtime.d.cts +1 -1
  13. package/dist/background-runtime.d.ts +1 -1
  14. package/dist/{chunk-gayfbmEU.js → chunk-BBtVdcKU.js} +1 -1
  15. package/dist/chunk-C2IbR39s.js +1 -0
  16. package/dist/chunk-CjhA_5Jk.js +1 -0
  17. package/dist/{chunk-n96BB0YH.js → chunk-Co4LmTFv.js} +1 -1
  18. package/dist/chunk-DABqu7C3.js +2 -0
  19. package/dist/chunk-DbD5qyBw.js +3 -0
  20. package/dist/{chunk-UyZbbbz3.js → chunk-MdlQetfC.js} +2 -2
  21. package/dist/{chunk-CqEWCkJt.js → chunk-WA9UucGf.js} +1 -1
  22. package/dist/client-dispatch.js +1 -1
  23. package/dist/client-history.js +1 -1
  24. package/dist/codegen.cjs +1 -1
  25. package/dist/codegen.js +1 -1
  26. package/dist/composer-actions-runtime.d.cts +2 -2
  27. package/dist/composer-actions-runtime.d.ts +2 -2
  28. package/dist/{composer-input-C76gfIoK.d.cts → composer-input-C2_WizXV.d.cts} +1 -1
  29. package/dist/{composer-input-C76gfIoK.d.ts → composer-input-C2_WizXV.d.ts} +1 -1
  30. package/dist/{composer-store-C7tvaz90.d.cts → composer-store-BV6kTlKv.d.cts} +1 -1
  31. package/dist/{composer-store-C7tvaz90.d.ts → composer-store-BV6kTlKv.d.ts} +1 -1
  32. package/dist/{context-mention-manager-IfT4JHma.d.cts → context-mention-manager-BfZzcydV.d.cts} +2 -2
  33. package/dist/{context-mention-manager-IfT4JHma.d.ts → context-mention-manager-BfZzcydV.d.ts} +2 -2
  34. package/dist/context-mentions-inline.d.cts +2 -2
  35. package/dist/context-mentions-inline.d.ts +2 -2
  36. package/dist/context-mentions.d.cts +3 -3
  37. package/dist/context-mentions.d.ts +3 -3
  38. package/dist/{defaults-Cc_p0PGM.d.cts → defaults-DiTeXyr3.d.cts} +1 -1
  39. package/dist/{defaults-Cc_p0PGM.d.ts → defaults-DiTeXyr3.d.ts} +1 -1
  40. package/dist/dictation-runtime.d.cts +2 -2
  41. package/dist/dictation-runtime.d.ts +2 -2
  42. package/dist/event-stream-view.d.cts +1 -1
  43. package/dist/event-stream-view.d.ts +1 -1
  44. package/dist/{fallback-speech-engine-CSxWpG0A.d.cts → fallback-speech-engine-DZ6Of7VU.d.cts} +1 -1
  45. package/dist/{fallback-speech-engine-CSxWpG0A.d.ts → fallback-speech-engine-DZ6Of7VU.d.ts} +1 -1
  46. package/dist/{feature-preferences-eeSOmDxg.d.cts → feature-preferences-BT549D_E.d.cts} +1 -1
  47. package/dist/{feature-preferences-eeSOmDxg.d.ts → feature-preferences-BT549D_E.d.ts} +1 -1
  48. package/dist/forms-ui.d.cts +2 -2
  49. package/dist/forms-ui.d.ts +2 -2
  50. package/dist/{history-confirm-dialog-DXkuAgh-.d.cts → history-confirm-dialog-Cm4oK4am.d.cts} +1 -1
  51. package/dist/{history-confirm-dialog-DXkuAgh-.d.ts → history-confirm-dialog-Cm4oK4am.d.ts} +1 -1
  52. package/dist/history-shell.d.cts +6 -6
  53. package/dist/history-shell.d.ts +6 -6
  54. package/dist/history-view.d.cts +1 -1
  55. package/dist/history-view.d.ts +1 -1
  56. package/dist/index.cjs +48 -47
  57. package/dist/index.cjs.map +1 -1
  58. package/dist/index.d.cts +9 -9
  59. package/dist/index.d.ts +9 -9
  60. package/dist/index.global.js +1 -1
  61. package/dist/index.global.js.map +1 -1
  62. package/dist/index.js +42 -41
  63. package/dist/index.js.map +1 -1
  64. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.cts → loading-label-runtime-loader-DmiPDkBu.d.cts} +1 -1
  65. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.ts → loading-label-runtime-loader-DmiPDkBu.d.ts} +1 -1
  66. package/dist/loading-label-runtime.d.cts +1 -1
  67. package/dist/loading-label-runtime.d.ts +1 -1
  68. package/dist/markdown-parsers.js +18 -15
  69. package/dist/{message-bubble-Cg_sTXUN.d.cts → message-bubble-D0rosKuK.d.cts} +1 -1
  70. package/dist/{message-bubble-Cg_sTXUN.d.ts → message-bubble-D0rosKuK.d.ts} +1 -1
  71. package/dist/preferences-runtime.d.cts +1 -1
  72. package/dist/preferences-runtime.d.ts +1 -1
  73. package/dist/{preview-BeMKALry.d.cts → preview-DRU27q-X.d.cts} +2 -2
  74. package/dist/{preview-BeMKALry.d.ts → preview-DRU27q-X.d.ts} +2 -2
  75. package/dist/{registry-BBe-Lesw.d.ts → registry-DRk3VMFc.d.cts} +3 -1
  76. package/dist/{registry-BBe-Lesw.d.cts → registry-DRk3VMFc.d.ts} +3 -1
  77. package/dist/{session-C0fSteA4.d.cts → session-Dj4bOm5y.d.cts} +18 -9
  78. package/dist/{session-C0fSteA4.d.ts → session-Dj4bOm5y.d.ts} +18 -9
  79. package/dist/session-reconnect.d.cts +2 -2
  80. package/dist/session-reconnect.d.ts +2 -2
  81. package/dist/smart-dom-reader.d.cts +1 -1
  82. package/dist/smart-dom-reader.d.ts +1 -1
  83. package/dist/theme-editor-preview.cjs +46 -45
  84. package/dist/theme-editor-preview.d.cts +1 -1
  85. package/dist/theme-editor-preview.d.ts +1 -1
  86. package/dist/theme-editor-preview.js +17 -19
  87. package/dist/theme-editor.d.cts +2 -2
  88. package/dist/theme-editor.d.ts +2 -2
  89. package/dist/theme-reference.d.cts +2 -2
  90. package/dist/theme-reference.d.ts +2 -2
  91. package/dist/{tool-loading-animation-BvdaZfwh.d.cts → tool-loading-animation-D1MS5Ace.d.cts} +1 -1
  92. package/dist/{tool-loading-animation-BvdaZfwh.d.ts → tool-loading-animation-D1MS5Ace.d.ts} +1 -1
  93. package/dist/{turn-activity-ui-BYBlInZQ.d.cts → turn-activity-ui-CorwdlI0.d.cts} +2 -2
  94. package/dist/{turn-activity-ui-BYBlInZQ.d.ts → turn-activity-ui-CorwdlI0.d.ts} +2 -2
  95. package/dist/turn-activity-ui.d.cts +1 -1
  96. package/dist/turn-activity-ui.d.ts +1 -1
  97. package/dist/{types-CiXi4ICM.d.cts → types-CSXHl5cN.d.cts} +5 -0
  98. package/dist/{types-CiXi4ICM.d.ts → types-CSXHl5cN.d.ts} +5 -0
  99. package/dist/voice-client-tools.d.cts +1 -1
  100. package/dist/voice-client-tools.d.ts +1 -1
  101. package/dist/voice-runtime.cjs +1 -1
  102. package/dist/voice-runtime.d.cts +2 -2
  103. package/dist/voice-runtime.d.ts +2 -2
  104. package/dist/voice-runtime.js +1 -1
  105. package/dist/voice-worklet-player.d.cts +2 -2
  106. package/dist/voice-worklet-player.d.ts +2 -2
  107. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.cts → webmcp-runtime-entry-DpDN6k-6.d.cts} +1 -1
  108. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.ts → webmcp-runtime-entry-DpDN6k-6.d.ts} +1 -1
  109. package/dist/webmcp-runtime.d.cts +1 -1
  110. package/dist/webmcp-runtime.d.ts +1 -1
  111. package/package.json +2 -2
  112. package/src/index-core.ts +1 -0
  113. package/src/markdown-parsers-eager.ts +2 -1
  114. package/src/markdown-parsers-entry.ts +1 -0
  115. package/src/markdown-parsers-loader.ts +2 -0
  116. package/src/postprocessors.ts +14 -0
  117. package/src/session.ts +3 -0
  118. package/src/types.ts +5 -0
  119. package/src/ui.background-conversations.test.ts +6 -0
  120. package/src/ui.page-cache.test.ts +268 -0
  121. package/src/ui.postprocess.test.ts +80 -1
  122. package/src/ui.ts +95 -27
  123. package/src/utils/actions.ts +3 -0
  124. package/src/utils/streaming-markdown.test.ts +103 -0
  125. package/src/utils/streaming-markdown.ts +186 -0
  126. package/dist/chunk-18tPmsiJ.js +0 -1
  127. package/dist/chunk-Dp2vWE9V.js +0 -1
  128. package/dist/chunk-VloQ2dnh.js +0 -1
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
3
+ import { createAgentExperience } from "./ui";
4
+ import type { AgentWidgetConfig, SpeechEngine, VoiceProvider, VoiceResult, VoiceStatus } from "./types";
5
+
6
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
7
+ beforeEach(() => { window.scrollTo = vi.fn(); });
8
+ afterEach(() => {
9
+ controllers.splice(0).forEach(controller => controller.destroy());
10
+ document.body.replaceChildren();
11
+ localStorage.clear();
12
+ sessionStorage.clear();
13
+ vi.restoreAllMocks();
14
+ vi.unstubAllGlobals();
15
+ });
16
+ function mount(config: AgentWidgetConfig = {}) {
17
+ const element = document.createElement("div");
18
+ document.body.append(element);
19
+ const controller = createAgentExperience(element, {
20
+ apiUrl: "/local-chat", launcher: { enabled: false }, persistState: false,
21
+ suggestionChips: [], ...config,
22
+ });
23
+ controllers.push(controller);
24
+ return { controller, element };
25
+ }
26
+ const transition = (name: "pagehide" | "pageshow", persisted = true) =>
27
+ window.dispatchEvent(new PageTransitionEvent(name, { persisted }));
28
+
29
+ function voice() {
30
+ let active = false;
31
+ let status: (status: VoiceStatus) => void = () => {};
32
+ let result: (result: VoiceResult) => void = () => {};
33
+ const provider: VoiceProvider = {
34
+ type: "custom", connect: vi.fn(async () => {}),
35
+ disconnect: vi.fn(async () => { active = false; }),
36
+ startListening: vi.fn(async () => { active = true; status("listening"); }),
37
+ stopListening: vi.fn(async () => { active = false; status("idle"); }),
38
+ onStatusChange: callback => { status = callback; },
39
+ onResult: callback => { result = callback; }, onError: () => {},
40
+ isBargeInActive: () => active,
41
+ };
42
+ return { provider, result: (text: string) => result({ text, isFinal: true, provider: "custom" }) };
43
+ }
44
+
45
+ it("aborts an active reply while retaining the same transcript and draft through repeated Back restores", async () => {
46
+ const signals: AbortSignal[] = [];
47
+ const customFetch = vi.fn((_url, init) => {
48
+ const signal = init!.signal!;
49
+ signals.push(signal);
50
+ return new Promise<Response>((_resolve, reject) => {
51
+ signal.addEventListener("abort", () => reject(new DOMException("Stopped", "AbortError")), { once: true });
52
+ });
53
+ });
54
+ const { controller, element } = mount({ customFetch });
55
+ for (let cycle = 0; cycle < 3; cycle++) {
56
+ expect(controller.submitMessage(`Question ${cycle}`)).toBe(true);
57
+ await vi.waitFor(() => expect(signals).toHaveLength(cycle + 1));
58
+ const input = element.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
59
+ input.value = "Keep this draft";
60
+ input.dispatchEvent(new Event("input", { bubbles: true }));
61
+ transition("pagehide");
62
+ expect(signals[cycle].aborted).toBe(true);
63
+ expect(controller.getState().streaming).toBe(false);
64
+ transition("pageshow");
65
+ expect(element.querySelector("[data-persona-composer-input]")).toBe(input);
66
+ expect(controller.getComposerState().text).toBe("Keep this draft");
67
+ expect(controller.getMessages().some(message => message.content === `Question ${cycle}`)).toBe(true);
68
+ await Promise.resolve();
69
+ expect(customFetch).toHaveBeenCalledTimes(cycle + 1);
70
+ }
71
+ });
72
+
73
+ it("disconnects session voice on cached hide and keeps the controls usable without restarting the call", async () => {
74
+ const { provider } = voice();
75
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
76
+ for (let cycle = 0; cycle < 2; cycle++) {
77
+ controller.startVoiceRecognition();
78
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledTimes(cycle + 1));
79
+ transition("pagehide");
80
+ expect(provider.disconnect).toHaveBeenCalledTimes(cycle + 1);
81
+ expect(controller.isVoiceActive()).toBe(false);
82
+ transition("pageshow");
83
+ await Promise.resolve();
84
+ expect(provider.startListening).toHaveBeenCalledTimes(cycle + 1);
85
+ }
86
+ });
87
+
88
+ it("does not let a pending microphone start cross a cached navigation", async () => {
89
+ const { provider } = voice();
90
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
91
+ controller.startVoiceRecognition();
92
+ transition("pagehide");
93
+ transition("pageshow");
94
+ await new Promise(resolve => setTimeout(resolve, 50));
95
+ expect(provider.startListening).not.toHaveBeenCalled();
96
+ controller.startVoiceRecognition();
97
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
98
+ });
99
+
100
+ it("cancels dictation without submitting its final result or losing the previous draft", async () => {
101
+ const { provider, result } = voice();
102
+ const { controller } = mount({ voiceRecognition: { enabled: true, completionBehavior: "review",
103
+ dictation: { provider: { type: "custom", custom: () => provider } } } });
104
+ controller.setMessage("Original draft");
105
+ controller.startDictation();
106
+ await vi.waitFor(() => expect(controller.getDictationState().phase).toBe("recording"));
107
+ transition("pagehide");
108
+ result("Late transcript");
109
+ transition("pageshow");
110
+ await vi.waitFor(() => expect(provider.disconnect).toHaveBeenCalled());
111
+ expect(controller.getDictationState().phase).toBe("idle");
112
+ expect(controller.getComposerState().text).toBe("Original draft");
113
+ expect(controller.getMessages()).toHaveLength(0);
114
+ });
115
+
116
+ it("stops browser recognition without accepting a trailing result and can record again on Back", () => {
117
+ const recordings: Recognition[] = [];
118
+ class Recognition {
119
+ onresult: ((event: unknown) => void) | null = null;
120
+ onend: (() => void) | null = null;
121
+ onerror: ((event: unknown) => void) | null = null;
122
+ start = vi.fn();
123
+ stop = vi.fn();
124
+ constructor() { recordings.push(this); }
125
+ }
126
+ vi.stubGlobal("SpeechRecognition", Recognition);
127
+ const { controller } = mount({ voiceRecognition: { enabled: true } });
128
+ controller.setMessage("Keep my draft");
129
+ expect(controller.startVoiceRecognition()).toBe(true);
130
+ expect(recordings[0].start).toHaveBeenCalledOnce();
131
+ transition("pagehide");
132
+ expect(recordings[0].stop).toHaveBeenCalledOnce();
133
+ recordings[0].onresult?.({ results: [Object.assign([{ transcript: "Late result" }], { isFinal: true })] });
134
+ transition("pageshow");
135
+ expect(controller.getComposerState().text).toBe("Keep my draft");
136
+ expect(controller.startVoiceRecognition()).toBe(true);
137
+ expect(recordings[1].start).toHaveBeenCalledOnce();
138
+ expect(controller.getMessages()).toHaveLength(0);
139
+ });
140
+
141
+ it("stops read-aloud playback on cached hide and does not resume it on Back", async () => {
142
+ const engine: SpeechEngine = { id: "cache-test", supportsPause: false,
143
+ speak: vi.fn((_request, callbacks) => { callbacks.onStart?.(); }),
144
+ stop: vi.fn(), pause: vi.fn(), resume: vi.fn(),
145
+ };
146
+ const { controller } = mount({ textToSpeech: { enabled: true, createEngine: () => engine } });
147
+ const message = controller.injectAssistantMessage({ content: "Read this reply aloud." });
148
+ controller.toggleReadAloud(message.id);
149
+ await vi.waitFor(() => expect(controller.getReadAloudState(message.id)).toBe("playing"));
150
+ transition("pagehide");
151
+ expect(engine.stop).toHaveBeenCalled();
152
+ expect(controller.getReadAloudState(message.id)).toBe("idle");
153
+ transition("pageshow");
154
+ await Promise.resolve();
155
+ expect(engine.speak).toHaveBeenCalledOnce();
156
+ });
157
+
158
+ it("abandons an asynchronous before-send hook and leaves its draft sendable after Back", async () => {
159
+ let finishHook: () => void = () => {};
160
+ let signal: AbortSignal | undefined;
161
+ const hook = vi.fn((_snapshot, context) => {
162
+ signal = context.signal;
163
+ return new Promise<void>(resolve => { finishHook = resolve; });
164
+ });
165
+ const customFetch = vi.fn(async () => new Response("", { headers: { "content-type": "text/event-stream" } }));
166
+ const { controller, element } = mount({ composer: { onBeforeSend: hook }, customFetch });
167
+ controller.setMessage("Unsent draft");
168
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
169
+ await vi.waitFor(() => expect(hook).toHaveBeenCalledOnce());
170
+ transition("pagehide");
171
+ expect(signal?.aborted).toBe(true);
172
+ transition("pageshow");
173
+ expect(customFetch).not.toHaveBeenCalled();
174
+ expect(controller.getComposerState().text).toBe("Unsent draft");
175
+ let finishNext: () => void = () => {};
176
+ const nextHook = vi.fn(() => new Promise<void>(resolve => { finishNext = resolve; }));
177
+ controller.update({ composer: { onBeforeSend: nextHook } });
178
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
179
+ await vi.waitFor(() => expect(nextHook).toHaveBeenCalledOnce());
180
+ finishHook();
181
+ await Promise.resolve();
182
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
183
+ expect(nextHook).toHaveBeenCalledOnce();
184
+ expect(customFetch).not.toHaveBeenCalled();
185
+ finishNext();
186
+ await vi.waitFor(() => expect(customFetch).toHaveBeenCalledOnce());
187
+ });
188
+
189
+ it("retains the live-call metadata used to redial on a genuine document navigation", async () => {
190
+ const { provider } = voice();
191
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
192
+ controller.startVoiceRecognition();
193
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
194
+ transition("pagehide", false);
195
+ expect(provider.disconnect).not.toHaveBeenCalled();
196
+ controller.destroy();
197
+ controllers.splice(controllers.indexOf(controller), 1);
198
+ expect(provider.disconnect).toHaveBeenCalledOnce();
199
+ expect(controller.getPersistentMetadata().voiceState).toMatchObject({ active: true });
200
+ });
201
+
202
+ it.each(["queued", "late", "injected"])("discards %s action resubmits across Back and accepts fresh actions", async mode => {
203
+ vi.useFakeTimers();
204
+ try {
205
+ let trigger = () => {};
206
+ const customFetch = vi.fn(async () => new Response("", { headers: { "content-type": "text/event-stream" } }));
207
+ const { controller } = mount({ customFetch, actionHandlers: [(_action, context) => {
208
+ trigger = context.triggerResubmit;
209
+ return { handled: true, displayText: "Action handled", resubmit: mode === "injected" };
210
+ }] });
211
+ controller.injectAssistantMessage({ content: '{"action":"search"}' });
212
+ await vi.advanceTimersByTimeAsync(0);
213
+ if (mode === "queued") trigger();
214
+ transition("pagehide");
215
+ transition("pageshow");
216
+ if (mode === "late") trigger();
217
+ if (mode === "injected") controller.injectAssistantMessage({ content: "Late result" });
218
+ await vi.advanceTimersByTimeAsync(200);
219
+ expect(customFetch).not.toHaveBeenCalled();
220
+ controller.injectAssistantMessage({ content: '{"action":"search"}' });
221
+ await vi.advanceTimersByTimeAsync(0);
222
+ if (mode !== "injected") trigger();
223
+ await vi.advanceTimersByTimeAsync(200);
224
+ expect(customFetch).toHaveBeenCalledOnce();
225
+ } finally { vi.useRealTimers(); }
226
+ });
227
+
228
+ it.each(["back", "destroy", "navigation"])("guards queued metadata voice restoration on %s", async lifecycle => {
229
+ vi.useFakeTimers();
230
+ try {
231
+ const { provider } = voice();
232
+ const { controller } = mount({
233
+ persistState: true, storageAdapter: { load: () => ({ messages: [], metadata: { voiceState: { active: true, timestamp: Date.now() } } }), save: () => {} },
234
+ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } },
235
+ });
236
+ if (lifecycle === "back") { transition("pagehide"); transition("pageshow"); }
237
+ if (lifecycle === "destroy") controller.destroy();
238
+ await vi.advanceTimersByTimeAsync(1100);
239
+ expect(provider.startListening).toHaveBeenCalledTimes(lifecycle === "navigation" ? 1 : 0);
240
+ if (lifecycle === "back") {
241
+ controller.startVoiceRecognition();
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ expect(provider.startListening).toHaveBeenCalledOnce();
244
+ }
245
+ } finally { vi.useRealTimers(); }
246
+ });
247
+
248
+ it("discards the delayed browser voice restore before Back while allowing a fresh mic tap", async () => {
249
+ vi.useFakeTimers();
250
+ try {
251
+ const start = vi.fn();
252
+ class Recognition { start = start; stop = vi.fn(); }
253
+ vi.stubGlobal("SpeechRecognition", Recognition);
254
+ const { controller } = mount({
255
+ persistState: true,
256
+ storageAdapter: { load: () => ({ messages: [], metadata: { voiceState: { active: true, timestamp: Date.now() } } }), save: () => {} },
257
+ voiceRecognition: { enabled: true },
258
+ });
259
+ await vi.advanceTimersByTimeAsync(100);
260
+ expect(start).not.toHaveBeenCalled();
261
+ transition("pagehide");
262
+ transition("pageshow");
263
+ await vi.advanceTimersByTimeAsync(1100);
264
+ expect(start).not.toHaveBeenCalled();
265
+ expect(controller.startVoiceRecognition()).toBe(true);
266
+ expect(start).toHaveBeenCalledOnce();
267
+ } finally { vi.useRealTimers(); }
268
+ });
@@ -3,7 +3,7 @@
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { buildPostprocessor } from "./ui";
6
- import { escapeHtml } from "./postprocessors";
6
+ import { escapeHtml, markdownPostprocessor, preprocessStreamingMarkdown } from "./postprocessors";
7
7
  import * as parsersLoader from "./markdown-parsers-loader";
8
8
  import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
9
9
 
@@ -93,6 +93,62 @@ describe("buildPostprocessor degraded path (parsers not loaded)", () => {
93
93
  });
94
94
 
95
95
  describe("buildPostprocessor loaded path (parsers available)", () => {
96
+ it("lets custom callbacks transform text before opting into the shared streaming repairs", () => {
97
+ const text = "<answer>Read **unfinished";
98
+ const raw = JSON.stringify({ answer: text });
99
+ const message: AgentWidgetMessage = {
100
+ id: "custom-stream", role: "assistant", content: text, rawContent: raw, createdAt: "2026-10-20T00:00:00.000Z",
101
+ };
102
+ const custom = vi.fn(({ text, streaming }: { text: string; streaming: boolean }) =>
103
+ markdownPostprocessor(preprocessStreamingMarkdown(text.replace("<answer>", ""), streaming)));
104
+ const transform = buildPostprocessor({ postprocessMessage: custom, sanitize: true }, undefined, undefined);
105
+ const displayText = text.replace("<answer>", "");
106
+ const builtIn = buildPostprocessor({ markdown: {} }, undefined, undefined);
107
+ expect(transform({ text, raw, message, streaming: true })).toBe(
108
+ builtIn({ text: displayText, message, streaming: true }));
109
+ expect(transform({ text, raw, message, streaming: false })).toBe("<p>Read **unfinished</p>\n");
110
+ expect(message.content).toBe(text);
111
+ expect(message.rawContent).toBe(raw);
112
+ expect(custom.mock.calls[0][0]).toMatchObject({ text, raw, streaming: true });
113
+ });
114
+
115
+ it("repairs streaming display without mutating the stored or final Markdown", () => {
116
+ const text = "Read **unfinished";
117
+ const message: AgentWidgetMessage = {
118
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
119
+ };
120
+ const transform = buildPostprocessor({ markdown: {} }, undefined, undefined);
121
+ expect(transform({ text, message, streaming: true })).toContain("<strong>unfinished</strong>");
122
+ expect(message.content).toBe(text);
123
+ expect(transform({ text, message, streaming: false })).toBe("<p>Read **unfinished</p>\n");
124
+ });
125
+
126
+ it("leaves custom postprocessors and plain-text rendering untouched during streaming", () => {
127
+ const text = "**unfinished";
128
+ const message: AgentWidgetMessage = {
129
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
130
+ };
131
+ const custom = vi.fn(({ text }: { text: string }) => escapeHtml(text));
132
+ const transform = buildPostprocessor({ markdown: {}, postprocessMessage: custom }, undefined, undefined);
133
+ expect(transform({ text, message, streaming: true })).toBe(text);
134
+ expect(custom.mock.calls[0][0].text).toBe(text);
135
+ expect(buildPostprocessor(undefined, undefined, undefined)({ text, message, streaming: true })).toBe(text);
136
+ });
137
+
138
+ it("sanitizes completed links after streaming repair and retains legacy custom parser compatibility", () => {
139
+ const text = "[bad](javascript:alert(1)) **hello";
140
+ const message: AgentWidgetMessage = {
141
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
142
+ };
143
+ const transform = buildPostprocessor({ markdown: {} }, undefined, undefined);
144
+ const out = transform({ text, message, streaming: true });
145
+ expect(out).toContain("<strong>hello</strong>");
146
+ expect(out).not.toContain("javascript:");
147
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
148
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue({ Marked: parsers.Marked, DOMPurify: parsers.DOMPurify });
149
+ expect(transform({ text, message, streaming: true })).toContain("**hello");
150
+ });
151
+
96
152
  it("renders markdown to real HTML, normalizes apostrophes, strips scripts", () => {
97
153
  // No spy: vitest.setup.ts eager-provides parsers, so this is the loaded path.
98
154
  const text = "**bold** I'll go\n\n<script>alert(1)</script>";
@@ -152,3 +208,26 @@ describe("buildPostprocessor failure containment", () => {
152
208
  expect(out).toBe(escapeHtml(SAMPLE));
153
209
  });
154
210
  });
211
+
212
+ describe("preprocessStreamingMarkdown parser readiness", () => {
213
+ it("leaves final text unchanged even when a parser repair would modify it", () => {
214
+ const repair = vi.fn(() => "repaired");
215
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
216
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue({ ...parsers, stabilizeStreamingMarkdown: repair });
217
+ expect(preprocessStreamingMarkdown("**unfinished", false)).toBe("**unfinished");
218
+ expect(repair).not.toHaveBeenCalled();
219
+ expect(preprocessStreamingMarkdown("**unfinished", true)).toBe("repaired");
220
+ });
221
+
222
+ it("uses the same lightweight table fallback before parsers load and with legacy loaders", () => {
223
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
224
+ const text = "| A | B |\n| ---";
225
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue(null);
226
+ const beforeLoad = preprocessStreamingMarkdown(text, true);
227
+ expect(beforeLoad).not.toBe(text);
228
+ expect(preprocessStreamingMarkdown("**unfinished", true)).toBe("**unfinished");
229
+ vi.mocked(parsersLoader.getMarkdownParsersSync).mockReturnValue({ Marked: parsers.Marked, DOMPurify: parsers.DOMPurify });
230
+ expect(preprocessStreamingMarkdown(text, true)).toBe(beforeLoad);
231
+ expect(preprocessStreamingMarkdown(text, false)).toBe(text);
232
+ });
233
+ });
package/src/ui.ts CHANGED
@@ -11,9 +11,8 @@ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
11
11
  import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
12
12
  import type { DictationSession } from "./dictation-session";
13
13
  import { usesSessionVoice, usesSessionVoiceForConfig } from "./utils/voice-support";
14
- import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
14
+ import { escapeHtml, createMarkdownProcessorFromConfig, preprocessStreamingMarkdown } from "./postprocessors";
15
15
  import { resolveSanitizer } from "./utils/sanitize";
16
- import { stabilizeStreamingTables } from "./utils/streaming-table";
17
16
  import { wrapScrollableTables, refreshTableScrollFades } from "./utils/table-scroll-fade";
18
17
  import { getMarkdownParsersSync } from "./markdown-parsers-loader";
19
18
  import { loadClientStream } from "./client-stream-loader";
@@ -817,7 +816,8 @@ export const buildPostprocessor = (
817
816
  // displayed entities literally (I'll -> &amp;#39;). Only run the sanitizer when
818
817
  // it can actually parse HTML; escapeHtml output is already inert. Checked per
819
818
  // render (not once at setup): the chunk lands later and the self-heal re-renders.
820
- const parsersReady = getMarkdownParsersSync() !== null;
819
+ const parsers = getMarkdownParsersSync();
820
+ const parsersReady = parsers !== null;
821
821
  let html: string;
822
822
  if (cfg?.postprocessMessage) {
823
823
  const out = cfg.postprocessMessage({
@@ -829,10 +829,9 @@ export const buildPostprocessor = (
829
829
  // sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
830
830
  html = sanitize ? sanitize(out) : out;
831
831
  } else if (markdownProcessor) {
832
- // While streaming, normalize tables-in-progress so they render as a real
833
- // <table> from the first row with a stable column count (Telegram-style
834
- // space reservation). The final, non-streaming render is left untouched.
835
- const source = context.streaming ? stabilizeStreamingTables(nextText) : nextText;
832
+ // Stabilize incomplete Markdown only for display during streaming. The
833
+ // final render and the stored response always use the original text.
834
+ const source = preprocessStreamingMarkdown(nextText, context.streaming);
836
835
  // Already escapeHtml(text) (single, safe) while parsers are not loaded.
837
836
  const out = markdownProcessor(source);
838
837
  html = sanitize && parsersReady ? sanitize(out) : out;
@@ -1226,6 +1225,10 @@ export const createAgentExperience = (
1226
1225
  getSessionMetadata,
1227
1226
  updateSessionMetadata,
1228
1227
  emit: eventBus.emit,
1228
+ captureResubmitGuard: () => {
1229
+ const generation = restartGeneration;
1230
+ return () => canRestart(generation);
1231
+ },
1229
1232
  documentRef: typeof document !== "undefined" ? document : null
1230
1233
  });
1231
1234
  actionManager.syncFromMetadata();
@@ -1250,10 +1253,31 @@ export const createAgentExperience = (
1250
1253
  // Track pending resubmit state for injection-triggered resubmit
1251
1254
  // When a handler returns resubmit: true, we wait for injectAssistantMessage()
1252
1255
  // to be called before triggering the actual resubmit (to avoid race conditions)
1256
+ // Cached restoration reuses this controller, so work queued by the old page
1257
+ // lifecycle must remain stale even after pageshow makes the page active again.
1258
+ let restartGeneration = 0;
1259
+ let destroyed = false;
1260
+ const canRestart = (generation: number) =>
1261
+ !destroyed && !pageHiding && generation === restartGeneration;
1262
+ const invalidatePendingRestarts = () => {
1263
+ restartGeneration++;
1264
+ pendingResubmit = false;
1265
+ if (pendingResubmitTimeout !== null) clearTimeout(pendingResubmitTimeout);
1266
+ pendingResubmitTimeout = null;
1267
+ };
1268
+ const scheduleResubmit = () => {
1269
+ const generation = restartGeneration;
1270
+ if (!canRestart(generation)) return;
1271
+ setTimeout(() => {
1272
+ if (canRestart(generation) && !session.isStreaming()) session.continueConversation();
1273
+ }, 100);
1274
+ };
1275
+
1253
1276
  let pendingResubmit = false;
1254
1277
  let pendingResubmitTimeout: ReturnType<typeof setTimeout> | null = null;
1255
1278
 
1256
1279
  const handleResubmitRequested = () => {
1280
+ if (!canRestart(restartGeneration)) return;
1257
1281
  pendingResubmit = true;
1258
1282
  // Clear any existing timeout
1259
1283
  if (pendingResubmitTimeout) {
@@ -5166,11 +5190,14 @@ export const createAgentExperience = (
5166
5190
  const timestamp = Number(rawVoiceState.timestamp ?? 0);
5167
5191
  voiceState.manuallyDeactivated = Boolean(rawVoiceState.manuallyDeactivated);
5168
5192
  if (wasActive && Date.now() - timestamp < VOICE_STATE_RESTORE_WINDOW) {
5193
+ const generation = restartGeneration;
5169
5194
  setTimeout(() => {
5195
+ if (!canRestart(generation)) return;
5170
5196
  if (!voiceState.active) {
5171
5197
  voiceState.manuallyDeactivated = false;
5172
5198
  if (usesSessionVoice(config.voiceRecognition?.provider)) {
5173
5199
  session.toggleVoice().then(() => {
5200
+ if (!canRestart(generation)) return;
5174
5201
  voiceState.active = isSessionVoiceLive();
5175
5202
  emitVoiceState("restore");
5176
5203
  if (voiceState.active) {
@@ -5192,10 +5219,10 @@ export const createAgentExperience = (
5192
5219
  }
5193
5220
  };
5194
5221
  // Re-stamp a live call as the page unloads: the restore window counts from
5195
- // the navigation, not from when the (possibly long) call was started. A
5196
- // back/forward-cache hide (`persisted`) isn't an unload: the page lives on.
5197
- const handleVoicePageHide = (event: PageTransitionEvent) => {
5198
- pageHiding = !event.persisted;
5222
+ // the navigation, not from when the (possibly long) call was started. Protect
5223
+ // that record from the trailing disconnect statuses, including cached hides.
5224
+ const handleVoicePageHide = () => {
5225
+ pageHiding = true;
5199
5226
  if (voiceState.active) persistVoiceMetadata();
5200
5227
  };
5201
5228
  if (typeof window !== "undefined") {
@@ -9625,6 +9652,7 @@ export const createAgentExperience = (
9625
9652
  // otherwise dispatch the same text/command twice. It also covers an async
9626
9653
  // `onBeforeSend` (the `preparing` phase).
9627
9654
  let submitInFlight = false;
9655
+ let submitEpoch = 0;
9628
9656
  /** Aborts the in-flight preparation on destroy or a superseding stop. */
9629
9657
  let submissionAbort: AbortController | null = null;
9630
9658
 
@@ -10035,10 +10063,11 @@ export const createAgentExperience = (
10035
10063
  }
10036
10064
  if (submitInFlight) return;
10037
10065
  submitInFlight = true;
10066
+ const epoch = ++submitEpoch;
10038
10067
  try {
10039
10068
  await performSubmit({ resolveInlineCommand: true, ...submitOptions });
10040
10069
  } finally {
10041
- submitInFlight = false;
10070
+ if (epoch === submitEpoch) submitInFlight = false;
10042
10071
  }
10043
10072
  };
10044
10073
 
@@ -10065,8 +10094,9 @@ export const createAgentExperience = (
10065
10094
  ): void => {
10066
10095
  if (submitInFlight) return;
10067
10096
  submitInFlight = true;
10097
+ const epoch = ++submitEpoch;
10068
10098
  const finish = () => {
10069
- submitInFlight = false;
10099
+ if (epoch === submitEpoch) submitInFlight = false;
10070
10100
  };
10071
10101
  try {
10072
10102
  const result = performSubmit({ ...options, overrideText: text });
@@ -10508,7 +10538,7 @@ export const createAgentExperience = (
10508
10538
 
10509
10539
  voiceState.active = false;
10510
10540
  emitVoiceState(source);
10511
- persistVoiceMetadata();
10541
+ if (!pageHiding) persistVoiceMetadata();
10512
10542
  syncComposerCompact();
10513
10543
 
10514
10544
  if (micButton) {
@@ -10698,9 +10728,10 @@ export const createAgentExperience = (
10698
10728
  const pendingSubmissionUnsub = eventBus.on("assistant:complete", () => {
10699
10729
  const pending = composerStore.getState().pendingSubmission;
10700
10730
  const generation = composerConversationGeneration;
10731
+ const restart = restartGeneration;
10701
10732
  if (!pending) return;
10702
10733
  setTimeout(() => {
10703
- if (session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
10734
+ if (!canRestart(restart) || session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
10704
10735
  sendPendingSubmission();
10705
10736
  }, 0);
10706
10737
  });
@@ -10713,11 +10744,13 @@ export const createAgentExperience = (
10713
10744
  return;
10714
10745
  }
10715
10746
  const generation = composerConversationGeneration;
10747
+ const restart = restartGeneration;
10716
10748
  setTimeout(() => {
10717
- if (generation !== composerConversationGeneration) return;
10749
+ if (!canRestart(restart) || generation !== composerConversationGeneration) return;
10718
10750
  if (!voiceState.active && !voiceState.manuallyDeactivated) {
10719
10751
  if (usesSessionVoice(config.voiceRecognition?.provider)) {
10720
10752
  session.toggleVoice().then(() => {
10753
+ if (!canRestart(restart) || generation !== composerConversationGeneration) return;
10721
10754
  voiceState.active = session.isVoiceActive();
10722
10755
  emitVoiceState("auto");
10723
10756
  if (session.isVoiceActive()) applyRuntypeMicRecordingStyles();
@@ -10730,17 +10763,50 @@ export const createAgentExperience = (
10730
10763
  });
10731
10764
  destroyCallbacks.push(autoResumeUnsub);
10732
10765
 
10766
+ // A cached document retains its DOM and controller, but must release live
10767
+ // transports and capture before it freezes. A fresh navigation still uses
10768
+ // the existing teardown/voice-metadata path above.
10769
+ let cachedPage = false;
10770
+ const suspendCachedPage = (event: PageTransitionEvent) => {
10771
+ if (!event.persisted || cachedPage) return;
10772
+ cachedPage = true;
10773
+ pageHiding = true;
10774
+ invalidatePendingRestarts();
10775
+ composerConversationGeneration++;
10776
+ submitEpoch++;
10777
+ submitInFlight = false;
10778
+ submissionAbort?.abort();
10779
+ submissionAbort = null;
10780
+ composerStore.setPreparing(false);
10781
+ dismissDictation();
10782
+ stopVoiceRecognition("system");
10783
+ session.cancel();
10784
+ session.stopSpeaking();
10785
+ session.cleanupVoice();
10786
+ throughputTracker?.reset();
10787
+ eventStreamView?.update();
10788
+ };
10789
+ const restoreCachedPage = (event: PageTransitionEvent) => {
10790
+ if (!event.persisted || !cachedPage) return;
10791
+ cachedPage = false;
10792
+ pageHiding = false;
10793
+ // Rebuild the provider lazily so the same controls can start a new call.
10794
+ // Neither stopped replies nor calls restart automatically on Back.
10795
+ if (config.voiceRecognition?.enabled === true && usesSessionVoiceForConfig(config)) session.setupVoice();
10796
+ };
10797
+ window.addEventListener("pagehide", suspendCachedPage);
10798
+ window.addEventListener("pageshow", restoreCachedPage);
10799
+ destroyCallbacks.push(() => {
10800
+ window.removeEventListener("pagehide", suspendCachedPage);
10801
+ window.removeEventListener("pageshow", restoreCachedPage);
10802
+ });
10803
+
10733
10804
  // Handle action:resubmit event - automatically trigger another model call
10734
10805
  // when an action handler needs the model to continue processing (e.g., analyzing search results)
10735
10806
  const resubmitUnsub = eventBus.on("action:resubmit", () => {
10736
10807
  // Short delay to allow UI to update with any injected messages
10737
10808
  // Handlers should call context.triggerResubmit() AFTER their async work completes
10738
- setTimeout(() => {
10739
- if (session && !session.isStreaming()) {
10740
- // Continue conversation without adding a visible user message
10741
- session.continueConversation();
10742
- }
10743
- }, 100);
10809
+ scheduleResubmit();
10744
10810
  });
10745
10811
  destroyCallbacks.push(resubmitUnsub);
10746
10812
 
@@ -12234,6 +12300,10 @@ export const createAgentExperience = (
12234
12300
  getSessionMetadata,
12235
12301
  updateSessionMetadata,
12236
12302
  emit: eventBus.emit,
12303
+ captureResubmitGuard: () => {
12304
+ const generation = restartGeneration;
12305
+ return () => canRestart(generation);
12306
+ },
12237
12307
  documentRef: typeof document !== "undefined" ? document : null
12238
12308
  });
12239
12309
 
@@ -12883,11 +12953,7 @@ export const createAgentExperience = (
12883
12953
  pendingResubmitTimeout = null;
12884
12954
  }
12885
12955
  // Short delay to ensure message is in context
12886
- setTimeout(() => {
12887
- if (session && !session.isStreaming()) {
12888
- session.continueConversation();
12889
- }
12890
- }, 100);
12956
+ scheduleResubmit();
12891
12957
  }
12892
12958
 
12893
12959
  return result;
@@ -13224,6 +13290,8 @@ export const createAgentExperience = (
13224
13290
  return session.submitNPSFeedback(rating, comment);
13225
13291
  },
13226
13292
  destroy() {
13293
+ destroyed = true;
13294
+ invalidatePendingRestarts();
13227
13295
  renderReadiness.destroy();
13228
13296
  slots.destroy();
13229
13297
  mount.removeEventListener("click", routeLinkClick);
@@ -28,6 +28,7 @@ type ActionManagerOptions = {
28
28
  payload: AgentWidgetControllerEventMap[K]
29
29
  ) => void;
30
30
  documentRef: Document | null;
31
+ captureResubmitGuard?: () => () => boolean;
31
32
  };
32
33
 
33
34
  const stripCodeFence = (value: string) => {
@@ -195,7 +196,9 @@ export const createActionManager = (options: ActionManagerOptions) => {
195
196
  try {
196
197
  // Create triggerResubmit function that emits the resubmit event
197
198
  // Handlers should call this AFTER async work completes (not return resubmit: true)
199
+ const canResubmit = options.captureResubmitGuard?.();
198
200
  const triggerResubmit = () => {
201
+ if (canResubmit && !canResubmit()) return;
199
202
  options.emit("action:resubmit", eventPayload);
200
203
  };
201
204