@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.
Files changed (61) hide show
  1. package/README.md +143 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
@@ -0,0 +1,924 @@
1
+ import { parse as parsePartialJson, ARR, OBJ, STR } from "partial-json";
2
+ import { createElement } from "../utils/dom";
3
+ import {
4
+ AgentWidgetAskUserQuestionFeature,
5
+ AgentWidgetConfig,
6
+ AgentWidgetMessage,
7
+ AskUserQuestionOption,
8
+ AskUserQuestionPayload,
9
+ AskUserQuestionPrompt,
10
+ } from "../types";
11
+
12
+ export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
13
+ export const ASK_USER_QUESTION_MAX = 8;
14
+
15
+ const SHEET_SENTINEL = "data-persona-ask-sheet-for";
16
+ const DEFAULT_FREE_TEXT_LABEL_ROWS = "Other";
17
+ const DEFAULT_FREE_TEXT_LABEL_PILLS = "Other…";
18
+ const DEFAULT_FREE_TEXT_PLACEHOLDER = "Type your own answer here";
19
+ const DEFAULT_SUBMIT_LABEL = "Send";
20
+ const DEFAULT_NEXT_LABEL = "Next";
21
+ const DEFAULT_BACK_LABEL = "Back";
22
+ const DEFAULT_SUBMIT_ALL_LABEL = "Submit all";
23
+ const DEFAULT_SKIP_LABEL = "Skip";
24
+ const DEFAULT_SKELETON_PILLS = 3;
25
+
26
+ export const ATTR_CURRENT_INDEX = "data-ask-current-index";
27
+ export const ATTR_QUESTION_COUNT = "data-ask-question-count";
28
+ export const ATTR_ANSWERS = "data-ask-answers";
29
+ export const ATTR_GROUPED = "data-ask-grouped";
30
+ export const ATTR_LAYOUT = "data-ask-layout";
31
+
32
+ export type AskUserQuestionLayout = "rows" | "pills";
33
+
34
+ export const resolveLayout = (
35
+ feature: AgentWidgetAskUserQuestionFeature
36
+ ): AskUserQuestionLayout => (feature.layout === "pills" ? "pills" : "rows");
37
+
38
+ export const getLayout = (sheet: HTMLElement): AskUserQuestionLayout =>
39
+ sheet.getAttribute(ATTR_LAYOUT) === "pills" ? "pills" : "rows";
40
+
41
+ let truncateWarned = false;
42
+
43
+ /**
44
+ * Escape a tool-call id for safe use inside a CSS attribute selector.
45
+ * `CSS.escape` would work but isn't available in all test environments (jsdom).
46
+ */
47
+ const escapeAttrValue = (value: string): string => value.replace(/["\\]/g, "\\$&");
48
+
49
+ export const isAskUserQuestionMessage = (message: AgentWidgetMessage): boolean => {
50
+ return (
51
+ message.variant === "tool" &&
52
+ !!message.toolCall &&
53
+ message.toolCall.name === ASK_USER_QUESTION_TOOL_NAME
54
+ );
55
+ };
56
+
57
+ const resolveFeature = (config?: AgentWidgetConfig): AgentWidgetAskUserQuestionFeature => {
58
+ return config?.features?.askUserQuestion ?? {};
59
+ };
60
+
61
+ /**
62
+ * Parse an `ask_user_question` tool-variant message into a partial payload.
63
+ * Safe to call mid-stream — will walk the tool call's `chunks` via
64
+ * `partial-json` and return `{ payload: null, complete: false }` when there
65
+ * isn't enough data yet. `complete` flips to `true` once the tool call
66
+ * reports status `"complete"`.
67
+ *
68
+ * Exported for plugin authors implementing `renderAskUserQuestion`.
69
+ */
70
+ export const parseAskUserQuestionPayload = (
71
+ message: AgentWidgetMessage
72
+ ): { payload: Partial<AskUserQuestionPayload> | null; complete: boolean } => {
73
+ const toolCall = message.toolCall;
74
+ if (!toolCall) return { payload: null, complete: false };
75
+
76
+ const complete = toolCall.status === "complete";
77
+
78
+ if (toolCall.args && typeof toolCall.args === "object") {
79
+ return { payload: toolCall.args as Partial<AskUserQuestionPayload>, complete };
80
+ }
81
+
82
+ const chunks = toolCall.chunks;
83
+ if (!chunks || chunks.length === 0) return { payload: null, complete };
84
+
85
+ try {
86
+ const text = chunks.join("");
87
+ const parsed = parsePartialJson(text, STR | OBJ | ARR);
88
+ if (parsed && typeof parsed === "object") {
89
+ return { payload: parsed as Partial<AskUserQuestionPayload>, complete };
90
+ }
91
+ } catch {
92
+ // malformed; fall through
93
+ }
94
+ return { payload: null, complete };
95
+ };
96
+
97
+ /**
98
+ * Return the questions array (capped to {@link ASK_USER_QUESTION_MAX}). Logs a
99
+ * single one-shot warning if a payload exceeds the cap.
100
+ */
101
+ export const promptsFromPayload = (
102
+ payload: Partial<AskUserQuestionPayload> | null
103
+ ): Partial<AskUserQuestionPrompt>[] => {
104
+ const all = Array.isArray(payload?.questions) ? (payload!.questions as Partial<AskUserQuestionPrompt>[]) : [];
105
+ if (all.length > ASK_USER_QUESTION_MAX && !truncateWarned) {
106
+ truncateWarned = true;
107
+ if (typeof console !== "undefined") {
108
+ // eslint-disable-next-line no-console
109
+ console.warn(
110
+ `[AgentWidget] ask_user_question received ${all.length} questions; truncating to ${ASK_USER_QUESTION_MAX}.`
111
+ );
112
+ }
113
+ }
114
+ return all.slice(0, ASK_USER_QUESTION_MAX);
115
+ };
116
+
117
+ /**
118
+ * Kept for plugin authors who only want to render the first question.
119
+ * @deprecated Plugins should iterate `payload.questions` themselves; the
120
+ * built-in renderer now paginates multi-question payloads.
121
+ */
122
+ const firstPrompt = (
123
+ payload: Partial<AskUserQuestionPayload> | null
124
+ ): Partial<AskUserQuestionPrompt> | null => {
125
+ return promptsFromPayload(payload)[0] ?? null;
126
+ };
127
+
128
+ const promptAt = (
129
+ payload: Partial<AskUserQuestionPayload> | null,
130
+ index: number
131
+ ): Partial<AskUserQuestionPrompt> | null => {
132
+ return promptsFromPayload(payload)[index] ?? null;
133
+ };
134
+
135
+ const applyStyleVars = (
136
+ root: HTMLElement,
137
+ feature: AgentWidgetAskUserQuestionFeature
138
+ ): void => {
139
+ const s = feature.styles;
140
+ if (!s) return;
141
+ if (s.sheetBackground) root.style.setProperty("--persona-ask-sheet-bg", s.sheetBackground);
142
+ if (s.sheetBorder) root.style.setProperty("--persona-ask-sheet-border", s.sheetBorder);
143
+ if (s.sheetShadow) root.style.setProperty("--persona-ask-sheet-shadow", s.sheetShadow);
144
+ if (s.pillBackground) root.style.setProperty("--persona-ask-pill-bg", s.pillBackground);
145
+ if (s.pillBackgroundSelected)
146
+ root.style.setProperty("--persona-ask-pill-bg-selected", s.pillBackgroundSelected);
147
+ if (s.pillTextColor) root.style.setProperty("--persona-ask-pill-fg", s.pillTextColor);
148
+ if (s.pillTextColorSelected)
149
+ root.style.setProperty("--persona-ask-pill-fg-selected", s.pillTextColorSelected);
150
+ if (s.pillBorderRadius) root.style.setProperty("--persona-ask-pill-radius", s.pillBorderRadius);
151
+ if (s.customInputBackground)
152
+ root.style.setProperty("--persona-ask-input-bg", s.customInputBackground);
153
+ };
154
+
155
+ const buildAffordance = (
156
+ layout: AskUserQuestionLayout,
157
+ multiSelect: boolean,
158
+ index: number
159
+ ): HTMLElement | null => {
160
+ if (layout !== "rows") return null;
161
+ const wrap = createElement("span", "persona-ask-row-affordance");
162
+ wrap.setAttribute("aria-hidden", "true");
163
+ if (multiSelect) {
164
+ const check = createElement("span", "persona-ask-row-check");
165
+ wrap.appendChild(check);
166
+ } else {
167
+ const badge = createElement("span", "persona-ask-row-badge");
168
+ badge.textContent = String(index + 1);
169
+ wrap.appendChild(badge);
170
+ }
171
+ return wrap;
172
+ };
173
+
174
+ const buildPill = (
175
+ option: AskUserQuestionOption,
176
+ index: number,
177
+ layout: AskUserQuestionLayout,
178
+ multiSelect: boolean
179
+ ): HTMLButtonElement => {
180
+ const cls =
181
+ layout === "rows"
182
+ ? "persona-ask-pill persona-ask-row persona-pointer-events-auto"
183
+ : "persona-ask-pill persona-pointer-events-auto";
184
+ const btn = createElement("button", cls) as HTMLButtonElement;
185
+ btn.type = "button";
186
+ btn.setAttribute("role", multiSelect ? "checkbox" : "button");
187
+ btn.setAttribute("aria-pressed", "false");
188
+ btn.setAttribute("data-ask-user-action", "pick");
189
+ btn.setAttribute("data-option-index", String(index));
190
+ btn.setAttribute("data-option-label", option.label);
191
+
192
+ if (layout === "rows") {
193
+ const content = createElement("span", "persona-ask-row-content");
194
+ const label = createElement("span", "persona-ask-row-label");
195
+ label.textContent = option.label;
196
+ content.appendChild(label);
197
+ if (option.description) {
198
+ const desc = createElement("span", "persona-ask-row-description");
199
+ desc.textContent = option.description;
200
+ content.appendChild(desc);
201
+ }
202
+ btn.appendChild(content);
203
+ const aff = buildAffordance(layout, multiSelect, index);
204
+ if (aff) btn.appendChild(aff);
205
+ } else {
206
+ btn.textContent = option.label;
207
+ if (option.description) btn.title = option.description;
208
+ }
209
+ return btn;
210
+ };
211
+
212
+ const buildSkeletonPill = (layout: AskUserQuestionLayout): HTMLElement => {
213
+ const cls =
214
+ layout === "rows"
215
+ ? "persona-ask-pill persona-ask-row persona-ask-pill-skeleton persona-pointer-events-none"
216
+ : "persona-ask-pill persona-ask-pill-skeleton persona-pointer-events-none";
217
+ const el = createElement("span", cls);
218
+ el.setAttribute("aria-hidden", "true");
219
+ return el;
220
+ };
221
+
222
+ /**
223
+ * Build the interactive pill list + optional free-text pill for a given prompt.
224
+ */
225
+ const buildPillList = (
226
+ prompt: Partial<AskUserQuestionPrompt> | null,
227
+ feature: AgentWidgetAskUserQuestionFeature,
228
+ complete: boolean,
229
+ layout: AskUserQuestionLayout
230
+ ): HTMLElement => {
231
+ const baseClass =
232
+ layout === "rows"
233
+ ? "persona-ask-pills persona-ask-pills--rows persona-flex persona-flex-col persona-gap-2"
234
+ : "persona-ask-pills persona-flex persona-flex-wrap persona-gap-2";
235
+ const list = createElement("div", baseClass);
236
+ list.setAttribute("role", "group");
237
+ list.setAttribute("data-ask-pill-list", "true");
238
+
239
+ const multiSelect = !!prompt?.multiSelect;
240
+ const realOptions = Array.isArray(prompt?.options) ? (prompt!.options as AskUserQuestionOption[]) : [];
241
+ const cleanOptions = realOptions.filter((o) => o && typeof o.label === "string" && o.label.length > 0);
242
+
243
+ if (cleanOptions.length === 0 && !complete) {
244
+ for (let i = 0; i < DEFAULT_SKELETON_PILLS; i++) {
245
+ list.appendChild(buildSkeletonPill(layout));
246
+ }
247
+ return list;
248
+ }
249
+
250
+ cleanOptions.forEach((option, index) => {
251
+ list.appendChild(buildPill(option, index, layout, multiSelect));
252
+ });
253
+
254
+ // Free-text affordance:
255
+ // - Rows layout: a composite row that visually matches the option rows
256
+ // and HAS the input inside it (no separate row below). Number badge
257
+ // `N+1` on the right; pressing it focuses the input via the
258
+ // `focus-free-text` action.
259
+ // - Pills layout (legacy): a dashed pill button that expands a separate
260
+ // input row on click (handled by `buildFreeTextRow`).
261
+ const allowFreeText = prompt?.allowFreeText !== false;
262
+ if (allowFreeText) {
263
+ const defaultLabel =
264
+ layout === "rows" ? DEFAULT_FREE_TEXT_LABEL_ROWS : DEFAULT_FREE_TEXT_LABEL_PILLS;
265
+ if (layout === "rows") {
266
+ const otherRow = createElement(
267
+ "div",
268
+ "persona-ask-pill persona-ask-row persona-ask-row--other persona-ask-pill-custom persona-pointer-events-auto"
269
+ );
270
+ otherRow.setAttribute("data-ask-user-action", "focus-free-text");
271
+ otherRow.setAttribute("data-option-index", String(cleanOptions.length));
272
+ otherRow.setAttribute("data-ask-other-row", "true");
273
+
274
+ const content = createElement("span", "persona-ask-row-content");
275
+ const input = document.createElement("input");
276
+ input.type = "text";
277
+ input.className = "persona-ask-row-input persona-flex-1 persona-pointer-events-auto";
278
+ input.placeholder = feature.freeTextPlaceholder ?? DEFAULT_FREE_TEXT_PLACEHOLDER;
279
+ input.setAttribute("data-ask-free-text-input", "true");
280
+ input.setAttribute(
281
+ "aria-label",
282
+ feature.freeTextLabel ?? defaultLabel
283
+ );
284
+ content.appendChild(input);
285
+ otherRow.appendChild(content);
286
+
287
+ const aff = buildAffordance(layout, multiSelect, cleanOptions.length);
288
+ if (aff) otherRow.appendChild(aff);
289
+ list.appendChild(otherRow);
290
+ } else {
291
+ const freeBtn = createElement(
292
+ "button",
293
+ "persona-ask-pill persona-ask-pill-custom persona-pointer-events-auto"
294
+ ) as HTMLButtonElement;
295
+ freeBtn.type = "button";
296
+ freeBtn.setAttribute("data-ask-user-action", "open-free-text");
297
+ freeBtn.textContent = feature.freeTextLabel ?? defaultLabel;
298
+ list.appendChild(freeBtn);
299
+ }
300
+ }
301
+
302
+ return list;
303
+ };
304
+
305
+ const buildFreeTextRow = (
306
+ feature: AgentWidgetAskUserQuestionFeature,
307
+ layout: AskUserQuestionLayout
308
+ ): HTMLElement => {
309
+ const cls =
310
+ layout === "rows"
311
+ ? "persona-ask-free-text persona-ask-free-text--rows persona-flex persona-gap-2 persona-mt-2"
312
+ : "persona-ask-free-text persona-hidden persona-flex persona-gap-2 persona-mt-2";
313
+ const row = createElement("div", cls);
314
+ row.setAttribute("data-ask-free-text-row", "true");
315
+
316
+ const input = document.createElement("input");
317
+ input.type = "text";
318
+ input.className =
319
+ "persona-ask-free-text-input persona-flex-1 persona-pointer-events-auto";
320
+ input.placeholder = feature.freeTextPlaceholder ?? DEFAULT_FREE_TEXT_PLACEHOLDER;
321
+ input.setAttribute("data-ask-free-text-input", "true");
322
+
323
+ row.appendChild(input);
324
+
325
+ // Pills (legacy) layout keeps the explicit Send button so the expand-on-click
326
+ // affordance has a commit target. Rows layout drops it: the input commits via
327
+ // Enter, or via the grouped Next/Submit-all flush path.
328
+ if (layout !== "rows") {
329
+ const submit = createElement(
330
+ "button",
331
+ "persona-ask-free-text-submit persona-pointer-events-auto"
332
+ ) as HTMLButtonElement;
333
+ submit.type = "button";
334
+ submit.textContent = feature.submitLabel ?? DEFAULT_SUBMIT_LABEL;
335
+ submit.setAttribute("data-ask-user-action", "submit-free-text");
336
+ row.appendChild(submit);
337
+ }
338
+
339
+ return row;
340
+ };
341
+
342
+ const buildMultiSelectActions = (
343
+ feature: AgentWidgetAskUserQuestionFeature
344
+ ): HTMLElement => {
345
+ const row = createElement(
346
+ "div",
347
+ "persona-ask-multi-actions persona-flex persona-justify-end persona-mt-2"
348
+ );
349
+ row.setAttribute("data-ask-multi-actions", "true");
350
+
351
+ const submit = createElement(
352
+ "button",
353
+ "persona-ask-multi-submit persona-pointer-events-auto"
354
+ ) as HTMLButtonElement;
355
+ submit.type = "button";
356
+ submit.textContent = feature.submitLabel ?? DEFAULT_SUBMIT_LABEL;
357
+ submit.setAttribute("data-ask-user-action", "submit-multi");
358
+ submit.disabled = true;
359
+
360
+ row.appendChild(submit);
361
+ return row;
362
+ };
363
+
364
+ const buildNavRow = (
365
+ index: number,
366
+ count: number,
367
+ feature: AgentWidgetAskUserQuestionFeature
368
+ ): HTMLElement => {
369
+ const row = createElement(
370
+ "div",
371
+ "persona-ask-nav persona-flex persona-justify-between persona-items-center persona-gap-2 persona-mt-2"
372
+ );
373
+ row.setAttribute("data-ask-nav-row", "true");
374
+
375
+ const back = createElement(
376
+ "button",
377
+ "persona-ask-nav-back persona-pointer-events-auto"
378
+ ) as HTMLButtonElement;
379
+ back.type = "button";
380
+ back.textContent = feature.backLabel ?? DEFAULT_BACK_LABEL;
381
+ back.setAttribute("data-ask-user-action", "back");
382
+ back.disabled = index === 0;
383
+ row.appendChild(back);
384
+
385
+ const rightGroup = createElement(
386
+ "div",
387
+ "persona-ask-nav-right persona-flex persona-items-center persona-gap-2"
388
+ );
389
+
390
+ const skip = createElement(
391
+ "button",
392
+ "persona-ask-nav-skip persona-pointer-events-auto"
393
+ ) as HTMLButtonElement;
394
+ skip.type = "button";
395
+ skip.textContent = feature.skipLabel ?? DEFAULT_SKIP_LABEL;
396
+ skip.setAttribute("data-ask-user-action", "skip");
397
+ rightGroup.appendChild(skip);
398
+
399
+ const next = createElement(
400
+ "button",
401
+ "persona-ask-nav-next persona-pointer-events-auto"
402
+ ) as HTMLButtonElement;
403
+ next.type = "button";
404
+ const isFinal = index === count - 1;
405
+ next.textContent = isFinal
406
+ ? feature.submitAllLabel ?? DEFAULT_SUBMIT_ALL_LABEL
407
+ : feature.nextLabel ?? DEFAULT_NEXT_LABEL;
408
+ next.setAttribute("data-ask-user-action", isFinal ? "submit-all" : "next");
409
+ next.disabled = true; // updated by syncNavState
410
+ rightGroup.appendChild(next);
411
+
412
+ row.appendChild(rightGroup);
413
+
414
+ return row;
415
+ };
416
+
417
+ /**
418
+ * Read the answers map stored on the sheet element.
419
+ */
420
+ export const readAnswersFromSheet = (
421
+ sheet: HTMLElement
422
+ ): Record<number, string | string[]> => {
423
+ const raw = sheet.getAttribute(ATTR_ANSWERS);
424
+ if (!raw) return {};
425
+ try {
426
+ const parsed = JSON.parse(raw);
427
+ return parsed && typeof parsed === "object" ? (parsed as Record<number, string | string[]>) : {};
428
+ } catch {
429
+ return {};
430
+ }
431
+ };
432
+
433
+ /**
434
+ * Write the answers map back to the sheet element.
435
+ */
436
+ export const writeAnswersToSheet = (
437
+ sheet: HTMLElement,
438
+ answers: Record<number, string | string[]>
439
+ ): void => {
440
+ sheet.setAttribute(ATTR_ANSWERS, JSON.stringify(answers));
441
+ };
442
+
443
+ export const getCurrentIndex = (sheet: HTMLElement): number => {
444
+ const raw = Number(sheet.getAttribute(ATTR_CURRENT_INDEX) ?? "0");
445
+ return Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0;
446
+ };
447
+
448
+ export const setCurrentIndex = (sheet: HTMLElement, index: number): void => {
449
+ sheet.setAttribute(ATTR_CURRENT_INDEX, String(Math.max(0, Math.floor(index))));
450
+ };
451
+
452
+ export const getQuestionCount = (sheet: HTMLElement): number => {
453
+ const raw = Number(sheet.getAttribute(ATTR_QUESTION_COUNT) ?? "1");
454
+ return Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1;
455
+ };
456
+
457
+ export const isGroupedSheet = (sheet: HTMLElement): boolean => {
458
+ return sheet.getAttribute(ATTR_GROUPED) === "true";
459
+ };
460
+
461
+ const restoreAnswersFromMessage = (
462
+ message: AgentWidgetMessage,
463
+ prompts: Partial<AskUserQuestionPrompt>[]
464
+ ): Record<number, string | string[]> => {
465
+ const stored = message.agentMetadata?.askUserQuestionAnswers;
466
+ if (!stored || typeof stored !== "object") return {};
467
+ const result: Record<number, string | string[]> = {};
468
+ prompts.forEach((p, i) => {
469
+ const q = typeof p?.question === "string" ? p.question : "";
470
+ if (q && Object.prototype.hasOwnProperty.call(stored, q)) {
471
+ const v = stored[q];
472
+ if (typeof v === "string" || Array.isArray(v)) {
473
+ result[i] = v;
474
+ }
475
+ }
476
+ });
477
+ return result;
478
+ };
479
+
480
+ const restoreIndexFromMessage = (
481
+ message: AgentWidgetMessage,
482
+ count: number
483
+ ): number => {
484
+ const stored = message.agentMetadata?.askUserQuestionIndex;
485
+ if (typeof stored !== "number" || !Number.isFinite(stored)) return 0;
486
+ return Math.max(0, Math.min(count - 1, Math.floor(stored)));
487
+ };
488
+
489
+ /**
490
+ * Keyed-by-question-text view of the current answers on a sheet. Used both for
491
+ * persistence to message metadata and for the final tool-result payload sent
492
+ * back to the agent.
493
+ */
494
+ export const buildStructuredAnswers = (
495
+ sheet: HTMLElement,
496
+ message: AgentWidgetMessage
497
+ ): Record<string, string | string[]> => {
498
+ const { payload } = parseAskUserQuestionPayload(message);
499
+ const prompts = promptsFromPayload(payload);
500
+ const indexed = readAnswersFromSheet(sheet);
501
+ const result: Record<string, string | string[]> = {};
502
+ const seen = new Set<string>();
503
+ prompts.forEach((p, i) => {
504
+ const q = typeof p?.question === "string" ? p.question : "";
505
+ if (!q) return;
506
+ if (seen.has(q) && typeof console !== "undefined") {
507
+ // eslint-disable-next-line no-console
508
+ console.warn(`[AgentWidget] ask_user_question has duplicate question text "${q}"; later answer wins.`);
509
+ }
510
+ seen.add(q);
511
+ if (Object.prototype.hasOwnProperty.call(indexed, i)) {
512
+ result[q] = indexed[i];
513
+ }
514
+ });
515
+ return result;
516
+ };
517
+
518
+ /**
519
+ * Apply the selected/unselected visual state to pills on the current page,
520
+ * based on the answer stored for `currentIndex`.
521
+ */
522
+ const applySelectionState = (sheet: HTMLElement): void => {
523
+ const answers = readAnswersFromSheet(sheet);
524
+ const currentIndex = getCurrentIndex(sheet);
525
+ const stored = answers[currentIndex];
526
+ const selected = new Set<string>();
527
+ if (typeof stored === "string") selected.add(stored);
528
+ else if (Array.isArray(stored)) stored.forEach((s) => selected.add(s));
529
+
530
+ const pills = sheet.querySelectorAll<HTMLButtonElement>('[data-ask-user-action="pick"][data-option-label]');
531
+ pills.forEach((pill) => {
532
+ const label = pill.getAttribute("data-option-label") ?? "";
533
+ const on = selected.has(label);
534
+ pill.setAttribute("aria-pressed", on ? "true" : "false");
535
+ pill.classList.toggle("persona-ask-pill-selected", on);
536
+ });
537
+
538
+ // Also pre-fill the free-text input if the saved answer doesn't match any pill.
539
+ const realPillLabels = new Set(
540
+ Array.from(pills).map((p) => p.getAttribute("data-option-label") ?? "")
541
+ );
542
+ // In rows mode the input lives inside the Other row of the pill list; in
543
+ // pills mode it lives in a separate (potentially hidden) free-text row.
544
+ // Querying the input directly covers both layouts.
545
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
546
+ if (freeInput) {
547
+ if (typeof stored === "string" && stored.length > 0 && !realPillLabels.has(stored)) {
548
+ freeInput.value = stored;
549
+ const freeRow = freeInput.closest<HTMLElement>('[data-ask-free-text-row="true"]');
550
+ freeRow?.classList.remove("persona-hidden");
551
+ } else {
552
+ freeInput.value = "";
553
+ }
554
+ }
555
+ };
556
+
557
+ /**
558
+ * Update the Next/Submit-all enabled state based on whether the current
559
+ * question has a non-empty answer stored.
560
+ */
561
+ const syncNavState = (sheet: HTMLElement): void => {
562
+ if (!isGroupedSheet(sheet)) return;
563
+ const answers = readAnswersFromSheet(sheet);
564
+ const currentIndex = getCurrentIndex(sheet);
565
+ const v = answers[currentIndex];
566
+ const hasAnswer =
567
+ (typeof v === "string" && v.length > 0) || (Array.isArray(v) && v.length > 0);
568
+ const next = sheet.querySelector<HTMLButtonElement>(
569
+ '[data-ask-user-action="next"], [data-ask-user-action="submit-all"]'
570
+ );
571
+ if (next) next.disabled = !hasAnswer;
572
+
573
+ // Multi-select submit (1-question mode) — keep existing behavior.
574
+ const multi = sheet.querySelector<HTMLButtonElement>('[data-ask-user-action="submit-multi"]');
575
+ if (multi) {
576
+ const labels = Array.from(
577
+ sheet.querySelectorAll<HTMLElement>('[aria-pressed="true"][data-option-label]')
578
+ );
579
+ multi.disabled = labels.length === 0;
580
+ }
581
+ };
582
+
583
+ /**
584
+ * Replace the page-scoped body of the sheet (question text, pills, free-text
585
+ * row, multi-select actions) with content for `currentIndex`. Called both on
586
+ * initial mount and after every Back/Next navigation. Preserves the stepper
587
+ * row, the dismiss button, and the nav row at the bottom.
588
+ */
589
+ const renderCurrentPage = (
590
+ sheet: HTMLElement,
591
+ message: AgentWidgetMessage,
592
+ config: AgentWidgetConfig | undefined
593
+ ): void => {
594
+ const feature = resolveFeature(config);
595
+ const layout = getLayout(sheet);
596
+ const { payload, complete } = parseAskUserQuestionPayload(message);
597
+ const grouped = isGroupedSheet(sheet);
598
+ const index = getCurrentIndex(sheet);
599
+ const count = getQuestionCount(sheet);
600
+ const prompt = grouped ? promptAt(payload, index) : firstPrompt(payload);
601
+ const multiSelect = !!prompt?.multiSelect;
602
+
603
+ // Inline stepper "{index+1}/{count}" lives in the header next to the
604
+ // question text. Empty in single-Q mode.
605
+ const stepInline = sheet.querySelector<HTMLElement>('[data-ask-step-inline="true"]');
606
+ if (stepInline) {
607
+ stepInline.textContent = grouped ? `${index + 1}/${count}` : "";
608
+ }
609
+ // Sweep any legacy stepper row from earlier renders.
610
+ const oldStepper = sheet.querySelector<HTMLElement>('[data-ask-stepper="true"]');
611
+ if (oldStepper) oldStepper.remove();
612
+
613
+ // Question text
614
+ const qText = sheet.querySelector<HTMLElement>('[data-ask-question="true"]');
615
+ if (qText) {
616
+ const text = typeof prompt?.question === "string" ? prompt.question : "";
617
+ qText.textContent = text;
618
+ qText.classList.toggle("persona-ask-question-skeleton", !text && !complete);
619
+ }
620
+
621
+ // Pills list
622
+ const pillList = sheet.querySelector<HTMLElement>('[data-ask-pill-list="true"]');
623
+ if (pillList) {
624
+ const fresh = buildPillList(prompt, feature, complete, layout);
625
+ pillList.replaceWith(fresh);
626
+ }
627
+
628
+ // Free-text row — re-build to clear stale input value across pages.
629
+ // Only present in pills (legacy) mode; in rows mode the input lives inside
630
+ // the Other row of the pill list, which is rebuilt above.
631
+ if (layout !== "rows") {
632
+ const oldFree = sheet.querySelector<HTMLElement>('[data-ask-free-text-row="true"]');
633
+ if (oldFree) oldFree.replaceWith(buildFreeTextRow(feature, layout));
634
+ }
635
+
636
+ // Multi-select action row — only relevant in 1-question mode.
637
+ const oldMulti = sheet.querySelector<HTMLElement>('[data-ask-multi-actions="true"]');
638
+ if (!grouped && multiSelect && !oldMulti) {
639
+ sheet.appendChild(buildMultiSelectActions(feature));
640
+ } else if ((!multiSelect || grouped) && oldMulti) {
641
+ oldMulti.remove();
642
+ }
643
+ sheet.setAttribute("data-multi-select", multiSelect ? "true" : "false");
644
+
645
+ // Nav row stays last; only present in grouped mode.
646
+ const oldNav = sheet.querySelector<HTMLElement>('[data-ask-nav-row="true"]');
647
+ if (grouped) {
648
+ const fresh = buildNavRow(index, count, feature);
649
+ if (oldNav) oldNav.replaceWith(fresh);
650
+ else sheet.appendChild(fresh);
651
+ } else if (oldNav) {
652
+ oldNav.remove();
653
+ }
654
+
655
+ applySelectionState(sheet);
656
+ syncNavState(sheet);
657
+ };
658
+
659
+ const buildSheet = (
660
+ message: AgentWidgetMessage,
661
+ config: AgentWidgetConfig | undefined,
662
+ payload: Partial<AskUserQuestionPayload> | null
663
+ ): HTMLElement => {
664
+ const feature = resolveFeature(config);
665
+ const layout = resolveLayout(feature);
666
+ const toolCallId = message.toolCall!.id;
667
+ const prompts = promptsFromPayload(payload);
668
+ const count = Math.max(1, prompts.length);
669
+ const grouped = count > 1;
670
+
671
+ const initialAnswers = restoreAnswersFromMessage(message, prompts);
672
+ const initialIndex = grouped ? restoreIndexFromMessage(message, count) : 0;
673
+
674
+ const sheet = createElement(
675
+ "div",
676
+ [
677
+ "persona-ask-sheet",
678
+ `persona-ask-sheet--${layout}`,
679
+ "persona-pointer-events-auto",
680
+ "persona-ask-sheet-enter",
681
+ ].join(" ")
682
+ );
683
+ sheet.setAttribute(SHEET_SENTINEL, toolCallId);
684
+ sheet.setAttribute("data-tool-call-id", toolCallId);
685
+ sheet.setAttribute("data-message-id", message.id);
686
+ sheet.setAttribute(ATTR_QUESTION_COUNT, String(count));
687
+ sheet.setAttribute(ATTR_CURRENT_INDEX, String(initialIndex));
688
+ sheet.setAttribute(ATTR_GROUPED, grouped ? "true" : "false");
689
+ sheet.setAttribute(ATTR_LAYOUT, layout);
690
+ writeAnswersToSheet(sheet, initialAnswers);
691
+ sheet.setAttribute("role", "group");
692
+ sheet.setAttribute("aria-label", "Suggested answers");
693
+
694
+ if (feature.slideInMs !== undefined) {
695
+ sheet.style.setProperty("--persona-ask-sheet-duration", `${feature.slideInMs}ms`);
696
+ }
697
+ applyStyleVars(sheet, feature);
698
+
699
+ // Header: question text (flex-1) + compact "N/M" stepper indicator on the
700
+ // right (grouped only). Skip in the nav row is the canonical escape hatch
701
+ // — plugins that want a different escape model render their own UX.
702
+ const header = createElement(
703
+ "div",
704
+ "persona-ask-sheet-header persona-flex persona-items-center persona-gap-3"
705
+ );
706
+
707
+ const qText = createElement("div", "persona-ask-sheet-question persona-flex-1");
708
+ qText.setAttribute("data-ask-question", "true");
709
+ qText.textContent = "";
710
+ header.appendChild(qText);
711
+
712
+ // Inline stepper indicator. Empty for single-Q; populated by
713
+ // renderCurrentPage to "{index+1}/{count}" in grouped mode.
714
+ const stepInline = createElement(
715
+ "span",
716
+ "persona-ask-sheet-step-inline"
717
+ );
718
+ stepInline.setAttribute("data-ask-step-inline", "true");
719
+ stepInline.textContent = "";
720
+ header.appendChild(stepInline);
721
+
722
+ sheet.appendChild(header);
723
+
724
+ // Skeleton placeholders — these get replaced wholesale by renderCurrentPage.
725
+ const skeletonClass =
726
+ layout === "rows"
727
+ ? "persona-ask-pills persona-ask-pills--rows persona-flex persona-flex-col persona-gap-2"
728
+ : "persona-ask-pills persona-flex persona-flex-wrap persona-gap-2";
729
+ const list = createElement("div", skeletonClass);
730
+ list.setAttribute("data-ask-pill-list", "true");
731
+ list.setAttribute("role", "group");
732
+ sheet.appendChild(list);
733
+
734
+ // Pills (legacy) layout uses a separate, hidden free-text row that expands
735
+ // on click. Rows layout embeds the input inside the Other row of the pill
736
+ // list, so the standalone row is unnecessary.
737
+ if (layout !== "rows") {
738
+ sheet.appendChild(buildFreeTextRow(feature, layout));
739
+ }
740
+
741
+ // Render the actual current page (stepper, pills, multi-actions, nav).
742
+ renderCurrentPage(sheet, message, config);
743
+
744
+ // Remove the enter class next frame so the slide-in transition runs.
745
+ requestAnimationFrame(() => {
746
+ requestAnimationFrame(() => sheet.classList.remove("persona-ask-sheet-enter"));
747
+ });
748
+
749
+ return sheet;
750
+ };
751
+
752
+ const syncSheetFromMessage = (
753
+ sheet: HTMLElement,
754
+ message: AgentWidgetMessage,
755
+ config: AgentWidgetConfig | undefined
756
+ ): void => {
757
+ // If the payload's question count grew (rare mid-stream), update the cached count.
758
+ const { payload } = parseAskUserQuestionPayload(message);
759
+ const newCount = Math.max(1, promptsFromPayload(payload).length);
760
+ if (newCount > getQuestionCount(sheet)) {
761
+ sheet.setAttribute(ATTR_QUESTION_COUNT, String(newCount));
762
+ if (newCount > 1 && !isGroupedSheet(sheet)) {
763
+ sheet.setAttribute(ATTR_GROUPED, "true");
764
+ }
765
+ }
766
+ renderCurrentPage(sheet, message, config);
767
+ };
768
+
769
+ /**
770
+ * Create the small in-transcript stub for an `ask_user_question` tool call.
771
+ * The stub is passive — the interactive sheet is mounted separately into
772
+ * the composer overlay via `ensureAskUserQuestionSheet`.
773
+ */
774
+ export const createAskUserQuestionBubble = (
775
+ message: AgentWidgetMessage,
776
+ config?: AgentWidgetConfig
777
+ ): HTMLElement => {
778
+ const bubble = createElement(
779
+ "div",
780
+ "persona-ask-stub persona-inline-flex persona-items-center persona-gap-2"
781
+ );
782
+ bubble.id = `bubble-${message.id}`;
783
+ bubble.setAttribute("data-message-id", message.id);
784
+ bubble.setAttribute("data-bubble-type", "ask-user-question");
785
+
786
+ const feature = resolveFeature(config);
787
+ applyStyleVars(bubble, feature);
788
+
789
+ const text = createElement("span", "persona-ask-stub-label");
790
+ const { complete } = parseAskUserQuestionPayload(message);
791
+ text.textContent = complete ? "Awaiting your response…" : "Preparing options…";
792
+ bubble.appendChild(text);
793
+
794
+ return bubble;
795
+ };
796
+
797
+ /**
798
+ * Mount or update the interactive answer-pill sheet for a given message.
799
+ * Idempotent — if a sheet already exists for the tool-call id, it is hydrated
800
+ * in-place instead of remounted, so streaming updates don't flicker.
801
+ */
802
+ export const ensureAskUserQuestionSheet = (
803
+ message: AgentWidgetMessage,
804
+ config: AgentWidgetConfig | undefined,
805
+ overlay: HTMLElement | null | undefined
806
+ ): void => {
807
+ if (!overlay) return;
808
+ if (!isAskUserQuestionMessage(message)) return;
809
+
810
+ const feature = resolveFeature(config);
811
+ if (feature.enabled === false) return;
812
+
813
+ const toolCallId = message.toolCall!.id;
814
+
815
+ // Only keep the latest sheet in the overlay — clear any stale siblings.
816
+ const siblings = overlay.querySelectorAll<HTMLElement>(`[${SHEET_SENTINEL}]`);
817
+ siblings.forEach((el) => {
818
+ if (el.getAttribute(SHEET_SENTINEL) !== toolCallId) {
819
+ el.remove();
820
+ }
821
+ });
822
+
823
+ const existing = overlay.querySelector<HTMLElement>(
824
+ `[${SHEET_SENTINEL}="${escapeAttrValue(toolCallId)}"]`
825
+ );
826
+ if (existing) {
827
+ syncSheetFromMessage(existing, message, config);
828
+ return;
829
+ }
830
+
831
+ const { payload } = parseAskUserQuestionPayload(message);
832
+ const sheet = buildSheet(message, config, payload);
833
+ overlay.appendChild(sheet);
834
+ };
835
+
836
+ /**
837
+ * Remove the sheet for a specific tool-call id, or all sheets if omitted.
838
+ * Runs a slide-out transition before removing.
839
+ */
840
+ export const removeAskUserQuestionSheet = (
841
+ overlay: HTMLElement | null | undefined,
842
+ toolCallId?: string
843
+ ): void => {
844
+ if (!overlay) return;
845
+
846
+ const selector = toolCallId
847
+ ? `[${SHEET_SENTINEL}="${escapeAttrValue(toolCallId)}"]`
848
+ : `[${SHEET_SENTINEL}]`;
849
+ const sheets = overlay.querySelectorAll<HTMLElement>(selector);
850
+
851
+ sheets.forEach((sheet) => {
852
+ sheet.classList.add("persona-ask-sheet-leave");
853
+ const duration = Number.parseInt(
854
+ getComputedStyle(sheet).getPropertyValue("--persona-ask-sheet-duration") || "180",
855
+ 10
856
+ );
857
+ const remove = () => sheet.remove();
858
+ setTimeout(remove, Number.isFinite(duration) ? duration : 180);
859
+ });
860
+ };
861
+
862
+ /**
863
+ * Read the currently-selected option labels from a multi-select sheet.
864
+ */
865
+ export const getSelectedLabels = (sheet: HTMLElement): string[] => {
866
+ return Array.from(
867
+ sheet.querySelectorAll<HTMLElement>('[aria-pressed="true"][data-option-label]')
868
+ )
869
+ .map((el) => el.getAttribute("data-option-label"))
870
+ .filter((label): label is string => typeof label === "string" && label.length > 0);
871
+ };
872
+
873
+ /**
874
+ * Update the answer for the current page and refresh visual state. Used by
875
+ * the ui.ts event handlers in grouped mode.
876
+ */
877
+ export const setCurrentAnswer = (
878
+ sheet: HTMLElement,
879
+ answer: string | string[]
880
+ ): void => {
881
+ const answers = readAnswersFromSheet(sheet);
882
+ const idx = getCurrentIndex(sheet);
883
+ if (typeof answer === "string" && answer.length === 0) {
884
+ delete answers[idx];
885
+ } else if (Array.isArray(answer) && answer.length === 0) {
886
+ delete answers[idx];
887
+ } else {
888
+ answers[idx] = answer;
889
+ }
890
+ writeAnswersToSheet(sheet, answers);
891
+ applySelectionState(sheet);
892
+ syncNavState(sheet);
893
+ };
894
+
895
+ /**
896
+ * Navigate to a page by index and re-render the current page contents.
897
+ */
898
+ export const navigateToPage = (
899
+ sheet: HTMLElement,
900
+ message: AgentWidgetMessage,
901
+ config: AgentWidgetConfig | undefined,
902
+ index: number
903
+ ): void => {
904
+ const count = getQuestionCount(sheet);
905
+ const clamped = Math.max(0, Math.min(count - 1, index));
906
+ setCurrentIndex(sheet, clamped);
907
+ renderCurrentPage(sheet, message, config);
908
+ };
909
+
910
+ /**
911
+ * Re-export of the post-render nav-state sync, for ui.ts to call after pill
912
+ * toggles in grouped multi-select mode.
913
+ */
914
+ export const refreshNavState = (sheet: HTMLElement): void => {
915
+ syncNavState(sheet);
916
+ };
917
+
918
+ /**
919
+ * Test seam — reset the one-shot truncation warning so each test can assert
920
+ * the warn fires exactly once.
921
+ */
922
+ export const __resetTruncateWarn = (): void => {
923
+ truncateWarned = false;
924
+ };