@runtypelabs/persona 3.18.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +281 -4
  5. package/dist/index.d.ts +281 -4
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1438 -619
  11. package/dist/theme-editor.d.cts +119 -1
  12. package/dist/theme-editor.d.ts +119 -1
  13. package/dist/theme-editor.js +1552 -619
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +4 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/styles/widget.css +348 -0
  30. package/src/types.ts +121 -1
  31. package/src/ui.component-directive.test.ts +183 -0
  32. package/src/ui.composer-bar.test.ts +1009 -0
  33. package/src/ui.ts +809 -72
  34. package/src/utils/attachment-manager.ts +1 -1
  35. package/src/utils/dock.test.ts +45 -0
  36. package/src/utils/dock.ts +3 -0
  37. package/src/utils/icons.ts +314 -58
  38. package/src/utils/stream-animation.ts +7 -2
@@ -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(
@@ -118,14 +170,218 @@ export interface PanelElements {
118
170
  rightActions: HTMLElement;
119
171
  /** Swap the send button between its send and stop appearances. */
120
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;
121
180
  }
122
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
+
123
373
  export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
124
- // Use flex-1 and min-h-0 to ensure the container fills its parent and allows
125
- // 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
+
126
382
  const container = createElement(
127
383
  "div",
128
- "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"
129
385
  );
130
386
  container.setAttribute("data-persona-theme-zone", "container");
131
387
 
@@ -191,7 +447,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
191
447
  }
192
448
  body.append(introCard, messagesWrapper);
193
449
 
194
- // 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.
195
452
  const composerElements: ComposerElements = buildComposer({ config });
196
453
  const showFooter = config?.layout?.showFooter !== false; // default to true
197
454
 
@@ -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
+ };
package/src/index.ts CHANGED
@@ -215,6 +215,10 @@ export type {
215
215
  export { createDropdownMenu } from "./utils/dropdown";
216
216
  export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
217
217
 
218
+ // Icon utility exports
219
+ export { renderLucideIcon } from "./utils/icons";
220
+ export type { IconName } from "./utils/icons";
221
+
218
222
  // Button utility exports
219
223
  export { createIconButton, createLabelButton, createToggleGroup, createComboButton } from "./utils/buttons";
220
224
  export type {
@@ -1,6 +1,6 @@
1
1
  import { createAgentExperience, AgentWidgetController } from "../ui";
2
2
  import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
3
- import { isDockedMountMode } from "../utils/dock";
3
+ import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
4
4
  import { createWidgetHostLayout } from "./host-layout";
5
5
 
6
6
  const ensureTarget = (target: string | HTMLElement): HTMLElement => {
@@ -183,8 +183,10 @@ export const initAgentWidget = (
183
183
  } as _AgentWidgetConfig;
184
184
  const previousDocked = isDockedMountMode(config);
185
185
  const nextDocked = isDockedMountMode(mergedConfig);
186
+ const previousComposerBar = isComposerBarMountMode(config);
187
+ const nextComposerBar = isComposerBarMountMode(mergedConfig);
186
188
 
187
- if (previousDocked !== nextDocked) {
189
+ if (previousDocked !== nextDocked || previousComposerBar !== nextComposerBar) {
188
190
  rebuildLayout(mergedConfig);
189
191
  return;
190
192
  }