@runtypelabs/persona 4.23.1 → 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 (158) 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-9Vxm-PPz.d.cts → types-Db61aI9J.d.cts} +25 -2
  4. package/dist/animations/{types-9Vxm-PPz.d.ts → types-Db61aI9J.d.ts} +25 -2
  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 +25 -2
  8. package/dist/animations-extra.d.ts +25 -2
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +261 -40
  12. package/dist/approval-ui.d.ts +261 -40
  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 +256 -28
  16. package/dist/artifacts-ui.d.ts +256 -28
  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 +256 -28
  21. package/dist/context-mentions.d.ts +256 -28
  22. package/dist/event-stream-view.d.cts +256 -28
  23. package/dist/event-stream-view.d.ts +256 -28
  24. package/dist/forms-ui.d.cts +291 -30
  25. package/dist/forms-ui.d.ts +291 -30
  26. package/dist/history-view.d.cts +25 -2
  27. package/dist/history-view.d.ts +25 -2
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +337 -51
  31. package/dist/index.d.ts +337 -51
  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 +256 -28
  41. package/dist/session-reconnect.d.ts +256 -28
  42. package/dist/smart-dom-reader.d.cts +256 -28
  43. package/dist/smart-dom-reader.d.ts +256 -28
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +263 -28
  46. package/dist/theme-editor-preview.d.ts +263 -28
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +276 -30
  50. package/dist/theme-editor.d.ts +276 -30
  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.tool-pair-replay.test.ts +325 -0
  66. package/src/client.ts +154 -61
  67. package/src/codegen.test.ts +24 -0
  68. package/src/components/activity-row.test.ts +100 -0
  69. package/src/components/activity-row.ts +136 -0
  70. package/src/components/approval-actions.test.ts +8 -5
  71. package/src/components/approval-actions.ts +19 -6
  72. package/src/components/approval-bubble.test.ts +56 -9
  73. package/src/components/approval-bubble.ts +36 -41
  74. package/src/components/composer-builder.ts +5 -2
  75. package/src/components/composer-parts.ts +10 -2
  76. package/src/components/header-builder.test.ts +32 -0
  77. package/src/components/header-builder.ts +7 -4
  78. package/src/components/header-layouts.test.ts +12 -0
  79. package/src/components/header-layouts.ts +3 -2
  80. package/src/components/launcher.ts +11 -2
  81. package/src/components/message-bubble.test.ts +22 -0
  82. package/src/components/message-bubble.ts +7 -3
  83. package/src/components/panel.test.ts +52 -1
  84. package/src/components/panel.ts +47 -8
  85. package/src/components/pill-composer-builder.test.ts +2 -0
  86. package/src/components/pill-composer-builder.ts +0 -1
  87. package/src/components/reasoning-bubble.ts +29 -17
  88. package/src/components/suggestions.test.ts +1 -0
  89. package/src/components/suggestions.ts +0 -16
  90. package/src/components/tool-bubble.ts +131 -114
  91. package/src/components/tool-details.test.ts +87 -0
  92. package/src/components/tool-details.ts +102 -0
  93. package/src/components/welcome.test.ts +8 -0
  94. package/src/components/welcome.ts +4 -2
  95. package/src/defaults-parity.test.ts +158 -0
  96. package/src/defaults.test.ts +69 -0
  97. package/src/defaults.ts +128 -49
  98. package/src/generated/runtype-openapi-contract.ts +28 -13
  99. package/src/index-core.ts +2 -0
  100. package/src/install.test.ts +24 -0
  101. package/src/install.ts +3 -1
  102. package/src/launcher-global.ts +2 -1
  103. package/src/launcher-variants.test.ts +52 -0
  104. package/src/runtime/init-update-reset.test.ts +21 -0
  105. package/src/session.ts +61 -9
  106. package/src/styles/widget-styles.test.ts +19 -0
  107. package/src/styles/widget.css +295 -23
  108. package/src/theme-editor/index.ts +2 -0
  109. package/src/theme-editor/preview-utils.test.ts +8 -0
  110. package/src/theme-editor/preview-utils.ts +7 -3
  111. package/src/theme-editor/sections.test.ts +31 -0
  112. package/src/theme-editor/sections.ts +55 -1
  113. package/src/theme-editor/state-preferences.test.ts +49 -0
  114. package/src/theme-editor/state.ts +73 -15
  115. package/src/theme-reference.test.ts +20 -0
  116. package/src/theme-reference.ts +11 -5
  117. package/src/tool-call-display-defaults.test.ts +33 -1
  118. package/src/types/theme.ts +80 -3
  119. package/src/types.ts +179 -23
  120. package/src/ui.activity-row.test.ts +91 -0
  121. package/src/ui.approval-plugin.test.ts +63 -0
  122. package/src/ui.composer-compact.test.ts +24 -2
  123. package/src/ui.composer-placement.test.ts +23 -0
  124. package/src/ui.detached-panel.test.ts +33 -6
  125. package/src/ui.docked.test.ts +19 -0
  126. package/src/ui.header-update-stability.test.ts +25 -0
  127. package/src/ui.identity-proof.test.ts +58 -0
  128. package/src/ui.launcher-teaser.test.ts +25 -0
  129. package/src/ui.message-width.test.ts +17 -2
  130. package/src/ui.reasoning-display.test.ts +92 -0
  131. package/src/ui.rebuild-composer.test.ts +20 -3
  132. package/src/ui.scroll.test.ts +32 -0
  133. package/src/ui.session-init.test.ts +312 -0
  134. package/src/ui.status-indicator.test.ts +82 -0
  135. package/src/ui.suggest-replies.test.ts +11 -0
  136. package/src/ui.ts +363 -74
  137. package/src/ui.v5-defaults.test.ts +144 -0
  138. package/src/ui.welcome.test.ts +92 -3
  139. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  140. package/src/utils/code-generators.ts +14 -0
  141. package/src/utils/composer-compact.test.ts +0 -2
  142. package/src/utils/composer-compact.ts +2 -4
  143. package/src/utils/composer-placement.ts +2 -3
  144. package/src/utils/config-merge.test.ts +81 -5
  145. package/src/utils/config-merge.ts +13 -3
  146. package/src/utils/defaults-provenance.ts +67 -0
  147. package/src/utils/dock.ts +7 -1
  148. package/src/utils/launcher-variant.ts +16 -0
  149. package/src/utils/panel-config.ts +97 -0
  150. package/src/utils/status-indicator.ts +39 -0
  151. package/src/utils/theme.test.ts +182 -0
  152. package/src/utils/theme.ts +131 -18
  153. package/src/utils/tokens.test.ts +122 -1
  154. package/src/utils/tokens.ts +177 -17
  155. package/src/utils/tool-pair-replay.ts +100 -0
  156. package/src/welcome.test.ts +23 -0
  157. package/src/welcome.ts +16 -9
  158. package/dist/approval-ui-K7R5Q5OC.js +0 -1
@@ -115,7 +115,6 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
115
115
  const composerForm = createNode("form", {
116
116
  className: "persona-widget-composer persona-pill-composer",
117
117
  attrs: { "data-persona-composer-form": "" },
118
- style: { outline: "none" },
119
118
  });
120
119
 
121
120
  // Three columns of the grid: [paperclip?] · textarea · mic + send.
@@ -1,6 +1,8 @@
1
+ import { applyActivityRow, activityDisplay, activityVariant, activityDuration } from "./activity-row";
1
2
  import { createElement } from "../utils/dom";
2
3
  import { renderLucideIcon } from "../utils/icons";
3
4
  import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
5
+ import { DEFAULT_REASONING_DISPLAY } from "../defaults";
4
6
  import { describeReasonStatus, computeReasoningElapsed, parseFormattedTemplate } from "../utils/formatting";
5
7
  import { appendCharSpans } from "../utils/tool-loading-animation";
6
8
  import {
@@ -12,9 +14,6 @@ import {
12
14
  updateExpandableBubbleUI,
13
15
  } from "./expandable-bubble";
14
16
 
15
- // Expansion state per widget instance
16
- export const reasoningExpansionState = new Set<string>();
17
-
18
17
  const getReasoningPreviewText = (message: AgentWidgetMessage, maxLines: number): string => {
19
18
  const text = message.reasoning?.chunks.join("").trim() ?? "";
20
19
  if (!text) return "";
@@ -27,32 +26,45 @@ const getReasoningPreviewText = (message: AgentWidgetMessage, maxLines: number):
27
26
  };
28
27
 
29
28
  // Helper function to update reasoning bubble UI after expansion state changes
30
- export const updateReasoningBubbleUI = (messageId: string, bubble: HTMLElement): void => {
29
+ export const updateReasoningBubbleUI = (
30
+ messageId: string,
31
+ bubble: HTMLElement,
32
+ expansionState: Set<string>
33
+ ): void => {
31
34
  updateExpandableBubbleUI(messageId, bubble, {
32
- stateSet: reasoningExpansionState,
35
+ stateSet: expansionState,
33
36
  previewKind: "reasoning",
34
37
  iconColor: "currentColor",
35
38
  });
36
39
  };
37
40
 
38
- export const createReasoningBubble = (message: AgentWidgetMessage, config?: AgentWidgetConfig): HTMLElement => {
41
+ export const createReasoningBubble = (
42
+ message: AgentWidgetMessage,
43
+ config?: AgentWidgetConfig,
44
+ expansionState = new Set<string>()
45
+ ): HTMLElement => {
46
+ config = activityDisplay(config, "reasoning");
39
47
  const reasoning = message.reasoning;
40
48
  const bubble = createExpandableBubbleShell("persona-reasoning-bubble", message.id);
41
49
 
42
50
  if (!reasoning) {
43
- return bubble;
51
+ return applyActivityRow(bubble, message, config, "reasoning");
44
52
  }
45
53
 
46
54
  const reasoningDisplayConfig = config?.features?.reasoningDisplay ?? {};
47
- const expandable = reasoningDisplayConfig.expandable !== false;
48
- const expanded = expandable && reasoningExpansionState.has(message.id);
55
+ const expandable = reasoningDisplayConfig.expandable ?? DEFAULT_REASONING_DISPLAY.expandable;
56
+ const expanded = expandable && expansionState.has(message.id);
49
57
  const isActive = reasoning.status !== "complete";
50
- const previewText = getReasoningPreviewText(message, reasoningDisplayConfig.previewMaxLines ?? 3);
58
+ const previewText = getReasoningPreviewText(
59
+ message,
60
+ reasoningDisplayConfig.previewMaxLines ?? DEFAULT_REASONING_DISPLAY.previewMaxLines
61
+ );
51
62
  const header = createExpandableHeader({ expandable, expanded, bubbleType: "reasoning" });
52
63
 
53
64
  const headerContent = createElement("div", "persona-flex persona-flex-col persona-text-left");
54
65
  const title = createElement("span", "persona-text-xs persona-text-persona-primary");
55
- const defaultSummary = "Thinking...";
66
+ const row = activityVariant(config, "reasoning") === "row";
67
+ const defaultSummary = row && !isActive ? `Thought for ${activityDuration(reasoning.durationMs ?? (reasoning.completedAt !== undefined && reasoning.startedAt !== undefined ? reasoning.completedAt - reasoning.startedAt : undefined))}` : "Thinking...";
56
68
  const reasoningConfig = config?.reasoning ?? {};
57
69
 
58
70
  // Elapsed helpers: defined early so they're available to renderCollapsedSummary
@@ -88,10 +100,10 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
88
100
  // Status span: used in the legacy (no-template) path
89
101
  const status = createElement("span", "persona-text-xs persona-text-persona-primary");
90
102
  status.textContent = describeReasonStatus(reasoning);
91
- headerContent.appendChild(status);
103
+ if (!row) headerContent.appendChild(status);
92
104
 
93
105
  // Template and animation support
94
- const loadingAnimation = reasoningDisplayConfig.loadingAnimation ?? "none";
106
+ const loadingAnimation = reasoningDisplayConfig.loadingAnimation ?? DEFAULT_REASONING_DISPLAY.loadingAnimation;
95
107
  const activeTemplate = reasoningConfig.activeTextTemplate;
96
108
  const completeTemplate = reasoningConfig.completeTextTemplate;
97
109
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
@@ -190,12 +202,12 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
190
202
  }
191
203
 
192
204
  // Legacy: hide title on complete, show status
193
- if (reasoning.status === "complete") {
205
+ if (reasoning.status === "complete" && !row) {
194
206
  title.style.display = "none";
195
207
  }
196
208
  } else if (!skipCustomElement) {
197
209
  // Legacy path: no template, no animation
198
- if (reasoning.status === "complete") {
210
+ if (reasoning.status === "complete" && !row) {
199
211
  title.style.display = "none";
200
212
  } else {
201
213
  title.style.display = "";
@@ -246,7 +258,7 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
246
258
 
247
259
  if (!expandable) {
248
260
  bubble.append(header, collapsedPreview);
249
- return bubble;
261
+ return applyActivityRow(bubble, message, config, "reasoning");
250
262
  }
251
263
 
252
264
  const content = createElement(
@@ -270,5 +282,5 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
270
282
  applyExpansionDisplay({ expanded, header, toggleIcon, content, collapsedPreview, iconColor });
271
283
 
272
284
  bubble.append(header, collapsedPreview, content);
273
- return bubble;
285
+ return applyActivityRow(bubble, message, config, "reasoning");
274
286
  };
@@ -257,6 +257,7 @@ describe("legacy suggestionChipsConfig scoping", () => {
257
257
  expect(button.style.paddingLeft).toBe("12px");
258
258
  expect(button.style.paddingTop).toBe("6px");
259
259
  expect(button.style.fontWeight).toBe("500");
260
+ expect(button.style.fontFamily).toBe("");
260
261
  });
261
262
 
262
263
  it("does not let chip config override card and list padding tokens", () => {
@@ -97,19 +97,6 @@ export const normalizeSuggestion = (
97
97
  };
98
98
  };
99
99
 
100
- const fontFamilyValue = (
101
- family: "sans-serif" | "serif" | "mono"
102
- ): string => {
103
- switch (family) {
104
- case "serif":
105
- return 'Georgia, "Times New Roman", Times, serif';
106
- case "mono":
107
- return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
108
- default:
109
- return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
110
- }
111
- };
112
-
113
100
  // Module-level, keyed on the widget config: one widget owns several suggestion
114
101
  // managers, so a per-instance flag hints once per surface instead of once.
115
102
  const hintedOverflowVariant = new WeakSet<AgentWidgetConfig>();
@@ -264,9 +251,6 @@ export const createSuggestionElement = (
264
251
  // Legacy `suggestionChipsConfig` compat is chip-only: its inline
265
252
  // styles would otherwise override the card/list padding tokens.
266
253
  if (variant === "chip") {
267
- if (chipsConfig?.fontFamily) {
268
- button.style.fontFamily = fontFamilyValue(chipsConfig.fontFamily);
269
- }
270
254
  if (chipsConfig?.fontWeight) {
271
255
  button.style.fontWeight = chipsConfig.fontWeight;
272
256
  }
@@ -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
+ });