@runtypelabs/persona 4.14.0 → 4.15.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 +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +241 -16
- package/dist/context-mentions.d.ts +241 -16
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +67 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +301 -41
- package/dist/index.d.ts +301 -41
- package/dist/index.global.js +59 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -66
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +241 -16
- package/dist/smart-dom-reader.d.ts +241 -16
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +255 -17
- package/dist/theme-editor-preview.d.ts +255 -17
- package/dist/theme-editor-preview.js +56 -56
- package/dist/theme-editor.cjs +5 -5
- package/dist/theme-editor.d.cts +242 -17
- package/dist/theme-editor.d.ts +242 -17
- package/dist/theme-editor.js +7 -7
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +38 -0
- package/dist/theme-reference.d.ts +38 -0
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +31 -5
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +233 -0
- package/src/components/suggestions.ts +463 -81
- package/src/components/widget-view.ts +4 -0
- package/src/defaults.ts +5 -3
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +18 -2
- package/src/launcher-global.ts +1 -1
- package/src/plugins/types.ts +71 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +162 -0
- package/src/styles/widget.css +430 -0
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +53 -5
- package/src/theme-reference.ts +9 -2
- package/src/types/theme.ts +32 -0
- package/src/types.ts +152 -16
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.suggest-replies.test.ts +1066 -1
- package/src/ui.ts +268 -20
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/theme.test.ts +79 -0
- package/src/utils/tokens.ts +64 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
|
@@ -44,8 +44,59 @@ describe("createComposerTextarea", () => {
|
|
|
44
44
|
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
45
45
|
textarea.dispatchEvent(new Event("input"));
|
|
46
46
|
expect(textarea.style.height).toBe("200px");
|
|
47
|
+
// The override keeps winning on later input events (pill composer path).
|
|
48
|
+
textarea.dispatchEvent(new Event("input"));
|
|
49
|
+
expect(textarea.style.height).toBe("200px");
|
|
50
|
+
expect(textarea.style.maxHeight).toBe("200px");
|
|
51
|
+
document.body.removeChild(textarea);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("caps at 3 lines of the rendered line height when themed", () => {
|
|
55
|
+
const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
|
|
56
|
+
textarea.style.lineHeight = "24px";
|
|
57
|
+
document.body.appendChild(textarea);
|
|
58
|
+
attachAutoResize();
|
|
59
|
+
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
60
|
+
textarea.dispatchEvent(new Event("input"));
|
|
61
|
+
expect(textarea.style.height).toBe("72px");
|
|
62
|
+
expect(textarea.style.maxHeight).toBe("72px");
|
|
47
63
|
document.body.removeChild(textarea);
|
|
48
64
|
});
|
|
65
|
+
|
|
66
|
+
it("defaults to 60px (3 × 20px) before any resize", () => {
|
|
67
|
+
const { textarea } = createComposerTextarea(baseConfig);
|
|
68
|
+
expect(textarea.style.maxHeight).toBe("60px");
|
|
69
|
+
expect(textarea.style.overflowY).toBe("auto");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back to 60px when the line height is `normal`", () => {
|
|
73
|
+
const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
|
|
74
|
+
textarea.style.lineHeight = "normal";
|
|
75
|
+
document.body.appendChild(textarea);
|
|
76
|
+
attachAutoResize();
|
|
77
|
+
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
78
|
+
textarea.dispatchEvent(new Event("input"));
|
|
79
|
+
expect(textarea.style.height).toBe("60px");
|
|
80
|
+
document.body.removeChild(textarea);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to 60px for unitless line heights and detached nodes", () => {
|
|
84
|
+
const unitless = createComposerTextarea(baseConfig);
|
|
85
|
+
unitless.textarea.style.lineHeight = "1.5";
|
|
86
|
+
document.body.appendChild(unitless.textarea);
|
|
87
|
+
unitless.attachAutoResize();
|
|
88
|
+
Object.defineProperty(unitless.textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
89
|
+
unitless.textarea.dispatchEvent(new Event("input"));
|
|
90
|
+
expect(unitless.textarea.style.height).toBe("60px");
|
|
91
|
+
document.body.removeChild(unitless.textarea);
|
|
92
|
+
|
|
93
|
+
// Never appended: getComputedStyle still must not throw or produce NaN.
|
|
94
|
+
const detached = createComposerTextarea(baseConfig);
|
|
95
|
+
detached.attachAutoResize();
|
|
96
|
+
Object.defineProperty(detached.textarea, "scrollHeight", { configurable: true, value: 10000 });
|
|
97
|
+
detached.textarea.dispatchEvent(new Event("input"));
|
|
98
|
+
expect(detached.textarea.style.height).toBe("60px");
|
|
99
|
+
});
|
|
49
100
|
});
|
|
50
101
|
|
|
51
102
|
describe("createSendButton", () => {
|
|
@@ -97,10 +148,12 @@ describe("createSendButton", () => {
|
|
|
97
148
|
expect(s.borderWidth).toBe("2px");
|
|
98
149
|
expect(s.borderStyle).toBe("solid");
|
|
99
150
|
expect(s.borderColor).toBe("rgb(7, 8, 9)");
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(s.
|
|
103
|
-
expect(s.
|
|
151
|
+
// Icon mode zeroes padding even when configured: the glyph is drawn at
|
|
152
|
+
// the button size, so padding would crush it.
|
|
153
|
+
expect(s.paddingLeft).toBe("0px");
|
|
154
|
+
expect(s.paddingRight).toBe("0px");
|
|
155
|
+
expect(s.paddingTop).toBe("0px");
|
|
156
|
+
expect(s.paddingBottom).toBe("0px");
|
|
104
157
|
// An explicit backgroundColor means the fallback class is not added.
|
|
105
158
|
expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(false);
|
|
106
159
|
});
|
|
@@ -142,6 +195,52 @@ describe("createSendButton", () => {
|
|
|
142
195
|
expect(iconCount(send.button)).toBe(1);
|
|
143
196
|
});
|
|
144
197
|
|
|
198
|
+
it("zeroes padding so configured padding cannot crush the glyph", () => {
|
|
199
|
+
const send = createSendButton({
|
|
200
|
+
...baseConfig,
|
|
201
|
+
sendButton: {
|
|
202
|
+
useIcon: true,
|
|
203
|
+
iconName: "send",
|
|
204
|
+
size: "40px",
|
|
205
|
+
paddingX: "12px",
|
|
206
|
+
paddingY: "10px",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
expect(send.button.style.paddingLeft).toBe("0px");
|
|
210
|
+
expect(send.button.style.paddingTop).toBe("0px");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("draws the glyph at half the button by default and honors iconSize", () => {
|
|
214
|
+
const send = createSendButton({
|
|
215
|
+
...baseConfig,
|
|
216
|
+
sendButton: { useIcon: true, iconName: "send", size: "32px" },
|
|
217
|
+
});
|
|
218
|
+
expect(send.button.querySelector("svg")?.getAttribute("width")).toBe("16");
|
|
219
|
+
|
|
220
|
+
const sized = createSendButton({
|
|
221
|
+
...baseConfig,
|
|
222
|
+
sendButton: {
|
|
223
|
+
useIcon: true,
|
|
224
|
+
iconName: "send",
|
|
225
|
+
size: "32px",
|
|
226
|
+
iconSize: "26px",
|
|
227
|
+
iconStrokeWidth: 2.5,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const svg = sized.button.querySelector("svg");
|
|
231
|
+
expect(svg?.getAttribute("width")).toBe("26");
|
|
232
|
+
expect(svg?.getAttribute("stroke-width")).toBe("2.5");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("keeps configured padding in text mode", () => {
|
|
236
|
+
const send = createSendButton({
|
|
237
|
+
...baseConfig,
|
|
238
|
+
sendButton: { useIcon: false, paddingX: "12px", paddingY: "10px" },
|
|
239
|
+
});
|
|
240
|
+
expect(send.button.style.paddingLeft).toBe("12px");
|
|
241
|
+
expect(send.button.style.paddingTop).toBe("10px");
|
|
242
|
+
});
|
|
243
|
+
|
|
145
244
|
it("does not stack a stale icon when an external re-render swapped the live icon node", () => {
|
|
146
245
|
const send = createSendButton(iconConfig);
|
|
147
246
|
// Simulate a DOM morph/re-render (e.g. a host calling controller.update())
|
|
@@ -39,19 +39,47 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
|
|
|
39
39
|
'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
|
|
40
40
|
textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
|
|
41
41
|
|
|
42
|
-
// Auto-resize: expand up to 3 lines for the full composer
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
42
|
+
// Auto-resize: expand up to 3 lines for the full composer. The pill composer
|
|
43
|
+
// overrides this maxHeight after construction (allowing more growth in
|
|
44
|
+
// expanded mode), and the closure below honors whatever maxHeight is set at
|
|
45
|
+
// the time of the input event.
|
|
46
46
|
const defaultMaxLines = 3;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Used only when the rendered line height can't be resolved (detached node,
|
|
48
|
+
// jsdom, `normal`); matches the `persona-text-sm` / composer default.
|
|
49
|
+
const fallbackLineHeight = 20;
|
|
50
|
+
|
|
51
|
+
// `theme.components.composer.lineHeight` (--persona-composer-line-height) can
|
|
52
|
+
// change the rendered line height, so 3 lines is only correct when measured
|
|
53
|
+
// from the live element. Read lazily: at construction the textarea is usually
|
|
54
|
+
// still detached and getComputedStyle would report nothing useful.
|
|
55
|
+
const readLineHeight = (): number => {
|
|
56
|
+
const view = textarea.ownerDocument?.defaultView;
|
|
57
|
+
if (!view?.getComputedStyle) return fallbackLineHeight;
|
|
58
|
+
// Only px values are meaningful here: `normal` and unitless ratios aren't
|
|
59
|
+
// line box heights we can multiply.
|
|
60
|
+
const computed = view.getComputedStyle(textarea).lineHeight;
|
|
61
|
+
if (!computed || !computed.trim().endsWith("px")) return fallbackLineHeight;
|
|
62
|
+
const parsed = parseFloat(computed);
|
|
63
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackLineHeight;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Tracks the last maxHeight this module wrote, so a caller's post-construction
|
|
67
|
+
// override is distinguishable from our own default and always wins.
|
|
68
|
+
let managedMaxHeight = `${defaultMaxLines * fallbackLineHeight}px`;
|
|
69
|
+
textarea.style.maxHeight = managedMaxHeight;
|
|
49
70
|
textarea.style.overflowY = "auto";
|
|
50
71
|
|
|
51
72
|
// Read maxHeight at event time so callers can change it after construction.
|
|
52
73
|
const readMaxHeight = (): number => {
|
|
53
|
-
const
|
|
54
|
-
|
|
74
|
+
const current = textarea.style.maxHeight;
|
|
75
|
+
if (current !== managedMaxHeight) {
|
|
76
|
+
const overridden = parseFloat(current);
|
|
77
|
+
if (Number.isFinite(overridden) && overridden > 0) return overridden;
|
|
78
|
+
}
|
|
79
|
+
const derived = defaultMaxLines * readLineHeight();
|
|
80
|
+
managedMaxHeight = `${derived}px`;
|
|
81
|
+
textarea.style.maxHeight = managedMaxHeight;
|
|
82
|
+
return derived;
|
|
55
83
|
};
|
|
56
84
|
|
|
57
85
|
const attachAutoResize = () => {
|
|
@@ -142,10 +170,13 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
142
170
|
borderWidth: sendButtonConfig.borderWidth || undefined,
|
|
143
171
|
borderStyle: sendButtonConfig.borderWidth ? "solid" : undefined,
|
|
144
172
|
borderColor: sendButtonConfig.borderColor || undefined,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
// Padding is text-mode-only: icon mode is a fixed `size` box with the
|
|
174
|
+
// glyph drawn at that size, so padding just crushes the SVG (the
|
|
175
|
+
// always-present defaults squeezed a 40px glyph into 16x20).
|
|
176
|
+
paddingLeft: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
|
|
177
|
+
paddingRight: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
|
|
178
|
+
paddingTop: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
|
|
179
|
+
paddingBottom: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
|
|
149
180
|
},
|
|
150
181
|
});
|
|
151
182
|
|
|
@@ -154,11 +185,18 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
154
185
|
let stopIcon: SVGElement | null = null;
|
|
155
186
|
|
|
156
187
|
if (useIcon) {
|
|
157
|
-
|
|
188
|
+
// Default glyph box is half the button, the closest clean ratio to the
|
|
189
|
+
// pre-fix live rendering (a 40px glyph crushed to a ~16px content box).
|
|
190
|
+
// Dense glyphs like the default "send" plane fill most of their viewBox;
|
|
191
|
+
// sparse ones (arrow-up) may want a larger explicit `iconSize`.
|
|
192
|
+
const iconSize =
|
|
193
|
+
parseFloat(sendButtonConfig.iconSize ?? "") ||
|
|
194
|
+
Math.round((parseFloat(buttonSize) || 24) * 0.5);
|
|
195
|
+
const iconStroke = sendButtonConfig.iconStrokeWidth ?? 2;
|
|
158
196
|
const iconColor = textColor?.trim() || "currentColor";
|
|
159
197
|
|
|
160
198
|
if (iconName) {
|
|
161
|
-
sendIcon = renderLucideIcon(iconName, iconSize, iconColor,
|
|
199
|
+
sendIcon = renderLucideIcon(iconName, iconSize, iconColor, iconStroke);
|
|
162
200
|
if (sendIcon) {
|
|
163
201
|
button.appendChild(sendIcon);
|
|
164
202
|
} else {
|
|
@@ -168,7 +206,7 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
168
206
|
button.textContent = iconText;
|
|
169
207
|
}
|
|
170
208
|
|
|
171
|
-
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor,
|
|
209
|
+
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, iconStroke);
|
|
172
210
|
} else {
|
|
173
211
|
button.textContent = sendLabel;
|
|
174
212
|
}
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
isAskUserQuestionMessage,
|
|
10
10
|
removeAskUserQuestionSheet,
|
|
11
11
|
} from "./ask-user-question-bubble";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isSuggestRepliesMessage,
|
|
14
|
+
resolveFollowUpsFeature,
|
|
15
|
+
} from "../suggest-replies-tool";
|
|
13
16
|
|
|
14
17
|
export const renderMessages = (
|
|
15
18
|
container: HTMLElement,
|
|
@@ -43,7 +46,7 @@ export const renderMessages = (
|
|
|
43
46
|
return;
|
|
44
47
|
} else if (
|
|
45
48
|
isSuggestRepliesMessage(message) &&
|
|
46
|
-
config
|
|
49
|
+
resolveFollowUpsFeature(config).enabled
|
|
47
50
|
) {
|
|
48
51
|
// No transcript bubble: the chips above the composer are the only UI.
|
|
49
52
|
// When the feature is disabled the message falls through to the generic
|
package/src/components/panel.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement, createNode } from "../utils/dom";
|
|
2
2
|
import { DEFAULT_FLOATING_LAUNCHER_WIDTH } from "../defaults";
|
|
3
|
-
import { AgentWidgetConfig } from "../types";
|
|
3
|
+
import { AgentWidgetConfig, AgentWidgetSuggestionSurface } from "../types";
|
|
4
|
+
import { suggestionSurfaceAttr } from "./suggestions";
|
|
4
5
|
import { positionMap } from "../utils/positioning";
|
|
5
6
|
import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
|
|
6
7
|
import { DEFAULT_OVERLAY_Z_INDEX } from "../utils/constants";
|
|
@@ -132,6 +133,10 @@ export interface PanelElements {
|
|
|
132
133
|
container: HTMLElement;
|
|
133
134
|
body: HTMLElement;
|
|
134
135
|
messagesWrapper: HTMLElement;
|
|
136
|
+
/** Starter prompts rendered inside the welcome card. */
|
|
137
|
+
starterSuggestions: HTMLElement;
|
|
138
|
+
/** Follow-up suggestions rendered after the transcript. */
|
|
139
|
+
transcriptSuggestions: HTMLElement;
|
|
135
140
|
/**
|
|
136
141
|
* Absolute-positioned slot above the composer footer. Interactive sheets
|
|
137
142
|
* (e.g. the answer-pill sheet for the ask_user_question tool) mount here
|
|
@@ -178,6 +183,17 @@ export interface PanelElements {
|
|
|
178
183
|
peekTextNode?: HTMLElement;
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
const createSuggestionHost = (
|
|
187
|
+
surface: AgentWidgetSuggestionSurface
|
|
188
|
+
): HTMLElement => {
|
|
189
|
+
const host = createNode("div", {
|
|
190
|
+
className: "persona-suggestions",
|
|
191
|
+
attrs: { "data-persona-suggestions": suggestionSurfaceAttr(surface) },
|
|
192
|
+
});
|
|
193
|
+
host.hidden = true;
|
|
194
|
+
return host;
|
|
195
|
+
};
|
|
196
|
+
|
|
181
197
|
/**
|
|
182
198
|
* Composer-bar panel: minimal close-only header (× in the top-right of the
|
|
183
199
|
* chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
|
|
@@ -276,6 +292,7 @@ const buildComposerBarPanel = (
|
|
|
276
292
|
config?.copy?.welcomeSubtitle ??
|
|
277
293
|
"Ask anything about your account or products.",
|
|
278
294
|
});
|
|
295
|
+
const starterSuggestions = createSuggestionHost("starter");
|
|
279
296
|
const introCard = createNode(
|
|
280
297
|
"div",
|
|
281
298
|
{
|
|
@@ -287,13 +304,15 @@ const buildComposerBarPanel = (
|
|
|
287
304
|
},
|
|
288
305
|
},
|
|
289
306
|
introTitle,
|
|
290
|
-
introSubtitle
|
|
307
|
+
introSubtitle,
|
|
308
|
+
starterSuggestions
|
|
291
309
|
);
|
|
292
310
|
|
|
293
311
|
const messagesWrapper = createElement(
|
|
294
312
|
"div",
|
|
295
313
|
"persona-flex persona-flex-col persona-gap-3"
|
|
296
314
|
);
|
|
315
|
+
const transcriptSuggestions = createSuggestionHost("followUp");
|
|
297
316
|
const contentMaxWidth = config?.layout?.contentMaxWidth;
|
|
298
317
|
if (contentMaxWidth) {
|
|
299
318
|
messagesWrapper.style.maxWidth = contentMaxWidth;
|
|
@@ -308,7 +327,7 @@ const buildComposerBarPanel = (
|
|
|
308
327
|
body.classList.remove("persona-gap-6");
|
|
309
328
|
body.classList.add("persona-gap-3");
|
|
310
329
|
}
|
|
311
|
-
body.append(introCard, messagesWrapper);
|
|
330
|
+
body.append(introCard, messagesWrapper, transcriptSuggestions);
|
|
312
331
|
|
|
313
332
|
// Composer overlay (interactive sheets like ask_user_question slide up here).
|
|
314
333
|
// Anchored to the bottom of the container (which is `position: relative`),
|
|
@@ -338,6 +357,8 @@ const buildComposerBarPanel = (
|
|
|
338
357
|
container,
|
|
339
358
|
body,
|
|
340
359
|
messagesWrapper,
|
|
360
|
+
starterSuggestions,
|
|
361
|
+
transcriptSuggestions,
|
|
341
362
|
composerOverlay,
|
|
342
363
|
suggestions: composerElements.suggestions,
|
|
343
364
|
textarea: composerElements.textarea,
|
|
@@ -413,6 +434,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
413
434
|
config?.copy?.welcomeSubtitle ??
|
|
414
435
|
"Ask anything about your account or products.",
|
|
415
436
|
});
|
|
437
|
+
const starterSuggestions = createSuggestionHost("starter");
|
|
416
438
|
// Background and box-shadow flow through the themable `components.introCard`
|
|
417
439
|
// tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
|
|
418
440
|
// default to flat (transparent / none). Docked mode always stays flat,
|
|
@@ -430,13 +452,15 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
430
452
|
},
|
|
431
453
|
},
|
|
432
454
|
introTitle,
|
|
433
|
-
introSubtitle
|
|
455
|
+
introSubtitle,
|
|
456
|
+
starterSuggestions
|
|
434
457
|
);
|
|
435
458
|
|
|
436
459
|
const messagesWrapper = createElement(
|
|
437
460
|
"div",
|
|
438
461
|
"persona-flex persona-flex-col persona-gap-3"
|
|
439
462
|
);
|
|
463
|
+
const transcriptSuggestions = createSuggestionHost("followUp");
|
|
440
464
|
|
|
441
465
|
const contentMaxWidth = config?.layout?.contentMaxWidth;
|
|
442
466
|
if (contentMaxWidth) {
|
|
@@ -452,7 +476,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
452
476
|
body.classList.remove("persona-gap-6");
|
|
453
477
|
body.classList.add("persona-gap-3");
|
|
454
478
|
}
|
|
455
|
-
body.append(introCard, messagesWrapper);
|
|
479
|
+
body.append(introCard, messagesWrapper, transcriptSuggestions);
|
|
456
480
|
|
|
457
481
|
// composer-bar mode early-returned above with its own pill builder; this
|
|
458
482
|
// path always uses the standard column-stacked composer card.
|
|
@@ -500,6 +524,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
500
524
|
container,
|
|
501
525
|
body,
|
|
502
526
|
messagesWrapper,
|
|
527
|
+
starterSuggestions,
|
|
528
|
+
transcriptSuggestions,
|
|
503
529
|
composerOverlay,
|
|
504
530
|
suggestions: composerElements.suggestions,
|
|
505
531
|
textarea: composerElements.textarea,
|
|
@@ -79,6 +79,24 @@ describe("buildPillComposer (single-row pill composer)", () => {
|
|
|
79
79
|
expect(formChildren[2]).toBe(elements.rightActions);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("keeps its 100px max-height cap, even under a themed line height", () => {
|
|
83
|
+
const elements = buildPillComposer({ config: { apiUrl: "/api" } });
|
|
84
|
+
expect(elements.textarea.style.maxHeight).toBe("100px");
|
|
85
|
+
|
|
86
|
+
// A themed line height must not re-derive the cap: the pill's explicit
|
|
87
|
+
// post-construction override wins over the 3-line default.
|
|
88
|
+
elements.textarea.style.lineHeight = "24px";
|
|
89
|
+
document.body.appendChild(elements.textarea);
|
|
90
|
+
Object.defineProperty(elements.textarea, "scrollHeight", {
|
|
91
|
+
configurable: true,
|
|
92
|
+
value: 10000,
|
|
93
|
+
});
|
|
94
|
+
elements.textarea.dispatchEvent(new Event("input"));
|
|
95
|
+
expect(elements.textarea.style.maxHeight).toBe("100px");
|
|
96
|
+
expect(elements.textarea.style.height).toBe("100px");
|
|
97
|
+
document.body.removeChild(elements.textarea);
|
|
98
|
+
});
|
|
99
|
+
|
|
82
100
|
it("returns null for optional controls when disabled (matching ComposerElements contract)", () => {
|
|
83
101
|
const elements = buildPillComposer({ config: { apiUrl: "/api" } });
|
|
84
102
|
expect(elements.attachmentButton).toBeNull();
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createSuggestions } from "./suggestions";
|
|
6
|
+
import type { AgentWidgetSession } from "../session";
|
|
7
|
+
|
|
8
|
+
const makeSession = (): AgentWidgetSession =>
|
|
9
|
+
({
|
|
10
|
+
isStreaming: () => false,
|
|
11
|
+
getMessages: () => [],
|
|
12
|
+
sendMessage: () => {},
|
|
13
|
+
}) as unknown as AgentWidgetSession;
|
|
14
|
+
|
|
15
|
+
const setup = () => {
|
|
16
|
+
const container = document.createElement("div");
|
|
17
|
+
document.body.appendChild(container);
|
|
18
|
+
const textarea = document.createElement("textarea");
|
|
19
|
+
document.body.appendChild(textarea);
|
|
20
|
+
return {
|
|
21
|
+
container,
|
|
22
|
+
textarea,
|
|
23
|
+
manager: createSuggestions(container),
|
|
24
|
+
session: makeSession(),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const withAnimate = (): ReturnType<typeof vi.fn> => {
|
|
29
|
+
const animate = vi.fn(() => ({}));
|
|
30
|
+
Object.defineProperty(Element.prototype, "animate", {
|
|
31
|
+
value: animate,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
});
|
|
35
|
+
return animate;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
Reflect.deleteProperty(Element.prototype, "animate");
|
|
40
|
+
vi.unstubAllGlobals();
|
|
41
|
+
document.body.replaceChildren();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("suggestion entrance animation", () => {
|
|
45
|
+
it("animates each item once for a newly shown set", () => {
|
|
46
|
+
const animate = withAnimate();
|
|
47
|
+
const { manager, session, textarea } = setup();
|
|
48
|
+
|
|
49
|
+
manager.render(["One", "Two"], session, textarea, [], undefined, {
|
|
50
|
+
variant: "card",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(animate).toHaveBeenCalledTimes(2);
|
|
54
|
+
const [, secondOptions] = animate.mock.calls[1] as [
|
|
55
|
+
unknown,
|
|
56
|
+
{ delay: number; duration: number; fill: string },
|
|
57
|
+
];
|
|
58
|
+
expect(secondOptions.delay).toBe(60);
|
|
59
|
+
expect(secondOptions.duration).toBe(250);
|
|
60
|
+
expect(secondOptions.fill).toBe("backwards");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not re-animate when render repeats the same items", () => {
|
|
64
|
+
const animate = withAnimate();
|
|
65
|
+
const { manager, session, textarea } = setup();
|
|
66
|
+
const items = ["One", "Two"];
|
|
67
|
+
|
|
68
|
+
manager.render(items, session, textarea, [], undefined, {
|
|
69
|
+
variant: "card",
|
|
70
|
+
});
|
|
71
|
+
manager.render(items, session, textarea, [], undefined, {
|
|
72
|
+
variant: "card",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(animate).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("animates again once the item set changes", () => {
|
|
79
|
+
const animate = withAnimate();
|
|
80
|
+
const { manager, session, textarea } = setup();
|
|
81
|
+
|
|
82
|
+
manager.render(["One"], session, textarea, [], undefined, {
|
|
83
|
+
variant: "card",
|
|
84
|
+
});
|
|
85
|
+
manager.render(["Two"], session, textarea, [], undefined, {
|
|
86
|
+
variant: "card",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(animate).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("skips the animation under prefers-reduced-motion", () => {
|
|
93
|
+
const animate = withAnimate();
|
|
94
|
+
vi.stubGlobal(
|
|
95
|
+
"matchMedia",
|
|
96
|
+
vi.fn(() => ({ matches: true }))
|
|
97
|
+
);
|
|
98
|
+
const { manager, session, textarea } = setup();
|
|
99
|
+
|
|
100
|
+
manager.render(["One", "Two"], session, textarea, [], undefined, {
|
|
101
|
+
variant: "card",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(animate).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("still renders when the platform has no Web Animations API", () => {
|
|
108
|
+
const { container, manager, session, textarea } = setup();
|
|
109
|
+
|
|
110
|
+
expect(typeof Element.prototype.animate).toBe("undefined");
|
|
111
|
+
expect(() =>
|
|
112
|
+
manager.render(["One", "Two"], session, textarea, [], undefined, {
|
|
113
|
+
variant: "card",
|
|
114
|
+
})
|
|
115
|
+
).not.toThrow();
|
|
116
|
+
expect(container.querySelectorAll(".persona-suggestion")).toHaveLength(2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("per-item icon color", () => {
|
|
121
|
+
const iconOf = (container: HTMLElement): SVGElement =>
|
|
122
|
+
container.querySelector(".persona-suggestion__icon") as SVGElement;
|
|
123
|
+
|
|
124
|
+
it("tints only the icon element", () => {
|
|
125
|
+
const { manager, session, textarea, container } = setup();
|
|
126
|
+
|
|
127
|
+
manager.render(
|
|
128
|
+
[{ label: "Compare plans", icon: "dollar-sign", iconColor: "#16a34a" }],
|
|
129
|
+
session,
|
|
130
|
+
textarea,
|
|
131
|
+
[],
|
|
132
|
+
undefined,
|
|
133
|
+
{ variant: "card" }
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const button = container.querySelector("button") as HTMLButtonElement;
|
|
137
|
+
expect(iconOf(container).style.color).toBe("rgb(22, 163, 74)");
|
|
138
|
+
expect(button.style.color).toBe("");
|
|
139
|
+
expect(button.style.borderColor).toBe("");
|
|
140
|
+
expect(button.style.background).toBe("");
|
|
141
|
+
expect(
|
|
142
|
+
(container.querySelector(".persona-suggestion__label") as HTMLElement)
|
|
143
|
+
.style.color
|
|
144
|
+
).toBe("");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("wins over the emphasis-primary icon tint without recoloring the button", () => {
|
|
148
|
+
const { manager, session, textarea, container } = setup();
|
|
149
|
+
|
|
150
|
+
manager.render(
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
label: "Compare plans",
|
|
154
|
+
icon: "dollar-sign",
|
|
155
|
+
iconColor: "#16a34a",
|
|
156
|
+
emphasis: "primary",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
session,
|
|
160
|
+
textarea,
|
|
161
|
+
[],
|
|
162
|
+
undefined,
|
|
163
|
+
{ variant: "card" }
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const button = container.querySelector("button") as HTMLButtonElement;
|
|
167
|
+
expect(button.dataset.emphasis).toBe("primary");
|
|
168
|
+
expect(iconOf(container).style.color).toBe("rgb(22, 163, 74)");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("leaves the icon in currentColor when iconColor is absent", () => {
|
|
172
|
+
const { manager, session, textarea, container } = setup();
|
|
173
|
+
|
|
174
|
+
manager.render(
|
|
175
|
+
[{ label: "Compare plans", icon: "dollar-sign" }],
|
|
176
|
+
session,
|
|
177
|
+
textarea,
|
|
178
|
+
[],
|
|
179
|
+
undefined,
|
|
180
|
+
{ variant: "card" }
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const icon = iconOf(container);
|
|
184
|
+
expect(icon.style.color).toBe("");
|
|
185
|
+
expect(icon.getAttribute("stroke")).toBe("currentColor");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("leaves string shorthand items unaffected", () => {
|
|
189
|
+
const { manager, session, textarea, container } = setup();
|
|
190
|
+
|
|
191
|
+
manager.render(["Compare plans"], session, textarea, [], undefined, {
|
|
192
|
+
variant: "card",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(container.querySelector(".persona-suggestion__icon")).toBeNull();
|
|
196
|
+
const button = container.querySelector("button") as HTMLButtonElement;
|
|
197
|
+
expect(button.style.color).toBe("");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("legacy suggestionChipsConfig scoping", () => {
|
|
202
|
+
const chipsConfig = {
|
|
203
|
+
fontFamily: "sans-serif" as const,
|
|
204
|
+
fontWeight: "500",
|
|
205
|
+
paddingX: "12px",
|
|
206
|
+
paddingY: "6px",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
it("applies inline chip styles to the chip variant", () => {
|
|
210
|
+
const { manager, session, textarea, container } = setup();
|
|
211
|
+
|
|
212
|
+
manager.render(["One"], session, textarea, [], chipsConfig, {
|
|
213
|
+
variant: "chip",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const button = container.querySelector("button") as HTMLButtonElement;
|
|
217
|
+
expect(button.style.paddingLeft).toBe("12px");
|
|
218
|
+
expect(button.style.paddingTop).toBe("6px");
|
|
219
|
+
expect(button.style.fontWeight).toBe("500");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("does not let chip config override card and list padding tokens", () => {
|
|
223
|
+
const { manager, session, textarea, container } = setup();
|
|
224
|
+
|
|
225
|
+
for (const variant of ["card", "list"] as const) {
|
|
226
|
+
manager.render(["One"], session, textarea, [], chipsConfig, { variant });
|
|
227
|
+
const button = container.querySelector("button") as HTMLButtonElement;
|
|
228
|
+
expect(button.style.paddingLeft).toBe("");
|
|
229
|
+
expect(button.style.paddingTop).toBe("");
|
|
230
|
+
expect(button.style.fontFamily).toBe("");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|