@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.8.0",
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);}\"",
@@ -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
- if (this.contextProviders.length) {
1236
- const contextAggregate: Record<string, unknown> = {};
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
- if (this.contextProviders.length) {
1315
- const contextAggregate: Record<string, unknown> = {};
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
- records = state.artifacts;
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
+ }