@runtypelabs/persona 3.17.0 → 3.18.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 +142 -0
- 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 +300 -1
- package/dist/index.d.ts +300 -1
- package/dist/index.global.js +75 -75
- 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 +1432 -159
- package/dist/theme-editor.d.cts +218 -0
- package/dist/theme-editor.d.ts +218 -0
- package/dist/theme-editor.js +1432 -159
- 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 +432 -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/messages.ts +33 -1
- package/src/components/panel.ts +41 -4
- package/src/defaults.ts +21 -0
- package/src/index.ts +16 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +432 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +150 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.ts +631 -5
- package/src/utils/storage.ts +10 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
ASK_USER_QUESTION_MAX,
|
|
6
|
+
ASK_USER_QUESTION_TOOL_NAME,
|
|
7
|
+
__resetTruncateWarn,
|
|
8
|
+
buildStructuredAnswers,
|
|
9
|
+
createAskUserQuestionBubble,
|
|
10
|
+
ensureAskUserQuestionSheet,
|
|
11
|
+
getCurrentIndex,
|
|
12
|
+
getQuestionCount,
|
|
13
|
+
getSelectedLabels,
|
|
14
|
+
isAskUserQuestionMessage,
|
|
15
|
+
isGroupedSheet,
|
|
16
|
+
navigateToPage,
|
|
17
|
+
readAnswersFromSheet,
|
|
18
|
+
removeAskUserQuestionSheet,
|
|
19
|
+
setCurrentAnswer,
|
|
20
|
+
} from "./ask-user-question-bubble";
|
|
21
|
+
import type {
|
|
22
|
+
AgentWidgetConfig,
|
|
23
|
+
AgentWidgetMessage,
|
|
24
|
+
AskUserQuestionPrompt,
|
|
25
|
+
} from "../types";
|
|
26
|
+
|
|
27
|
+
const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
|
|
28
|
+
id: "msg-1",
|
|
29
|
+
role: "assistant",
|
|
30
|
+
content: "",
|
|
31
|
+
createdAt: new Date().toISOString(),
|
|
32
|
+
variant: "tool",
|
|
33
|
+
streaming: false,
|
|
34
|
+
toolCall: {
|
|
35
|
+
id: "tool-1",
|
|
36
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
37
|
+
status: "complete",
|
|
38
|
+
args: {
|
|
39
|
+
questions: [
|
|
40
|
+
{
|
|
41
|
+
question: "Who shops on your site?",
|
|
42
|
+
options: [
|
|
43
|
+
{ label: "Hobbyists" },
|
|
44
|
+
{ label: "Professionals" },
|
|
45
|
+
{ label: "Gift-seekers" },
|
|
46
|
+
],
|
|
47
|
+
multiSelect: false,
|
|
48
|
+
allowFreeText: true,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
chunks: [],
|
|
53
|
+
},
|
|
54
|
+
...overrides,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const makeOverlay = () => {
|
|
58
|
+
const overlay = document.createElement("div");
|
|
59
|
+
overlay.setAttribute("data-persona-composer-overlay", "");
|
|
60
|
+
document.body.innerHTML = "";
|
|
61
|
+
document.body.appendChild(overlay);
|
|
62
|
+
return overlay;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("isAskUserQuestionMessage", () => {
|
|
66
|
+
it("is true for a tool-variant message whose toolCall.name is ask_user_question", () => {
|
|
67
|
+
expect(isAskUserQuestionMessage(makeMessage())).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is false for other tool calls", () => {
|
|
71
|
+
const other = makeMessage({
|
|
72
|
+
toolCall: {
|
|
73
|
+
id: "t2",
|
|
74
|
+
name: "search_products",
|
|
75
|
+
status: "complete",
|
|
76
|
+
chunks: [],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(isAskUserQuestionMessage(other)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is false for non-tool variants", () => {
|
|
83
|
+
const notTool = makeMessage({ variant: undefined, toolCall: undefined });
|
|
84
|
+
expect(isAskUserQuestionMessage(notTool)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("createAskUserQuestionBubble", () => {
|
|
89
|
+
it("renders a compact transcript stub carrying the message id", () => {
|
|
90
|
+
const bubble = createAskUserQuestionBubble(makeMessage());
|
|
91
|
+
expect(bubble.getAttribute("data-message-id")).toBe("msg-1");
|
|
92
|
+
expect(bubble.getAttribute("data-bubble-type")).toBe("ask-user-question");
|
|
93
|
+
expect(bubble.textContent).toContain("Awaiting");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("ensureAskUserQuestionSheet", () => {
|
|
98
|
+
it("mounts a sheet in the overlay with real pills when args are complete", () => {
|
|
99
|
+
const overlay = makeOverlay();
|
|
100
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
101
|
+
|
|
102
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]');
|
|
103
|
+
expect(sheet).not.toBeNull();
|
|
104
|
+
const pills = sheet!.querySelectorAll('[data-ask-user-action="pick"]');
|
|
105
|
+
expect(pills.length).toBe(3);
|
|
106
|
+
// Default rows layout — label is in `.persona-ask-row-label`, alongside
|
|
107
|
+
// a number-badge affordance. Read the dedicated label slot.
|
|
108
|
+
const firstLabel = pills[0].querySelector(".persona-ask-row-label");
|
|
109
|
+
expect(firstLabel?.textContent).toBe("Hobbyists");
|
|
110
|
+
// Free-text affordance present by default — rows mode embeds the input
|
|
111
|
+
// inside the Other row via `focus-free-text` (digit shortcut + click chrome).
|
|
112
|
+
const custom = sheet!.querySelector('[data-ask-user-action="focus-free-text"]');
|
|
113
|
+
expect(custom).not.toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders skeleton pills while streaming (status=running, no parsable chunks)", () => {
|
|
117
|
+
const overlay = makeOverlay();
|
|
118
|
+
const streaming = makeMessage({
|
|
119
|
+
toolCall: {
|
|
120
|
+
id: "tool-streaming",
|
|
121
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
122
|
+
status: "running",
|
|
123
|
+
chunks: ['{"ques'],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
ensureAskUserQuestionSheet(streaming, {} as AgentWidgetConfig, overlay);
|
|
127
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-streaming"]');
|
|
128
|
+
expect(sheet).not.toBeNull();
|
|
129
|
+
const skeletons = sheet!.querySelectorAll(".persona-ask-pill-skeleton");
|
|
130
|
+
expect(skeletons.length).toBeGreaterThan(0);
|
|
131
|
+
const realPills = sheet!.querySelectorAll('[data-ask-user-action="pick"]');
|
|
132
|
+
expect(realPills.length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("hydrates skeleton pills into real pills when streaming chunks become parsable", () => {
|
|
136
|
+
const overlay = makeOverlay();
|
|
137
|
+
const msg = makeMessage({
|
|
138
|
+
toolCall: {
|
|
139
|
+
id: "tool-hyd",
|
|
140
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
141
|
+
status: "running",
|
|
142
|
+
chunks: ['{"questions":[{"question":"X","options":['],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
146
|
+
|
|
147
|
+
// Simulate more chunks arriving — options now parseable
|
|
148
|
+
msg.toolCall!.chunks = ['{"questions":[{"question":"X","options":[{"label":"A"},{"label":"B"}'];
|
|
149
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
150
|
+
|
|
151
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-hyd"]')!;
|
|
152
|
+
const pills = sheet.querySelectorAll('[data-ask-user-action="pick"]');
|
|
153
|
+
expect(pills.length).toBe(2);
|
|
154
|
+
expect(pills[0].querySelector(".persona-ask-row-label")?.textContent).toBe("A");
|
|
155
|
+
expect(pills[1].querySelector(".persona-ask-row-label")?.textContent).toBe("B");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("is idempotent — re-invoking does not duplicate sheets", () => {
|
|
159
|
+
const overlay = makeOverlay();
|
|
160
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
161
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
162
|
+
const sheets = overlay.querySelectorAll('[data-persona-ask-sheet-for]');
|
|
163
|
+
expect(sheets.length).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("respects enabled: false — sheet is not mounted", () => {
|
|
167
|
+
const overlay = makeOverlay();
|
|
168
|
+
ensureAskUserQuestionSheet(
|
|
169
|
+
makeMessage(),
|
|
170
|
+
{ features: { askUserQuestion: { enabled: false } } } as AgentWidgetConfig,
|
|
171
|
+
overlay
|
|
172
|
+
);
|
|
173
|
+
expect(overlay.querySelector('[data-persona-ask-sheet-for]')).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("omits the free-text pill when allowFreeText is false", () => {
|
|
177
|
+
const overlay = makeOverlay();
|
|
178
|
+
const msg = makeMessage();
|
|
179
|
+
(msg.toolCall!.args as any).questions[0].allowFreeText = false;
|
|
180
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
181
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
182
|
+
expect(sheet.querySelector('[data-ask-user-action="open-free-text"]')).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("renders a multi-select submit row when multiSelect is true", () => {
|
|
186
|
+
const overlay = makeOverlay();
|
|
187
|
+
const msg = makeMessage();
|
|
188
|
+
(msg.toolCall!.args as any).questions[0].multiSelect = true;
|
|
189
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
190
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
191
|
+
expect(sheet.querySelector('[data-ask-user-action="submit-multi"]')).not.toBeNull();
|
|
192
|
+
expect(sheet.getAttribute("data-multi-select")).toBe("true");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not render a dismiss button — Skip in the nav row is the canonical escape", () => {
|
|
196
|
+
const overlay = makeOverlay();
|
|
197
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
198
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
199
|
+
expect(sheet.querySelector('[data-ask-user-action="dismiss"]')).toBeNull();
|
|
200
|
+
expect(sheet.querySelector(".persona-ask-sheet-close")).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("getSelectedLabels", () => {
|
|
205
|
+
it("collects labels from pills with aria-pressed=true", () => {
|
|
206
|
+
const overlay = makeOverlay();
|
|
207
|
+
const msg = makeMessage();
|
|
208
|
+
(msg.toolCall!.args as any).questions[0].multiSelect = true;
|
|
209
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
210
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
211
|
+
const pills = sheet.querySelectorAll<HTMLElement>('[data-ask-user-action="pick"]');
|
|
212
|
+
pills[0].setAttribute("aria-pressed", "true");
|
|
213
|
+
pills[2].setAttribute("aria-pressed", "true");
|
|
214
|
+
expect(getSelectedLabels(sheet as HTMLElement)).toEqual(["Hobbyists", "Gift-seekers"]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("removeAskUserQuestionSheet", () => {
|
|
219
|
+
it("removes the sheet for a specific tool-call id after the slide-out delay", async () => {
|
|
220
|
+
const overlay = makeOverlay();
|
|
221
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
222
|
+
expect(overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')).not.toBeNull();
|
|
223
|
+
removeAskUserQuestionSheet(overlay, "tool-1");
|
|
224
|
+
// The implementation defers removal with setTimeout — flush it.
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
226
|
+
expect(overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Grouped (paginated) multi-question payloads
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
const makeGroupedMessage = (
|
|
235
|
+
questions: Partial<AskUserQuestionPrompt>[],
|
|
236
|
+
agentMetadata?: AgentWidgetMessage["agentMetadata"]
|
|
237
|
+
): AgentWidgetMessage =>
|
|
238
|
+
makeMessage({
|
|
239
|
+
toolCall: {
|
|
240
|
+
id: "tool-grouped",
|
|
241
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
242
|
+
status: "complete",
|
|
243
|
+
args: { questions },
|
|
244
|
+
chunks: [],
|
|
245
|
+
},
|
|
246
|
+
agentMetadata,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const sheetFor = (overlay: Element, toolCallId: string): HTMLElement =>
|
|
250
|
+
overlay.querySelector<HTMLElement>(`[data-persona-ask-sheet-for="${toolCallId}"]`)!;
|
|
251
|
+
|
|
252
|
+
describe("grouped questions — stepper UI", () => {
|
|
253
|
+
it("renders 'Question 1 of N' chip and Back/Next nav row when N > 1", () => {
|
|
254
|
+
const overlay = makeOverlay();
|
|
255
|
+
const msg = makeGroupedMessage([
|
|
256
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
257
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
258
|
+
{ question: "Q3?", options: [{ label: "E" }, { label: "F" }] },
|
|
259
|
+
]);
|
|
260
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
261
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
262
|
+
|
|
263
|
+
expect(isGroupedSheet(sheet)).toBe(true);
|
|
264
|
+
expect(getQuestionCount(sheet)).toBe(3);
|
|
265
|
+
expect(getCurrentIndex(sheet)).toBe(0);
|
|
266
|
+
|
|
267
|
+
const stepInline = sheet.querySelector('[data-ask-step-inline="true"]');
|
|
268
|
+
expect(stepInline?.textContent).toBe("1/3");
|
|
269
|
+
|
|
270
|
+
expect(sheet.querySelector('[data-ask-user-action="back"]')).not.toBeNull();
|
|
271
|
+
expect(sheet.querySelector('[data-ask-user-action="next"]')).not.toBeNull();
|
|
272
|
+
expect(sheet.querySelector('[data-ask-user-action="submit-all"]')).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("leaves the inline stepper empty in single-Q mode (no grouped UI)", () => {
|
|
276
|
+
const overlay = makeOverlay();
|
|
277
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
278
|
+
const sheet = sheetFor(overlay, "tool-1");
|
|
279
|
+
|
|
280
|
+
expect(isGroupedSheet(sheet)).toBe(false);
|
|
281
|
+
const stepInline = sheet.querySelector('[data-ask-step-inline="true"]');
|
|
282
|
+
expect(stepInline?.textContent).toBe("");
|
|
283
|
+
expect(sheet.querySelector('[data-ask-nav-row="true"]')).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("Next is disabled until current page has an answer; enabled after pick", () => {
|
|
287
|
+
const overlay = makeOverlay();
|
|
288
|
+
const msg = makeGroupedMessage([
|
|
289
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
290
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
291
|
+
]);
|
|
292
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
293
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
294
|
+
const next = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="next"]')!;
|
|
295
|
+
expect(next.disabled).toBe(true);
|
|
296
|
+
|
|
297
|
+
setCurrentAnswer(sheet, "A");
|
|
298
|
+
expect(next.disabled).toBe(false);
|
|
299
|
+
const pillA = sheet.querySelector<HTMLElement>('[data-option-label="A"]');
|
|
300
|
+
expect(pillA?.getAttribute("aria-pressed")).toBe("true");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("navigateToPage(1) shows page 2 and Back becomes enabled; Submit-all on final page", () => {
|
|
304
|
+
const overlay = makeOverlay();
|
|
305
|
+
const msg = makeGroupedMessage([
|
|
306
|
+
{ question: "Q1?", options: [{ label: "A" }] },
|
|
307
|
+
{ question: "Q2?", options: [{ label: "B" }] },
|
|
308
|
+
]);
|
|
309
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
310
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
311
|
+
setCurrentAnswer(sheet, "A");
|
|
312
|
+
navigateToPage(sheet, msg, undefined, 1);
|
|
313
|
+
|
|
314
|
+
const back = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="back"]')!;
|
|
315
|
+
expect(back.disabled).toBe(false);
|
|
316
|
+
const submitAll = sheet.querySelector('[data-ask-user-action="submit-all"]');
|
|
317
|
+
expect(submitAll).not.toBeNull();
|
|
318
|
+
expect(sheet.querySelector('[data-ask-user-action="next"]')).toBeNull();
|
|
319
|
+
|
|
320
|
+
const stepInline = sheet.querySelector('[data-ask-step-inline="true"]');
|
|
321
|
+
expect(stepInline?.textContent).toBe("2/2");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("Back from page 2 → page 1 preserves the prior answer's selected pill state", () => {
|
|
325
|
+
const overlay = makeOverlay();
|
|
326
|
+
const msg = makeGroupedMessage([
|
|
327
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
328
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
329
|
+
]);
|
|
330
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
331
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
332
|
+
|
|
333
|
+
setCurrentAnswer(sheet, "A");
|
|
334
|
+
navigateToPage(sheet, msg, undefined, 1);
|
|
335
|
+
setCurrentAnswer(sheet, "C");
|
|
336
|
+
navigateToPage(sheet, msg, undefined, 0);
|
|
337
|
+
|
|
338
|
+
const pillA = sheet.querySelector<HTMLElement>('[data-option-label="A"]');
|
|
339
|
+
expect(pillA?.getAttribute("aria-pressed")).toBe("true");
|
|
340
|
+
expect(getCurrentIndex(sheet)).toBe(0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("multi-select page stores an array; pill toggles preserve other selections", () => {
|
|
344
|
+
const overlay = makeOverlay();
|
|
345
|
+
const msg = makeGroupedMessage([
|
|
346
|
+
{
|
|
347
|
+
question: "Pick many",
|
|
348
|
+
options: [{ label: "X" }, { label: "Y" }, { label: "Z" }],
|
|
349
|
+
multiSelect: true,
|
|
350
|
+
allowFreeText: false,
|
|
351
|
+
},
|
|
352
|
+
{ question: "Q2?", options: [{ label: "A" }] },
|
|
353
|
+
]);
|
|
354
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
355
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
356
|
+
|
|
357
|
+
setCurrentAnswer(sheet, ["X", "Z"]);
|
|
358
|
+
const stored = readAnswersFromSheet(sheet)[0];
|
|
359
|
+
expect(stored).toEqual(["X", "Z"]);
|
|
360
|
+
const pillX = sheet.querySelector<HTMLElement>('[data-option-label="X"]');
|
|
361
|
+
const pillY = sheet.querySelector<HTMLElement>('[data-option-label="Y"]');
|
|
362
|
+
const pillZ = sheet.querySelector<HTMLElement>('[data-option-label="Z"]');
|
|
363
|
+
expect(pillX?.getAttribute("aria-pressed")).toBe("true");
|
|
364
|
+
expect(pillY?.getAttribute("aria-pressed")).toBe("false");
|
|
365
|
+
expect(pillZ?.getAttribute("aria-pressed")).toBe("true");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("buildStructuredAnswers keys answers by question text", () => {
|
|
369
|
+
const overlay = makeOverlay();
|
|
370
|
+
const msg = makeGroupedMessage([
|
|
371
|
+
{ question: "Tone?", options: [{ label: "Bold" }] },
|
|
372
|
+
{ question: "Length?", options: [{ label: "Short" }, { label: "Long" }] },
|
|
373
|
+
]);
|
|
374
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
375
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
376
|
+
|
|
377
|
+
setCurrentAnswer(sheet, "Bold");
|
|
378
|
+
navigateToPage(sheet, msg, undefined, 1);
|
|
379
|
+
setCurrentAnswer(sheet, "Short");
|
|
380
|
+
|
|
381
|
+
expect(buildStructuredAnswers(sheet, msg)).toEqual({
|
|
382
|
+
"Tone?": "Bold",
|
|
383
|
+
"Length?": "Short",
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("hydrates from agentMetadata — restores index and prior answers on a fresh mount", () => {
|
|
388
|
+
const overlay = makeOverlay();
|
|
389
|
+
const msg = makeGroupedMessage(
|
|
390
|
+
[
|
|
391
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
392
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
393
|
+
{ question: "Q3?", options: [{ label: "E" }, { label: "F" }] },
|
|
394
|
+
],
|
|
395
|
+
{
|
|
396
|
+
askUserQuestionAnswers: { "Q1?": "B", "Q2?": "D" },
|
|
397
|
+
askUserQuestionIndex: 1,
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
401
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
402
|
+
|
|
403
|
+
expect(getCurrentIndex(sheet)).toBe(1);
|
|
404
|
+
const pillD = sheet.querySelector<HTMLElement>('[data-option-label="D"]');
|
|
405
|
+
expect(pillD?.getAttribute("aria-pressed")).toBe("true");
|
|
406
|
+
expect(buildStructuredAnswers(sheet, msg)).toEqual({ "Q1?": "B", "Q2?": "D" });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("renders all 8 questions when at the cap, and warns + truncates at 9", () => {
|
|
410
|
+
__resetTruncateWarn();
|
|
411
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
412
|
+
|
|
413
|
+
const overlay = makeOverlay();
|
|
414
|
+
const eight: Partial<AskUserQuestionPrompt>[] = Array.from({ length: 8 }, (_, i) => ({
|
|
415
|
+
question: `Q${i + 1}?`,
|
|
416
|
+
options: [{ label: `${i + 1}-A` }],
|
|
417
|
+
}));
|
|
418
|
+
ensureAskUserQuestionSheet(makeGroupedMessage(eight), {} as AgentWidgetConfig, overlay);
|
|
419
|
+
let sheet = sheetFor(overlay, "tool-grouped");
|
|
420
|
+
expect(getQuestionCount(sheet)).toBe(ASK_USER_QUESTION_MAX);
|
|
421
|
+
expect(warn).not.toHaveBeenCalled();
|
|
422
|
+
|
|
423
|
+
overlay.innerHTML = "";
|
|
424
|
+
const nine = [...eight, { question: "overflow", options: [{ label: "X" }] }];
|
|
425
|
+
ensureAskUserQuestionSheet(makeGroupedMessage(nine), {} as AgentWidgetConfig, overlay);
|
|
426
|
+
sheet = sheetFor(overlay, "tool-grouped");
|
|
427
|
+
expect(getQuestionCount(sheet)).toBe(ASK_USER_QUESTION_MAX);
|
|
428
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
429
|
+
expect(warn.mock.calls[0]?.[0]).toContain("truncating to");
|
|
430
|
+
|
|
431
|
+
warn.mockRestore();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("rows layout (default)", () => {
|
|
436
|
+
it("renders option rows with description visible inline (not in title attr)", () => {
|
|
437
|
+
const overlay = makeOverlay();
|
|
438
|
+
const msg = makeMessage({
|
|
439
|
+
toolCall: {
|
|
440
|
+
id: "tool-rows-desc",
|
|
441
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
442
|
+
status: "complete",
|
|
443
|
+
args: {
|
|
444
|
+
questions: [
|
|
445
|
+
{
|
|
446
|
+
question: "Pick one",
|
|
447
|
+
options: [
|
|
448
|
+
{ label: "Discord", description: "Real Discord invite to link" },
|
|
449
|
+
{ label: "Slack", description: "Public Slack community" },
|
|
450
|
+
],
|
|
451
|
+
multiSelect: false,
|
|
452
|
+
allowFreeText: false,
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
chunks: [],
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
460
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-rows-desc"]')!;
|
|
461
|
+
expect(sheet.getAttribute("data-ask-layout")).toBe("rows");
|
|
462
|
+
const firstRow = sheet.querySelector<HTMLElement>('[data-ask-user-action="pick"]')!;
|
|
463
|
+
expect(firstRow.querySelector(".persona-ask-row-label")?.textContent).toBe("Discord");
|
|
464
|
+
expect(firstRow.querySelector(".persona-ask-row-description")?.textContent).toBe(
|
|
465
|
+
"Real Discord invite to link"
|
|
466
|
+
);
|
|
467
|
+
// No title-attr fallback in rows layout — description is inline.
|
|
468
|
+
expect(firstRow.getAttribute("title")).toBeNull();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("shows numeric badges 1..N on single-select rows", () => {
|
|
472
|
+
const overlay = makeOverlay();
|
|
473
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
474
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
475
|
+
const rows = sheet.querySelectorAll<HTMLElement>('[data-ask-user-action="pick"]');
|
|
476
|
+
expect(rows[0].querySelector(".persona-ask-row-badge")?.textContent).toBe("1");
|
|
477
|
+
expect(rows[1].querySelector(".persona-ask-row-badge")?.textContent).toBe("2");
|
|
478
|
+
expect(rows[2].querySelector(".persona-ask-row-badge")?.textContent).toBe("3");
|
|
479
|
+
// Multi-select check should NOT be present on single-select rows.
|
|
480
|
+
expect(rows[0].querySelector(".persona-ask-row-check")).toBeNull();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("shows checkbox affordance instead of badge on multi-select rows", () => {
|
|
484
|
+
const overlay = makeOverlay();
|
|
485
|
+
const multi = makeMessage({
|
|
486
|
+
toolCall: {
|
|
487
|
+
id: "tool-multi",
|
|
488
|
+
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
489
|
+
status: "complete",
|
|
490
|
+
args: {
|
|
491
|
+
questions: [
|
|
492
|
+
{
|
|
493
|
+
question: "Pick any",
|
|
494
|
+
options: [{ label: "A" }, { label: "B" }],
|
|
495
|
+
multiSelect: true,
|
|
496
|
+
allowFreeText: false,
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
chunks: [],
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
ensureAskUserQuestionSheet(multi, {} as AgentWidgetConfig, overlay);
|
|
504
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-multi"]')!;
|
|
505
|
+
const rows = sheet.querySelectorAll<HTMLElement>('[data-ask-user-action="pick"]');
|
|
506
|
+
expect(rows[0].querySelector(".persona-ask-row-check")).not.toBeNull();
|
|
507
|
+
expect(rows[0].querySelector(".persona-ask-row-badge")).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("embeds the free-text input INSIDE the Other row (rows mode) — no separate row, no Send button", () => {
|
|
511
|
+
const overlay = makeOverlay();
|
|
512
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
513
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
514
|
+
// No standalone free-text row in rows mode — input lives inside the Other row.
|
|
515
|
+
expect(sheet.querySelector('[data-ask-free-text-row="true"]')).toBeNull();
|
|
516
|
+
const otherRow = sheet.querySelector<HTMLElement>('[data-ask-other-row="true"]')!;
|
|
517
|
+
expect(otherRow).not.toBeNull();
|
|
518
|
+
expect(otherRow.classList.contains("persona-ask-row--other")).toBe(true);
|
|
519
|
+
expect(otherRow.querySelector('[data-ask-free-text-input="true"]')).not.toBeNull();
|
|
520
|
+
expect(otherRow.querySelector('[data-ask-user-action="submit-free-text"]')).toBeNull();
|
|
521
|
+
// Other row carries the focus-free-text action (digit shortcut + click chrome).
|
|
522
|
+
expect(otherRow.getAttribute("data-ask-user-action")).toBe("focus-free-text");
|
|
523
|
+
// Number badge for the Other row (N+1 — 3 real options + Other = 4).
|
|
524
|
+
const badge = otherRow.querySelector<HTMLElement>(".persona-ask-row-badge");
|
|
525
|
+
expect(badge?.textContent).toBe("4");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("respects layout: 'pills' opt-out — no row affordances, free-text starts hidden, Send button still rendered", () => {
|
|
529
|
+
const overlay = makeOverlay();
|
|
530
|
+
const config = {
|
|
531
|
+
features: { askUserQuestion: { layout: "pills" } },
|
|
532
|
+
} as unknown as AgentWidgetConfig;
|
|
533
|
+
ensureAskUserQuestionSheet(makeMessage(), config, overlay);
|
|
534
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
535
|
+
expect(sheet.getAttribute("data-ask-layout")).toBe("pills");
|
|
536
|
+
const firstPill = sheet.querySelector<HTMLElement>('[data-ask-user-action="pick"]')!;
|
|
537
|
+
expect(firstPill.querySelector(".persona-ask-row-label")).toBeNull();
|
|
538
|
+
expect(firstPill.querySelector(".persona-ask-row-badge")).toBeNull();
|
|
539
|
+
expect(firstPill.textContent).toBe("Hobbyists");
|
|
540
|
+
const freeRow = sheet.querySelector<HTMLElement>('[data-ask-free-text-row="true"]')!;
|
|
541
|
+
expect(freeRow.classList.contains("persona-hidden")).toBe(true);
|
|
542
|
+
// Pills mode keeps the Send button to commit the expand-on-click input.
|
|
543
|
+
expect(freeRow.querySelector('[data-ask-user-action="submit-free-text"]')).not.toBeNull();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe("Skip button", () => {
|
|
548
|
+
it("renders a Skip button alongside Next in the grouped nav row", () => {
|
|
549
|
+
const overlay = makeOverlay();
|
|
550
|
+
const msg = makeGroupedMessage([
|
|
551
|
+
{ question: "Q1", options: [{ label: "A" }] },
|
|
552
|
+
{ question: "Q2", options: [{ label: "B" }] },
|
|
553
|
+
]);
|
|
554
|
+
ensureAskUserQuestionSheet(msg, {} as AgentWidgetConfig, overlay);
|
|
555
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
556
|
+
const skip = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="skip"]');
|
|
557
|
+
expect(skip).not.toBeNull();
|
|
558
|
+
expect(skip!.disabled).toBe(false);
|
|
559
|
+
expect(skip!.textContent).toBe("Skip");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("uses the configured skipLabel override", () => {
|
|
563
|
+
const overlay = makeOverlay();
|
|
564
|
+
const msg = makeGroupedMessage([
|
|
565
|
+
{ question: "Q1", options: [{ label: "A" }] },
|
|
566
|
+
{ question: "Q2", options: [{ label: "B" }] },
|
|
567
|
+
]);
|
|
568
|
+
const config = {
|
|
569
|
+
features: { askUserQuestion: { skipLabel: "Pass" } },
|
|
570
|
+
} as unknown as AgentWidgetConfig;
|
|
571
|
+
ensureAskUserQuestionSheet(msg, config, overlay);
|
|
572
|
+
const sheet = sheetFor(overlay, "tool-grouped");
|
|
573
|
+
const skip = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="skip"]')!;
|
|
574
|
+
expect(skip.textContent).toBe("Pass");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("does not render a Skip button in single-question (non-grouped) mode", () => {
|
|
578
|
+
const overlay = makeOverlay();
|
|
579
|
+
ensureAskUserQuestionSheet(makeMessage(), {} as AgentWidgetConfig, overlay);
|
|
580
|
+
const sheet = overlay.querySelector('[data-persona-ask-sheet-for="tool-1"]')!;
|
|
581
|
+
expect(sheet.querySelector('[data-ask-user-action="skip"]')).toBeNull();
|
|
582
|
+
});
|
|
583
|
+
});
|