@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/src/ui.ts CHANGED
@@ -30,9 +30,16 @@ import {
30
30
  PersonaArtifactRecord,
31
31
  PersonaArtifactManualUpsert,
32
32
  PersonaArtifactFileMeta,
33
- PersonaArtifactActionContext
33
+ PersonaArtifactActionContext,
34
+ AgentWidgetContentSegment,
35
+ AgentWidgetContextMentionRef
34
36
  } from "./types";
35
37
  import { AttachmentManager } from "./utils/attachment-manager";
38
+ import {
39
+ createContextMentionOrchestrator,
40
+ type ContextMentionOrchestrator,
41
+ } from "./utils/context-mention-orchestrator";
42
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
36
43
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
37
44
  import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
38
45
  import { resolveTokenValue } from "./utils/tokens";
@@ -535,6 +542,35 @@ function buildDropOverlay(
535
542
  return overlay;
536
543
  }
537
544
 
545
+ // Deep-merge two mention context maps (`{ [sourceId]: { [itemId]: unknown } }`).
546
+ // A source contributing to both bundles keeps BOTH per-item maps; a shallow
547
+ // spread would let one sourceId's inner map wholesale replace the other's.
548
+ export const mergeMentionContext = (
549
+ a: MentionSubmitBundle["context"],
550
+ b: MentionSubmitBundle["context"]
551
+ ): MentionSubmitBundle["context"] => {
552
+ const out: MentionSubmitBundle["context"] = { ...a };
553
+ for (const [sourceId, items] of Object.entries(b)) {
554
+ const existing = out[sourceId];
555
+ out[sourceId] = existing ? { ...existing, ...items } : items;
556
+ }
557
+ return out;
558
+ };
559
+
560
+ // Concatenate the fulfilled halves of two finalize() results (either may be
561
+ // absent when its side rejected), deep-merging their per-source context.
562
+ export const mergeFinalizedMentions = (
563
+ bundles: MentionSubmitBundle[]
564
+ ): MentionSubmitBundle =>
565
+ bundles.reduce<MentionSubmitBundle>(
566
+ (acc, b) => ({
567
+ blocks: [...acc.blocks, ...b.blocks],
568
+ contentParts: [...acc.contentParts, ...b.contentParts],
569
+ context: mergeMentionContext(acc.context, b.context),
570
+ }),
571
+ { blocks: [], contentParts: [], context: {} }
572
+ );
573
+
538
574
  export const createAgentExperience = (
539
575
  mount: HTMLElement,
540
576
  initialConfig?: AgentWidgetConfig,
@@ -840,9 +876,13 @@ export const createAgentExperience = (
840
876
  header,
841
877
  footer,
842
878
  actionsRow: _actionsRow,
843
- leftActions,
844
879
  rightActions
845
880
  } = panelElements;
881
+ // Nullable + reassignable: a plugin `replaceComposer` swaps the footer, and
882
+ // `bindComposerRefsFromFooter` must repoint this at the NEW left-action
883
+ // cluster (or null it so the composerForm fallback fires) — a stale ref would
884
+ // insert the mention/attachment buttons into the detached old subtree.
885
+ let leftActions: HTMLElement | null = panelElements.leftActions;
846
886
  let setSendButtonMode = panelElements.setSendButtonMode;
847
887
 
848
888
  // Use mutable references for mic button so we can update them dynamically
@@ -981,6 +1021,9 @@ export const createAgentExperience = (
981
1021
 
982
1022
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
983
1023
  let attachmentManager: AttachmentManager | null = null;
1024
+ // Context mentions orchestrator (core, tiny); lazy-loads the heavy runtime on
1025
+ // first use. Null when `contextMentions` is disabled / has no sources.
1026
+ let mentionOrchestrator: ContextMentionOrchestrator | null = null;
984
1027
 
985
1028
  /** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
986
1029
  let composerVoiceBridge: (() => void) | null = null;
@@ -1251,6 +1294,10 @@ export const createAgentExperience = (
1251
1294
  ".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
1252
1295
  );
1253
1296
  if (ar) _actionsRow = ar;
1297
+ // Rebind the left-action cluster to the NEW footer (both composer builders
1298
+ // ship this class). Assign unconditionally: when a custom plugin composer
1299
+ // has no left cluster, null lets the button-insert fall back to the form.
1300
+ leftActions = pick<HTMLElement>(".persona-widget-composer__left-actions");
1254
1301
  };
1255
1302
  ensureComposerAttachmentSurface(footer);
1256
1303
  bindComposerRefsFromFooter(footer);
@@ -1303,6 +1350,49 @@ export const createAgentExperience = (
1303
1350
  container.appendChild(overlay);
1304
1351
  }
1305
1352
 
1353
+ // Context mentions: render the affordance button + chip row eagerly (so the
1354
+ // feature is discoverable) and lazy-load the heavy runtime on first use. The
1355
+ // orchestrator returns null when disabled or sourceless.
1356
+ const mentionPrefetch = () => mentionOrchestrator?.prefetch();
1357
+ if (config.contextMentions?.enabled && textarea) {
1358
+ // Slash-command dispatch (prompt macros write text / submit; client actions
1359
+ // read/replace the value) and submission are owned by the composer input
1360
+ // surface itself — the mention runtime builds a textarea (chip) or
1361
+ // contenteditable (inline) adapter from this textarea. Broader host actions
1362
+ // (clear transcript, theme) are still wired via closures over the controller.
1363
+ mentionOrchestrator = createContextMentionOrchestrator({
1364
+ config,
1365
+ textarea,
1366
+ anchor: composerForm ?? textarea,
1367
+ getMessages: () => session.getMessages(),
1368
+ // The engine chunk creates the mention live regions in this container on
1369
+ // mount, keeping the live-region helper out of the core bundle.
1370
+ liveRegionHost: container,
1371
+ });
1372
+
1373
+ if (mentionOrchestrator) {
1374
+ // Chip row sits directly above the textarea.
1375
+ const ta = textarea;
1376
+ ta.parentElement?.insertBefore(mentionOrchestrator.contextRow, ta);
1377
+ // Each channel's "add context" affordance is a secondary control that
1378
+ // augments the outgoing message, so it joins the LEFT action cluster
1379
+ // (beside the attachment button) — never the right cluster with mic +
1380
+ // send. Buttons are inserted leftmost in channel order so they read as
1381
+ // "add to my message" and stay clear of the primary send action. Both
1382
+ // composer builders (full + pill) always ship `leftActions`; the form
1383
+ // fallback is purely defensive.
1384
+ const buttons = mentionOrchestrator.affordanceButtons;
1385
+ for (let i = buttons.length - 1; i >= 0; i--) {
1386
+ const btn = buttons[i];
1387
+ if (leftActions) leftActions.insertBefore(btn, leftActions.firstChild);
1388
+ else composerForm?.appendChild(btn);
1389
+ }
1390
+ // The focus-prefetch listener (warm the chunk on first focus so the first
1391
+ // `@` is instant) is registered through the shared `composerListeners`
1392
+ // registry below, so it survives the inline contenteditable swap too.
1393
+ }
1394
+ }
1395
+
1306
1396
  // Slot system: allow custom content injection into specific regions
1307
1397
  const renderSlots = () => {
1308
1398
  const slots = config.layout?.slots ?? {};
@@ -6174,26 +6264,93 @@ export const createAgentExperience = (
6174
6264
  setOpenState(true, "auto");
6175
6265
  };
6176
6266
 
6177
- const handleSubmit = (event: Event) => {
6178
- event.preventDefault();
6267
+ // Combine `@`-chip mentions with an inline server-command's context bundle so
6268
+ // both reach the message in one `mentions` payload. Either side may be null.
6269
+ type SubmitMentions = NonNullable<
6270
+ ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
6271
+ >;
6272
+ const mergeSubmitMentions = (
6273
+ a: SubmitMentions | null,
6274
+ b: SubmitMentions | null
6275
+ ): SubmitMentions | null => {
6276
+ if (!a) return b;
6277
+ if (!b) return a;
6278
+ return {
6279
+ refs: [...a.refs, ...b.refs],
6280
+ // allSettled, not all: one side rejecting must not discard the other
6281
+ // side's already-resolved context (the bubble already echoed its chips).
6282
+ finalize: async () => {
6283
+ const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
6284
+ const fulfilled: MentionSubmitBundle[] = [];
6285
+ if (ra.status === "fulfilled") fulfilled.push(ra.value);
6286
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
6287
+ if (rb.status === "fulfilled") fulfilled.push(rb.value);
6288
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
6289
+ return mergeFinalizedMentions(fulfilled);
6290
+ },
6291
+ };
6292
+ };
6179
6293
 
6180
- // While a response is streaming, the submit button acts as a stop button.
6181
- // Abort the in-flight stream and leave textarea contents / attachments
6182
- // intact so the user can edit and resend without retyping.
6183
- if (session.isStreaming()) {
6184
- session.cancel();
6185
- // Cancelling emits no terminal/error SSE frame, so reset the throughput
6186
- // tracker (as clear-chat does) to avoid a stale `running` row lingering.
6187
- throughputTracker?.reset();
6188
- eventStreamView?.update();
6189
- return;
6190
- }
6294
+ // Inline-mode composer element: after the contenteditable swap, `textarea`
6295
+ // additionally exposes `getInlineMessageFields()` (built in the inline chunk,
6296
+ // which owns the document model the core never imports it). Structural shape
6297
+ // only, so no runtime import crosses the bundle boundary.
6298
+ type InlineComposerFieldsHost = {
6299
+ getInlineMessageFields?: () => {
6300
+ content: string;
6301
+ contextMentions: AgentWidgetContextMentionRef[];
6302
+ contentSegments: AgentWidgetContentSegment[];
6303
+ };
6304
+ };
6305
+ // Read the ordered display segments from the live inline composer, but only
6306
+ // when at least one mention is present: a plain-text inline message must keep
6307
+ // the normal markdown-rendered bubble (segments bypass that path), so we return
6308
+ // undefined for pure prose and let the bubble render `content` as usual.
6309
+ const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
6310
+ const host = textarea as unknown as InlineComposerFieldsHost;
6311
+ const fields = host.getInlineMessageFields?.();
6312
+ if (!fields) return undefined;
6313
+ const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
6314
+ return hasMention ? fields.contentSegments : undefined;
6315
+ };
6191
6316
 
6317
+ // Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
6318
+ // lazy chunk load + host resolve) before the composer clears, and
6319
+ // `isStreaming()` is still false in that window, so a second Enter would
6320
+ // otherwise dispatch the same text/command twice.
6321
+ let submitInFlight = false;
6322
+
6323
+ const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
6192
6324
  const value = textarea.value.trim();
6193
6325
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
6194
6326
 
6195
- // Must have text or attachments to send
6196
- if (!value && !hasAttachments) return;
6327
+ // Inline slash command (Slack-style): every `command:"server"` plus any
6328
+ // arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
6329
+ // text to send, and an action sends nothing at all.
6330
+ const inline = value
6331
+ ? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
6332
+ : null;
6333
+
6334
+ if (inline?.kind === "action") {
6335
+ // Ran in the browser; nothing to send. Clear the composer + any chips.
6336
+ textarea.value = "";
6337
+ textarea.style.height = "auto";
6338
+ resetHistoryNavigation();
6339
+ mentionOrchestrator?.clear();
6340
+ return;
6341
+ }
6342
+
6343
+ // Gather `@`-chip mentions synchronously (detaches chips + captures composer
6344
+ // text before clearing); `finalize()` resolves them inside `sendMessage`.
6345
+ const chipMentions = mentionOrchestrator?.collectForSubmit() ?? null;
6346
+ const serverMentions = inline?.kind === "server" ? inline.mentions : null;
6347
+ const mentions = mergeSubmitMentions(chipMentions, serverMentions);
6348
+ // A prompt command replaces the outgoing text with its resolved macro.
6349
+ const sendText = inline?.kind === "prompt" ? inline.sendText : value;
6350
+
6351
+ const hasChips = !!chipMentions && chipMentions.refs.length > 0;
6352
+ // Must have text, attachments, chips, or an inline server command's context.
6353
+ if (!sendText && !hasAttachments && !hasChips && !serverMentions) return;
6197
6354
 
6198
6355
  maybeExpandComposerBar();
6199
6356
 
@@ -6204,22 +6361,65 @@ export const createAgentExperience = (
6204
6361
  // Add image parts first
6205
6362
  contentParts.push(...attachmentManager!.getContentParts());
6206
6363
  // Add text part if there's text
6207
- if (value) {
6208
- contentParts.push(createTextPart(value));
6364
+ if (sendText) {
6365
+ contentParts.push(createTextPart(sendText));
6209
6366
  }
6210
6367
  }
6211
6368
 
6369
+ // Capture the inline display segments BEFORE clearing the composer (clearing
6370
+ // rebuilds the document as empty text). Only set when a `prompt` macro didn't
6371
+ // replace the outgoing text — a macro's segments no longer match `sendText`.
6372
+ const contentSegments =
6373
+ inline?.kind === "prompt" ? undefined : readInlineContentSegments();
6374
+
6212
6375
  textarea.value = "";
6213
6376
  textarea.style.height = "auto"; // Reset height after clearing
6214
6377
  resetHistoryNavigation();
6215
6378
 
6216
- // Send message with optional content parts
6217
- session.sendMessage(value, { contentParts });
6379
+ // Send message with optional content parts + mentions
6380
+ session.sendMessage(sendText, {
6381
+ contentParts,
6382
+ mentions: mentions ?? undefined,
6383
+ contentSegments,
6384
+ viaVoice: submitOptions?.viaVoice,
6385
+ });
6218
6386
 
6219
- // Clear attachments after sending
6387
+ // Clear attachments + mention chips after sending
6220
6388
  if (hasAttachments) {
6221
6389
  attachmentManager!.clearAttachments();
6222
6390
  }
6391
+ if (chipMentions) {
6392
+ mentionOrchestrator?.clear();
6393
+ }
6394
+ };
6395
+
6396
+ const doSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
6397
+ if (submitInFlight) return;
6398
+ submitInFlight = true;
6399
+ try {
6400
+ await performSubmit(submitOptions);
6401
+ } finally {
6402
+ submitInFlight = false;
6403
+ }
6404
+ };
6405
+
6406
+ const handleSubmit = (event: Event) => {
6407
+ event.preventDefault();
6408
+
6409
+ // While a response is streaming, the submit button acts as a stop button.
6410
+ // Abort the in-flight stream and leave textarea contents / attachments
6411
+ // intact so the user can edit and resend without retyping.
6412
+ if (session.isStreaming()) {
6413
+ session.cancel();
6414
+ // Cancelling emits no terminal/error SSE frame, so reset the throughput
6415
+ // tracker (as clear-chat does) to avoid a stale `running` row lingering.
6416
+ throughputTracker?.reset();
6417
+ eventStreamView?.update();
6418
+ return;
6419
+ }
6420
+
6421
+ if (submitInFlight) return;
6422
+ void doSubmit();
6223
6423
  };
6224
6424
 
6225
6425
  // --- Composer message-history navigation (Up/Down arrows) ---
@@ -6258,15 +6458,30 @@ export const createAgentExperience = (
6258
6458
  textarea.setSelectionRange(end, end);
6259
6459
  };
6260
6460
 
6261
- const handleComposerInput = () => {
6262
- // A real edit leaves history-navigation mode.
6461
+ const handleComposerInput = (event: Event) => {
6462
+ // The synthetic input from history recall (guarded by `suppressHistoryReset`)
6463
+ // is not user typing: it must neither open the mention menu (an undefined
6464
+ // inputType reads as menu-opening) nor exit history-navigation mode.
6263
6465
  if (suppressHistoryReset) return;
6466
+ // Drive the mention menu (open/update/close) + lazy-load on first trigger.
6467
+ // Skip while an IME composition is active: the intermediate value isn't the
6468
+ // user's committed text, so it must not open or filter the menu.
6469
+ if (!(event as InputEvent).isComposing) {
6470
+ mentionOrchestrator?.handleInput((event as InputEvent).inputType ?? undefined);
6471
+ }
6472
+ // A real edit leaves history-navigation mode.
6264
6473
  resetHistoryNavigation();
6265
6474
  };
6266
6475
 
6267
6476
  const handleComposerKeydown = (event: KeyboardEvent) => {
6268
6477
  if (!textarea) return;
6269
6478
 
6479
+ // Mention menu takes precedence when open (↑/↓ nav, Enter/Tab select, Esc
6480
+ // close) and handles Backspace-removes-last-chip on an empty composer. One
6481
+ // handler, no competing capture-phase listener. Skip during IME composition
6482
+ // so Enter-to-confirm-composition isn't swallowed as a menu selection.
6483
+ if (!event.isComposing && mentionOrchestrator?.handleKeydown(event)) return;
6484
+
6270
6485
  // Up/Down: walk through previously sent user messages.
6271
6486
  if (
6272
6487
  historyNavigationEnabled() &&
@@ -6304,6 +6519,12 @@ export const createAgentExperience = (
6304
6519
  event.preventDefault();
6305
6520
  return;
6306
6521
  }
6522
+ // A submit is already awaiting its async pre-send work; a second Enter in
6523
+ // that window would dispatch the same text twice.
6524
+ if (submitInFlight) {
6525
+ event.preventDefault();
6526
+ return;
6527
+ }
6307
6528
  resetHistoryNavigation();
6308
6529
  event.preventDefault();
6309
6530
  sendButton.click();
@@ -6409,9 +6630,11 @@ export const createAgentExperience = (
6409
6630
  const finalValue = textarea.value.trim();
6410
6631
  if (finalValue && speechRecognition && isRecording) {
6411
6632
  stopVoiceRecognition();
6412
- textarea.value = "";
6413
- textarea.style.height = "auto"; // Reset height after clearing
6414
- session.sendMessage(finalValue, { viaVoice: true });
6633
+ // Route through the normal submit path so mentions are collected +
6634
+ // cleared like a manual send (the transcript already lives in the
6635
+ // composer). Sending directly would leave stale tracked mention
6636
+ // context to attach to the next unrelated message.
6637
+ void doSubmit({ viaVoice: true });
6415
6638
  }
6416
6639
  }, pauseDuration);
6417
6640
  }
@@ -6429,9 +6652,9 @@ export const createAgentExperience = (
6429
6652
  if (isRecording) {
6430
6653
  const finalValue = textarea.value.trim();
6431
6654
  if (finalValue && finalValue !== initialText.trim()) {
6432
- textarea.value = "";
6433
- textarea.style.height = "auto"; // Reset height after clearing
6434
- session.sendMessage(finalValue, { viaVoice: true });
6655
+ // Route through the normal submit path (mentions collect + clear), same
6656
+ // as the pause-timer branch above.
6657
+ void doSubmit({ viaVoice: true });
6435
6658
  }
6436
6659
  stopVoiceRecognition();
6437
6660
  }
@@ -7313,9 +7536,43 @@ export const createAgentExperience = (
7313
7536
  if (composerForm) {
7314
7537
  composerForm.addEventListener("submit", handleSubmit);
7315
7538
  }
7316
- textarea?.addEventListener("keydown", handleComposerKeydown);
7317
- textarea?.addEventListener("input", handleComposerInput);
7318
- textarea?.addEventListener("paste", handleInputPaste);
7539
+
7540
+ // Single registry of composer listeners. The initial attach and the inline
7541
+ // contenteditable swap-reattach both consume this array, so a listener added
7542
+ // here is mechanically included in both — no hand-maintained enumeration to
7543
+ // drift. Add new composer listeners here, not as ad-hoc addEventListener calls.
7544
+ type ComposerListener = [event: string, handler: (event: Event) => void];
7545
+ const composerListeners: ComposerListener[] = [
7546
+ ["keydown", handleComposerKeydown as unknown as (event: Event) => void],
7547
+ ["input", handleComposerInput as (event: Event) => void],
7548
+ ["paste", handleInputPaste as unknown as (event: Event) => void],
7549
+ // Warm the mention chunk on first focus so the first `@` is instant.
7550
+ ["focus", mentionPrefetch as (event: Event) => void],
7551
+ ];
7552
+ const attachComposerListeners = (el: HTMLElement | null): void => {
7553
+ if (!el) return;
7554
+ for (const [event, handler] of composerListeners) {
7555
+ el.addEventListener(event, handler);
7556
+ }
7557
+ };
7558
+ const detachComposerListeners = (el: HTMLElement | null): void => {
7559
+ if (!el) return;
7560
+ for (const [event, handler] of composerListeners) {
7561
+ el.removeEventListener(event, handler);
7562
+ }
7563
+ };
7564
+
7565
+ attachComposerListeners(textarea);
7566
+
7567
+ // Inline mention mode swaps the textarea for a contenteditable surface (loaded
7568
+ // lazily). When that happens, move the composer listeners onto the new element
7569
+ // and repoint `textarea` (the swapped element shims the textarea API the rest of
7570
+ // the composer code relies on). Fires immediately if the swap already occurred.
7571
+ mentionOrchestrator?.onComposerSwap((next, prev) => {
7572
+ detachComposerListeners(prev);
7573
+ textarea = next as unknown as HTMLTextAreaElement;
7574
+ attachComposerListeners(next);
7575
+ });
7319
7576
 
7320
7577
  const escStopDoc = mount.ownerDocument ?? document;
7321
7578
  escStopDoc.addEventListener("keydown", handleEscStop, true);
@@ -7396,10 +7653,9 @@ export const createAgentExperience = (
7396
7653
  if (composerForm) {
7397
7654
  composerForm.removeEventListener("submit", handleSubmit);
7398
7655
  }
7399
- textarea?.removeEventListener("keydown", handleComposerKeydown);
7400
- textarea?.removeEventListener("input", handleComposerInput);
7401
- textarea?.removeEventListener("paste", handleInputPaste);
7656
+ detachComposerListeners(textarea);
7402
7657
  escStopDoc.removeEventListener("keydown", handleEscStop, true);
7658
+ mentionOrchestrator?.destroy();
7403
7659
  });
7404
7660
 
7405
7661
  destroyCallbacks.push(() => {
@@ -8441,19 +8697,11 @@ export const createAgentExperience = (
8441
8697
  attachmentButton.style.minHeight = attachIconSize;
8442
8698
  attachmentButton.style.fontSize = "18px";
8443
8699
  attachmentButton.style.lineHeight = "1";
8444
- attachmentButton.style.backgroundColor = "transparent";
8445
- attachmentButton.style.color = "var(--persona-primary, #111827)";
8446
- attachmentButton.style.border = "none";
8447
- attachmentButton.style.borderRadius = "6px";
8448
- attachmentButton.style.transition = "background-color 0.15s ease";
8449
-
8450
- // Add hover effect via mouseenter/mouseleave
8451
- attachmentButton.addEventListener("mouseenter", () => {
8452
- attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
8453
- });
8454
- attachmentButton.addEventListener("mouseleave", () => {
8455
- attachmentButton!.style.backgroundColor = "transparent";
8456
- });
8700
+ // Appearance (bg / fg / border / radius / hover) is themed from the
8701
+ // shared `.persona-attachment-button` CSS rule via the
8702
+ // `--persona-button-ghost-*` tokens — matching the static
8703
+ // createAttachmentControls path. Only sizing stays inline here so
8704
+ // this runtime-created button restyles identically to the built-in one.
8457
8705
 
8458
8706
  const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
8459
8707
  if (attachIconSvg) {
@@ -8475,8 +8723,10 @@ export const createAgentExperience = (
8475
8723
  tooltip.textContent = attachTooltipText;
8476
8724
  attachmentButtonWrapper.appendChild(tooltip);
8477
8725
 
8478
- // Insert into left actions container
8479
- leftActions.append(attachmentButtonWrapper);
8726
+ // Insert into left actions container (fall back to the form when a
8727
+ // custom composer has no left cluster).
8728
+ if (leftActions) leftActions.append(attachmentButtonWrapper);
8729
+ else composerForm.appendChild(attachmentButtonWrapper);
8480
8730
 
8481
8731
  // Initialize attachment manager
8482
8732
  if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createChunkLoader } from "./chunk-loader";
3
+
4
+ type FakeModule = { value: number };
5
+
6
+ describe("createChunkLoader", () => {
7
+ it("uses the fallback import when no loader is registered", async () => {
8
+ const mod: FakeModule = { value: 1 };
9
+ const fallbackImport = vi.fn(async () => mod);
10
+ const { load } = createChunkLoader<FakeModule>({ fallbackImport });
11
+
12
+ await expect(load()).resolves.toBe(mod);
13
+ expect(fallbackImport).toHaveBeenCalledTimes(1);
14
+ });
15
+
16
+ it("memoizes the resolved module and shares the in-flight promise", async () => {
17
+ const mod: FakeModule = { value: 2 };
18
+ const loader = vi.fn(async () => mod);
19
+ const { setLoader, load } = createChunkLoader<FakeModule>({
20
+ fallbackImport: async () => ({ value: -1 }),
21
+ });
22
+ setLoader(loader);
23
+
24
+ // Concurrent callers share one import.
25
+ const [a, b] = await Promise.all([load(), load()]);
26
+ expect(a).toBe(mod);
27
+ expect(b).toBe(mod);
28
+ // A later call returns the cached module without importing again.
29
+ await expect(load()).resolves.toBe(mod);
30
+ expect(loader).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it("retries after a rejected load instead of caching the failure", async () => {
34
+ const mod: FakeModule = { value: 3 };
35
+ const loader = vi
36
+ .fn<() => Promise<FakeModule>>()
37
+ .mockRejectedValueOnce(new Error("network"))
38
+ .mockResolvedValueOnce(mod);
39
+ const { setLoader, load } = createChunkLoader<FakeModule>({
40
+ fallbackImport: async () => ({ value: -1 }),
41
+ });
42
+ setLoader(loader);
43
+
44
+ await expect(load()).rejects.toThrow("network");
45
+ // The failed promise was cleared, so a later call retries and resolves.
46
+ await expect(load()).resolves.toBe(mod);
47
+ expect(loader).toHaveBeenCalledTimes(2);
48
+ // Once resolved, the module is cached (no third loader call).
49
+ await expect(load()).resolves.toBe(mod);
50
+ expect(loader).toHaveBeenCalledTimes(2);
51
+ });
52
+
53
+ it("does not invalidate the cache on setLoader by default", async () => {
54
+ const first: FakeModule = { value: 4 };
55
+ const second: FakeModule = { value: 5 };
56
+ const { setLoader, load } = createChunkLoader<FakeModule>({
57
+ fallbackImport: async () => ({ value: -1 }),
58
+ });
59
+
60
+ setLoader(async () => first);
61
+ await expect(load()).resolves.toBe(first);
62
+ // Registering a new loader after a resolved load has no effect: cache holds.
63
+ setLoader(async () => second);
64
+ await expect(load()).resolves.toBe(first);
65
+ });
66
+
67
+ it("invalidates the cache on setLoader when resetOnSetLoader is set", async () => {
68
+ const first: FakeModule = { value: 6 };
69
+ const second: FakeModule = { value: 7 };
70
+ const { setLoader, load } = createChunkLoader<FakeModule>({
71
+ fallbackImport: async () => ({ value: -1 }),
72
+ resetOnSetLoader: true,
73
+ });
74
+
75
+ setLoader(async () => first);
76
+ await expect(load()).resolves.toBe(first);
77
+ // A swapped loader takes effect: the previous module is discarded.
78
+ setLoader(async () => second);
79
+ await expect(load()).resolves.toBe(second);
80
+ });
81
+
82
+ it("provide() seeds the cache and getSync() reads it", async () => {
83
+ const mod: FakeModule = { value: 8 };
84
+ const loader = vi.fn(async () => ({ value: -1 }));
85
+ const { setLoader, load, provide, getSync } = createChunkLoader<FakeModule>({
86
+ fallbackImport: async () => ({ value: -1 }),
87
+ });
88
+ setLoader(loader);
89
+
90
+ expect(getSync()).toBeNull();
91
+ provide(mod);
92
+ expect(getSync()).toBe(mod);
93
+ // A provided module short-circuits load(): the loader is never called.
94
+ await expect(load()).resolves.toBe(mod);
95
+ expect(loader).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Generic memoized loader for a lazy code-split chunk.
3
+ *
4
+ * Backs the near-identical loader indirections in the core/eager bundle
5
+ * (`context-mentions-loader.ts`, `context-mentions-inline-loader.ts`,
6
+ * `markdown-parsers-loader.ts`): each registers a runtime loader (IIFE/CDN
7
+ * sibling-URL chunk) and falls back to an external/relative dynamic import for
8
+ * ESM/CJS consumers. The shared semantics are:
9
+ *
10
+ * - `load()` memoizes the resolved module (`moduleCache`) and the in-flight
11
+ * promise (`loadPromise`) so concurrent callers share one import.
12
+ * - A rejected load clears `loadPromise` so a later call retries after a
13
+ * transient failure (one dropped fetch must not disable the feature for the
14
+ * whole session); the current caller still sees the rejection.
15
+ * - The registered loader wins over `fallbackImport`; if no loader is
16
+ * registered, `fallbackImport` is used.
17
+ *
18
+ * IMPORTANT: this module must import nothing beyond types. It sits in the core
19
+ * bundle, so any value import here could drag chunk code back into core and
20
+ * defeat the split (see `context-mentions-bundle.test.ts`).
21
+ */
22
+
23
+ export type ChunkLoaderOptions<T> = {
24
+ /**
25
+ * Dynamic import used when no runtime loader has been registered. Keep the
26
+ * `import(...)` literal in the calling module (not here) so the bundler
27
+ * resolves it as an external subpath / relative chunk at that call site.
28
+ */
29
+ fallbackImport: () => Promise<T>;
30
+ /**
31
+ * When true, registering a new loader clears the cached module and any
32
+ * in-flight promise, so a swapped loader (test injection) takes effect
33
+ * deterministically. Production registers its loader once before any load, so
34
+ * this only matters mid-session.
35
+ */
36
+ resetOnSetLoader?: boolean;
37
+ };
38
+
39
+ export type ChunkLoader<T> = {
40
+ setLoader: (l: () => Promise<T>) => void;
41
+ load: () => Promise<T>;
42
+ /** Register a resolved module synchronously (eager builds that bundle it). */
43
+ provide: (mod: T) => void;
44
+ /** The cached module, or null if not loaded/provided yet. */
45
+ getSync: () => T | null;
46
+ };
47
+
48
+ export const createChunkLoader = <T>(
49
+ options: ChunkLoaderOptions<T>
50
+ ): ChunkLoader<T> => {
51
+ const { fallbackImport, resetOnSetLoader = false } = options;
52
+
53
+ let loader: (() => Promise<T>) | null = null;
54
+ let moduleCache: T | null = null;
55
+ let loadPromise: Promise<T> | null = null;
56
+
57
+ const setLoader = (l: () => Promise<T>): void => {
58
+ loader = l;
59
+ if (resetOnSetLoader) {
60
+ moduleCache = null;
61
+ loadPromise = null;
62
+ }
63
+ };
64
+
65
+ const load = (): Promise<T> => {
66
+ if (moduleCache) return Promise.resolve(moduleCache);
67
+ if (loadPromise) return loadPromise;
68
+ const importChunk = loader ?? fallbackImport;
69
+ loadPromise = importChunk()
70
+ .then((mod) => {
71
+ moduleCache = mod;
72
+ return mod;
73
+ })
74
+ .catch((err) => {
75
+ loadPromise = null;
76
+ throw err;
77
+ });
78
+ return loadPromise;
79
+ };
80
+
81
+ const provide = (mod: T): void => {
82
+ moduleCache = mod;
83
+ };
84
+
85
+ const getSync = (): T | null => moduleCache;
86
+
87
+ return { setLoader, load, provide, getSync };
88
+ };