@runtypelabs/persona 4.23.2 → 4.24.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 (156) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
  4. package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +215 -37
  12. package/dist/approval-ui.d.ts +215 -37
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +210 -25
  16. package/dist/artifacts-ui.d.ts +210 -25
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +210 -25
  21. package/dist/context-mentions.d.ts +210 -25
  22. package/dist/event-stream-view.d.cts +210 -25
  23. package/dist/event-stream-view.d.ts +210 -25
  24. package/dist/forms-ui.d.cts +245 -27
  25. package/dist/forms-ui.d.ts +245 -27
  26. package/dist/history-view.d.cts +2 -0
  27. package/dist/history-view.d.ts +2 -0
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +270 -39
  31. package/dist/index.d.ts +270 -39
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +210 -25
  41. package/dist/session-reconnect.d.ts +210 -25
  42. package/dist/smart-dom-reader.d.cts +210 -25
  43. package/dist/smart-dom-reader.d.ts +210 -25
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +217 -25
  46. package/dist/theme-editor-preview.d.ts +217 -25
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +230 -27
  50. package/dist/theme-editor.d.ts +230 -27
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/widget.css +1 -1
  57. package/package.json +3 -2
  58. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  59. package/src/approval-ui-entry.ts +0 -1
  60. package/src/approval-ui-loader.ts +5 -4
  61. package/src/approval-ui.ts +0 -1
  62. package/src/client-identity-proof.test.ts +193 -0
  63. package/src/client-session-warm.test.ts +216 -0
  64. package/src/client-visitor-history.test.ts +6 -0
  65. package/src/client.ts +122 -30
  66. package/src/codegen.test.ts +24 -0
  67. package/src/components/activity-row.test.ts +100 -0
  68. package/src/components/activity-row.ts +136 -0
  69. package/src/components/approval-actions.test.ts +8 -5
  70. package/src/components/approval-actions.ts +19 -6
  71. package/src/components/approval-bubble.test.ts +56 -9
  72. package/src/components/approval-bubble.ts +36 -41
  73. package/src/components/composer-builder.ts +5 -2
  74. package/src/components/composer-parts.ts +10 -2
  75. package/src/components/header-builder.test.ts +32 -0
  76. package/src/components/header-builder.ts +7 -4
  77. package/src/components/header-layouts.test.ts +12 -0
  78. package/src/components/header-layouts.ts +3 -2
  79. package/src/components/launcher.ts +11 -2
  80. package/src/components/message-bubble.test.ts +22 -0
  81. package/src/components/message-bubble.ts +7 -3
  82. package/src/components/panel.test.ts +52 -1
  83. package/src/components/panel.ts +47 -8
  84. package/src/components/pill-composer-builder.test.ts +2 -0
  85. package/src/components/pill-composer-builder.ts +0 -1
  86. package/src/components/reasoning-bubble.ts +29 -17
  87. package/src/components/suggestions.test.ts +1 -0
  88. package/src/components/suggestions.ts +0 -16
  89. package/src/components/tool-bubble.ts +131 -114
  90. package/src/components/tool-details.test.ts +87 -0
  91. package/src/components/tool-details.ts +102 -0
  92. package/src/components/welcome.test.ts +8 -0
  93. package/src/components/welcome.ts +4 -2
  94. package/src/defaults-parity.test.ts +158 -0
  95. package/src/defaults.test.ts +69 -0
  96. package/src/defaults.ts +128 -49
  97. package/src/generated/runtype-openapi-contract.ts +2 -2
  98. package/src/index-core.ts +2 -0
  99. package/src/install.test.ts +24 -0
  100. package/src/install.ts +3 -1
  101. package/src/launcher-global.ts +2 -1
  102. package/src/launcher-variants.test.ts +52 -0
  103. package/src/runtime/init-update-reset.test.ts +21 -0
  104. package/src/session.ts +29 -6
  105. package/src/styles/widget-styles.test.ts +19 -0
  106. package/src/styles/widget.css +295 -23
  107. package/src/theme-editor/index.ts +2 -0
  108. package/src/theme-editor/preview-utils.test.ts +8 -0
  109. package/src/theme-editor/preview-utils.ts +7 -3
  110. package/src/theme-editor/sections.test.ts +31 -0
  111. package/src/theme-editor/sections.ts +55 -1
  112. package/src/theme-editor/state-preferences.test.ts +49 -0
  113. package/src/theme-editor/state.ts +73 -15
  114. package/src/theme-reference.test.ts +20 -0
  115. package/src/theme-reference.ts +11 -5
  116. package/src/tool-call-display-defaults.test.ts +33 -1
  117. package/src/types/theme.ts +80 -3
  118. package/src/types.ts +136 -22
  119. package/src/ui.activity-row.test.ts +91 -0
  120. package/src/ui.approval-plugin.test.ts +63 -0
  121. package/src/ui.composer-compact.test.ts +24 -2
  122. package/src/ui.composer-placement.test.ts +23 -0
  123. package/src/ui.detached-panel.test.ts +33 -6
  124. package/src/ui.docked.test.ts +19 -0
  125. package/src/ui.header-update-stability.test.ts +25 -0
  126. package/src/ui.identity-proof.test.ts +58 -0
  127. package/src/ui.launcher-teaser.test.ts +25 -0
  128. package/src/ui.message-width.test.ts +17 -2
  129. package/src/ui.reasoning-display.test.ts +92 -0
  130. package/src/ui.rebuild-composer.test.ts +20 -3
  131. package/src/ui.scroll.test.ts +32 -0
  132. package/src/ui.session-init.test.ts +312 -0
  133. package/src/ui.status-indicator.test.ts +82 -0
  134. package/src/ui.suggest-replies.test.ts +11 -0
  135. package/src/ui.ts +363 -74
  136. package/src/ui.v5-defaults.test.ts +144 -0
  137. package/src/ui.welcome.test.ts +92 -3
  138. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  139. package/src/utils/code-generators.ts +14 -0
  140. package/src/utils/composer-compact.test.ts +0 -2
  141. package/src/utils/composer-compact.ts +2 -4
  142. package/src/utils/composer-placement.ts +2 -3
  143. package/src/utils/config-merge.test.ts +81 -5
  144. package/src/utils/config-merge.ts +13 -3
  145. package/src/utils/defaults-provenance.ts +67 -0
  146. package/src/utils/dock.ts +7 -1
  147. package/src/utils/launcher-variant.ts +16 -0
  148. package/src/utils/panel-config.ts +97 -0
  149. package/src/utils/status-indicator.ts +39 -0
  150. package/src/utils/theme.test.ts +182 -0
  151. package/src/utils/theme.ts +131 -18
  152. package/src/utils/tokens.test.ts +122 -1
  153. package/src/utils/tokens.ts +177 -17
  154. package/src/welcome.test.ts +23 -0
  155. package/src/welcome.ts +16 -9
  156. package/dist/approval-ui-K7R5Q5OC.js +0 -1
@@ -1,5 +1,8 @@
1
+ import { appendToolDetails } from "./tool-details";
2
+ import { applyActivityRow, activityDisplay, activityVariant } from "./activity-row";
1
3
  import { createElement, createNode } from "../utils/dom";
2
4
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
5
+ import { DEFAULT_TOOL_CALL_DISPLAY } from "../defaults";
3
6
  import { formatUnknownValue, describeToolTitle, resolveToolHeaderText, computeToolElapsed, parseFormattedTemplate } from "../utils/formatting";
4
7
  import { appendCharSpans } from "../utils/tool-loading-animation";
5
8
  import {
@@ -11,19 +14,15 @@ import {
11
14
  updateExpandableBubbleUI,
12
15
  } from "./expandable-bubble";
13
16
 
14
- // Expansion state per widget instance
15
- export const toolExpansionState = new Set<string>();
16
-
17
- // Default the toggle chevron to the tool-call title color so it stays
18
- // readable on whatever surface the title does. The title falls back to
19
- // `.persona-text-persona-primary` (var(--persona-primary)) when no
20
- // `headerTextColor` is set, so mirror that here instead of `currentColor`.
17
+ // Rows inherit their muted label color; cards retain the legacy primary fallback.
18
+ // Explicit toggle and header colors win in both variants.
21
19
  const toolChevronColor = (
22
- toolCallConfig: NonNullable<AgentWidgetConfig["toolCall"]>
20
+ toolCallConfig: NonNullable<AgentWidgetConfig["toolCall"]>,
21
+ activityRow = false
23
22
  ): string =>
24
23
  toolCallConfig.toggleTextColor ||
25
24
  toolCallConfig.headerTextColor ||
26
- "var(--persona-primary, #171717)";
25
+ (activityRow ? "currentColor" : "var(--persona-primary, #171717)");
27
26
 
28
27
  const getToolPreviewText = (message: AgentWidgetMessage, maxLines: number): string => {
29
28
  const tool = message.toolCall;
@@ -72,11 +71,14 @@ const getToolSummaryText = (
72
71
  ): { summary: string; previewText: string; isActive: boolean } => {
73
72
  const tool = message.toolCall;
74
73
  const toolDisplayConfig = config?.features?.toolCallDisplay;
75
- const collapsedMode = toolDisplayConfig?.collapsedMode ?? "tool-call";
74
+ const collapsedMode = toolDisplayConfig?.collapsedMode ?? DEFAULT_TOOL_CALL_DISPLAY.collapsedMode;
76
75
  const previewText = tool?.success === false
77
76
  ? tool.error || "Tool failed"
78
- : getToolPreviewText(message, toolDisplayConfig?.previewMaxLines ?? 3);
79
- const defaultSummary = tool ? describeToolTitle(tool) : "";
77
+ : getToolPreviewText(
78
+ message,
79
+ toolDisplayConfig?.previewMaxLines ?? DEFAULT_TOOL_CALL_DISPLAY.previewMaxLines
80
+ );
81
+ const defaultSummary = tool ? (activityVariant(config, "tool") === "row" ? `${tool.status === "complete" ? "Used" : "Using"} ${tool.name?.trim() || "tool"}` : describeToolTitle(tool)) : "";
80
82
 
81
83
  if (!tool) {
82
84
  return { summary: defaultSummary, previewText, isActive: false };
@@ -103,15 +105,25 @@ const getToolSummaryText = (
103
105
  };
104
106
 
105
107
  // Helper function to update tool bubble UI after expansion state changes
106
- export const updateToolBubbleUI = (messageId: string, bubble: HTMLElement, config?: AgentWidgetConfig): void => {
108
+ export const updateToolBubbleUI = (
109
+ messageId: string,
110
+ bubble: HTMLElement,
111
+ expansionState: Set<string>,
112
+ config?: AgentWidgetConfig
113
+ ): void => {
107
114
  updateExpandableBubbleUI(messageId, bubble, {
108
- stateSet: toolExpansionState,
115
+ stateSet: expansionState,
109
116
  previewKind: "tool",
110
- iconColor: toolChevronColor(config?.toolCall ?? {}),
117
+ iconColor: toolChevronColor(config?.toolCall ?? {}, bubble.classList.contains("persona-activity-row")),
111
118
  });
112
119
  };
113
120
 
114
- export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidgetConfig): HTMLElement => {
121
+ export const createToolBubble = (
122
+ message: AgentWidgetMessage,
123
+ config?: AgentWidgetConfig,
124
+ expansionState = new Set<string>()
125
+ ): HTMLElement => {
126
+ config = activityDisplay(config, "tool");
115
127
  const tool = message.toolCall;
116
128
  const toolCallConfig = config?.toolCall ?? {};
117
129
 
@@ -133,15 +145,15 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
133
145
  bubble.style.boxShadow =
134
146
  toolCallConfig.shadow !== undefined
135
147
  ? (toolCallConfig.shadow.trim() === "" ? "none" : toolCallConfig.shadow)
136
- : "var(--persona-tool-bubble-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
148
+ : "var(--persona-tool-bubble-shadow)";
137
149
 
138
150
  if (!tool) {
139
- return bubble;
151
+ return applyActivityRow(bubble, message, config, "tool");
140
152
  }
141
153
 
142
154
  const toolDisplayConfig = config?.features?.toolCallDisplay ?? {};
143
- const expandable = toolDisplayConfig.expandable !== false;
144
- const expanded = expandable && toolExpansionState.has(message.id);
155
+ const expandable = toolDisplayConfig.expandable ?? DEFAULT_TOOL_CALL_DISPLAY.expandable;
156
+ const expanded = expandable && expansionState.has(message.id);
145
157
  const { summary, previewText, isActive } = getToolSummaryText(message, config);
146
158
 
147
159
  const header = createExpandableHeader({ expandable, expanded, bubbleType: "tool" });
@@ -181,7 +193,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
181
193
  toolCall: tool,
182
194
  defaultSummary: summary,
183
195
  previewText,
184
- collapsedMode: toolDisplayConfig.collapsedMode ?? "tool-call",
196
+ collapsedMode: toolDisplayConfig.collapsedMode ?? DEFAULT_TOOL_CALL_DISPLAY.collapsedMode,
185
197
  isActive,
186
198
  config: config ?? {},
187
199
  elapsed: computeToolElapsed(tool),
@@ -198,7 +210,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
198
210
  }
199
211
 
200
212
  // Apply loading animation when tool is active and no custom HTMLElement was provided
201
- const loadingAnimation = toolDisplayConfig.loadingAnimation ?? "none";
213
+ const loadingAnimation = toolDisplayConfig.loadingAnimation ?? DEFAULT_TOOL_CALL_DISPLAY.loadingAnimation;
202
214
  const activeTemplate = toolCallConfig.activeTextTemplate;
203
215
  const completeTemplate = toolCallConfig.completeTextTemplate;
204
216
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
@@ -280,7 +292,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
280
292
  }
281
293
  }
282
294
 
283
- const iconColor = toolChevronColor(toolCallConfig);
295
+ const iconColor = toolChevronColor(toolCallConfig, activityVariant(config, "tool") === "row");
284
296
  const toggleIcon = appendHeaderToggle(header, headerContent, {
285
297
  expandable,
286
298
  expanded,
@@ -308,7 +320,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
308
320
 
309
321
  if (!expandable) {
310
322
  bubble.append(header, collapsedPreview);
311
- return bubble;
323
+ return applyActivityRow(bubble, message, config, "tool");
312
324
  }
313
325
 
314
326
  const content = createElement(
@@ -333,109 +345,114 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
333
345
  content.style.paddingBottom = toolCallConfig.contentPaddingY;
334
346
  }
335
347
 
336
- // Add tool name at the top of content
337
- if (tool.name) {
338
- const toolName = createElement("div", "persona-text-xs persona-text-persona-muted persona-italic");
339
- if (toolCallConfig.contentTextColor) {
340
- toolName.style.color = toolCallConfig.contentTextColor;
341
- } else if (toolCallConfig.headerTextColor) {
342
- toolName.style.color = toolCallConfig.headerTextColor;
348
+ if (config.future?.v5Defaults === true) {
349
+ appendToolDetails(content, message, config);
350
+ } else {
351
+ // Add tool name at the top of content
352
+ if (tool.name) {
353
+ const toolName = createElement("div", "persona-text-xs persona-text-persona-muted persona-italic");
354
+ if (toolCallConfig.contentTextColor) {
355
+ toolName.style.color = toolCallConfig.contentTextColor;
356
+ } else if (toolCallConfig.headerTextColor) {
357
+ toolName.style.color = toolCallConfig.headerTextColor;
358
+ }
359
+ toolName.textContent = tool.name;
360
+ content.appendChild(toolName);
343
361
  }
344
- toolName.textContent = tool.name;
345
- content.appendChild(toolName);
346
- }
347
362
 
348
- if (tool.args !== undefined) {
349
- const argsBlock = createElement("div", "persona-space-y-1");
350
- const argsLabel = createElement(
351
- "div",
352
- "persona-text-xs persona-text-persona-muted"
353
- );
354
- if (toolCallConfig.labelTextColor) {
355
- argsLabel.style.color = toolCallConfig.labelTextColor;
363
+ if (tool.args !== undefined) {
364
+ const argsBlock = createElement("div", "persona-space-y-1");
365
+ const argsLabel = createElement(
366
+ "div",
367
+ "persona-text-xs persona-text-persona-muted"
368
+ );
369
+ if (toolCallConfig.labelTextColor) {
370
+ argsLabel.style.color = toolCallConfig.labelTextColor;
371
+ }
372
+ argsLabel.textContent = "Arguments";
373
+ const argsPre = createElement(
374
+ "pre",
375
+ "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
376
+ );
377
+ // Ensure font size matches header text (0.75rem / 12px)
378
+ argsPre.style.fontSize = "0.75rem";
379
+ argsPre.style.lineHeight = "1rem";
380
+ applyToolCodeBlockColors(argsPre, toolCallConfig);
381
+ argsPre.textContent = formatUnknownValue(tool.args);
382
+ argsBlock.append(argsLabel, argsPre);
383
+ content.appendChild(argsBlock);
356
384
  }
357
- argsLabel.textContent = "Arguments";
358
- const argsPre = createElement(
359
- "pre",
360
- "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
361
- );
362
- // Ensure font size matches header text (0.75rem / 12px)
363
- argsPre.style.fontSize = "0.75rem";
364
- argsPre.style.lineHeight = "1rem";
365
- applyToolCodeBlockColors(argsPre, toolCallConfig);
366
- argsPre.textContent = formatUnknownValue(tool.args);
367
- argsBlock.append(argsLabel, argsPre);
368
- content.appendChild(argsBlock);
369
- }
370
385
 
371
- if (tool.chunks && tool.chunks.length) {
372
- const logsBlock = createElement("div", "persona-space-y-1");
373
- const logsLabel = createElement(
374
- "div",
375
- "persona-text-xs persona-text-persona-muted"
376
- );
377
- if (toolCallConfig.labelTextColor) {
378
- logsLabel.style.color = toolCallConfig.labelTextColor;
386
+ if (tool.chunks && tool.chunks.length) {
387
+ const logsBlock = createElement("div", "persona-space-y-1");
388
+ const logsLabel = createElement(
389
+ "div",
390
+ "persona-text-xs persona-text-persona-muted"
391
+ );
392
+ if (toolCallConfig.labelTextColor) {
393
+ logsLabel.style.color = toolCallConfig.labelTextColor;
394
+ }
395
+ logsLabel.textContent = "Activity";
396
+ const logsPre = createElement(
397
+ "pre",
398
+ "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
399
+ );
400
+ // Ensure font size matches header text (0.75rem / 12px)
401
+ logsPre.style.fontSize = "0.75rem";
402
+ logsPre.style.lineHeight = "1rem";
403
+ applyToolCodeBlockColors(logsPre, toolCallConfig);
404
+ logsPre.textContent = tool.chunks.join("");
405
+ logsBlock.append(logsLabel, logsPre);
406
+ content.appendChild(logsBlock);
379
407
  }
380
- logsLabel.textContent = "Activity";
381
- const logsPre = createElement(
382
- "pre",
383
- "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
384
- );
385
- // Ensure font size matches header text (0.75rem / 12px)
386
- logsPre.style.fontSize = "0.75rem";
387
- logsPre.style.lineHeight = "1rem";
388
- applyToolCodeBlockColors(logsPre, toolCallConfig);
389
- logsPre.textContent = tool.chunks.join("");
390
- logsBlock.append(logsLabel, logsPre);
391
- content.appendChild(logsBlock);
392
- }
393
408
 
394
- if (tool.success === false) {
395
- content.appendChild(createNode("div", {
396
- className: "persona-text-sm persona-whitespace-pre-wrap",
397
- text: tool.error || "Tool failed",
398
- attrs: { "data-persona-tool-error": "" },
399
- }));
400
- }
409
+ if (tool.success === false) {
410
+ content.appendChild(createNode("div", {
411
+ className: "persona-text-sm persona-whitespace-pre-wrap",
412
+ text: tool.error || "Tool failed",
413
+ attrs: { "data-persona-tool-error": "" },
414
+ }));
415
+ }
401
416
 
402
- if (tool.status === "complete" && tool.result !== undefined) {
403
- const resultBlock = createElement("div", "persona-space-y-1");
404
- const resultLabel = createElement(
405
- "div",
406
- "persona-text-xs persona-text-persona-muted"
407
- );
408
- if (toolCallConfig.labelTextColor) {
409
- resultLabel.style.color = toolCallConfig.labelTextColor;
417
+ if (tool.status === "complete" && tool.result !== undefined) {
418
+ const resultBlock = createElement("div", "persona-space-y-1");
419
+ const resultLabel = createElement(
420
+ "div",
421
+ "persona-text-xs persona-text-persona-muted"
422
+ );
423
+ if (toolCallConfig.labelTextColor) {
424
+ resultLabel.style.color = toolCallConfig.labelTextColor;
425
+ }
426
+ resultLabel.textContent = "Result";
427
+ const resultPre = createElement(
428
+ "pre",
429
+ "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
430
+ );
431
+ // Ensure font size matches header text (0.75rem / 12px)
432
+ resultPre.style.fontSize = "0.75rem";
433
+ resultPre.style.lineHeight = "1rem";
434
+ applyToolCodeBlockColors(resultPre, toolCallConfig);
435
+ resultPre.textContent = formatUnknownValue(tool.result);
436
+ resultBlock.append(resultLabel, resultPre);
437
+ content.appendChild(resultBlock);
410
438
  }
411
- resultLabel.textContent = "Result";
412
- const resultPre = createElement(
413
- "pre",
414
- "persona-max-h-48 persona-overflow-auto persona-whitespace-pre-wrap persona-rounded-lg persona-border persona-px-3 persona-py-2 persona-text-xs"
415
- );
416
- // Ensure font size matches header text (0.75rem / 12px)
417
- resultPre.style.fontSize = "0.75rem";
418
- resultPre.style.lineHeight = "1rem";
419
- applyToolCodeBlockColors(resultPre, toolCallConfig);
420
- resultPre.textContent = formatUnknownValue(tool.result);
421
- resultBlock.append(resultLabel, resultPre);
422
- content.appendChild(resultBlock);
423
- }
424
439
 
425
- if (tool.status === "complete" && typeof tool.duration === "number") {
426
- const duration = createElement(
427
- "div",
428
- "persona-text-xs persona-text-persona-muted"
429
- );
430
- if (toolCallConfig.contentTextColor) {
431
- duration.style.color = toolCallConfig.contentTextColor;
440
+ if (tool.status === "complete" && typeof tool.duration === "number") {
441
+ const duration = createElement(
442
+ "div",
443
+ "persona-text-xs persona-text-persona-muted"
444
+ );
445
+ if (toolCallConfig.contentTextColor) {
446
+ duration.style.color = toolCallConfig.contentTextColor;
447
+ }
448
+ duration.textContent = `Duration: ${tool.duration}ms`;
449
+ content.appendChild(duration);
432
450
  }
433
- duration.textContent = `Duration: ${tool.duration}ms`;
434
- content.appendChild(duration);
451
+
435
452
  }
436
453
 
437
454
  applyExpansionDisplay({ expanded, header, toggleIcon, content, collapsedPreview, iconColor });
438
455
 
439
456
  bubble.append(header, collapsedPreview, content);
440
- return bubble;
457
+ return applyActivityRow(bubble, message, config, "tool");
441
458
  };
@@ -0,0 +1,87 @@
1
+ // @vitest-environment jsdom
2
+ import { readFileSync } from "node:fs";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createToolBubble } from "./tool-bubble";
5
+ import { copyToolDetail } from "./tool-details";
6
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
7
+
8
+ const message = (tool: Partial<NonNullable<AgentWidgetMessage["toolCall"]>> = {}): AgentWidgetMessage => ({
9
+ id: "tool", role: "assistant", content: "", createdAt: "2026-01-01", variant: "tool",
10
+ toolCall: { id: "call", name: "Search", status: "complete", ...tool },
11
+ });
12
+ const render = (tool: Partial<NonNullable<AgentWidgetMessage["toolCall"]>>, config: AgentWidgetConfig = {}) =>
13
+ createToolBubble(message(tool), { future: { v5Defaults: true }, ...config }, new Set(["tool"]));
14
+ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
15
+
16
+ describe("V5 tool details", () => {
17
+ it.each([undefined, null, "", " ", {}, [], "{}", "[]", "null"].map(value => [value]))("omits empty request %j", args => {
18
+ const bubble = render({ args, result: "Done" });
19
+ expect(bubble.querySelector('[data-persona-tool-detail="request"]')).toBeNull();
20
+ expect(bubble.querySelector('[data-persona-tool-detail="response"] pre')?.textContent).toBe("Done");
21
+ });
22
+ it.each([0, false, { count: 0 }])("keeps meaningful request %j", args => {
23
+ expect(render({ args }).querySelector('[data-persona-tool-detail="request"]')).not.toBeNull();
24
+ });
25
+ it("shows chunks during streaming and replaces them with the final result", () => {
26
+ const running = render({ status: "running", chunks: ["Found ", "one"], result: "stale" });
27
+ expect(running.querySelector("pre")?.textContent).toBe("Found one");
28
+ const done = render({ chunks: ["Found one"], result: { count: 1 } });
29
+ expect(done.querySelectorAll('[data-persona-tool-detail="response"]')).toHaveLength(1);
30
+ expect(done.querySelector("pre")?.textContent).toBe('{\n "count": 1\n}');
31
+ expect(done.querySelector(".persona-tool-detail-code")).not.toBeNull();
32
+ });
33
+ it("keeps errors and escapes output, with plain prose typography", () => {
34
+ const bubble = render({ result: '<img src=x onerror="alert(1)">', success: false, error: "Denied" });
35
+ expect(bubble.querySelector("img")).toBeNull();
36
+ expect(bubble.querySelector(".persona-tool-detail-code")).toBeNull();
37
+ expect(bubble.querySelector("[data-persona-tool-error]")?.textContent).toBe("Denied");
38
+ });
39
+ it("preserves V4 sections and explicit V5 styles", () => {
40
+ const legacy = createToolBubble(message({ args: {}, chunks: ["progress"], result: "done" }));
41
+ expect(legacy.textContent).toContain("Arguments");
42
+ expect(legacy.textContent).toContain("Activity");
43
+ expect(legacy.textContent).toContain("Result");
44
+ expect(legacy.querySelector(".persona-tool-detail")).toBeNull();
45
+ const custom = render({ result: "done" }, { toolCall: {
46
+ codeBlockBackgroundColor: "red", codeBlockTextColor: "blue", codeBlockBorderColor: "green", labelTextColor: "purple",
47
+ } });
48
+ expect(custom.querySelector<HTMLElement>(".persona-tool-detail")?.style.backgroundColor).toBe("red");
49
+ expect(custom.querySelector<HTMLElement>("pre")?.style.color).toBe("blue");
50
+ expect(custom.querySelector<HTMLElement>(".persona-tool-detail-label")?.style.color).toBe("purple");
51
+ });
52
+ it("uses a flat surface instead of inheriting markdown code boxes", () => {
53
+ const style = document.createElement("style");
54
+ style.textContent = readFileSync("src/styles/widget.css", "utf8");
55
+ document.head.appendChild(style);
56
+ const bubble = render({ result: "Plain response" });
57
+ document.body.appendChild(bubble);
58
+ try {
59
+ const pre = bubble.querySelector("pre")!;
60
+ expect(getComputedStyle(pre).padding).toBe("0px");
61
+ expect(["transparent", "rgba(0, 0, 0, 0)"]).toContain(getComputedStyle(pre).backgroundColor);
62
+ expect(getComputedStyle(pre).fontSize).toBe("13px");
63
+ } finally { bubble.remove(); style.remove(); }
64
+ });
65
+ it("copies all output, shows a check, and restores the copy icon", async () => {
66
+ vi.useFakeTimers();
67
+ const writeText = vi.fn().mockResolvedValue(undefined);
68
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
69
+ const text = "Long output\n".repeat(50);
70
+ const button = render({ result: text }).querySelector<HTMLButtonElement>(".persona-tool-detail-copy")!;
71
+ await copyToolDetail(button);
72
+ expect(writeText).toHaveBeenCalledWith(text);
73
+ expect(button.dataset.copied).toBe("true");
74
+ expect(button.getAttribute("aria-label")).toBe("Copied");
75
+ vi.advanceTimersByTime(1800);
76
+ expect(button.dataset.copied).toBeUndefined();
77
+ expect(button.getAttribute("aria-label")).toBe("Copy response");
78
+ });
79
+ it("handles unavailable clipboard without claiming success", async () => {
80
+ vi.useFakeTimers();
81
+ vi.stubGlobal("navigator", {});
82
+ const button = render({ result: "done" }).querySelector<HTMLButtonElement>("button[data-persona-copy-tool-detail]")!;
83
+ await copyToolDetail(button);
84
+ expect(button.getAttribute("aria-label")).toBe("Copy failed — try again");
85
+ expect(button.dataset.copied).toBeUndefined();
86
+ });
87
+ });
@@ -0,0 +1,102 @@
1
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
2
+ import { createNode } from "../utils/dom";
3
+ import { formatUnknownValue } from "../utils/formatting";
4
+ import { renderLucideIcon } from "../utils/icons";
5
+
6
+ const isEmptyRequest = (value: unknown): boolean => {
7
+ if (value == null) return true;
8
+ if (typeof value === "string") {
9
+ if (!value.trim()) return true;
10
+ try { return isEmptyRequest(JSON.parse(value)); } catch { return false; }
11
+ }
12
+ return typeof value === "object" && Object.keys(value).length === 0;
13
+ };
14
+
15
+ const isStructured = (value: unknown): boolean => {
16
+ if (value !== null && typeof value === "object") return true;
17
+ if (typeof value !== "string") return false;
18
+ try {
19
+ const parsed: unknown = JSON.parse(value);
20
+ return parsed !== null && typeof parsed === "object";
21
+ } catch { return false; }
22
+ };
23
+
24
+ /** Plain text only: tool output is untrusted and must never become HTML. */
25
+ export function appendToolDetails(content: HTMLElement, message: AgentWidgetMessage, config: AgentWidgetConfig): void {
26
+ const tool = message.toolCall;
27
+ if (!tool) return;
28
+ const styles = config.toolCall ?? {};
29
+ const section = (label: "Request" | "Response", value: unknown) => {
30
+ const block = createNode("section", {
31
+ className: "persona-tool-detail",
32
+ attrs: { "data-persona-tool-detail": label.toLowerCase() },
33
+ style: {
34
+ backgroundColor: styles.codeBlockBackgroundColor,
35
+ borderColor: styles.codeBlockBorderColor,
36
+ borderWidth: styles.codeBlockBorderColor ? "1px" : undefined,
37
+ },
38
+ });
39
+ const title = createNode("div", {
40
+ className: "persona-tool-detail-label",
41
+ text: label,
42
+ style: { color: styles.labelTextColor },
43
+ });
44
+ const text = formatUnknownValue(value);
45
+ const pre = createNode("pre", {
46
+ className: `persona-tool-detail-value${isStructured(value) ? " persona-tool-detail-code" : ""}`,
47
+ text,
48
+ attrs: text.length > 420 || text.split("\n").length > 7
49
+ ? { tabindex: "0", "aria-label": `${label} content` } : {},
50
+ style: { color: styles.codeBlockTextColor ?? styles.contentTextColor },
51
+ });
52
+ const copy = createNode("button", {
53
+ className: "persona-tool-detail-copy",
54
+ attrs: { type: "button", "data-persona-copy-tool-detail": "", "aria-label": `Copy ${label.toLowerCase()}`, title: `Copy ${label.toLowerCase()}` },
55
+ });
56
+ const icon = renderLucideIcon("copy", 14, "currentColor", 1.7);
57
+ if (icon) copy.appendChild(icon);
58
+ block.append(title, pre, copy);
59
+ content.appendChild(block);
60
+ };
61
+ if (!isEmptyRequest(tool.args)) section("Request", tool.args);
62
+ const response = tool.status === "complete" && tool.result !== undefined
63
+ ? tool.result : tool.chunks?.join("");
64
+ if (response !== undefined && (typeof response !== "string" || response.trim())) section("Response", response);
65
+ if (tool.success === false) {
66
+ content.appendChild(createNode("div", {
67
+ className: "persona-text-sm persona-whitespace-pre-wrap",
68
+ text: tool.error || "Tool failed",
69
+ attrs: { "data-persona-tool-error": "", role: "status" },
70
+ }));
71
+ }
72
+ }
73
+
74
+ const copyResets = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
75
+
76
+ /** Delegated by the transcript so copying works after streaming DOM morphs. */
77
+ export async function copyToolDetail(button: HTMLButtonElement): Promise<void> {
78
+ const block = button.closest("[data-persona-tool-detail]");
79
+ const value = block?.querySelector("pre");
80
+ if (!value) return;
81
+ const label = `Copy ${block?.getAttribute("data-persona-tool-detail")}`;
82
+ clearTimeout(copyResets.get(button));
83
+ try {
84
+ await navigator.clipboard.writeText(value.textContent ?? "");
85
+ button.dataset.copied = "true";
86
+ button.setAttribute("aria-label", "Copied");
87
+ button.title = "Copied";
88
+ const icon = renderLucideIcon("check", 14, "currentColor", 2);
89
+ if (icon) button.replaceChildren(icon);
90
+ } catch {
91
+ button.setAttribute("aria-label", "Copy failed — try again");
92
+ button.title = "Copy failed — try again";
93
+ }
94
+ copyResets.set(button, setTimeout(() => {
95
+ delete button.dataset.copied;
96
+ button.setAttribute("aria-label", label);
97
+ button.title = label;
98
+ const icon = renderLucideIcon("copy", 14, "currentColor", 1.7);
99
+ if (icon) button.replaceChildren(icon);
100
+ copyResets.delete(button);
101
+ }, 1800));
102
+ }
@@ -151,6 +151,14 @@ describe("composer lift animation", () => {
151
151
  footer.remove();
152
152
  });
153
153
 
154
+ it("accepts the centered layout's 320ms transition", () => {
155
+ const footer = makeFooter();
156
+ const animate = stubAnimate(footer);
157
+ animateComposerLiftChange(footer, 240, "drop", 320);
158
+ expect(animate.mock.calls[0]![1]).toMatchObject({ duration: 320 });
159
+ footer.remove();
160
+ });
161
+
154
162
  it("reverses the travel for the rise", () => {
155
163
  const footer = makeFooter();
156
164
  const animate = stubAnimate(footer);
@@ -177,6 +177,7 @@ export const applyWelcomeConfig = (
177
177
  applyWelcomeIcon(elements.iconHolder, resolved.icon);
178
178
  elements.host.setAttribute("data-persona-welcome-variant", resolved.variant);
179
179
  elements.host.setAttribute("data-persona-welcome-dismiss", resolved.dismiss);
180
+ elements.host.setAttribute("data-persona-welcome-layout", resolved.layout ?? "top");
180
181
  elements.host.setAttribute(
181
182
  "data-persona-welcome-anchor",
182
183
  resolved.anchor ?? "bottom"
@@ -422,7 +423,8 @@ export const animateWelcomeOut = (
422
423
  export const animateComposerLiftChange = (
423
424
  footer: HTMLElement,
424
425
  distance: number,
425
- direction: "drop" | "rise"
426
+ direction: "drop" | "rise",
427
+ duration = 260
426
428
  ): Animation | null => {
427
429
  if (
428
430
  distance <= 1 ||
@@ -437,6 +439,6 @@ export const animateComposerLiftChange = (
437
439
  // risk pinning a stale transform across a later re-show.
438
440
  return footer.animate(
439
441
  [{ transform: `translateY(${offset}px)` }, { transform: "none" }],
440
- { duration: 260, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "none" }
442
+ { duration, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "none" }
441
443
  );
442
444
  };