@runtypelabs/persona 4.13.0 → 4.14.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 (64) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +98 -6
  4. package/dist/context-mentions.d.ts +98 -6
  5. package/dist/index.cjs +64 -64
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +183 -8
  8. package/dist/index.d.ts +183 -8
  9. package/dist/index.global.js +54 -54
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +67 -67
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +1 -1
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +98 -6
  16. package/dist/smart-dom-reader.d.ts +98 -6
  17. package/dist/theme-editor-preview.cjs +66 -66
  18. package/dist/theme-editor-preview.d.cts +100 -6
  19. package/dist/theme-editor-preview.d.ts +100 -6
  20. package/dist/theme-editor-preview.js +75 -75
  21. package/dist/theme-editor.cjs +10 -10
  22. package/dist/theme-editor.d.cts +134 -7
  23. package/dist/theme-editor.d.ts +134 -7
  24. package/dist/theme-editor.js +10 -10
  25. package/dist/theme-reference.cjs +1 -1
  26. package/dist/widget.css +1 -1
  27. package/package.json +1 -1
  28. package/src/artifacts-session.test.ts +106 -0
  29. package/src/client.test.ts +2 -2
  30. package/src/client.ts +4 -1
  31. package/src/components/artifact-card.ts +13 -2
  32. package/src/components/artifact-inline.ts +11 -2
  33. package/src/components/composer-builder.test.ts +2 -0
  34. package/src/components/composer-builder.ts +1 -1
  35. package/src/components/composer-parts.test.ts +2 -0
  36. package/src/components/composer-parts.ts +1 -1
  37. package/src/components/message-bubble.test.ts +7 -1
  38. package/src/components/message-bubble.ts +11 -8
  39. package/src/index-core.ts +34 -0
  40. package/src/session.ts +45 -4
  41. package/src/styles/widget.css +10 -1
  42. package/src/theme-editor/index.ts +5 -0
  43. package/src/theme-editor/presets.test.ts +43 -0
  44. package/src/theme-editor/presets.ts +101 -87
  45. package/src/theme-editor/sections.test.ts +39 -7
  46. package/src/theme-editor/sections.ts +60 -8
  47. package/src/theme-editor/state-preferences.test.ts +15 -0
  48. package/src/theme-editor/state.ts +46 -0
  49. package/src/theme-editor/types.ts +15 -1
  50. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  51. package/src/theme-editor/webmcp/tools.ts +5 -0
  52. package/src/types.ts +139 -9
  53. package/src/ui.artifact-pane-gating.test.ts +5 -5
  54. package/src/ui.config-preferences.test.ts +166 -0
  55. package/src/ui.ts +43 -25
  56. package/src/ui.typing-indicator.test.ts +135 -0
  57. package/src/utils/artifact-display.test.ts +180 -16
  58. package/src/utils/artifact-display.ts +155 -14
  59. package/src/utils/config-merge.test.ts +60 -0
  60. package/src/utils/config-merge.ts +6 -1
  61. package/src/utils/feature-preferences.test.ts +326 -0
  62. package/src/utils/feature-preferences.ts +498 -0
  63. package/src/utils/theme.test.ts +92 -0
  64. package/src/utils/tokens.ts +7 -7
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ const createMount = () => {
8
+ const mount = document.createElement("div");
9
+ document.body.appendChild(mount);
10
+ return mount;
11
+ };
12
+
13
+ /**
14
+ * Mount a widget with a never-resolving fetch, send a message, and return the
15
+ * standalone typing indicator bubble rendered during the silent gap.
16
+ */
17
+ const renderTypingBubble = async (
18
+ config: Record<string, unknown>
19
+ ): Promise<{ bubble: HTMLElement; destroy: () => void }> => {
20
+ global.fetch = vi.fn().mockImplementation(
21
+ () => new Promise(() => {})
22
+ ) as unknown as typeof fetch;
23
+
24
+ const mount = createMount();
25
+ const controller = createAgentExperience(mount, {
26
+ apiUrl: "https://api.example.com/chat",
27
+ launcher: { enabled: false },
28
+ ...config,
29
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
30
+
31
+ controller.submitMessage("hello");
32
+ await Promise.resolve();
33
+ await Promise.resolve();
34
+
35
+ const bubble = mount.querySelector<HTMLElement>('[data-typing-indicator="true"]')!;
36
+ return { bubble, destroy: () => controller.destroy() };
37
+ };
38
+
39
+ describe("standalone typing indicator bubble", () => {
40
+ afterEach(() => {
41
+ document.body.innerHTML = "";
42
+ if (typeof localStorage !== "undefined") localStorage.clear();
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ it("uses the assistant bubble classes under the default bubble layout", async () => {
47
+ const { bubble, destroy } = await renderTypingBubble({});
48
+
49
+ expect(bubble).not.toBeNull();
50
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
51
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(true);
52
+ expect(bubble.classList.contains("persona-border")).toBe(true);
53
+ expect(bubble.classList.contains("persona-px-5")).toBe(true);
54
+
55
+ destroy();
56
+ });
57
+
58
+ it("drops the shadow and border under the minimal layout", async () => {
59
+ const { bubble, destroy } = await renderTypingBubble({
60
+ layout: { messages: { layout: "minimal" } },
61
+ });
62
+
63
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
64
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
65
+ expect(bubble.classList.contains("persona-border")).toBe(false);
66
+ expect(bubble.classList.contains("persona-px-3")).toBe(true);
67
+ // Minimal keeps the surface background, matching its message bubbles.
68
+ expect(bubble.style.backgroundColor).toContain("--persona-message-assistant-bg");
69
+
70
+ destroy();
71
+ });
72
+
73
+ it("drops the background entirely under the flat layout", async () => {
74
+ const { bubble, destroy } = await renderTypingBubble({
75
+ layout: { messages: { layout: "flat" } },
76
+ });
77
+
78
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
79
+ expect(bubble.classList.contains("persona-border")).toBe(false);
80
+ expect(bubble.style.backgroundColor).toBe("");
81
+ expect(bubble.style.color).toBe(
82
+ "var(--persona-message-assistant-text, var(--persona-text))"
83
+ );
84
+
85
+ destroy();
86
+ });
87
+
88
+ it("renders bare text classes when loadingIndicator.showBubble is false", async () => {
89
+ const { bubble, destroy } = await renderTypingBubble({
90
+ loadingIndicator: { showBubble: false },
91
+ });
92
+
93
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(false);
94
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
95
+ expect(bubble.classList.contains("persona-border")).toBe(false);
96
+ expect(bubble.classList.contains("persona-text-persona-text")).toBe(true);
97
+ expect(bubble.classList.contains("persona-text-persona-primary")).toBe(false);
98
+ expect(bubble.style.backgroundColor).toBe("");
99
+
100
+ destroy();
101
+ });
102
+
103
+ it("honors the assistant bubble theme background token", async () => {
104
+ const { bubble, destroy } = await renderTypingBubble({
105
+ layout: { messages: { layout: "minimal" } },
106
+ theme: {
107
+ semantic: {
108
+ colors: {
109
+ container: "#112233",
110
+ text: "#ddeeff",
111
+ border: "#445566",
112
+ },
113
+ },
114
+ },
115
+ });
116
+
117
+ // The bubble reads the same variable the assistant message bubbles read,
118
+ // so `theme.components.message.assistant.background` covers both.
119
+ expect(bubble.style.backgroundColor).toBe(
120
+ "var(--persona-message-assistant-bg, var(--persona-container))"
121
+ );
122
+ const root = bubble.closest<HTMLElement>("[data-persona-root]");
123
+ expect(root?.style.getPropertyValue("--persona-message-assistant-bg")).toBe(
124
+ "#112233"
125
+ );
126
+ expect(root?.style.getPropertyValue("--persona-message-assistant-text")).toBe(
127
+ "#ddeeff"
128
+ );
129
+ expect(root?.style.getPropertyValue("--persona-message-assistant-border")).toBe(
130
+ "#445566"
131
+ );
132
+
133
+ destroy();
134
+ });
135
+ });
@@ -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
  : {}),
@@ -66,6 +66,66 @@ describe("mergeConfigUpdate", () => {
66
66
  expect(next.features?.streamAnimation?.plugins?.p1).toBeUndefined();
67
67
  });
68
68
 
69
+ it("replaces artifact display scope maps so reset preferences do not strand stale keys", () => {
70
+ const previous = mergeWithDefaults({
71
+ features: {
72
+ artifacts: {
73
+ enabled: true,
74
+ display: {
75
+ default: "card",
76
+ files: {
77
+ byMediaType: {
78
+ "text/html": "inline",
79
+ "text/csv": "inline",
80
+ },
81
+ },
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ const next = mergeConfigUpdate(previous, {
88
+ features: {
89
+ artifacts: {
90
+ display: {
91
+ default: "card",
92
+ files: {
93
+ byMediaType: {
94
+ "text/csv": "panel",
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ });
101
+
102
+ expect(next.features?.artifacts?.display).toEqual({
103
+ default: "card",
104
+ files: {
105
+ byMediaType: {
106
+ "text/csv": "panel",
107
+ },
108
+ },
109
+ });
110
+ });
111
+
112
+ it("replaces the preferences slice wholesale so dropped overrides do not strand", () => {
113
+ const prev = base({
114
+ preferences: {
115
+ showToolCalls: false,
116
+ artifacts: { display: "inline", filePreview: { enabled: false } },
117
+ },
118
+ });
119
+ // The host's new slice dropped showToolCalls and filePreview: both must go.
120
+ const next = mergeConfigUpdate(prev, {
121
+ preferences: { artifacts: { display: "inline" } },
122
+ });
123
+ expect(next.preferences).toEqual({ artifacts: { display: "inline" } });
124
+ // Explicit-undefined still clears the whole slice.
125
+ const cleared = mergeConfigUpdate(next, { preferences: undefined });
126
+ expect(cleared.preferences).toBeUndefined();
127
+ });
128
+
69
129
  it("replaces storageAdapter wholesale (no hybrid: new adapter's absent save is not inherited)", () => {
70
130
  const oldSave = vi.fn();
71
131
  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