@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.
- package/README.md +143 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
- package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -4
- package/dist/index.d.ts +580 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +2844 -752
- package/dist/theme-editor.d.cts +337 -1
- package/dist/theme-editor.d.ts +337 -1
- package/dist/theme-editor.js +2958 -752
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +780 -0
- package/package.json +1 -1
- package/src/client.test.ts +134 -0
- package/src/client.ts +71 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- 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/messages.ts +33 -1
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +303 -9
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/defaults.ts +21 -0
- package/src/index.ts +20 -1
- package/src/plugins/types.ts +57 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +780 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +271 -1
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +1439 -76
- 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/storage.ts +10 -2
- package/src/utils/stream-animation.ts +7 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
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(
|
|
@@ -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
|
-
//
|
|
119
|
-
//
|
|
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-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
+
};
|