@runtypelabs/persona 3.17.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
- package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -4
- package/dist/index.d.ts +580 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +2844 -752
- package/dist/theme-editor.d.cts +337 -1
- package/dist/theme-editor.d.ts +337 -1
- package/dist/theme-editor.js +2958 -752
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +780 -0
- package/package.json +1 -1
- package/src/client.test.ts +134 -0
- package/src/client.ts +71 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/messages.ts +33 -1
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +303 -9
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/defaults.ts +21 -0
- package/src/index.ts +20 -1
- package/src/plugins/types.ts +57 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +780 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +271 -1
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +1439 -76
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.ts +7 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -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
|
+
};
|