@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-9Vxm-PPz.d.cts → types-Db61aI9J.d.cts} +25 -2
- package/dist/animations/{types-9Vxm-PPz.d.ts → types-Db61aI9J.d.ts} +25 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +25 -2
- package/dist/animations-extra.d.ts +25 -2
- package/dist/approval-ui-3NMYNXBD.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +261 -40
- package/dist/approval-ui.d.ts +261 -40
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
- package/dist/artifacts-ui.d.cts +256 -28
- package/dist/artifacts-ui.d.ts +256 -28
- package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +13 -13
- package/dist/context-mentions.d.cts +256 -28
- package/dist/context-mentions.d.ts +256 -28
- package/dist/event-stream-view.d.cts +256 -28
- package/dist/event-stream-view.d.ts +256 -28
- package/dist/forms-ui.d.cts +291 -30
- package/dist/forms-ui.d.ts +291 -30
- package/dist/history-view.d.cts +25 -2
- package/dist/history-view.d.ts +25 -2
- package/dist/index.cjs +70 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +337 -51
- package/dist/index.d.ts +337 -51
- package/dist/index.global.js +57 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +70 -69
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +256 -28
- package/dist/session-reconnect.d.ts +256 -28
- package/dist/smart-dom-reader.d.cts +256 -28
- package/dist/smart-dom-reader.d.ts +256 -28
- package/dist/theme-editor-preview.cjs +74 -73
- package/dist/theme-editor-preview.d.cts +263 -28
- package/dist/theme-editor-preview.d.ts +263 -28
- package/dist/theme-editor-preview.js +51 -50
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +276 -30
- package/dist/theme-editor.d.ts +276 -30
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1522 -11
- package/dist/theme-reference.d.ts +1522 -11
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +3 -2
- package/src/__fixtures__/defaults-parity.v4.json +776 -0
- package/src/approval-ui-entry.ts +0 -1
- package/src/approval-ui-loader.ts +5 -4
- package/src/approval-ui.ts +0 -1
- package/src/client-identity-proof.test.ts +193 -0
- package/src/client-session-warm.test.ts +216 -0
- package/src/client-visitor-history.test.ts +6 -0
- package/src/client.tool-pair-replay.test.ts +325 -0
- package/src/client.ts +154 -61
- package/src/codegen.test.ts +24 -0
- package/src/components/activity-row.test.ts +100 -0
- package/src/components/activity-row.ts +136 -0
- package/src/components/approval-actions.test.ts +8 -5
- package/src/components/approval-actions.ts +19 -6
- package/src/components/approval-bubble.test.ts +56 -9
- package/src/components/approval-bubble.ts +36 -41
- package/src/components/composer-builder.ts +5 -2
- package/src/components/composer-parts.ts +10 -2
- package/src/components/header-builder.test.ts +32 -0
- package/src/components/header-builder.ts +7 -4
- package/src/components/header-layouts.test.ts +12 -0
- package/src/components/header-layouts.ts +3 -2
- package/src/components/launcher.ts +11 -2
- package/src/components/message-bubble.test.ts +22 -0
- package/src/components/message-bubble.ts +7 -3
- package/src/components/panel.test.ts +52 -1
- package/src/components/panel.ts +47 -8
- package/src/components/pill-composer-builder.test.ts +2 -0
- package/src/components/pill-composer-builder.ts +0 -1
- package/src/components/reasoning-bubble.ts +29 -17
- package/src/components/suggestions.test.ts +1 -0
- package/src/components/suggestions.ts +0 -16
- package/src/components/tool-bubble.ts +131 -114
- package/src/components/tool-details.test.ts +87 -0
- package/src/components/tool-details.ts +102 -0
- package/src/components/welcome.test.ts +8 -0
- package/src/components/welcome.ts +4 -2
- package/src/defaults-parity.test.ts +158 -0
- package/src/defaults.test.ts +69 -0
- package/src/defaults.ts +128 -49
- package/src/generated/runtype-openapi-contract.ts +28 -13
- package/src/index-core.ts +2 -0
- package/src/install.test.ts +24 -0
- package/src/install.ts +3 -1
- package/src/launcher-global.ts +2 -1
- package/src/launcher-variants.test.ts +52 -0
- package/src/runtime/init-update-reset.test.ts +21 -0
- package/src/session.ts +61 -9
- package/src/styles/widget-styles.test.ts +19 -0
- package/src/styles/widget.css +295 -23
- package/src/theme-editor/index.ts +2 -0
- package/src/theme-editor/preview-utils.test.ts +8 -0
- package/src/theme-editor/preview-utils.ts +7 -3
- package/src/theme-editor/sections.test.ts +31 -0
- package/src/theme-editor/sections.ts +55 -1
- package/src/theme-editor/state-preferences.test.ts +49 -0
- package/src/theme-editor/state.ts +73 -15
- package/src/theme-reference.test.ts +20 -0
- package/src/theme-reference.ts +11 -5
- package/src/tool-call-display-defaults.test.ts +33 -1
- package/src/types/theme.ts +80 -3
- package/src/types.ts +179 -23
- package/src/ui.activity-row.test.ts +91 -0
- package/src/ui.approval-plugin.test.ts +63 -0
- package/src/ui.composer-compact.test.ts +24 -2
- package/src/ui.composer-placement.test.ts +23 -0
- package/src/ui.detached-panel.test.ts +33 -6
- package/src/ui.docked.test.ts +19 -0
- package/src/ui.header-update-stability.test.ts +25 -0
- package/src/ui.identity-proof.test.ts +58 -0
- package/src/ui.launcher-teaser.test.ts +25 -0
- package/src/ui.message-width.test.ts +17 -2
- package/src/ui.reasoning-display.test.ts +92 -0
- package/src/ui.rebuild-composer.test.ts +20 -3
- package/src/ui.scroll.test.ts +32 -0
- package/src/ui.session-init.test.ts +312 -0
- package/src/ui.status-indicator.test.ts +82 -0
- package/src/ui.suggest-replies.test.ts +11 -0
- package/src/ui.ts +363 -74
- package/src/ui.v5-defaults.test.ts +144 -0
- package/src/ui.welcome.test.ts +92 -3
- package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
- package/src/utils/code-generators.ts +14 -0
- package/src/utils/composer-compact.test.ts +0 -2
- package/src/utils/composer-compact.ts +2 -4
- package/src/utils/composer-placement.ts +2 -3
- package/src/utils/config-merge.test.ts +81 -5
- package/src/utils/config-merge.ts +13 -3
- package/src/utils/defaults-provenance.ts +67 -0
- package/src/utils/dock.ts +7 -1
- package/src/utils/launcher-variant.ts +16 -0
- package/src/utils/panel-config.ts +97 -0
- package/src/utils/status-indicator.ts +39 -0
- package/src/utils/theme.test.ts +182 -0
- package/src/utils/theme.ts +131 -18
- package/src/utils/tokens.test.ts +122 -1
- package/src/utils/tokens.ts +177 -17
- package/src/utils/tool-pair-replay.ts +100 -0
- package/src/welcome.test.ts +23 -0
- package/src/welcome.ts +16 -9
- package/dist/approval-ui-K7R5Q5OC.js +0 -1
package/src/codegen.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|