@runtypelabs/persona 4.19.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/approval-ui.d.cts +77 -5
  2. package/dist/approval-ui.d.ts +77 -5
  3. package/dist/artifacts-ui.d.cts +77 -5
  4. package/dist/artifacts-ui.d.ts +77 -5
  5. package/dist/codegen.cjs +1 -1
  6. package/dist/codegen.js +1 -1
  7. package/dist/context-mentions.d.cts +77 -5
  8. package/dist/context-mentions.d.ts +77 -5
  9. package/dist/event-stream-view.d.cts +77 -5
  10. package/dist/event-stream-view.d.ts +77 -5
  11. package/dist/forms-ui.d.cts +77 -5
  12. package/dist/forms-ui.d.ts +77 -5
  13. package/dist/index.cjs +50 -50
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +78 -6
  16. package/dist/index.d.ts +78 -6
  17. package/dist/index.global.js +31 -31
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +50 -50
  20. package/dist/index.js.map +1 -1
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/session-reconnect.d.cts +77 -5
  23. package/dist/session-reconnect.d.ts +77 -5
  24. package/dist/smart-dom-reader.d.cts +77 -5
  25. package/dist/smart-dom-reader.d.ts +77 -5
  26. package/dist/theme-editor-preview.cjs +49 -49
  27. package/dist/theme-editor-preview.d.cts +77 -5
  28. package/dist/theme-editor-preview.d.ts +77 -5
  29. package/dist/theme-editor-preview.js +28 -28
  30. package/dist/theme-editor.d.cts +77 -5
  31. package/dist/theme-editor.d.ts +77 -5
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +46 -5
  34. package/dist/theme-reference.d.ts +46 -5
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +1 -1
  38. package/src/components/composer-actions.ts +27 -1
  39. package/src/components/composer-model-picker.ts +4 -14
  40. package/src/components/composer-overflow-menu.ts +14 -0
  41. package/src/components/composer-parts.ts +20 -0
  42. package/src/index-core.ts +2 -0
  43. package/src/styles/widget-styles.test.ts +83 -1
  44. package/src/styles/widget.css +50 -14
  45. package/src/theme-reference.ts +1 -1
  46. package/src/types/theme.ts +47 -5
  47. package/src/types.ts +33 -0
  48. package/src/ui.composer-action-visibility.test.ts +202 -0
  49. package/src/ui.composer-models.test.ts +37 -0
  50. package/src/ui.composer-modes.test.ts +82 -0
  51. package/src/ui.composer-overflow.test.ts +78 -0
  52. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
  53. package/src/utils/composer-modes.test.ts +33 -0
  54. package/src/utils/composer-modes.ts +19 -2
  55. package/src/utils/tokens-parity.test.ts +20 -0
  56. package/src/utils/tokens.test.ts +76 -0
  57. package/src/utils/tokens.ts +4 -4
@@ -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: {