@runtypelabs/persona 4.8.0 → 4.10.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 (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
package/src/index-core.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
 
6
6
  export type {
7
7
  AgentWidgetConfig,
8
+ AgentWidgetConfigPatch,
8
9
  TargetResolver,
9
10
  ResolvedTarget,
10
11
  AgentWidgetFeatureFlags,
@@ -29,6 +30,25 @@ export type {
29
30
  // Context provider types (e.g. for config.contextProviders)
30
31
  AgentWidgetContextProvider,
31
32
  AgentWidgetContextProviderContext,
33
+ // Context mention types (e.g. for config.contextMentions)
34
+ AgentWidgetContextMentionConfig,
35
+ AgentWidgetContextMentionSource,
36
+ AgentWidgetContextMentionItem,
37
+ AgentWidgetContextMentionPayload,
38
+ AgentWidgetContextMentionRef,
39
+ AgentWidgetContextMentionSearchContext,
40
+ AgentWidgetContextMentionResolveContext,
41
+ AgentWidgetContextMentionMenuRenderContext,
42
+ AgentWidgetContextMentionItemRenderContext,
43
+ AgentWidgetContextMentionChipRenderContext,
44
+ AgentWidgetContextMentionTokenRenderContext,
45
+ // Per-mention LLM block formatter entry (config.contextMentions.llmFormat)
46
+ AgentWidgetMentionLlmEntry,
47
+ // Skills / slash-commands (config.contextMentions.triggers)
48
+ AgentWidgetMentionTriggerChannel,
49
+ AgentWidgetMentionTriggerPosition,
50
+ AgentWidgetContextMentionCommandContext,
51
+ AgentWidgetContextMentionComposerCapability,
32
52
  AgentWidgetCustomFetch,
33
53
  AgentWidgetSSEEventParser,
34
54
  AgentWidgetSSEEventResult,
@@ -226,6 +246,13 @@ export {
226
246
  fileToImagePart,
227
247
  validateImageFile
228
248
  } from "./utils/content";
249
+ // Context mention helpers (for building config.contextMentions.sources)
250
+ export {
251
+ defaultMentionFilter,
252
+ createStaticMentionSource,
253
+ createSlashCommandsSource,
254
+ } from "./utils/mention-matcher";
255
+ export type { SlashCommandDefinition } from "./utils/mention-matcher";
229
256
  export {
230
257
  collectEnrichedPageContext,
231
258
  formatEnrichedContext,
@@ -137,3 +137,54 @@ setRuntypeTtsLoader(() => {
137
137
  }
138
138
  return import(/* @vite-ignore */ chunkUrl);
139
139
  });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Deferred Context Mentions loading.
143
+ //
144
+ // This bundle is built with `./context-mentions-entry` external (see
145
+ // `tsup.global.config.ts`): the mention controller/manager/menu runtime is kept
146
+ // out of the CDN payload. Register a loader that imports the self-contained
147
+ // `context-mentions.js` chunk from a sibling URL; the orchestrator calls it on
148
+ // the first `@`/affordance-button interaction (and can prefetch on composer
149
+ // focus) only when `contextMentions.enabled`. Same pattern as the chunks above.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ import { setContextMentionsLoader } from "./context-mentions-loader";
153
+ import { setContextMentionsInlineLoader } from "./context-mentions-inline-loader";
154
+
155
+ setContextMentionsLoader(() => {
156
+ const chunkUrl = widgetScriptSrc?.replace(
157
+ /index\.global\.js($|\?)/,
158
+ "context-mentions.js$1",
159
+ );
160
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
161
+ return Promise.reject(
162
+ new Error(
163
+ "Could not derive the context-mentions.js URL from the widget script URL " +
164
+ `(${widgetScriptSrc ?? "unavailable"}). Self-hosted deployments that ` +
165
+ "rename index.global.js should host context-mentions.js alongside it.",
166
+ ),
167
+ );
168
+ }
169
+ return import(/* @vite-ignore */ chunkUrl);
170
+ });
171
+
172
+ // Sibling loader for the inline-mention contenteditable chunk, loaded on composer
173
+ // mount when `contextMentions.display === "inline"`. Same sibling-URL scheme.
174
+ setContextMentionsInlineLoader(() => {
175
+ const chunkUrl = widgetScriptSrc?.replace(
176
+ /index\.global\.js($|\?)/,
177
+ "context-mentions-inline.js$1",
178
+ );
179
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
180
+ return Promise.reject(
181
+ new Error(
182
+ "Could not derive the context-mentions-inline.js URL from the widget " +
183
+ `script URL (${widgetScriptSrc ?? "unavailable"}). Self-hosted ` +
184
+ "deployments that rename index.global.js should host " +
185
+ "context-mentions-inline.js alongside it.",
186
+ ),
187
+ );
188
+ }
189
+ return import(/* @vite-ignore */ chunkUrl);
190
+ });
@@ -1,3 +1,4 @@
1
+ import { createChunkLoader } from "./utils/chunk-loader";
1
2
  import type { Marked } from "marked";
2
3
  import type DOMPurify from "dompurify";
3
4
 
@@ -6,19 +7,22 @@ export type MarkdownParsersModule = {
6
7
  DOMPurify: typeof DOMPurify;
7
8
  };
8
9
 
9
- let loader: (() => Promise<MarkdownParsersModule>) | null = null;
10
- let moduleCache: MarkdownParsersModule | null = null;
11
- let loadPromise: Promise<MarkdownParsersModule> | null = null;
10
+ // Memoization + rejection-retry semantics live in `createChunkLoader`. The
11
+ // ESM/CJS fallback imports `./markdown-parsers-entry` directly; the IIFE/CDN
12
+ // build registers a loader that fetches the `markdown-parsers.js` chunk.
13
+ const { setLoader, load, provide, getSync } =
14
+ createChunkLoader<MarkdownParsersModule>({
15
+ fallbackImport: () => import("./markdown-parsers-entry"),
16
+ });
12
17
 
13
18
  // Surfaces that want to self-heal when the lazy `markdown-parsers.js` chunk
14
19
  // lands. See `onMarkdownParsersReady` below for why this is centralized.
15
20
  const readySubscribers = new Set<() => void>();
16
21
 
17
- // Flip `moduleCache` and fan out to everyone waiting to re-render, exactly once
18
- // per subscriber. Called from BOTH the eager (`provideMarkdownParsers`) and lazy
22
+ // Fan out to everyone waiting to re-render, exactly once per subscriber.
23
+ // Called from BOTH the eager (`provideMarkdownParsers`) and lazy
19
24
  // (`loadMarkdownParsers`) paths so a subscriber can't miss the transition.
20
- const markParsersReady = (mod: MarkdownParsersModule): MarkdownParsersModule => {
21
- moduleCache = mod;
25
+ const notifyParsersReady = (): void => {
22
26
  // Snapshot + clear first: fire-once semantics, and a callback that re-subscribes
23
27
  // (it won't — `onMarkdownParsersReady` no-ops once ready) can't loop.
24
28
  const subs = [...readySubscribers];
@@ -31,11 +35,25 @@ const markParsersReady = (mod: MarkdownParsersModule): MarkdownParsersModule =>
31
35
  /* subscriber threw: swallow so the remaining re-renders still run */
32
36
  }
33
37
  }
34
- return mod;
35
38
  };
36
39
 
37
- export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>) => {
38
- loader = l;
40
+ export const setMarkdownParsersLoader = setLoader;
41
+
42
+ // On a failed chunk load (404, ad blocker, offline), `createChunkLoader`
43
+ // resets its cached promise so a later call retries. Pending subscribers are
44
+ // KEPT — a surface that rendered escaped during the failed attempt must still
45
+ // heal when a retry (kicked by a new subscriber or an explicit
46
+ // loadMarkdownParsers() call) succeeds; dropping them left that surface
47
+ // escaped forever after a transient failure. They're only retained while the
48
+ // parsers stay unloaded — success releases them (fire-once in
49
+ // `notifyParsersReady`), and callers can unsubscribe.
50
+ export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
51
+ const cached = getSync();
52
+ if (cached) return Promise.resolve(cached);
53
+ return load().then((mod) => {
54
+ notifyParsersReady();
55
+ return mod;
56
+ });
39
57
  };
40
58
 
41
59
  /**
@@ -58,12 +76,13 @@ export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>
58
76
  * Fires at most once per subscription.
59
77
  */
60
78
  export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
61
- if (moduleCache) return () => {};
79
+ if (getSync()) return () => {};
62
80
  readySubscribers.add(cb);
63
81
  // Ensure the chunk is actually being fetched; harmless if already in flight.
64
82
  // Swallow rejection here — on failure the subscriber stays registered (see
65
- // `onLoadFailure`) and the surface keeps its escaped fallback until a retry
66
- // succeeds; this catch only avoids an unhandled rejection.
83
+ // the note on `loadMarkdownParsers`) and the surface keeps its escaped
84
+ // fallback until a retry succeeds; this catch only avoids an unhandled
85
+ // rejection.
67
86
  void loadMarkdownParsers().catch(() => {});
68
87
  return () => {
69
88
  readySubscribers.delete(cb);
@@ -79,36 +98,8 @@ export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
79
98
  * it lazy-loads the `markdown-parsers.js` chunk instead.
80
99
  */
81
100
  export const provideMarkdownParsers = (mod: MarkdownParsersModule): void => {
82
- markParsersReady(mod);
101
+ provide(mod);
102
+ notifyParsersReady();
83
103
  };
84
104
 
85
- // On a failed chunk load (404, ad blocker, offline), don't cache the rejected
86
- // promise: reset it so a later subscribe/load call can retry. Pending
87
- // subscribers are KEPT — a surface that rendered escaped during the failed
88
- // attempt must still heal when a retry (kicked by a new subscriber or an
89
- // explicit loadMarkdownParsers() call) succeeds; dropping them here left that
90
- // surface escaped forever after a transient failure. They're only retained
91
- // while the parsers stay unloaded — success releases them (fire-once in
92
- // `markParsersReady`), and callers can unsubscribe. On a permanently failing
93
- // setup they persist for the page's life, which is the price of healing.
94
- // Re-throw so awaiters of loadMarkdownParsers() still observe the rejection.
95
- const onLoadFailure = (err: unknown): never => {
96
- loadPromise = null;
97
- throw err;
98
- };
99
-
100
- export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
101
- if (moduleCache) return Promise.resolve(moduleCache);
102
- if (loadPromise) return loadPromise;
103
- if (!loader) {
104
- // Fallback for regular ESM/CJS consumers (they import directly)
105
- loadPromise = import("./markdown-parsers-entry").then(markParsersReady, onLoadFailure);
106
- return loadPromise;
107
- }
108
- loadPromise = loader().then(markParsersReady, onLoadFailure);
109
- return loadPromise;
110
- };
111
-
112
- export const getMarkdownParsersSync = (): MarkdownParsersModule | null => {
113
- return moduleCache;
114
- };
105
+ export const getMarkdownParsersSync = getSync;
@@ -188,6 +188,46 @@ describe("createPopover", () => {
188
188
  expect(content.getRootNode()).toBe(shadow);
189
189
  });
190
190
 
191
+ it("anchors vertically to a verticalOffset when placement is top-start", () => {
192
+ const { anchor, content } = setup();
193
+ anchor.getBoundingClientRect = () =>
194
+ ({
195
+ left: 100,
196
+ right: 400,
197
+ top: 200,
198
+ bottom: 260,
199
+ width: 300,
200
+ height: 60,
201
+ x: 100,
202
+ y: 200,
203
+ toJSON: () => ({}),
204
+ }) as DOMRect;
205
+ content.getBoundingClientRect = () =>
206
+ ({
207
+ width: 180,
208
+ height: 100,
209
+ left: 0,
210
+ right: 180,
211
+ top: 0,
212
+ bottom: 100,
213
+ x: 0,
214
+ y: 0,
215
+ toJSON: () => ({}),
216
+ }) as DOMRect;
217
+
218
+ const popover = createPopover({
219
+ anchor,
220
+ content,
221
+ placement: "top-start",
222
+ offset: 6,
223
+ verticalOffset: () => 40,
224
+ });
225
+ popover.open();
226
+
227
+ // anchorTop = 200 + 40 = 240 → top = 240 - 6 - 100 = 134
228
+ expect(content.style.top).toBe("134px");
229
+ });
230
+
191
231
  it("destroy removes content and detaches listeners", () => {
192
232
  vi.useFakeTimers();
193
233
  const onDismiss = vi.fn();
package/src/plugin-kit.ts CHANGED
@@ -117,6 +117,23 @@ export interface PopoverOptions {
117
117
  offset?: number;
118
118
  /** Set `content`'s `min-width` to the anchor's width. Default `false`. */
119
119
  matchAnchorWidth?: boolean;
120
+ /**
121
+ * Horizontal anchoring override for `*-start` placements. Returns the desired
122
+ * left offset in px measured from the anchor's LEFT edge (e.g. the `@` trigger's
123
+ * x within the composer), or `null` to left-align with the anchor as usual. When
124
+ * this option is present the content is content-sized and capped to the anchor's
125
+ * width, and the resolved left is clamped so the content never overflows the
126
+ * anchor's left/right edges (Slack-style: a near-right trigger shifts the menu
127
+ * left to fit). Omit it entirely for plain anchor-left/right placement.
128
+ */
129
+ horizontalOffset?: () => number | null;
130
+ /**
131
+ * Vertical anchoring override for `top-*` placements. Returns the desired
132
+ * anchor point's top offset in px measured from the anchor's TOP edge (e.g.
133
+ * the trigger line's top within the composer), or `null` to use the anchor's
134
+ * top edge as usual.
135
+ */
136
+ verticalOffset?: () => number | null;
120
137
  /**
121
138
  * Inline `z-index` for `content`. Default `2147483000` so it overlays the rest
122
139
  * of the widget. Pass `null` to leave z-index to your own CSS.
@@ -175,6 +192,8 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
175
192
  placement = "bottom-start",
176
193
  offset = 6,
177
194
  matchAnchorWidth = false,
195
+ horizontalOffset,
196
+ verticalOffset,
178
197
  zIndex = 2147483000,
179
198
  onOpen,
180
199
  onDismiss,
@@ -189,16 +208,31 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
189
208
  const rect = anchor.getBoundingClientRect();
190
209
  content.style.position = "fixed";
191
210
  if (matchAnchorWidth) content.style.minWidth = `${rect.width}px`;
211
+ // Trigger-anchored mode: content is sized to its own contents but never wider
212
+ // than the composer, so it can shift horizontally within the composer's span.
213
+ if (horizontalOffset) content.style.maxWidth = `${rect.width}px`;
214
+
215
+ // Single content measurement per reposition (after width constraints apply).
216
+ const contentRect = content.getBoundingClientRect();
192
217
 
218
+ const offY = verticalOffset?.() ?? null;
219
+ const anchorTop = offY != null ? rect.top + offY : rect.top;
193
220
  const top =
194
221
  placement === "top-start" || placement === "top-end"
195
- ? rect.top - offset - content.getBoundingClientRect().height
222
+ ? anchorTop - offset - contentRect.height
196
223
  : rect.bottom + offset;
197
224
 
198
- const left =
199
- placement === "bottom-end" || placement === "top-end"
200
- ? rect.right - content.getBoundingClientRect().width
201
- : rect.left;
225
+ const isEnd = placement === "bottom-end" || placement === "top-end";
226
+ // Default: anchor left (or anchor right for `*-end`). Trigger-anchored mode
227
+ // (offset present and non-null) overrides with the clamped trigger position so
228
+ // the menu never overflows the anchor's edges — a near-right trigger shifts it
229
+ // left to fit (Slack-style). A null offset falls back to the default.
230
+ let left = isEnd ? rect.right - contentRect.width : rect.left;
231
+ const off = horizontalOffset?.() ?? null;
232
+ if (off != null) {
233
+ const maxLeft = Math.max(rect.left, rect.right - contentRect.width);
234
+ left = Math.min(Math.max(rect.left + off, rect.left), maxLeft);
235
+ }
202
236
 
203
237
  content.style.top = `${top}px`;
204
238
  content.style.left = `${left}px`;
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+
3
+ // End-to-end handle.update() through the REAL ui module (init.test.ts mocks it,
4
+ // so it cannot see the controller's own patch merge). Regression: explicit
5
+ // undefined resets must reach the controller, not be materialized as absent
6
+ // keys by the handle's pre-merge.
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+
10
+ import { initAgentWidget } from "./init";
11
+
12
+ describe("handle.update explicit-undefined reset reaches the controller", () => {
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it("round-trips artifact pane appearance seamless -> panel via explicit undefined", () => {
19
+ window.scrollTo = vi.fn();
20
+ document.body.innerHTML = `<div id="target"></div>`;
21
+
22
+ const handle = initAgentWidget({
23
+ target: "#target",
24
+ config: {
25
+ apiUrl: "https://api.example.com/chat",
26
+ launcher: { enabled: false },
27
+ features: { artifacts: { enabled: true } },
28
+ },
29
+ });
30
+
31
+ const root = () => document.querySelector<HTMLElement>("[data-persona-root]")!;
32
+ expect(root().classList.contains("persona-artifact-appearance-panel")).toBe(true);
33
+
34
+ handle.update({
35
+ features: {
36
+ artifacts: {
37
+ layout: { paneAppearance: "seamless", splitGap: "0", paneShadow: "none" },
38
+ },
39
+ },
40
+ });
41
+ expect(root().classList.contains("persona-artifact-appearance-seamless")).toBe(true);
42
+
43
+ handle.update({
44
+ features: {
45
+ artifacts: {
46
+ layout: { paneAppearance: undefined, splitGap: undefined, paneShadow: undefined },
47
+ },
48
+ },
49
+ });
50
+ expect(root().classList.contains("persona-artifact-appearance-panel")).toBe(true);
51
+ expect(root().classList.contains("persona-artifact-appearance-seamless")).toBe(false);
52
+ expect(root().style.getPropertyValue("--persona-artifact-pane-shadow")).toBe("");
53
+
54
+ handle.destroy();
55
+ });
56
+
57
+ it("handle and controller stored configs converge on the same merged result", () => {
58
+ window.scrollTo = vi.fn();
59
+ document.body.innerHTML = `<div id="target"></div>`;
60
+
61
+ const handle = initAgentWidget({
62
+ target: "#target",
63
+ config: {
64
+ apiUrl: "https://api.example.com/chat",
65
+ launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
66
+ },
67
+ });
68
+
69
+ // Sibling override must survive an unrelated patch in BOTH layers: the
70
+ // controller renders it now, and the handle's stored config must carry it
71
+ // into a mount-mode rebuild. tooltipText also renames the aria-label.
72
+ handle.update({ launcher: { clearChat: { tooltipText: "Wipe" } } });
73
+ const clear = () => document.querySelector<HTMLButtonElement>('button[aria-label="Wipe"]')!;
74
+ expect(clear().style.backgroundColor).toBe("rgb(18, 52, 86)");
75
+
76
+ handle.update({ launcher: { mountMode: "docked" } });
77
+ expect(clear().style.backgroundColor).toBe("rgb(18, 52, 86)");
78
+
79
+ handle.destroy();
80
+ });
81
+ });
@@ -483,3 +483,65 @@ describe("initAgentWidget docked mode", () => {
483
483
  handleB.destroy();
484
484
  });
485
485
  });
486
+
487
+ describe("initAgentWidget update merge policy", () => {
488
+ beforeEach(() => {
489
+ document.body.innerHTML = "";
490
+ createAgentExperienceMock.mockReset();
491
+ });
492
+
493
+ it("passes the raw patch to the controller so explicit-undefined resets survive", async () => {
494
+ const { initAgentWidget } = await import("./init");
495
+ document.body.innerHTML = `<div id="target"></div>`;
496
+
497
+ let controllerRef: ReturnType<typeof createMockController> | undefined;
498
+ createAgentExperienceMock.mockImplementation((_mount, config) => {
499
+ controllerRef = createMockController(config);
500
+ return controllerRef;
501
+ });
502
+
503
+ const handle = initAgentWidget({
504
+ target: "#target",
505
+ config: {
506
+ apiUrl: "https://api.example.com/chat",
507
+ launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
508
+ },
509
+ });
510
+
511
+ // The merged config materializes explicit-undefined as absence, which the
512
+ // controller's patch merge would preserve; only the raw patch carries resets.
513
+ const patch = { launcher: { clearChat: { tooltipText: "Clear" } } };
514
+ handle.update(patch);
515
+
516
+ const passed = controllerRef!.update.mock.calls.at(-1)?.[0] as any;
517
+ expect(passed).toBe(patch);
518
+
519
+ handle.destroy();
520
+ });
521
+
522
+ it("retains prior nested overrides when a mount-mode change rebuilds the controller", async () => {
523
+ const { initAgentWidget } = await import("./init");
524
+ document.body.innerHTML = `<div id="target"></div>`;
525
+
526
+ createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
527
+
528
+ const handle = initAgentWidget({
529
+ target: "#target",
530
+ config: {
531
+ apiUrl: "https://api.example.com/chat",
532
+ launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
533
+ },
534
+ });
535
+
536
+ // Patch a sibling, then flip the mount mode to force a rebuild.
537
+ handle.update({ launcher: { clearChat: { tooltipText: "Clear" } } });
538
+ handle.update({ launcher: { mountMode: "docked" } });
539
+
540
+ const rebuildConfig = createAgentExperienceMock.mock.calls.at(-1)?.[1] as any;
541
+ expect(rebuildConfig.launcher.clearChat.backgroundColor).toBe("#123456");
542
+ expect(rebuildConfig.launcher.clearChat.tooltipText).toBe("Clear");
543
+ expect(rebuildConfig.launcher.mountMode).toBe("docked");
544
+
545
+ handle.destroy();
546
+ });
547
+ });
@@ -1,6 +1,7 @@
1
1
  import { createAgentExperience, AgentWidgetController } from "../ui";
2
- import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
2
+ import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetConfigPatch, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
3
3
  import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
4
+ import { mergeConfigUpdate } from "../utils/config-merge";
4
5
  import { createWidgetHostLayout } from "./host-layout";
5
6
 
6
7
  const ensureTarget = (target: string | HTMLElement): HTMLElement => {
@@ -126,19 +127,8 @@ export const initAgentWidget = (
126
127
  };
127
128
 
128
129
  const handleBase = {
129
- update(nextConfig: _AgentWidgetConfig) {
130
- const mergedConfig = {
131
- ...config,
132
- ...nextConfig,
133
- launcher: {
134
- ...(config?.launcher ?? {}),
135
- ...(nextConfig?.launcher ?? {}),
136
- dock: {
137
- ...(config?.launcher?.dock ?? {}),
138
- ...(nextConfig?.launcher?.dock ?? {}),
139
- },
140
- },
141
- } as _AgentWidgetConfig;
130
+ update(nextConfig: AgentWidgetConfigPatch) {
131
+ const mergedConfig = mergeConfigUpdate(config ?? ({} as _AgentWidgetConfig), nextConfig);
142
132
  const previousDocked = isDockedMountMode(config);
143
133
  const nextDocked = isDockedMountMode(mergedConfig);
144
134
  const previousComposerBar = isComposerBarMountMode(config);
@@ -151,6 +141,9 @@ export const initAgentWidget = (
151
141
 
152
142
  config = mergedConfig;
153
143
  hostLayout.updateConfig(config);
144
+ // Pass the raw patch: mergedConfig materializes explicit-undefined resets
145
+ // as absent keys, which the controller's patch merge would preserve
146
+ // instead of clearing. Both layers share mergeConfigUpdate, so they converge.
154
147
  controller.update(nextConfig);
155
148
  syncHostState();
156
149
  },