@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
@@ -66,6 +66,30 @@ describe("codegen subpath", () => {
66
66
  });
67
67
  });
68
68
 
69
+ it("preserves an authored v5 defaults opt-in in every output format", () => {
70
+ const futureConfig = { ...config, future: { v5Defaults: true } };
71
+ for (const format of [
72
+ "esm",
73
+ "script-installer",
74
+ "script-manual",
75
+ "script-advanced",
76
+ "react-component",
77
+ "react-advanced",
78
+ ] as const) {
79
+ const code = fromSubpath(futureConfig, format);
80
+ expect(code).toContain("v5Defaults");
81
+ expect(code).toContain("true");
82
+ }
83
+
84
+ const installer = fromSubpath(futureConfig, "script-installer");
85
+ const payload = JSON.parse(installer.match(/data-config='([^']*)'/)![1]);
86
+ expect(payload.future).toEqual({ v5Defaults: true });
87
+
88
+ expect(
89
+ fromSubpath({ ...config, future: { v5Defaults: false } }, "esm")
90
+ ).toContain("future: { v5Defaults: false }");
91
+ });
92
+
69
93
  describe("mount target option", () => {
70
94
  it("defaults to body when no target is given", () => {
71
95
  expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { activityDuration, createActivityLifecycle } from './activity-row';
4
+ import { createToolBubble } from './tool-bubble';
5
+ import { createReasoningBubble } from './reasoning-bubble';
6
+ import type { AgentWidgetMessage } from '../types';
7
+ const message = (status: 'pending' | 'running' | 'complete' = 'running', chunks: string[] = []): AgentWidgetMessage => ({ id: 'tool', role: 'assistant', content: '', createdAt: '2026-01-01', variant: 'tool', toolCall: { id: 'tool', name: 'Search', status, chunks, durationMs: 65000 } });
8
+ afterEach(() => vi.useRealTimers());
9
+ describe.each([false, true])('activity rows (V5 %s)', v5Defaults => {
10
+ it('defaults to the selected variant and supports explicit overrides', () => {
11
+ const config = { future: { v5Defaults } };
12
+ expect(createToolBubble(message(), config).classList.contains('persona-activity-row')).toBe(v5Defaults);
13
+ for (const variant of ['card', 'row'] as const) {
14
+ const node = createToolBubble(message('complete'), { ...config, features: { toolCallDisplay: { variant } } });
15
+ expect(node.classList.contains('persona-activity-row')).toBe(variant === 'row');
16
+ if (variant === 'row') { expect(node.textContent).toContain('Used Search'); expect(node.textContent).toContain('1m 5s'); }
17
+ }
18
+ });
19
+ it('resolves icon visibility and removes the icon slot on successful completion', () => {
20
+ for (const kind of ['tool', 'reasoning'] as const) {
21
+ for (const mode of [undefined, 'always', 'active', 'never'] as const) {
22
+ const key = kind === 'tool' ? 'toolCallDisplay' : 'reasoningDisplay';
23
+ const config = { future: { v5Defaults }, features: { [key]: { variant: 'row' as const, iconVisibility: mode } } };
24
+ const m = message('complete');
25
+ const node = kind === 'tool' ? createToolBubble(m, config) : createReasoningBubble({ ...m, toolCall: undefined, variant: 'reasoning', reasoning: { id: 'r', status: 'complete', chunks: ['Details'] } }, config);
26
+ expect(!!node.querySelector('.persona-activity-icon')).toBe((mode ?? (v5Defaults ? 'active' : 'always')) === 'always');
27
+ expect(node.querySelector('.persona-activity-chevron')).not.toBeNull();
28
+ }
29
+ }
30
+ });
31
+ it('keeps attention and active indicators unless explicitly disabled', () => {
32
+ for (const status of ['pending', 'running', 'complete'] as const) {
33
+ const m = message(status); if (status === 'complete') m.toolCall!.success = false;
34
+ for (const iconVisibility of ['active', 'never'] as const) {
35
+ const node = createToolBubble(m, { future: { v5Defaults }, features: { toolCallDisplay: { variant: 'row', iconVisibility } } });
36
+ expect(!!node.querySelector('.persona-activity-icon')).toBe(iconVisibility === 'active');
37
+ }
38
+ }
39
+ });
40
+ it('keeps custom content and exposes accessible row controls', () => {
41
+ const node = createToolBubble(message(), { future: { v5Defaults }, features: { toolCallDisplay: { variant: 'row' } }, toolCall: { renderCollapsedSummary: () => 'Custom summary' } });
42
+ expect(node.textContent?.replace(/\u00a0/g, ' ')).toContain('Custom summary');
43
+ const button = node.querySelector('button')!;
44
+ expect(button.getAttribute('aria-expanded')).toBe('false');
45
+ expect(button.getAttribute('aria-controls')).toBe(node.querySelector('.persona-activity-body')?.id);
46
+ expect(Array.from(button.children).map(el => el.className)).toEqual(expect.arrayContaining(['persona-activity-icon']));
47
+ expect(button.children[1].classList.contains('persona-activity-label')).toBe(true);
48
+ expect(button.children[2].classList.contains('persona-activity-chevron')).toBe(true);
49
+ });
50
+ it('formats completed reasoning and retains card override', () => {
51
+ const m: AgentWidgetMessage = { ...message(), variant: 'reasoning', toolCall: undefined, reasoning: { id: 'r', status: 'complete', chunks: ['Details'], durationMs: 100 } };
52
+ const node = createReasoningBubble(m, { future: { v5Defaults }, features: { reasoningDisplay: { variant: 'row' } } });
53
+ expect(node.textContent).toContain('Thought for a moment');
54
+ });
55
+ });
56
+ it.each([['pending', 'pending'], ['running', 'running'], ['complete', 'done']] as const)('maps %s status to %s', (status, expected) => {
57
+ const node = createToolBubble(message(status), { features: { toolCallDisplay: { variant: 'row' } } });
58
+ expect(node.dataset.activityState).toBe(expected);
59
+ expect(node.querySelector('.persona-activity-icon svg')).not.toBeNull();
60
+ });
61
+ it('maps failure, denied, and approval-waiting icons', () => {
62
+ const m = message('complete'); m.toolCall!.success = false;
63
+ expect(createToolBubble(m, { future: { v5Defaults: true } }).dataset.activityState).toBe('error');
64
+ for (const status of ['denied', 'pending'] as const) {
65
+ m.approval = { id: 'a', status, agentId: 'a', executionId: 'e', toolName: 'Search', description: '' };
66
+ expect(createToolBubble(m, { future: { v5Defaults: true } }).dataset.activityState).toBe(status === 'denied' ? 'denied' : 'awaiting-approval');
67
+ }
68
+ });
69
+ it('formats short and multi-minute durations', () => {
70
+ expect([undefined, 200, 12000, 65000].map(activityDuration)).toEqual(['a moment', 'a moment', '12s', '1m 5s']);
71
+ });
72
+ describe('activity lifecycle', () => {
73
+ it('waits for a token and collapses exactly once after completion', () => {
74
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
75
+ lifecycle.observe(message(), 'tool', expanded); expect(expanded.size).toBe(0);
76
+ lifecycle.observe(message('running', ['token']), 'tool', expanded); expect(expanded.has('tool')).toBe(true);
77
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded);
78
+ vi.advanceTimersByTime(999); expect(expanded.has('tool')).toBe(true);
79
+ vi.advanceTimersByTime(1); expect(expanded.size).toBe(0); expect(changed).toHaveBeenCalledTimes(1);
80
+ expanded.add('tool'); lifecycle.observe(message('complete', ['token']), 'tool', expanded); vi.advanceTimersByTime(2000); expect(expanded.has('tool')).toBe(true);
81
+ });
82
+ it('manual control cancels automatic collapse and expansion', () => {
83
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
84
+ lifecycle.observe(message('running', ['token']), 'tool', expanded);
85
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded); lifecycle.manual('tool');
86
+ vi.advanceTimersByTime(2000); expect(expanded.has('tool')).toBe(true); expect(changed).not.toHaveBeenCalled();
87
+ lifecycle.clear(); expanded.clear(); lifecycle.manual('tool'); lifecycle.observe(message('running', ['token']), 'tool', expanded); expect(expanded.size).toBe(0);
88
+ });
89
+ it('supports disabling automatics and cleans pending timers on clear/prune', () => {
90
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
91
+ lifecycle.observe(message('running', ['token']), 'tool', expanded, { autoExpand: false, autoCollapseDelay: false });
92
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded, { autoCollapseDelay: false });
93
+ expect(expanded.size).toBe(0); expect(vi.getTimerCount()).toBe(0);
94
+ for (const cleanup of ['clear', 'prune'] as const) {
95
+ lifecycle.clear(); lifecycle.observe(message('running', ['token']), 'tool', expanded); lifecycle.observe(message('complete', ['token']), 'tool', expanded);
96
+ if (cleanup === 'clear') lifecycle.clear(); else lifecycle.prune(new Set());
97
+ vi.advanceTimersByTime(2000); expect(changed).not.toHaveBeenCalled();
98
+ }
99
+ });
100
+ });
@@ -0,0 +1,136 @@
1
+ import { Circle, Clock } from "lucide";
2
+ import { renderIconNode } from "../utils/icon-node";
3
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
4
+ import { DEFAULT_TOOL_CALL_DISPLAY, DEFAULT_REASONING_DISPLAY, DEFAULTS_V5 } from "../defaults";
5
+ import { createElement } from "../utils/dom";
6
+ import { renderLucideIcon } from "../utils/icons";
7
+ import { appendHeaderToggle } from "./expandable-bubble";
8
+
9
+ export type ActivityKind = "tool" | "reasoning";
10
+
11
+ /** Standalone builders and the full UI use the same versioned defaults. */
12
+ export function activityDisplay(config: AgentWidgetConfig | undefined, kind: ActivityKind): AgentWidgetConfig {
13
+ const key = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
14
+ return { ...config, features: { ...config?.features, [key]: {
15
+ ...(kind === "tool" ? DEFAULT_TOOL_CALL_DISPLAY : DEFAULT_REASONING_DISPLAY),
16
+ ...(config?.future?.v5Defaults === true ? DEFAULTS_V5.features?.[key] : {}),
17
+ ...config?.features?.[key],
18
+ } } };
19
+ }
20
+
21
+ export function activityVariant(config: AgentWidgetConfig | undefined, kind: ActivityKind): "card" | "row" {
22
+ const key = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
23
+ return config?.features?.[key]?.variant ?? (config?.future?.v5Defaults ? "row" : "card");
24
+ }
25
+
26
+ export function activityDuration(ms: number | undefined): string {
27
+ const seconds = Math.max(0, Math.floor((ms ?? 0) / 1000));
28
+ if (seconds < 1) return "a moment";
29
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
30
+ }
31
+
32
+ /** Reuses card content/render hooks while replacing only its visual chrome. */
33
+ export function applyActivityRow(bubble: HTMLElement, message: AgentWidgetMessage, config: AgentWidgetConfig, kind: ActivityKind): HTMLElement {
34
+ if (activityVariant(config, kind) !== "row") return bubble;
35
+ bubble.className = `persona-message-bubble persona-${kind}-bubble persona-activity-row`;
36
+ bubble.dataset.activityKind = kind;
37
+ if (kind === "tool" && config.toolCall?.shadow === undefined) bubble.style.boxShadow = "var(--persona-tool-bubble-shadow, none)";
38
+ const header = bubble.querySelector<HTMLButtonElement>(":scope > button");
39
+ if (!header) return bubble;
40
+ header.className = "persona-activity-header";
41
+ const data = kind === "tool" ? message.toolCall : message.reasoning;
42
+ const active = data?.status !== "complete";
43
+ const approval = message.approval?.status ?? message.toolCall?.approvalStatus;
44
+ const state = approval === "denied" ? "denied" : approval === "pending" ? "awaiting-approval"
45
+ : message.toolCall?.success === false ? "error" : !active ? "done"
46
+ : data?.status === "pending" ? "pending" : "running";
47
+ bubble.dataset.activityState = state;
48
+ const icon = createElement("span", "persona-activity-icon");
49
+ icon.dataset.activityIcon = state;
50
+ const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", "awaiting-approval": "clock" };
51
+ const customIcon = kind === "reasoning" ? config.features?.reasoningDisplay?.iconName : undefined;
52
+ const glyph = customIcon ? renderLucideIcon(customIcon, 16, "currentColor", 2)
53
+ : state === "pending" ? renderIconNode(Circle, 16, "currentColor", 2)
54
+ : state === "awaiting-approval" ? renderIconNode(Clock, 16, "currentColor", 2)
55
+ : renderLucideIcon(names[state], 16, "currentColor", 2);
56
+ if (glyph) icon.appendChild(glyph);
57
+ header.querySelector(".persona-reasoning-header-icon")?.remove();
58
+ const displayKey = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
59
+ const visibility = config.features?.[displayKey]?.iconVisibility ??
60
+ (config.future?.v5Defaults ? "active" : "always");
61
+ if (visibility === "always" || (visibility === "active" && state !== "done")) header.prepend(icon);
62
+ if (kind === "tool" && data) {
63
+ const duration = createElement("span", "persona-activity-duration");
64
+ if (active && data.startedAt) duration.setAttribute("data-tool-elapsed", String(data.startedAt));
65
+ const elapsed = (kind === "tool" ? message.toolCall?.duration : undefined) ?? data.durationMs ?? (data.completedAt !== undefined && data.startedAt !== undefined ? data.completedAt - data.startedAt : undefined);
66
+ duration.textContent = elapsed === undefined ? "" : activityDuration(elapsed);
67
+ header.appendChild(duration);
68
+ }
69
+ const label = header.querySelector<HTMLElement>(":scope > div:not(.persona-ml-auto)");
70
+ label?.classList.add("persona-activity-label");
71
+ const meta = header.querySelector<HTMLElement>(".persona-ml-auto");
72
+ meta?.classList.add("persona-activity-chevron");
73
+ const body = bubble.querySelector<HTMLElement>(":scope > .persona-border-t");
74
+ body?.classList.add("persona-activity-body");
75
+ if (body) { body.id = `activity-details-${message.id}`; header.setAttribute("aria-controls", body.id); }
76
+ return bubble;
77
+ }
78
+
79
+ /** Per-widget lifecycle: one open and one delayed close; user intent is sticky. */
80
+ export function createActivityLifecycle(onCollapse: (id: string, kind: ActivityKind) => void) {
81
+ const seen = new Set<string>();
82
+ const manual = new Set<string>();
83
+ const finished = new Set<string>();
84
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
85
+ const cancel = (id: string) => { clearTimeout(timers.get(id)); timers.delete(id); };
86
+ return {
87
+ observe(message: AgentWidgetMessage, kind: ActivityKind, expanded: Set<string>, options: { autoExpand?: boolean; autoCollapseDelay?: number | false } = {}) {
88
+ const data = kind === "tool" ? message.toolCall : message.reasoning;
89
+ if (!data || manual.has(message.id)) return;
90
+ if (!seen.has(message.id) && data.status !== "complete" && data.chunks?.some(Boolean)) {
91
+ seen.add(message.id);
92
+ if (options.autoExpand !== false) expanded.add(message.id);
93
+ }
94
+ if (seen.has(message.id) && data.status === "complete" && !finished.has(message.id)) {
95
+ finished.add(message.id);
96
+ if (options.autoCollapseDelay === false) return;
97
+ timers.set(message.id, setTimeout(() => {
98
+ timers.delete(message.id);
99
+ expanded.delete(message.id);
100
+ onCollapse(message.id, kind);
101
+ }, options.autoCollapseDelay ?? 1000));
102
+ }
103
+ },
104
+ manual(id: string) { manual.add(id); cancel(id); },
105
+ prune(ids: Set<string>) {
106
+ for (const id of new Set([...seen, ...manual, ...finished])) {
107
+ if (!ids.has(id)) { cancel(id); seen.delete(id); manual.delete(id); finished.delete(id); }
108
+ }
109
+ },
110
+ clear() { for (const id of timers.keys()) cancel(id); seen.clear(); manual.clear(); finished.clear(); },
111
+ };
112
+ }
113
+
114
+ /** A collapsible group uses the same accessible row and expansion contract. */
115
+ export function createActivityGroup(message: AgentWidgetMessage, config: AgentWidgetConfig, expanded: boolean, label: string | HTMLElement): { bubble: HTMLElement; body: HTMLElement } {
116
+ const bubble = createElement("div", "");
117
+ bubble.id = `bubble-${message.id}`;
118
+ bubble.dataset.messageId = message.id;
119
+ const header = createElement("button", "") as HTMLButtonElement;
120
+ header.type = "button";
121
+ header.dataset.expandHeader = "true";
122
+ header.dataset.bubbleType = "tool";
123
+ header.setAttribute("aria-expanded", String(expanded));
124
+ const copy = createElement("div", "");
125
+ if (typeof label === "string") copy.textContent = label; else copy.appendChild(label);
126
+ appendHeaderToggle(header, copy, { expandable: true, expanded, iconColor: config.toolCall?.toggleTextColor || config.toolCall?.headerTextColor || "currentColor", metaGap: true });
127
+ const body = createElement("div", "persona-border-t");
128
+ body.id = `activity-details-${message.id}`;
129
+ body.style.display = expanded ? "" : "none";
130
+ header.setAttribute("aria-controls", body.id);
131
+ bubble.append(header, body);
132
+ applyActivityRow(bubble, message, { ...config, features: { ...config.features, toolCallDisplay: { ...config.features?.toolCallDisplay, variant: "row" } } }, "tool");
133
+ bubble.dataset.personaToolGroup = "true";
134
+ if (config.features?.toolCallDisplay?.loadingAnimation === "shimmer") bubble.dataset.activityGroupShimmer = "true";
135
+ return { bubble, body };
136
+ }
@@ -1,8 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { createBuiltInApprovalPlugin } from "./approval-actions";
5
- import { approvalDetailsExpansionState } from "./approval-bubble";
6
5
  import type {
7
6
  AgentWidgetApproval,
8
7
  AgentWidgetConfig,
@@ -43,9 +42,10 @@ const makeMessage = (
43
42
  // Each widget owns its own plugin instance + teardown (state is per-instance).
44
43
  // Track teardowns so afterEach can release any leftover document listeners.
45
44
  let teardowns: Array<() => void> = [];
45
+ let expansionState: Map<string, boolean>;
46
46
 
47
47
  const makePlugin = (): { plugin: AgentWidgetPlugin; teardown: () => void } => {
48
- const handle = createBuiltInApprovalPlugin();
48
+ const handle = createBuiltInApprovalPlugin(expansionState);
49
49
  teardowns.push(handle.teardown);
50
50
  return handle;
51
51
  };
@@ -81,10 +81,13 @@ const click = (el: Element | null | undefined): void => {
81
81
  afterEach(() => {
82
82
  teardowns.forEach((t) => t());
83
83
  teardowns = [];
84
- approvalDetailsExpansionState.clear();
85
84
  document.body.innerHTML = "";
86
85
  });
87
86
 
87
+ beforeEach(() => {
88
+ expansionState = new Map<string, boolean>();
89
+ });
90
+
88
91
  describe("built-in approval — flag off (default)", () => {
89
92
  it("renders a single Allow + Deny, no split control", () => {
90
93
  const { el } = render();
@@ -226,7 +229,7 @@ describe("built-in approval — parameters disclosure", () => {
226
229
  expect(pre?.hidden).toBe(true);
227
230
  click(el?.querySelector(".persona-approval-head"));
228
231
  expect(pre?.hidden).toBe(false);
229
- expect(approvalDetailsExpansionState.get("msg-1")).toBe(true);
232
+ expect(expansionState.get("msg-1")).toBe(true);
230
233
  });
231
234
 
232
235
  it("detailsDisplay:'expanded' shows params up front", () => {
@@ -34,7 +34,7 @@ import type {
34
34
  AgentWidgetApprovalConfig,
35
35
  } from "../types";
36
36
  import type { AgentWidgetPlugin } from "../plugins/types";
37
- import { humanizeToolName, approvalDetailsExpansionState } from "./approval-bubble";
37
+ import { humanizeToolName } from "./approval-bubble";
38
38
 
39
39
  type Approval = NonNullable<AgentWidgetMessage["approval"]>;
40
40
  type Decide = (options?: { remember?: boolean }) => void;
@@ -103,10 +103,11 @@ const resolveApprovalConfig = (
103
103
 
104
104
  const isDetailsExpanded = (
105
105
  messageId: string,
106
+ expansionState: Map<string, boolean>,
106
107
  approvalConfig?: AgentWidgetApprovalConfig
107
108
  ): boolean => {
108
109
  const mode = approvalConfig?.detailsDisplay ?? "collapsed";
109
- return approvalDetailsExpansionState.get(messageId) ?? mode === "expanded";
110
+ return expansionState.get(messageId) ?? mode === "expanded";
110
111
  };
111
112
 
112
113
  const kbd = (label: string): HTMLElement => {
@@ -173,6 +174,7 @@ const buildResolvedTrace = (approval: Approval): HTMLElement => {
173
174
 
174
175
  const buildPending = (
175
176
  state: InstanceState,
177
+ expansionState: Map<string, boolean>,
176
178
  message: AgentWidgetMessage,
177
179
  approval: Approval,
178
180
  approvalConfig: AgentWidgetApprovalConfig | undefined,
@@ -199,7 +201,7 @@ const buildPending = (
199
201
  const hasDescription = Boolean(approval.description) && detailsMode !== "hidden";
200
202
  const hasParams = approval.parameters != null && detailsMode !== "hidden";
201
203
  const hasDetails = hasDescription || hasParams;
202
- const expanded = hasDetails && isDetailsExpanded(message.id, approvalConfig);
204
+ const expanded = hasDetails && isDetailsExpanded(message.id, expansionState, approvalConfig);
203
205
 
204
206
  // The card uses the whole header as the disclosure toggle (chevron-only, no
205
207
  // separate text button), but the legacy `showDetailsLabel`/`hideDetailsLabel`
@@ -351,7 +353,7 @@ const buildPending = (
351
353
  pre.hidden = !willOpen;
352
354
  head.setAttribute("aria-expanded", willOpen ? "true" : "false");
353
355
  head.setAttribute("aria-label", willOpen ? hideDetailsLabel : showDetailsLabel);
354
- approvalDetailsExpansionState.set(message.id, willOpen);
356
+ expansionState.set(message.id, willOpen);
355
357
  }
356
358
  return;
357
359
  }
@@ -389,7 +391,9 @@ const buildPending = (
389
391
  * callbacks — both close over the SAME per-instance state, so destroying one
390
392
  * widget never disturbs another widget's open approvals on the same page.
391
393
  */
392
- export const createBuiltInApprovalPlugin = (): {
394
+ export const createBuiltInApprovalPlugin = (
395
+ expansionState = new Map<string, boolean>()
396
+ ): {
393
397
  plugin: AgentWidgetPlugin;
394
398
  teardown: () => void;
395
399
  } => {
@@ -419,7 +423,16 @@ export const createBuiltInApprovalPlugin = (): {
419
423
  // re-render of an older card doesn't reorder it ahead of a newer one.
420
424
  detachMessage(state, message.id);
421
425
  const enableAlways = approvalConfig?.enableAlwaysAllow === true;
422
- const card = buildPending(state, message, approval, approvalConfig, approve, deny, enableAlways);
426
+ const card = buildPending(
427
+ state,
428
+ expansionState,
429
+ message,
430
+ approval,
431
+ approvalConfig,
432
+ approve,
433
+ deny,
434
+ enableAlways
435
+ );
423
436
 
424
437
  if (enableAlways) {
425
438
  if (!state.pendingOrder.includes(message.id)) state.pendingOrder.push(message.id);
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { beforeEach, describe, expect, it } from "vitest";
4
4
  import {
5
- approvalDetailsExpansionState,
6
5
  createApprovalBubble,
7
6
  humanizeToolName,
8
7
  updateApprovalDetailsUI,
@@ -11,6 +10,7 @@ import {
11
10
  getWebMcpToolDisplayTitle,
12
11
  recordWebMcpToolDisplayTitles,
13
12
  } from "../webmcp-bridge";
13
+ import { createTheme, themeToCssVariables } from "../utils/tokens";
14
14
  import type {
15
15
  AgentWidgetApproval,
16
16
  AgentWidgetConfig,
@@ -74,6 +74,51 @@ describe("createApprovalBubble shadow", () => {
74
74
  });
75
75
  });
76
76
 
77
+ describe("createApprovalBubble resolved-status colors", () => {
78
+ const colorFor = (status: "approved" | "denied" | "timeout") =>
79
+ createApprovalBubble(makeMessage({ status }))
80
+ .querySelector("[data-approval-icon] svg")
81
+ ?.getAttribute("stroke") ?? "";
82
+
83
+ const resolveCssVariable = (
84
+ value: string,
85
+ variables: Record<string, string>
86
+ ): string | undefined => {
87
+ const match = /^var\((--[\w-]+)(?:,\s*(.+))?\)$/.exec(value.trim());
88
+ if (!match) return value;
89
+ return variables[match[1]] ??
90
+ (match[2] ? resolveCssVariable(match[2], variables) : undefined);
91
+ };
92
+
93
+ it("resolves each legacy feedback color through emitted palette tokens", () => {
94
+ const variables = themeToCssVariables(createTheme());
95
+
96
+ expect(resolveCssVariable(colorFor("approved"), variables)).toBe("#16a34a");
97
+ expect(resolveCssVariable(colorFor("denied"), variables)).toBe("#dc2626");
98
+ expect(resolveCssVariable(colorFor("timeout"), variables)).toBe("#ca8a04");
99
+ });
100
+
101
+ it("keeps host feedback token overrides ahead of palette fallbacks", () => {
102
+ const variables = {
103
+ ...themeToCssVariables(createTheme()),
104
+ "--persona-feedback-error": "rebeccapurple",
105
+ };
106
+
107
+ expect(resolveCssVariable(colorFor("denied"), variables)).toBe(
108
+ "rebeccapurple"
109
+ );
110
+ });
111
+
112
+ it("gives the pending Deny label and icon a color emitted by the default theme", () => {
113
+ const bubble = createApprovalBubble(makeMessage());
114
+ const deny = bubble.querySelector<HTMLButtonElement>('[data-approval-action="deny"]')!;
115
+ const variables = themeToCssVariables(createTheme());
116
+ expect(resolveCssVariable(deny.style.color, variables)).toBe("#dc2626");
117
+ expect(resolveCssVariable(deny.querySelector("svg")!.getAttribute("stroke")!, variables))
118
+ .toBe("#dc2626");
119
+ });
120
+ });
121
+
77
122
  const TOOL_DESCRIPTION =
78
123
  "Add products to the shopping cart. IMPORTANT: If a product has required options you MUST include the variantId.";
79
124
 
@@ -95,12 +140,14 @@ const getDetails = (bubble: HTMLElement) =>
95
140
  bubble.querySelector("[data-approval-details]") as HTMLElement | null;
96
141
 
97
142
  beforeEach(() => {
98
- approvalDetailsExpansionState.clear();
143
+ expansionState = new Map<string, boolean>();
99
144
  // Reset display titles recorded by a prior test (the map is rebuilt from
100
145
  // each full snapshot, so recording an empty snapshot clears it).
101
146
  recordWebMcpToolDisplayTitles([]);
102
147
  });
103
148
 
149
+ let expansionState: Map<string, boolean>;
150
+
104
151
  describe("humanizeToolName", () => {
105
152
  it("converts snake_case to a sentence", () => {
106
153
  expect(humanizeToolName("add_to_cart")).toBe("Add to cart");
@@ -157,8 +204,8 @@ describe("createApprovalBubble summary and details", () => {
157
204
  });
158
205
 
159
206
  it("respects a per-message expansion override from prior toggling", () => {
160
- approvalDetailsExpansionState.set("msg-1", true);
161
- const bubble = createApprovalBubble(makeDetailedMessage());
207
+ expansionState.set("msg-1", true);
208
+ const bubble = createApprovalBubble(makeDetailedMessage(), undefined, expansionState);
162
209
  expect(getToggle(bubble)?.getAttribute("aria-expanded")).toBe("true");
163
210
  expect(getDetails(bubble)?.style.display).toBe("");
164
211
  });
@@ -204,7 +251,7 @@ describe("createApprovalBubble summary and details", () => {
204
251
  recordWebMcpToolDisplayTitles([
205
252
  { name: "add_to_cart", description: "", title: "Add to Cart" },
206
253
  ]);
207
- const bubble = createApprovalBubble(makeDetailedMessage());
254
+ const bubble = createApprovalBubble(makeDetailedMessage(), undefined, expansionState);
208
255
  expect(getSummary(bubble)?.textContent).toBe(
209
256
  "The assistant wants to use “Add to Cart”."
210
257
  );
@@ -265,14 +312,14 @@ describe("updateApprovalDetailsUI", () => {
265
312
  const bubble = createApprovalBubble(makeDetailedMessage());
266
313
  expect(getDetails(bubble)?.style.display).toBe("none");
267
314
 
268
- approvalDetailsExpansionState.set("msg-1", true);
269
- updateApprovalDetailsUI("msg-1", bubble);
315
+ expansionState.set("msg-1", true);
316
+ updateApprovalDetailsUI("msg-1", bubble, undefined, expansionState);
270
317
  expect(getDetails(bubble)?.style.display).toBe("");
271
318
  expect(getToggle(bubble)?.getAttribute("aria-expanded")).toBe("true");
272
319
  expect(getToggle(bubble)?.textContent).toContain("Hide details");
273
320
 
274
- approvalDetailsExpansionState.set("msg-1", false);
275
- updateApprovalDetailsUI("msg-1", bubble);
321
+ expansionState.set("msg-1", false);
322
+ updateApprovalDetailsUI("msg-1", bubble, undefined, expansionState);
276
323
  expect(getDetails(bubble)?.style.display).toBe("none");
277
324
  expect(getToggle(bubble)?.textContent).toContain("Show details");
278
325
  });