@runtypelabs/persona 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
package/src/client.ts
CHANGED
|
@@ -21,10 +21,15 @@ import {
|
|
|
21
21
|
ClientFeedbackRequest,
|
|
22
22
|
ClientFeedbackType,
|
|
23
23
|
PersonaArtifactKind,
|
|
24
|
+
PersonaArtifactFileMeta,
|
|
24
25
|
ContentPart,
|
|
25
26
|
WebMcpConfirmHandler
|
|
26
27
|
} from "./types";
|
|
27
28
|
import { WebMcpBridge, computeClientToolsFingerprint, isWebMcpToolName } from "./webmcp-bridge";
|
|
29
|
+
import {
|
|
30
|
+
buildArtifactRefRawContent,
|
|
31
|
+
resolveArtifactDisplayMode
|
|
32
|
+
} from "./utils/artifact-display";
|
|
28
33
|
import { resolveTarget } from "./utils/target";
|
|
29
34
|
import { builtInClientToolsForDispatch } from "./ask-user-question-tool";
|
|
30
35
|
import {
|
|
@@ -1177,6 +1182,66 @@ export class AgentWidgetClient {
|
|
|
1177
1182
|
});
|
|
1178
1183
|
}
|
|
1179
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* The opt-in structured mention channel: the MOST RECENT user turn's
|
|
1187
|
+
* `mentionContext` (set by `session.applyMentionBundle`) namespaced under
|
|
1188
|
+
* `mentions`. Only the latest user message is consulted — otherwise an older
|
|
1189
|
+
* turn's mentions would re-attach to `context.mentions` on every later send.
|
|
1190
|
+
* Returns null when the latest user turn carried no structured mention context.
|
|
1191
|
+
* The default model-visible path (`llmAppend`) already rode into the message's
|
|
1192
|
+
* `llmContent`/`contentParts`, so it needs nothing here.
|
|
1193
|
+
*/
|
|
1194
|
+
private latestMentionContext(
|
|
1195
|
+
messages: AgentWidgetMessage[]
|
|
1196
|
+
): Record<string, unknown> | null {
|
|
1197
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1198
|
+
const m = messages[i];
|
|
1199
|
+
if (m.role !== "user") continue;
|
|
1200
|
+
// First user message from the end IS the latest user turn: use its
|
|
1201
|
+
// mention context if any, and never look further back.
|
|
1202
|
+
if (m.mentionContext && Object.keys(m.mentionContext).length > 0) {
|
|
1203
|
+
return { mentions: m.mentionContext };
|
|
1204
|
+
}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Aggregate request `context`: merge every context provider's result with the
|
|
1212
|
+
* opt-in mention context from the latest user turn. Returns null when nothing
|
|
1213
|
+
* contributed, so callers can skip setting `payload.context`. Shared by the
|
|
1214
|
+
* agent and flow payload builders.
|
|
1215
|
+
*/
|
|
1216
|
+
private async buildContextAggregate(
|
|
1217
|
+
messages: AgentWidgetMessage[]
|
|
1218
|
+
): Promise<Record<string, unknown> | null> {
|
|
1219
|
+
const contextAggregate: Record<string, unknown> = {};
|
|
1220
|
+
if (this.contextProviders.length) {
|
|
1221
|
+
await Promise.all(
|
|
1222
|
+
this.contextProviders.map(async (provider) => {
|
|
1223
|
+
try {
|
|
1224
|
+
const result = await provider({
|
|
1225
|
+
messages,
|
|
1226
|
+
config: this.config
|
|
1227
|
+
});
|
|
1228
|
+
if (result && typeof result === "object") {
|
|
1229
|
+
Object.assign(contextAggregate, result);
|
|
1230
|
+
}
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
if (typeof console !== "undefined") {
|
|
1233
|
+
// eslint-disable-next-line no-console
|
|
1234
|
+
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
const mentionContext = this.latestMentionContext(messages);
|
|
1241
|
+
if (mentionContext) Object.assign(contextAggregate, mentionContext);
|
|
1242
|
+
return Object.keys(contextAggregate).length ? contextAggregate : null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1180
1245
|
private async buildAgentPayload(
|
|
1181
1246
|
messages: AgentWidgetMessage[]
|
|
1182
1247
|
): Promise<AgentWidgetAgentRequestPayload> {
|
|
@@ -1226,32 +1291,9 @@ export class AgentWidgetClient {
|
|
|
1226
1291
|
payload.clientTools = clientTools;
|
|
1227
1292
|
}
|
|
1228
1293
|
|
|
1229
|
-
// Add context from providers
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
await Promise.all(
|
|
1233
|
-
this.contextProviders.map(async (provider) => {
|
|
1234
|
-
try {
|
|
1235
|
-
const result = await provider({
|
|
1236
|
-
messages,
|
|
1237
|
-
config: this.config
|
|
1238
|
-
});
|
|
1239
|
-
if (result && typeof result === "object") {
|
|
1240
|
-
Object.assign(contextAggregate, result);
|
|
1241
|
-
}
|
|
1242
|
-
} catch (error) {
|
|
1243
|
-
if (typeof console !== "undefined") {
|
|
1244
|
-
// eslint-disable-next-line no-console
|
|
1245
|
-
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
})
|
|
1249
|
-
);
|
|
1250
|
-
|
|
1251
|
-
if (Object.keys(contextAggregate).length) {
|
|
1252
|
-
payload.context = contextAggregate;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1294
|
+
// Add context from providers + opt-in mention context.
|
|
1295
|
+
const contextAggregate = await this.buildContextAggregate(messages);
|
|
1296
|
+
if (contextAggregate) payload.context = contextAggregate;
|
|
1255
1297
|
|
|
1256
1298
|
return payload;
|
|
1257
1299
|
}
|
|
@@ -1306,31 +1348,8 @@ export class AgentWidgetClient {
|
|
|
1306
1348
|
payload.clientTools = clientTools;
|
|
1307
1349
|
}
|
|
1308
1350
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
await Promise.all(
|
|
1312
|
-
this.contextProviders.map(async (provider) => {
|
|
1313
|
-
try {
|
|
1314
|
-
const result = await provider({
|
|
1315
|
-
messages,
|
|
1316
|
-
config: this.config
|
|
1317
|
-
});
|
|
1318
|
-
if (result && typeof result === "object") {
|
|
1319
|
-
Object.assign(contextAggregate, result);
|
|
1320
|
-
}
|
|
1321
|
-
} catch (error) {
|
|
1322
|
-
if (typeof console !== "undefined") {
|
|
1323
|
-
// eslint-disable-next-line no-console
|
|
1324
|
-
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
})
|
|
1328
|
-
);
|
|
1329
|
-
|
|
1330
|
-
if (Object.keys(contextAggregate).length) {
|
|
1331
|
-
payload.context = contextAggregate;
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1351
|
+
const contextAggregate = await this.buildContextAggregate(messages);
|
|
1352
|
+
if (contextAggregate) payload.context = contextAggregate;
|
|
1334
1353
|
|
|
1335
1354
|
if (this.requestMiddleware) {
|
|
1336
1355
|
try {
|
|
@@ -1701,12 +1720,25 @@ export class AgentWidgetClient {
|
|
|
1701
1720
|
|
|
1702
1721
|
// Track tool call IDs for artifact emit tools so we can suppress their UI
|
|
1703
1722
|
const artifactToolCallIds = new Set<string>();
|
|
1704
|
-
// Track artifact reference card
|
|
1723
|
+
// Track artifact block messages (reference card or inline block) so we can
|
|
1724
|
+
// update them on artifact_complete
|
|
1705
1725
|
const artifactCardMessages = new Map<string, AgentWidgetMessage>();
|
|
1706
1726
|
// Track artifact IDs that already have a reference card (from auto-creation or transcript_insert)
|
|
1707
1727
|
const artifactIdsWithCards = new Set<string>();
|
|
1708
|
-
// Accumulate artifact markdown content
|
|
1709
|
-
|
|
1728
|
+
// Accumulate artifact markdown content (and component props) for embedding
|
|
1729
|
+
// in block props on complete. `props` accumulates across `artifact_update`
|
|
1730
|
+
// events so an inline component block hydrates with its real props after a
|
|
1731
|
+
// refresh (the session artifact registry, which also tracks them live, is
|
|
1732
|
+
// not persisted).
|
|
1733
|
+
const artifactContent = new Map<
|
|
1734
|
+
string,
|
|
1735
|
+
{
|
|
1736
|
+
markdown: string;
|
|
1737
|
+
title?: string;
|
|
1738
|
+
file?: PersonaArtifactFileMeta;
|
|
1739
|
+
props?: Record<string, unknown>;
|
|
1740
|
+
}
|
|
1741
|
+
>();
|
|
1710
1742
|
const isArtifactEmitToolName = (name: string | undefined): boolean => {
|
|
1711
1743
|
if (!name) return false;
|
|
1712
1744
|
const normalized = name.replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
@@ -3105,17 +3137,57 @@ export class AgentWidgetClient {
|
|
|
3105
3137
|
const at = payload.artifactType as PersonaArtifactKind;
|
|
3106
3138
|
const artId = String(payload.id);
|
|
3107
3139
|
const artTitle = typeof payload.title === "string" ? payload.title : undefined;
|
|
3140
|
+
// Additive `file` metadata: validate shape (object with string path +
|
|
3141
|
+
// string mimeType; optional string language). Drop silently if malformed
|
|
3142
|
+
// so old/new backends and unexpected payloads never break the stream.
|
|
3143
|
+
const rawFile = payload.file;
|
|
3144
|
+
let artFile: PersonaArtifactFileMeta | undefined;
|
|
3145
|
+
if (
|
|
3146
|
+
rawFile &&
|
|
3147
|
+
typeof rawFile === "object" &&
|
|
3148
|
+
!Array.isArray(rawFile) &&
|
|
3149
|
+
typeof rawFile.path === "string" &&
|
|
3150
|
+
typeof rawFile.mimeType === "string"
|
|
3151
|
+
) {
|
|
3152
|
+
artFile = {
|
|
3153
|
+
path: rawFile.path,
|
|
3154
|
+
mimeType: rawFile.mimeType,
|
|
3155
|
+
...(typeof rawFile.language === "string" ? { language: rawFile.language } : {}),
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3108
3158
|
onEvent({
|
|
3109
3159
|
type: "artifact_start",
|
|
3110
3160
|
id: artId,
|
|
3111
3161
|
artifactType: at,
|
|
3112
3162
|
title: artTitle,
|
|
3113
|
-
component: typeof payload.component === "string" ? payload.component : undefined
|
|
3163
|
+
component: typeof payload.component === "string" ? payload.component : undefined,
|
|
3164
|
+
...(artFile ? { file: artFile } : {})
|
|
3114
3165
|
});
|
|
3115
|
-
|
|
3116
|
-
//
|
|
3166
|
+
// Seed component props from artifact_start when the payload
|
|
3167
|
+
// carries them; artifact_update events accumulate the rest below.
|
|
3168
|
+
const startProps =
|
|
3169
|
+
payload.props && typeof payload.props === "object" && !Array.isArray(payload.props)
|
|
3170
|
+
? { ...(payload.props as Record<string, unknown>) }
|
|
3171
|
+
: undefined;
|
|
3172
|
+
artifactContent.set(artId, {
|
|
3173
|
+
markdown: "",
|
|
3174
|
+
title: artTitle,
|
|
3175
|
+
file: artFile,
|
|
3176
|
+
...(startProps ? { props: startProps } : {})
|
|
3177
|
+
});
|
|
3178
|
+
// Insert the in-thread artifact block (skip if already present from
|
|
3179
|
+
// transcript_insert). The resolved display mode picks the component:
|
|
3180
|
+
// "card"/"panel" inject the reference card; "inline" injects the
|
|
3181
|
+
// inline preview block. Both share the rawContent JSON-component
|
|
3182
|
+
// shape so transcript persistence and hydration work unchanged.
|
|
3117
3183
|
if (!artifactIdsWithCards.has(artId)) {
|
|
3118
3184
|
artifactIdsWithCards.add(artId);
|
|
3185
|
+
const displayMode = resolveArtifactDisplayMode(
|
|
3186
|
+
this.config.features?.artifacts,
|
|
3187
|
+
at
|
|
3188
|
+
);
|
|
3189
|
+
const artComponent =
|
|
3190
|
+
typeof payload.component === "string" ? payload.component : undefined;
|
|
3119
3191
|
const cardMsg: AgentWidgetMessage = {
|
|
3120
3192
|
id: `artifact-ref-${artId}`,
|
|
3121
3193
|
role: "assistant",
|
|
@@ -3123,9 +3195,13 @@ export class AgentWidgetClient {
|
|
|
3123
3195
|
createdAt: new Date().toISOString(),
|
|
3124
3196
|
streaming: true,
|
|
3125
3197
|
sequence: nextSequence(),
|
|
3126
|
-
rawContent:
|
|
3127
|
-
|
|
3128
|
-
|
|
3198
|
+
rawContent: buildArtifactRefRawContent(displayMode, {
|
|
3199
|
+
artifactId: artId,
|
|
3200
|
+
title: artTitle,
|
|
3201
|
+
artifactType: at,
|
|
3202
|
+
status: "streaming",
|
|
3203
|
+
...(artFile ? { file: artFile } : {}),
|
|
3204
|
+
...(artComponent ? { component: artComponent } : {}),
|
|
3129
3205
|
}),
|
|
3130
3206
|
};
|
|
3131
3207
|
artifactCardMessages.set(artId, cardMsg);
|
|
@@ -3152,6 +3228,13 @@ export class AgentWidgetClient {
|
|
|
3152
3228
|
props,
|
|
3153
3229
|
component: typeof payload.component === "string" ? payload.component : undefined
|
|
3154
3230
|
});
|
|
3231
|
+
// Accumulate for hydration: embedded on artifact_complete so an
|
|
3232
|
+
// inline component block re-renders with its real props after a
|
|
3233
|
+
// refresh.
|
|
3234
|
+
const updateAcc = artifactContent.get(String(payload.id));
|
|
3235
|
+
if (updateAcc) {
|
|
3236
|
+
updateAcc.props = { ...(updateAcc.props ?? {}), ...props };
|
|
3237
|
+
}
|
|
3155
3238
|
} else if (payloadType === "artifact_complete") {
|
|
3156
3239
|
const artCompleteId = String(payload.id);
|
|
3157
3240
|
onEvent({ type: "artifact_complete", id: artCompleteId });
|
|
@@ -3168,6 +3251,20 @@ export class AgentWidgetClient {
|
|
|
3168
3251
|
if (acc?.markdown) {
|
|
3169
3252
|
parsed.props.markdown = acc.markdown;
|
|
3170
3253
|
}
|
|
3254
|
+
// Persist file metadata too so the download path unfences correctly after refresh.
|
|
3255
|
+
if (acc?.file) {
|
|
3256
|
+
parsed.props.file = acc.file;
|
|
3257
|
+
}
|
|
3258
|
+
// Embed accumulated component props so an inline component
|
|
3259
|
+
// block hydrates with its real props after a refresh. Only
|
|
3260
|
+
// the inline block reads them; the card never does.
|
|
3261
|
+
if (
|
|
3262
|
+
parsed.component === "PersonaArtifactInline" &&
|
|
3263
|
+
acc?.props &&
|
|
3264
|
+
Object.keys(acc.props).length > 0
|
|
3265
|
+
) {
|
|
3266
|
+
parsed.props.componentProps = acc.props;
|
|
3267
|
+
}
|
|
3171
3268
|
}
|
|
3172
3269
|
refMsg.rawContent = JSON.stringify(parsed);
|
|
3173
3270
|
} catch { /* ignore parse errors */ }
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { PersonaArtifactCard } from "./artifact-card";
|
|
5
|
+
import { componentRegistry, type ComponentRenderer } from "./registry";
|
|
6
|
+
import type { ComponentContext } from "./registry";
|
|
7
|
+
import type {
|
|
8
|
+
AgentWidgetArtifactsFeature,
|
|
9
|
+
AgentWidgetConfig,
|
|
10
|
+
PersonaArtifactStatusLabelContext,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
const makeContext = (
|
|
14
|
+
artifacts?: AgentWidgetArtifactsFeature
|
|
15
|
+
): ComponentContext =>
|
|
16
|
+
({
|
|
17
|
+
message: {},
|
|
18
|
+
config: {
|
|
19
|
+
features: { artifacts: { enabled: true, ...(artifacts ?? {}) } },
|
|
20
|
+
} as AgentWidgetConfig,
|
|
21
|
+
updateProps: () => {},
|
|
22
|
+
}) as unknown as ComponentContext;
|
|
23
|
+
|
|
24
|
+
const render = (
|
|
25
|
+
props: Record<string, unknown>,
|
|
26
|
+
artifacts?: AgentWidgetArtifactsFeature
|
|
27
|
+
): HTMLElement => PersonaArtifactCard(props, makeContext(artifacts));
|
|
28
|
+
|
|
29
|
+
const streamingProps = {
|
|
30
|
+
artifactId: "a1",
|
|
31
|
+
title: "index.html",
|
|
32
|
+
status: "streaming",
|
|
33
|
+
artifactType: "markdown",
|
|
34
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const NBSP = String.fromCharCode(0xa0);
|
|
38
|
+
|
|
39
|
+
const findStatusText = (root: HTMLElement): HTMLElement | null =>
|
|
40
|
+
root.querySelector(
|
|
41
|
+
".persona-tool-loading-shimmer, .persona-tool-loading-shimmer-color, .persona-tool-loading-rainbow, .persona-tool-loading-pulse"
|
|
42
|
+
) as HTMLElement | null;
|
|
43
|
+
|
|
44
|
+
describe("PersonaArtifactCard streaming status", () => {
|
|
45
|
+
it("renders shimmer animation by default with no pulsing dot", () => {
|
|
46
|
+
const root = render(streamingProps);
|
|
47
|
+
|
|
48
|
+
// No leftover pulsing dot element.
|
|
49
|
+
expect(root.querySelector(".persona-rounded-full")).toBeNull();
|
|
50
|
+
expect(root.innerHTML).not.toContain("persona-pulse ");
|
|
51
|
+
|
|
52
|
+
const statusText = findStatusText(root);
|
|
53
|
+
expect(statusText).not.toBeNull();
|
|
54
|
+
expect(statusText!.classList.contains("persona-tool-loading-shimmer")).toBe(
|
|
55
|
+
true
|
|
56
|
+
);
|
|
57
|
+
expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
|
|
58
|
+
expect(
|
|
59
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-duration")
|
|
60
|
+
).toBe("2000ms");
|
|
61
|
+
|
|
62
|
+
const chars = statusText!.querySelectorAll(".persona-tool-char");
|
|
63
|
+
expect(chars.length).toBeGreaterThan(0);
|
|
64
|
+
expect((chars[0] as HTMLElement).style.getPropertyValue("--char-index")).toBe(
|
|
65
|
+
"0"
|
|
66
|
+
);
|
|
67
|
+
// Reconstruct the animated text from the character spans.
|
|
68
|
+
const text = Array.from(chars)
|
|
69
|
+
.map((c) => (c.textContent === NBSP ? " " : c.textContent))
|
|
70
|
+
.join("");
|
|
71
|
+
expect(text).toBe("Generating html...");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders plain text with no animation classes when loadingAnimation is none", () => {
|
|
75
|
+
const root = render(streamingProps, { loadingAnimation: "none" });
|
|
76
|
+
|
|
77
|
+
expect(findStatusText(root)).toBeNull();
|
|
78
|
+
expect(root.querySelector(".persona-tool-char")).toBeNull();
|
|
79
|
+
expect(root.textContent).toContain("Generating html...");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("applies the pulse class to the status element without char spans", () => {
|
|
83
|
+
const root = render(streamingProps, { loadingAnimation: "pulse" });
|
|
84
|
+
|
|
85
|
+
const statusText = root.querySelector(
|
|
86
|
+
".persona-tool-loading-pulse"
|
|
87
|
+
) as HTMLElement | null;
|
|
88
|
+
expect(statusText).not.toBeNull();
|
|
89
|
+
expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
|
|
90
|
+
expect(statusText!.querySelector(".persona-tool-char")).toBeNull();
|
|
91
|
+
expect(statusText!.textContent).toBe("Generating html...");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sets color CSS vars for shimmer-color mode", () => {
|
|
95
|
+
const root = render(streamingProps, {
|
|
96
|
+
loadingAnimation: "shimmer-color",
|
|
97
|
+
loadingAnimationColor: "#111111",
|
|
98
|
+
loadingAnimationSecondaryColor: "#eeeeee",
|
|
99
|
+
loadingAnimationDuration: 3000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const statusText = root.querySelector(
|
|
103
|
+
".persona-tool-loading-shimmer-color"
|
|
104
|
+
) as HTMLElement | null;
|
|
105
|
+
expect(statusText).not.toBeNull();
|
|
106
|
+
expect(
|
|
107
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-color")
|
|
108
|
+
).toBe("#111111");
|
|
109
|
+
expect(
|
|
110
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-secondary-color")
|
|
111
|
+
).toBe("#eeeeee");
|
|
112
|
+
expect(
|
|
113
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-duration")
|
|
114
|
+
).toBe("3000ms");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("carries the artifact-card class so radius/border/background come from CSS", () => {
|
|
118
|
+
const root = render(streamingProps);
|
|
119
|
+
|
|
120
|
+
// Standalone card must not hardcode the rounded-xl utility class.
|
|
121
|
+
expect(root.classList.contains("persona-rounded-xl")).toBe(false);
|
|
122
|
+
// Radius/border/background now live in widget.css keyed off this class,
|
|
123
|
+
// following the --persona-artifact-card-radius → assistant-bubble chain.
|
|
124
|
+
expect(root.classList.contains("persona-artifact-card")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("keeps border/background off inline styles so they come from CSS", () => {
|
|
128
|
+
const root = render(streamingProps);
|
|
129
|
+
|
|
130
|
+
expect(root.classList.contains("persona-artifact-card")).toBe(true);
|
|
131
|
+
// Border/bg/radius/cursor moved to widget.css keyed off the card class.
|
|
132
|
+
expect(root.style.border).toBe("");
|
|
133
|
+
expect(root.style.background).toBe("");
|
|
134
|
+
expect(root.style.backgroundColor).toBe("");
|
|
135
|
+
expect(root.style.borderRadius).toBe("");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders the Download button as a label button carrying the artifact id", () => {
|
|
139
|
+
const root = render({
|
|
140
|
+
artifactId: "a1",
|
|
141
|
+
title: "index.html",
|
|
142
|
+
status: "complete",
|
|
143
|
+
artifactType: "markdown",
|
|
144
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const dl = root.querySelector(
|
|
148
|
+
"[data-download-artifact]"
|
|
149
|
+
) as HTMLButtonElement | null;
|
|
150
|
+
expect(dl).not.toBeNull();
|
|
151
|
+
expect(dl!.tagName).toBe("BUTTON");
|
|
152
|
+
expect(dl!.classList.contains("persona-label-btn")).toBe(true);
|
|
153
|
+
expect(dl!.textContent).toBe("Download");
|
|
154
|
+
expect(dl!.getAttribute("data-download-artifact")).toBe("a1");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("renders the complete state with a Download button and no animation", () => {
|
|
158
|
+
const root = render({
|
|
159
|
+
artifactId: "a1",
|
|
160
|
+
title: "index.html",
|
|
161
|
+
status: "complete",
|
|
162
|
+
artifactType: "markdown",
|
|
163
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(findStatusText(root)).toBeNull();
|
|
167
|
+
expect(root.querySelector(".persona-tool-char")).toBeNull();
|
|
168
|
+
|
|
169
|
+
const dl = root.querySelector(
|
|
170
|
+
"[data-download-artifact]"
|
|
171
|
+
) as HTMLButtonElement | null;
|
|
172
|
+
expect(dl).not.toBeNull();
|
|
173
|
+
expect(dl!.textContent).toBe("Download");
|
|
174
|
+
// Subtitle shows the file type label, not a "Generating" status.
|
|
175
|
+
expect(root.textContent).not.toContain("Generating");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("PersonaArtifactCard statusLabel", () => {
|
|
180
|
+
// Reconstruct the (possibly char-span animated) status label text.
|
|
181
|
+
const labelText = (root: HTMLElement): string => {
|
|
182
|
+
const label = root.querySelector(
|
|
183
|
+
".persona-artifact-status-label"
|
|
184
|
+
) as HTMLElement | null;
|
|
185
|
+
if (!label) return "";
|
|
186
|
+
const chars = label.querySelectorAll(".persona-tool-char");
|
|
187
|
+
if (chars.length === 0) return label.textContent ?? "";
|
|
188
|
+
return Array.from(chars)
|
|
189
|
+
.map((c) => (c.textContent === NBSP ? " " : c.textContent))
|
|
190
|
+
.join("");
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
it("replaces the default status text with a plain string", () => {
|
|
194
|
+
const root = render(streamingProps, { statusLabel: "Cooking up your file..." });
|
|
195
|
+
expect(labelText(root)).toBe("Cooking up your file...");
|
|
196
|
+
expect(root.textContent).not.toContain("Generating");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("calls a statusLabel function with the card surface and ctx", () => {
|
|
200
|
+
const calls: PersonaArtifactStatusLabelContext[] = [];
|
|
201
|
+
const root = render(streamingProps, {
|
|
202
|
+
statusLabel: (ctx) => {
|
|
203
|
+
calls.push(ctx);
|
|
204
|
+
return "Custom label";
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(calls.length).toBe(1);
|
|
208
|
+
expect(calls[0].surface).toBe("card");
|
|
209
|
+
expect(calls[0].artifactId).toBe("a1");
|
|
210
|
+
expect(calls[0].artifactType).toBe("markdown");
|
|
211
|
+
// file: index.html -> fileTypeLabel "HTML".
|
|
212
|
+
expect(calls[0].typeLabel).toBe("HTML");
|
|
213
|
+
expect(typeof calls[0].content).toBe("function");
|
|
214
|
+
expect(labelText(root)).toBe("Custom label");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("renders a { label, detail } as an animated label + plain detail span", () => {
|
|
218
|
+
const root = render(streamingProps, {
|
|
219
|
+
statusLabel: () => ({ label: "Writing", detail: "just started" }),
|
|
220
|
+
});
|
|
221
|
+
expect(labelText(root)).toBe("Writing");
|
|
222
|
+
const detail = root.querySelector(
|
|
223
|
+
".persona-artifact-status-detail"
|
|
224
|
+
) as HTMLElement | null;
|
|
225
|
+
expect(detail).not.toBeNull();
|
|
226
|
+
expect(detail!.textContent).toBe("just started");
|
|
227
|
+
// The detail span is un-animated (no char spans / loading classes).
|
|
228
|
+
expect(detail!.querySelector(".persona-tool-char")).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("falls back to the default label when statusLabel throws", () => {
|
|
232
|
+
const root = render(streamingProps, {
|
|
233
|
+
statusLabel: () => {
|
|
234
|
+
throw new Error("boom");
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(labelText(root)).toBe("Generating html...");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("PersonaArtifactCard custom card actions", () => {
|
|
242
|
+
const completeProps = {
|
|
243
|
+
artifactId: "a1",
|
|
244
|
+
title: "index.html",
|
|
245
|
+
status: "complete",
|
|
246
|
+
artifactType: "markdown",
|
|
247
|
+
markdown: "# Hi",
|
|
248
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it("renders a card action button before Download on complete cards", () => {
|
|
252
|
+
const root = render(completeProps, {
|
|
253
|
+
cardActions: [
|
|
254
|
+
{ id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const btn = root.querySelector(
|
|
259
|
+
'[data-artifact-custom-action="save"]'
|
|
260
|
+
) as HTMLButtonElement | null;
|
|
261
|
+
expect(btn).not.toBeNull();
|
|
262
|
+
expect(btn!.tagName).toBe("BUTTON");
|
|
263
|
+
// Delegated in ui.ts: the card carries the id attribute, no direct listener.
|
|
264
|
+
expect(btn!.getAttribute("data-artifact-custom-action")).toBe("save");
|
|
265
|
+
|
|
266
|
+
const dl = root.querySelector(
|
|
267
|
+
"[data-download-artifact]"
|
|
268
|
+
) as HTMLElement | null;
|
|
269
|
+
expect(dl).not.toBeNull();
|
|
270
|
+
// Custom actions render before Download.
|
|
271
|
+
expect(
|
|
272
|
+
btn!.compareDocumentPosition(dl!) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
273
|
+
).toBeTruthy();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not render card actions while streaming", () => {
|
|
277
|
+
const root = render(streamingProps, {
|
|
278
|
+
cardActions: [
|
|
279
|
+
{ id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("suppresses a card action whose visible() returns false", () => {
|
|
286
|
+
const root = render(completeProps, {
|
|
287
|
+
cardActions: [
|
|
288
|
+
{
|
|
289
|
+
id: "save",
|
|
290
|
+
label: "Save",
|
|
291
|
+
icon: "star",
|
|
292
|
+
visible: () => false,
|
|
293
|
+
onClick: () => {},
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("componentRegistry registration options", () => {
|
|
302
|
+
const noop: ComponentRenderer = () => document.createElement("div");
|
|
303
|
+
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
componentRegistry.unregister("OptTest");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("registers the built-in artifact card with bubbleChrome:false", () => {
|
|
309
|
+
expect(componentRegistry.getOptions("PersonaArtifactCard")?.bubbleChrome).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("returns the options passed to register", () => {
|
|
313
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
314
|
+
expect(componentRegistry.getOptions("OptTest")).toEqual({ bubbleChrome: false });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("returns undefined when a component is registered without options", () => {
|
|
318
|
+
componentRegistry.register("OptTest", noop);
|
|
319
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("clears prior options when re-registered without options", () => {
|
|
323
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
324
|
+
componentRegistry.register("OptTest", noop);
|
|
325
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("drops options on unregister", () => {
|
|
329
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
330
|
+
componentRegistry.unregister("OptTest");
|
|
331
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
});
|