@runtypelabs/persona 4.19.0 → 4.21.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-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
- package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.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.d.cts +99 -5
- package/dist/approval-ui.d.ts +99 -5
- package/dist/artifacts-ui.d.cts +99 -5
- package/dist/artifacts-ui.d.ts +99 -5
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +99 -5
- package/dist/context-mentions.d.ts +99 -5
- package/dist/event-stream-view.d.cts +99 -5
- package/dist/event-stream-view.d.ts +99 -5
- package/dist/forms-ui.d.cts +123 -9
- package/dist/forms-ui.d.ts +123 -9
- package/dist/history-view.d.cts +2 -0
- package/dist/history-view.d.ts +2 -0
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -10
- package/dist/index.d.ts +128 -10
- package/dist/index.global.js +34 -34
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +99 -5
- package/dist/session-reconnect.d.ts +99 -5
- package/dist/smart-dom-reader.d.cts +99 -5
- package/dist/smart-dom-reader.d.ts +99 -5
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +99 -5
- package/dist/theme-editor-preview.d.ts +99 -5
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +99 -5
- package/dist/theme-editor.d.ts +99 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +46 -5
- package/dist/theme-reference.d.ts +46 -5
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-runtime.d.cts +16 -0
- package/dist/webmcp-runtime.d.ts +16 -0
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-visitor-history.test.ts +342 -3
- package/src/client.test.ts +141 -3
- package/src/client.ts +157 -39
- package/src/components/composer-actions.ts +27 -1
- package/src/components/composer-model-picker.ts +4 -14
- package/src/components/composer-overflow-menu.ts +14 -0
- package/src/components/composer-parts.ts +20 -0
- package/src/generated/runtype-openapi-contract.ts +2 -0
- package/src/index-core.ts +2 -0
- package/src/session.ts +139 -103
- package/src/session.webmcp.test.ts +48 -1
- package/src/styles/widget-styles.test.ts +83 -1
- package/src/styles/widget.css +50 -14
- package/src/theme-reference.ts +1 -1
- package/src/types/theme.ts +47 -5
- package/src/types.ts +63 -0
- package/src/ui.composer-action-visibility.test.ts +202 -0
- package/src/ui.composer-models.test.ts +37 -0
- package/src/ui.composer-modes.test.ts +82 -0
- package/src/ui.composer-overflow.test.ts +78 -0
- package/src/ui.history-lifecycle.test.ts +2 -2
- package/src/ui.ts +77 -6
- package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
- package/src/utils/composer-modes.test.ts +33 -0
- package/src/utils/composer-modes.ts +19 -2
- package/src/utils/tokens-parity.test.ts +20 -0
- package/src/utils/tokens.test.ts +76 -0
- package/src/utils/tokens.ts +4 -4
package/src/session.ts
CHANGED
|
@@ -1553,6 +1553,7 @@ export class AgentWidgetSession {
|
|
|
1553
1553
|
|
|
1554
1554
|
/** Conversation-scoped caches that must not leak across records. */
|
|
1555
1555
|
private resetConversationScopedState(): void {
|
|
1556
|
+
this.historyInternals.setStoredResumableHandle?.(null);
|
|
1556
1557
|
this.agentExecution = null;
|
|
1557
1558
|
this.clearArtifactState();
|
|
1558
1559
|
this.webMcpInflightKeys.clear();
|
|
@@ -3341,9 +3342,16 @@ export class AgentWidgetSession {
|
|
|
3341
3342
|
}
|
|
3342
3343
|
|
|
3343
3344
|
try {
|
|
3344
|
-
const response = await this.client.resumeFlow(
|
|
3345
|
-
|
|
3346
|
-
|
|
3345
|
+
const response = await this.client.resumeFlow(
|
|
3346
|
+
executionId,
|
|
3347
|
+
{ [toolName]: answer },
|
|
3348
|
+
{
|
|
3349
|
+
after:
|
|
3350
|
+
this.resumable?.executionId === executionId
|
|
3351
|
+
? this.resumable.lastEventId
|
|
3352
|
+
: undefined,
|
|
3353
|
+
},
|
|
3354
|
+
);
|
|
3347
3355
|
|
|
3348
3356
|
if (!response.ok) {
|
|
3349
3357
|
const errorData = await response.json().catch(() => null);
|
|
@@ -3566,10 +3574,12 @@ export class AgentWidgetSession {
|
|
|
3566
3574
|
/**
|
|
3567
3575
|
* Resolve one or more parallel local-tool awaits sharing one paused
|
|
3568
3576
|
* executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
|
|
3569
|
-
* delegates size-1 resolves here after its guards.
|
|
3570
|
-
* against the page registry concurrently: every gated call renders
|
|
3571
|
-
* native approval bubble, and a sibling's confirm Promise never
|
|
3572
|
-
* another's execution.
|
|
3577
|
+
* delegates size-1 resolves here after its guards. By default each call is
|
|
3578
|
+
* executed against the page registry concurrently: every gated call renders
|
|
3579
|
+
* its own native approval bubble, and a sibling's confirm Promise never
|
|
3580
|
+
* blocks another's execution. With `webmcp.execution: "sequential"` the
|
|
3581
|
+
* calls run one at a time in emission order instead (see the config docs
|
|
3582
|
+
* for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
|
|
3573
3583
|
* (server prefers it over tool name; name-keying remains the fallback for
|
|
3574
3584
|
* legacy single/distinct-tool turns), so two calls to the SAME tool no longer
|
|
3575
3585
|
* collide. The server is tolerant: any call we omit (declined-after-abort,
|
|
@@ -3599,111 +3609,125 @@ export class AgentWidgetSession {
|
|
|
3599
3609
|
this.webMcpResolveControllers.add(batchController);
|
|
3600
3610
|
this.setStreaming(true);
|
|
3601
3611
|
|
|
3602
|
-
// Phase 1: execute every pending call
|
|
3603
|
-
//
|
|
3604
|
-
//
|
|
3605
|
-
const
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
)
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
}
|
|
3612
|
+
// Phase 1: execute every pending call. A null result means the call was
|
|
3613
|
+
// deduped, aborted, or threw; it's omitted from the resume and (per the
|
|
3614
|
+
// tolerant server) re-pauses for retry.
|
|
3615
|
+
const executeOne = async (
|
|
3616
|
+
toolMessage: AgentWidgetMessage,
|
|
3617
|
+
): Promise<ExecutedWebMcpTool | null> => {
|
|
3618
|
+
const wireToolName = toolMessage.toolCall?.name;
|
|
3619
|
+
const callId = toolMessage.toolCall?.id;
|
|
3620
|
+
if (!wireToolName || !callId) return null;
|
|
3621
|
+
|
|
3622
|
+
const dedupeKey = `${executionId}:${callId}`;
|
|
3623
|
+
if (
|
|
3624
|
+
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
3625
|
+
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
3626
|
+
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
3627
|
+
) {
|
|
3628
|
+
return null;
|
|
3629
|
+
}
|
|
3630
|
+
this.webMcpInflightKeys.add(dedupeKey);
|
|
3631
|
+
claimedKeys.push(dedupeKey);
|
|
3632
|
+
|
|
3633
|
+
// Clear the awaiting flag and keep the tool bubble running while the
|
|
3634
|
+
// browser-side WebMCP promise is in flight. The initial `step_await`
|
|
3635
|
+
// only means the server paused for a local tool; it is not completion.
|
|
3636
|
+
const startedAt = this.markWebMcpToolRunning(toolMessage);
|
|
3637
|
+
|
|
3638
|
+
// Per-call id wins for resume keying; fall back to the wire tool name
|
|
3639
|
+
// for legacy servers that don't emit `webMcpToolCallId`.
|
|
3640
|
+
const resumeKey =
|
|
3641
|
+
toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
|
|
3642
|
+
|
|
3643
|
+
// Built-in fire-and-forget tool: no bridge, no confirm gate, no
|
|
3644
|
+
// browser-side execution: the chips render from the message list and
|
|
3645
|
+
// the canned output joins the batch's single /resume.
|
|
3646
|
+
if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
|
|
3647
|
+
return {
|
|
3648
|
+
dedupeKey,
|
|
3649
|
+
resumeKey,
|
|
3650
|
+
output: suggestRepliesToolResult(),
|
|
3651
|
+
toolMessage,
|
|
3652
|
+
startedAt,
|
|
3653
|
+
completedAt: Date.now(),
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3645
3656
|
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3657
|
+
const execPromise = this.client.executeWebMcpToolCall(
|
|
3658
|
+
wireToolName,
|
|
3659
|
+
toolMessage.toolCall?.args,
|
|
3660
|
+
batchController.signal,
|
|
3661
|
+
);
|
|
3651
3662
|
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
);
|
|
3673
|
-
}
|
|
3674
|
-
this.markWebMcpToolComplete(
|
|
3675
|
-
toolMessage,
|
|
3676
|
-
buildWebMcpErrorResult(
|
|
3677
|
-
isAbortError
|
|
3678
|
-
? "Aborted by cancel()"
|
|
3679
|
-
: getWebMcpErrorMessage(error),
|
|
3680
|
-
),
|
|
3681
|
-
startedAt,
|
|
3663
|
+
let output: unknown;
|
|
3664
|
+
if (!execPromise) {
|
|
3665
|
+
output = {
|
|
3666
|
+
isError: true,
|
|
3667
|
+
content: [
|
|
3668
|
+
{ type: "text", text: "WebMCP not enabled on this widget." },
|
|
3669
|
+
],
|
|
3670
|
+
};
|
|
3671
|
+
} else {
|
|
3672
|
+
try {
|
|
3673
|
+
output = await execPromise;
|
|
3674
|
+
} catch (error) {
|
|
3675
|
+
const isAbortError =
|
|
3676
|
+
error instanceof Error &&
|
|
3677
|
+
(error.name === "AbortError" ||
|
|
3678
|
+
error.message.includes("aborted") ||
|
|
3679
|
+
error.message.includes("abort"));
|
|
3680
|
+
if (!isAbortError) {
|
|
3681
|
+
this.callbacks.onError?.(
|
|
3682
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
3682
3683
|
);
|
|
3683
|
-
// Release the dedupe claim so a re-emit can retry this call.
|
|
3684
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3685
|
-
return null;
|
|
3686
3684
|
}
|
|
3687
|
-
}
|
|
3688
|
-
if (batchController.signal.aborted) {
|
|
3689
3685
|
this.markWebMcpToolComplete(
|
|
3690
3686
|
toolMessage,
|
|
3691
|
-
buildWebMcpErrorResult(
|
|
3687
|
+
buildWebMcpErrorResult(
|
|
3688
|
+
isAbortError
|
|
3689
|
+
? "Aborted by cancel()"
|
|
3690
|
+
: getWebMcpErrorMessage(error),
|
|
3691
|
+
),
|
|
3692
3692
|
startedAt,
|
|
3693
3693
|
);
|
|
3694
|
+
// Release the dedupe claim so a re-emit can retry this call.
|
|
3694
3695
|
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3695
3696
|
return null;
|
|
3696
3697
|
}
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
output,
|
|
3698
|
+
}
|
|
3699
|
+
if (batchController.signal.aborted) {
|
|
3700
|
+
this.markWebMcpToolComplete(
|
|
3701
3701
|
toolMessage,
|
|
3702
|
+
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3702
3703
|
startedAt,
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3704
|
+
);
|
|
3705
|
+
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3706
|
+
return null;
|
|
3707
|
+
}
|
|
3708
|
+
return {
|
|
3709
|
+
dedupeKey,
|
|
3710
|
+
resumeKey,
|
|
3711
|
+
output,
|
|
3712
|
+
toolMessage,
|
|
3713
|
+
startedAt,
|
|
3714
|
+
completedAt: Date.now(),
|
|
3715
|
+
};
|
|
3716
|
+
};
|
|
3717
|
+
|
|
3718
|
+
// Parallel (default): a gated sibling's approval never blocks another's
|
|
3719
|
+
// execution. Sequential: one at a time in emission order, so tools that
|
|
3720
|
+
// share page state (a canvas, a form) don't interleave; the next call
|
|
3721
|
+
// starts only once the previous one has settled, approval included.
|
|
3722
|
+
let executed: Array<ExecutedWebMcpTool | null>;
|
|
3723
|
+
if (this.config.webmcp?.execution === "sequential") {
|
|
3724
|
+
executed = [];
|
|
3725
|
+
for (const toolMessage of snapshots) {
|
|
3726
|
+
executed.push(await executeOne(toolMessage));
|
|
3727
|
+
}
|
|
3728
|
+
} else {
|
|
3729
|
+
executed = await Promise.all(snapshots.map(executeOne));
|
|
3730
|
+
}
|
|
3707
3731
|
|
|
3708
3732
|
let ready: ExecutedWebMcpTool[] = [];
|
|
3709
3733
|
try {
|
|
@@ -3720,6 +3744,10 @@ export class AgentWidgetSession {
|
|
|
3720
3744
|
|
|
3721
3745
|
const response = await this.client.resumeFlow(executionId, toolOutputs, {
|
|
3722
3746
|
signal: batchController.signal,
|
|
3747
|
+
after:
|
|
3748
|
+
this.resumable?.executionId === executionId
|
|
3749
|
+
? this.resumable.lastEventId
|
|
3750
|
+
: undefined,
|
|
3723
3751
|
});
|
|
3724
3752
|
if (!response.ok) {
|
|
3725
3753
|
const errorData = await response.json().catch(() => null);
|
|
@@ -3895,7 +3923,13 @@ export class AgentWidgetSession {
|
|
|
3895
3923
|
const response = await this.client.resumeFlow(
|
|
3896
3924
|
executionId,
|
|
3897
3925
|
{ [resumeKey]: output },
|
|
3898
|
-
{
|
|
3926
|
+
{
|
|
3927
|
+
signal: options?.signal,
|
|
3928
|
+
after:
|
|
3929
|
+
this.resumable?.executionId === executionId
|
|
3930
|
+
? this.resumable.lastEventId
|
|
3931
|
+
: undefined,
|
|
3932
|
+
},
|
|
3899
3933
|
);
|
|
3900
3934
|
if (!response.ok) {
|
|
3901
3935
|
const errorData = await response.json().catch(() => null);
|
|
@@ -4323,9 +4357,11 @@ export class AgentWidgetSession {
|
|
|
4323
4357
|
if (event.status === "connecting") {
|
|
4324
4358
|
this.setStreaming(true);
|
|
4325
4359
|
} else if (event.status === "idle" || event.status === "error") {
|
|
4326
|
-
//
|
|
4327
|
-
//
|
|
4328
|
-
|
|
4360
|
+
// A client-tool pause is an intentional stream end but NOT a terminal:
|
|
4361
|
+
// retain its cursor so `/client/resume` can attach after the await
|
|
4362
|
+
// frame instead of replaying the whole turn. Real terminals and
|
|
4363
|
+
// ordinary non-durable idles still clear the handle.
|
|
4364
|
+
if (!this.isAwaitPending()) this.clearResumable();
|
|
4329
4365
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
4330
4366
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
4331
4367
|
// idle status, but the successor tool is still executing. The resolve's
|
|
@@ -13,9 +13,10 @@ const makeSession = (overrides?: {
|
|
|
13
13
|
resumeOk?: boolean;
|
|
14
14
|
isOperational?: boolean;
|
|
15
15
|
executeImpl?: () => Promise<WebMcpToolResult>;
|
|
16
|
+
webmcp?: Record<string, unknown>;
|
|
16
17
|
}) => {
|
|
17
18
|
const session = new AgentWidgetSession(
|
|
18
|
-
{ apiUrl: "http://test", webmcp: { enabled: true } },
|
|
19
|
+
{ apiUrl: "http://test", webmcp: { enabled: true, ...overrides?.webmcp } },
|
|
19
20
|
{
|
|
20
21
|
onMessagesChanged: () => undefined,
|
|
21
22
|
onStatusChanged: () => undefined,
|
|
@@ -949,6 +950,52 @@ describe("AgentWidgetSession: WebMCP parallel batched resume (core#3878)", () =>
|
|
|
949
950
|
expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
|
|
950
951
|
});
|
|
951
952
|
|
|
953
|
+
it("webmcp.execution: 'sequential' runs siblings one at a time, in emission order", async () => {
|
|
954
|
+
// Paint-style tools share page state (select tool + color, then draw), so
|
|
955
|
+
// a multi-call turn must not interleave. The second execute must not even
|
|
956
|
+
// START until the first has resolved; the batch still posts ONE /resume.
|
|
957
|
+
let releaseA: (r: WebMcpToolResult) => void = () => undefined;
|
|
958
|
+
let releaseB: (r: WebMcpToolResult) => void = () => undefined;
|
|
959
|
+
const pA = new Promise<WebMcpToolResult>((r) => (releaseA = r));
|
|
960
|
+
const pB = new Promise<WebMcpToolResult>((r) => (releaseB = r));
|
|
961
|
+
const started: string[] = [];
|
|
962
|
+
|
|
963
|
+
const { session, resumeSpy } = makeSession({
|
|
964
|
+
webmcp: { execution: "sequential" },
|
|
965
|
+
});
|
|
966
|
+
const client = (session as unknown as { client: Record<string, unknown> })
|
|
967
|
+
.client;
|
|
968
|
+
(client.executeWebMcpToolCall as ReturnType<typeof vi.fn>).mockImplementation(
|
|
969
|
+
(_name: string, args: { sku: string }) => {
|
|
970
|
+
started.push(args.sku);
|
|
971
|
+
return args.sku === "SHOE-001" ? pA : pB;
|
|
972
|
+
},
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
feed(session, parallelAwait("toolu_A", "SHOE-001"));
|
|
976
|
+
feed(session, parallelAwait("toolu_B", "SHOE-007"));
|
|
977
|
+
endStream(session);
|
|
978
|
+
await flushMicrotasks();
|
|
979
|
+
|
|
980
|
+
// Only the first call is in flight; the second waits on it.
|
|
981
|
+
expect(started).toEqual(["SHOE-001"]);
|
|
982
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
983
|
+
|
|
984
|
+
releaseA({ content: [{ type: "text", text: "a" }] });
|
|
985
|
+
await flushMicrotasks();
|
|
986
|
+
expect(started).toEqual(["SHOE-001", "SHOE-007"]);
|
|
987
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
988
|
+
|
|
989
|
+
releaseB({ content: [{ type: "text", text: "b" }] });
|
|
990
|
+
await flushMicrotasks();
|
|
991
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
992
|
+
const toolOutputs = (resumeSpy.mock.calls[0]! as unknown[])[1] as Record<
|
|
993
|
+
string,
|
|
994
|
+
unknown
|
|
995
|
+
>;
|
|
996
|
+
expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
|
|
997
|
+
});
|
|
998
|
+
|
|
952
999
|
it("records elapsed time for each resolved tool in a batched WebMCP resume", async () => {
|
|
953
1000
|
vi.useFakeTimers();
|
|
954
1001
|
vi.setSystemTime(1_000);
|
|
@@ -585,6 +585,38 @@ describe("overflow menu row shading", () => {
|
|
|
585
585
|
);
|
|
586
586
|
expect(widgetCss).toContain(".persona-composer-overflow-menu__slot:hover");
|
|
587
587
|
});
|
|
588
|
+
|
|
589
|
+
it("reads every overflowMenu token as a full path over the stock fallback", () => {
|
|
590
|
+
const at = widgetCss.indexOf(".persona-composer-overflow-menu {");
|
|
591
|
+
expect(at, "overflow menu panel rule not found").toBeGreaterThan(-1);
|
|
592
|
+
const panel = widgetCss.slice(at, widgetCss.indexOf("}", at));
|
|
593
|
+
|
|
594
|
+
expect(panel).toContain(
|
|
595
|
+
"--persona-components-composer-overflowMenu-background,"
|
|
596
|
+
);
|
|
597
|
+
expect(panel).toContain("var(--persona-surface, #ffffff)");
|
|
598
|
+
expect(panel).toContain(
|
|
599
|
+
"--persona-components-composer-overflowMenu-borderColor,"
|
|
600
|
+
);
|
|
601
|
+
expect(panel).toContain("var(--persona-border, #e5e7eb)");
|
|
602
|
+
expect(panel).toContain(
|
|
603
|
+
"--persona-components-composer-overflowMenu-borderRadius,"
|
|
604
|
+
);
|
|
605
|
+
expect(panel).toContain(
|
|
606
|
+
"--persona-components-composer-overflowMenu-foreground,"
|
|
607
|
+
);
|
|
608
|
+
expect(panel).toContain(
|
|
609
|
+
"--persona-components-composer-overflowMenu-shadow,"
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("keeps the portaled panel rule unprefixed", () => {
|
|
614
|
+
// createPopover mounts the panel outside the widget root, so a
|
|
615
|
+
// `[data-persona-root]` prefix would never match it.
|
|
616
|
+
expect(widgetCss).not.toContain(
|
|
617
|
+
"[data-persona-root] .persona-composer-overflow-menu"
|
|
618
|
+
);
|
|
619
|
+
});
|
|
588
620
|
});
|
|
589
621
|
|
|
590
622
|
describe("composer control focus rings", () => {
|
|
@@ -658,7 +690,11 @@ describe("model picker chevron", () => {
|
|
|
658
690
|
|
|
659
691
|
it("colors the chevron from the theme token, never a baked-in hex", () => {
|
|
660
692
|
const rule = ruleFor(".persona-composer-model-picker-chevron");
|
|
661
|
-
|
|
693
|
+
// Same chain as the label, so the glyph and the text never diverge.
|
|
694
|
+
expect(rule).toContain(
|
|
695
|
+
"--persona-components-composer-modelPicker-labelColor"
|
|
696
|
+
);
|
|
697
|
+
expect(rule).toContain("var(--persona-button-ghost-fg");
|
|
662
698
|
// The mask only needs opaque alpha, so the SVG's own stroke is not a color.
|
|
663
699
|
expect(rule).toContain("mask-image");
|
|
664
700
|
expect(rule).toContain("-webkit-mask-image");
|
|
@@ -840,6 +876,52 @@ describe("model picker popover", () => {
|
|
|
840
876
|
});
|
|
841
877
|
});
|
|
842
878
|
|
|
879
|
+
describe("model picker closed control surface", () => {
|
|
880
|
+
const ruleFor = (selector: string): string => {
|
|
881
|
+
const at = widgetCss.indexOf(selector);
|
|
882
|
+
expect(at, `selector not found: ${selector}`).toBeGreaterThan(-1);
|
|
883
|
+
return widgetCss.slice(at, widgetCss.indexOf("}", at));
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
it("reads the surface tokens over the ghost button fallbacks", () => {
|
|
887
|
+
// One vocabulary for both presentations: the native select and the popover
|
|
888
|
+
// trigger share this rule, so a page that stays native still themes.
|
|
889
|
+
const rule = ruleFor("[data-persona-root] .persona-composer-model-picker {");
|
|
890
|
+
expect(rule).toContain(
|
|
891
|
+
"--persona-components-composer-modelPicker-background,"
|
|
892
|
+
);
|
|
893
|
+
expect(rule).toContain("var(--persona-button-ghost-bg, transparent)");
|
|
894
|
+
expect(rule).toContain(
|
|
895
|
+
"--persona-components-composer-modelPicker-borderRadius,"
|
|
896
|
+
);
|
|
897
|
+
expect(rule).toContain("var(--persona-button-ghost-radius");
|
|
898
|
+
expect(rule).toContain(
|
|
899
|
+
"--persona-components-composer-modelPicker-labelColor,"
|
|
900
|
+
);
|
|
901
|
+
expect(rule).toContain("var(--persona-button-ghost-fg");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("draws borderColor as an inset ring so the control never resizes", () => {
|
|
905
|
+
// A real border would eat 1px of the content box under `border-box` sizing
|
|
906
|
+
// and shift the label on every theme that leaves the token unset.
|
|
907
|
+
const rule = ruleFor("[data-persona-root] .persona-composer-model-picker {");
|
|
908
|
+
expect(rule).toContain("border: none");
|
|
909
|
+
expect(rule).toContain(
|
|
910
|
+
"box-shadow: inset 0 0 0 1px\n var(--persona-components-composer-modelPicker-borderColor, transparent)"
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("keeps hover on its own token so a themed fill survives the pointer", () => {
|
|
915
|
+
const rule = ruleFor(
|
|
916
|
+
"[data-persona-root] .persona-composer-model-picker:hover:not(:disabled) {"
|
|
917
|
+
);
|
|
918
|
+
expect(rule).toContain(
|
|
919
|
+
"--persona-components-composer-modelPicker-hoverBackground,"
|
|
920
|
+
);
|
|
921
|
+
expect(rule).toContain("var(--persona-button-ghost-hover-bg");
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
843
925
|
describe("composer motion", () => {
|
|
844
926
|
/** Every `@media (prefers-reduced-motion: no-preference)` block, joined. */
|
|
845
927
|
const reducedMotionSafeCss = (): string => {
|
package/src/styles/widget.css
CHANGED
|
@@ -1371,7 +1371,8 @@
|
|
|
1371
1371
|
*
|
|
1372
1372
|
* The panel is mounted by createPopover next to the widget root (or inside the
|
|
1373
1373
|
* shadow root), so its rules are deliberately un-prefixed by
|
|
1374
|
-
* `[data-persona-root]` and must stand on their own.
|
|
1374
|
+
* `[data-persona-root]` and must stand on their own. It sits outside the themed
|
|
1375
|
+
* mount, so the menu forwards its configured tokens onto the panel on open. */
|
|
1375
1376
|
.persona-composer-overflow-menu {
|
|
1376
1377
|
display: flex;
|
|
1377
1378
|
flex-direction: column;
|
|
@@ -1380,11 +1381,27 @@
|
|
|
1380
1381
|
max-height: 320px;
|
|
1381
1382
|
overflow-y: auto;
|
|
1382
1383
|
padding: 4px;
|
|
1383
|
-
border-radius: var(
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1384
|
+
border-radius: var(
|
|
1385
|
+
--persona-components-composer-overflowMenu-borderRadius,
|
|
1386
|
+
var(--persona-radius-lg, 0.5rem)
|
|
1387
|
+
);
|
|
1388
|
+
border: 1px solid
|
|
1389
|
+
var(
|
|
1390
|
+
--persona-components-composer-overflowMenu-borderColor,
|
|
1391
|
+
var(--persona-border, #e5e7eb)
|
|
1392
|
+
);
|
|
1393
|
+
background: var(
|
|
1394
|
+
--persona-components-composer-overflowMenu-background,
|
|
1395
|
+
var(--persona-surface, #ffffff)
|
|
1396
|
+
);
|
|
1397
|
+
color: var(
|
|
1398
|
+
--persona-components-composer-overflowMenu-foreground,
|
|
1399
|
+
var(--persona-text, #111827)
|
|
1400
|
+
);
|
|
1401
|
+
box-shadow: var(
|
|
1402
|
+
--persona-components-composer-overflowMenu-shadow,
|
|
1403
|
+
var(--persona-shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12))
|
|
1404
|
+
);
|
|
1388
1405
|
font-family: var(--persona-font-family, inherit);
|
|
1389
1406
|
}
|
|
1390
1407
|
|
|
@@ -1598,9 +1615,22 @@
|
|
|
1598
1615
|
padding-inline-start: 0.625rem;
|
|
1599
1616
|
padding-inline-end: 1.75rem;
|
|
1600
1617
|
border: none;
|
|
1601
|
-
border-radius: var(
|
|
1602
|
-
|
|
1603
|
-
|
|
1618
|
+
border-radius: var(
|
|
1619
|
+
--persona-components-composer-modelPicker-borderRadius,
|
|
1620
|
+
var(--persona-button-ghost-radius, var(--persona-radius-md, 0.375rem))
|
|
1621
|
+
);
|
|
1622
|
+
background-color: var(
|
|
1623
|
+
--persona-components-composer-modelPicker-background,
|
|
1624
|
+
var(--persona-button-ghost-bg, transparent)
|
|
1625
|
+
);
|
|
1626
|
+
color: var(
|
|
1627
|
+
--persona-components-composer-modelPicker-labelColor,
|
|
1628
|
+
var(--persona-button-ghost-fg, var(--persona-text, #111827))
|
|
1629
|
+
);
|
|
1630
|
+
/* A ring, not a border: `border-box` sizing would pull the label in by 1px on
|
|
1631
|
+
every theme that never sets the token. */
|
|
1632
|
+
box-shadow: inset 0 0 0 1px
|
|
1633
|
+
var(--persona-components-composer-modelPicker-borderColor, transparent);
|
|
1604
1634
|
font-family: inherit;
|
|
1605
1635
|
font-size: 0.8125rem;
|
|
1606
1636
|
cursor: pointer;
|
|
@@ -1616,16 +1646,19 @@
|
|
|
1616
1646
|
text-align: start;
|
|
1617
1647
|
}
|
|
1618
1648
|
|
|
1619
|
-
/* The chevron is a MASK, not a data-URI stroke color: the fill
|
|
1620
|
-
|
|
1621
|
-
Lucide `chevron-down` geometry as the icon registry draws. */
|
|
1649
|
+
/* The chevron is a MASK, not a data-URI stroke color: the fill follows the
|
|
1650
|
+
closed control's own text color, so the glyph and the label never diverge.
|
|
1651
|
+
Same Lucide `chevron-down` geometry as the icon registry draws. */
|
|
1622
1652
|
.persona-composer-model-picker-chevron {
|
|
1623
1653
|
justify-self: end;
|
|
1624
1654
|
margin-inline-end: 0.625rem;
|
|
1625
1655
|
width: 0.875rem;
|
|
1626
1656
|
height: 0.875rem;
|
|
1627
1657
|
pointer-events: none;
|
|
1628
|
-
background-color: var(
|
|
1658
|
+
background-color: var(
|
|
1659
|
+
--persona-components-composer-modelPicker-labelColor,
|
|
1660
|
+
var(--persona-button-ghost-fg, var(--persona-text, #111827))
|
|
1661
|
+
);
|
|
1629
1662
|
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
1630
1663
|
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
1631
1664
|
-webkit-mask-repeat: no-repeat;
|
|
@@ -1653,7 +1686,10 @@
|
|
|
1653
1686
|
}
|
|
1654
1687
|
|
|
1655
1688
|
[data-persona-root] .persona-composer-model-picker:hover:not(:disabled) {
|
|
1656
|
-
background-color: var(
|
|
1689
|
+
background-color: var(
|
|
1690
|
+
--persona-components-composer-modelPicker-hoverBackground,
|
|
1691
|
+
var(--persona-button-ghost-hover-bg, rgba(0, 0, 0, 0.05))
|
|
1692
|
+
);
|
|
1657
1693
|
}
|
|
1658
1694
|
|
|
1659
1695
|
[data-persona-root] .persona-composer-model-picker:disabled {
|
package/src/theme-reference.ts
CHANGED
|
@@ -173,7 +173,7 @@ export const THEME_TOKEN_DOCS = {
|
|
|
173
173
|
'Shared scrollbar appearance for every scroller: thumb (default semantic.colors.border), track (default transparent).',
|
|
174
174
|
reasoningBubble: 'shadow: reasoning/thinking row box-shadow.',
|
|
175
175
|
composer:
|
|
176
|
-
'Message input form: shadow (box-shadow), borderColor, padding (form inset), gap (textarea row to actions row), fontSize and lineHeight (textarea type), controlSize (edge of every icon control in the action row: attachment, mention, mic, action buttons, overflow trigger, icon-mode send; default 40px) and controlIconSize (glyph inside them, default 24px). Per-control config keys (sendButton.size, voiceRecognition.iconSize) override controlSize; coarse-pointer devices floor the hit area at 40px. overlayBand paints the band behind an overlaid footer (composer.placement: "overlay"). segmented styles a segmented mode-group track (trackBackground, trackBorderRadius, padding, activeBackground, activeForeground, activeShadow, inactiveForeground, itemPadding); every key is optional and the stylesheet owns the defaults. modelPicker styles the
|
|
176
|
+
'Message input form: shadow (box-shadow), borderColor, padding (form inset), gap (textarea row to actions row), fontSize and lineHeight (textarea type), controlSize (edge of every icon control in the action row: attachment, mention, mic, action buttons, overflow trigger, icon-mode send; default 40px) and controlIconSize (glyph inside them, default 24px). Per-control config keys (sendButton.size, voiceRecognition.iconSize) override controlSize; coarse-pointer devices floor the hit area at 40px. overlayBand paints the band behind an overlaid footer (composer.placement: "overlay"). segmented styles a segmented mode-group track (trackBackground, trackBorderRadius, padding, activeBackground, activeForeground, activeShadow, inactiveForeground, itemPadding); every key is optional and the stylesheet owns the defaults. modelPicker styles the model picker (background, hoverBackground, borderColor, borderRadius and labelColor on the closed control, which the native select and the popover trigger share; menuBackground, menuBorderRadius, rowHoverBackground, descriptionColor and suffixColor on the popover only), also all optional. overflowMenu styles the composer overflow "+" menu panel (background, borderColor, borderRadius, foreground, shadow), also all optional.',
|
|
177
177
|
artifact:
|
|
178
178
|
'toolbar (icon styling, copy menu), tab (background, active states), pane (background, toolbarBackground).',
|
|
179
179
|
},
|