@runtypelabs/persona 4.12.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 (68) 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.scroll-additive.test.ts +365 -0
  56. package/src/ui.scroll.test.ts +19 -6
  57. package/src/ui.ts +158 -53
  58. package/src/ui.typing-indicator.test.ts +135 -0
  59. package/src/utils/artifact-display.test.ts +180 -16
  60. package/src/utils/artifact-display.ts +155 -14
  61. package/src/utils/auto-follow.test.ts +38 -0
  62. package/src/utils/auto-follow.ts +35 -0
  63. package/src/utils/config-merge.test.ts +60 -0
  64. package/src/utils/config-merge.ts +6 -1
  65. package/src/utils/feature-preferences.test.ts +326 -0
  66. package/src/utils/feature-preferences.ts +498 -0
  67. package/src/utils/theme.test.ts +92 -0
  68. package/src/utils/tokens.ts +7 -7
@@ -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
  : {}),
@@ -6,10 +6,12 @@ import {
6
6
  createFollowStateController,
7
7
  getScrollBottomOffset,
8
8
  hasSelectionWithin,
9
+ isAttentionWorthyMessage,
9
10
  isElementNearBottom,
10
11
  resolveFollowStateFromScroll,
11
12
  resolveFollowStateFromWheel
12
13
  } from "./auto-follow";
14
+ import type { AgentWidgetMessage } from "../types";
13
15
 
14
16
  describe("auto-follow utilities", () => {
15
17
  it("tracks pause and resume state", () => {
@@ -199,3 +201,39 @@ describe("auto-follow utilities", () => {
199
201
  ).toBe("resume");
200
202
  });
201
203
  });
204
+
205
+ describe("isAttentionWorthyMessage", () => {
206
+ const msg = (over: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
207
+ id: "m",
208
+ role: "assistant",
209
+ content: "",
210
+ createdAt: "2026-07-28T00:00:00.000Z",
211
+ ...over,
212
+ });
213
+
214
+ it("treats prose, artifacts/components, images and approvals as output", () => {
215
+ expect(isAttentionWorthyMessage(msg({ content: "Here is the answer." }))).toBe(true);
216
+ // Artifact / component blocks carry their payload in rawContent with an
217
+ // empty `content` — the case a text-only test silently drops.
218
+ expect(
219
+ isAttentionWorthyMessage(msg({ rawContent: '{"component":"Chart","props":{}}' }))
220
+ ).toBe(true);
221
+ expect(
222
+ isAttentionWorthyMessage(
223
+ msg({ contentParts: [{ type: "image", image: "data:image/png;base64,x" }] as never })
224
+ )
225
+ ).toBe(true);
226
+ // Blocked on the reader deciding: must never sit off-screen.
227
+ expect(isAttentionWorthyMessage(msg({ variant: "approval" }))).toBe(true);
228
+ });
229
+
230
+ it("treats tool calls and reasoning as progress chrome", () => {
231
+ expect(isAttentionWorthyMessage(msg({ variant: "tool", content: "searching" }))).toBe(false);
232
+ expect(isAttentionWorthyMessage(msg({ variant: "reasoning", content: "thinking" }))).toBe(false);
233
+ });
234
+
235
+ it("skips the empty streaming placeholder and non-assistant roles", () => {
236
+ expect(isAttentionWorthyMessage(msg({ content: " " }))).toBe(false);
237
+ expect(isAttentionWorthyMessage(msg({ role: "user", content: "hi" }))).toBe(false);
238
+ });
239
+ });
@@ -1,3 +1,38 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ /**
4
+ * Is this message *output a human came for*, as opposed to progress chrome?
5
+ *
6
+ * This is the classifier behind anchor-top's "first unread" positioning: the
7
+ * transcript anchors to the first attention-worthy block of a response and
8
+ * leaves everything else to scroll past underneath. Two categories:
9
+ *
10
+ * - **Chrome** — tool calls and reasoning. Rendered so the reader *can* audit
11
+ * the work, never positioned to. A response that opens with six tool calls
12
+ * should not park the reader on the sixth one.
13
+ * - **Attention-worthy** — anything that carries the answer: prose, an
14
+ * artifact or component block (charts, forms, documents — these carry their
15
+ * payload in `rawContent` with an EMPTY `content`, so testing text alone
16
+ * silently drops them), multi-modal parts (images), and approval requests
17
+ * (which are the strongest case of all: the turn is blocked on the reader
18
+ * deciding, so it must never sit off-screen).
19
+ *
20
+ * The empty streaming placeholder — an assistant bubble created at
21
+ * `text_start` before its first token — is deliberately NOT attention-worthy,
22
+ * so the anchor waits for content rather than jumping to a blank bubble.
23
+ */
24
+ export function isAttentionWorthyMessage(message: AgentWidgetMessage): boolean {
25
+ if (message.role !== "assistant") return false;
26
+ if (message.variant === "tool" || message.variant === "reasoning") return false;
27
+ // Blocked on the reader: attention-worthy even with no text of its own.
28
+ if (message.variant === "approval") return true;
29
+ return Boolean(
30
+ message.content?.trim() ||
31
+ message.rawContent?.trim() ||
32
+ message.contentParts?.length
33
+ );
34
+ }
35
+
1
36
  export type FollowStateAction = "none" | "pause" | "resume";
2
37
 
3
38
  export type FollowStateController = {
@@ -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