@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
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
3
- import { AgentWidgetMessage } from './types';
3
+ import { AgentWidgetMessage, AgentWidgetContentSegment } from './types';
4
4
 
5
5
  describe('AgentWidgetSession - Message Injection', () => {
6
6
  let session: AgentWidgetSession;
@@ -369,6 +369,51 @@ describe('AgentWidgetSession - Message Injection', () => {
369
369
  });
370
370
  });
371
371
 
372
+ describe('AgentWidgetSession - inline contentSegments', () => {
373
+ const originalFetch = global.fetch;
374
+ afterEach(() => {
375
+ global.fetch = originalFetch;
376
+ vi.restoreAllMocks();
377
+ });
378
+
379
+ const makeHangingSession = () => {
380
+ // A never-settling fetch keeps the turn in-flight so the appended user
381
+ // message can be inspected without a dispatch-error bubble replacing it.
382
+ global.fetch = vi.fn().mockImplementation(() => new Promise(() => {}));
383
+ return new AgentWidgetSession(
384
+ { apiUrl: 'http://example.invalid/chat' },
385
+ {
386
+ onMessagesChanged: () => {},
387
+ onStatusChanged: () => {},
388
+ onStreamingChanged: () => {},
389
+ }
390
+ );
391
+ };
392
+
393
+ it('stores ordered contentSegments on the sent user message', () => {
394
+ const session = makeHangingSession();
395
+ const segments: AgentWidgetContentSegment[] = [
396
+ { kind: 'text', text: 'Check ' },
397
+ { kind: 'mention', ref: { sourceId: 'files', itemId: 'app', label: 'App.tsx' } },
398
+ { kind: 'text', text: ' please' },
399
+ ];
400
+ void session.sendMessage('Check @App.tsx please', {
401
+ contentSegments: segments,
402
+ });
403
+ const userMsg = session.getMessages().find((m) => m.role === 'user');
404
+ // Segments must land on the stored (sequence-normalized) copy, not just the
405
+ // orphaned input literal — this is the fix for the dead inline-token branch.
406
+ expect(userMsg?.contentSegments).toEqual(segments);
407
+ });
408
+
409
+ it('omits contentSegments for a plain message', () => {
410
+ const session = makeHangingSession();
411
+ void session.sendMessage('plain message');
412
+ const userMsg = session.getMessages().find((m) => m.role === 'user');
413
+ expect(userMsg?.contentSegments).toBeUndefined();
414
+ });
415
+ });
416
+
372
417
  describe('AgentWidgetSession - cancel()', () => {
373
418
  const originalFetch = global.fetch;
374
419
 
@@ -404,9 +449,12 @@ describe('AgentWidgetSession - cancel()', () => {
404
449
 
405
450
  // Kick off the dispatch but don't await: we want it in-flight when we cancel.
406
451
  const dispatchPromise = session.sendMessage('Hello');
407
- // Let the session set up the AbortController and call fetch.
408
- await Promise.resolve();
409
- await Promise.resolve();
452
+ // Drain microtasks until the session has set up the AbortController and
453
+ // called fetch (payload building is async, so the exact tick count is not a
454
+ // contract — wait for the observable effect instead).
455
+ for (let i = 0; i < 20 && capturedSignal === null; i++) {
456
+ await Promise.resolve();
457
+ }
410
458
 
411
459
  expect(streaming).toBe(true);
412
460
  expect(session.isStreaming()).toBe(true);
package/src/session.ts CHANGED
@@ -27,14 +27,21 @@ import {
27
27
  generateUserMessageId,
28
28
  generateAssistantMessageId
29
29
  } from "./utils/message-id";
30
- import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "./utils/content";
30
+ import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, createTextPart, hasImages } from "./utils/content";
31
+ import {
32
+ buildArtifactRefRawContent,
33
+ resolveArtifactDisplayMode
34
+ } from "./utils/artifact-display";
31
35
  import type {
32
36
  VoiceProvider,
33
37
  VoiceStatus,
34
38
  VoiceConfig,
35
39
  ReadAloudState,
36
- SpeechEngine
40
+ SpeechEngine,
41
+ AgentWidgetContentSegment,
42
+ AgentWidgetContextMentionRef
37
43
  } from "./types";
44
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
38
45
  import {
39
46
  createVoiceProvider,
40
47
  isVoiceSupported,
@@ -1125,17 +1132,92 @@ export class AgentWidgetSession {
1125
1132
  });
1126
1133
  }
1127
1134
 
1135
+ /**
1136
+ * Resolve the gathered mentions and merge them into the user message's
1137
+ * model-visible content. Default path is `llmAppend` → `llmContent` (or a text
1138
+ * `contentPart` when parts are present, since `contentParts` wins the priority
1139
+ * chain); the opt-in structured `context` lands on `mentionContext` for the
1140
+ * client to namespace into the request `context`. Failures were already
1141
+ * dropped by `finalize()`.
1142
+ */
1143
+ private async applyMentionBundle(
1144
+ userMessage: AgentWidgetMessage,
1145
+ typedText: string,
1146
+ finalize: () => Promise<MentionSubmitBundle>
1147
+ ): Promise<void> {
1148
+ let bundle: MentionSubmitBundle;
1149
+ try {
1150
+ bundle = await finalize();
1151
+ } catch {
1152
+ return; // resolution failed entirely; send the typed text as-is
1153
+ }
1154
+
1155
+ // Blocks arrive pre-formatted from the mention layer (fenced/document/custom
1156
+ // per `contextMentions.llmFormat`); the session just joins them and appends
1157
+ // the typed prose last.
1158
+ const block = bundle.blocks.join("\n\n");
1159
+ const llmText = [block, typedText].filter(Boolean).join("\n\n");
1160
+
1161
+ const hasAttachments =
1162
+ Array.isArray(userMessage.contentParts) &&
1163
+ userMessage.contentParts.length > 0;
1164
+ const hasMentionParts = bundle.contentParts.length > 0;
1165
+
1166
+ if (hasAttachments) {
1167
+ // The typed text already rides as a content part; add the block + mention
1168
+ // parts ahead of the existing attachment parts (mentions first).
1169
+ const parts: ContentPart[] = [];
1170
+ if (block) parts.push(createTextPart(block));
1171
+ parts.push(...bundle.contentParts);
1172
+ parts.push(...userMessage.contentParts!);
1173
+ userMessage.contentParts = parts;
1174
+ } else if (hasMentionParts) {
1175
+ const parts: ContentPart[] = [];
1176
+ if (llmText) parts.push(createTextPart(llmText));
1177
+ parts.push(...bundle.contentParts);
1178
+ userMessage.contentParts = parts;
1179
+ } else if (block) {
1180
+ userMessage.llmContent = llmText;
1181
+ }
1182
+
1183
+ if (Object.keys(bundle.context).length > 0) {
1184
+ userMessage.mentionContext = bundle.context;
1185
+ }
1186
+ }
1187
+
1128
1188
  public async sendMessage(
1129
1189
  rawInput: string,
1130
1190
  options?: {
1131
1191
  viaVoice?: boolean;
1132
1192
  /** Multi-modal content parts (e.g., images) to include with the message */
1133
1193
  contentParts?: ContentPart[];
1194
+ /**
1195
+ * Context mentions gathered from the composer. `refs` echo immediately as
1196
+ * chips; `finalize()` resolves any pending/submit sources just before
1197
+ * dispatch (after the instant echo) and the bundle is merged into this
1198
+ * message's model-visible content.
1199
+ */
1200
+ mentions?: {
1201
+ refs: AgentWidgetContextMentionRef[];
1202
+ finalize: () => Promise<MentionSubmitBundle>;
1203
+ };
1204
+ /**
1205
+ * Ordered prose + mention segments for inline-mode display (`display:
1206
+ * "inline"`). Stored on the user message so its bubble re-renders `@tokens`
1207
+ * in place (and suppresses the chip row). Display/transcript only — the
1208
+ * model still sees resolved bodies via the `mentions` bundle above.
1209
+ */
1210
+ contentSegments?: AgentWidgetContentSegment[];
1134
1211
  }
1135
1212
  ) {
1136
1213
  const input = rawInput.trim();
1137
- // Allow sending if there's text OR attachments
1138
- if (!input && (!options?.contentParts || options.contentParts.length === 0)) return;
1214
+ // Allow sending if there's text OR attachments OR mentions
1215
+ if (
1216
+ !input &&
1217
+ (!options?.contentParts || options.contentParts.length === 0) &&
1218
+ (!options?.mentions || options.mentions.refs.length === 0)
1219
+ )
1220
+ return;
1139
1221
 
1140
1222
  this.stopSpeaking();
1141
1223
  this.abortController?.abort();
@@ -1156,25 +1238,63 @@ export class AgentWidgetSession {
1156
1238
  // the proxy path auto-generates a different id than `assistantMessageId`.
1157
1239
  this.activeAssistantMessageId = null;
1158
1240
 
1241
+ // Fallback display text ONLY when the sole content is image attachments.
1242
+ // A mention/command-only submit (empty text + a chip) must NOT read as
1243
+ // "[Image]"; it renders its chips with empty text instead.
1244
+ const imageOnlyFallback =
1245
+ options?.contentParts && hasImages(options.contentParts)
1246
+ ? IMAGE_ONLY_MESSAGE_FALLBACK_TEXT
1247
+ : "";
1248
+
1159
1249
  const userMessage: AgentWidgetMessage = {
1160
1250
  id: userMessageId,
1161
1251
  role: "user",
1162
- content: input || IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, // Display text (fallback if only images)
1252
+ content: input || imageOnlyFallback, // Display text (fallback if only images)
1163
1253
  createdAt: new Date().toISOString(),
1164
1254
  sequence: this.nextSequence(),
1165
1255
  viaVoice: options?.viaVoice || false,
1166
1256
  // Include contentParts if provided (for multi-modal messages)
1167
1257
  ...(options?.contentParts && options.contentParts.length > 0 && {
1168
1258
  contentParts: options.contentParts
1259
+ }),
1260
+ // Echo mention chips immediately (refs only; payloads merged below).
1261
+ ...(options?.mentions && options.mentions.refs.length > 0 && {
1262
+ contextMentions: options.mentions.refs
1263
+ }),
1264
+ // Inline mode: ordered display segments for in-prose `@token` rendering.
1265
+ ...(options?.contentSegments && options.contentSegments.length > 0 && {
1266
+ contentSegments: options.contentSegments
1169
1267
  })
1170
1268
  };
1171
1269
 
1172
1270
  this.appendMessage(userMessage);
1173
1271
  this.setStreaming(true);
1174
1272
 
1273
+ // Assign the fresh controller BEFORE the mention await so cancel() (or a
1274
+ // superseding sendMessage) during finalize() aborts THIS turn, not a stale
1275
+ // prior controller.
1175
1276
  const controller = new AbortController();
1176
1277
  this.abortController = controller;
1177
1278
 
1279
+ // Resolve + merge mentions AFTER the instant echo but BEFORE dispatch, so
1280
+ // the model sees the context while the user's bubble already rendered.
1281
+ // `appendMessage` stores a sequence-normalized COPY (see `ensureSequence`),
1282
+ // so mutate THAT live reference — not the orphaned `userMessage` literal —
1283
+ // or the merged `llmContent`/`contentParts`/`mentionContext` never reach the
1284
+ // dispatch snapshot below. Re-emit so any merged parts also reach the UI.
1285
+ if (options?.mentions) {
1286
+ const stored =
1287
+ this.messages.find((m) => m.id === userMessageId) ?? userMessage;
1288
+ await this.applyMentionBundle(stored, input, options.mentions.finalize);
1289
+ // A cancel() or new sendMessage during finalize aborted this controller
1290
+ // (and replaced/nulled the shared ref). Bail without dispatching and
1291
+ // leave whatever idle/streaming state that caller already set.
1292
+ if (controller.signal.aborted || this.abortController !== controller) {
1293
+ return;
1294
+ }
1295
+ this.callbacks.onMessagesChanged([...this.messages]);
1296
+ }
1297
+
1178
1298
  const snapshot = [...this.messages];
1179
1299
 
1180
1300
  try {
@@ -2607,33 +2727,82 @@ export class AgentWidgetSession {
2607
2727
  const id =
2608
2728
  manual.id ||
2609
2729
  `art_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
2610
- if (manual.artifactType === "markdown") {
2611
- const rec: PersonaArtifactRecord = {
2612
- id,
2613
- artifactType: "markdown",
2614
- title: manual.title,
2615
- status: "complete",
2616
- markdown: manual.content
2617
- };
2618
- this.artifacts.set(id, rec);
2619
- this.selectedArtifactId = id;
2620
- this.emitArtifactsState();
2621
- return rec;
2622
- }
2623
- const rec: PersonaArtifactRecord = {
2624
- id,
2625
- artifactType: "component",
2626
- title: manual.title,
2627
- status: "complete",
2628
- component: manual.component,
2629
- props: manual.props ?? {}
2630
- };
2730
+ const rec: PersonaArtifactRecord =
2731
+ manual.artifactType === "markdown"
2732
+ ? {
2733
+ id,
2734
+ artifactType: "markdown",
2735
+ title: manual.title,
2736
+ status: "complete",
2737
+ markdown: manual.content,
2738
+ ...(manual.file ? { file: manual.file } : {})
2739
+ }
2740
+ : {
2741
+ id,
2742
+ artifactType: "component",
2743
+ title: manual.title,
2744
+ status: "complete",
2745
+ component: manual.component,
2746
+ props: manual.props ?? {}
2747
+ };
2631
2748
  this.artifacts.set(id, rec);
2632
2749
  this.selectedArtifactId = id;
2633
2750
  this.emitArtifactsState();
2751
+ if (manual.transcript !== false) {
2752
+ this.injectArtifactRefBlock(rec);
2753
+ }
2634
2754
  return rec;
2635
2755
  }
2636
2756
 
2757
+ /**
2758
+ * Injects (or refreshes) the in-thread artifact block for a programmatically
2759
+ * upserted artifact, matching the streamed UX: the resolved display mode
2760
+ * picks the component ("card"/"panel" → reference card, "inline" → inline
2761
+ * preview).
2762
+ *
2763
+ * Unlike the streamed path (which embeds content on `artifact_complete`),
2764
+ * the programmatic record is complete up front, so the final markdown /
2765
+ * file meta / component name + props are embedded immediately and the block
2766
+ * hydrates after a refresh without the original registry state.
2767
+ *
2768
+ * A re-upsert of the same id rebuilds the existing block's persisted
2769
+ * rawContent in place (the registry is not persisted, so hydration reads
2770
+ * these props); without this the block would keep the first version's
2771
+ * content after a refresh.
2772
+ */
2773
+ private injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
2774
+ const refId = `artifact-ref-${rec.id}`;
2775
+ const displayMode = resolveArtifactDisplayMode(
2776
+ this.config.features?.artifacts,
2777
+ rec.artifactType
2778
+ );
2779
+ const rawContent = buildArtifactRefRawContent(displayMode, {
2780
+ artifactId: rec.id,
2781
+ title: rec.title,
2782
+ artifactType: rec.artifactType,
2783
+ status: "complete",
2784
+ ...(rec.file ? { file: rec.file } : {}),
2785
+ ...(rec.component ? { component: rec.component } : {}),
2786
+ ...(rec.props ? { componentProps: rec.props } : {}),
2787
+ ...(rec.markdown !== undefined ? { markdown: rec.markdown } : {})
2788
+ });
2789
+ // Re-upsert: mutate the STORED message (inject stores a copy via
2790
+ // ensureSequence, so a local copy would be lost) and notify so the
2791
+ // transcript re-renders and persistence picks up the new version.
2792
+ const existing = this.messages.find((m) => m.id === refId);
2793
+ if (existing) {
2794
+ existing.rawContent = rawContent;
2795
+ existing.streaming = false;
2796
+ this.callbacks.onMessagesChanged([...this.messages]);
2797
+ return;
2798
+ }
2799
+ this.injectAssistantMessage({
2800
+ id: refId,
2801
+ content: "",
2802
+ rawContent
2803
+ });
2804
+ }
2805
+
2637
2806
  private clearArtifactState(): void {
2638
2807
  if (this.artifacts.size === 0 && this.selectedArtifactId === null) return;
2639
2808
  this.artifacts.clear();
@@ -2657,7 +2826,8 @@ export class AgentWidgetSession {
2657
2826
  artifactType: "markdown",
2658
2827
  title: ev.title,
2659
2828
  status: "streaming",
2660
- markdown: ""
2829
+ markdown: "",
2830
+ ...(ev.file ? { file: ev.file } : {})
2661
2831
  });
2662
2832
  } else {
2663
2833
  this.artifacts.set(ev.id, {
@@ -4,10 +4,11 @@
4
4
  // vendored library under jsdom. The pure-mapper correctness guarantee lives in
5
5
  // utils/smart-dom-adapter.test.ts (no DOM, no library); this test confirms the
6
6
  // vendored runtime loads and the provider wires up against a real document.
7
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
8
8
  import {
9
9
  collectSmartDomContext,
10
- createSmartDomReaderContextProvider
10
+ createSmartDomReaderContextProvider,
11
+ createSmartDomMentionSource
11
12
  } from "./smart-dom-reader";
12
13
 
13
14
  // jsdom implements no layout, so getBoundingClientRect()/offsetParent report the
@@ -116,6 +117,66 @@ describe("smart-dom-reader entry (jsdom)", () => {
116
117
  expect(texts).toContain("Scoped shadow action");
117
118
  });
118
119
 
120
+ it("mention source surfaces page elements as items and resolves text at submit", async () => {
121
+ document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
122
+ const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
123
+ expect(source.id).toBe("page");
124
+ expect(source.resolveOn).toBe("submit");
125
+
126
+ const items = await source.search("", { messages: [], config: {} as never, signal: new AbortController().signal });
127
+ const go = items.find((i) => i.label.includes("Continue"));
128
+ expect(go).toBeTruthy();
129
+ expect(go!.id).toMatch(/#go|go/); // selector is the item id
130
+
131
+ // Filtering narrows the snapshot client-side.
132
+ const filtered = await source.search("checkout", { messages: [], config: {} as never, signal: new AbortController().signal });
133
+ expect(filtered.some((i) => i.label.includes("Continue"))).toBe(true);
134
+
135
+ // resolve() reads the live element text at submit.
136
+ const payload = await source.resolve(go!, {
137
+ messages: [],
138
+ config: {} as never,
139
+ composerText: "",
140
+ args: "",
141
+ signal: new AbortController().signal,
142
+ });
143
+ expect(payload.llmAppend).toContain("Continue to checkout");
144
+ expect(payload.context).toMatchObject({ selector: go!.id });
145
+ });
146
+
147
+ it("applies mapItem to reshape surfaced items without breaking resolve", async () => {
148
+ document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
149
+ const source = createSmartDomMentionSource({
150
+ label: "Page",
151
+ ...JSDOM_OPTS,
152
+ mapItem: (el, defaultItem) => ({
153
+ ...defaultItem,
154
+ iconName: "star",
155
+ description: `custom:${el.tagName}`,
156
+ }),
157
+ });
158
+
159
+ const items = await source.search("", {
160
+ messages: [],
161
+ config: {} as never,
162
+ signal: new AbortController().signal,
163
+ });
164
+ const go = items.find((i) => i.label.includes("Continue"));
165
+ expect(go).toBeTruthy();
166
+ expect(go!.iconName).toBe("star");
167
+ expect(go!.description).toMatch(/^custom:/i);
168
+
169
+ // id stays the selector, so submit-time resolve still reads the live element.
170
+ const payload = await source.resolve(go!, {
171
+ messages: [],
172
+ config: {} as never,
173
+ composerText: "",
174
+ args: "",
175
+ signal: new AbortController().signal,
176
+ });
177
+ expect(payload.llmAppend).toContain("Continue to checkout");
178
+ });
179
+
119
180
  it("provider returns formatted context under the configured key", async () => {
120
181
  document.body.innerHTML = `<main><button id="go">Continue</button></main>`;
121
182
  const provider = createSmartDomReaderContextProvider({
@@ -132,4 +193,70 @@ describe("smart-dom-reader entry (jsdom)", () => {
132
193
  document.body.innerHTML = "";
133
194
  expect(collectSmartDomContext(JSDOM_OPTS)).toEqual([]);
134
195
  });
196
+
197
+ const searchCtx = () => ({
198
+ messages: [],
199
+ config: {} as never,
200
+ signal: new AbortController().signal,
201
+ });
202
+
203
+ it("reuses the snapshot across empty-query searches within the TTL (scans once)", async () => {
204
+ document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
205
+ const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
206
+
207
+ // Count extractions by observing DOM reads: a spy on querySelectorAll fires
208
+ // per scan. Simpler and TTL-independent: mutate the DOM between the two empty
209
+ // searches and assert the second still returns the pre-mutation snapshot.
210
+ const first = await source.search("", searchCtx());
211
+ expect(first.some((i) => i.label.includes("Continue"))).toBe(true);
212
+
213
+ // Change the page; a rescan would surface the new element.
214
+ document.body.innerHTML = `<main><button id="new">Brand new action</button></main>`;
215
+ const second = await source.search("", searchCtx());
216
+ // Still the cached snapshot: no rescan happened within the TTL.
217
+ expect(second.some((i) => i.label.includes("Continue"))).toBe(true);
218
+ expect(second.some((i) => i.label.includes("Brand new"))).toBe(false);
219
+ });
220
+
221
+ it("rescans on an empty query once the TTL has elapsed", async () => {
222
+ vi.useFakeTimers();
223
+ try {
224
+ document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
225
+ const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
226
+
227
+ const first = await source.search("", searchCtx());
228
+ expect(first.some((i) => i.label.includes("Continue"))).toBe(true);
229
+
230
+ // Mutate the page, then advance past the 2s TTL so the next empty query rescans.
231
+ document.body.innerHTML = `<main><button id="new">Brand new action</button></main>`;
232
+ vi.advanceTimersByTime(2500);
233
+
234
+ const second = await source.search("", searchCtx());
235
+ expect(second.some((i) => i.label.includes("Brand new"))).toBe(true);
236
+ expect(second.some((i) => i.label.includes("Continue"))).toBe(false);
237
+ } finally {
238
+ vi.useRealTimers();
239
+ }
240
+ });
241
+
242
+ it("resolves an item from a cached snapshot against the live element", async () => {
243
+ document.body.innerHTML = `<main><button id="go">Continue to checkout</button></main>`;
244
+ const source = createSmartDomMentionSource({ label: "Page", ...JSDOM_OPTS });
245
+
246
+ // Prime the snapshot, then re-search (cached) to get the item without rescanning.
247
+ await source.search("", searchCtx());
248
+ const items = await source.search("checkout", searchCtx());
249
+ const go = items.find((i) => i.label.includes("Continue"));
250
+ expect(go).toBeTruthy();
251
+
252
+ const payload = await source.resolve(go!, {
253
+ messages: [],
254
+ config: {} as never,
255
+ composerText: "",
256
+ args: "",
257
+ signal: new AbortController().signal,
258
+ });
259
+ expect(payload.llmAppend).toContain("Continue to checkout");
260
+ expect(payload.context).toMatchObject({ selector: go!.id });
261
+ });
135
262
  });
@@ -40,10 +40,16 @@ import {
40
40
  type SmartDomAdapterOptions
41
41
  } from "./utils/smart-dom-adapter";
42
42
  import { formatEnrichedContext, type EnrichedPageElement } from "./utils/dom-context";
43
- import type { AgentWidgetContextProvider } from "./types";
43
+ import { defaultMentionFilter } from "./utils/mention-matcher";
44
+ import type {
45
+ AgentWidgetContextProvider,
46
+ AgentWidgetContextMentionItem,
47
+ AgentWidgetContextMentionSource,
48
+ } from "./types";
44
49
 
45
50
  export { smartDomResultToEnriched };
46
51
  export type { SmartDomAdapterOptions } from "./utils/smart-dom-adapter";
52
+ export type { EnrichedPageElement } from "./utils/dom-context";
47
53
 
48
54
  /** Options for {@link collectSmartDomContext} and {@link createSmartDomReaderContextProvider}. */
49
55
  export interface SmartDomContextOptions extends SmartDomAdapterOptions {
@@ -132,3 +138,123 @@ export function createSmartDomReaderContextProvider(
132
138
  return { [contextKey]: formatEnrichedContext(elements) };
133
139
  };
134
140
  }
141
+
142
+ /** Options for {@link createSmartDomMentionSource}. */
143
+ export interface SmartDomMentionSourceOptions extends SmartDomContextOptions {
144
+ /** Source id. Default: "page". */
145
+ id?: string;
146
+ /** Group header shown in the menu. Default: "Page". */
147
+ label?: string;
148
+ /**
149
+ * Reshape each surfaced element into a mention item. Receives the raw
150
+ * {@link EnrichedPageElement} and the default-mapped item, so you can tweak
151
+ * one field (`{ ...defaultItem, iconName: "star" }`) or build from scratch.
152
+ * The returned item's `id` MUST stay a selector `resolve()` can read at submit
153
+ * (default: `el.selector`) unless you also override resolution. @default built-in mapping
154
+ */
155
+ mapItem?: (
156
+ el: EnrichedPageElement,
157
+ defaultItem: AgentWidgetContextMentionItem
158
+ ) => AgentWidgetContextMentionItem;
159
+ }
160
+
161
+ // Empty-query snapshot reuse window: the full Shadow-DOM-piercing scan is
162
+ // synchronous and runs inside the keystroke handler, so rescanning on every `@`
163
+ // / backspace-to-empty stalls the input. Reuse a snapshot this fresh; rescan
164
+ // past it so a menu opened after page changes still sees current content.
165
+ const SNAPSHOT_TTL_MS = 2000;
166
+
167
+ const iconForInteractivity = (kind: EnrichedPageElement["interactivity"]): string => {
168
+ switch (kind) {
169
+ case "clickable":
170
+ return "mouse-pointer-click";
171
+ case "input":
172
+ return "text-cursor-input";
173
+ case "navigable":
174
+ return "link";
175
+ default:
176
+ return "text";
177
+ }
178
+ };
179
+
180
+ const elementToMentionItem = (
181
+ el: EnrichedPageElement
182
+ ): AgentWidgetContextMentionItem => {
183
+ const ariaLabel = el.attributes["aria-label"];
184
+ const raw = (ariaLabel || el.text || el.tagName).trim();
185
+ const label = raw.length > 48 ? `${raw.slice(0, 47)}…` : raw || el.tagName;
186
+ const descParts = [el.role ?? el.tagName, el.interactivity].filter(Boolean);
187
+ return {
188
+ id: el.selector, // stable key + the selector resolve() reads at submit
189
+ label,
190
+ description: descParts.join(" · "),
191
+ iconName: iconForInteractivity(el.interactivity),
192
+ group: undefined,
193
+ };
194
+ };
195
+
196
+ /**
197
+ * First-class **supported** mention source backed by smart-dom-reader: surfaces
198
+ * visible page elements (Shadow-DOM-piercing) as mentionable items, resolving a
199
+ * fresh snapshot of the chosen element's text at SUBMIT (`resolveOn: "submit"`),
200
+ * since the page is time-sensitive. The element list is snapshotted when the
201
+ * menu opens (empty query) and filtered client-side with `defaultMentionFilter`
202
+ * as the user types.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * import { createSmartDomMentionSource } from "@runtypelabs/persona/smart-dom-reader";
207
+ *
208
+ * initAgentWidget({
209
+ * contextMentions: { enabled: true, sources: [createSmartDomMentionSource()] },
210
+ * });
211
+ * ```
212
+ */
213
+ export function createSmartDomMentionSource(
214
+ opts: SmartDomMentionSourceOptions = {}
215
+ ): AgentWidgetContextMentionSource {
216
+ const id = opts.id ?? "page";
217
+ const label = opts.label ?? "Page";
218
+ let snapshot: AgentWidgetContextMentionItem[] | null = null;
219
+ let capturedAt = 0;
220
+
221
+ const rescan = (): AgentWidgetContextMentionItem[] => {
222
+ const items = collectSmartDomContext(opts).map((el) => {
223
+ const defaultItem = elementToMentionItem(el);
224
+ return opts.mapItem ? opts.mapItem(el, defaultItem) : defaultItem;
225
+ });
226
+ capturedAt = Date.now();
227
+ return items;
228
+ };
229
+
230
+ return {
231
+ id,
232
+ label,
233
+ resolveOn: "submit",
234
+ search: (query) => {
235
+ // Rescan on empty query only when there's no snapshot or it's staler than
236
+ // the TTL; reopening the menu quickly reuses the snapshot, keystrokes always
237
+ // filter the existing one, so we never re-scan mid-typing.
238
+ const stale = !snapshot || Date.now() - capturedAt > SNAPSHOT_TTL_MS;
239
+ if (!snapshot || (query === "" && stale)) {
240
+ snapshot = rescan();
241
+ }
242
+ return defaultMentionFilter(snapshot, query);
243
+ },
244
+ resolve: (item) => {
245
+ const doc =
246
+ opts.document ?? (typeof document !== "undefined" ? document : undefined);
247
+ let text = "";
248
+ try {
249
+ const el = doc?.querySelector(item.id);
250
+ text = el?.textContent?.trim() ?? "";
251
+ } catch {
252
+ /* invalid selector at resolve time — fall through to label only */
253
+ }
254
+ return {
255
+ llmAppend: `Page element "${item.label}" (${item.id}):\n${text || "(no text)"}`,
256
+ context: { selector: item.id },
257
+ };
258
+ },
259
+ };
260
+ }