@runtypelabs/persona 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
package/src/session.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
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
|
});
|
package/src/smart-dom-reader.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|