@runtypelabs/persona 4.5.0 → 4.6.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 (42) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-C6tFDxKy.d.cts → types-CSmiKRVa.d.cts} +11 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-CSmiKRVa.d.ts} +11 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-DFBSCFYN.js +1 -0
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +51 -51
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +238 -3
  13. package/dist/index.d.ts +238 -3
  14. package/dist/index.global.js +37 -37
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +51 -51
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
  20. package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
  21. package/dist/session-reconnect-U77QFUR7.js +1 -0
  22. package/dist/smart-dom-reader.d.cts +95 -0
  23. package/dist/smart-dom-reader.d.ts +95 -0
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +135 -1
  26. package/dist/theme-editor-preview.d.ts +135 -1
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.d.cts +95 -0
  29. package/dist/theme-editor.d.ts +95 -0
  30. package/package.json +5 -3
  31. package/src/client.ts +55 -9
  32. package/src/generated/runtype-openapi-contract.ts +16 -1
  33. package/src/reconnect-wake.test.ts +162 -0
  34. package/src/reconnect.test.ts +430 -0
  35. package/src/session-reconnect.ts +282 -0
  36. package/src/session.ts +408 -5
  37. package/src/types.ts +107 -1
  38. package/src/ui.stream-animation-update.test.ts +99 -0
  39. package/src/ui.ts +71 -1
  40. package/src/utils/constants.ts +3 -1
  41. package/src/utils/morph.test.ts +47 -0
  42. package/src/utils/morph.ts +18 -0
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import {
7
+ registerStreamAnimationPlugin,
8
+ unregisterStreamAnimationPlugin,
9
+ } from "./utils/stream-animation";
10
+ import type { StreamAnimationPlugin } from "./types";
11
+
12
+ // A plugin animation carries its own CSS (built-in animations live in
13
+ // widget.css). Plugin styles are injected only via ensurePluginActive, which the
14
+ // initial mount runs but controller.update() historically skipped — so switching
15
+ // to a plugin animation live used to set the type without ever injecting the CSS.
16
+ const TEST_PLUGIN: StreamAnimationPlugin = {
17
+ name: "test-reveal",
18
+ containerClass: "test-reveal-stream",
19
+ wrap: "char",
20
+ styles: ".test-reveal-stream .persona-stream-char { opacity: 0; }",
21
+ };
22
+
23
+ const createMount = () => {
24
+ const mount = document.createElement("div");
25
+ document.body.appendChild(mount);
26
+ return mount;
27
+ };
28
+
29
+ const styleTag = (mount: HTMLElement) =>
30
+ mount.querySelector('style[data-persona-animation="test-reveal"]');
31
+
32
+ describe("createAgentExperience: stream-animation plugin activation on update()", () => {
33
+ beforeEach(() => {
34
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
35
+ cb(0);
36
+ return 1;
37
+ });
38
+ vi.stubGlobal("cancelAnimationFrame", () => {});
39
+ window.scrollTo = vi.fn();
40
+ registerStreamAnimationPlugin(TEST_PLUGIN);
41
+ });
42
+
43
+ afterEach(() => {
44
+ unregisterStreamAnimationPlugin("test-reveal");
45
+ document.body.innerHTML = "";
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ it("injects a plugin animation's styles when switched in via update()", () => {
50
+ const mount = createMount();
51
+ const controller = createAgentExperience(mount, {
52
+ apiUrl: "https://api.example.com/chat",
53
+ launcher: { enabled: false },
54
+ features: { streamAnimation: { type: "none" } },
55
+ });
56
+
57
+ // Nothing injected while the type is "none".
58
+ expect(styleTag(mount)).toBeNull();
59
+
60
+ controller.update({
61
+ features: { streamAnimation: { type: "test-reveal" } },
62
+ });
63
+
64
+ // The plugin's <style> is now present, so the animation can actually render.
65
+ expect(styleTag(mount)).not.toBeNull();
66
+
67
+ controller.destroy();
68
+ });
69
+
70
+ it("activates the plugin at mount when configured up front", () => {
71
+ const mount = createMount();
72
+ const controller = createAgentExperience(mount, {
73
+ apiUrl: "https://api.example.com/chat",
74
+ launcher: { enabled: false },
75
+ features: { streamAnimation: { type: "test-reveal" } },
76
+ });
77
+
78
+ expect(styleTag(mount)).not.toBeNull();
79
+ controller.destroy();
80
+ });
81
+
82
+ it("does not duplicate the style tag when the same plugin is re-selected", () => {
83
+ const mount = createMount();
84
+ const controller = createAgentExperience(mount, {
85
+ apiUrl: "https://api.example.com/chat",
86
+ launcher: { enabled: false },
87
+ features: { streamAnimation: { type: "test-reveal" } },
88
+ });
89
+
90
+ controller.update({ features: { streamAnimation: { type: "none" } } });
91
+ controller.update({ features: { streamAnimation: { type: "test-reveal" } } });
92
+
93
+ expect(
94
+ mount.querySelectorAll('style[data-persona-animation="test-reveal"]'),
95
+ ).toHaveLength(1);
96
+
97
+ controller.destroy();
98
+ });
99
+ });
package/src/ui.ts CHANGED
@@ -294,6 +294,11 @@ type Controller = {
294
294
  clearChat: () => void;
295
295
  setMessage: (message: string) => boolean;
296
296
  submitMessage: (message?: string) => boolean;
297
+ /**
298
+ * Manually retry a dropped durable stream (e.g. from a "Reconnect" button).
299
+ * No-op unless a resumable durable turn dropped and `reconnectStream` is set.
300
+ */
301
+ reconnect: () => void;
297
302
  startVoiceRecognition: () => boolean;
298
303
  stopVoiceRecognition: () => boolean;
299
304
  /**
@@ -775,6 +780,8 @@ export const createAgentExperience = (
775
780
  if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
776
781
  if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
777
782
  if (status === "error") return statusConfig.errorText ?? statusCopy.error;
783
+ if (status === "paused") return statusConfig.pausedText ?? statusCopy.paused;
784
+ if (status === "resuming") return statusConfig.resumingText ?? statusCopy.resuming;
778
785
  return statusCopy[status];
779
786
  };
780
787
 
@@ -5243,6 +5250,8 @@ export const createAgentExperience = (
5243
5250
  if (s === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
5244
5251
  if (s === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
5245
5252
  if (s === "error") return currentStatusConfig.errorText ?? statusCopy.error;
5253
+ if (s === "paused") return currentStatusConfig.pausedText ?? statusCopy.paused;
5254
+ if (s === "resuming") return currentStatusConfig.resumingText ?? statusCopy.resuming;
5246
5255
  return statusCopy[s];
5247
5256
  };
5248
5257
  applyStatusToElement(statusText, getCurrentStatusText(status), currentStatusConfig, status);
@@ -5310,11 +5319,31 @@ export const createAgentExperience = (
5310
5319
  lastArtifactsState = state;
5311
5320
  syncArtifactPane();
5312
5321
  persistState();
5322
+ },
5323
+ onReconnect(event) {
5324
+ // Map the durable-reconnect lifecycle to public controller events.
5325
+ const { executionId, lastEventId } = event.handle;
5326
+ if (event.phase === "paused") {
5327
+ eventBus.emit("stream:paused", { executionId, after: lastEventId });
5328
+ } else if (event.phase === "resuming") {
5329
+ eventBus.emit("stream:resuming", {
5330
+ executionId,
5331
+ after: lastEventId,
5332
+ attempt: event.attempt ?? 1,
5333
+ });
5334
+ } else {
5335
+ eventBus.emit("stream:resumed", { executionId, after: lastEventId });
5336
+ }
5313
5337
  }
5314
5338
  });
5315
5339
 
5316
5340
  sessionRef.current = session;
5317
5341
 
5342
+ // On teardown, cancel any in-flight turn/reconnect so a pending durable
5343
+ // reconnect's backoff timer and focus/online listeners don't outlive the
5344
+ // widget (cancel() → teardownReconnect()).
5345
+ destroyCallbacks.push(() => session.cancel());
5346
+
5318
5347
  // Mirror read-aloud playback state into the action buttons, and surface it as
5319
5348
  // a controller event (parallel to message:copy / message:feedback).
5320
5349
  let lastReadAloudId: string | null = null;
@@ -5380,6 +5409,17 @@ export const createAgentExperience = (
5380
5409
  });
5381
5410
  }
5382
5411
 
5412
+ // Durable-session reconnect boot path: once history is in place,
5413
+ // if the host passed a non-terminal resume handle (+ a reconnect transport),
5414
+ // immediately re-enter `resuming` and replay everything past the cursor into
5415
+ // the restored conversation. Fires AFTER hydration so the trailing partial
5416
+ // assistant bubble exists for the replay to append to.
5417
+ const maybeBootResume = () => {
5418
+ if (config.resume && typeof config.reconnectStream === "function") {
5419
+ session.resumeFromHandle(config.resume);
5420
+ }
5421
+ };
5422
+
5383
5423
  if (pendingStoredState) {
5384
5424
  pendingStoredState
5385
5425
  .then((state) => {
@@ -5410,7 +5450,10 @@ export const createAgentExperience = (
5410
5450
  // eslint-disable-next-line no-console
5411
5451
  console.error("[AgentWidget] Failed to hydrate stored state:", error);
5412
5452
  }
5413
- });
5453
+ })
5454
+ .finally(() => maybeBootResume());
5455
+ } else {
5456
+ maybeBootResume();
5414
5457
  }
5415
5458
 
5416
5459
  // Centralized so both the default composer (`handleSubmit`) and the plugin
@@ -6683,6 +6726,7 @@ export const createAgentExperience = (
6683
6726
  const previousShowToolCalls = config.features?.showToolCalls;
6684
6727
  const previousToolCallDisplay = config.features?.toolCallDisplay;
6685
6728
  const previousReasoningDisplay = config.features?.reasoningDisplay;
6729
+ const previousStreamAnimationType = config.features?.streamAnimation?.type;
6686
6730
  config = { ...config, ...nextConfig };
6687
6731
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
6688
6732
  applyFullHeightStyles();
@@ -6932,6 +6976,29 @@ export const createAgentExperience = (
6932
6976
  renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
6933
6977
  }
6934
6978
 
6979
+ // Re-activate the stream-animation plugin when the type changes via
6980
+ // update(). Built-in animations (typewriter, word-fade, letter-rise,
6981
+ // pop-bubble) carry their CSS in widget.css, so swapping the type is
6982
+ // enough. Plugin animations (wipe, glyph-cycle, and custom plugins
6983
+ // registered via registerStreamAnimationPlugin) inject their styles and
6984
+ // run onAttach only through ensurePluginActive, which the initial mount
6985
+ // calls but update() otherwise skips. Without this, switching to a plugin
6986
+ // animation live sets the type but never injects the CSS, so it silently
6987
+ // does nothing. ensurePluginActive is idempotent (styles inject once per
6988
+ // root), so re-selecting a previously-activated plugin is a no-op.
6989
+ const nextStreamAnimationType = config.features?.streamAnimation?.type;
6990
+ if (
6991
+ nextStreamAnimationType !== previousStreamAnimationType &&
6992
+ nextStreamAnimationType &&
6993
+ nextStreamAnimationType !== "none"
6994
+ ) {
6995
+ const streamAnimationPlugin = resolveStreamAnimationPlugin(
6996
+ nextStreamAnimationType,
6997
+ config.features?.streamAnimation?.plugins
6998
+ );
6999
+ if (streamAnimationPlugin) ensurePluginActive(streamAnimationPlugin, mount);
7000
+ }
7001
+
6935
7002
  // Update panel icon sizes
6936
7003
  const launcher = config.launcher ?? {};
6937
7004
  const headerIconHidden = launcher.headerIconHidden ?? false;
@@ -7954,6 +8021,9 @@ export const createAgentExperience = (
7954
8021
  if (!isPanelToggleable()) return;
7955
8022
  setOpenState(!open, "api");
7956
8023
  },
8024
+ reconnect() {
8025
+ session.reconnectNow();
8026
+ },
7957
8027
  clearChat() {
7958
8028
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
7959
8029
  artifactsPaneUserHidden = false;
@@ -4,7 +4,9 @@ export const statusCopy: Record<AgentWidgetSessionStatus, string> = {
4
4
  idle: "Online",
5
5
  connecting: "Connecting…",
6
6
  connected: "Streaming…",
7
- error: "Offline"
7
+ error: "Offline",
8
+ paused: "Connection lost…",
9
+ resuming: "Reconnecting…"
8
10
  };
9
11
 
10
12
  /**
@@ -83,4 +83,51 @@ describe("morphMessages", () => {
83
83
  expect(container.querySelector("span")!.textContent).toBe("New text");
84
84
  });
85
85
  });
86
+
87
+ describe("data-tool-elapsed (live duration counter)", () => {
88
+ it("preserves the live span's timer-owned text while the same tool is active", () => {
89
+ // The 100ms global timer wrote "0.3s"; a re-render arrives carrying the
90
+ // render-time value "<0.1s". Morphing it in would make the boundary
91
+ // flicker, so the still-live span (same startedAt) must be left alone.
92
+ const container = makeContainer(
93
+ '<span data-tool-elapsed="1000">0.3s</span>'
94
+ );
95
+ const oldSpan = container.querySelector("span")!;
96
+
97
+ morphMessages(
98
+ container,
99
+ makeNewContent('<span data-tool-elapsed="1000">&lt;0.1s</span>')
100
+ );
101
+
102
+ expect(container.querySelector("span")).toBe(oldSpan);
103
+ expect(container.querySelector("span")!.textContent).toBe("0.3s");
104
+ });
105
+
106
+ it("allows morph to the final static duration once the tool completes", () => {
107
+ const container = makeContainer(
108
+ '<span data-tool-elapsed="1000">0.7s</span>'
109
+ );
110
+
111
+ morphMessages(container, makeNewContent("<span>0.8s</span>"));
112
+
113
+ const span = container.querySelector("span")!;
114
+ expect(span.textContent).toBe("0.8s");
115
+ expect(span.hasAttribute("data-tool-elapsed")).toBe(false);
116
+ });
117
+
118
+ it("allows morph when the slot is reused by a different tool (startedAt changed)", () => {
119
+ const container = makeContainer(
120
+ '<span data-tool-elapsed="1000">0.5s</span>'
121
+ );
122
+
123
+ morphMessages(
124
+ container,
125
+ makeNewContent('<span data-tool-elapsed="2000">&lt;0.1s</span>')
126
+ );
127
+
128
+ const span = container.querySelector("span")!;
129
+ expect(span.getAttribute("data-tool-elapsed")).toBe("2000");
130
+ expect(span.textContent).toBe("<0.1s");
131
+ });
132
+ });
86
133
  });
@@ -37,6 +37,24 @@ export const morphMessages = (
37
37
  if (oldNode.hasAttribute("data-preserve-runtime")) {
38
38
  return false;
39
39
  }
40
+ // The live tool-elapsed span's text is owned by the 100ms global
41
+ // timer in ui.ts (it rewrites `now - startedAt` on a fixed cadence).
42
+ // Re-morphing it to the render-time value fights that timer, so while
43
+ // a tool stays active across frequent re-renders the "<0.1s" boundary
44
+ // flickers. Preserve it while the SAME tool is still live (matching
45
+ // `data-tool-elapsed`, i.e. startedAt); allow the morph once the tool
46
+ // completes (new node drops the marker → final static duration) or the
47
+ // slot is reused by another tool (marker value changed).
48
+ if (oldNode.hasAttribute("data-tool-elapsed")) {
49
+ const sameLiveTool =
50
+ newNode instanceof HTMLElement &&
51
+ newNode.getAttribute("data-tool-elapsed") ===
52
+ oldNode.getAttribute("data-tool-elapsed");
53
+ if (sameLiveTool) {
54
+ return false;
55
+ }
56
+ return;
57
+ }
40
58
  if (oldNode.hasAttribute("data-preserve-animation")) {
41
59
  // Allow morph when the new node drops the attribute (e.g. tool completed)
42
60
  if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {