@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.
- package/README.md +22 -1
- package/dist/activity-ui.d.cts +3 -3
- package/dist/activity-ui.d.ts +3 -3
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.d.cts +2 -2
- package/dist/approval-ui.d.ts +2 -2
- package/dist/artifacts-ui.d.cts +3 -3
- package/dist/artifacts-ui.d.ts +3 -3
- package/dist/background-runtime.d.cts +1 -1
- package/dist/background-runtime.d.ts +1 -1
- package/dist/{chunk-gayfbmEU.js → chunk-BBtVdcKU.js} +1 -1
- package/dist/chunk-C2IbR39s.js +1 -0
- package/dist/chunk-CjhA_5Jk.js +1 -0
- package/dist/{chunk-n96BB0YH.js → chunk-Co4LmTFv.js} +1 -1
- package/dist/chunk-DABqu7C3.js +2 -0
- package/dist/chunk-DbD5qyBw.js +3 -0
- package/dist/{chunk-UyZbbbz3.js → chunk-MdlQetfC.js} +2 -2
- package/dist/{chunk-CqEWCkJt.js → chunk-WA9UucGf.js} +1 -1
- package/dist/client-dispatch.js +1 -1
- package/dist/client-history.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/composer-actions-runtime.d.cts +2 -2
- package/dist/composer-actions-runtime.d.ts +2 -2
- package/dist/{composer-input-C76gfIoK.d.cts → composer-input-C2_WizXV.d.cts} +1 -1
- package/dist/{composer-input-C76gfIoK.d.ts → composer-input-C2_WizXV.d.ts} +1 -1
- package/dist/{composer-store-C7tvaz90.d.cts → composer-store-BV6kTlKv.d.cts} +1 -1
- package/dist/{composer-store-C7tvaz90.d.ts → composer-store-BV6kTlKv.d.ts} +1 -1
- package/dist/{context-mention-manager-IfT4JHma.d.cts → context-mention-manager-BfZzcydV.d.cts} +2 -2
- package/dist/{context-mention-manager-IfT4JHma.d.ts → context-mention-manager-BfZzcydV.d.ts} +2 -2
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-Cc_p0PGM.d.cts → defaults-DiTeXyr3.d.cts} +1 -1
- package/dist/{defaults-Cc_p0PGM.d.ts → defaults-DiTeXyr3.d.ts} +1 -1
- package/dist/dictation-runtime.d.cts +2 -2
- package/dist/dictation-runtime.d.ts +2 -2
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-CSxWpG0A.d.cts → fallback-speech-engine-DZ6Of7VU.d.cts} +1 -1
- package/dist/{fallback-speech-engine-CSxWpG0A.d.ts → fallback-speech-engine-DZ6Of7VU.d.ts} +1 -1
- package/dist/{feature-preferences-eeSOmDxg.d.cts → feature-preferences-BT549D_E.d.cts} +1 -1
- package/dist/{feature-preferences-eeSOmDxg.d.ts → feature-preferences-BT549D_E.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/{history-confirm-dialog-DXkuAgh-.d.cts → history-confirm-dialog-Cm4oK4am.d.cts} +1 -1
- package/dist/{history-confirm-dialog-DXkuAgh-.d.ts → history-confirm-dialog-Cm4oK4am.d.ts} +1 -1
- package/dist/history-shell.d.cts +6 -6
- package/dist/history-shell.d.ts +6 -6
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +48 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -9
- package/dist/index.d.ts +9 -9
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -41
- package/dist/index.js.map +1 -1
- package/dist/{loading-label-runtime-loader-C53YyPJ-.d.cts → loading-label-runtime-loader-DmiPDkBu.d.cts} +1 -1
- package/dist/{loading-label-runtime-loader-C53YyPJ-.d.ts → loading-label-runtime-loader-DmiPDkBu.d.ts} +1 -1
- package/dist/loading-label-runtime.d.cts +1 -1
- package/dist/loading-label-runtime.d.ts +1 -1
- package/dist/markdown-parsers.js +18 -15
- package/dist/{message-bubble-Cg_sTXUN.d.cts → message-bubble-D0rosKuK.d.cts} +1 -1
- package/dist/{message-bubble-Cg_sTXUN.d.ts → message-bubble-D0rosKuK.d.ts} +1 -1
- package/dist/preferences-runtime.d.cts +1 -1
- package/dist/preferences-runtime.d.ts +1 -1
- package/dist/{preview-BeMKALry.d.cts → preview-DRU27q-X.d.cts} +2 -2
- package/dist/{preview-BeMKALry.d.ts → preview-DRU27q-X.d.ts} +2 -2
- package/dist/{registry-BBe-Lesw.d.ts → registry-DRk3VMFc.d.cts} +3 -1
- package/dist/{registry-BBe-Lesw.d.cts → registry-DRk3VMFc.d.ts} +3 -1
- package/dist/{session-C0fSteA4.d.cts → session-Dj4bOm5y.d.cts} +18 -9
- package/dist/{session-C0fSteA4.d.ts → session-Dj4bOm5y.d.ts} +18 -9
- package/dist/session-reconnect.d.cts +2 -2
- package/dist/session-reconnect.d.ts +2 -2
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +46 -45
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +17 -19
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{tool-loading-animation-BvdaZfwh.d.cts → tool-loading-animation-D1MS5Ace.d.cts} +1 -1
- package/dist/{tool-loading-animation-BvdaZfwh.d.ts → tool-loading-animation-D1MS5Ace.d.ts} +1 -1
- package/dist/{turn-activity-ui-BYBlInZQ.d.cts → turn-activity-ui-CorwdlI0.d.cts} +2 -2
- package/dist/{turn-activity-ui-BYBlInZQ.d.ts → turn-activity-ui-CorwdlI0.d.ts} +2 -2
- package/dist/turn-activity-ui.d.cts +1 -1
- package/dist/turn-activity-ui.d.ts +1 -1
- package/dist/{types-CiXi4ICM.d.cts → types-CSXHl5cN.d.cts} +5 -0
- package/dist/{types-CiXi4ICM.d.ts → types-CSXHl5cN.d.ts} +5 -0
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +2 -2
- package/dist/voice-runtime.d.ts +2 -2
- package/dist/voice-runtime.js +1 -1
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-BTbOg3Au.d.cts → webmcp-runtime-entry-DpDN6k-6.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-BTbOg3Au.d.ts → webmcp-runtime-entry-DpDN6k-6.d.ts} +1 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/package.json +2 -2
- package/src/index-core.ts +1 -0
- package/src/markdown-parsers-eager.ts +2 -1
- package/src/markdown-parsers-entry.ts +1 -0
- package/src/markdown-parsers-loader.ts +2 -0
- package/src/postprocessors.ts +14 -0
- package/src/session.ts +3 -0
- package/src/types.ts +5 -0
- package/src/ui.background-conversations.test.ts +6 -0
- package/src/ui.page-cache.test.ts +268 -0
- package/src/ui.postprocess.test.ts +80 -1
- package/src/ui.ts +95 -27
- package/src/utils/actions.ts +3 -0
- package/src/utils/streaming-markdown.test.ts +103 -0
- package/src/utils/streaming-markdown.ts +186 -0
- package/dist/chunk-18tPmsiJ.js +0 -1
- package/dist/chunk-Dp2vWE9V.js +0 -1
- 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 -> &#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
|
|
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
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
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.
|
|
5196
|
-
//
|
|
5197
|
-
const handleVoicePageHide = (
|
|
5198
|
-
pageHiding =
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/utils/actions.ts
CHANGED
|
@@ -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
|
|