@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.
Files changed (43) hide show
  1. package/README.md +142 -0
  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 +300 -1
  11. package/dist/index.d.ts +300 -1
  12. package/dist/index.global.js +75 -75
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +47 -47
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +1432 -159
  17. package/dist/theme-editor.d.cts +218 -0
  18. package/dist/theme-editor.d.ts +218 -0
  19. package/dist/theme-editor.js +1432 -159
  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 +432 -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/messages.ts +33 -1
  30. package/src/components/panel.ts +41 -4
  31. package/src/defaults.ts +21 -0
  32. package/src/index.ts +16 -1
  33. package/src/plugins/types.ts +57 -0
  34. package/src/session.test.ts +183 -0
  35. package/src/session.ts +242 -3
  36. package/src/styles/widget.css +432 -0
  37. package/src/types/theme.ts +15 -0
  38. package/src/types.ts +150 -0
  39. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  40. package/src/ui.ts +631 -5
  41. package/src/utils/storage.ts +10 -2
  42. package/src/utils/theme.test.ts +36 -0
  43. package/src/utils/tokens.ts +23 -0
@@ -4,6 +4,11 @@ import { MessageTransform, MessageActionCallbacks } from "./message-bubble";
4
4
  import { createStandardBubble } from "./message-bubble";
5
5
  import { createReasoningBubble } from "./reasoning-bubble";
6
6
  import { createToolBubble } from "./tool-bubble";
7
+ import {
8
+ ensureAskUserQuestionSheet,
9
+ isAskUserQuestionMessage,
10
+ removeAskUserQuestionSheet,
11
+ } from "./ask-user-question-bubble";
7
12
 
8
13
  export const renderMessages = (
9
14
  container: HTMLElement,
@@ -12,16 +17,29 @@ export const renderMessages = (
12
17
  showReasoning: boolean,
13
18
  showToolCalls: boolean,
14
19
  config?: AgentWidgetConfig,
15
- actionCallbacks?: MessageActionCallbacks
20
+ actionCallbacks?: MessageActionCallbacks,
21
+ composerOverlay?: HTMLElement | null
16
22
  ) => {
17
23
  container.innerHTML = "";
18
24
  const fragment = createFragment();
19
25
 
26
+ // Track which ask_user_question tool-call ids are currently in the message
27
+ // list, so we can prune stale sheets from the overlay afterward.
28
+ const liveAskToolIds = new Set<string>();
29
+
20
30
  messages.forEach((message) => {
21
31
  let bubble: HTMLElement;
22
32
  if (message.variant === "reasoning" && message.reasoning) {
23
33
  if (!showReasoning) return;
24
34
  bubble = createReasoningBubble(message, config);
35
+ } else if (isAskUserQuestionMessage(message)) {
36
+ // No transcript bubble — the overlay sheet is the only question UI.
37
+ if (config?.features?.askUserQuestion?.enabled === false) return;
38
+ if (!message.agentMetadata?.askUserQuestionAnswered) {
39
+ if (message.toolCall?.id) liveAskToolIds.add(message.toolCall.id);
40
+ ensureAskUserQuestionSheet(message, config, composerOverlay ?? null);
41
+ }
42
+ return;
25
43
  } else if (message.variant === "tool" && message.toolCall) {
26
44
  if (!showToolCalls) return;
27
45
  bubble = createToolBubble(message, config);
@@ -45,6 +63,20 @@ export const renderMessages = (
45
63
 
46
64
  container.appendChild(fragment);
47
65
  container.scrollTop = container.scrollHeight;
66
+
67
+ // Clean up any orphaned ask_user_question sheets whose source message is no
68
+ // longer in the list (e.g. after clearChat or a message splice).
69
+ if (composerOverlay) {
70
+ const sheets = composerOverlay.querySelectorAll<HTMLElement>(
71
+ '[data-persona-ask-sheet-for]'
72
+ );
73
+ sheets.forEach((sheet) => {
74
+ const id = sheet.getAttribute('data-persona-ask-sheet-for');
75
+ if (id && !liveAskToolIds.has(id)) {
76
+ removeAskUserQuestionSheet(composerOverlay, id);
77
+ }
78
+ });
79
+ }
48
80
  };
49
81
 
50
82
 
@@ -81,6 +81,12 @@ export interface PanelElements {
81
81
  container: HTMLElement;
82
82
  body: HTMLElement;
83
83
  messagesWrapper: HTMLElement;
84
+ /**
85
+ * Absolute-positioned slot above the composer footer. Interactive sheets
86
+ * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
87
+ * so they slide in without reflowing the chat transcript.
88
+ */
89
+ composerOverlay: HTMLElement;
84
90
  suggestions: HTMLElement;
85
91
  textarea: HTMLTextAreaElement;
86
92
  sendButton: HTMLButtonElement;
@@ -138,10 +144,17 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
138
144
  body.id = "persona-scroll-container";
139
145
  body.setAttribute("data-persona-theme-zone", "messages");
140
146
 
141
- const introCardClasses = isDockedMountMode(config)
142
- ? "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
143
- : "persona-rounded-2xl persona-bg-persona-surface persona-p-6 persona-shadow-sm";
144
- const introCard = createElement("div", introCardClasses);
147
+ const introCard = createElement(
148
+ "div",
149
+ "persona-rounded-2xl persona-bg-persona-surface persona-p-6"
150
+ );
151
+ // Box-shadow flows through the themable `components.introCard.shadow` token
152
+ // (--persona-intro-card-shadow). Docked mode keeps a flat look by default;
153
+ // floating mode falls back to the legacy `persona-shadow-sm` value when no
154
+ // token is set.
155
+ introCard.style.boxShadow = isDockedMountMode(config)
156
+ ? "none"
157
+ : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
145
158
  const introTitle = createElement(
146
159
  "h2",
147
160
  "persona-text-lg persona-font-semibold persona-text-persona-primary"
@@ -193,6 +206,26 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
193
206
 
194
207
  container.append(body);
195
208
 
209
+ // Composer overlay slot: sits between body and footer, absolutely positioned
210
+ // above the composer so sheets (e.g. the ask_user_question answer-pill sheet)
211
+ // can slide up without reflowing the chat transcript above. Uses inline
212
+ // styles for left/right/bottom because widget.css is hand-authored and
213
+ // doesn't ship `.persona-left-0` / `.persona-right-0` rules — without
214
+ // them the overlay shrink-wraps to content and collapses the sheet width.
215
+ const composerOverlay = createElement(
216
+ "div",
217
+ "persona-composer-overlay persona-pointer-events-none"
218
+ );
219
+ composerOverlay.setAttribute("data-persona-composer-overlay", "");
220
+ composerOverlay.style.position = "absolute";
221
+ composerOverlay.style.left = "0";
222
+ composerOverlay.style.right = "0";
223
+ composerOverlay.style.bottom = "0";
224
+ // Above .persona-scroll-to-bottom-indicator (z-index 10, sibling in the
225
+ // container) so suggestion chips and the ask-user-question sheet are not
226
+ // covered by the "jump to latest" button.
227
+ composerOverlay.style.zIndex = "20";
228
+
196
229
  if (showFooter) {
197
230
  container.append(composerElements.footer);
198
231
  } else {
@@ -201,10 +234,14 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
201
234
  container.append(composerElements.footer);
202
235
  }
203
236
 
237
+ // Append overlay last so it stacks above the footer / body content.
238
+ container.append(composerOverlay);
239
+
204
240
  return {
205
241
  container,
206
242
  body,
207
243
  messagesWrapper,
244
+ composerOverlay,
208
245
  suggestions: composerElements.suggestions,
209
246
  textarea: composerElements.textarea,
210
247
  sendButton: composerElements.sendButton,
package/src/defaults.ts CHANGED
@@ -143,6 +143,13 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
143
143
  speed: 120,
144
144
  duration: 1800,
145
145
  },
146
+ askUserQuestion: {
147
+ enabled: true,
148
+ slideInMs: 180,
149
+ freeTextLabel: "Other…",
150
+ freeTextPlaceholder: "Type your answer…",
151
+ submitLabel: "Send",
152
+ },
146
153
  },
147
154
  suggestionChips: [
148
155
  "What can you help me with?",
@@ -259,6 +266,8 @@ export function mergeWithDefaults(
259
266
  const csb = config.features?.scrollToBottom;
260
267
  const dsa = DEFAULT_WIDGET_CONFIG.features?.streamAnimation;
261
268
  const csa = config.features?.streamAnimation;
269
+ const dau = DEFAULT_WIDGET_CONFIG.features?.askUserQuestion;
270
+ const cau = config.features?.askUserQuestion;
262
271
  const mergedArtifacts =
263
272
  da === undefined && ca === undefined
264
273
  ? undefined
@@ -284,12 +293,24 @@ export function mergeWithDefaults(
284
293
  ...dsa,
285
294
  ...csa,
286
295
  };
296
+ const mergedAskUserQuestion =
297
+ dau === undefined && cau === undefined
298
+ ? undefined
299
+ : {
300
+ ...dau,
301
+ ...cau,
302
+ styles: {
303
+ ...dau?.styles,
304
+ ...cau?.styles,
305
+ },
306
+ };
287
307
  return {
288
308
  ...DEFAULT_WIDGET_CONFIG.features,
289
309
  ...config.features,
290
310
  ...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
291
311
  ...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
292
312
  ...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
313
+ ...(mergedAskUserQuestion !== undefined ? { askUserQuestion: mergedAskUserQuestion } : {}),
293
314
  };
294
315
  })(),
295
316
  suggestionChips: config.suggestionChips ?? DEFAULT_WIDGET_CONFIG.suggestionChips,
package/src/index.ts CHANGED
@@ -86,9 +86,24 @@ export type {
86
86
  EventStreamToolbarRenderContext,
87
87
  EventStreamPayloadRenderContext,
88
88
  // Controller event map
89
- AgentWidgetControllerEventMap
89
+ AgentWidgetControllerEventMap,
90
+ // Ask-user-question (built-in answer-pill sheet) types
91
+ AskUserQuestionPayload,
92
+ AskUserQuestionPrompt,
93
+ AskUserQuestionOption,
94
+ AgentWidgetAskUserQuestionFeature,
95
+ AgentWidgetAskUserQuestionStyles
90
96
  } from "./types";
91
97
 
98
+ export {
99
+ ASK_USER_QUESTION_TOOL_NAME,
100
+ createAskUserQuestionBubble,
101
+ ensureAskUserQuestionSheet,
102
+ removeAskUserQuestionSheet,
103
+ isAskUserQuestionMessage,
104
+ parseAskUserQuestionPayload
105
+ } from "./components/ask-user-question-bubble";
106
+
92
107
  export { initAgentWidgetFn as initAgentWidget };
93
108
  export {
94
109
  createWidgetHostLayout,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AgentWidgetMessage,
3
3
  AgentWidgetConfig,
4
+ AskUserQuestionPayload,
4
5
  LoadingIndicatorRenderContext,
5
6
  IdleIndicatorRenderContext,
6
7
  EventStreamViewRenderContext,
@@ -107,6 +108,62 @@ export interface AgentWidgetPlugin {
107
108
  config: AgentWidgetConfig;
108
109
  }) => HTMLElement | null;
109
110
 
111
+ /**
112
+ * Custom renderer for `ask_user_question` tool calls.
113
+ *
114
+ * When a plugin returns an `HTMLElement`, it is inserted into the transcript
115
+ * in place of the default (which is no transcript bubble — the built-in
116
+ * renders a sheet over the composer). The built-in composer-overlay sheet
117
+ * is suppressed so the plugin's UI fully owns the interaction.
118
+ *
119
+ * Return `null` to fall through to the built-in overlay sheet.
120
+ *
121
+ * The context gives you a pre-parsed `payload` (may be partial while the
122
+ * tool call is still streaming — check `complete`) and two callbacks:
123
+ * `resolve(answer)` resumes the paused LOCAL tool with the user's answer,
124
+ * and `dismiss()` cancels with the sentinel `"(dismissed)"`.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * renderAskUserQuestion: ({ payload, resolve, dismiss }) => {
129
+ * const prompt = payload.questions?.[0];
130
+ * if (!prompt) return null;
131
+ * const root = document.createElement("div");
132
+ * root.textContent = prompt.question ?? "";
133
+ * (prompt.options ?? []).forEach((option) => {
134
+ * const btn = document.createElement("button");
135
+ * btn.textContent = option.label;
136
+ * btn.addEventListener("click", () => resolve(option.label));
137
+ * root.appendChild(btn);
138
+ * });
139
+ * return root;
140
+ * }
141
+ * ```
142
+ */
143
+ renderAskUserQuestion?: (context: {
144
+ message: AgentWidgetMessage;
145
+ /**
146
+ * Parsed `{ questions: [...] }` payload. May be partial while the tool
147
+ * call is still streaming; see `complete`. `null` when no payload has
148
+ * arrived yet.
149
+ */
150
+ payload: Partial<AskUserQuestionPayload> | null;
151
+ /** `true` once the tool-call args have fully streamed in. */
152
+ complete: boolean;
153
+ /**
154
+ * Resume the paused LOCAL tool with the user's answer. Posts to the
155
+ * resume endpoint, pipes the SSE stream back into the session, and
156
+ * appends a user-visible answer bubble to the transcript.
157
+ */
158
+ resolve: (answer: string) => void;
159
+ /**
160
+ * Cancel the question. Resumes with the sentinel `"(dismissed)"` so the
161
+ * server doesn't sit in `waiting_for_local` forever. Idempotent.
162
+ */
163
+ dismiss: () => void;
164
+ config: AgentWidgetConfig;
165
+ }) => HTMLElement | null;
166
+
110
167
  /**
111
168
  * Custom renderer for approval bubbles
112
169
  * Return null to use default renderer
@@ -337,3 +337,186 @@ describe('AgentWidgetSession - cancel()', () => {
337
337
  expect(stopVoicePlaybackSpy).toHaveBeenCalledTimes(1);
338
338
  });
339
339
  });
340
+
341
+ describe('AgentWidgetSession.resolveAskUserQuestion', () => {
342
+ const makeAwaitingMessage = (): AgentWidgetMessage => ({
343
+ id: 'tool-msg-1',
344
+ role: 'assistant',
345
+ content: '',
346
+ createdAt: new Date().toISOString(),
347
+ variant: 'tool',
348
+ streaming: false,
349
+ toolCall: {
350
+ id: 'runtime_ask_user_question_1',
351
+ name: 'ask_user_question',
352
+ status: 'complete',
353
+ args: { questions: [{ question: 'Who?', options: [{ label: 'A' }] }] },
354
+ chunks: [],
355
+ },
356
+ agentMetadata: {
357
+ executionId: 'exec_abc',
358
+ awaitingLocalTool: true,
359
+ },
360
+ });
361
+
362
+ it('POSTs to /resume, appends a user bubble, and pipes the SSE stream through connectStream', async () => {
363
+ let capturedUrl: string | undefined;
364
+ let capturedBody: Record<string, unknown> | undefined;
365
+ global.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
366
+ capturedUrl = url;
367
+ capturedBody = JSON.parse(init.body as string);
368
+ const encoder = new TextEncoder();
369
+ const stream = new ReadableStream({
370
+ start(controller) {
371
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
372
+ controller.close();
373
+ },
374
+ });
375
+ return { ok: true, body: stream };
376
+ });
377
+
378
+ const seen: AgentWidgetMessage[] = [];
379
+ const session = new AgentWidgetSession(
380
+ { apiUrl: 'http://localhost:43111/api/chat/dispatch' },
381
+ {
382
+ onMessagesChanged: (msgs) => { seen.splice(0, seen.length, ...msgs); },
383
+ onStatusChanged: () => {},
384
+ onStreamingChanged: () => {},
385
+ }
386
+ );
387
+
388
+ const connectSpy = vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
389
+ await session.resolveAskUserQuestion(makeAwaitingMessage(), 'Hobbyists');
390
+
391
+ expect(capturedUrl).toBe('http://localhost:43111/api/chat/dispatch/resume');
392
+ expect(capturedBody).toEqual({
393
+ executionId: 'exec_abc',
394
+ toolOutputs: { ["ask_user_question"]: 'Hobbyists' },
395
+ streamResponse: true,
396
+ });
397
+
398
+ const userBubble = seen.find((m) => m.role === 'user' && m.content === 'Hobbyists');
399
+ expect(userBubble).toBeDefined();
400
+
401
+ expect(connectSpy).toHaveBeenCalledTimes(1);
402
+ });
403
+
404
+ it('flips askUserQuestionAnswered on the tool message before the POST fires', async () => {
405
+ const awaiting = makeAwaitingMessage();
406
+
407
+ const fetchMock = vi.fn().mockImplementation(async () => {
408
+ const encoder = new TextEncoder();
409
+ const stream = new ReadableStream({
410
+ start(controller) {
411
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
412
+ controller.close();
413
+ },
414
+ });
415
+ return { ok: true, body: stream };
416
+ });
417
+ global.fetch = fetchMock;
418
+
419
+ let latest: AgentWidgetMessage[] = [];
420
+ const session = new AgentWidgetSession(
421
+ {
422
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
423
+ initialMessages: [awaiting],
424
+ },
425
+ {
426
+ onMessagesChanged: (msgs) => { latest = msgs; },
427
+ onStatusChanged: () => {},
428
+ onStreamingChanged: () => {},
429
+ }
430
+ );
431
+
432
+ vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
433
+
434
+ // Capture the flag state at the exact moment fetch is called — this is
435
+ // the "before the POST fires" assertion. The flag must be flipped BEFORE
436
+ // any network I/O so the subsequent stream-driven renders skip the sheet.
437
+ let flagAtFetch: { awaiting?: boolean; answered?: boolean } | undefined;
438
+ fetchMock.mockImplementationOnce(async () => {
439
+ const toolMsg = session.getMessages().find((m) => m.id === awaiting.id);
440
+ flagAtFetch = {
441
+ awaiting: toolMsg?.agentMetadata?.awaitingLocalTool,
442
+ answered: toolMsg?.agentMetadata?.askUserQuestionAnswered,
443
+ };
444
+ const encoder = new TextEncoder();
445
+ const stream = new ReadableStream({
446
+ start(controller) {
447
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
448
+ controller.close();
449
+ },
450
+ });
451
+ return { ok: true, body: stream };
452
+ });
453
+
454
+ await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
455
+
456
+ expect(flagAtFetch).toEqual({ awaiting: false, answered: true });
457
+
458
+ const finalToolMsg = latest.find((m) => m.id === awaiting.id);
459
+ expect(finalToolMsg?.agentMetadata?.askUserQuestionAnswered).toBe(true);
460
+ expect(finalToolMsg?.agentMetadata?.awaitingLocalTool).toBe(false);
461
+ expect(finalToolMsg?.agentMetadata?.executionId).toBe('exec_abc');
462
+ });
463
+
464
+ it('leaves the answered flag flipped even when resume fails', async () => {
465
+ const awaiting = makeAwaitingMessage();
466
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) });
467
+
468
+ const errors: Error[] = [];
469
+ let latest: AgentWidgetMessage[] = [];
470
+ const session = new AgentWidgetSession(
471
+ {
472
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
473
+ initialMessages: [awaiting],
474
+ },
475
+ {
476
+ onMessagesChanged: (msgs) => { latest = msgs; },
477
+ onStatusChanged: () => {},
478
+ onStreamingChanged: () => {},
479
+ onError: (e) => errors.push(e),
480
+ }
481
+ );
482
+
483
+ await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
484
+
485
+ expect(errors.length).toBe(1);
486
+ const finalToolMsg = latest.find((m) => m.id === awaiting.id);
487
+ expect(finalToolMsg?.agentMetadata?.askUserQuestionAnswered).toBe(true);
488
+ });
489
+
490
+ it('markAskUserQuestionResolved is idempotent and a no-op when the message is not in state', () => {
491
+ const session = new AgentWidgetSession(
492
+ { apiUrl: 'http://localhost:8000' },
493
+ {
494
+ onMessagesChanged: () => {},
495
+ onStatusChanged: () => {},
496
+ onStreamingChanged: () => {},
497
+ }
498
+ );
499
+ // No throw when the tool message isn't tracked in session state
500
+ expect(() => session.markAskUserQuestionResolved(makeAwaitingMessage())).not.toThrow();
501
+ });
502
+
503
+ it('surfaces errors through onError when the message is missing executionId', async () => {
504
+ const errors: Error[] = [];
505
+ const session = new AgentWidgetSession(
506
+ { apiUrl: 'http://localhost:8000' },
507
+ {
508
+ onMessagesChanged: () => {},
509
+ onStatusChanged: () => {},
510
+ onStreamingChanged: () => {},
511
+ onError: (e) => errors.push(e),
512
+ }
513
+ );
514
+
515
+ const bad = makeAwaitingMessage();
516
+ bad.agentMetadata = { ...bad.agentMetadata, executionId: undefined };
517
+
518
+ await session.resolveAskUserQuestion(bad, 'x');
519
+ expect(errors.length).toBe(1);
520
+ expect(errors[0].message).toMatch(/executionId/);
521
+ });
522
+ });