@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.
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +98 -6
- package/dist/context-mentions.d.ts +98 -6
- package/dist/index.cjs +64 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -8
- package/dist/index.d.ts +183 -8
- package/dist/index.global.js +54 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -67
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +98 -6
- package/dist/smart-dom-reader.d.ts +98 -6
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +100 -6
- package/dist/theme-editor-preview.d.ts +100 -6
- package/dist/theme-editor-preview.js +75 -75
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +134 -7
- package/dist/theme-editor.d.ts +134 -7
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/artifacts-session.test.ts +106 -0
- package/src/client.test.ts +2 -2
- package/src/client.ts +4 -1
- package/src/components/artifact-card.ts +13 -2
- package/src/components/artifact-inline.ts +11 -2
- package/src/components/composer-builder.test.ts +2 -0
- package/src/components/composer-builder.ts +1 -1
- package/src/components/composer-parts.test.ts +2 -0
- package/src/components/composer-parts.ts +1 -1
- package/src/components/message-bubble.test.ts +7 -1
- package/src/components/message-bubble.ts +11 -8
- package/src/index-core.ts +34 -0
- package/src/session.ts +45 -4
- package/src/styles/widget.css +10 -1
- package/src/theme-editor/index.ts +5 -0
- package/src/theme-editor/presets.test.ts +43 -0
- package/src/theme-editor/presets.ts +101 -87
- package/src/theme-editor/sections.test.ts +39 -7
- package/src/theme-editor/sections.ts +60 -8
- package/src/theme-editor/state-preferences.test.ts +15 -0
- package/src/theme-editor/state.ts +46 -0
- package/src/theme-editor/types.ts +15 -1
- package/src/theme-editor/webmcp/tools.test.ts +13 -0
- package/src/theme-editor/webmcp/tools.ts +5 -0
- package/src/types.ts +139 -9
- package/src/ui.artifact-pane-gating.test.ts +5 -5
- package/src/ui.config-preferences.test.ts +166 -0
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +158 -53
- package/src/ui.typing-indicator.test.ts +135 -0
- package/src/utils/artifact-display.test.ts +180 -16
- package/src/utils/artifact-display.ts +155 -14
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
- package/src/utils/config-merge.test.ts +60 -0
- package/src/utils/config-merge.ts +6 -1
- package/src/utils/feature-preferences.test.ts +326 -0
- package/src/utils/feature-preferences.ts +498 -0
- package/src/utils/theme.test.ts +92 -0
- package/src/utils/tokens.ts +7 -7
|
@@ -1,42 +1,206 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
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,
|
|
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 },
|
|
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" },
|
|
15
|
-
expect(resolveArtifactDisplayMode({ display: "
|
|
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
|
|
19
|
-
expect(resolveArtifactDisplayMode({ display: { default: "
|
|
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
|
|
49
|
+
it("prefers a byKind override over the default", () => {
|
|
23
50
|
const feature = {
|
|
24
|
-
display: { default: "panel" as const,
|
|
51
|
+
display: { default: "panel" as const, byKind: { markdown: "inline" as const } }
|
|
25
52
|
};
|
|
26
|
-
expect(resolveArtifactDisplayMode(feature,
|
|
53
|
+
expect(resolveArtifactDisplayMode(feature, markdown)).toBe("inline");
|
|
27
54
|
});
|
|
28
55
|
|
|
29
|
-
it("falls back to the default on a
|
|
56
|
+
it("falls back to the default on a byKind miss", () => {
|
|
30
57
|
const feature = {
|
|
31
|
-
display: { default: "
|
|
58
|
+
display: { default: "collapsed" as const, byKind: { markdown: "inline" as const } }
|
|
32
59
|
};
|
|
33
|
-
expect(resolveArtifactDisplayMode(feature,
|
|
60
|
+
expect(resolveArtifactDisplayMode(feature, component)).toBe("collapsed");
|
|
34
61
|
});
|
|
35
62
|
|
|
36
|
-
it("returns 'panel' for an object with neither default nor a matching
|
|
37
|
-
expect(resolveArtifactDisplayMode({ display: {} },
|
|
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: {
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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: `
|
|
12
|
-
*
|
|
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
|
-
|
|
161
|
+
artifact: PersonaArtifactDisplayDescriptor
|
|
17
162
|
): PersonaArtifactDisplayMode {
|
|
18
|
-
|
|
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
|
-
* "
|
|
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
|
+
});
|
package/src/utils/auto-follow.ts
CHANGED
|
@@ -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,
|
|
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
|