@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.
- package/README.md +14 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- 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 +6 -6
- package/dist/codegen.js +8 -8
- 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 +72 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +634 -3
- package/dist/index.d.ts +634 -3
- package/dist/index.global.js +59 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +64 -56
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- 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/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +236 -57
- package/dist/theme-editor-preview.d.cts +485 -1
- package/dist/theme-editor-preview.d.ts +485 -1
- package/dist/theme-editor-preview.js +53 -47
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- 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/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/index-core.ts +27 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- 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 +243 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +542 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +379 -84
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -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/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -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/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/theme.test.ts +54 -4
- package/src/utils/theme.ts +6 -3
- package/src/utils/tokens.ts +27 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -62,6 +62,16 @@
|
|
|
62
62
|
"import": "./dist/animations/wipe.js",
|
|
63
63
|
"require": "./dist/animations/wipe.cjs"
|
|
64
64
|
},
|
|
65
|
+
"./context-mentions": {
|
|
66
|
+
"types": "./dist/context-mentions.d.ts",
|
|
67
|
+
"import": "./dist/context-mentions.js",
|
|
68
|
+
"require": "./dist/context-mentions.cjs"
|
|
69
|
+
},
|
|
70
|
+
"./context-mentions-inline": {
|
|
71
|
+
"types": "./dist/context-mentions-inline.d.ts",
|
|
72
|
+
"import": "./dist/context-mentions-inline.js",
|
|
73
|
+
"require": "./dist/context-mentions-inline.cjs"
|
|
74
|
+
},
|
|
65
75
|
"./widget.css": "./dist/widget.css"
|
|
66
76
|
},
|
|
67
77
|
"files": [
|
|
@@ -120,7 +130,7 @@
|
|
|
120
130
|
"access": "public"
|
|
121
131
|
},
|
|
122
132
|
"scripts": {
|
|
123
|
-
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations && pnpm run check:dist-vite8",
|
|
133
|
+
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:context-mentions && pnpm run build:context-mentions-inline && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations && pnpm run check:dist-vite8",
|
|
124
134
|
"check:dist-vite8": "node scripts/check-dist-no-in-for-init.mjs",
|
|
125
135
|
"build:markdown-parsers": "tsup --config tsup.markdown-parsers.config.ts",
|
|
126
136
|
"build:plugin-kit": "tsup src/plugin-kit.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
@@ -133,8 +143,10 @@
|
|
|
133
143
|
"build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
|
|
134
144
|
"build:codegen": "tsup src/codegen.ts --format esm,cjs --minify --dts",
|
|
135
145
|
"build:styles": "node scripts/build-styles.mjs",
|
|
136
|
-
"build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" && tsup --config tsup.global.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
|
|
146
|
+
"build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" --external @runtypelabs/persona/context-mentions --external @runtypelabs/persona/context-mentions-inline && tsup --config tsup.global.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
|
|
137
147
|
"build:webmcp-polyfill": "tsup --config tsup.webmcp-polyfill.config.ts",
|
|
148
|
+
"build:context-mentions": "tsup --config tsup.context-mentions.config.ts",
|
|
149
|
+
"build:context-mentions-inline": "tsup --config tsup.context-mentions-inline.config.ts",
|
|
138
150
|
"build:runtype-tts": "tsup --config tsup.runtype-tts.config.ts",
|
|
139
151
|
"build:installer": "tsup src/install.ts --format iife --global-name SiteAgentInstaller --out-dir dist --minify --sourcemap --no-splitting",
|
|
140
152
|
"build:launcher": "tsup src/launcher-global.ts --format iife --global-name AgentWidgetLauncher --minify --sourcemap --splitting false --out-dir dist && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/launcher-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/launcher'+ext);}\"",
|
package/src/client.test.ts
CHANGED
|
@@ -4542,3 +4542,72 @@ describe('AgentWidgetClient - Artifact display modes', () => {
|
|
|
4542
4542
|
expect(props.markdown).toBe('# Hello');
|
|
4543
4543
|
});
|
|
4544
4544
|
});
|
|
4545
|
+
|
|
4546
|
+
describe('AgentWidgetClient - mention context', () => {
|
|
4547
|
+
const agentDone = (controller: ReadableStreamDefaultController, encoder: TextEncoder) => {
|
|
4548
|
+
controller.enqueue(encoder.encode(sseEvent('agent_complete', {
|
|
4549
|
+
executionId: 'exec_1',
|
|
4550
|
+
agentId: 'agent_123',
|
|
4551
|
+
success: true,
|
|
4552
|
+
iterations: 1,
|
|
4553
|
+
completedAt: new Date().toISOString(),
|
|
4554
|
+
seq: 1,
|
|
4555
|
+
})));
|
|
4556
|
+
controller.close();
|
|
4557
|
+
};
|
|
4558
|
+
|
|
4559
|
+
const captureContext = () => {
|
|
4560
|
+
let capturedPayload: any = null;
|
|
4561
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, options: any) => {
|
|
4562
|
+
capturedPayload = JSON.parse(options.body);
|
|
4563
|
+
const encoder = new TextEncoder();
|
|
4564
|
+
return {
|
|
4565
|
+
ok: true,
|
|
4566
|
+
body: new ReadableStream({ start(c) { agentDone(c, encoder); } }),
|
|
4567
|
+
};
|
|
4568
|
+
});
|
|
4569
|
+
return () => capturedPayload;
|
|
4570
|
+
};
|
|
4571
|
+
|
|
4572
|
+
it('attaches the latest user turn mention context under context.mentions', async () => {
|
|
4573
|
+
const get = captureContext();
|
|
4574
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000', agentId: 'agent_123' });
|
|
4575
|
+
|
|
4576
|
+
await client.dispatch({
|
|
4577
|
+
messages: [{
|
|
4578
|
+
id: 'u1', role: 'user', content: 'Summarize this',
|
|
4579
|
+
createdAt: '2025-01-01T00:00:00.000Z',
|
|
4580
|
+
mentionContext: { files: { app: { path: 'App.tsx' } } },
|
|
4581
|
+
} as AgentWidgetMessage],
|
|
4582
|
+
}, () => {});
|
|
4583
|
+
|
|
4584
|
+
expect(get().context).toEqual({ mentions: { files: { app: { path: 'App.tsx' } } } });
|
|
4585
|
+
});
|
|
4586
|
+
|
|
4587
|
+
it('does NOT re-attach an older turn mention context on a later send', async () => {
|
|
4588
|
+
const get = captureContext();
|
|
4589
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000', agentId: 'agent_123' });
|
|
4590
|
+
|
|
4591
|
+
await client.dispatch({
|
|
4592
|
+
messages: [
|
|
4593
|
+
{
|
|
4594
|
+
id: 'u1', role: 'user', content: 'Summarize this',
|
|
4595
|
+
createdAt: '2025-01-01T00:00:00.000Z',
|
|
4596
|
+
mentionContext: { files: { app: { path: 'App.tsx' } } },
|
|
4597
|
+
} as AgentWidgetMessage,
|
|
4598
|
+
{
|
|
4599
|
+
id: 'a1', role: 'assistant', content: 'Done',
|
|
4600
|
+
createdAt: '2025-01-01T00:00:01.000Z',
|
|
4601
|
+
} as AgentWidgetMessage,
|
|
4602
|
+
{
|
|
4603
|
+
id: 'u2', role: 'user', content: 'Now a plain follow-up',
|
|
4604
|
+
createdAt: '2025-01-01T00:00:02.000Z',
|
|
4605
|
+
} as AgentWidgetMessage,
|
|
4606
|
+
],
|
|
4607
|
+
}, () => {});
|
|
4608
|
+
|
|
4609
|
+
// The most recent user turn (u2) carried no mentionContext, so the stale
|
|
4610
|
+
// context from u1 must not leak into this request.
|
|
4611
|
+
expect(get().context).toBeUndefined();
|
|
4612
|
+
});
|
|
4613
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -1182,6 +1182,66 @@ export class AgentWidgetClient {
|
|
|
1182
1182
|
});
|
|
1183
1183
|
}
|
|
1184
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* The opt-in structured mention channel: the MOST RECENT user turn's
|
|
1187
|
+
* `mentionContext` (set by `session.applyMentionBundle`) namespaced under
|
|
1188
|
+
* `mentions`. Only the latest user message is consulted — otherwise an older
|
|
1189
|
+
* turn's mentions would re-attach to `context.mentions` on every later send.
|
|
1190
|
+
* Returns null when the latest user turn carried no structured mention context.
|
|
1191
|
+
* The default model-visible path (`llmAppend`) already rode into the message's
|
|
1192
|
+
* `llmContent`/`contentParts`, so it needs nothing here.
|
|
1193
|
+
*/
|
|
1194
|
+
private latestMentionContext(
|
|
1195
|
+
messages: AgentWidgetMessage[]
|
|
1196
|
+
): Record<string, unknown> | null {
|
|
1197
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1198
|
+
const m = messages[i];
|
|
1199
|
+
if (m.role !== "user") continue;
|
|
1200
|
+
// First user message from the end IS the latest user turn: use its
|
|
1201
|
+
// mention context if any, and never look further back.
|
|
1202
|
+
if (m.mentionContext && Object.keys(m.mentionContext).length > 0) {
|
|
1203
|
+
return { mentions: m.mentionContext };
|
|
1204
|
+
}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Aggregate request `context`: merge every context provider's result with the
|
|
1212
|
+
* opt-in mention context from the latest user turn. Returns null when nothing
|
|
1213
|
+
* contributed, so callers can skip setting `payload.context`. Shared by the
|
|
1214
|
+
* agent and flow payload builders.
|
|
1215
|
+
*/
|
|
1216
|
+
private async buildContextAggregate(
|
|
1217
|
+
messages: AgentWidgetMessage[]
|
|
1218
|
+
): Promise<Record<string, unknown> | null> {
|
|
1219
|
+
const contextAggregate: Record<string, unknown> = {};
|
|
1220
|
+
if (this.contextProviders.length) {
|
|
1221
|
+
await Promise.all(
|
|
1222
|
+
this.contextProviders.map(async (provider) => {
|
|
1223
|
+
try {
|
|
1224
|
+
const result = await provider({
|
|
1225
|
+
messages,
|
|
1226
|
+
config: this.config
|
|
1227
|
+
});
|
|
1228
|
+
if (result && typeof result === "object") {
|
|
1229
|
+
Object.assign(contextAggregate, result);
|
|
1230
|
+
}
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
if (typeof console !== "undefined") {
|
|
1233
|
+
// eslint-disable-next-line no-console
|
|
1234
|
+
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
const mentionContext = this.latestMentionContext(messages);
|
|
1241
|
+
if (mentionContext) Object.assign(contextAggregate, mentionContext);
|
|
1242
|
+
return Object.keys(contextAggregate).length ? contextAggregate : null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1185
1245
|
private async buildAgentPayload(
|
|
1186
1246
|
messages: AgentWidgetMessage[]
|
|
1187
1247
|
): Promise<AgentWidgetAgentRequestPayload> {
|
|
@@ -1231,32 +1291,9 @@ export class AgentWidgetClient {
|
|
|
1231
1291
|
payload.clientTools = clientTools;
|
|
1232
1292
|
}
|
|
1233
1293
|
|
|
1234
|
-
// Add context from providers
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
await Promise.all(
|
|
1238
|
-
this.contextProviders.map(async (provider) => {
|
|
1239
|
-
try {
|
|
1240
|
-
const result = await provider({
|
|
1241
|
-
messages,
|
|
1242
|
-
config: this.config
|
|
1243
|
-
});
|
|
1244
|
-
if (result && typeof result === "object") {
|
|
1245
|
-
Object.assign(contextAggregate, result);
|
|
1246
|
-
}
|
|
1247
|
-
} catch (error) {
|
|
1248
|
-
if (typeof console !== "undefined") {
|
|
1249
|
-
// eslint-disable-next-line no-console
|
|
1250
|
-
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
})
|
|
1254
|
-
);
|
|
1255
|
-
|
|
1256
|
-
if (Object.keys(contextAggregate).length) {
|
|
1257
|
-
payload.context = contextAggregate;
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1294
|
+
// Add context from providers + opt-in mention context.
|
|
1295
|
+
const contextAggregate = await this.buildContextAggregate(messages);
|
|
1296
|
+
if (contextAggregate) payload.context = contextAggregate;
|
|
1260
1297
|
|
|
1261
1298
|
return payload;
|
|
1262
1299
|
}
|
|
@@ -1311,31 +1348,8 @@ export class AgentWidgetClient {
|
|
|
1311
1348
|
payload.clientTools = clientTools;
|
|
1312
1349
|
}
|
|
1313
1350
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
await Promise.all(
|
|
1317
|
-
this.contextProviders.map(async (provider) => {
|
|
1318
|
-
try {
|
|
1319
|
-
const result = await provider({
|
|
1320
|
-
messages,
|
|
1321
|
-
config: this.config
|
|
1322
|
-
});
|
|
1323
|
-
if (result && typeof result === "object") {
|
|
1324
|
-
Object.assign(contextAggregate, result);
|
|
1325
|
-
}
|
|
1326
|
-
} catch (error) {
|
|
1327
|
-
if (typeof console !== "undefined") {
|
|
1328
|
-
// eslint-disable-next-line no-console
|
|
1329
|
-
console.warn("[AgentWidget] Context provider failed:", error);
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
})
|
|
1333
|
-
);
|
|
1334
|
-
|
|
1335
|
-
if (Object.keys(contextAggregate).length) {
|
|
1336
|
-
payload.context = contextAggregate;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1351
|
+
const contextAggregate = await this.buildContextAggregate(messages);
|
|
1352
|
+
if (contextAggregate) payload.context = contextAggregate;
|
|
1339
1353
|
|
|
1340
1354
|
if (this.requestMiddleware) {
|
|
1341
1355
|
try {
|
|
@@ -436,6 +436,37 @@ describe("artifact-pane expand toggle", () => {
|
|
|
436
436
|
expect(btn.title).toBe("Expand artifacts panel");
|
|
437
437
|
expect(btn.querySelector("svg")).toBeTruthy();
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
it("resets content scroll after expanded-state transitions only", () => {
|
|
441
|
+
const frames: Array<(time: number) => void> = [];
|
|
442
|
+
vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
|
|
443
|
+
frames.push(callback);
|
|
444
|
+
return frames.length;
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
const pane = createArtifactPane(makeExpandConfig(), { onSelect: () => {} });
|
|
448
|
+
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
|
|
449
|
+
const content = contentEl(pane);
|
|
450
|
+
|
|
451
|
+
content.scrollLeft = 80;
|
|
452
|
+
pane.setExpanded(true);
|
|
453
|
+
expect(frames).toHaveLength(1);
|
|
454
|
+
frames.shift()?.(0);
|
|
455
|
+
expect(content.scrollLeft).toBe(0);
|
|
456
|
+
|
|
457
|
+
content.scrollLeft = 80;
|
|
458
|
+
pane.setExpanded(true);
|
|
459
|
+
expect(frames).toHaveLength(0);
|
|
460
|
+
expect(content.scrollLeft).toBe(80);
|
|
461
|
+
|
|
462
|
+
pane.setExpanded(false);
|
|
463
|
+
expect(frames).toHaveLength(1);
|
|
464
|
+
frames.shift()?.(0);
|
|
465
|
+
expect(content.scrollLeft).toBe(0);
|
|
466
|
+
} finally {
|
|
467
|
+
vi.unstubAllGlobals();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
439
470
|
});
|
|
440
471
|
|
|
441
472
|
const makeCopyButtonConfig = (): AgentWidgetConfig =>
|
|
@@ -698,6 +729,22 @@ describe("artifact-pane tabs", () => {
|
|
|
698
729
|
Element.prototype.scrollIntoView = orig;
|
|
699
730
|
}
|
|
700
731
|
});
|
|
732
|
+
|
|
733
|
+
it("resets content scroll only when the selected artifact changes", () => {
|
|
734
|
+
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
|
|
735
|
+
const [a1, a2] = twoFileRecords();
|
|
736
|
+
|
|
737
|
+
pane.update({ artifacts: [a1], selectedId: "a1" });
|
|
738
|
+
const content = contentEl(pane);
|
|
739
|
+
content.scrollLeft = 80;
|
|
740
|
+
|
|
741
|
+
pane.update({ artifacts: [a1, a2], selectedId: "a2" });
|
|
742
|
+
expect(content.scrollLeft).toBe(0);
|
|
743
|
+
|
|
744
|
+
content.scrollLeft = 80;
|
|
745
|
+
pane.update({ artifacts: [a1, a2], selectedId: "a2" });
|
|
746
|
+
expect(content.scrollLeft).toBe(80);
|
|
747
|
+
});
|
|
701
748
|
});
|
|
702
749
|
|
|
703
750
|
const listIn = (pane: ReturnType<typeof createArtifactPane>): HTMLElement =>
|
|
@@ -421,6 +421,21 @@ export function createArtifactPane(
|
|
|
421
421
|
"div",
|
|
422
422
|
"persona-artifact-content persona-flex-1 persona-min-h-0 persona-overflow-y-auto persona-p-3"
|
|
423
423
|
);
|
|
424
|
+
const resetContentInlineScroll = () => {
|
|
425
|
+
// The shared body survives artifact and layout changes. Always return it to
|
|
426
|
+
// inline start at those lifecycle boundaries so a wide previous preview
|
|
427
|
+
// cannot leave the next one clipped by a stale horizontal offset.
|
|
428
|
+
content.scrollLeft = 0;
|
|
429
|
+
};
|
|
430
|
+
const scheduleContentInlineScrollReset = () => {
|
|
431
|
+
// ui.ts applies the expanded root class immediately before setExpanded().
|
|
432
|
+
// Wait one frame so the new pane width has taken effect before resetting.
|
|
433
|
+
if (typeof requestAnimationFrame === "function") {
|
|
434
|
+
requestAnimationFrame(resetContentInlineScroll);
|
|
435
|
+
} else {
|
|
436
|
+
setTimeout(resetContentInlineScroll, 0);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
424
439
|
if (panePadding) {
|
|
425
440
|
for (const el of [list, customBarMount]) {
|
|
426
441
|
el.style.paddingLeft = panePadding;
|
|
@@ -437,6 +452,7 @@ export function createArtifactPane(
|
|
|
437
452
|
let records: PersonaArtifactRecord[] = [];
|
|
438
453
|
let selectedId: string | null = null;
|
|
439
454
|
let mobileOpen = false;
|
|
455
|
+
let expandedState = false;
|
|
440
456
|
// Lazy-render gate. `visible` reflects the host's explicit setVisible() signal
|
|
441
457
|
// (never inferred from layout/offsetParent, so class-hiding + jsdom behave the
|
|
442
458
|
// same). It defaults to true so the pane renders eagerly when constructed
|
|
@@ -682,11 +698,13 @@ export function createArtifactPane(
|
|
|
682
698
|
element: shell,
|
|
683
699
|
backdrop,
|
|
684
700
|
update(state: { artifacts: PersonaArtifactRecord[]; selectedId: string | null }) {
|
|
685
|
-
|
|
686
|
-
selectedId =
|
|
701
|
+
const nextSelectedId =
|
|
687
702
|
state.selectedId ??
|
|
688
703
|
state.artifacts[state.artifacts.length - 1]?.id ??
|
|
689
704
|
null;
|
|
705
|
+
const selectionChanged = nextSelectedId !== selectedId;
|
|
706
|
+
records = state.artifacts;
|
|
707
|
+
selectedId = nextSelectedId;
|
|
690
708
|
if (records.length > 0) {
|
|
691
709
|
mobileOpen = true;
|
|
692
710
|
}
|
|
@@ -696,6 +714,7 @@ export function createArtifactPane(
|
|
|
696
714
|
// executing artifact scripts twice. Defer the whole render() to reveal.
|
|
697
715
|
dirty = true;
|
|
698
716
|
if (visible) flush();
|
|
717
|
+
if (selectionChanged) resetContentInlineScroll();
|
|
699
718
|
},
|
|
700
719
|
setMobileOpen(open: boolean) {
|
|
701
720
|
mobileOpen = open;
|
|
@@ -707,6 +726,10 @@ export function createArtifactPane(
|
|
|
707
726
|
}
|
|
708
727
|
},
|
|
709
728
|
setExpanded(expanded: boolean) {
|
|
729
|
+
if (expanded !== expandedState) {
|
|
730
|
+
expandedState = expanded;
|
|
731
|
+
scheduleContentInlineScrollReset();
|
|
732
|
+
}
|
|
710
733
|
// Swap the icon (the state signal) and update the accessible label. We
|
|
711
734
|
// deliberately avoid aria-pressed here: the icon-btn [aria-pressed] CSS
|
|
712
735
|
// would add unwanted active styling.
|
|
@@ -341,27 +341,18 @@ export const createAttachmentControls = (config?: AgentWidgetConfig): Attachment
|
|
|
341
341
|
"aria-label": attachmentsConfig.buttonTooltipText ?? "Attach file",
|
|
342
342
|
},
|
|
343
343
|
style: {
|
|
344
|
+
// Appearance is themed from the CSS rule for `.persona-attachment-button`
|
|
345
|
+
// via the `--persona-button-ghost-*` tokens (components.button.ghost).
|
|
346
|
+
// Only config-driven sizing is inline.
|
|
344
347
|
width: attachIconSize,
|
|
345
348
|
height: attachIconSize,
|
|
346
349
|
minWidth: attachIconSize,
|
|
347
350
|
minHeight: attachIconSize,
|
|
348
351
|
fontSize: "18px",
|
|
349
352
|
lineHeight: "1",
|
|
350
|
-
backgroundColor: "transparent",
|
|
351
|
-
color: "var(--persona-primary, #111827)",
|
|
352
|
-
border: "none",
|
|
353
|
-
borderRadius: "6px",
|
|
354
|
-
transition: "background-color 0.15s ease",
|
|
355
353
|
},
|
|
356
354
|
});
|
|
357
355
|
|
|
358
|
-
button.addEventListener("mouseenter", () => {
|
|
359
|
-
button.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
|
|
360
|
-
});
|
|
361
|
-
button.addEventListener("mouseleave", () => {
|
|
362
|
-
button.style.backgroundColor = "transparent";
|
|
363
|
-
});
|
|
364
|
-
|
|
365
356
|
const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
|
|
366
357
|
if (attachIconSvg) {
|
|
367
358
|
button.appendChild(attachIconSvg);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { createMentionButton } from "./context-mention-button";
|
|
5
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
6
|
+
import type { AgentWidgetContextMentionConfig } from "../types";
|
|
7
|
+
|
|
8
|
+
// Icon rendered inside the default 40px button: iconSize = round(40 * 0.6) = 24,
|
|
9
|
+
// strokeWidth 1.5 (see createMentionButton). Compare against the registry's own
|
|
10
|
+
// output so the assertion tracks lucide glyph changes instead of hard-coding SVG.
|
|
11
|
+
const refIcon = (name: string) =>
|
|
12
|
+
renderLucideIcon(name, 24, "currentColor", 1.5)!.innerHTML;
|
|
13
|
+
|
|
14
|
+
const config = (
|
|
15
|
+
overrides: Partial<AgentWidgetContextMentionConfig> = {}
|
|
16
|
+
): AgentWidgetContextMentionConfig => ({ enabled: true, sources: [], ...overrides });
|
|
17
|
+
|
|
18
|
+
describe("createMentionButton", () => {
|
|
19
|
+
it("invokes onOpen on click and labels itself from config", () => {
|
|
20
|
+
const onOpen = vi.fn();
|
|
21
|
+
const { button, wrapper } = createMentionButton({
|
|
22
|
+
config: config({ buttonTooltipText: "Add context" }),
|
|
23
|
+
onOpen,
|
|
24
|
+
});
|
|
25
|
+
expect(button.getAttribute("aria-label")).toBe("Add context");
|
|
26
|
+
expect(wrapper.querySelector(".persona-send-button-tooltip")?.textContent).toBe(
|
|
27
|
+
"Add context"
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
31
|
+
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("defaults to a '+' add-context icon, not an '@' glyph", () => {
|
|
35
|
+
const { button } = createMentionButton({ config: config(), onOpen: vi.fn() });
|
|
36
|
+
const svg = button.querySelector("svg");
|
|
37
|
+
expect(svg).not.toBeNull();
|
|
38
|
+
// "@" reads as power-user; the consumer-recognized affordance is "+".
|
|
39
|
+
expect(svg!.innerHTML).toBe(refIcon("plus"));
|
|
40
|
+
expect(svg!.innerHTML).not.toBe(refIcon("at-sign"));
|
|
41
|
+
expect(button.textContent).not.toContain("@");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("honors a buttonIconName override for power-user surfaces", () => {
|
|
45
|
+
const { button } = createMentionButton({
|
|
46
|
+
config: config({ buttonIconName: "at-sign" }),
|
|
47
|
+
onOpen: vi.fn(),
|
|
48
|
+
});
|
|
49
|
+
expect(button.querySelector("svg")!.innerHTML).toBe(refIcon("at-sign"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stops the click from bubbling so the composer's focus-textarea handler never runs", () => {
|
|
53
|
+
const parent = document.createElement("form");
|
|
54
|
+
const parentClick = vi.fn();
|
|
55
|
+
parent.addEventListener("click", parentClick);
|
|
56
|
+
document.body.appendChild(parent);
|
|
57
|
+
|
|
58
|
+
const { wrapper } = createMentionButton({ config: config(), onOpen: vi.fn() });
|
|
59
|
+
parent.appendChild(wrapper);
|
|
60
|
+
|
|
61
|
+
wrapper
|
|
62
|
+
.querySelector("button")!
|
|
63
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
64
|
+
|
|
65
|
+
// The button's handler calls stopPropagation, so the parent (which stands in
|
|
66
|
+
// for the composer form's "click anywhere → focus textarea" listener) is
|
|
67
|
+
// never reached — the picker's search field keeps focus.
|
|
68
|
+
expect(parentClick).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createElement, createNode } from "../utils/dom";
|
|
2
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
3
|
+
import type { AgentWidgetContextMentionConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface MentionButtonParts {
|
|
6
|
+
button: HTMLButtonElement;
|
|
7
|
+
wrapper: HTMLElement;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The visible composer affordance ("add context") — the discoverable entry
|
|
12
|
+
* point that opens the mention menu. Lives in the CORE bundle (not the lazy
|
|
13
|
+
* chunk) so the button paints immediately when `contextMentions.enabled`, before
|
|
14
|
+
* the heavy menu runtime loads on first interaction.
|
|
15
|
+
*
|
|
16
|
+
* Styled to match `createAttachmentControls`' icon button for visual parity.
|
|
17
|
+
*/
|
|
18
|
+
export function createMentionButton(opts: {
|
|
19
|
+
config: AgentWidgetContextMentionConfig;
|
|
20
|
+
buttonSize?: string;
|
|
21
|
+
onOpen: () => void;
|
|
22
|
+
}): MentionButtonParts {
|
|
23
|
+
const { config, onOpen } = opts;
|
|
24
|
+
const size = opts.buttonSize ?? "40px";
|
|
25
|
+
const sizeNum = parseFloat(size) || 40;
|
|
26
|
+
const iconSize = Math.round(sizeNum * 0.6);
|
|
27
|
+
// Default to a "+" signifier, not an "@" glyph: no major consumer chat app
|
|
28
|
+
// (ChatGPT, Claude, Gemini, Perplexity) puts a literal "@" button in the
|
|
29
|
+
// composer — "@" is a typed power-user accelerator, while "+"/"add context"
|
|
30
|
+
// is the recognized consumer affordance. Override via `buttonIconName`
|
|
31
|
+
// (e.g. "at-sign") on a power-user surface.
|
|
32
|
+
const iconName = config.buttonIconName ?? "plus";
|
|
33
|
+
const tooltipText = config.buttonTooltipText ?? "Add context";
|
|
34
|
+
|
|
35
|
+
const wrapper = createElement("div", "persona-send-button-wrapper");
|
|
36
|
+
const button = createNode("button", {
|
|
37
|
+
className:
|
|
38
|
+
"persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-mention-button",
|
|
39
|
+
attrs: {
|
|
40
|
+
type: "button",
|
|
41
|
+
"data-persona-composer-mention-button": "",
|
|
42
|
+
"aria-label": tooltipText,
|
|
43
|
+
"aria-haspopup": "listbox",
|
|
44
|
+
// Reflects the picker's open state: the orchestrator flips aria-expanded
|
|
45
|
+
// and adds aria-controls (the menu's listbox id) via `onPickerOpenChange`
|
|
46
|
+
// when this button opens/closes the picker. Starts collapsed.
|
|
47
|
+
"aria-expanded": "false",
|
|
48
|
+
},
|
|
49
|
+
style: {
|
|
50
|
+
// Appearance (bg / fg / border / radius / hover) is themed from the CSS
|
|
51
|
+
// rule for `.persona-mention-button` via the `--persona-button-ghost-*`
|
|
52
|
+
// tokens (components.button.ghost). Only config-driven sizing is inline.
|
|
53
|
+
width: size,
|
|
54
|
+
height: size,
|
|
55
|
+
minWidth: size,
|
|
56
|
+
minHeight: size,
|
|
57
|
+
fontSize: "18px",
|
|
58
|
+
lineHeight: "1",
|
|
59
|
+
},
|
|
60
|
+
}) as HTMLButtonElement;
|
|
61
|
+
|
|
62
|
+
const icon = renderLucideIcon(iconName, iconSize, "currentColor", 1.5);
|
|
63
|
+
if (icon) button.appendChild(icon);
|
|
64
|
+
else button.textContent = "+";
|
|
65
|
+
|
|
66
|
+
button.addEventListener("click", (e) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
// Stop the click from bubbling to the composer form's "click anywhere →
|
|
69
|
+
// focus textarea" handler, which would otherwise steal focus from the
|
|
70
|
+
// picker's search field the moment it opens.
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
onOpen();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
wrapper.appendChild(button);
|
|
76
|
+
|
|
77
|
+
const tooltip = createElement("div", "persona-send-button-tooltip");
|
|
78
|
+
tooltip.textContent = tooltipText;
|
|
79
|
+
wrapper.appendChild(tooltip);
|
|
80
|
+
|
|
81
|
+
return { button, wrapper };
|
|
82
|
+
}
|