@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.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. 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
- if (this.contextProviders.length) {
1231
- const contextAggregate: Record<string, unknown> = {};
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
- if (this.contextProviders.length) {
1310
- const contextAggregate: Record<string, unknown> = {};
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 messages so we can update them on artifact_complete
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 for embedding in card props on complete
1709
- const artifactContent = new Map<string, { markdown: string; title?: string }>();
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
- artifactContent.set(artId, { markdown: "", title: artTitle });
3116
- // Insert inline artifact reference card (skip if already present from transcript_insert)
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: JSON.stringify({
3127
- component: "PersonaArtifactCard",
3128
- props: { artifactId: artId, title: artTitle, artifactType: at, status: "streaming" },
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
+ });