@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.
- package/README.md +14 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +72 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +634 -3
- package/dist/index.d.ts +634 -3
- package/dist/index.global.js +59 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +64 -56
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +236 -57
- package/dist/theme-editor-preview.d.cts +485 -1
- package/dist/theme-editor-preview.d.ts +485 -1
- package/dist/theme-editor-preview.js +53 -47
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +27 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +243 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +542 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +379 -84
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +54 -4
- package/src/utils/theme.ts +6 -3
- package/src/utils/tokens.ts +27 -11
|
@@ -10,36 +10,44 @@
|
|
|
10
10
|
* to the textarea.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
17
|
-
draft:
|
|
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
|
|
28
|
-
history:
|
|
29
|
-
/** Current
|
|
30
|
-
currentValue:
|
|
31
|
-
/** True when the caret sits at the very start of the
|
|
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
|
|
40
|
-
value?:
|
|
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: {
|
|
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
|
+
}
|