@runtypelabs/persona 4.23.2 → 4.25.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-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
- package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +2 -0
- package/dist/animations-extra.d.ts +2 -0
- package/dist/approval-ui-3NMYNXBD.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +278 -37
- package/dist/approval-ui.d.ts +278 -37
- 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 +273 -25
- package/dist/artifacts-ui.d.ts +273 -25
- 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 +273 -25
- package/dist/context-mentions.d.ts +273 -25
- package/dist/event-stream-view.d.cts +273 -25
- package/dist/event-stream-view.d.ts +273 -25
- package/dist/forms-ui.d.cts +319 -27
- package/dist/forms-ui.d.ts +319 -27
- package/dist/history-view.d.cts +2 -0
- package/dist/history-view.d.ts +2 -0
- package/dist/index.cjs +70 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +344 -39
- package/dist/index.d.ts +344 -39
- 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 +273 -25
- package/dist/session-reconnect.d.ts +273 -25
- package/dist/smart-dom-reader.d.cts +273 -25
- package/dist/smart-dom-reader.d.ts +273 -25
- package/dist/theme-editor-preview.cjs +74 -73
- package/dist/theme-editor-preview.d.cts +280 -25
- package/dist/theme-editor-preview.d.ts +280 -25
- package/dist/theme-editor-preview.js +51 -50
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +293 -27
- package/dist/theme-editor.d.ts +293 -27
- 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/voice-runtime-7DA7MJH4.js +1 -0
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.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.ts +122 -30
- 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 +2 -2
- package/src/index-core.ts +4 -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 +59 -7
- package/src/session.voice.test.ts +72 -1
- 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 +207 -22
- 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 +422 -74
- package/src/ui.v5-defaults.test.ts +144 -0
- package/src/ui.voice-prewarm.test.ts +222 -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/voice/runtype-voice-provider.ts +238 -27
- package/src/voice/voice.test.ts +272 -2
- package/src/welcome.test.ts +23 -0
- package/src/welcome.ts +16 -9
- package/dist/approval-ui-K7R5Q5OC.js +0 -1
- package/dist/voice-runtime-EFR3R5ES.js +0 -1
package/src/client.ts
CHANGED
|
@@ -307,6 +307,14 @@ export class AgentWidgetClient {
|
|
|
307
307
|
private currentClientTurnId: string | null = null;
|
|
308
308
|
private clientSession: ClientSession | null = null;
|
|
309
309
|
private sessionInitPromise: Promise<ClientSession> | null = null;
|
|
310
|
+
/**
|
|
311
|
+
* Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
|
|
312
|
+
* the session value (`null` or an expired session) the one early attempt was
|
|
313
|
+
* made against, so a failed attempt is not retried on every keystroke. It is
|
|
314
|
+
* re-armed when a session is installed or cleared, so each session lifetime
|
|
315
|
+
* gets one early init.
|
|
316
|
+
*/
|
|
317
|
+
private sessionWarmLatch: ClientSession | null | false = false;
|
|
310
318
|
|
|
311
319
|
// Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
|
|
312
320
|
// Fingerprint of the clientTools[] last *sent in full* and confirmed by a
|
|
@@ -621,9 +629,41 @@ export class AgentWidgetClient {
|
|
|
621
629
|
}
|
|
622
630
|
}
|
|
623
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Fire-and-forget early `initSession()` for client-token mode, driven by the
|
|
634
|
+
* widget's `sessionInit` trigger (or a host calling it directly, e.g. on
|
|
635
|
+
* hover). It fires at most once per session lifetime and is a no-op while a
|
|
636
|
+
* session is live or an init is already in flight. Errors are swallowed, with
|
|
637
|
+
* no `onSessionExpired` and no UI: a send calls `initSession()` again and
|
|
638
|
+
* reuses the in-flight promise, so any failure resurfaces there exactly as it
|
|
639
|
+
* would without the early init. Sends nothing new on the wire. Outside
|
|
640
|
+
* client-token mode this does nothing.
|
|
641
|
+
*
|
|
642
|
+
* Resolves with the session this call initialized, or `null` when it was
|
|
643
|
+
* skipped or failed. It never rejects.
|
|
644
|
+
*/
|
|
645
|
+
public async warmSession(): Promise<ClientSession | null> {
|
|
646
|
+
const current = this.clientSession;
|
|
647
|
+
if (
|
|
648
|
+
!this.isClientTokenMode() ||
|
|
649
|
+
this.sessionInitPromise ||
|
|
650
|
+
(current && new Date() < current.expiresAt) ||
|
|
651
|
+
this.sessionWarmLatch === current
|
|
652
|
+
) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
this.sessionWarmLatch = current;
|
|
656
|
+
// Swallowed on purpose: the send's own initSession() reports failures.
|
|
657
|
+
return this.initSession().catch(() => null);
|
|
658
|
+
}
|
|
659
|
+
|
|
624
660
|
/**
|
|
625
661
|
* Initialize session for client token mode.
|
|
626
|
-
* Called
|
|
662
|
+
* Called by the send if no live session exists. The widget normally calls
|
|
663
|
+
* it earlier, when the visitor shows intent to send (see the `sessionInit`
|
|
664
|
+
* config option and `warmSession()`), and concurrent calls share one
|
|
665
|
+
* in-flight request, so a send racing an early init issues a single
|
|
666
|
+
* `/v1/client/init`.
|
|
627
667
|
*/
|
|
628
668
|
public async initSession(): Promise<ClientSession> {
|
|
629
669
|
if (!this.isClientTokenMode()) {
|
|
@@ -640,21 +680,28 @@ export class AgentWidgetClient {
|
|
|
640
680
|
return this.sessionInitPromise;
|
|
641
681
|
}
|
|
642
682
|
|
|
643
|
-
this
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
683
|
+
// Callers that dedupe share this promise, so the stale check below covers
|
|
684
|
+
// them too (the send reusing an early init included).
|
|
685
|
+
const pending: Promise<ClientSession> = (this.sessionInitPromise = this._doInitSession()
|
|
686
|
+
.then((session) => {
|
|
687
|
+
// A clear or replacement (credential change, start-new, proof re-init)
|
|
688
|
+
// while this was in flight makes the result stale: never install it.
|
|
689
|
+
if (this.sessionInitPromise !== pending) return this.initSession();
|
|
690
|
+
this.clientSession = session;
|
|
691
|
+
this.sessionWarmLatch = false;
|
|
692
|
+
// A freshly-minted session must resend the full WebMCP tool list on its
|
|
693
|
+
// next turn: drop any diff-only fingerprint cached under a prior session,
|
|
694
|
+
// so we never claim "unchanged" against a session the server didn't store
|
|
695
|
+
// the set under. (Belt-and-suspenders with the sessionId comparison in the
|
|
696
|
+
// send decision and the server's 409 resend signal.)
|
|
697
|
+
this.resetClientToolsFingerprint();
|
|
698
|
+
this.config.onSessionInit?.(session);
|
|
699
|
+
return session;
|
|
700
|
+
})
|
|
701
|
+
.finally(() => {
|
|
702
|
+
if (this.sessionInitPromise === pending) this.sessionInitPromise = null;
|
|
703
|
+
}));
|
|
704
|
+
return pending;
|
|
658
705
|
}
|
|
659
706
|
|
|
660
707
|
/** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
|
|
@@ -966,6 +1013,7 @@ export class AgentWidgetClient {
|
|
|
966
1013
|
const installed = this.finishInit(session, previousConversationId, false);
|
|
967
1014
|
this.clientSession = installed;
|
|
968
1015
|
this.sessionInitPromise = null;
|
|
1016
|
+
this.sessionWarmLatch = false;
|
|
969
1017
|
this.resetClientToolsFingerprint();
|
|
970
1018
|
return installed;
|
|
971
1019
|
}
|
|
@@ -1032,6 +1080,7 @@ export class AgentWidgetClient {
|
|
|
1032
1080
|
if (settled) return;
|
|
1033
1081
|
settled = true;
|
|
1034
1082
|
this.clientSession = session;
|
|
1083
|
+
this.sessionWarmLatch = false;
|
|
1035
1084
|
this.resetClientToolsFingerprint();
|
|
1036
1085
|
},
|
|
1037
1086
|
discard: () => {
|
|
@@ -1102,6 +1151,7 @@ export class AgentWidgetClient {
|
|
|
1102
1151
|
public clearClientSession(): void {
|
|
1103
1152
|
this.clientSession = null;
|
|
1104
1153
|
this.sessionInitPromise = null;
|
|
1154
|
+
this.sessionWarmLatch = false;
|
|
1105
1155
|
this.resetClientToolsFingerprint();
|
|
1106
1156
|
}
|
|
1107
1157
|
|
|
@@ -1212,6 +1262,7 @@ export class AgentWidgetClient {
|
|
|
1212
1262
|
|
|
1213
1263
|
if (response.status === 401) {
|
|
1214
1264
|
this.clientSession = null;
|
|
1265
|
+
this.sessionWarmLatch = false;
|
|
1215
1266
|
this.config.onSessionExpired?.();
|
|
1216
1267
|
throw new Error('Session expired. Please refresh to continue.');
|
|
1217
1268
|
}
|
|
@@ -1231,10 +1282,9 @@ export class AgentWidgetClient {
|
|
|
1231
1282
|
messageId: string,
|
|
1232
1283
|
type: 'upvote' | 'downvote' | 'copy'
|
|
1233
1284
|
): Promise<void> {
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1285
|
+
// Feedback on a restored transcript can precede any init (`sessionInit`
|
|
1286
|
+
// defers it to intent); the feedback click is intent enough.
|
|
1287
|
+
const session = this.getClientSession() ?? (await this.initSession());
|
|
1238
1288
|
|
|
1239
1289
|
return this.sendFeedback({
|
|
1240
1290
|
sessionId: session.sessionId,
|
|
@@ -1251,10 +1301,7 @@ export class AgentWidgetClient {
|
|
|
1251
1301
|
* @param comment - Optional comment
|
|
1252
1302
|
*/
|
|
1253
1303
|
public async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
|
|
1254
|
-
const session = this.getClientSession();
|
|
1255
|
-
if (!session) {
|
|
1256
|
-
throw new Error('No active session. Please initialize session first.');
|
|
1257
|
-
}
|
|
1304
|
+
const session = this.getClientSession() ?? (await this.initSession());
|
|
1258
1305
|
|
|
1259
1306
|
return this.sendFeedback({
|
|
1260
1307
|
sessionId: session.sessionId,
|
|
@@ -1272,10 +1319,7 @@ export class AgentWidgetClient {
|
|
|
1272
1319
|
* @param comment - Optional comment
|
|
1273
1320
|
*/
|
|
1274
1321
|
public async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
|
|
1275
|
-
const session = this.getClientSession();
|
|
1276
|
-
if (!session) {
|
|
1277
|
-
throw new Error('No active session. Please initialize session first.');
|
|
1278
|
-
}
|
|
1322
|
+
const session = this.getClientSession() ?? (await this.initSession());
|
|
1279
1323
|
|
|
1280
1324
|
return this.sendFeedback({
|
|
1281
1325
|
sessionId: session.sessionId,
|
|
@@ -1311,6 +1355,40 @@ export class AgentWidgetClient {
|
|
|
1311
1355
|
return this.config.getIdentityProof ? 'verified-user' : 'browser';
|
|
1312
1356
|
}
|
|
1313
1357
|
|
|
1358
|
+
private async resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
|
|
1359
|
+
signal?.throwIfAborted();
|
|
1360
|
+
const provider = this.config.identityProvider;
|
|
1361
|
+
if (provider === undefined) return undefined;
|
|
1362
|
+
const getIdentityProof = this.config.getIdentityProof;
|
|
1363
|
+
if (!provider.trim() || !getIdentityProof) {
|
|
1364
|
+
throw new Error('Chat identity requires identityProvider and getIdentityProof.');
|
|
1365
|
+
}
|
|
1366
|
+
let token: string | null;
|
|
1367
|
+
let onAbort: (() => void) | undefined;
|
|
1368
|
+
try {
|
|
1369
|
+
const proof = Promise.resolve().then(() => getIdentityProof());
|
|
1370
|
+
token = signal
|
|
1371
|
+
? await Promise.race([
|
|
1372
|
+
proof,
|
|
1373
|
+
new Promise<never>((_, reject) => {
|
|
1374
|
+
onAbort = () => reject(signal.reason);
|
|
1375
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1376
|
+
if (signal.aborted) onAbort();
|
|
1377
|
+
}),
|
|
1378
|
+
])
|
|
1379
|
+
: await proof;
|
|
1380
|
+
} catch {
|
|
1381
|
+
signal?.throwIfAborted();
|
|
1382
|
+
throw new Error('The identity proof provider failed.');
|
|
1383
|
+
} finally {
|
|
1384
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
1385
|
+
}
|
|
1386
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
1387
|
+
throw new Error('A fresh identity proof is required to send this message.');
|
|
1388
|
+
}
|
|
1389
|
+
return { provider, token };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1314
1392
|
/**
|
|
1315
1393
|
* Resolve the proof for one logical request. `null` is an intentional
|
|
1316
1394
|
* browser-scope fallback only while the visitor has never been bound.
|
|
@@ -1850,6 +1928,7 @@ export class AgentWidgetClient {
|
|
|
1850
1928
|
}
|
|
1851
1929
|
session = this.finishInit(renewed, previous.conversationId ?? null, false);
|
|
1852
1930
|
this.clientSession = session;
|
|
1931
|
+
this.sessionWarmLatch = false;
|
|
1853
1932
|
this.resetClientToolsFingerprint();
|
|
1854
1933
|
this.config.onSessionInit?.(session);
|
|
1855
1934
|
};
|
|
@@ -1909,16 +1988,23 @@ export class AgentWidgetClient {
|
|
|
1909
1988
|
const recoveryVisitorToken =
|
|
1910
1989
|
session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
|
|
1911
1990
|
assertCurrentTurn();
|
|
1912
|
-
return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
|
|
1991
|
+
return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
|
|
1992
|
+
assertCurrentTurn();
|
|
1993
|
+
const identityProof = await this.resolveChatIdentityProof(options.signal);
|
|
1994
|
+
assertCurrentTurn();
|
|
1913
1995
|
const chatRequest: ClientChatRequest = {
|
|
1914
1996
|
...baseChatRequest,
|
|
1915
1997
|
sessionId: session.sessionId,
|
|
1916
1998
|
...toolFields,
|
|
1999
|
+
...(identityProof && { identityProof }),
|
|
1917
2000
|
};
|
|
1918
2001
|
|
|
1919
2002
|
if (this.debug) {
|
|
1920
2003
|
// eslint-disable-next-line no-console
|
|
1921
|
-
console.debug("[AgentWidgetClient] client token dispatch",
|
|
2004
|
+
console.debug("[AgentWidgetClient] client token dispatch", {
|
|
2005
|
+
...chatRequest,
|
|
2006
|
+
...(identityProof && { identityProof: { provider: identityProof.provider, token: '[REDACTED]' } }),
|
|
2007
|
+
});
|
|
1922
2008
|
}
|
|
1923
2009
|
|
|
1924
2010
|
return fetch(this.getClientApiUrl('chat'), {
|
|
@@ -1951,6 +2037,12 @@ export class AgentWidgetClient {
|
|
|
1951
2037
|
if (!response.ok) {
|
|
1952
2038
|
const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
|
|
1953
2039
|
|
|
2040
|
+
if (response.status === 401 && data.error === 'invalid_identity_proof') {
|
|
2041
|
+
const error = new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
|
|
2042
|
+
forward({ type: 'error', error });
|
|
2043
|
+
throw error;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
1954
2046
|
if (response.status === 401) {
|
|
1955
2047
|
// Session expired
|
|
1956
2048
|
this.clearClientSession();
|
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);
|