@runtypelabs/persona 3.18.0 → 3.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 (42) hide show
  1. package/README.md +45 -2
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +383 -6
  5. package/dist/index.d.ts +383 -6
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1514 -626
  11. package/dist/theme-editor.d.cts +192 -1
  12. package/dist/theme-editor.d.ts +192 -1
  13. package/dist/theme-editor.js +1628 -626
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +5 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/session.test.ts +123 -0
  30. package/src/session.ts +58 -4
  31. package/src/styles/widget.css +348 -0
  32. package/src/types.ts +196 -1
  33. package/src/ui.component-directive.test.ts +183 -0
  34. package/src/ui.composer-bar.test.ts +1009 -0
  35. package/src/ui.ts +827 -72
  36. package/src/utils/attachment-manager.ts +1 -1
  37. package/src/utils/component-middleware.test.ts +134 -0
  38. package/src/utils/component-middleware.ts +44 -13
  39. package/src/utils/dock.test.ts +45 -0
  40. package/src/utils/dock.ts +3 -0
  41. package/src/utils/icons.ts +314 -58
  42. package/src/utils/stream-animation.ts +7 -2
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "../ui";
6
+ import { createLocalStorageAdapter } from "../utils/storage";
7
+ import type { AgentWidgetStorageAdapter } from "../types";
8
+
9
+ const DEFAULT_KEY = "persona-state";
10
+
11
+ const baseConfig = () => ({
12
+ apiUrl: "https://api.example.com/chat",
13
+ launcher: { enabled: false } as const,
14
+ });
15
+
16
+ const inject = (controller: ReturnType<typeof createAgentExperience>) =>
17
+ controller.injectAssistantMessage({ content: "hello world" });
18
+
19
+ describe("persistState gates storage adapter", () => {
20
+ afterEach(() => {
21
+ document.body.innerHTML = "";
22
+ try {
23
+ window.localStorage.clear();
24
+ } catch {
25
+ /* jsdom edge cases */
26
+ }
27
+ });
28
+
29
+ it("persistState: false skips the default localStorage adapter", () => {
30
+ const mount = document.createElement("div");
31
+ document.body.appendChild(mount);
32
+
33
+ const controller = createAgentExperience(mount, {
34
+ ...baseConfig(),
35
+ persistState: false,
36
+ });
37
+
38
+ inject(controller);
39
+
40
+ expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
41
+ controller.destroy();
42
+ });
43
+
44
+ it("persistState: false ignores any user-supplied storageAdapter (strict semantic)", () => {
45
+ const mount = document.createElement("div");
46
+ document.body.appendChild(mount);
47
+
48
+ const customAdapter: AgentWidgetStorageAdapter = {
49
+ load: vi.fn(() => null),
50
+ save: vi.fn(),
51
+ clear: vi.fn(),
52
+ };
53
+
54
+ const controller = createAgentExperience(mount, {
55
+ ...baseConfig(),
56
+ persistState: false,
57
+ storageAdapter: customAdapter,
58
+ });
59
+
60
+ inject(controller);
61
+
62
+ expect(customAdapter.load).not.toHaveBeenCalled();
63
+ expect(customAdapter.save).not.toHaveBeenCalled();
64
+ controller.destroy();
65
+ });
66
+
67
+ it("default config (persistState undefined) writes to the default localStorage key", () => {
68
+ const mount = document.createElement("div");
69
+ document.body.appendChild(mount);
70
+
71
+ const controller = createAgentExperience(mount, baseConfig());
72
+
73
+ inject(controller);
74
+
75
+ const stored = window.localStorage.getItem(DEFAULT_KEY);
76
+ expect(stored).not.toBeNull();
77
+ const parsed = JSON.parse(stored!);
78
+ expect(parsed.messages).toBeInstanceOf(Array);
79
+ expect(parsed.messages.length).toBeGreaterThan(0);
80
+ controller.destroy();
81
+ });
82
+
83
+ it("persistState: true keeps using the default localStorage adapter", () => {
84
+ const mount = document.createElement("div");
85
+ document.body.appendChild(mount);
86
+
87
+ const controller = createAgentExperience(mount, {
88
+ ...baseConfig(),
89
+ persistState: true,
90
+ });
91
+
92
+ inject(controller);
93
+
94
+ expect(window.localStorage.getItem(DEFAULT_KEY)).not.toBeNull();
95
+ controller.destroy();
96
+ });
97
+
98
+ it("two widgets with different storageAdapter keys keep their messages isolated", () => {
99
+ const mountA = document.createElement("div");
100
+ const mountB = document.createElement("div");
101
+ document.body.appendChild(mountA);
102
+ document.body.appendChild(mountB);
103
+
104
+ const controllerA = createAgentExperience(mountA, {
105
+ ...baseConfig(),
106
+ storageAdapter: createLocalStorageAdapter("persona-state-test-a"),
107
+ });
108
+ const controllerB = createAgentExperience(mountB, {
109
+ ...baseConfig(),
110
+ storageAdapter: createLocalStorageAdapter("persona-state-test-b"),
111
+ });
112
+
113
+ controllerA.injectAssistantMessage({ content: "message in A" });
114
+ controllerB.injectAssistantMessage({ content: "message in B" });
115
+
116
+ const storedA = JSON.parse(window.localStorage.getItem("persona-state-test-a")!);
117
+ const storedB = JSON.parse(window.localStorage.getItem("persona-state-test-b")!);
118
+
119
+ const aHasA = storedA.messages.some((m: { content?: string }) => m.content === "message in A");
120
+ const aHasB = storedA.messages.some((m: { content?: string }) => m.content === "message in B");
121
+ const bHasA = storedB.messages.some((m: { content?: string }) => m.content === "message in A");
122
+ const bHasB = storedB.messages.some((m: { content?: string }) => m.content === "message in B");
123
+
124
+ expect(aHasA).toBe(true);
125
+ expect(aHasB).toBe(false);
126
+ expect(bHasB).toBe(true);
127
+ expect(bHasA).toBe(false);
128
+ expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
129
+
130
+ controllerA.destroy();
131
+ controllerB.destroy();
132
+ });
133
+
134
+ it("persistState: false does not read from localStorage on init", () => {
135
+ // Pre-seed the default key with a stored message.
136
+ window.localStorage.setItem(
137
+ DEFAULT_KEY,
138
+ JSON.stringify({ messages: [{ id: "stale", role: "assistant", content: "stale" }] })
139
+ );
140
+
141
+ const mount = document.createElement("div");
142
+ document.body.appendChild(mount);
143
+
144
+ const controller = createAgentExperience(mount, {
145
+ ...baseConfig(),
146
+ persistState: false,
147
+ });
148
+
149
+ expect(controller.getMessages()).toEqual([]);
150
+ controller.destroy();
151
+ });
152
+ });
@@ -244,6 +244,129 @@ describe('AgentWidgetSession - Message Injection', () => {
244
244
  expect(messages[0].content).toBe('Legacy message');
245
245
  });
246
246
  });
247
+
248
+ describe('rawContent forwarding', () => {
249
+ it('preserves rawContent on injected messages', () => {
250
+ const directive = JSON.stringify({
251
+ text: 'Booking form',
252
+ component: 'BookingForm',
253
+ props: { title: 'Schedule' }
254
+ });
255
+
256
+ const result = session.injectMessage({
257
+ role: 'assistant',
258
+ content: 'Booking form',
259
+ rawContent: directive
260
+ });
261
+
262
+ expect(result.rawContent).toBe(directive);
263
+ expect(messages[0].rawContent).toBe(directive);
264
+ });
265
+
266
+ it('forwards rawContent through injectAssistantMessage', () => {
267
+ const directive = JSON.stringify({ text: 'Form', component: 'Form', props: {} });
268
+
269
+ const result = session.injectAssistantMessage({
270
+ content: 'Form',
271
+ rawContent: directive
272
+ });
273
+
274
+ expect(result.rawContent).toBe(directive);
275
+ });
276
+
277
+ it('forwards rawContent through injectMessageBatch', () => {
278
+ const directiveA = JSON.stringify({ text: 'A', component: 'CompA', props: {} });
279
+ const directiveB = JSON.stringify({ text: 'B', component: 'CompB', props: {} });
280
+
281
+ const results = session.injectMessageBatch([
282
+ { role: 'assistant', content: 'A', rawContent: directiveA },
283
+ { role: 'assistant', content: 'B', rawContent: directiveB }
284
+ ]);
285
+
286
+ expect(results[0].rawContent).toBe(directiveA);
287
+ expect(results[1].rawContent).toBe(directiveB);
288
+ });
289
+
290
+ it('omits rawContent when not provided', () => {
291
+ const result = session.injectMessage({
292
+ role: 'assistant',
293
+ content: 'plain'
294
+ });
295
+
296
+ expect(result.rawContent).toBeUndefined();
297
+ });
298
+ });
299
+
300
+ describe('injectComponentDirective', () => {
301
+ it('builds rawContent from component + props + text', () => {
302
+ const result = session.injectComponentDirective({
303
+ component: 'DynamicForm',
304
+ props: { title: 'Book a demo', fields: [{ label: 'Email' }] },
305
+ text: 'Share your details to book a demo.'
306
+ });
307
+
308
+ expect(result.role).toBe('assistant');
309
+ expect(result.content).toBe('Share your details to book a demo.');
310
+ expect(result.id).toMatch(/^ast_/);
311
+ expect(result.rawContent).toBeDefined();
312
+
313
+ const parsed = JSON.parse(result.rawContent as string);
314
+ expect(parsed).toEqual({
315
+ text: 'Share your details to book a demo.',
316
+ component: 'DynamicForm',
317
+ props: { title: 'Book a demo', fields: [{ label: 'Email' }] }
318
+ });
319
+ });
320
+
321
+ it('defaults text to empty string and props to {}', () => {
322
+ const result = session.injectComponentDirective({ component: 'DynamicForm' });
323
+
324
+ expect(result.content).toBe('');
325
+ const parsed = JSON.parse(result.rawContent as string);
326
+ expect(parsed).toEqual({ text: '', component: 'DynamicForm', props: {} });
327
+ });
328
+
329
+ it('forwards llmContent for redacted LLM context', () => {
330
+ const result = session.injectComponentDirective({
331
+ component: 'DynamicForm',
332
+ props: { title: 'Book a demo' },
333
+ text: 'Booking form below.',
334
+ llmContent: '[Showed booking form]'
335
+ });
336
+
337
+ expect(result.llmContent).toBe('[Showed booking form]');
338
+ });
339
+
340
+ it('honors custom id, createdAt, sequence', () => {
341
+ const result = session.injectComponentDirective({
342
+ component: 'DynamicForm',
343
+ id: 'my-form-1',
344
+ createdAt: '2026-01-01T00:00:00.000Z',
345
+ sequence: 999
346
+ });
347
+
348
+ expect(result.id).toBe('my-form-1');
349
+ expect(result.createdAt).toBe('2026-01-01T00:00:00.000Z');
350
+ expect(result.sequence).toBe(999);
351
+ });
352
+
353
+ it('upserts an existing directive by id', () => {
354
+ session.injectComponentDirective({
355
+ component: 'DynamicForm',
356
+ props: { title: 'v1' },
357
+ id: 'reuse-me'
358
+ });
359
+ session.injectComponentDirective({
360
+ component: 'DynamicForm',
361
+ props: { title: 'v2' },
362
+ id: 'reuse-me'
363
+ });
364
+
365
+ expect(messages).toHaveLength(1);
366
+ const parsed = JSON.parse(messages[0].rawContent as string);
367
+ expect(parsed.props.title).toBe('v2');
368
+ });
369
+ });
247
370
  });
248
371
 
249
372
  describe('AgentWidgetSession - cancel()', () => {
package/src/session.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  InjectAssistantMessageOptions,
12
12
  InjectUserMessageOptions,
13
13
  InjectSystemMessageOptions,
14
+ InjectComponentDirectiveOptions,
14
15
  PersonaArtifactRecord,
15
16
  PersonaArtifactManualUpsert
16
17
  } from "./types";
@@ -572,7 +573,8 @@ export class AgentWidgetSession {
572
573
  createdAt,
573
574
  sequence,
574
575
  streaming = false,
575
- voiceProcessing
576
+ voiceProcessing,
577
+ rawContent
576
578
  } = options;
577
579
 
578
580
  // Generate appropriate ID based on role
@@ -594,7 +596,8 @@ export class AgentWidgetSession {
594
596
  // Only include optional fields if provided
595
597
  ...(llmContent !== undefined && { llmContent }),
596
598
  ...(contentParts !== undefined && { contentParts }),
597
- ...(voiceProcessing !== undefined && { voiceProcessing })
599
+ ...(voiceProcessing !== undefined && { voiceProcessing }),
600
+ ...(rawContent !== undefined && { rawContent })
598
601
  };
599
602
 
600
603
  // Use upsert to handle both new messages and updates (streaming)
@@ -673,7 +676,9 @@ export class AgentWidgetSession {
673
676
  id,
674
677
  createdAt,
675
678
  sequence,
676
- streaming = false
679
+ streaming = false,
680
+ voiceProcessing,
681
+ rawContent
677
682
  } = options;
678
683
 
679
684
  const messageId =
@@ -692,7 +697,9 @@ export class AgentWidgetSession {
692
697
  sequence: sequence ?? this.nextSequence(),
693
698
  streaming,
694
699
  ...(llmContent !== undefined && { llmContent }),
695
- ...(contentParts !== undefined && { contentParts })
700
+ ...(contentParts !== undefined && { contentParts }),
701
+ ...(voiceProcessing !== undefined && { voiceProcessing }),
702
+ ...(rawContent !== undefined && { rawContent })
696
703
  };
697
704
 
698
705
  results.push(message);
@@ -705,6 +712,53 @@ export class AgentWidgetSession {
705
712
  return results;
706
713
  }
707
714
 
715
+ /**
716
+ * Convenience method for injecting a registered component directive as
717
+ * an assistant message — the same shape Persona produces from a streamed
718
+ * `{ "text": "...", "component": "...", "props": {...} }` payload.
719
+ *
720
+ * Sets `content` to `text`, `rawContent` to the JSON directive (so
721
+ * `extractComponentDirectiveFromMessage` can find it), and forwards
722
+ * `llmContent` / `id` / `createdAt` / `sequence`.
723
+ *
724
+ * @example
725
+ * session.injectComponentDirective({
726
+ * component: "DynamicForm",
727
+ * props: { title: "Book a demo", fields: [...] },
728
+ * text: "Share your details to book a demo.",
729
+ * llmContent: "[Showed booking form]"
730
+ * });
731
+ */
732
+ public injectComponentDirective(
733
+ options: InjectComponentDirectiveOptions
734
+ ): AgentWidgetMessage {
735
+ const {
736
+ component,
737
+ props = {},
738
+ text = "",
739
+ llmContent,
740
+ id,
741
+ createdAt,
742
+ sequence
743
+ } = options;
744
+
745
+ const directive: { text: string; component: string; props: Record<string, unknown> } = {
746
+ text,
747
+ component,
748
+ props
749
+ };
750
+
751
+ return this.injectMessage({
752
+ role: "assistant",
753
+ content: text,
754
+ rawContent: JSON.stringify(directive),
755
+ ...(llmContent !== undefined && { llmContent }),
756
+ ...(id !== undefined && { id }),
757
+ ...(createdAt !== undefined && { createdAt }),
758
+ ...(sequence !== undefined && { sequence })
759
+ });
760
+ }
761
+
708
762
  public async sendMessage(
709
763
  rawInput: string,
710
764
  options?: {