@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.
- package/README.md +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +281 -4
- package/dist/index.d.ts +281 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +1438 -619
- package/dist/theme-editor.d.cts +119 -1
- package/dist/theme-editor.d.ts +119 -1
- package/dist/theme-editor.js +1552 -619
- package/dist/widget.css +348 -0
- package/package.json +1 -1
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +262 -5
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/index.ts +4 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/styles/widget.css +348 -0
- package/src/types.ts +121 -1
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +809 -72
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/stream-animation.ts +7 -2
package/src/components/panel.ts
CHANGED
|
@@ -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
|
-
//
|
|
125
|
-
//
|
|
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-
|
|
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
|
-
//
|
|
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 {
|
package/src/runtime/init.ts
CHANGED
|
@@ -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
|
}
|