@runtypelabs/persona 4.15.0 → 4.16.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 (86) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +2 -2
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +283 -9
  16. package/dist/smart-dom-reader.d.ts +283 -9
  17. package/dist/testing.cjs +3 -3
  18. package/dist/testing.d.cts +7 -0
  19. package/dist/testing.d.ts +7 -0
  20. package/dist/testing.js +3 -3
  21. package/dist/theme-editor-preview.cjs +64 -64
  22. package/dist/theme-editor-preview.d.cts +283 -9
  23. package/dist/theme-editor-preview.d.ts +283 -9
  24. package/dist/theme-editor-preview.js +56 -56
  25. package/dist/theme-editor.cjs +10 -10
  26. package/dist/theme-editor.d.cts +283 -10
  27. package/dist/theme-editor.d.ts +283 -10
  28. package/dist/theme-editor.js +10 -10
  29. package/dist/theme-reference.cjs +1 -1
  30. package/dist/theme-reference.d.cts +66 -3
  31. package/dist/theme-reference.d.ts +66 -3
  32. package/dist/theme-reference.js +1 -1
  33. package/dist/widget.css +1 -1
  34. package/package.json +1 -1
  35. package/src/components/artifact-pane.test.ts +27 -0
  36. package/src/components/artifact-pane.ts +7 -3
  37. package/src/components/header-builder.ts +27 -2
  38. package/src/components/header-layouts.test.ts +56 -0
  39. package/src/components/header-layouts.ts +29 -9
  40. package/src/components/launcher-surface.test.ts +403 -0
  41. package/src/components/launcher.ts +297 -2
  42. package/src/components/panel.ts +69 -74
  43. package/src/components/suggestions.test.ts +40 -0
  44. package/src/components/suggestions.ts +213 -153
  45. package/src/components/welcome.test.ts +121 -0
  46. package/src/components/welcome.ts +305 -0
  47. package/src/components/widget-view.test.ts +5 -3
  48. package/src/components/widget-view.ts +14 -7
  49. package/src/defaults.ts +2 -2
  50. package/src/index-core.ts +12 -1
  51. package/src/launcher-global.ts +10 -7
  52. package/src/plugins/types.ts +66 -1
  53. package/src/styles/widget-styles.test.ts +220 -0
  54. package/src/styles/widget.css +353 -9
  55. package/src/testing/mock-stream.test.ts +19 -0
  56. package/src/testing/mock-stream.ts +20 -0
  57. package/src/theme-editor/sections.ts +30 -1
  58. package/src/theme-reference.ts +26 -4
  59. package/src/types/theme.ts +40 -3
  60. package/src/types.ts +164 -6
  61. package/src/ui.body-top-slot.test.ts +95 -0
  62. package/src/ui.content-width.test.ts +91 -0
  63. package/src/ui.docked.test.ts +7 -4
  64. package/src/ui.launcher-teaser.test.ts +162 -0
  65. package/src/ui.rebuild-composer.test.ts +491 -0
  66. package/src/ui.render-welcome.test.ts +505 -0
  67. package/src/ui.scroll-additive.test.ts +91 -21
  68. package/src/ui.scroll.test.ts +302 -14
  69. package/src/ui.suggest-replies.test.ts +46 -0
  70. package/src/ui.ts +747 -136
  71. package/src/ui.welcome.test.ts +504 -0
  72. package/src/utils/artifact-gate.test.ts +27 -0
  73. package/src/utils/artifact-gate.ts +9 -0
  74. package/src/utils/attachment-manager.test.ts +37 -0
  75. package/src/utils/attachment-manager.ts +15 -0
  76. package/src/utils/auto-follow.test.ts +23 -0
  77. package/src/utils/auto-follow.ts +11 -1
  78. package/src/utils/config-merge.ts +2 -0
  79. package/src/utils/content-width.test.ts +37 -0
  80. package/src/utils/content-width.ts +17 -0
  81. package/src/utils/plugin-storage.test.ts +94 -0
  82. package/src/utils/plugin-storage.ts +98 -0
  83. package/src/utils/theme.test.ts +137 -0
  84. package/src/utils/tokens.ts +49 -3
  85. package/src/welcome.test.ts +213 -0
  86. package/src/welcome.ts +130 -0
@@ -0,0 +1,305 @@
1
+ import { createElement, createNode } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { getBubbleClasses } from "./message-bubble";
4
+ import type {
5
+ AgentWidgetConfig,
6
+ AgentWidgetWelcomeIcon,
7
+ } from "../types";
8
+ import type { ResolvedWelcomeConfig } from "../welcome";
9
+
10
+ /**
11
+ * Welcome surface DOM. The host is permanent: it mounts once with the panel
12
+ * and every variant/visibility change is applied to it in place, so live
13
+ * `controller.update()` never rebuilds it.
14
+ */
15
+
16
+ export interface WelcomeHostElements {
17
+ /** Permanent host; also carries `data-persona-intro-card` for slot parity. */
18
+ host: HTMLElement;
19
+ iconHolder: HTMLElement;
20
+ title: HTMLElement;
21
+ subtitle: HTMLElement;
22
+ starterSuggestions: HTMLElement;
23
+ }
24
+
25
+ const WELCOME_ICON_SIZE = 40;
26
+
27
+ /** Replaces the holder's content; hides it when no icon is configured. */
28
+ export const applyWelcomeIcon = (
29
+ holder: HTMLElement,
30
+ icon: AgentWidgetWelcomeIcon | undefined
31
+ ): void => {
32
+ holder.replaceChildren();
33
+ if (!icon) {
34
+ holder.hidden = true;
35
+ return;
36
+ }
37
+
38
+ if (typeof icon === "function") {
39
+ let custom: HTMLElement | SVGElement | null = null;
40
+ try {
41
+ custom = icon();
42
+ } catch (error) {
43
+ console.warn("[persona] welcome.icon renderer threw", error);
44
+ }
45
+ if (!custom) {
46
+ holder.hidden = true;
47
+ return;
48
+ }
49
+ holder.appendChild(custom);
50
+ holder.hidden = false;
51
+ return;
52
+ }
53
+
54
+ if (icon.type === "lucide") {
55
+ const svg = renderLucideIcon(icon.name, WELCOME_ICON_SIZE);
56
+ if (!svg) {
57
+ holder.hidden = true;
58
+ return;
59
+ }
60
+ holder.appendChild(svg);
61
+ } else if (icon.type === "image") {
62
+ holder.appendChild(
63
+ createNode("img", {
64
+ className: "persona-welcome-icon-image",
65
+ attrs: { src: icon.url, alt: icon.alt },
66
+ })
67
+ );
68
+ } else {
69
+ holder.appendChild(
70
+ createNode("span", {
71
+ className: "persona-welcome-icon-text",
72
+ text: icon.text,
73
+ })
74
+ );
75
+ }
76
+ holder.hidden = false;
77
+ };
78
+
79
+ /**
80
+ * Build the welcome host. `starterSuggestions` is created by the caller so the
81
+ * suggestion surface keeps its existing wiring.
82
+ */
83
+ export const buildWelcomeHost = (
84
+ config: AgentWidgetConfig | undefined,
85
+ starterSuggestions: HTMLElement,
86
+ options: { flatShadow?: boolean } = {}
87
+ ): WelcomeHostElements => {
88
+ const iconHolder = createNode("div", {
89
+ className: "persona-welcome-icon",
90
+ attrs: { "data-persona-welcome-icon": "" },
91
+ });
92
+ iconHolder.hidden = true;
93
+
94
+ // Typography flows through `components.introCard.title` / `.subtitle`;
95
+ // the CSS fallbacks reproduce the utility classes these replaced.
96
+ const title = createElement("h2", "persona-welcome-title");
97
+ const subtitle = createElement("p", "persona-welcome-subtitle");
98
+
99
+ // Background and box-shadow flow through the themable `components.introCard`
100
+ // tokens; both default to flat. Docked mode always stays flat. Padding comes
101
+ // from `--persona-intro-card-padding` (see widget.css), which zeroes its
102
+ // horizontal component when the card resolves flat so the text shares the
103
+ // content column's left edge instead of carrying a phantom card inset.
104
+ const host = createNode(
105
+ "div",
106
+ {
107
+ className: "persona-welcome persona-rounded-2xl",
108
+ attrs: {
109
+ "data-persona-welcome": "",
110
+ "data-persona-intro-card": "",
111
+ "data-persona-welcome-variant": "card",
112
+ },
113
+ style: {
114
+ background: "var(--persona-intro-card-bg, transparent)",
115
+ boxShadow: options.flatShadow
116
+ ? "none"
117
+ : "var(--persona-intro-card-shadow, none)",
118
+ border: "var(--persona-intro-card-border, none)",
119
+ },
120
+ },
121
+ iconHolder,
122
+ title,
123
+ subtitle,
124
+ starterSuggestions
125
+ );
126
+
127
+ return { host, iconHolder, title, subtitle, starterSuggestions };
128
+ };
129
+
130
+ /**
131
+ * Show/hide the permanent host. The body gap tightens when nothing renders
132
+ * there so a hidden welcome leaves no phantom section spacing.
133
+ */
134
+ export const applyWelcomeVisibility = (
135
+ body: HTMLElement,
136
+ host: HTMLElement,
137
+ visible: boolean
138
+ ): void => {
139
+ host.hidden = !visible;
140
+ host.style.display = visible ? "" : "none";
141
+ body.classList.toggle("persona-gap-6", visible);
142
+ body.classList.toggle("persona-gap-3", !visible);
143
+ };
144
+
145
+ /**
146
+ * Apply resolved config to the permanent host. Every field is set on every
147
+ * call so `controller.update()` can drop a field and see it revert.
148
+ */
149
+ export const applyWelcomeConfig = (
150
+ elements: WelcomeHostElements,
151
+ resolved: ResolvedWelcomeConfig
152
+ ): void => {
153
+ elements.title.textContent = resolved.title;
154
+ elements.subtitle.textContent = resolved.subtitle;
155
+ // An empty subtitle omits the paragraph entirely, margin included.
156
+ elements.subtitle.hidden = resolved.subtitle === "";
157
+ applyWelcomeIcon(elements.iconHolder, resolved.icon);
158
+ elements.host.setAttribute("data-persona-welcome-variant", resolved.variant);
159
+ elements.host.setAttribute("data-persona-welcome-dismiss", resolved.dismiss);
160
+ };
161
+
162
+ /** Marks the element a `renderWelcome` plugin returned. */
163
+ export const WELCOME_PLUGIN_CONTENT_ATTR = "data-persona-welcome-plugin";
164
+ const WELCOME_OVERLAY_ATTR = "data-persona-welcome-overlay";
165
+ const WELCOME_OVERLAY_BODY_CLASS = "persona-welcome-overlay-active";
166
+
167
+ // An absolute child of a scrolled scroller sits at the content origin, not
168
+ // the visible viewport, and overflow: hidden freezes scrollTop rather than
169
+ // resetting it. Zero the scroll while the overlay is up, restore on clear.
170
+ const overlayScrollTop = new WeakMap<HTMLElement, number>();
171
+
172
+ /**
173
+ * Mount plugin content inside the permanent host. The default children are
174
+ * hidden by CSS while the content attribute is set, so nothing about their
175
+ * own hidden state has to be saved and restored.
176
+ */
177
+ export const mountWelcomePluginContent = (
178
+ body: HTMLElement,
179
+ host: HTMLElement,
180
+ element: HTMLElement
181
+ ): void => {
182
+ element.setAttribute(WELCOME_PLUGIN_CONTENT_ATTR, "");
183
+ host.appendChild(element);
184
+ host.setAttribute("data-persona-welcome-content", "plugin");
185
+ // Plugin content renders regardless of derived visibility and overlays the
186
+ // transcript, which is what makes "return to home" over a conversation work.
187
+ host.setAttribute(WELCOME_OVERLAY_ATTR, "");
188
+ if (!body.classList.contains(WELCOME_OVERLAY_BODY_CLASS)) {
189
+ overlayScrollTop.set(body, body.scrollTop);
190
+ body.classList.add(WELCOME_OVERLAY_BODY_CLASS);
191
+ body.scrollTop = 0;
192
+ }
193
+ };
194
+
195
+ /** Remove plugin content and hand the host back to the default renderer. */
196
+ export const clearWelcomePluginContent = (
197
+ body: HTMLElement,
198
+ host: HTMLElement,
199
+ element: HTMLElement | null
200
+ ): void => {
201
+ if (element && element !== host) element.remove();
202
+ host.removeAttribute("data-persona-welcome-content");
203
+ host.removeAttribute(WELCOME_OVERLAY_ATTR);
204
+ if (body.classList.contains(WELCOME_OVERLAY_BODY_CLASS)) {
205
+ body.classList.remove(WELCOME_OVERLAY_BODY_CLASS);
206
+ body.scrollTop = overlayScrollTop.get(body) ?? body.scrollTop;
207
+ overlayScrollTop.delete(body);
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Greeting host: a sibling above `messagesWrapper`, so it is pinned at
213
+ * transcript position zero without ever entering the morph target.
214
+ */
215
+ export const buildGreetingHost = (): HTMLElement => {
216
+ const host = createNode("div", {
217
+ className: "persona-welcome-greeting persona-flex persona-flex-col",
218
+ attrs: { "data-persona-welcome-greeting": "" },
219
+ });
220
+ host.hidden = true;
221
+ return host;
222
+ };
223
+
224
+ /**
225
+ * Render the display-only greeting bubble. Plain text by contract: no
226
+ * markdown pipeline, no action affordances, no message id, never a session
227
+ * message.
228
+ */
229
+ export const renderWelcomeGreeting = (
230
+ host: HTMLElement,
231
+ text: string | undefined,
232
+ config: AgentWidgetConfig | undefined
233
+ ): void => {
234
+ host.replaceChildren();
235
+ if (!text) {
236
+ host.hidden = true;
237
+ return;
238
+ }
239
+
240
+ const layout = config?.layout?.messages?.layout ?? "bubble";
241
+ const bubble = createNode("div", {
242
+ className: getBubbleClasses("assistant", layout).join(" "),
243
+ attrs: { "data-persona-theme-zone": "assistant-message" },
244
+ text,
245
+ style:
246
+ layout === "flat"
247
+ ? {
248
+ backgroundColor: "transparent",
249
+ color: "var(--persona-message-assistant-text, var(--persona-text))",
250
+ }
251
+ : {
252
+ backgroundColor:
253
+ "var(--persona-message-assistant-bg, var(--persona-container))",
254
+ color: "var(--persona-message-assistant-text, var(--persona-text))",
255
+ },
256
+ });
257
+
258
+ host.appendChild(createNode("div", { className: "persona-flex" }, bubble));
259
+ host.hidden = false;
260
+ };
261
+
262
+ const prefersReducedMotion = (): boolean =>
263
+ typeof window !== "undefined" &&
264
+ typeof window.matchMedia === "function" &&
265
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
266
+
267
+ /**
268
+ * Hero dismiss. WAAPI, not a CSS transition: morph re-renders strip
269
+ * post-render CSS state and cancel transitions mid-stream. Shares the
270
+ * starters' entrance motion spec so the two read as one system.
271
+ *
272
+ * Returns the animation so the caller can hold the host visible while it runs
273
+ * and cancel it on a re-show. `onFinished` receives it, or null when the motion
274
+ * was skipped and the callback ran synchronously.
275
+ */
276
+ export const animateWelcomeOut = (
277
+ host: HTMLElement,
278
+ onFinished: (animation: Animation | null) => void
279
+ ): Animation | null => {
280
+ if (
281
+ prefersReducedMotion() ||
282
+ typeof host.animate !== "function" ||
283
+ !host.isConnected
284
+ ) {
285
+ onFinished(null);
286
+ return null;
287
+ }
288
+ // `forwards`, not `backwards`: backwards only fills the delay phase, so the
289
+ // element snaps back to opacity 1 for a frame after the keyframes end.
290
+ const animation = host.animate(
291
+ [
292
+ { opacity: "1", transform: "none" },
293
+ { opacity: "0", transform: "translateY(-4px)" },
294
+ ],
295
+ { duration: 180, easing: "ease-out", fill: "forwards" }
296
+ );
297
+ let settled = false;
298
+ const settle = () => {
299
+ if (settled) return;
300
+ settled = true;
301
+ onFinished(animation);
302
+ };
303
+ animation.finished.then(settle).catch(settle);
304
+ return animation;
305
+ };
@@ -129,15 +129,17 @@ describe("createWidgetView: replacement helpers", () => {
129
129
  describe("resolveLauncher", () => {
130
130
  const config: AgentWidgetConfig = { apiUrl: "/api" };
131
131
 
132
- it("builds the default launcher controller and wires onToggle", () => {
132
+ it("builds the default launcher surface and wires onToggle", () => {
133
133
  const onToggle = vi.fn();
134
134
  const { instance, element } = resolveLauncher({ config, plugins: [], onToggle });
135
135
 
136
136
  expect(instance).not.toBeNull();
137
137
  expect(element).toBe(instance?.element);
138
- expect(element.tagName).toBe("BUTTON");
138
+ // The mounted element is the surface wrapper; the button is inside it.
139
+ expect(element.tagName).toBe("DIV");
140
+ expect(instance?.launcher.element.tagName).toBe("BUTTON");
139
141
 
140
- element.dispatchEvent(new Event("click"));
142
+ instance!.launcher.element.dispatchEvent(new Event("click"));
141
143
  expect(onToggle).toHaveBeenCalledTimes(1);
142
144
  });
143
145
 
@@ -2,7 +2,7 @@ import { AgentWidgetConfig } from "../types";
2
2
  import { AgentWidgetPlugin } from "../plugins/types";
3
3
  import { createWrapper, buildPanel, PanelElements } from "./panel";
4
4
  import { HeaderElements } from "./header-builder";
5
- import { createLauncherButton, LauncherButton } from "./launcher";
5
+ import { createLauncherButton, createLauncherSurface, LauncherSurface } from "./launcher";
6
6
 
7
7
  /**
8
8
  * Widget view assembly layer.
@@ -34,6 +34,9 @@ export interface WidgetTranscriptRefs {
34
34
  transcriptSuggestions: HTMLElement;
35
35
  /** Absolute slot above the composer where interactive sheets mount. */
36
36
  composerOverlay: HTMLElement;
37
+ welcomeHost: HTMLElement;
38
+ welcomeIconHolder: HTMLElement;
39
+ greetingHost: HTMLElement;
37
40
  introTitle: HTMLElement;
38
41
  introSubtitle: HTMLElement;
39
42
  }
@@ -124,6 +127,9 @@ export const createWidgetView = ({
124
127
  starterSuggestions: panelElements.starterSuggestions,
125
128
  transcriptSuggestions: panelElements.transcriptSuggestions,
126
129
  composerOverlay: panelElements.composerOverlay,
130
+ welcomeHost: panelElements.welcomeHost,
131
+ welcomeIconHolder: panelElements.welcomeIconHolder,
132
+ greetingHost: panelElements.greetingHost,
127
133
  introTitle: panelElements.introTitle,
128
134
  introSubtitle: panelElements.introSubtitle,
129
135
  };
@@ -199,11 +205,11 @@ export interface ResolveLauncherOptions {
199
205
 
200
206
  export interface ResolvedLauncher {
201
207
  /**
202
- * The default launcher controller (with `update`/`destroy`), or null when a
208
+ * The default launcher surface (with `update`/`destroy`), or null when a
203
209
  * plugin supplied a custom launcher element.
204
210
  */
205
- instance: LauncherButton | null;
206
- /** The element to mount: either the default button or the plugin's element. */
211
+ instance: LauncherSurface | null;
212
+ /** The element to mount: either the default surface or the plugin's element. */
207
213
  element: HTMLElement;
208
214
  }
209
215
 
@@ -213,8 +219,9 @@ export interface ResolvedLauncher {
213
219
  * both the initial-build and re-enable call sites in ui.ts.
214
220
  *
215
221
  * When a plugin returns a custom element, `instance` is null (the plugin owns
216
- * its own updates); otherwise the default `LauncherButton` controller is
217
- * returned so the caller can drive `update()`/`destroy()`.
222
+ * its own updates, including any teaser); otherwise the default
223
+ * `LauncherSurface` controller is returned so the caller can drive
224
+ * `update()`/`destroy()`.
218
225
  */
219
226
  export const resolveLauncher = ({
220
227
  config,
@@ -233,6 +240,6 @@ export const resolveLauncher = ({
233
240
  }
234
241
  }
235
242
 
236
- const instance = createLauncherButton(config, onToggle);
243
+ const instance = createLauncherSurface(config, onToggle);
237
244
  return { instance, element: instance.element };
238
245
  };
package/src/defaults.ts CHANGED
@@ -79,8 +79,8 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
79
79
  colorScheme: "light",
80
80
  launcher: DEFAULT_LAUNCHER_CONFIG,
81
81
  copy: {
82
- welcomeTitle: "Hello 👋",
83
- welcomeSubtitle: "Ask anything about your account or products.",
82
+ // No welcomeTitle / welcomeSubtitle here on purpose: `resolveWelcomeConfig`
83
+ // owns those defaults so presence still means "the host set this".
84
84
  inputPlaceholder: "How can I help...",
85
85
  sendButtonLabel: "Send",
86
86
  },
package/src/index-core.ts CHANGED
@@ -31,6 +31,7 @@ export type {
31
31
  AgentWidgetInitOptions,
32
32
  AgentWidgetMessage,
33
33
  AgentWidgetLauncherConfig,
34
+ AgentWidgetLauncherTeaserConfig,
34
35
  AgentWidgetDockConfig,
35
36
  AgentWidgetEvent,
36
37
  AgentWidgetStreamParser,
@@ -151,7 +152,11 @@ export type {
151
152
  AgentWidgetSuggestionSurfaceConfig,
152
153
  AgentWidgetStarterSuggestionsConfig,
153
154
  AgentWidgetFollowUpSuggestionsConfig,
154
- AgentWidgetSuggestionsConfig
155
+ AgentWidgetSuggestionsConfig,
156
+ AgentWidgetWelcomeConfig,
157
+ AgentWidgetWelcomeIcon,
158
+ AgentWidgetWelcomeVariant,
159
+ AgentWidgetWelcomeDismiss
155
160
  } from "./types";
156
161
 
157
162
  export {
@@ -341,8 +346,13 @@ export type {
341
346
  AgentWidgetPlugin,
342
347
  AgentWidgetTransformSuggestionsContext,
343
348
  AgentWidgetRenderSuggestionContext,
349
+ AgentWidgetRenderWelcomeContext,
344
350
  AgentWidgetSuggestionSelectContext
345
351
  } from "./plugins/types";
352
+ // Welcome resolver: `renderWelcome` receives the resolved shape.
353
+ export { resolveWelcomeConfig, isWelcomeVisible } from "./welcome";
354
+ export type { ResolvedWelcomeConfig } from "./welcome";
355
+ export type { AgentWidgetPluginStorage } from "./utils/plugin-storage";
346
356
  export { pluginRegistry } from "./plugins/registry";
347
357
 
348
358
  // Stream animation plugin API: lets consumers register custom animations
@@ -447,6 +457,7 @@ export type {
447
457
  SemanticSpacing,
448
458
  SemanticTypography,
449
459
  ComponentTokens,
460
+ TextStyleTokens,
450
461
  ArtifactToolbarTokens,
451
462
  ArtifactTabTokens,
452
463
  ArtifactPaneTokens,
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Critical-path launcher entry: built to `launcher.global.js` (IIFE).
3
3
  *
4
- * Ships ONLY the real collapsed launcher (`createLauncherButton`) plus the
4
+ * Ships ONLY the real collapsed launcher surface (`createLauncherSurface`,
5
+ * which carries the optional launcher teaser) plus the
5
6
  * theme application path, so the launcher paints pixel-identically to the full
6
7
  * widget from a tiny bundle. The heavy conversation panel is deferred until
7
8
  * first open by the installer (Phase 2).
@@ -16,7 +17,7 @@
16
17
  * window.AgentWidgetLauncher.mount({ target, config, onOpen })
17
18
  * → { root, element, update, destroy }
18
19
  */
19
- import { createLauncherButton } from "./components/launcher";
20
+ import { createLauncherSurface } from "./components/launcher";
20
21
  import { applyThemeVariables } from "./utils/theme";
21
22
  import { DEFAULT_LAUNCHER_CONFIG } from "./defaults";
22
23
  import type { AgentWidgetConfig } from "./types";
@@ -100,20 +101,22 @@ export const mount = (
100
101
  root.setAttribute(CRITICAL_LAUNCHER_ATTR, "true");
101
102
  applyThemeVariables(root, config);
102
103
 
103
- const launcher = createLauncherButton(config, onOpen);
104
- root.appendChild(launcher.element);
104
+ const surface = createLauncherSurface(config, onOpen);
105
+ root.appendChild(surface.element);
105
106
  target.appendChild(root);
106
107
 
107
108
  return {
108
109
  root,
109
- element: launcher.element,
110
+ element: surface.launcher.element,
110
111
  update: (next: AgentWidgetConfig) => {
111
112
  const merged = mergeCriticalLauncherConfig(next);
112
113
  applyThemeVariables(root, merged);
113
- launcher.update(merged);
114
+ surface.update(merged);
114
115
  },
116
+ // Handoff destroys the whole surface, so a pending teaser timer cannot
117
+ // outlive the critical launcher.
115
118
  destroy: () => {
116
- launcher.destroy();
119
+ surface.destroy();
117
120
  root.remove();
118
121
  },
119
122
  };
@@ -1,3 +1,5 @@
1
+ import type { ResolvedWelcomeConfig } from "../welcome";
2
+ import type { AgentWidgetPluginStorage } from "../utils/plugin-storage";
1
3
  import {
2
4
  AgentWidgetMessage,
3
5
  AgentWidgetConfig,
@@ -13,7 +15,8 @@ import {
13
15
  AgentWidgetResolvedSuggestion,
14
16
  AgentWidgetSuggestionSource,
15
17
  AgentWidgetSuggestionSurface,
16
- AgentWidgetSuggestionVariant
18
+ AgentWidgetSuggestionVariant,
19
+ AgentWidgetWelcomeVariant
17
20
  } from "../types";
18
21
 
19
22
  export type AgentWidgetTransformSuggestionsContext = {
@@ -45,6 +48,28 @@ export type AgentWidgetRenderSuggestionContext = {
45
48
  select: () => void;
46
49
  };
47
50
 
51
+ export type AgentWidgetRenderWelcomeContext = {
52
+ /** Alias-resolved welcome config; never the raw `copy.welcome*` shape. */
53
+ config: ResolvedWelcomeConfig;
54
+ variant: AgentWidgetWelcomeVariant;
55
+ /** Derived visibility for this render; governs the default renderer only. */
56
+ visible: boolean;
57
+ /** The core-owned welcome card, live-updated by `controller.update()`. */
58
+ defaultRenderer: () => HTMLElement;
59
+ sendMessage: (text: string) => void;
60
+ /** Re-run welcome arbitration: cleanups run, previous content is removed. */
61
+ requestRender: () => void;
62
+ /**
63
+ * Persona's starter renderer, wired through the full select pipeline:
64
+ * `onSuggestionSelect`, cancelable `persona:suggestion:*` events, send/fill.
65
+ */
66
+ renderStarter: (suggestion: AgentWidgetSuggestion) => HTMLElement;
67
+ /** Sync store namespaced by `persistState.keyPrefix` plus the plugin id. */
68
+ storage: AgentWidgetPluginStorage;
69
+ /** Teardown for this render; runs before the next one and on destroy. */
70
+ onCleanup: (fn: () => void) => void;
71
+ };
72
+
48
73
  export type AgentWidgetSuggestionSelectContext = {
49
74
  suggestion: AgentWidgetResolvedSuggestion;
50
75
  surface: AgentWidgetSuggestionSurface;
@@ -115,6 +140,37 @@ export interface AgentWidgetPlugin {
115
140
  onToggle: () => void;
116
141
  }) => HTMLElement | null;
117
142
 
143
+ /**
144
+ * Custom renderer for the welcome surface (card, hero, greeting stack).
145
+ * First plugin returning an element wins; null falls through to the default,
146
+ * same contract as `renderSuggestion`.
147
+ *
148
+ * The core owns the welcome host: it stays mounted for the panel's lifetime
149
+ * and content is swapped inside it. A plugin element renders regardless of
150
+ * `ctx.visible` (derived visibility governs the default renderer only) and
151
+ * the host overlays the transcript while that element is active, so a home
152
+ * screen can return over an existing conversation. Return null again to
153
+ * clear the overlay.
154
+ *
155
+ * The ctx carries no controller: plugins are host-instantiated, so close a
156
+ * factory over the init handle (`plugin.attach(controller)`).
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * renderWelcome: ({ config, renderStarter, requestRender, onCleanup }) => {
161
+ * const root = document.createElement("div");
162
+ * root.textContent = config.title;
163
+ * root.appendChild(renderStarter({ label: "Track my order" }));
164
+ * const timer = setInterval(requestRender, 60_000);
165
+ * onCleanup(() => clearInterval(timer));
166
+ * return root;
167
+ * }
168
+ * ```
169
+ */
170
+ renderWelcome?: (
171
+ context: AgentWidgetRenderWelcomeContext
172
+ ) => HTMLElement | null;
173
+
118
174
  /**
119
175
  * Custom renderer for panel header
120
176
  * Return null to use default renderer
@@ -157,6 +213,15 @@ export interface AgentWidgetPlugin {
157
213
  * Omitted when `config.voiceRecognition.enabled` is not true.
158
214
  */
159
215
  onVoiceToggle?: () => void;
216
+ /**
217
+ * Re-run composer arbitration and swap the footer in place. The hook runs
218
+ * once at panel construction, so this is how a composer plugin unlocks
219
+ * (returns null) or re-renders later. Pending attachments, the mention
220
+ * affordances, and the composer listener registry are re-wired by the core.
221
+ */
222
+ requestRender: () => void;
223
+ /** Same sync store as the `renderWelcome` ctx, namespaced per plugin id. */
224
+ storage: AgentWidgetPluginStorage;
160
225
  }) => HTMLElement | null;
161
226
 
162
227
  /**