@runtypelabs/persona 3.17.0 → 3.19.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 (61) hide show
  1. package/README.md +143 -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-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
@@ -2,20 +2,72 @@ import { createElement } from "../utils/dom";
2
2
  import { DEFAULT_FLOATING_LAUNCHER_WIDTH } from "../defaults";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { positionMap } from "../utils/positioning";
5
- import { isDockedMountMode } from "../utils/dock";
5
+ import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
6
6
  import { DEFAULT_OVERLAY_Z_INDEX } from "../utils/constants";
7
7
  import { buildHeader, attachHeaderToContainer, HeaderElements } from "./header-builder";
8
8
  import { buildHeaderWithLayout } from "./header-layouts";
9
+ import { createCloseButton, createClearChatButton } from "./header-parts";
9
10
  import { buildComposer, ComposerElements } from "./composer-builder";
11
+ import { buildPillComposer, buildPillPeekBanner } from "./pill-composer-builder";
10
12
 
11
13
  export interface PanelWrapper {
12
14
  wrapper: HTMLElement;
13
15
  panel: HTMLElement;
16
+ /**
17
+ * Composer-bar mode only: viewport-fixed sibling of `wrapper` that owns
18
+ * the persistent pill (`footer`) and peek banner. Lives outside the
19
+ * wrapper so it never inherits the wrapper's geometry transitions —
20
+ * critical for modal mode where the wrapper is `transform: translate(-50%, -50%)`
21
+ * (a transformed ancestor establishes a containing block for `position: fixed`
22
+ * descendants, which would trap the pill inside the wrapper otherwise).
23
+ */
24
+ pillRoot?: HTMLElement;
14
25
  }
15
26
 
16
27
  export const createWrapper = (config?: AgentWidgetConfig): PanelWrapper => {
17
28
  const launcherEnabled = config?.launcher?.enabled ?? true;
18
29
  const dockedMode = isDockedMountMode(config);
30
+ const composerBarMode = isComposerBarMountMode(config);
31
+
32
+ if (composerBarMode) {
33
+ const cb = config?.launcher?.composerBar ?? {};
34
+ // Geometry (left/transform/bottom/top/width/max-width) is intentionally
35
+ // NOT set here — it's owned entirely by `applyComposerBarGeometry()` in
36
+ // ui.ts so that collapsed → expanded transitions can clear the previous
37
+ // state's inline styles cleanly. Setting geometry here would persist
38
+ // across state changes and override the per-state values, which
39
+ // previously caused expanded panels to render at collapsed dimensions.
40
+ const wrapper = createElement(
41
+ "div",
42
+ "persona-widget-wrapper persona-fixed persona-transition"
43
+ );
44
+ wrapper.setAttribute("data-persona-composer-bar", "");
45
+ wrapper.dataset.state = "collapsed";
46
+ wrapper.dataset.expandedSize = cb.expandedSize ?? "anchored";
47
+ wrapper.style.zIndex = String(
48
+ config?.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
49
+ );
50
+
51
+ const panel = createElement(
52
+ "div",
53
+ "persona-widget-panel persona-relative persona-flex persona-flex-1 persona-min-h-0 persona-flex-col"
54
+ );
55
+ panel.style.width = "100%";
56
+ wrapper.appendChild(panel);
57
+
58
+ // Pill lives in a separate viewport-fixed sibling — see PanelWrapper
59
+ // docs above. ui.ts appends `peekBanner` and `footer` here, then
60
+ // appends pillRoot to `mount` immediately after the wrapper.
61
+ const pillRoot = createElement("div", "persona-widget-pill-root");
62
+ pillRoot.setAttribute("data-persona-composer-bar", "");
63
+ pillRoot.dataset.state = "collapsed";
64
+ pillRoot.dataset.expandedSize = cb.expandedSize ?? "anchored";
65
+ pillRoot.style.zIndex = String(
66
+ config?.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
67
+ );
68
+
69
+ return { wrapper, panel, pillRoot };
70
+ }
19
71
 
20
72
  if (dockedMode) {
21
73
  const wrapper = createElement(
@@ -81,6 +133,12 @@ export interface PanelElements {
81
133
  container: HTMLElement;
82
134
  body: HTMLElement;
83
135
  messagesWrapper: HTMLElement;
136
+ /**
137
+ * Absolute-positioned slot above the composer footer. Interactive sheets
138
+ * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
139
+ * so they slide in without reflowing the chat transcript.
140
+ */
141
+ composerOverlay: HTMLElement;
84
142
  suggestions: HTMLElement;
85
143
  textarea: HTMLTextAreaElement;
86
144
  sendButton: HTMLButtonElement;
@@ -112,14 +170,218 @@ export interface PanelElements {
112
170
  rightActions: HTMLElement;
113
171
  /** Swap the send button between its send and stop appearances. */
114
172
  setSendButtonMode: (mode: "send" | "stop") => void;
173
+ /**
174
+ * Composer-bar peek banner: the chrome-less row above the pill that
175
+ * shows a trailing preview of the most recent assistant message.
176
+ * Undefined for non-composer-bar modes.
177
+ */
178
+ peekBanner?: HTMLButtonElement;
179
+ peekTextNode?: HTMLElement;
115
180
  }
116
181
 
182
+ /**
183
+ * Composer-bar panel: minimal close-only header (× in the top-right of the
184
+ * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
185
+ * the panel — caller (`ui.ts`) appends them to the `pillRoot` returned by
186
+ * `createWrapper`, which is a viewport-fixed sibling of the wrapper.
187
+ * This decouples the pill from the wrapper's geometry transitions (so the
188
+ * pill stays anchored at the viewport bottom regardless of expanded mode).
189
+ */
190
+ const buildComposerBarPanel = (
191
+ config: AgentWidgetConfig | undefined,
192
+ showClose: boolean,
193
+ ): PanelElements => {
194
+ // Container = the chat panel chrome (border + bg + radius + shadow applied
195
+ // inline by `applyFullHeightStyles` in ui.ts). `position: relative` anchors
196
+ // the absolute close button + composerOverlay.
197
+ const container = createElement(
198
+ "div",
199
+ "persona-widget-container persona-relative persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-text-persona-primary"
200
+ );
201
+ container.setAttribute("data-persona-theme-zone", "container");
202
+
203
+ // Minimal header — just an absolutely-positioned close button.
204
+ // The wrapper uses inline styles (top/right/z-index values) because the
205
+ // widget's hand-authored CSS doesn't ship every Tailwind utility.
206
+ // Composer-bar's defaults are roughly half the floating-launcher's
207
+ // (button 16px, icon 14px) to match the minimal aesthetic; the user can
208
+ // still override via `launcher.closeButtonSize`.
209
+ const { button: closeButton, wrapper: closeButtonWrapper } = createCloseButton(
210
+ config,
211
+ {
212
+ showClose,
213
+ wrapperClassName: "persona-composer-bar-close",
214
+ buttonSize: "16px",
215
+ iconSize: "14px",
216
+ }
217
+ );
218
+ closeButtonWrapper.style.position = "absolute";
219
+ closeButtonWrapper.style.top = "8px";
220
+ closeButtonWrapper.style.right = "8px";
221
+ closeButtonWrapper.style.zIndex = "10";
222
+
223
+ // Clear / "start over" button — sits immediately to the left of the close
224
+ // button in the panel chrome's top-right corner. Same minimal sizing as
225
+ // the close icon (16px button, 14px icon) so the two read as a paired
226
+ // action group rather than a header strip. Wired by `setupClearChatButton()`
227
+ // in ui.ts via the `clearChatButton` field on PanelElements.
228
+ //
229
+ // Right offset = close button right (8px) + close button width (16px) +
230
+ // 8px inter-button gap = 32px.
231
+ const clearChatEnabled = config?.launcher?.clearChat?.enabled ?? true;
232
+ let clearChatButton: HTMLButtonElement | null = null;
233
+ let clearChatButtonWrapper: HTMLElement | null = null;
234
+ if (clearChatEnabled) {
235
+ const parts = createClearChatButton(config, {
236
+ wrapperClassName: "persona-composer-bar-clear-chat",
237
+ buttonSize: "16px",
238
+ iconSize: "14px",
239
+ });
240
+ clearChatButton = parts.button;
241
+ clearChatButtonWrapper = parts.wrapper;
242
+ clearChatButtonWrapper.style.position = "absolute";
243
+ clearChatButtonWrapper.style.top = "8px";
244
+ clearChatButtonWrapper.style.right = "32px";
245
+ clearChatButtonWrapper.style.zIndex = "10";
246
+ }
247
+ // Placeholder header element so PanelElements.header exists (some downstream
248
+ // code reads it). It carries `data-persona-widget-header` for plugin /
249
+ // selector parity but renders nothing visible — the close button is the only
250
+ // header chrome in composer-bar mode.
251
+ const headerPlaceholder = createElement("span", "persona-widget-header");
252
+ headerPlaceholder.setAttribute("data-persona-theme-zone", "header");
253
+ headerPlaceholder.style.display = "none";
254
+
255
+ // Body — extra top padding (set inline so the hand-authored widget.css
256
+ // doesn't need a `pt-12` utility) so the absolute close button doesn't
257
+ // overlap the welcome card / first message.
258
+ const body = createElement(
259
+ "div",
260
+ "persona-widget-body persona-flex persona-flex-1 persona-min-h-0 persona-flex-col persona-gap-6 persona-overflow-y-auto persona-bg-persona-container persona-px-6 persona-py-6"
261
+ );
262
+ body.style.paddingTop = "48px";
263
+ body.id = "persona-scroll-container";
264
+ body.setAttribute("data-persona-theme-zone", "messages");
265
+
266
+ const introCard = createElement(
267
+ "div",
268
+ "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
269
+ );
270
+ introCard.style.boxShadow =
271
+ "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
272
+ introCard.setAttribute("data-persona-intro-card", "");
273
+ const introTitle = createElement(
274
+ "h2",
275
+ "persona-text-lg persona-font-semibold persona-text-persona-primary"
276
+ );
277
+ introTitle.textContent = config?.copy?.welcomeTitle ?? "Hello 👋";
278
+ const introSubtitle = createElement(
279
+ "p",
280
+ "persona-mt-2 persona-text-sm persona-text-persona-muted"
281
+ );
282
+ introSubtitle.textContent =
283
+ config?.copy?.welcomeSubtitle ??
284
+ "Ask anything about your account or products.";
285
+ introCard.append(introTitle, introSubtitle);
286
+
287
+ const messagesWrapper = createElement(
288
+ "div",
289
+ "persona-flex persona-flex-col persona-gap-3"
290
+ );
291
+ const contentMaxWidth = config?.layout?.contentMaxWidth;
292
+ if (contentMaxWidth) {
293
+ messagesWrapper.style.maxWidth = contentMaxWidth;
294
+ messagesWrapper.style.marginLeft = "auto";
295
+ messagesWrapper.style.marginRight = "auto";
296
+ messagesWrapper.style.width = "100%";
297
+ }
298
+
299
+ const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
300
+ if (!showWelcomeCard) {
301
+ introCard.style.display = "none";
302
+ body.classList.remove("persona-gap-6");
303
+ body.classList.add("persona-gap-3");
304
+ }
305
+ body.append(introCard, messagesWrapper);
306
+
307
+ // Composer overlay (interactive sheets like ask_user_question slide up here).
308
+ // Anchored to the bottom of the container (which is `position: relative`),
309
+ // so sheets render at the bottom of the chat chrome — just above the gap +
310
+ // pill that sit below the container.
311
+ const composerOverlay = createElement(
312
+ "div",
313
+ "persona-composer-overlay persona-pointer-events-none"
314
+ );
315
+ composerOverlay.setAttribute("data-persona-composer-overlay", "");
316
+ composerOverlay.style.position = "absolute";
317
+ composerOverlay.style.left = "0";
318
+ composerOverlay.style.right = "0";
319
+ composerOverlay.style.bottom = "0";
320
+ composerOverlay.style.zIndex = "20";
321
+
322
+ // Pill composer — caller appends as a sibling of container in the panel.
323
+ const composerElements: ComposerElements = buildPillComposer({ config });
324
+
325
+ // Peek banner — caller inserts as a sibling of container/footer between
326
+ // them in the panel. Hidden by default; ui.ts toggles visibility.
327
+ const { root: peekBanner, textNode: peekTextNode } = buildPillPeekBanner();
328
+
329
+ // Container = [close button (absolute), placeholder header, body, overlay].
330
+ // Footer (pill) is intentionally NOT appended here.
331
+ container.append(headerPlaceholder, closeButtonWrapper, body, composerOverlay);
332
+ if (clearChatButtonWrapper) {
333
+ container.appendChild(clearChatButtonWrapper);
334
+ }
335
+
336
+ return {
337
+ container,
338
+ body,
339
+ messagesWrapper,
340
+ composerOverlay,
341
+ suggestions: composerElements.suggestions,
342
+ textarea: composerElements.textarea,
343
+ sendButton: composerElements.sendButton,
344
+ sendButtonWrapper: composerElements.sendButtonWrapper,
345
+ micButton: composerElements.micButton,
346
+ micButtonWrapper: composerElements.micButtonWrapper,
347
+ composerForm: composerElements.composerForm,
348
+ statusText: composerElements.statusText,
349
+ introTitle,
350
+ introSubtitle,
351
+ closeButton,
352
+ closeButtonWrapper,
353
+ clearChatButton,
354
+ clearChatButtonWrapper,
355
+ iconHolder: createElement("span"),
356
+ headerTitle: createElement("span"),
357
+ headerSubtitle: createElement("span"),
358
+ header: headerPlaceholder,
359
+ footer: composerElements.footer,
360
+ attachmentButton: composerElements.attachmentButton,
361
+ attachmentButtonWrapper: composerElements.attachmentButtonWrapper,
362
+ attachmentInput: composerElements.attachmentInput,
363
+ attachmentPreviewsContainer: composerElements.attachmentPreviewsContainer,
364
+ actionsRow: composerElements.actionsRow,
365
+ leftActions: composerElements.leftActions,
366
+ rightActions: composerElements.rightActions,
367
+ setSendButtonMode: composerElements.setSendButtonMode,
368
+ peekBanner,
369
+ peekTextNode,
370
+ };
371
+ };
372
+
117
373
  export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
118
- // Use flex-1 and min-h-0 to ensure the container fills its parent and allows
119
- // the body (chat messages area) to scroll while header/footer stay fixed
374
+ // Composer-bar mode renders a purpose-built panel: minimal close-only
375
+ // header (small × in the top-right of the chat panel chrome), and the
376
+ // pill (footer) is a SIBLING of the container so it stays visible/usable
377
+ // when the chat is expanded above it.
378
+ if (isComposerBarMountMode(config)) {
379
+ return buildComposerBarPanel(config, showClose);
380
+ }
381
+
120
382
  const container = createElement(
121
383
  "div",
122
- "persona-widget-container persona-flex persona-h-full persona-w-full persona-flex-1 persona-min-h-0 persona-flex-col persona-bg-persona-surface persona-text-persona-primary persona-rounded-2xl persona-overflow-hidden persona-border persona-border-persona-border"
384
+ "persona-widget-container persona-flex persona-h-full persona-w-full persona-flex-1 persona-min-h-0 persona-flex-col persona-text-persona-primary persona-bg-persona-surface persona-rounded-2xl persona-overflow-hidden persona-border persona-border-persona-border"
123
385
  );
124
386
  container.setAttribute("data-persona-theme-zone", "container");
125
387
 
@@ -138,10 +400,17 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
138
400
  body.id = "persona-scroll-container";
139
401
  body.setAttribute("data-persona-theme-zone", "messages");
140
402
 
141
- const introCardClasses = isDockedMountMode(config)
142
- ? "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
143
- : "persona-rounded-2xl persona-bg-persona-surface persona-p-6 persona-shadow-sm";
144
- const introCard = createElement("div", introCardClasses);
403
+ const introCard = createElement(
404
+ "div",
405
+ "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
406
+ );
407
+ // Box-shadow flows through the themable `components.introCard.shadow` token
408
+ // (--persona-intro-card-shadow). Docked mode keeps a flat look by default;
409
+ // floating mode falls back to the legacy `persona-shadow-sm` value when no
410
+ // token is set.
411
+ introCard.style.boxShadow = isDockedMountMode(config)
412
+ ? "none"
413
+ : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
145
414
  const introTitle = createElement(
146
415
  "h2",
147
416
  "persona-text-lg persona-font-semibold persona-text-persona-primary"
@@ -178,7 +447,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
178
447
  }
179
448
  body.append(introCard, messagesWrapper);
180
449
 
181
- // Build composer/footer using extracted builder
450
+ // composer-bar mode early-returned above with its own pill builder; this
451
+ // path always uses the standard column-stacked composer card.
182
452
  const composerElements: ComposerElements = buildComposer({ config });
183
453
  const showFooter = config?.layout?.showFooter !== false; // default to true
184
454
 
@@ -193,6 +463,26 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
193
463
 
194
464
  container.append(body);
195
465
 
466
+ // Composer overlay slot: sits between body and footer, absolutely positioned
467
+ // above the composer so sheets (e.g. the ask_user_question answer-pill sheet)
468
+ // can slide up without reflowing the chat transcript above. Uses inline
469
+ // styles for left/right/bottom because widget.css is hand-authored and
470
+ // doesn't ship `.persona-left-0` / `.persona-right-0` rules — without
471
+ // them the overlay shrink-wraps to content and collapses the sheet width.
472
+ const composerOverlay = createElement(
473
+ "div",
474
+ "persona-composer-overlay persona-pointer-events-none"
475
+ );
476
+ composerOverlay.setAttribute("data-persona-composer-overlay", "");
477
+ composerOverlay.style.position = "absolute";
478
+ composerOverlay.style.left = "0";
479
+ composerOverlay.style.right = "0";
480
+ composerOverlay.style.bottom = "0";
481
+ // Above .persona-scroll-to-bottom-indicator (z-index 10, sibling in the
482
+ // container) so suggestion chips and the ask-user-question sheet are not
483
+ // covered by the "jump to latest" button.
484
+ composerOverlay.style.zIndex = "20";
485
+
196
486
  if (showFooter) {
197
487
  container.append(composerElements.footer);
198
488
  } else {
@@ -201,10 +491,14 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
201
491
  container.append(composerElements.footer);
202
492
  }
203
493
 
494
+ // Append overlay last so it stacks above the footer / body content.
495
+ container.append(composerOverlay);
496
+
204
497
  return {
205
498
  container,
206
499
  body,
207
500
  messagesWrapper,
501
+ composerOverlay,
208
502
  suggestions: composerElements.suggestions,
209
503
  textarea: composerElements.textarea,
210
504
  sendButton: composerElements.sendButton,
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { buildPillComposer } from "./pill-composer-builder";
5
+ import type { AgentWidgetConfig } from "../types";
6
+
7
+ describe("buildPillComposer (single-row pill composer)", () => {
8
+ it("returns a footer wrapping the pill form, with stable selectors preserved", () => {
9
+ const config: AgentWidgetConfig = {
10
+ apiUrl: "/api",
11
+ attachments: { enabled: true },
12
+ voiceRecognition: { enabled: true, provider: { type: "runtype" } },
13
+ };
14
+ const elements = buildPillComposer({ config });
15
+
16
+ expect(elements.footer.classList.contains("persona-widget-footer--pill")).toBe(true);
17
+ expect(elements.composerForm.classList.contains("persona-pill-composer")).toBe(true);
18
+ expect(elements.composerForm.classList.contains("persona-widget-composer")).toBe(true);
19
+ // Crucial: the pill form does NOT carry the column-stack utility classes
20
+ // that fight CSS layout rules.
21
+ expect(elements.composerForm.classList.contains("persona-flex-col")).toBe(false);
22
+ expect(elements.composerForm.classList.contains("persona-rounded-2xl")).toBe(false);
23
+
24
+ expect(elements.composerForm.getAttribute("data-persona-composer-form")).toBe("");
25
+ expect(elements.textarea.getAttribute("data-persona-composer-input")).toBe("");
26
+ expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
27
+ });
28
+
29
+ it("hides suggestions and status text by default in pill mode", () => {
30
+ const elements = buildPillComposer({ config: { apiUrl: "/api" } });
31
+ expect(elements.suggestions.style.display).toBe("none");
32
+ expect(elements.statusText.style.display).toBe("none");
33
+ });
34
+
35
+ it("renders the paperclip in the leftActions cell when attachments are enabled", () => {
36
+ const elements = buildPillComposer({
37
+ config: { apiUrl: "/api", attachments: { enabled: true } },
38
+ });
39
+ expect(elements.attachmentButton).not.toBeNull();
40
+ expect(elements.leftActions.contains(elements.attachmentButtonWrapper!)).toBe(true);
41
+ });
42
+
43
+ it("renders the mic in the rightActions cell when voice is enabled", () => {
44
+ const elements = buildPillComposer({
45
+ config: {
46
+ apiUrl: "/api",
47
+ voiceRecognition: { enabled: true, provider: { type: "runtype" } },
48
+ },
49
+ });
50
+ expect(elements.micButton).not.toBeNull();
51
+ expect(elements.rightActions.contains(elements.micButtonWrapper!)).toBe(true);
52
+ });
53
+
54
+ it("places the previews container ABOVE the pill (in the footer, before the form)", () => {
55
+ const elements = buildPillComposer({
56
+ config: { apiUrl: "/api", attachments: { enabled: true } },
57
+ });
58
+ const footerChildren = Array.from(elements.footer.children);
59
+ expect(footerChildren.indexOf(elements.attachmentPreviewsContainer!)).toBeLessThan(
60
+ footerChildren.indexOf(elements.composerForm)
61
+ );
62
+ expect(elements.attachmentPreviewsContainer!.classList.contains("persona-pill-composer__previews")).toBe(true);
63
+ });
64
+
65
+ it("forms a 3-cell layout: leftActions · textarea · rightActions inside the form", () => {
66
+ const elements = buildPillComposer({
67
+ config: { apiUrl: "/api", attachments: { enabled: true } },
68
+ });
69
+ // After the (hidden) file input, the next three children are the grid cells.
70
+ const formChildren = Array.from(elements.composerForm.children).filter(
71
+ (c) => (c as HTMLElement).tagName !== "INPUT"
72
+ );
73
+ expect(formChildren[0]).toBe(elements.leftActions);
74
+ expect(formChildren[1]).toBe(elements.textarea);
75
+ expect(formChildren[2]).toBe(elements.rightActions);
76
+ });
77
+
78
+ it("returns null for optional controls when disabled (matching ComposerElements contract)", () => {
79
+ const elements = buildPillComposer({ config: { apiUrl: "/api" } });
80
+ expect(elements.attachmentButton).toBeNull();
81
+ expect(elements.attachmentInput).toBeNull();
82
+ expect(elements.attachmentPreviewsContainer).toBeNull();
83
+ expect(elements.micButton).toBeNull();
84
+ });
85
+ });
@@ -0,0 +1,183 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { ComposerBuildContext, ComposerElements } from "./composer-builder";
4
+ import {
5
+ createAttachmentControls,
6
+ createComposerTextarea,
7
+ createMicButton,
8
+ createSendButton,
9
+ createStatusText,
10
+ createSuggestionsRow,
11
+ } from "./composer-parts";
12
+
13
+ export interface PillPeekBanner {
14
+ /**
15
+ * The peek button itself — a chrome-less row that floats above the pill,
16
+ * showing a chat-bubble icon, a trailing-100-char preview of the most
17
+ * recent assistant message, and a chevron-up. Rendered hidden by default
18
+ * (opacity 0, pointer-events none); ui.ts toggles
19
+ * `.persona-pill-peek--visible` based on streaming/hover/open state.
20
+ */
21
+ root: HTMLButtonElement;
22
+ /** Wrapper around the trailing message preview text. */
23
+ textNode: HTMLElement;
24
+ }
25
+
26
+ /**
27
+ * Build the peek banner for `launcher.mountMode: "composer-bar"`. The peek
28
+ * is the user's path back into the expanded chat from the collapsed pill —
29
+ * it fades in during streaming OR on composer hover, and clicking it opens
30
+ * the panel. ui.ts owns visibility + content updates via
31
+ * `syncComposerBarPeek`; this factory just produces the inert DOM shell.
32
+ *
33
+ * Placed in the panel between `container` and `footer` so it visually sits
34
+ * just above the pill in the collapsed-state UI.
35
+ */
36
+ export const buildPillPeekBanner = (): PillPeekBanner => {
37
+ const root = createElement("button", "persona-pill-peek") as HTMLButtonElement;
38
+ root.type = "button";
39
+ root.setAttribute("data-persona-pill-peek", "");
40
+ root.setAttribute("aria-label", "Show conversation");
41
+ root.setAttribute("tabindex", "-1");
42
+
43
+ const iconHolder = createElement("span", "persona-pill-peek__icon");
44
+ const messageIcon = renderLucideIcon("message-square", 16, "currentColor", 1.5);
45
+ if (messageIcon) {
46
+ iconHolder.appendChild(messageIcon);
47
+ }
48
+
49
+ const textNode = createElement("span", "persona-pill-peek__text");
50
+
51
+ const caret = createElement("span", "persona-pill-peek__caret");
52
+ const caretIcon = renderLucideIcon("chevron-up", 16, "currentColor", 1.5);
53
+ if (caretIcon) {
54
+ caret.appendChild(caretIcon);
55
+ }
56
+
57
+ root.append(iconHolder, textNode, caret);
58
+ return { root, textNode };
59
+ };
60
+
61
+ /**
62
+ * Single-row pill composer for `launcher.mountMode: "composer-bar"`.
63
+ *
64
+ * Same control factories as `buildComposer` — the only difference is the
65
+ * layout shell + className. The form ships with `persona-pill-composer`
66
+ * (no `persona-flex-col` / `persona-rounded-2xl` baggage), so the CSS
67
+ * layout rules apply at normal specificity without `!important` fights.
68
+ *
69
+ * Returns the same `ComposerElements` shape as `buildComposer` so panel.ts
70
+ * and ui.ts plumbing is unconditional past the choice of builder.
71
+ *
72
+ * Suggestions row + status text are built (so plugin code that mutates
73
+ * them keeps working and `bindComposerRefsFromFooter` finds them) but are
74
+ * `display: none` by default — pill UX is just textarea + 3 buttons.
75
+ *
76
+ * Attachment previews float ABOVE the pill in their own row when
77
+ * AttachmentManager toggles the previews container's `display` property
78
+ * as items are added/removed.
79
+ */
80
+ export const buildPillComposer = (context: ComposerBuildContext): ComposerElements => {
81
+ const { config } = context;
82
+
83
+ const footer = createElement("div", "persona-widget-footer persona-widget-footer--pill");
84
+ footer.setAttribute("data-persona-theme-zone", "composer");
85
+
86
+ const suggestions = createSuggestionsRow();
87
+ suggestions.style.display = "none";
88
+ const statusText = createStatusText(config);
89
+ statusText.style.display = "none";
90
+
91
+ const { textarea, attachAutoResize } = createComposerTextarea(config);
92
+ // Pill textarea: starts single-line, allowed to grow up to ~5 lines so
93
+ // expanded mode still supports multi-line composition. attachAutoResize
94
+ // reads max-height at event time, so this override flows through.
95
+ textarea.style.maxHeight = "100px";
96
+ attachAutoResize();
97
+
98
+ const send = createSendButton(config);
99
+ const mic = createMicButton(config);
100
+ const attachment = createAttachmentControls(config);
101
+
102
+ if (attachment) {
103
+ attachment.previewsContainer.classList.add("persona-pill-composer__previews");
104
+ }
105
+
106
+ // Pill form: NO `persona-flex-col`. Only the marker classes that the rest
107
+ // of the codebase queries by name.
108
+ const composerForm = createElement(
109
+ "form",
110
+ "persona-widget-composer persona-pill-composer"
111
+ ) as HTMLFormElement;
112
+ composerForm.setAttribute("data-persona-composer-form", "");
113
+ composerForm.style.outline = "none";
114
+
115
+ // Three columns of the grid: [paperclip?] · textarea · mic + send.
116
+ // The empty leftActions wrapper still ships when attachments are off so
117
+ // the grid has a consistent first cell (auto width → collapses to 0).
118
+ const leftActions = createElement(
119
+ "div",
120
+ "persona-widget-composer__left-actions persona-pill-composer__left"
121
+ );
122
+ if (attachment) leftActions.append(attachment.wrapper);
123
+
124
+ const rightActions = createElement(
125
+ "div",
126
+ "persona-widget-composer__right-actions persona-pill-composer__right"
127
+ );
128
+ if (mic) rightActions.append(mic.wrapper);
129
+ rightActions.append(send.wrapper);
130
+
131
+ composerForm.addEventListener("click", (e) => {
132
+ if (
133
+ e.target !== send.button &&
134
+ e.target !== send.wrapper &&
135
+ e.target !== mic?.button &&
136
+ e.target !== mic?.wrapper &&
137
+ e.target !== attachment?.button &&
138
+ e.target !== attachment?.wrapper
139
+ ) {
140
+ textarea.focus();
141
+ }
142
+ });
143
+
144
+ if (attachment) composerForm.append(attachment.input);
145
+ composerForm.append(leftActions, textarea, rightActions);
146
+
147
+ // Footer assembly:
148
+ // [previews row, hidden until attachments exist]
149
+ // [pill form]
150
+ // [hidden suggestions]
151
+ // [hidden status]
152
+ if (attachment) footer.append(attachment.previewsContainer);
153
+ footer.append(composerForm, suggestions, statusText);
154
+
155
+ // The pill flattens left/right into the form's grid; there's no separate
156
+ // wrapper. Surface the form itself as `actionsRow` to satisfy the
157
+ // ComposerElements contract — downstream code only treats it as an
158
+ // opaque ref. `bindComposerRefsFromFooter` queries for the legacy
159
+ // `.persona-flex.persona-items-center.persona-justify-between` class
160
+ // selector and won't find one in pill mode; that lookup writes to
161
+ // `_actionsRow` (the underscore prefix marks it as soft-optional).
162
+ const actionsRow = composerForm;
163
+
164
+ return {
165
+ footer,
166
+ suggestions,
167
+ composerForm,
168
+ textarea,
169
+ sendButton: send.button,
170
+ sendButtonWrapper: send.wrapper,
171
+ micButton: mic?.button ?? null,
172
+ micButtonWrapper: mic?.wrapper ?? null,
173
+ statusText,
174
+ attachmentButton: attachment?.button ?? null,
175
+ attachmentButtonWrapper: attachment?.wrapper ?? null,
176
+ attachmentInput: attachment?.input ?? null,
177
+ attachmentPreviewsContainer: attachment?.previewsContainer ?? null,
178
+ actionsRow,
179
+ leftActions,
180
+ rightActions,
181
+ setSendButtonMode: send.setMode,
182
+ };
183
+ };