@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/types/theme.ts
CHANGED
|
@@ -663,21 +663,41 @@ export interface ComposerSegmentedTokens {
|
|
|
663
663
|
}
|
|
664
664
|
|
|
665
665
|
/**
|
|
666
|
-
* Model picker
|
|
666
|
+
* Model picker chrome (`composer.models`).
|
|
667
|
+
*
|
|
668
|
+
* The surface keys (`background`, `hoverBackground`, `borderColor`,
|
|
669
|
+
* `borderRadius`, `labelColor`) style the CLOSED control, so the native
|
|
670
|
+
* `<select>` and the `presentation: "popover"` trigger share one vocabulary.
|
|
671
|
+
* The `menu*`, `rowHoverBackground`, and `descriptionColor` keys style the
|
|
672
|
+
* popover panel and are inert under the native select, as is `suffixColor`,
|
|
673
|
+
* which a `<select>` cannot carry either.
|
|
667
674
|
*
|
|
668
675
|
* Every key is optional and unset ones emit nothing: the stylesheet's `var()`
|
|
669
676
|
* fallbacks own the default look, which derives from the surface and ghost
|
|
670
|
-
* button tokens.
|
|
671
|
-
* `suffixColor`, which the native control cannot carry either.
|
|
677
|
+
* button tokens.
|
|
672
678
|
*/
|
|
673
679
|
export interface ComposerModelPickerTokens {
|
|
680
|
+
/** Closed control fill. @default the ghost button fill (transparent) */
|
|
681
|
+
background?: TokenReference<'color'>;
|
|
682
|
+
/** Closed control fill under hover. @default the ghost hover wash */
|
|
683
|
+
hoverBackground?: TokenReference<'color'>;
|
|
684
|
+
/**
|
|
685
|
+
* Closed control outline, drawn as a 1px inset ring so setting it never
|
|
686
|
+
* resizes the control. @default none
|
|
687
|
+
*/
|
|
688
|
+
borderColor?: TokenReference<'color'>;
|
|
689
|
+
/** Closed control corner radius. @default the ghost button radius */
|
|
690
|
+
borderRadius?: TokenReference<'radius'>;
|
|
674
691
|
/** Panel fill behind the rows. @default semantic.colors.surface */
|
|
675
692
|
menuBackground?: TokenReference<'color'>;
|
|
676
693
|
/** Panel corner radius. @default palette.radius.lg */
|
|
677
694
|
menuBorderRadius?: TokenReference<'radius'>;
|
|
678
695
|
/** Row fill under hover and the focused option. @default the ghost hover wash */
|
|
679
696
|
rowHoverBackground?: TokenReference<'color'>;
|
|
680
|
-
/**
|
|
697
|
+
/**
|
|
698
|
+
* Row label color, plus the closed control's text and chevron in both
|
|
699
|
+
* presentations. @default the ghost button foreground
|
|
700
|
+
*/
|
|
681
701
|
labelColor?: TokenReference<'color'>;
|
|
682
702
|
/** Second line under a row label. @default semantic.colors.textMuted */
|
|
683
703
|
descriptionColor?: TokenReference<'color'>;
|
|
@@ -685,6 +705,26 @@ export interface ComposerModelPickerTokens {
|
|
|
685
705
|
suffixColor?: TokenReference<'color'>;
|
|
686
706
|
}
|
|
687
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Composer overflow `+` menu panel (`composer.actionOverflow`).
|
|
710
|
+
*
|
|
711
|
+
* Every key is optional and unset ones emit nothing: the stylesheet's `var()`
|
|
712
|
+
* fallbacks own the default look. The panel is portaled outside the themed
|
|
713
|
+
* mount, so the menu forwards the configured values onto it when it opens.
|
|
714
|
+
*/
|
|
715
|
+
export interface ComposerOverflowMenuTokens {
|
|
716
|
+
/** Panel fill behind the rows. @default semantic.colors.surface */
|
|
717
|
+
background?: TokenReference<'color'>;
|
|
718
|
+
/** Panel border color. @default semantic.colors.border */
|
|
719
|
+
borderColor?: TokenReference<'color'>;
|
|
720
|
+
/** Panel corner radius. @default palette.radius.lg */
|
|
721
|
+
borderRadius?: TokenReference<'radius'>;
|
|
722
|
+
/** Row text and glyph color. @default semantic.colors.text */
|
|
723
|
+
foreground?: TokenReference<'color'>;
|
|
724
|
+
/** Panel elevation (raw CSS box-shadow, `none` allowed). @default palette.shadows.md */
|
|
725
|
+
shadow?: string;
|
|
726
|
+
}
|
|
727
|
+
|
|
688
728
|
/** Composer (message input) chrome. */
|
|
689
729
|
export interface ComposerChromeTokens {
|
|
690
730
|
/** Box-shadow on the composer form (raw CSS, e.g. `none`). */
|
|
@@ -719,8 +759,10 @@ export interface ComposerChromeTokens {
|
|
|
719
759
|
overlayBand?: string;
|
|
720
760
|
/** Segmented mode-group track. */
|
|
721
761
|
segmented?: ComposerSegmentedTokens;
|
|
722
|
-
/** Model picker popover
|
|
762
|
+
/** Model picker chrome (closed control plus popover panel). */
|
|
723
763
|
modelPicker?: ComposerModelPickerTokens;
|
|
764
|
+
/** Overflow `+` menu panel chrome. */
|
|
765
|
+
overflowMenu?: ComposerOverflowMenuTokens;
|
|
724
766
|
}
|
|
725
767
|
|
|
726
768
|
/**
|
package/src/types.ts
CHANGED
|
@@ -908,6 +908,22 @@ export type AgentWidgetWebMcpConfig = {
|
|
|
908
908
|
* `window.confirm` fallback only applies when no widget UI is attached.
|
|
909
909
|
*/
|
|
910
910
|
onConfirm?: WebMcpConfirmHandler;
|
|
911
|
+
/**
|
|
912
|
+
* How the widget runs the `webmcp:*` calls of ONE turn when the model emits
|
|
913
|
+
* several at once.
|
|
914
|
+
*
|
|
915
|
+
* - `'parallel'` (default): every call executes concurrently and a gated
|
|
916
|
+
* sibling's approval never blocks another's execution. Right for
|
|
917
|
+
* independent, order-insensitive tools (catalog lookups, add-to-cart).
|
|
918
|
+
* - `'sequential'`: calls execute one at a time, in the order the model
|
|
919
|
+
* emitted them; the next starts only after the previous one settles
|
|
920
|
+
* (including its approval bubble). Use this when the page tools share
|
|
921
|
+
* mutable state that concurrent execution would interleave, e.g. a
|
|
922
|
+
* canvas where every stroke selects a color/tool before drawing.
|
|
923
|
+
*
|
|
924
|
+
* Either way, all outputs still return to the server in a single `/resume`.
|
|
925
|
+
*/
|
|
926
|
+
execution?: 'parallel' | 'sequential';
|
|
911
927
|
};
|
|
912
928
|
|
|
913
929
|
/**
|
|
@@ -4007,6 +4023,16 @@ export type ComposerActionPlacement = "start" | "end";
|
|
|
4007
4023
|
*/
|
|
4008
4024
|
export type ComposerActionPresentation = "bar" | "overflow" | "auto";
|
|
4009
4025
|
|
|
4026
|
+
/**
|
|
4027
|
+
* When a composer action is mounted, as a function of the draft.
|
|
4028
|
+
*
|
|
4029
|
+
* - `"always"` (default): the control is always mounted.
|
|
4030
|
+
* - `"when-empty"`: hidden as soon as the draft holds text or an attachment,
|
|
4031
|
+
* and while a response streams.
|
|
4032
|
+
* - `"when-text"`: the inverse, on the same draft state the send control reads.
|
|
4033
|
+
*/
|
|
4034
|
+
export type ComposerActionVisibility = "always" | "when-empty" | "when-text";
|
|
4035
|
+
|
|
4010
4036
|
/**
|
|
4011
4037
|
* Fields shared by every composer action.
|
|
4012
4038
|
*
|
|
@@ -4022,6 +4048,13 @@ export type ComposerActionBase = {
|
|
|
4022
4048
|
presentation?: ComposerActionPresentation;
|
|
4023
4049
|
order?: number;
|
|
4024
4050
|
visible?: boolean | ((state: Readonly<ComposerState>) => boolean);
|
|
4051
|
+
/**
|
|
4052
|
+
* Draft-driven visibility, on the same state `sendButton.visibility` reads.
|
|
4053
|
+
* Hidden removes the control from layout and tab order. Combined with
|
|
4054
|
+
* `visible`: both must pass.
|
|
4055
|
+
* @default "always"
|
|
4056
|
+
*/
|
|
4057
|
+
visibility?: ComposerActionVisibility;
|
|
4025
4058
|
disabled?: boolean | ((state: Readonly<ComposerState>) => boolean);
|
|
4026
4059
|
/** Disable while a response streams, through the composer's own mechanism. */
|
|
4027
4060
|
disableWhenStreaming?: boolean;
|
|
@@ -4180,6 +4213,16 @@ export type ComposerMode = {
|
|
|
4180
4213
|
*/
|
|
4181
4214
|
export type ComposerModeGroupPresentation = "buttons" | "segmented";
|
|
4182
4215
|
|
|
4216
|
+
/**
|
|
4217
|
+
* Whether a group's active modes also render as removable chips.
|
|
4218
|
+
*
|
|
4219
|
+
* - `"auto"` (default): one chip per active mode, except under `"segmented"`
|
|
4220
|
+
* presentation, where the track already shows the state.
|
|
4221
|
+
* - `"hidden"`: no chips for this group under any presentation. The pressed bar
|
|
4222
|
+
* button carries the state, and deselection stays on the button.
|
|
4223
|
+
*/
|
|
4224
|
+
export type ComposerModeChipVisibility = "auto" | "hidden";
|
|
4225
|
+
|
|
4183
4226
|
/**
|
|
4184
4227
|
* Selection policy for a set of modes. `"single"` deselects siblings when one
|
|
4185
4228
|
* is chosen; `"multiple"` toggles independently.
|
|
@@ -4189,6 +4232,12 @@ export type ComposerModeGroup = {
|
|
|
4189
4232
|
selection: "single" | "multiple";
|
|
4190
4233
|
/** @default "buttons" */
|
|
4191
4234
|
presentation?: ComposerModeGroupPresentation;
|
|
4235
|
+
/**
|
|
4236
|
+
* Chip row policy for this group's active modes. The chip row itself is
|
|
4237
|
+
* derived from its children, so a group with no other chips leaves no gap.
|
|
4238
|
+
* @default "auto"
|
|
4239
|
+
*/
|
|
4240
|
+
chipVisibility?: ComposerModeChipVisibility;
|
|
4192
4241
|
/**
|
|
4193
4242
|
* Accessible name for a segmented track. With it the track is a labelled
|
|
4194
4243
|
* `role="group"`; without it the segments stand on their own names.
|
|
@@ -5641,6 +5690,8 @@ export type ClientSession = {
|
|
|
5641
5690
|
targetId?: string;
|
|
5642
5691
|
/** Opaque change token; differs whenever the conversation transcript mutated. */
|
|
5643
5692
|
conversationRevision?: string;
|
|
5693
|
+
/** Server-negotiated durable reconnect support for this client-token session. */
|
|
5694
|
+
durableRecovery?: { enabled: boolean };
|
|
5644
5695
|
/** Visitor grant backing history. `token` appears only at mint. */
|
|
5645
5696
|
visitor?: ClientVisitorGrant;
|
|
5646
5697
|
/** Configuration from the server */
|
|
@@ -5681,6 +5732,8 @@ export type ClientInitResponse = {
|
|
|
5681
5732
|
conversationId?: string;
|
|
5682
5733
|
targetId?: string;
|
|
5683
5734
|
conversationRevision?: string;
|
|
5735
|
+
/** Present on servers that understand the durable-recovery negotiation. */
|
|
5736
|
+
durableRecovery?: { enabled: boolean };
|
|
5684
5737
|
visitor?: ClientVisitorGrant;
|
|
5685
5738
|
config: {
|
|
5686
5739
|
welcomeMessage: string | null;
|
|
@@ -5772,6 +5825,16 @@ export interface WidgetHistoryInternals {
|
|
|
5772
5825
|
/** Persist the revision beside the active conversation id (internal only). */
|
|
5773
5826
|
setStoredConversationRevision?: (revision: string | null) => void;
|
|
5774
5827
|
getStoredConversationRevision?: () => string | null;
|
|
5828
|
+
/** Internal client-token durable resume handle, persisted with widget state. */
|
|
5829
|
+
setStoredResumableHandle?: (
|
|
5830
|
+
handle: { executionId: string; after: string } | null
|
|
5831
|
+
) => void;
|
|
5832
|
+
getStoredResumableHandle?: () => {
|
|
5833
|
+
executionId: string;
|
|
5834
|
+
after: string;
|
|
5835
|
+
} | null;
|
|
5836
|
+
/** True only while Persona owns a persisted client-token recovery attempt. */
|
|
5837
|
+
shouldResumeDurableConversation?: () => boolean;
|
|
5775
5838
|
/** "Show earlier messages" cursor for the persisted transcript. */
|
|
5776
5839
|
setStoredMessageCursor?: (cursor: string | null) => void;
|
|
5777
5840
|
getStoredMessageCursor?: () => string | null;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import type { ComposerAction } from "./types";
|
|
7
|
+
|
|
8
|
+
const mounts: HTMLElement[] = [];
|
|
9
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
10
|
+
|
|
11
|
+
const makeController = (config: Record<string, unknown> = {}) => {
|
|
12
|
+
const mount = document.createElement("div");
|
|
13
|
+
document.body.appendChild(mount);
|
|
14
|
+
mounts.push(mount);
|
|
15
|
+
const controller = createAgentExperience(mount, {
|
|
16
|
+
apiUrl: "https://api.example.com/chat",
|
|
17
|
+
launcher: { enabled: false },
|
|
18
|
+
persistState: false,
|
|
19
|
+
suggestionChips: [],
|
|
20
|
+
...config,
|
|
21
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
22
|
+
controllers.push(controller);
|
|
23
|
+
return { mount, controller };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The store coalesces on a microtask, so state-driven syncs need a turn. */
|
|
27
|
+
const flush = async (times = 4) => {
|
|
28
|
+
for (let i = 0; i < times; i += 1) {
|
|
29
|
+
// eslint-disable-next-line no-await-in-loop
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const actionEl = (mount: HTMLElement, id: string) =>
|
|
35
|
+
mount.querySelector<HTMLElement>(`[data-persona-composer-action="${id}"]`);
|
|
36
|
+
|
|
37
|
+
const typeInto = (mount: HTMLElement, value: string): void => {
|
|
38
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
39
|
+
"[data-persona-composer-input]"
|
|
40
|
+
)!;
|
|
41
|
+
textarea.value = value;
|
|
42
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const voiceAction = (patch: Partial<ComposerAction> = {}): ComposerAction =>
|
|
46
|
+
({
|
|
47
|
+
id: "voice-mode",
|
|
48
|
+
placement: "end",
|
|
49
|
+
label: "Voice mode",
|
|
50
|
+
iconName: "mic",
|
|
51
|
+
onSelect: () => {},
|
|
52
|
+
...patch,
|
|
53
|
+
}) as ComposerAction;
|
|
54
|
+
|
|
55
|
+
describe("ComposerAction.visibility", () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
window.scrollTo = vi.fn();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
controllers.splice(0).forEach((controller) => {
|
|
62
|
+
try {
|
|
63
|
+
controller.destroy();
|
|
64
|
+
} catch {
|
|
65
|
+
/* already destroyed in the test */
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
69
|
+
document.body.innerHTML = "";
|
|
70
|
+
window.localStorage.clear();
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("mounts the control on every draft state under the default", async () => {
|
|
75
|
+
const { mount } = makeController({
|
|
76
|
+
composer: { actions: [voiceAction()] },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
80
|
+
typeInto(mount, "Hello");
|
|
81
|
+
await flush();
|
|
82
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('"when-empty" drops the control out of the DOM once the draft has text', async () => {
|
|
86
|
+
const { mount } = makeController({
|
|
87
|
+
composer: { actions: [voiceAction({ visibility: "when-empty" })] },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await flush();
|
|
91
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
92
|
+
|
|
93
|
+
typeInto(mount, "Hello");
|
|
94
|
+
await flush();
|
|
95
|
+
// Removed, not faded: gone from layout and from tab order.
|
|
96
|
+
expect(actionEl(mount, "voice-mode")).toBeNull();
|
|
97
|
+
|
|
98
|
+
// Whitespace is not a draft.
|
|
99
|
+
typeInto(mount, " ");
|
|
100
|
+
await flush();
|
|
101
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('"when-text" is the inverse of "when-empty" on the same state', async () => {
|
|
105
|
+
const { mount } = makeController({
|
|
106
|
+
composer: {
|
|
107
|
+
actions: [
|
|
108
|
+
voiceAction({ visibility: "when-empty" }),
|
|
109
|
+
voiceAction({ id: "improve", visibility: "when-text", label: "Improve" }),
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await flush();
|
|
115
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
116
|
+
expect(actionEl(mount, "improve")).toBeNull();
|
|
117
|
+
|
|
118
|
+
typeInto(mount, "Hello");
|
|
119
|
+
await flush();
|
|
120
|
+
expect(actionEl(mount, "voice-mode")).toBeNull();
|
|
121
|
+
expect(actionEl(mount, "improve")).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('hides a "when-empty" control while streaming, and holds it across chunks', async () => {
|
|
125
|
+
const chunks = [
|
|
126
|
+
'data: {"type":"text_delta","delta":"one"}\n\n',
|
|
127
|
+
'data: {"type":"text_delta","delta":" two"}\n\n',
|
|
128
|
+
];
|
|
129
|
+
let release: () => void = () => {};
|
|
130
|
+
const settled = new Promise<void>((resolve) => {
|
|
131
|
+
release = resolve;
|
|
132
|
+
});
|
|
133
|
+
const encoder = new TextEncoder();
|
|
134
|
+
global.fetch = vi.fn().mockImplementation(
|
|
135
|
+
async () =>
|
|
136
|
+
({
|
|
137
|
+
ok: true,
|
|
138
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
139
|
+
body: new ReadableStream({
|
|
140
|
+
async start(controller) {
|
|
141
|
+
for (const chunk of chunks) {
|
|
142
|
+
controller.enqueue(encoder.encode(chunk));
|
|
143
|
+
// Each chunk is its own turn, so the action row re-syncs between
|
|
144
|
+
// them: a post-render patch would be visible here.
|
|
145
|
+
await Promise.resolve();
|
|
146
|
+
}
|
|
147
|
+
await settled;
|
|
148
|
+
controller.close();
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
}) as unknown as Response
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const { mount } = makeController({
|
|
155
|
+
composer: { actions: [voiceAction({ visibility: "when-empty" })] },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
typeInto(mount, "Hello");
|
|
159
|
+
await flush();
|
|
160
|
+
mount
|
|
161
|
+
.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!
|
|
162
|
+
.click();
|
|
163
|
+
|
|
164
|
+
// The draft cleared on submit; the stream is what keeps the control hidden.
|
|
165
|
+
for (let i = 0; i < 12; i += 1) {
|
|
166
|
+
// eslint-disable-next-line no-await-in-loop
|
|
167
|
+
await flush();
|
|
168
|
+
expect(actionEl(mount, "voice-mode")).toBeNull();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
release();
|
|
172
|
+
await flush(20);
|
|
173
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("stays hidden when the visible predicate says so, whatever the draft", async () => {
|
|
177
|
+
const { mount } = makeController({
|
|
178
|
+
composer: {
|
|
179
|
+
actions: [voiceAction({ visibility: "when-empty", visible: false })],
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await flush();
|
|
184
|
+
expect(actionEl(mount, "voice-mode")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("follows a live update() of the visibility mode", async () => {
|
|
188
|
+
const { mount, controller } = makeController({
|
|
189
|
+
composer: { actions: [voiceAction()] },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
typeInto(mount, "Hello");
|
|
193
|
+
await flush();
|
|
194
|
+
expect(actionEl(mount, "voice-mode")).toBeTruthy();
|
|
195
|
+
|
|
196
|
+
controller.update({
|
|
197
|
+
composer: { actions: [voiceAction({ visibility: "when-empty" })] },
|
|
198
|
+
});
|
|
199
|
+
await flush();
|
|
200
|
+
expect(actionEl(mount, "voice-mode")).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -335,6 +335,43 @@ describe("composer model picker", () => {
|
|
|
335
335
|
);
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
it("returns the menu to the stylesheet fallback when an update unsets the token", () => {
|
|
339
|
+
const token = "--persona-components-composer-modelPicker-menuBackground";
|
|
340
|
+
const { mount, controller } = makeController({
|
|
341
|
+
...popoverConfig(),
|
|
342
|
+
theme: {
|
|
343
|
+
components: { composer: { modelPicker: { menuBackground: "#1e1f20" } } },
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
expect(mount.style.getPropertyValue(token)).toBe("#1e1f20");
|
|
347
|
+
// The panel is portaled to document.body, so it never inherits the
|
|
348
|
+
// mount's theme vars. Stamped on the trigger here because jsdom's
|
|
349
|
+
// computed style does not inherit custom properties.
|
|
350
|
+
const button = trigger(mount)!;
|
|
351
|
+
button.style.setProperty(token, "#1e1f20");
|
|
352
|
+
|
|
353
|
+
button.click();
|
|
354
|
+
const panel = menu()!;
|
|
355
|
+
expect(panel.style.getPropertyValue(token)).toBe("#1e1f20");
|
|
356
|
+
button.click();
|
|
357
|
+
|
|
358
|
+
// Explicit undefined is the documented reset, so the mount stops
|
|
359
|
+
// carrying the var; the trigger's stand-in follows it.
|
|
360
|
+
controller.update({
|
|
361
|
+
theme: {
|
|
362
|
+
components: { composer: { modelPicker: { menuBackground: undefined } } },
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
expect(mount.style.getPropertyValue(token)).toBe("");
|
|
366
|
+
button.style.removeProperty(token);
|
|
367
|
+
|
|
368
|
+
// The menu outlives the update, so the next open has to clear the value
|
|
369
|
+
// the first one stamped; an empty inline value is the fallback state.
|
|
370
|
+
trigger(mount)!.click();
|
|
371
|
+
expect(menu()).toBe(panel);
|
|
372
|
+
expect(panel.style.getPropertyValue(token)).toBe("");
|
|
373
|
+
});
|
|
374
|
+
|
|
338
375
|
it("shows the selected label on the closed control", () => {
|
|
339
376
|
const { mount } = makeController(
|
|
340
377
|
popoverConfig({ selectedModelId: "smart" })
|
|
@@ -326,3 +326,85 @@ describe("composer modes", () => {
|
|
|
326
326
|
expect(panel.querySelector('[role="menuitem"]')?.textContent).toContain("Hidden");
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
describe("modeGroups chipVisibility", () => {
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
window.scrollTo = vi.fn();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
afterEach(() => {
|
|
336
|
+
controllers.splice(0).forEach((controller) => {
|
|
337
|
+
try {
|
|
338
|
+
controller.destroy();
|
|
339
|
+
} catch {
|
|
340
|
+
/* already destroyed */
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
344
|
+
document.body.innerHTML = "";
|
|
345
|
+
window.localStorage.clear();
|
|
346
|
+
vi.restoreAllMocks();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const hiddenGroups: ComposerModeGroup[] = [
|
|
350
|
+
{ id: "tool", selection: "single", chipVisibility: "hidden" },
|
|
351
|
+
{ id: "style", selection: "multiple" },
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
it("keeps the bar button but renders no chip for the opted-out group", async () => {
|
|
355
|
+
const { mount, controller } = makeController({
|
|
356
|
+
composer: { modes, modeGroups: hiddenGroups },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
modeButton(mount, "search").click();
|
|
360
|
+
await flush();
|
|
361
|
+
expect(controller.getComposerState().activeModeIds).toEqual(["search"]);
|
|
362
|
+
expect(modeButton(mount, "search").getAttribute("aria-pressed")).toBe("true");
|
|
363
|
+
expect(chips(mount)).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("still chips a group that did not opt out", async () => {
|
|
367
|
+
const { mount } = makeController({
|
|
368
|
+
composer: { modes, modeGroups: hiddenGroups },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
modeButton(mount, "search").click();
|
|
372
|
+
modeButton(mount, "concise").click();
|
|
373
|
+
await flush();
|
|
374
|
+
expect(
|
|
375
|
+
chips(mount).map((chip) => chip.getAttribute("data-persona-composer-mode"))
|
|
376
|
+
).toEqual(["concise"]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("leaves no chip row gap when every active mode is suppressed", async () => {
|
|
380
|
+
const { mount } = makeController({
|
|
381
|
+
composer: {
|
|
382
|
+
modes,
|
|
383
|
+
modeGroups: hiddenGroups,
|
|
384
|
+
defaultActiveModeIds: ["search"],
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
await flush();
|
|
388
|
+
const row = chipRow(mount);
|
|
389
|
+
expect(row === null || row.style.display === "none").toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("follows a live update() that opts the group back in", async () => {
|
|
393
|
+
const { mount, controller } = makeController({
|
|
394
|
+
composer: {
|
|
395
|
+
modes,
|
|
396
|
+
modeGroups: hiddenGroups,
|
|
397
|
+
defaultActiveModeIds: ["search"],
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
await flush();
|
|
401
|
+
expect(chips(mount)).toHaveLength(0);
|
|
402
|
+
|
|
403
|
+
controller.update({ composer: { modes, modeGroups } });
|
|
404
|
+
await flush();
|
|
405
|
+
expect(
|
|
406
|
+
chips(mount).map((chip) => chip.getAttribute("data-persona-composer-mode"))
|
|
407
|
+
).toEqual(["search"]);
|
|
408
|
+
expect(chipRow(mount)!.style.display).toBe("flex");
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -103,6 +103,84 @@ describe("composer overflow menu in the live widget", () => {
|
|
|
103
103
|
vi.restoreAllMocks();
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
it("forwards the panel surface tokens onto the portaled menu on open", () => {
|
|
107
|
+
const { mount } = makeController({
|
|
108
|
+
composer: {
|
|
109
|
+
actions: [action("folded", { presentation: "overflow" })],
|
|
110
|
+
actionOverflow: { enabled: true },
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
// The panel is portaled to document.body, so it never inherits the mount's
|
|
114
|
+
// theme vars. Stamped on the trigger here because jsdom's computed style
|
|
115
|
+
// does not inherit custom properties.
|
|
116
|
+
const trigger = triggerOf(mount)!;
|
|
117
|
+
trigger.style.setProperty(
|
|
118
|
+
"--persona-components-composer-overflowMenu-background",
|
|
119
|
+
"#353535"
|
|
120
|
+
);
|
|
121
|
+
trigger.style.setProperty(
|
|
122
|
+
"--persona-components-composer-overflowMenu-borderColor",
|
|
123
|
+
"rgba(255, 255, 255, 0.08)"
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const panel = openPanel(mount)!;
|
|
127
|
+
|
|
128
|
+
expect(
|
|
129
|
+
panel.style.getPropertyValue(
|
|
130
|
+
"--persona-components-composer-overflowMenu-background"
|
|
131
|
+
)
|
|
132
|
+
).toBe("#353535");
|
|
133
|
+
expect(
|
|
134
|
+
panel.style.getPropertyValue(
|
|
135
|
+
"--persona-components-composer-overflowMenu-borderColor"
|
|
136
|
+
)
|
|
137
|
+
).toBe("rgba(255, 255, 255, 0.08)");
|
|
138
|
+
// Unset keys stamp nothing, so the stylesheet's fallback still stands.
|
|
139
|
+
expect(
|
|
140
|
+
panel.style.getPropertyValue(
|
|
141
|
+
"--persona-components-composer-overflowMenu-shadow"
|
|
142
|
+
)
|
|
143
|
+
).toBe("");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns the panel to the stylesheet fallback when an update unsets the token", () => {
|
|
147
|
+
const token = "--persona-components-composer-overflowMenu-background";
|
|
148
|
+
const { mount, controller } = makeController({
|
|
149
|
+
composer: {
|
|
150
|
+
actions: [action("folded", { presentation: "overflow" })],
|
|
151
|
+
actionOverflow: { enabled: true },
|
|
152
|
+
},
|
|
153
|
+
theme: {
|
|
154
|
+
components: { composer: { overflowMenu: { background: "#353535" } } },
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
expect(mount.style.getPropertyValue(token)).toBe("#353535");
|
|
158
|
+
// Stamped on the trigger for the same reason as the test above: jsdom's
|
|
159
|
+
// computed style does not inherit custom properties.
|
|
160
|
+
const trigger = triggerOf(mount)!;
|
|
161
|
+
trigger.style.setProperty(token, "#353535");
|
|
162
|
+
|
|
163
|
+
const panel = openPanel(mount)!;
|
|
164
|
+
expect(panel.style.getPropertyValue(token)).toBe("#353535");
|
|
165
|
+
triggerOf(mount)!.click();
|
|
166
|
+
|
|
167
|
+
// Explicit undefined is the documented reset, so the mount stops carrying
|
|
168
|
+
// the var; the trigger's stand-in follows it.
|
|
169
|
+
controller.update({
|
|
170
|
+
theme: {
|
|
171
|
+
components: { composer: { overflowMenu: { background: undefined } } },
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
expect(mount.style.getPropertyValue(token)).toBe("");
|
|
175
|
+
trigger.style.removeProperty(token);
|
|
176
|
+
|
|
177
|
+
// The panel outlives the update, so the next open has to clear the value
|
|
178
|
+
// the first one stamped; an empty inline value is the fallback state.
|
|
179
|
+
const reopened = openPanel(mount)!;
|
|
180
|
+
expect(reopened).toBe(panel);
|
|
181
|
+
expect(reopened.style.getPropertyValue(token)).toBe("");
|
|
182
|
+
});
|
|
183
|
+
|
|
106
184
|
it("sorts the trigger at the 900 anchor by default", () => {
|
|
107
185
|
const { mount } = makeController({
|
|
108
186
|
composer: {
|
|
@@ -212,7 +212,7 @@ describe("history controller lifecycle", () => {
|
|
|
212
212
|
expect(window.localStorage.getItem(keysA.storageKey)).toBe("cvt_alpha");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("
|
|
215
|
+
it("keeps the recovery credential store when history UI is disabled", async () => {
|
|
216
216
|
await seed();
|
|
217
217
|
const { controller } = mount({
|
|
218
218
|
clientToken: TOKEN_A,
|
|
@@ -224,7 +224,7 @@ describe("history controller lifecycle", () => {
|
|
|
224
224
|
await flush(20);
|
|
225
225
|
|
|
226
226
|
expect(recorder.instances.length).toBe(1);
|
|
227
|
-
expect(recorder.instances[0]!.destroy).
|
|
227
|
+
expect(recorder.instances[0]!.destroy).not.toHaveBeenCalled();
|
|
228
228
|
expect(recorder.instances[0]!.clear).not.toHaveBeenCalled();
|
|
229
229
|
});
|
|
230
230
|
});
|