@runtypelabs/persona 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +1 -0
  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 +59 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +622 -2
  26. package/dist/index.d.ts +622 -2
  27. package/dist/index.global.js +54 -46
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +59 -51
  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 +234 -55
  42. package/dist/theme-editor-preview.d.cts +473 -0
  43. package/dist/theme-editor-preview.d.ts +473 -0
  44. package/dist/theme-editor-preview.js +48 -42
  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/composer-parts.ts +3 -12
  57. package/src/components/context-mention-button.test.ts +70 -0
  58. package/src/components/context-mention-button.ts +82 -0
  59. package/src/components/context-mention-chip.ts +134 -0
  60. package/src/components/context-mention-menu.test.ts +508 -0
  61. package/src/components/context-mention-menu.ts +0 -0
  62. package/src/components/message-bubble.test.ts +175 -0
  63. package/src/components/message-bubble.ts +177 -19
  64. package/src/components/panel.ts +7 -10
  65. package/src/context-mentions-bundle.test.ts +163 -0
  66. package/src/context-mentions-entry.ts +185 -0
  67. package/src/context-mentions-inline-entry.test.ts +136 -0
  68. package/src/context-mentions-inline-entry.ts +226 -0
  69. package/src/context-mentions-inline-loader.test.ts +30 -0
  70. package/src/context-mentions-inline-loader.ts +36 -0
  71. package/src/context-mentions-inline.ts +15 -0
  72. package/src/context-mentions-loader.ts +32 -0
  73. package/src/context-mentions.ts +16 -0
  74. package/src/index-core.ts +26 -0
  75. package/src/index-global.ts +51 -0
  76. package/src/markdown-parsers-loader.ts +35 -44
  77. package/src/plugin-kit.test.ts +40 -0
  78. package/src/plugin-kit.ts +39 -5
  79. package/src/session.mentions.test.ts +175 -0
  80. package/src/session.test.ts +52 -4
  81. package/src/session.ts +121 -5
  82. package/src/smart-dom-reader.test.ts +129 -2
  83. package/src/smart-dom-reader.ts +127 -1
  84. package/src/styles/context-mention-menu-css.ts +176 -0
  85. package/src/styles/widget.css +229 -126
  86. package/src/theme-editor/sections.ts +3 -3
  87. package/src/types/theme.ts +2 -0
  88. package/src/types.ts +525 -0
  89. package/src/ui.mention-submit.test.ts +235 -0
  90. package/src/ui.ts +301 -51
  91. package/src/utils/chunk-loader.test.ts +97 -0
  92. package/src/utils/chunk-loader.ts +88 -0
  93. package/src/utils/composer-contenteditable.test.ts +507 -0
  94. package/src/utils/composer-contenteditable.ts +626 -0
  95. package/src/utils/composer-document.test.ts +280 -0
  96. package/src/utils/composer-document.ts +293 -0
  97. package/src/utils/composer-history.test.ts +35 -7
  98. package/src/utils/composer-history.ts +30 -20
  99. package/src/utils/composer-input.ts +159 -0
  100. package/src/utils/context-mention-controller.test.ts +1215 -0
  101. package/src/utils/context-mention-controller.ts +1186 -0
  102. package/src/utils/context-mention-manager.test.ts +422 -0
  103. package/src/utils/context-mention-manager.ts +410 -0
  104. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  105. package/src/utils/context-mention-orchestrator.ts +348 -0
  106. package/src/utils/live-region.test.ts +108 -0
  107. package/src/utils/live-region.ts +94 -0
  108. package/src/utils/mention-channels.ts +63 -0
  109. package/src/utils/mention-llm-format.test.ts +91 -0
  110. package/src/utils/mention-llm-format.ts +79 -0
  111. package/src/utils/mention-matcher.test.ts +86 -0
  112. package/src/utils/mention-matcher.ts +221 -0
  113. package/src/utils/mention-token.ts +72 -0
  114. package/src/utils/mention-trigger.test.ts +155 -0
  115. package/src/utils/mention-trigger.ts +156 -0
  116. package/src/utils/theme.test.ts +28 -4
  117. 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.9.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 {
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ import { createElement, createNode } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import type {
4
+ AgentWidgetContextMentionConfig,
5
+ AgentWidgetContextMentionPayload,
6
+ AgentWidgetContextMentionRef,
7
+ } from "../types";
8
+
9
+ export type MentionChipStatus = "resolving" | "ready" | "error";
10
+
11
+ export interface MentionChipParts {
12
+ el: HTMLElement;
13
+ /** Update lifecycle state; `payload` is forwarded to `renderMentionChip` once resolved. */
14
+ setStatus: (
15
+ status: MentionChipStatus,
16
+ payload?: AgentWidgetContextMentionPayload
17
+ ) => void;
18
+ }
19
+
20
+ /**
21
+ * A single compact pill chip (icon + label + ×, ~26px) for the composer context
22
+ * row. Mirrors the attachment chip's remove affordance but is a text pill, not a
23
+ * 48×48 thumbnail. Honors `config.renderMentionChip` when provided.
24
+ */
25
+ export function createMentionChip(opts: {
26
+ ref: AgentWidgetContextMentionRef;
27
+ config: AgentWidgetContextMentionConfig;
28
+ onRemove: () => void;
29
+ }): MentionChipParts {
30
+ const { ref, config, onRemove } = opts;
31
+
32
+ if (config.renderMentionChip) {
33
+ // Mid-level override: the host owns the markup. Status (and the resolved
34
+ // payload, once available) is reflected by re-invoking the renderer.
35
+ let status: MentionChipStatus = "resolving";
36
+ let payload: AgentWidgetContextMentionPayload | undefined;
37
+ let el = config.renderMentionChip({ ref, status, payload, remove: onRemove });
38
+ const setStatus = (
39
+ next: MentionChipStatus,
40
+ nextPayload?: AgentWidgetContextMentionPayload
41
+ ) => {
42
+ if (next === status && nextPayload === payload) return;
43
+ status = next;
44
+ payload = nextPayload;
45
+ const replacement = config.renderMentionChip!({
46
+ ref,
47
+ status,
48
+ payload,
49
+ remove: onRemove,
50
+ });
51
+ el.replaceWith(replacement);
52
+ el = replacement;
53
+ };
54
+ // Getter, not a snapshot: setStatus swaps the node, and the manager's
55
+ // remove()/clear() must target the live element, not the detached original.
56
+ return {
57
+ get el() {
58
+ return el;
59
+ },
60
+ setStatus,
61
+ };
62
+ }
63
+
64
+ const iconName = ref.iconName ?? config.chipIconName ?? "at-sign";
65
+
66
+ const chip = createNode("div", {
67
+ className: "persona-mention-chip",
68
+ attrs: {
69
+ "data-persona-mention-chip": "",
70
+ "data-status": "resolving",
71
+ // Full label on hover, since the pill truncates long labels with ellipsis.
72
+ title: ref.label,
73
+ },
74
+ });
75
+
76
+ const iconHost = createElement("span", "persona-mention-chip-icon");
77
+ const setIcon = (name: string) => {
78
+ iconHost.replaceChildren();
79
+ const svg = renderLucideIcon(name, 13, "currentColor", 2);
80
+ if (svg) iconHost.appendChild(svg);
81
+ };
82
+ setIcon(iconName);
83
+
84
+ // The spinner shown while the mention resolves; swapped for the icon on ready.
85
+ const spinner = createElement("span", "persona-mention-chip-spinner");
86
+
87
+ const label = createNode("span", {
88
+ className: "persona-mention-chip-label",
89
+ text: ref.label,
90
+ });
91
+
92
+ const removeBtn = createNode("button", {
93
+ className: "persona-mention-chip-remove",
94
+ attrs: {
95
+ type: "button",
96
+ "aria-label": `Remove ${ref.label} context`,
97
+ },
98
+ }) as HTMLButtonElement;
99
+ const x = renderLucideIcon("x", 11, "currentColor", 2.5);
100
+ if (x) removeBtn.appendChild(x);
101
+ else removeBtn.textContent = "×";
102
+ removeBtn.addEventListener("click", (e) => {
103
+ e.preventDefault();
104
+ e.stopPropagation();
105
+ onRemove();
106
+ });
107
+
108
+ chip.appendChild(spinner);
109
+ chip.appendChild(label);
110
+ chip.appendChild(removeBtn);
111
+
112
+ const setStatus = (status: MentionChipStatus) => {
113
+ chip.setAttribute("data-status", status);
114
+ if (status === "resolving") {
115
+ if (spinner.parentNode !== chip) chip.insertBefore(spinner, label);
116
+ if (iconHost.parentNode === chip) iconHost.remove();
117
+ chip.setAttribute("title", ref.label);
118
+ } else {
119
+ if (spinner.parentNode === chip) spinner.remove();
120
+ if (iconHost.parentNode !== chip) chip.insertBefore(iconHost, label);
121
+ if (status === "error") {
122
+ // Error is not color-only: swap to an alert icon and explain in the
123
+ // hover title (and the aria-label reflects it via the title too).
124
+ setIcon("triangle-alert");
125
+ chip.setAttribute("title", `Couldn't add ${ref.label} to context`);
126
+ } else {
127
+ setIcon(iconName);
128
+ chip.setAttribute("title", ref.label);
129
+ }
130
+ }
131
+ };
132
+
133
+ return { el: chip, setStatus };
134
+ }