@runtypelabs/persona 4.8.0 → 4.10.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 (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AgentWidgetSession } from "./session";
3
+ import type {
4
+ AgentWidgetMessage,
5
+ AgentWidgetRequestPayload,
6
+ ContentPart,
7
+ } from "./types";
8
+ import type { MentionSubmitBundle } from "./context-mentions-entry";
9
+
10
+ /** A closed, empty SSE stream so `dispatch` completes with no events. */
11
+ function closedStream(): ReadableStream<Uint8Array> {
12
+ return new ReadableStream({ start: (c) => c.close() });
13
+ }
14
+
15
+ /**
16
+ * Regression coverage for the mention/command submit path:
17
+ * - an empty-text, chip-only submit must NOT render the "[Image]" fallback
18
+ * (that fallback is for image-only turns);
19
+ * - a `command: "server"` mention's `resolve().context` must ride to the
20
+ * backend under request `context.mentions` (the c1 dispatch channel).
21
+ */
22
+ describe("AgentWidgetSession — mention/command submit", () => {
23
+ function makeSession(captured: { payload?: AgentWidgetRequestPayload }) {
24
+ let messages: AgentWidgetMessage[] = [];
25
+ let status = "idle";
26
+ let streaming = false;
27
+ const session = new AgentWidgetSession(
28
+ {
29
+ apiUrl: "http://localhost:8000",
30
+ customFetch: async (_url, _init, payload) => {
31
+ captured.payload = payload;
32
+ return { ok: true, body: closedStream() } as unknown as Response;
33
+ },
34
+ },
35
+ {
36
+ onMessagesChanged: (m) => {
37
+ messages = m;
38
+ },
39
+ onStatusChanged: (s) => {
40
+ status = s;
41
+ },
42
+ onStreamingChanged: (s) => {
43
+ streaming = s;
44
+ },
45
+ onError: () => {},
46
+ }
47
+ );
48
+ return {
49
+ session,
50
+ user: () => messages.find((m) => m.role === "user"),
51
+ status: () => status,
52
+ streaming: () => streaming,
53
+ };
54
+ }
55
+
56
+ it("renders no [Image] fallback for a command-only submit and forwards context.mentions", async () => {
57
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
58
+ const { session, user } = makeSession(captured);
59
+
60
+ // A server skill: `/lookup 1042` resolved to structured context, namespaced
61
+ // `{ [sourceId]: { [itemId]: context } }` exactly as the manager builds it.
62
+ const context = { commands: { lookup: { intent: "lookup-order", orderId: "1042" } } };
63
+ const refs = [{ sourceId: "commands", itemId: "lookup", label: "lookup" }];
64
+ const bundle: MentionSubmitBundle = {
65
+ blocks: [],
66
+ contentParts: [],
67
+ context,
68
+ };
69
+
70
+ await session.sendMessage("", {
71
+ mentions: {
72
+ refs,
73
+ finalize: async () => bundle,
74
+ },
75
+ });
76
+
77
+ const msg = user();
78
+ expect(msg).toBeDefined();
79
+ // The bug: an empty-text, mention-only submit used to display "[Image]".
80
+ expect(msg!.content).toBe("");
81
+ expect(msg!.content).not.toBe("[Image]");
82
+ // The command chip echoes immediately as a ref.
83
+ expect(msg!.contextMentions).toEqual(refs);
84
+ // Structured server-command data is merged onto the message…
85
+ expect(msg!.mentionContext).toEqual(context);
86
+ // …and rides to the backend under request `context.mentions`.
87
+ expect(captured.payload?.context).toMatchObject({ mentions: context });
88
+ });
89
+
90
+ it("merges resolved @-mention llmContent into the dispatched snapshot", async () => {
91
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
92
+ const { session } = makeSession(captured);
93
+
94
+ await session.sendMessage("summarize this", {
95
+ mentions: {
96
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
97
+ finalize: async (): Promise<MentionSubmitBundle> => ({
98
+ blocks: ["```App.tsx\nFILE BODY\n```"],
99
+ contentParts: [],
100
+ context: {},
101
+ }),
102
+ },
103
+ });
104
+
105
+ const dispatched = (captured.payload?.messages ?? []).find(
106
+ (m) => m.role === "user"
107
+ );
108
+ // The client collapses `llmContent` into the payload `content` field via the
109
+ // content-priority chain, so the model must see the resolved file body
110
+ // appended to the typed prose — not just the raw "summarize this".
111
+ const sent = dispatched?.content;
112
+ expect(typeof sent).toBe("string");
113
+ expect(sent as string).toContain("FILE BODY");
114
+ expect(sent as string).toContain("summarize this");
115
+ // Mention blocks lead; the typed prose lands last (joined by a blank line).
116
+ expect(sent as string).toBe("```App.tsx\nFILE BODY\n```\n\nsummarize this");
117
+ });
118
+
119
+ it("still renders the [Image] fallback for an image-only submit", async () => {
120
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
121
+ const { session, user } = makeSession(captured);
122
+
123
+ const contentParts: ContentPart[] = [
124
+ { type: "image", image: "data:image/png;base64,abc123", mimeType: "image/png" },
125
+ ];
126
+ await session.sendMessage("", { contentParts });
127
+
128
+ expect(user()!.content).toBe("[Image]");
129
+ });
130
+
131
+ it("cancel() during a pending mention finalize aborts the turn without dispatching", async () => {
132
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
133
+ const { session, status, streaming } = makeSession(captured);
134
+
135
+ let releaseFinalize!: (bundle: MentionSubmitBundle) => void;
136
+ const pending = new Promise<MentionSubmitBundle>((resolve) => {
137
+ releaseFinalize = resolve;
138
+ });
139
+
140
+ const sent = session.sendMessage("summarize this", {
141
+ mentions: {
142
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
143
+ finalize: () => pending,
144
+ },
145
+ });
146
+
147
+ // Stop mid-resolution, then let finalize settle: dispatch must not fire.
148
+ session.cancel();
149
+ releaseFinalize({ blocks: ["```App.tsx\nBODY\n```"], contentParts: [], context: {} });
150
+ await sent;
151
+
152
+ expect(captured.payload).toBeUndefined();
153
+ expect(status()).toBe("idle");
154
+ expect(streaming()).toBe(false);
155
+ });
156
+
157
+ it("dispatches a normal mention submit once finalize resolves", async () => {
158
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
159
+ const { session, streaming } = makeSession(captured);
160
+
161
+ await session.sendMessage("summarize this", {
162
+ mentions: {
163
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
164
+ finalize: async (): Promise<MentionSubmitBundle> => ({
165
+ blocks: ["```App.tsx\nBODY\n```"],
166
+ contentParts: [],
167
+ context: {},
168
+ }),
169
+ },
170
+ });
171
+
172
+ expect(captured.payload).toBeDefined();
173
+ expect(streaming()).toBe(false);
174
+ });
175
+ });
@@ -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,7 +27,7 @@ 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
31
  import {
32
32
  buildArtifactRefRawContent,
33
33
  resolveArtifactDisplayMode
@@ -37,8 +37,11 @@ import type {
37
37
  VoiceStatus,
38
38
  VoiceConfig,
39
39
  ReadAloudState,
40
- SpeechEngine
40
+ SpeechEngine,
41
+ AgentWidgetContentSegment,
42
+ AgentWidgetContextMentionRef
41
43
  } from "./types";
44
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
42
45
  import {
43
46
  createVoiceProvider,
44
47
  isVoiceSupported,
@@ -1129,17 +1132,92 @@ export class AgentWidgetSession {
1129
1132
  });
1130
1133
  }
1131
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
+
1132
1188
  public async sendMessage(
1133
1189
  rawInput: string,
1134
1190
  options?: {
1135
1191
  viaVoice?: boolean;
1136
1192
  /** Multi-modal content parts (e.g., images) to include with the message */
1137
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[];
1138
1211
  }
1139
1212
  ) {
1140
1213
  const input = rawInput.trim();
1141
- // Allow sending if there's text OR attachments
1142
- 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;
1143
1221
 
1144
1222
  this.stopSpeaking();
1145
1223
  this.abortController?.abort();
@@ -1160,25 +1238,63 @@ export class AgentWidgetSession {
1160
1238
  // the proxy path auto-generates a different id than `assistantMessageId`.
1161
1239
  this.activeAssistantMessageId = null;
1162
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
+
1163
1249
  const userMessage: AgentWidgetMessage = {
1164
1250
  id: userMessageId,
1165
1251
  role: "user",
1166
- content: input || IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, // Display text (fallback if only images)
1252
+ content: input || imageOnlyFallback, // Display text (fallback if only images)
1167
1253
  createdAt: new Date().toISOString(),
1168
1254
  sequence: this.nextSequence(),
1169
1255
  viaVoice: options?.viaVoice || false,
1170
1256
  // Include contentParts if provided (for multi-modal messages)
1171
1257
  ...(options?.contentParts && options.contentParts.length > 0 && {
1172
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
1173
1267
  })
1174
1268
  };
1175
1269
 
1176
1270
  this.appendMessage(userMessage);
1177
1271
  this.setStreaming(true);
1178
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.
1179
1276
  const controller = new AbortController();
1180
1277
  this.abortController = controller;
1181
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
+
1182
1298
  const snapshot = [...this.messages];
1183
1299
 
1184
1300
  try {
@@ -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
  });