@runtypelabs/persona 4.13.0 → 4.15.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 (106) hide show
  1. package/README.md +4 -4
  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-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +339 -22
  14. package/dist/context-mentions.d.ts +339 -22
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +68 -67
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +483 -48
  19. package/dist/index.d.ts +483 -48
  20. package/dist/index.global.js +61 -58
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +70 -69
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +339 -22
  27. package/dist/smart-dom-reader.d.ts +339 -22
  28. package/dist/theme-editor-preview.cjs +67 -67
  29. package/dist/theme-editor-preview.d.cts +355 -23
  30. package/dist/theme-editor-preview.d.ts +355 -23
  31. package/dist/theme-editor-preview.js +75 -75
  32. package/dist/theme-editor.cjs +10 -10
  33. package/dist/theme-editor.d.cts +376 -24
  34. package/dist/theme-editor.d.ts +376 -24
  35. package/dist/theme-editor.js +10 -10
  36. package/dist/theme-reference.cjs +1 -1
  37. package/dist/theme-reference.d.cts +38 -0
  38. package/dist/theme-reference.d.ts +38 -0
  39. package/dist/theme-reference.js +1 -1
  40. package/dist/widget.css +1 -1
  41. package/package.json +1 -1
  42. package/src/artifacts-session.test.ts +106 -0
  43. package/src/ask-user-question-tool.ts +10 -7
  44. package/src/client.test.ts +2 -2
  45. package/src/client.ts +4 -1
  46. package/src/components/artifact-card.ts +13 -2
  47. package/src/components/artifact-inline.ts +11 -2
  48. package/src/components/artifact-preview.ts +1 -1
  49. package/src/components/composer-builder.test.ts +2 -0
  50. package/src/components/composer-builder.ts +1 -1
  51. package/src/components/composer-parts.test.ts +105 -4
  52. package/src/components/composer-parts.ts +54 -16
  53. package/src/components/message-bubble.test.ts +7 -1
  54. package/src/components/message-bubble.ts +11 -8
  55. package/src/components/messages.ts +5 -2
  56. package/src/components/panel.ts +31 -5
  57. package/src/components/pill-composer-builder.test.ts +18 -0
  58. package/src/components/suggestions.test.ts +233 -0
  59. package/src/components/suggestions.ts +463 -81
  60. package/src/components/widget-view.ts +4 -0
  61. package/src/defaults.ts +5 -3
  62. package/src/generated/runtype-openapi-contract.ts +17 -0
  63. package/src/index-core.ts +52 -2
  64. package/src/launcher-global.ts +1 -1
  65. package/src/plugins/types.ts +71 -3
  66. package/src/presets.ts +3 -3
  67. package/src/session.ts +55 -12
  68. package/src/styles/widget-styles.test.ts +162 -0
  69. package/src/styles/widget.css +440 -1
  70. package/src/suggest-replies-tool.test.ts +233 -2
  71. package/src/suggest-replies-tool.ts +115 -39
  72. package/src/theme-editor/index.ts +5 -0
  73. package/src/theme-editor/presets.test.ts +43 -0
  74. package/src/theme-editor/presets.ts +101 -87
  75. package/src/theme-editor/preview-utils.test.ts +34 -0
  76. package/src/theme-editor/preview-utils.ts +77 -2
  77. package/src/theme-editor/sections.test.ts +72 -7
  78. package/src/theme-editor/sections.ts +113 -13
  79. package/src/theme-editor/state-preferences.test.ts +15 -0
  80. package/src/theme-editor/state.ts +46 -0
  81. package/src/theme-editor/types.ts +15 -1
  82. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  83. package/src/theme-editor/webmcp/tools.ts +5 -0
  84. package/src/theme-reference.ts +9 -2
  85. package/src/types/theme.ts +32 -0
  86. package/src/types.ts +291 -25
  87. package/src/ui.artifact-pane-gating.test.ts +5 -5
  88. package/src/ui.config-preferences.test.ts +166 -0
  89. package/src/ui.detached-panel.test.ts +1 -1
  90. package/src/ui.suggest-replies.test.ts +1066 -1
  91. package/src/ui.ts +311 -45
  92. package/src/ui.typing-indicator.test.ts +135 -0
  93. package/src/utils/artifact-display.test.ts +180 -16
  94. package/src/utils/artifact-display.ts +155 -14
  95. package/src/utils/artifact-file.test.ts +1 -1
  96. package/src/utils/code-generators.test.ts +40 -0
  97. package/src/utils/code-generators.ts +22 -0
  98. package/src/utils/config-merge.test.ts +84 -0
  99. package/src/utils/config-merge.ts +6 -1
  100. package/src/utils/feature-preferences.test.ts +326 -0
  101. package/src/utils/feature-preferences.ts +498 -0
  102. package/src/utils/icons.test.ts +26 -0
  103. package/src/utils/icons.ts +8 -0
  104. package/src/utils/theme.test.ts +171 -0
  105. package/src/utils/tokens.ts +71 -7
  106. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -1,42 +1,206 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { resolveArtifactDisplayMode } from "./artifact-display";
2
+ import {
3
+ resolveArtifactDisplay,
4
+ resolveArtifactDisplayMode,
5
+ } from "./artifact-display";
3
6
 
4
7
  describe("resolveArtifactDisplayMode", () => {
8
+ const markdown = { artifactType: "markdown" as const };
9
+ const component = { artifactType: "component" as const };
10
+
5
11
  it("returns 'panel' when the feature is undefined", () => {
6
- expect(resolveArtifactDisplayMode(undefined, "markdown")).toBe("panel");
12
+ expect(resolveArtifactDisplayMode(undefined, markdown)).toBe("panel");
7
13
  });
8
14
 
9
15
  it("returns 'panel' when display is unset", () => {
10
- expect(resolveArtifactDisplayMode({ enabled: true }, "markdown")).toBe("panel");
16
+ expect(resolveArtifactDisplayMode({ enabled: true }, markdown)).toBe("panel");
17
+ });
18
+
19
+ it("canonicalizes the deprecated 'card' alias to 'collapsed'", () => {
20
+ expect(resolveArtifactDisplayMode({ display: "card" }, markdown)).toBe(
21
+ "collapsed"
22
+ );
23
+ expect(
24
+ resolveArtifactDisplayMode(
25
+ { display: { files: "card" } },
26
+ {
27
+ artifactType: "markdown",
28
+ file: { path: "a.txt", mimeType: "text/plain" },
29
+ }
30
+ )
31
+ ).toBe("collapsed");
32
+ expect(
33
+ resolveArtifactDisplayMode(
34
+ { display: { byKind: { component: "card" } } },
35
+ component
36
+ )
37
+ ).toBe("collapsed");
11
38
  });
12
39
 
13
40
  it("returns the string form directly", () => {
14
- expect(resolveArtifactDisplayMode({ display: "inline" }, "markdown")).toBe("inline");
15
- expect(resolveArtifactDisplayMode({ display: "card" }, "component")).toBe("card");
41
+ expect(resolveArtifactDisplayMode({ display: "inline" }, markdown)).toBe("inline");
42
+ expect(resolveArtifactDisplayMode({ display: "collapsed" }, component)).toBe("collapsed");
16
43
  });
17
44
 
18
- it("uses the object default when no byType entry matches", () => {
19
- expect(resolveArtifactDisplayMode({ display: { default: "card" } }, "markdown")).toBe("card");
45
+ it("uses the object default when no byKind entry matches", () => {
46
+ expect(resolveArtifactDisplayMode({ display: { default: "collapsed" } }, markdown)).toBe("collapsed");
20
47
  });
21
48
 
22
- it("prefers a byType override over the default", () => {
49
+ it("prefers a byKind override over the default", () => {
23
50
  const feature = {
24
- display: { default: "panel" as const, byType: { markdown: "inline" as const } }
51
+ display: { default: "panel" as const, byKind: { markdown: "inline" as const } }
25
52
  };
26
- expect(resolveArtifactDisplayMode(feature, "markdown")).toBe("inline");
53
+ expect(resolveArtifactDisplayMode(feature, markdown)).toBe("inline");
27
54
  });
28
55
 
29
- it("falls back to the default on a byType miss", () => {
56
+ it("falls back to the default on a byKind miss", () => {
30
57
  const feature = {
31
- display: { default: "card" as const, byType: { markdown: "inline" as const } }
58
+ display: { default: "collapsed" as const, byKind: { markdown: "inline" as const } }
32
59
  };
33
- expect(resolveArtifactDisplayMode(feature, "component")).toBe("card");
60
+ expect(resolveArtifactDisplayMode(feature, component)).toBe("collapsed");
34
61
  });
35
62
 
36
- it("returns 'panel' for an object with neither default nor a matching byType", () => {
37
- expect(resolveArtifactDisplayMode({ display: {} }, "markdown")).toBe("panel");
63
+ it("returns 'panel' for an object with neither default nor a matching byKind", () => {
64
+ expect(resolveArtifactDisplayMode({ display: {} }, markdown)).toBe("panel");
38
65
  expect(
39
- resolveArtifactDisplayMode({ display: { byType: { component: "inline" } } }, "markdown")
66
+ resolveArtifactDisplayMode({ display: { byKind: { component: "inline" } } }, markdown)
40
67
  ).toBe("panel");
41
68
  });
69
+
70
+ it("supports deprecated byType but lets canonical byKind win", () => {
71
+ const feature = {
72
+ display: {
73
+ byType: { component: "collapsed" as const },
74
+ byKind: { component: "inline" as const },
75
+ },
76
+ };
77
+ expect(resolveArtifactDisplayMode(feature, component)).toBe("inline");
78
+ });
79
+
80
+ it("resolves exact MIME, wildcard MIME, files default, kind, and default precedence", () => {
81
+ const feature = {
82
+ display: {
83
+ default: "collapsed" as const,
84
+ byKind: { component: "panel" as const },
85
+ files: {
86
+ default: "collapsed" as const,
87
+ byMediaType: {
88
+ "text/html": "inline" as const,
89
+ "image/*": "panel" as const,
90
+ },
91
+ },
92
+ },
93
+ };
94
+ expect(
95
+ resolveArtifactDisplayMode(feature, {
96
+ artifactType: "markdown",
97
+ file: { path: "a.html", mimeType: "Text/HTML; charset=utf-8" },
98
+ })
99
+ ).toBe("inline");
100
+ expect(
101
+ resolveArtifactDisplayMode(feature, {
102
+ artifactType: "markdown",
103
+ file: { path: "chart.png", mimeType: "image/png" },
104
+ })
105
+ ).toBe("panel");
106
+ expect(
107
+ resolveArtifactDisplayMode(feature, {
108
+ artifactType: "markdown",
109
+ file: { path: "a.txt", mimeType: "text/plain" },
110
+ })
111
+ ).toBe("collapsed");
112
+ expect(resolveArtifactDisplayMode(feature, component)).toBe("panel");
113
+ expect(resolveArtifactDisplayMode(feature, markdown)).toBe("collapsed");
114
+ });
115
+
116
+ it("prefers an exact MIME rule over a wildcard for the same major type", () => {
117
+ const feature = {
118
+ display: {
119
+ files: {
120
+ byMediaType: {
121
+ "image/*": "collapsed" as const,
122
+ "image/svg+xml": "inline" as const,
123
+ },
124
+ },
125
+ },
126
+ };
127
+ expect(
128
+ resolveArtifactDisplayMode(feature, {
129
+ artifactType: "markdown",
130
+ file: { path: "logo.svg", mimeType: "image/svg+xml" },
131
+ })
132
+ ).toBe("inline");
133
+ });
134
+
135
+ it("applies a files string to every file-backed artifact", () => {
136
+ const feature = { display: { files: "inline" as const } };
137
+ expect(
138
+ resolveArtifactDisplayMode(feature, {
139
+ artifactType: "markdown",
140
+ file: { path: "a.csv", mimeType: "text/csv" },
141
+ })
142
+ ).toBe("inline");
143
+ expect(resolveArtifactDisplayMode(feature, markdown)).toBe("panel");
144
+ });
145
+
146
+ it("honors the producer preferredMode hint over defaults but not explicit rules", () => {
147
+ const hinted = {
148
+ artifactType: "markdown" as const,
149
+ presentation: { preferredMode: "inline" as const },
150
+ };
151
+ expect(resolveArtifactDisplayMode(undefined, hinted)).toBe("inline");
152
+ expect(resolveArtifactDisplayMode({ display: "collapsed" }, hinted)).toBe(
153
+ "inline"
154
+ );
155
+ expect(
156
+ resolveArtifactDisplayMode({ display: { default: "collapsed" } }, hinted)
157
+ ).toBe("inline");
158
+ expect(
159
+ resolveArtifactDisplayMode(
160
+ { display: { byKind: { markdown: "panel" } } },
161
+ hinted
162
+ )
163
+ ).toBe("panel");
164
+ expect(
165
+ resolveArtifactDisplayMode(
166
+ { display: { files: "panel" } },
167
+ { ...hinted, file: { path: "a.txt", mimeType: "text/plain" } }
168
+ )
169
+ ).toBe("panel");
170
+ });
171
+
172
+ it("returns the winning rule and configured selector for explainable host UI", () => {
173
+ expect(
174
+ resolveArtifactDisplay(
175
+ {
176
+ display: {
177
+ files: {
178
+ default: "panel",
179
+ byMediaType: { "text/html": "inline" },
180
+ },
181
+ },
182
+ },
183
+ {
184
+ artifactType: "markdown",
185
+ file: { path: "a.html", mimeType: "Text/HTML; charset=utf-8" },
186
+ }
187
+ )
188
+ ).toEqual({
189
+ mode: "inline",
190
+ matchedBy: {
191
+ type: "mediaType",
192
+ mediaType: "text/html",
193
+ selector: "text/html",
194
+ },
195
+ });
196
+ expect(
197
+ resolveArtifactDisplay(
198
+ { display: { files: { byMediaType: { "image/*": "inline" } } } },
199
+ {
200
+ artifactType: "markdown",
201
+ file: { path: "a.png", mimeType: "image/png" },
202
+ }
203
+ ).matchedBy
204
+ ).toEqual({ type: "mediaType", mediaType: "image/png", selector: "image/*" });
205
+ });
42
206
  });
@@ -1,27 +1,166 @@
1
1
  import type {
2
2
  AgentWidgetArtifactsFeature,
3
3
  PersonaArtifactDisplayMode,
4
- PersonaArtifactFileMeta
4
+ PersonaArtifactDisplayModeInput,
5
+ PersonaArtifactFileMeta,
6
+ PersonaArtifactKind,
7
+ PersonaArtifactPresentation,
8
+ PersonaArtifactRecord,
5
9
  } from "../types";
6
10
 
11
+ /** Canonicalize the deprecated `"card"` alias to `"collapsed"`. */
12
+ export function canonicalArtifactDisplayMode(
13
+ mode: PersonaArtifactDisplayModeInput
14
+ ): PersonaArtifactDisplayMode {
15
+ return mode === "card" ? "collapsed" : mode;
16
+ }
17
+
18
+ export type PersonaArtifactDisplayDescriptor = Pick<
19
+ PersonaArtifactRecord,
20
+ "artifactType" | "file" | "presentation"
21
+ >;
22
+
23
+ export type PersonaArtifactDisplayMatch =
24
+ /** `selector` is the configured `byMediaType` key ("text/html" or "image/*"). */
25
+ | { type: "mediaType"; mediaType: string; selector: string }
26
+ | { type: "files" }
27
+ | {
28
+ type: "kind";
29
+ kind: PersonaArtifactKind;
30
+ configuredBy: "byKind" | "byType";
31
+ }
32
+ | { type: "preferredMode" }
33
+ | { type: "default" }
34
+ | { type: "personaDefault" };
35
+
36
+ export type PersonaArtifactDisplayResolution = {
37
+ mode: PersonaArtifactDisplayMode;
38
+ matchedBy: PersonaArtifactDisplayMatch;
39
+ };
40
+
41
+ export function normalizeMediaType(mediaType: string): string {
42
+ return mediaType.split(";", 1)[0].trim().toLowerCase();
43
+ }
44
+
45
+ function matchMediaType(
46
+ byMediaType: Record<string, PersonaArtifactDisplayModeInput> | undefined,
47
+ mediaType: string
48
+ ): { mode: PersonaArtifactDisplayMode; selector: string } | undefined {
49
+ if (!byMediaType) return undefined;
50
+ const entries = Object.entries(byMediaType);
51
+ for (const [candidate, mode] of entries) {
52
+ if (normalizeMediaType(candidate) === mediaType) {
53
+ return {
54
+ mode: canonicalArtifactDisplayMode(mode),
55
+ selector: normalizeMediaType(candidate),
56
+ };
57
+ }
58
+ }
59
+ const wildcard = `${mediaType.split("/", 1)[0]}/*`;
60
+ for (const [candidate, mode] of entries) {
61
+ if (normalizeMediaType(candidate) === wildcard) {
62
+ return { mode: canonicalArtifactDisplayMode(mode), selector: wildcard };
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
7
68
  /**
8
- * Resolves the display mode for an artifact of the given type from
9
- * `features.artifacts.display`.
69
+ * Resolve an artifact's display mode together with the rule that selected it.
70
+ * Always returns a canonical mode ("card" inputs resolve as "collapsed").
10
71
  *
11
- * Resolution order: `display.byType[artifactType]`, else `display.default`,
12
- * else `display` (string form), else `"panel"` (current behavior).
72
+ * Resolution order: file rules (exact MIME, then `major/*` wildcard, then the
73
+ * files default), kind, the producer's `presentation.preferredMode` hint,
74
+ * configured default, then `"panel"`.
75
+ */
76
+ export function resolveArtifactDisplay(
77
+ feature: AgentWidgetArtifactsFeature | undefined,
78
+ artifact: PersonaArtifactDisplayDescriptor
79
+ ): PersonaArtifactDisplayResolution {
80
+ const display = feature?.display;
81
+ const rules =
82
+ typeof display === "string" ? { default: display } : display ?? {};
83
+
84
+ if (artifact.file && rules.files) {
85
+ if (typeof rules.files === "string") {
86
+ return {
87
+ mode: canonicalArtifactDisplayMode(rules.files),
88
+ matchedBy: { type: "files" },
89
+ };
90
+ }
91
+ const mediaType = artifact.file.mimeType
92
+ ? normalizeMediaType(artifact.file.mimeType)
93
+ : undefined;
94
+ const byMediaType = mediaType
95
+ ? matchMediaType(rules.files.byMediaType, mediaType)
96
+ : undefined;
97
+ if (mediaType && byMediaType) {
98
+ return {
99
+ mode: byMediaType.mode,
100
+ matchedBy: {
101
+ type: "mediaType",
102
+ mediaType,
103
+ selector: byMediaType.selector,
104
+ },
105
+ };
106
+ }
107
+ if (rules.files.default) {
108
+ return {
109
+ mode: canonicalArtifactDisplayMode(rules.files.default),
110
+ matchedBy: { type: "files" },
111
+ };
112
+ }
113
+ }
114
+
115
+ const byKind = rules.byKind?.[artifact.artifactType];
116
+ if (byKind) {
117
+ return {
118
+ mode: canonicalArtifactDisplayMode(byKind),
119
+ matchedBy: {
120
+ type: "kind",
121
+ kind: artifact.artifactType,
122
+ configuredBy: "byKind",
123
+ },
124
+ };
125
+ }
126
+ const byType = rules.byType?.[artifact.artifactType];
127
+ if (byType) {
128
+ return {
129
+ mode: canonicalArtifactDisplayMode(byType),
130
+ matchedBy: {
131
+ type: "kind",
132
+ kind: artifact.artifactType,
133
+ configuredBy: "byType",
134
+ },
135
+ };
136
+ }
137
+
138
+ const preferred = artifact.presentation?.preferredMode;
139
+ if (preferred) {
140
+ return {
141
+ mode: canonicalArtifactDisplayMode(preferred),
142
+ matchedBy: { type: "preferredMode" },
143
+ };
144
+ }
145
+
146
+ if (rules.default) {
147
+ return {
148
+ mode: canonicalArtifactDisplayMode(rules.default),
149
+ matchedBy: { type: "default" },
150
+ };
151
+ }
152
+ return { mode: "panel", matchedBy: { type: "personaDefault" } };
153
+ }
154
+
155
+ /**
156
+ * Resolve only the mode. Use `resolveArtifactDisplay` when a host needs to
157
+ * explain which rule selected it.
13
158
  */
14
159
  export function resolveArtifactDisplayMode(
15
160
  feature: AgentWidgetArtifactsFeature | undefined,
16
- artifactType: string
161
+ artifact: PersonaArtifactDisplayDescriptor
17
162
  ): PersonaArtifactDisplayMode {
18
- const display = feature?.display;
19
- if (!display) return "panel";
20
- if (typeof display === "string") return display;
21
- const byType = display.byType as
22
- | Partial<Record<string, PersonaArtifactDisplayMode>>
23
- | undefined;
24
- return byType?.[artifactType] ?? display.default ?? "panel";
163
+ return resolveArtifactDisplay(feature, artifact).mode;
25
164
  }
26
165
 
27
166
  /** Props embedded in an in-thread artifact block's rawContent JSON. */
@@ -32,6 +171,7 @@ export type ArtifactRefBlockProps = {
32
171
  status: "streaming" | "complete";
33
172
  /** File metadata for previewable file artifacts (persists the download path). */
34
173
  file?: PersonaArtifactFileMeta;
174
+ presentation?: PersonaArtifactPresentation;
35
175
  /**
36
176
  * Component name for component-type artifacts. Only embedded for the
37
177
  * "inline" display mode: the inline block renders component artifacts
@@ -54,7 +194,7 @@ export type ArtifactRefBlockProps = {
54
194
  * Builds the `rawContent` JSON for the synthetic assistant message that
55
195
  * anchors an artifact in the transcript (message id `artifact-ref-<id>`).
56
196
  *
57
- * "card"/"panel" modes render the compact `PersonaArtifactCard`; "inline"
197
+ * "collapsed"/"panel" modes render the compact `PersonaArtifactCard`; "inline"
58
198
  * renders the `PersonaArtifactInline` preview block. Both share this shape so
59
199
  * transcript persistence and hydration work unchanged. Used by the streaming
60
200
  * client (`artifact_start`) and the programmatic `session.upsertArtifact()`.
@@ -72,6 +212,7 @@ export function buildArtifactRefRawContent(
72
212
  artifactType: props.artifactType,
73
213
  status: props.status,
74
214
  ...(props.file ? { file: props.file } : {}),
215
+ ...(props.presentation ? { presentation: props.presentation } : {}),
75
216
  ...(displayMode === "inline" && props.component
76
217
  ? { component: props.component }
77
218
  : {}),
@@ -10,7 +10,7 @@ import {
10
10
  // Canonical cross-repo fixture. The zero-width space is written as a
11
11
  // backslash-u200b escape sequence (not a literal invisible char) so the bytes
12
12
  // are unambiguous in source.
13
- // Mirrors core/packages/shared/src/file-artifact.ts escapeFenceTerminators fixture — keep byte-identical.
13
+ // Mirrors core/packages/shared/src/file-artifact.ts escapeFenceTerminators fixture — keep in sync.
14
14
  const ZWSP = "\u200b";
15
15
  const FIXTURE_RAW = "# Title\n\n```js\nconsole.log('hi')\n```\n";
16
16
  const FIXTURE_ESCAPED =
@@ -563,6 +563,46 @@ describe("Full Config with Hooks Integration", () => {
563
563
  // Backward Compatibility Tests
564
564
  // =============================================================================
565
565
 
566
+ describe("Suggestions Serialization", () => {
567
+ it("preserves structured suggestion configuration in every format", () => {
568
+ const config = {
569
+ ...minimalConfig,
570
+ suggestions: {
571
+ starters: {
572
+ variant: "card",
573
+ behavior: "fill",
574
+ items: [
575
+ {
576
+ id: "compare",
577
+ label: "Compare plans",
578
+ prompt: "Compare plans for my team",
579
+ },
580
+ ],
581
+ },
582
+ followUps: {
583
+ placement: "after-message",
584
+ overflow: "scroll",
585
+ },
586
+ },
587
+ };
588
+ const formats: CodeFormat[] = [
589
+ "esm",
590
+ "react-component",
591
+ "react-advanced",
592
+ "script-installer",
593
+ "script-manual",
594
+ "script-advanced",
595
+ ];
596
+
597
+ for (const format of formats) {
598
+ const code = generateCodeSnippet(config, format);
599
+ expect(code).toContain("suggestions");
600
+ expect(code).toContain("Compare plans");
601
+ expect(code).toContain("after-message");
602
+ }
603
+ });
604
+ });
605
+
566
606
  describe("Backward Compatibility", () => {
567
607
  it("should work without options parameter", () => {
568
608
  const code = generateCodeSnippet(minimalConfig, "esm");
@@ -430,6 +430,19 @@ function generateMarkdownConfig(config: any, indent: string): string[] {
430
430
  return lines;
431
431
  }
432
432
 
433
+ // Suggestions may contain nested rich items. JSON object syntax is also valid
434
+ // JavaScript/TypeScript, so preserve the complete public shape instead of
435
+ // duplicating every leaf in each output-format generator.
436
+ function generateSuggestionsConfig(config: any, indent: string): string[] {
437
+ if (!config.suggestions) return [];
438
+ const serialized = JSON.stringify(config.suggestions, null, 2).split("\n");
439
+ return serialized.map((line, index) => {
440
+ const prefix = index === 0 ? `${indent}suggestions: ` : indent;
441
+ const suffix = index === serialized.length - 1 ? "," : "";
442
+ return `${prefix}${line}${suffix}`;
443
+ });
444
+ }
445
+
433
446
  // Helper to generate layout config (excluding render functions and slots)
434
447
  function generateLayoutConfig(config: any, indent: string): string[] {
435
448
  const lines: string[] = [];
@@ -697,6 +710,8 @@ function generateESMCode(config: any, options?: CodeGeneratorOptions): string {
697
710
  lines.push(" },");
698
711
  }
699
712
 
713
+ lines.push(...generateSuggestionsConfig(config, " "));
714
+
700
715
  if (config.suggestionChips && config.suggestionChips.length > 0) {
701
716
  lines.push(" suggestionChips: [");
702
717
  config.suggestionChips.forEach((chip: string) => {
@@ -845,6 +860,8 @@ function generateReactComponentCode(config: any, options?: CodeGeneratorOptions)
845
860
  lines.push(" },");
846
861
  }
847
862
 
863
+ lines.push(...generateSuggestionsConfig(config, " "));
864
+
848
865
  if (config.suggestionChips && config.suggestionChips.length > 0) {
849
866
  lines.push(" suggestionChips: [");
850
867
  config.suggestionChips.forEach((chip: string) => {
@@ -1110,6 +1127,8 @@ function generateReactAdvancedCode(config: any, options?: CodeGeneratorOptions):
1110
1127
  lines.push(" },");
1111
1128
  }
1112
1129
 
1130
+ lines.push(...generateSuggestionsConfig(config, " "));
1131
+
1113
1132
  if (config.suggestionChips && config.suggestionChips.length > 0) {
1114
1133
  lines.push(" suggestionChips: [");
1115
1134
  config.suggestionChips.forEach((chip: string) => {
@@ -1312,6 +1331,7 @@ function buildSerializableConfig(config: any): Record<string, any> {
1312
1331
  if (config.voiceRecognition) serializableConfig.voiceRecognition = config.voiceRecognition;
1313
1332
  if (config.statusIndicator) serializableConfig.statusIndicator = config.statusIndicator;
1314
1333
  if (config.features) serializableConfig.features = config.features;
1334
+ if (config.suggestions) serializableConfig.suggestions = config.suggestions;
1315
1335
  if (config.suggestionChips?.length > 0) serializableConfig.suggestionChips = config.suggestionChips;
1316
1336
  if (config.suggestionChipsConfig) serializableConfig.suggestionChipsConfig = config.suggestionChipsConfig;
1317
1337
  if (config.debug) serializableConfig.debug = config.debug;
@@ -1520,6 +1540,8 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
1520
1540
  lines.push(" },");
1521
1541
  }
1522
1542
 
1543
+ lines.push(...generateSuggestionsConfig(config, " "));
1544
+
1523
1545
  if (config.suggestionChips && config.suggestionChips.length > 0) {
1524
1546
  lines.push(" suggestionChips: [");
1525
1547
  config.suggestionChips.forEach((chip: string) => {
@@ -32,6 +32,30 @@ describe("mergeConfigUpdate", () => {
32
32
  expect(next.suggestionChips).toEqual(["x"]);
33
33
  });
34
34
 
35
+ it("patches suggestion surfaces without dropping sibling behavior", () => {
36
+ const prev = base({
37
+ suggestions: {
38
+ starters: { variant: "card", behavior: "fill" },
39
+ followUps: { variant: "chip", placement: "after-message" },
40
+ },
41
+ });
42
+ const next = mergeConfigUpdate(prev, {
43
+ suggestions: {
44
+ followUps: { overflow: "wrap" },
45
+ },
46
+ });
47
+
48
+ expect(next.suggestions?.starters).toEqual({
49
+ variant: "card",
50
+ behavior: "fill",
51
+ });
52
+ expect(next.suggestions?.followUps).toEqual({
53
+ variant: "chip",
54
+ placement: "after-message",
55
+ overflow: "wrap",
56
+ });
57
+ });
58
+
35
59
  it("replaces callbacks wholesale", () => {
36
60
  const first = vi.fn();
37
61
  const second = vi.fn();
@@ -66,6 +90,66 @@ describe("mergeConfigUpdate", () => {
66
90
  expect(next.features?.streamAnimation?.plugins?.p1).toBeUndefined();
67
91
  });
68
92
 
93
+ it("replaces artifact display scope maps so reset preferences do not strand stale keys", () => {
94
+ const previous = mergeWithDefaults({
95
+ features: {
96
+ artifacts: {
97
+ enabled: true,
98
+ display: {
99
+ default: "card",
100
+ files: {
101
+ byMediaType: {
102
+ "text/html": "inline",
103
+ "text/csv": "inline",
104
+ },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ });
110
+
111
+ const next = mergeConfigUpdate(previous, {
112
+ features: {
113
+ artifacts: {
114
+ display: {
115
+ default: "card",
116
+ files: {
117
+ byMediaType: {
118
+ "text/csv": "panel",
119
+ },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ });
125
+
126
+ expect(next.features?.artifacts?.display).toEqual({
127
+ default: "card",
128
+ files: {
129
+ byMediaType: {
130
+ "text/csv": "panel",
131
+ },
132
+ },
133
+ });
134
+ });
135
+
136
+ it("replaces the preferences slice wholesale so dropped overrides do not strand", () => {
137
+ const prev = base({
138
+ preferences: {
139
+ showToolCalls: false,
140
+ artifacts: { display: "inline", filePreview: { enabled: false } },
141
+ },
142
+ });
143
+ // The host's new slice dropped showToolCalls and filePreview: both must go.
144
+ const next = mergeConfigUpdate(prev, {
145
+ preferences: { artifacts: { display: "inline" } },
146
+ });
147
+ expect(next.preferences).toEqual({ artifacts: { display: "inline" } });
148
+ // Explicit-undefined still clears the whole slice.
149
+ const cleared = mergeConfigUpdate(next, { preferences: undefined });
150
+ expect(cleared.preferences).toBeUndefined();
151
+ });
152
+
69
153
  it("replaces storageAdapter wholesale (no hybrid: new adapter's absent save is not inherited)", () => {
70
154
  const oldSave = vi.fn();
71
155
  const oldLoad = vi.fn();
@@ -9,9 +9,12 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
9
9
 
10
10
  // Dotted paths whose plain-object value replaces wholesale instead of recursing.
11
11
  // - user-defined key maps: recursive merge would strand stale keys from the old value
12
- // (headers, targetProviders, components, features.streamAnimation.plugins).
12
+ // (headers, targetProviders, components, artifact display scopes,
13
+ // features.streamAnimation.plugins).
13
14
  // - implementation objects: partial merge splices two impls into a broken hybrid
14
15
  // (agent, storageAdapter, voiceRecognition.provider.custom).
16
+ // - preferences: the sparse slice a host passes IS its full opinion; recursive
17
+ // merge would keep overrides the host dropped from its slice.
15
18
  const REPLACE_LEAF_PATHS = new Set<string>([
16
19
  "headers",
17
20
  "agent",
@@ -19,7 +22,9 @@ const REPLACE_LEAF_PATHS = new Set<string>([
19
22
  "components",
20
23
  "targetProviders",
21
24
  "voiceRecognition.provider.custom",
25
+ "features.artifacts.display",
22
26
  "features.streamAnimation.plugins",
27
+ "preferences",
23
28
  ]);
24
29
 
25
30
  // Recursive patch merge. A key merges recursively iff both previous and patch