@runtypelabs/persona 4.8.0 → 4.10.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 (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -10,36 +10,44 @@
10
10
  * to the textarea.
11
11
  */
12
12
 
13
- export interface ComposerHistoryState {
13
+ /**
14
+ * Generic over the entry type `T` so the same machine drives both composer
15
+ * surfaces: chip mode recalls `string`s (textarea values); inline mode recalls
16
+ * `ComposerDocument`s (blocks + mention ids). The machine never inspects an entry
17
+ * — it only indexes the list and swaps the draft — so it stays entirely
18
+ * value-agnostic. Defaults to `string` so every existing chip-mode caller is
19
+ * unchanged.
20
+ */
21
+ export interface ComposerHistoryState<T = string> {
14
22
  /** Index into the history list, or -1 when not navigating. */
15
23
  index: number;
16
- /** The user's in-progress text, saved when navigation begins. */
17
- draft: string;
24
+ /** The user's in-progress entry, saved when navigation begins. */
25
+ draft: T;
18
26
  }
19
27
 
20
- export const INITIAL_HISTORY_STATE: ComposerHistoryState = {
28
+ export const INITIAL_HISTORY_STATE: ComposerHistoryState<string> = {
21
29
  index: -1,
22
30
  draft: ""
23
31
  };
24
32
 
25
- export interface ComposerHistoryInput {
33
+ export interface ComposerHistoryInput<T = string> {
26
34
  direction: "up" | "down";
27
- /** Previously sent user-message texts, oldest first. */
28
- history: string[];
29
- /** Current textarea value (saved as the draft when navigation begins). */
30
- currentValue: string;
31
- /** True when the caret sits at the very start of the textarea. */
35
+ /** Previously sent user entries, oldest first. */
36
+ history: T[];
37
+ /** Current composer entry (saved as the draft when navigation begins). */
38
+ currentValue: T;
39
+ /** True when the caret sits at the very start of the composer. */
32
40
  atStart: boolean;
33
- state: ComposerHistoryState;
41
+ state: ComposerHistoryState<T>;
34
42
  }
35
43
 
36
- export interface ComposerHistoryResult {
44
+ export interface ComposerHistoryResult<T = string> {
37
45
  /** Whether the key was consumed (caller should preventDefault). */
38
46
  handled: boolean;
39
- /** New textarea value to apply: only present when it should change. */
40
- value?: string;
47
+ /** New entry to apply: only present when it should change. */
48
+ value?: T;
41
49
  /** Next navigation state. */
42
- state: ComposerHistoryState;
50
+ state: ComposerHistoryState<T>;
43
51
  }
44
52
 
45
53
  /**
@@ -50,9 +58,9 @@ export interface ComposerHistoryResult {
50
58
  * - **Down** only acts while already navigating, stepping toward newer messages
51
59
  * and finally restoring the saved draft once it walks past the newest entry.
52
60
  */
53
- export function navigateComposerHistory(
54
- input: ComposerHistoryInput
55
- ): ComposerHistoryResult {
61
+ export function navigateComposerHistory<T = string>(
62
+ input: ComposerHistoryInput<T>
63
+ ): ComposerHistoryResult<T> {
56
64
  const { direction, history, currentValue, atStart, state } = input;
57
65
  const inHistory = state.index !== -1;
58
66
 
@@ -104,10 +112,12 @@ export function navigateComposerHistory(
104
112
  };
105
113
  }
106
114
 
107
- // Stepped past the newest entry: restore the saved draft and exit.
115
+ // Stepped past the newest entry: restore the saved draft and exit. `index: -1`
116
+ // marks "not navigating"; the draft field is dead until the next Up overwrites
117
+ // it, so keep the existing (correctly-typed) value rather than fabricating one.
108
118
  return {
109
119
  handled: true,
110
120
  value: state.draft,
111
- state: { ...INITIAL_HISTORY_STATE }
121
+ state: { index: -1, draft: state.draft }
112
122
  };
113
123
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The composer input surface the mention controller drives, abstracted over the
3
+ * two editing modes:
4
+ *
5
+ * - **chip mode** (`display: "chip"`, default): a plain `<textarea>`. Logical
6
+ * text is the raw value and logical caret is `selectionStart`.
7
+ * - **inline mode** (`display: "inline"`, Phase 4): a contenteditable surface
8
+ * whose atomic mention tokens collapse to a single `` in the logical text
9
+ * (see `composer-document.ts`). Its adapter additionally implements the
10
+ * optional document/token methods below.
11
+ *
12
+ * The controller only ever touches the composer through this capability, so the
13
+ * same menu/keyboard/search engine serves both surfaces. `ComposerInputCapability`
14
+ * is a superset of the public `AgentWidgetContextMentionComposerCapability`
15
+ * (`getValue`/`setValue`/`submit`) — the single source of truth for composer
16
+ * access — so a client `command:"action"` handler receives the same object.
17
+ *
18
+ * IMPORTANT (bundle guard): this module imports the document model TYPE-ONLY, so
19
+ * the chip chunk (`context-mentions.js`) never pulls `composer-document.ts`
20
+ * runtime. The contenteditable adapter, which does need it, lives in the separate
21
+ * inline chunk. Keep it that way — see `context-mentions-bundle.test.ts`.
22
+ */
23
+
24
+ import type { MentionTriggerMatch } from "./mention-trigger";
25
+ import type {
26
+ ComposerDocument,
27
+ ComposerMentionId
28
+ } from "./composer-document";
29
+ import type {
30
+ AgentWidgetContextMentionComposerCapability,
31
+ AgentWidgetContextMentionRef
32
+ } from "../types";
33
+
34
+ export interface ComposerInputCapability
35
+ extends AgentWidgetContextMentionComposerCapability {
36
+ /** The editable element — ARIA target for the menu, and focus receiver. */
37
+ readonly element: HTMLElement;
38
+ /** Text used for trigger parsing. Chip: `=== getValue()`; inline: text + ``. */
39
+ getLogicalText: () => string;
40
+ /** Caret/selection in LOGICAL coordinates. */
41
+ getSelection: () => { start: number; end: number };
42
+ /** Move the caret/selection (logical coordinates). */
43
+ setSelection: (start: number, end?: number) => void;
44
+ /** Replace the plain text and place the caret. No input event, no focus. */
45
+ setValueWithCaret: (value: string, caret: number) => void;
46
+ /** Fire input listeners after a programmatic edit. */
47
+ dispatchInput: () => void;
48
+ focus: () => void;
49
+
50
+ /**
51
+ * INLINE ONLY (contenteditable). Bounding rect (VIEWPORT coordinates) of the
52
+ * LOGICAL character range `[start, end)`, or `null` when unmeasurable (invalid
53
+ * range or a zero-size/degenerate rect). Used to trigger-anchor the mention menu
54
+ * horizontally to the `@` glyph. Chip (textarea) adapters omit it — no cheap
55
+ * per-glyph rect without a mirror-div hack, so the menu stays composer-anchored
56
+ * (graceful degradation). Callers treat a missing method exactly like a `null`
57
+ * result.
58
+ */
59
+ getLogicalRangeRect?: (start: number, end: number) => DOMRect | null;
60
+
61
+ // ---- inline-only (contenteditable adapter); chip adapters omit these ----
62
+
63
+ /** Canonical document (inline mode). */
64
+ getDocument?: () => ComposerDocument;
65
+ /**
66
+ * INLINE ONLY. Replace the active trigger range (`match`, logical coordinates)
67
+ * with an atomic mention token and place the caret after it. Returns the new
68
+ * mention's id (resolve-tracking key), or `null` if the range no longer matches
69
+ * the document (stale menu). Chip adapters leave this undefined — the controller
70
+ * then falls back to `stripMentionQuery`.
71
+ */
72
+ insertMentionAtTrigger?: (
73
+ ref: AgentWidgetContextMentionRef,
74
+ match: MentionTriggerMatch
75
+ ) => ComposerMentionId | null;
76
+ /**
77
+ * INLINE ONLY. Insert an atomic mention token at the current caret/selection
78
+ * (picker path — the affordance button opens with no trigger char to replace).
79
+ * Returns the new mention's id. Chip adapters leave this undefined.
80
+ */
81
+ insertMentionAtSelection?: (
82
+ ref: AgentWidgetContextMentionRef
83
+ ) => ComposerMentionId;
84
+ /**
85
+ * INLINE ONLY. Reflect a mention's resolve status on its live token element —
86
+ * used to surface resolve errors inline. Toggles the error styling for `"error"`
87
+ * and clears it otherwise. No-op for an unknown id. Chip adapters omit this.
88
+ */
89
+ setMentionStatus?: (
90
+ id: ComposerMentionId,
91
+ status: "pending" | "resolved" | "error"
92
+ ) => void;
93
+ /**
94
+ * INLINE ONLY. Replace the LOGICAL range `[start, end)` with plain `text`,
95
+ * preserving every mention token that lies outside the range, and place the
96
+ * caret after the inserted text. This is the token-safe edit the controller
97
+ * uses for query-strip / slash-completion / prompt-macro rewrites, since a plain
98
+ * `getValue()` string-slice would misalign against DISPLAY text (`@Label`
99
+ * expands a token to many chars) and re-rendering as one text block would
100
+ * destroy every token. Chip adapters omit it — there DISPLAY === LOGICAL, so the
101
+ * controller string-slices `getValue()` + `setValueWithCaret` instead.
102
+ */
103
+ replaceLogicalRange?: (start: number, end: number, text: string) => void;
104
+ }
105
+
106
+ /**
107
+ * Submit the form that owns a composer element: prefer `requestSubmit()` (fires
108
+ * native validation + a cancelable `submit`), falling back to a dispatched
109
+ * cancelable `submit` event where `requestSubmit` is unavailable. Shared by both
110
+ * composer adapters so submission behaves identically across editing modes.
111
+ */
112
+ export function requestFormSubmit(form: HTMLFormElement | null | undefined): void {
113
+ if (!form) return;
114
+ if (typeof form.requestSubmit === "function") {
115
+ form.requestSubmit();
116
+ } else {
117
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Chip-mode adapter: a `ComposerInputCapability` backed by a raw `<textarea>`.
123
+ * Logical text === value, logical caret === `selectionStart`. `submit()` derives
124
+ * the composer form from `textarea.form` (falling back to a dispatched `submit`
125
+ * event where `requestSubmit` is unavailable), matching the widget's own behavior
126
+ * — so the composer surface owns submission, no separate capability needed.
127
+ */
128
+ export function createTextareaComposerInput(
129
+ textarea: HTMLTextAreaElement
130
+ ): ComposerInputCapability {
131
+ const dispatchInput = (): void => {
132
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
133
+ };
134
+ return {
135
+ element: textarea,
136
+ getValue: () => textarea.value,
137
+ getLogicalText: () => textarea.value,
138
+ getSelection: () => ({
139
+ start: textarea.selectionStart ?? 0,
140
+ end: textarea.selectionEnd ?? 0
141
+ }),
142
+ setSelection: (start, end = start) => {
143
+ textarea.setSelectionRange(start, end);
144
+ },
145
+ setValueWithCaret: (value, caret) => {
146
+ textarea.value = value;
147
+ textarea.setSelectionRange(caret, caret);
148
+ },
149
+ setValue: (value: string) => {
150
+ textarea.value = value;
151
+ textarea.setSelectionRange(value.length, value.length);
152
+ dispatchInput();
153
+ textarea.focus();
154
+ },
155
+ submit: () => requestFormSubmit(textarea.form),
156
+ dispatchInput,
157
+ focus: () => textarea.focus()
158
+ };
159
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { mergeConfigUpdate } from "./config-merge";
4
+ import { mergeWithDefaults } from "../defaults";
5
+ import type { AgentWidgetConfig, StreamAnimationPlugin } from "../types";
6
+
7
+ // Stored controller config is post-mergeWithDefaults; simulate that here.
8
+ const base = (overrides: Partial<AgentWidgetConfig> = {}): AgentWidgetConfig =>
9
+ mergeWithDefaults({ apiUrl: "https://api.example.com/chat", ...overrides }) as AgentWidgetConfig;
10
+
11
+ describe("mergeConfigUpdate", () => {
12
+ it("recursively merges nested plain objects, preserving sibling overrides", () => {
13
+ const prev = base({ launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } } });
14
+ const next = mergeConfigUpdate(prev, { launcher: { title: "After" } });
15
+ expect(next.launcher?.clearChat?.backgroundColor).toBe("#123456");
16
+ expect(next.launcher?.title).toBe("After");
17
+ // Launcher defaults survive a partial patch.
18
+ expect(next.launcher?.mountMode).toBe("floating");
19
+ });
20
+
21
+ it("patches composerBar without replacing sibling launcher fields", () => {
22
+ const prev = base({ launcher: { title: "Keep", composerBar: { collapsedMaxWidth: "700px" } } });
23
+ const next = mergeConfigUpdate(prev, { launcher: { composerBar: { bottomOffset: "8px" } } });
24
+ expect(next.launcher?.title).toBe("Keep");
25
+ expect(next.launcher?.composerBar?.collapsedMaxWidth).toBe("700px");
26
+ expect(next.launcher?.composerBar?.bottomOffset).toBe("8px");
27
+ });
28
+
29
+ it("replaces arrays wholesale (suggestionChips)", () => {
30
+ const prev = base({ suggestionChips: ["a", "b", "c"] });
31
+ const next = mergeConfigUpdate(prev, { suggestionChips: ["x"] });
32
+ expect(next.suggestionChips).toEqual(["x"]);
33
+ });
34
+
35
+ it("replaces callbacks wholesale", () => {
36
+ const first = vi.fn();
37
+ const second = vi.fn();
38
+ const prev = base({ onSessionInit: first });
39
+ const next = mergeConfigUpdate(prev, { onSessionInit: second });
40
+ expect(next.onSessionInit).toBe(second);
41
+ });
42
+
43
+ it("guards boolean|object unions in both directions", () => {
44
+ const objFirst = base({ approval: { backgroundColor: "#ffffff" } });
45
+ const toScalar = mergeConfigUpdate(objFirst, { approval: false });
46
+ expect(toScalar.approval).toBe(false);
47
+
48
+ const scalarFirst = base({ persistState: true });
49
+ const toObject = mergeConfigUpdate(scalarFirst, { persistState: { storage: "local" } });
50
+ expect(toObject.persistState).toEqual({ storage: "local" });
51
+ });
52
+
53
+ it("replaces the headers map wholesale (stale keys do not survive)", () => {
54
+ const prev = base({ headers: { "X-A": "1", "X-B": "2" } });
55
+ const next = mergeConfigUpdate(prev, { headers: { "X-B": "3" } });
56
+ expect(next.headers).toEqual({ "X-B": "3" });
57
+ expect(next.headers?.["X-A"]).toBeUndefined();
58
+ });
59
+
60
+ it("replaces features.streamAnimation.plugins wholesale", () => {
61
+ const p1 = { name: "p1" } as StreamAnimationPlugin;
62
+ const p2 = { name: "p2" } as StreamAnimationPlugin;
63
+ const prev = base({ features: { streamAnimation: { plugins: { p1 } } } });
64
+ const next = mergeConfigUpdate(prev, { features: { streamAnimation: { plugins: { p2 } } } });
65
+ expect(next.features?.streamAnimation?.plugins).toEqual({ p2 });
66
+ expect(next.features?.streamAnimation?.plugins?.p1).toBeUndefined();
67
+ });
68
+
69
+ it("replaces storageAdapter wholesale (no hybrid: new adapter's absent save is not inherited)", () => {
70
+ const oldSave = vi.fn();
71
+ const oldLoad = vi.fn();
72
+ const newLoad = vi.fn();
73
+ const prev = base({ storageAdapter: { save: oldSave, load: oldLoad } });
74
+ const next = mergeConfigUpdate(prev, { storageAdapter: { load: newLoad } });
75
+ expect(next.storageAdapter?.load).toBe(newLoad);
76
+ expect(next.storageAdapter?.save).toBeUndefined();
77
+ });
78
+
79
+ it("resets a cleared key to its default value (launcher.title)", () => {
80
+ const prev = base({ launcher: { title: "Custom" } });
81
+ expect(prev.launcher?.title).toBe("Custom");
82
+ const next = mergeConfigUpdate(prev, { launcher: { title: undefined } });
83
+ expect(next.launcher?.title).toBe("Chat Assistant");
84
+ // Other launcher fields are untouched by the clear.
85
+ expect(next.launcher?.mountMode).toBe("floating");
86
+ });
87
+
88
+ it("leaves a cleared key unset when no default exists (launcher.closeButtonColor)", () => {
89
+ const prev = base({ launcher: { closeButtonColor: "#ff0000" } });
90
+ expect(prev.launcher?.closeButtonColor).toBe("#ff0000");
91
+ const next = mergeConfigUpdate(prev, { launcher: { closeButtonColor: undefined } });
92
+ // closeButtonColor is intentionally omitted from the launcher defaults.
93
+ expect(next.launcher?.closeButtonColor).toBeUndefined();
94
+ });
95
+
96
+ it("resets a cleared key with a truthy default (messageActions.showCopy resets to true)", () => {
97
+ const prev = base({ messageActions: { showCopy: false } });
98
+ expect(prev.messageActions?.showCopy).toBe(false);
99
+ const next = mergeConfigUpdate(prev, { messageActions: { showCopy: undefined } });
100
+ // The key must be absent (not own-undefined) so the default spread repopulates it.
101
+ expect(next.messageActions?.showCopy).toBe(true);
102
+ });
103
+
104
+ it("resets a cleared nested key with a truthy default (layout.header.showTitle resets to true)", () => {
105
+ const prev = base({ layout: { header: { showTitle: false } } });
106
+ expect(prev.layout?.header?.showTitle).toBe(false);
107
+ const next = mergeConfigUpdate(prev, { layout: { header: { showTitle: undefined } } });
108
+ expect(next.layout?.header?.showTitle).toBe(true);
109
+ });
110
+
111
+ it("preserves keys absent from the patch", () => {
112
+ const prev = base({ launcher: { title: "Keep", subtitle: "Sub" } });
113
+ const next = mergeConfigUpdate(prev, { launcher: { title: "New" } });
114
+ expect(next.launcher?.title).toBe("New");
115
+ expect(next.launcher?.subtitle).toBe("Sub");
116
+ });
117
+
118
+ it("deep-merges theme partials through update, preserving earlier theme overrides", () => {
119
+ const prev = base({ theme: { semantic: { colors: { primary: "#111111" } } } });
120
+ const next = mergeConfigUpdate(prev, { theme: { semantic: { colors: { secondary: "#222222" } } } });
121
+ expect(next.theme?.semantic?.colors?.primary).toBe("#111111");
122
+ expect(next.theme?.semantic?.colors?.secondary).toBe("#222222");
123
+ });
124
+
125
+ it("is idempotent: merging a config over its equal self is a no-op", () => {
126
+ const prev = base({ launcher: { clearChat: { backgroundColor: "#123456" } } });
127
+ const merged = mergeConfigUpdate(prev, { launcher: { title: "T" } });
128
+ const again = mergeConfigUpdate(merged, merged);
129
+ expect(again).toEqual(merged);
130
+ });
131
+ });
@@ -0,0 +1,61 @@
1
+ import { mergeWithDefaults } from "../defaults";
2
+ import type { AgentWidgetConfig, AgentWidgetConfigPatch } from "../types";
3
+
4
+ // Same predicate as utils/deep-merge.ts: plain object, non-null, non-array.
5
+ // Class instances and DOM nodes also pass here, so replace-leaf paths guard the
6
+ // ones that must not be recursed into.
7
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value);
9
+
10
+ // Dotted paths whose plain-object value replaces wholesale instead of recursing.
11
+ // - user-defined key maps: recursive merge would strand stale keys from the old value
12
+ // (headers, targetProviders, components, features.streamAnimation.plugins).
13
+ // - implementation objects: partial merge splices two impls into a broken hybrid
14
+ // (agent, storageAdapter, voiceRecognition.provider.custom).
15
+ const REPLACE_LEAF_PATHS = new Set<string>([
16
+ "headers",
17
+ "agent",
18
+ "storageAdapter",
19
+ "components",
20
+ "targetProviders",
21
+ "voiceRecognition.provider.custom",
22
+ "features.streamAnimation.plugins",
23
+ ]);
24
+
25
+ // Recursive patch merge. A key merges recursively iff both previous and patch
26
+ // values are plain objects and the path is not a replace-leaf; otherwise the
27
+ // patch value replaces. A key present in the patch with value undefined is
28
+ // deleted from the result (an own undefined property would shadow defaults in
29
+ // mergeWithDefaults' spreads); absent keys are never visited, so they are
30
+ // preserved. theme/darkTheme need no special case: the both-sides-plain-object
31
+ // rule already reproduces mergeThemePartials/deepMerge (defaults.ts:225-236).
32
+ const mergePatch = (previous: unknown, patch: unknown, path: string): unknown => {
33
+ if (!isPlainObject(previous) || !isPlainObject(patch)) return patch;
34
+ if (REPLACE_LEAF_PATHS.has(path)) return patch;
35
+
36
+ const result: Record<string, unknown> = { ...previous };
37
+ for (const key of Object.keys(patch)) {
38
+ if (patch[key] === undefined) {
39
+ delete result[key];
40
+ continue;
41
+ }
42
+ const childPath = path ? `${path}.${key}` : key;
43
+ result[key] = mergePatch(previous[key], patch[key], childPath);
44
+ }
45
+ return result;
46
+ };
47
+
48
+ /**
49
+ * Merge a config patch over the previous widget config with one consistent
50
+ * recursive policy, then re-apply mergeWithDefaults so a cleared
51
+ * (explicit-undefined) key resets to its default. The stored controller config
52
+ * is already post-mergeWithDefaults, so re-applying it is idempotent and keeps
53
+ * initial mount, live update, and rebuild on the same merge policy.
54
+ */
55
+ export function mergeConfigUpdate(
56
+ previousConfig: AgentWidgetConfig,
57
+ patch: AgentWidgetConfigPatch
58
+ ): AgentWidgetConfig {
59
+ const merged = mergePatch(previousConfig, patch, "") as Partial<AgentWidgetConfig>;
60
+ return mergeWithDefaults(merged) as AgentWidgetConfig;
61
+ }