@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/src/ui.ts CHANGED
@@ -5,6 +5,7 @@ import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parse
5
5
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
6
6
  import {
7
7
  AgentWidgetConfig,
8
+ AgentWidgetConfigPatch,
8
9
  AgentWidgetApprovalDecisionOptions,
9
10
  AgentWidgetMessage,
10
11
  AgentWidgetEvent,
@@ -30,9 +31,16 @@ import {
30
31
  PersonaArtifactRecord,
31
32
  PersonaArtifactManualUpsert,
32
33
  PersonaArtifactFileMeta,
33
- PersonaArtifactActionContext
34
+ PersonaArtifactActionContext,
35
+ AgentWidgetContentSegment,
36
+ AgentWidgetContextMentionRef
34
37
  } from "./types";
35
38
  import { AttachmentManager } from "./utils/attachment-manager";
39
+ import {
40
+ createContextMentionOrchestrator,
41
+ type ContextMentionOrchestrator,
42
+ } from "./utils/context-mention-orchestrator";
43
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
36
44
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
37
45
  import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
38
46
  import { resolveTokenValue } from "./utils/tokens";
@@ -126,6 +134,7 @@ import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resi
126
134
  import { enhanceWithForms } from "./components/forms";
127
135
  import { pluginRegistry } from "./plugins/registry";
128
136
  import { mergeWithDefaults, DEFAULT_FLOATING_LAUNCHER_WIDTH } from "./defaults";
137
+ import { mergeConfigUpdate } from "./utils/config-merge";
129
138
  import { createEventBus } from "./utils/events";
130
139
  import {
131
140
  createActionManager,
@@ -298,7 +307,7 @@ const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
298
307
  }));
299
308
 
300
309
  type Controller = {
301
- update: (config: AgentWidgetConfig) => void;
310
+ update: (config: AgentWidgetConfigPatch) => void;
302
311
  destroy: () => void;
303
312
  open: () => void;
304
313
  close: () => void;
@@ -535,6 +544,35 @@ function buildDropOverlay(
535
544
  return overlay;
536
545
  }
537
546
 
547
+ // Deep-merge two mention context maps (`{ [sourceId]: { [itemId]: unknown } }`).
548
+ // A source contributing to both bundles keeps BOTH per-item maps; a shallow
549
+ // spread would let one sourceId's inner map wholesale replace the other's.
550
+ export const mergeMentionContext = (
551
+ a: MentionSubmitBundle["context"],
552
+ b: MentionSubmitBundle["context"]
553
+ ): MentionSubmitBundle["context"] => {
554
+ const out: MentionSubmitBundle["context"] = { ...a };
555
+ for (const [sourceId, items] of Object.entries(b)) {
556
+ const existing = out[sourceId];
557
+ out[sourceId] = existing ? { ...existing, ...items } : items;
558
+ }
559
+ return out;
560
+ };
561
+
562
+ // Concatenate the fulfilled halves of two finalize() results (either may be
563
+ // absent when its side rejected), deep-merging their per-source context.
564
+ export const mergeFinalizedMentions = (
565
+ bundles: MentionSubmitBundle[]
566
+ ): MentionSubmitBundle =>
567
+ bundles.reduce<MentionSubmitBundle>(
568
+ (acc, b) => ({
569
+ blocks: [...acc.blocks, ...b.blocks],
570
+ contentParts: [...acc.contentParts, ...b.contentParts],
571
+ context: mergeMentionContext(acc.context, b.context),
572
+ }),
573
+ { blocks: [], contentParts: [], context: {} }
574
+ );
575
+
538
576
  export const createAgentExperience = (
539
577
  mount: HTMLElement,
540
578
  initialConfig?: AgentWidgetConfig,
@@ -840,9 +878,13 @@ export const createAgentExperience = (
840
878
  header,
841
879
  footer,
842
880
  actionsRow: _actionsRow,
843
- leftActions,
844
881
  rightActions
845
882
  } = panelElements;
883
+ // Nullable + reassignable: a plugin `replaceComposer` swaps the footer, and
884
+ // `bindComposerRefsFromFooter` must repoint this at the NEW left-action
885
+ // cluster (or null it so the composerForm fallback fires) — a stale ref would
886
+ // insert the mention/attachment buttons into the detached old subtree.
887
+ let leftActions: HTMLElement | null = panelElements.leftActions;
846
888
  let setSendButtonMode = panelElements.setSendButtonMode;
847
889
 
848
890
  // Use mutable references for mic button so we can update them dynamically
@@ -981,6 +1023,9 @@ export const createAgentExperience = (
981
1023
 
982
1024
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
983
1025
  let attachmentManager: AttachmentManager | null = null;
1026
+ // Context mentions orchestrator (core, tiny); lazy-loads the heavy runtime on
1027
+ // first use. Null when `contextMentions` is disabled / has no sources.
1028
+ let mentionOrchestrator: ContextMentionOrchestrator | null = null;
984
1029
 
985
1030
  /** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
986
1031
  let composerVoiceBridge: (() => void) | null = null;
@@ -1251,6 +1296,10 @@ export const createAgentExperience = (
1251
1296
  ".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
1252
1297
  );
1253
1298
  if (ar) _actionsRow = ar;
1299
+ // Rebind the left-action cluster to the NEW footer (both composer builders
1300
+ // ship this class). Assign unconditionally: when a custom plugin composer
1301
+ // has no left cluster, null lets the button-insert fall back to the form.
1302
+ leftActions = pick<HTMLElement>(".persona-widget-composer__left-actions");
1254
1303
  };
1255
1304
  ensureComposerAttachmentSurface(footer);
1256
1305
  bindComposerRefsFromFooter(footer);
@@ -1303,6 +1352,49 @@ export const createAgentExperience = (
1303
1352
  container.appendChild(overlay);
1304
1353
  }
1305
1354
 
1355
+ // Context mentions: render the affordance button + chip row eagerly (so the
1356
+ // feature is discoverable) and lazy-load the heavy runtime on first use. The
1357
+ // orchestrator returns null when disabled or sourceless.
1358
+ const mentionPrefetch = () => mentionOrchestrator?.prefetch();
1359
+ if (config.contextMentions?.enabled && textarea) {
1360
+ // Slash-command dispatch (prompt macros write text / submit; client actions
1361
+ // read/replace the value) and submission are owned by the composer input
1362
+ // surface itself — the mention runtime builds a textarea (chip) or
1363
+ // contenteditable (inline) adapter from this textarea. Broader host actions
1364
+ // (clear transcript, theme) are still wired via closures over the controller.
1365
+ mentionOrchestrator = createContextMentionOrchestrator({
1366
+ config,
1367
+ textarea,
1368
+ anchor: composerForm ?? textarea,
1369
+ getMessages: () => session.getMessages(),
1370
+ // The engine chunk creates the mention live regions in this container on
1371
+ // mount, keeping the live-region helper out of the core bundle.
1372
+ liveRegionHost: container,
1373
+ });
1374
+
1375
+ if (mentionOrchestrator) {
1376
+ // Chip row sits directly above the textarea.
1377
+ const ta = textarea;
1378
+ ta.parentElement?.insertBefore(mentionOrchestrator.contextRow, ta);
1379
+ // Each channel's "add context" affordance is a secondary control that
1380
+ // augments the outgoing message, so it joins the LEFT action cluster
1381
+ // (beside the attachment button) — never the right cluster with mic +
1382
+ // send. Buttons are inserted leftmost in channel order so they read as
1383
+ // "add to my message" and stay clear of the primary send action. Both
1384
+ // composer builders (full + pill) always ship `leftActions`; the form
1385
+ // fallback is purely defensive.
1386
+ const buttons = mentionOrchestrator.affordanceButtons;
1387
+ for (let i = buttons.length - 1; i >= 0; i--) {
1388
+ const btn = buttons[i];
1389
+ if (leftActions) leftActions.insertBefore(btn, leftActions.firstChild);
1390
+ else composerForm?.appendChild(btn);
1391
+ }
1392
+ // The focus-prefetch listener (warm the chunk on first focus so the first
1393
+ // `@` is instant) is registered through the shared `composerListeners`
1394
+ // registry below, so it survives the inline contenteditable swap too.
1395
+ }
1396
+ }
1397
+
1306
1398
  // Slot system: allow custom content injection into specific regions
1307
1399
  const renderSlots = () => {
1308
1400
  const slots = config.layout?.slots ?? {};
@@ -6174,26 +6266,93 @@ export const createAgentExperience = (
6174
6266
  setOpenState(true, "auto");
6175
6267
  };
6176
6268
 
6177
- const handleSubmit = (event: Event) => {
6178
- event.preventDefault();
6269
+ // Combine `@`-chip mentions with an inline server-command's context bundle so
6270
+ // both reach the message in one `mentions` payload. Either side may be null.
6271
+ type SubmitMentions = NonNullable<
6272
+ ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
6273
+ >;
6274
+ const mergeSubmitMentions = (
6275
+ a: SubmitMentions | null,
6276
+ b: SubmitMentions | null
6277
+ ): SubmitMentions | null => {
6278
+ if (!a) return b;
6279
+ if (!b) return a;
6280
+ return {
6281
+ refs: [...a.refs, ...b.refs],
6282
+ // allSettled, not all: one side rejecting must not discard the other
6283
+ // side's already-resolved context (the bubble already echoed its chips).
6284
+ finalize: async () => {
6285
+ const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
6286
+ const fulfilled: MentionSubmitBundle[] = [];
6287
+ if (ra.status === "fulfilled") fulfilled.push(ra.value);
6288
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
6289
+ if (rb.status === "fulfilled") fulfilled.push(rb.value);
6290
+ else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
6291
+ return mergeFinalizedMentions(fulfilled);
6292
+ },
6293
+ };
6294
+ };
6179
6295
 
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
- }
6296
+ // Inline-mode composer element: after the contenteditable swap, `textarea`
6297
+ // additionally exposes `getInlineMessageFields()` (built in the inline chunk,
6298
+ // which owns the document model the core never imports it). Structural shape
6299
+ // only, so no runtime import crosses the bundle boundary.
6300
+ type InlineComposerFieldsHost = {
6301
+ getInlineMessageFields?: () => {
6302
+ content: string;
6303
+ contextMentions: AgentWidgetContextMentionRef[];
6304
+ contentSegments: AgentWidgetContentSegment[];
6305
+ };
6306
+ };
6307
+ // Read the ordered display segments from the live inline composer, but only
6308
+ // when at least one mention is present: a plain-text inline message must keep
6309
+ // the normal markdown-rendered bubble (segments bypass that path), so we return
6310
+ // undefined for pure prose and let the bubble render `content` as usual.
6311
+ const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
6312
+ const host = textarea as unknown as InlineComposerFieldsHost;
6313
+ const fields = host.getInlineMessageFields?.();
6314
+ if (!fields) return undefined;
6315
+ const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
6316
+ return hasMention ? fields.contentSegments : undefined;
6317
+ };
6318
+
6319
+ // Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
6320
+ // lazy chunk load + host resolve) before the composer clears, and
6321
+ // `isStreaming()` is still false in that window, so a second Enter would
6322
+ // otherwise dispatch the same text/command twice.
6323
+ let submitInFlight = false;
6191
6324
 
6325
+ const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
6192
6326
  const value = textarea.value.trim();
6193
6327
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
6194
6328
 
6195
- // Must have text or attachments to send
6196
- if (!value && !hasAttachments) return;
6329
+ // Inline slash command (Slack-style): every `command:"server"` plus any
6330
+ // arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
6331
+ // text to send, and an action sends nothing at all.
6332
+ const inline = value
6333
+ ? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
6334
+ : null;
6335
+
6336
+ if (inline?.kind === "action") {
6337
+ // Ran in the browser; nothing to send. Clear the composer + any chips.
6338
+ textarea.value = "";
6339
+ textarea.style.height = "auto";
6340
+ resetHistoryNavigation();
6341
+ mentionOrchestrator?.clear();
6342
+ return;
6343
+ }
6344
+
6345
+ // Gather `@`-chip mentions synchronously (detaches chips + captures composer
6346
+ // text before clearing); `finalize()` resolves them inside `sendMessage`.
6347
+ const chipMentions = mentionOrchestrator?.collectForSubmit() ?? null;
6348
+ const serverMentions = inline?.kind === "server" ? inline.mentions : null;
6349
+ const mentions = mergeSubmitMentions(chipMentions, serverMentions);
6350
+ // A prompt command replaces the outgoing text with its resolved macro.
6351
+ const sendText = inline?.kind === "prompt" ? inline.sendText : value;
6352
+
6353
+ const hasChips = !!chipMentions && chipMentions.refs.length > 0;
6354
+ // Must have text, attachments, chips, or an inline server command's context.
6355
+ if (!sendText && !hasAttachments && !hasChips && !serverMentions) return;
6197
6356
 
6198
6357
  maybeExpandComposerBar();
6199
6358
 
@@ -6204,22 +6363,65 @@ export const createAgentExperience = (
6204
6363
  // Add image parts first
6205
6364
  contentParts.push(...attachmentManager!.getContentParts());
6206
6365
  // Add text part if there's text
6207
- if (value) {
6208
- contentParts.push(createTextPart(value));
6366
+ if (sendText) {
6367
+ contentParts.push(createTextPart(sendText));
6209
6368
  }
6210
6369
  }
6211
6370
 
6371
+ // Capture the inline display segments BEFORE clearing the composer (clearing
6372
+ // rebuilds the document as empty text). Only set when a `prompt` macro didn't
6373
+ // replace the outgoing text — a macro's segments no longer match `sendText`.
6374
+ const contentSegments =
6375
+ inline?.kind === "prompt" ? undefined : readInlineContentSegments();
6376
+
6212
6377
  textarea.value = "";
6213
6378
  textarea.style.height = "auto"; // Reset height after clearing
6214
6379
  resetHistoryNavigation();
6215
6380
 
6216
- // Send message with optional content parts
6217
- session.sendMessage(value, { contentParts });
6381
+ // Send message with optional content parts + mentions
6382
+ session.sendMessage(sendText, {
6383
+ contentParts,
6384
+ mentions: mentions ?? undefined,
6385
+ contentSegments,
6386
+ viaVoice: submitOptions?.viaVoice,
6387
+ });
6218
6388
 
6219
- // Clear attachments after sending
6389
+ // Clear attachments + mention chips after sending
6220
6390
  if (hasAttachments) {
6221
6391
  attachmentManager!.clearAttachments();
6222
6392
  }
6393
+ if (chipMentions) {
6394
+ mentionOrchestrator?.clear();
6395
+ }
6396
+ };
6397
+
6398
+ const doSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
6399
+ if (submitInFlight) return;
6400
+ submitInFlight = true;
6401
+ try {
6402
+ await performSubmit(submitOptions);
6403
+ } finally {
6404
+ submitInFlight = false;
6405
+ }
6406
+ };
6407
+
6408
+ const handleSubmit = (event: Event) => {
6409
+ event.preventDefault();
6410
+
6411
+ // While a response is streaming, the submit button acts as a stop button.
6412
+ // Abort the in-flight stream and leave textarea contents / attachments
6413
+ // intact so the user can edit and resend without retyping.
6414
+ if (session.isStreaming()) {
6415
+ session.cancel();
6416
+ // Cancelling emits no terminal/error SSE frame, so reset the throughput
6417
+ // tracker (as clear-chat does) to avoid a stale `running` row lingering.
6418
+ throughputTracker?.reset();
6419
+ eventStreamView?.update();
6420
+ return;
6421
+ }
6422
+
6423
+ if (submitInFlight) return;
6424
+ void doSubmit();
6223
6425
  };
6224
6426
 
6225
6427
  // --- Composer message-history navigation (Up/Down arrows) ---
@@ -6258,15 +6460,30 @@ export const createAgentExperience = (
6258
6460
  textarea.setSelectionRange(end, end);
6259
6461
  };
6260
6462
 
6261
- const handleComposerInput = () => {
6262
- // A real edit leaves history-navigation mode.
6463
+ const handleComposerInput = (event: Event) => {
6464
+ // The synthetic input from history recall (guarded by `suppressHistoryReset`)
6465
+ // is not user typing: it must neither open the mention menu (an undefined
6466
+ // inputType reads as menu-opening) nor exit history-navigation mode.
6263
6467
  if (suppressHistoryReset) return;
6468
+ // Drive the mention menu (open/update/close) + lazy-load on first trigger.
6469
+ // Skip while an IME composition is active: the intermediate value isn't the
6470
+ // user's committed text, so it must not open or filter the menu.
6471
+ if (!(event as InputEvent).isComposing) {
6472
+ mentionOrchestrator?.handleInput((event as InputEvent).inputType ?? undefined);
6473
+ }
6474
+ // A real edit leaves history-navigation mode.
6264
6475
  resetHistoryNavigation();
6265
6476
  };
6266
6477
 
6267
6478
  const handleComposerKeydown = (event: KeyboardEvent) => {
6268
6479
  if (!textarea) return;
6269
6480
 
6481
+ // Mention menu takes precedence when open (↑/↓ nav, Enter/Tab select, Esc
6482
+ // close) and handles Backspace-removes-last-chip on an empty composer. One
6483
+ // handler, no competing capture-phase listener. Skip during IME composition
6484
+ // so Enter-to-confirm-composition isn't swallowed as a menu selection.
6485
+ if (!event.isComposing && mentionOrchestrator?.handleKeydown(event)) return;
6486
+
6270
6487
  // Up/Down: walk through previously sent user messages.
6271
6488
  if (
6272
6489
  historyNavigationEnabled() &&
@@ -6304,6 +6521,12 @@ export const createAgentExperience = (
6304
6521
  event.preventDefault();
6305
6522
  return;
6306
6523
  }
6524
+ // A submit is already awaiting its async pre-send work; a second Enter in
6525
+ // that window would dispatch the same text twice.
6526
+ if (submitInFlight) {
6527
+ event.preventDefault();
6528
+ return;
6529
+ }
6307
6530
  resetHistoryNavigation();
6308
6531
  event.preventDefault();
6309
6532
  sendButton.click();
@@ -6409,9 +6632,11 @@ export const createAgentExperience = (
6409
6632
  const finalValue = textarea.value.trim();
6410
6633
  if (finalValue && speechRecognition && isRecording) {
6411
6634
  stopVoiceRecognition();
6412
- textarea.value = "";
6413
- textarea.style.height = "auto"; // Reset height after clearing
6414
- session.sendMessage(finalValue, { viaVoice: true });
6635
+ // Route through the normal submit path so mentions are collected +
6636
+ // cleared like a manual send (the transcript already lives in the
6637
+ // composer). Sending directly would leave stale tracked mention
6638
+ // context to attach to the next unrelated message.
6639
+ void doSubmit({ viaVoice: true });
6415
6640
  }
6416
6641
  }, pauseDuration);
6417
6642
  }
@@ -6429,9 +6654,9 @@ export const createAgentExperience = (
6429
6654
  if (isRecording) {
6430
6655
  const finalValue = textarea.value.trim();
6431
6656
  if (finalValue && finalValue !== initialText.trim()) {
6432
- textarea.value = "";
6433
- textarea.style.height = "auto"; // Reset height after clearing
6434
- session.sendMessage(finalValue, { viaVoice: true });
6657
+ // Route through the normal submit path (mentions collect + clear), same
6658
+ // as the pause-timer branch above.
6659
+ void doSubmit({ viaVoice: true });
6435
6660
  }
6436
6661
  stopVoiceRecognition();
6437
6662
  }
@@ -7313,9 +7538,43 @@ export const createAgentExperience = (
7313
7538
  if (composerForm) {
7314
7539
  composerForm.addEventListener("submit", handleSubmit);
7315
7540
  }
7316
- textarea?.addEventListener("keydown", handleComposerKeydown);
7317
- textarea?.addEventListener("input", handleComposerInput);
7318
- textarea?.addEventListener("paste", handleInputPaste);
7541
+
7542
+ // Single registry of composer listeners. The initial attach and the inline
7543
+ // contenteditable swap-reattach both consume this array, so a listener added
7544
+ // here is mechanically included in both — no hand-maintained enumeration to
7545
+ // drift. Add new composer listeners here, not as ad-hoc addEventListener calls.
7546
+ type ComposerListener = [event: string, handler: (event: Event) => void];
7547
+ const composerListeners: ComposerListener[] = [
7548
+ ["keydown", handleComposerKeydown as unknown as (event: Event) => void],
7549
+ ["input", handleComposerInput as (event: Event) => void],
7550
+ ["paste", handleInputPaste as unknown as (event: Event) => void],
7551
+ // Warm the mention chunk on first focus so the first `@` is instant.
7552
+ ["focus", mentionPrefetch as (event: Event) => void],
7553
+ ];
7554
+ const attachComposerListeners = (el: HTMLElement | null): void => {
7555
+ if (!el) return;
7556
+ for (const [event, handler] of composerListeners) {
7557
+ el.addEventListener(event, handler);
7558
+ }
7559
+ };
7560
+ const detachComposerListeners = (el: HTMLElement | null): void => {
7561
+ if (!el) return;
7562
+ for (const [event, handler] of composerListeners) {
7563
+ el.removeEventListener(event, handler);
7564
+ }
7565
+ };
7566
+
7567
+ attachComposerListeners(textarea);
7568
+
7569
+ // Inline mention mode swaps the textarea for a contenteditable surface (loaded
7570
+ // lazily). When that happens, move the composer listeners onto the new element
7571
+ // and repoint `textarea` (the swapped element shims the textarea API the rest of
7572
+ // the composer code relies on). Fires immediately if the swap already occurred.
7573
+ mentionOrchestrator?.onComposerSwap((next, prev) => {
7574
+ detachComposerListeners(prev);
7575
+ textarea = next as unknown as HTMLTextAreaElement;
7576
+ attachComposerListeners(next);
7577
+ });
7319
7578
 
7320
7579
  const escStopDoc = mount.ownerDocument ?? document;
7321
7580
  escStopDoc.addEventListener("keydown", handleEscStop, true);
@@ -7396,10 +7655,9 @@ export const createAgentExperience = (
7396
7655
  if (composerForm) {
7397
7656
  composerForm.removeEventListener("submit", handleSubmit);
7398
7657
  }
7399
- textarea?.removeEventListener("keydown", handleComposerKeydown);
7400
- textarea?.removeEventListener("input", handleComposerInput);
7401
- textarea?.removeEventListener("paste", handleInputPaste);
7658
+ detachComposerListeners(textarea);
7402
7659
  escStopDoc.removeEventListener("keydown", handleEscStop, true);
7660
+ mentionOrchestrator?.destroy();
7403
7661
  });
7404
7662
 
7405
7663
  destroyCallbacks.push(() => {
@@ -7427,7 +7685,7 @@ export const createAgentExperience = (
7427
7685
  }
7428
7686
 
7429
7687
  const controller: Controller = {
7430
- update(nextConfig: AgentWidgetConfig) {
7688
+ update(nextConfig: AgentWidgetConfigPatch) {
7431
7689
  const previousToolCallConfig = config.toolCall;
7432
7690
  const previousMessageActions = config.messageActions;
7433
7691
  const previousLayoutMessages = config.layout?.messages;
@@ -7439,7 +7697,10 @@ export const createAgentExperience = (
7439
7697
  const previousToolCallDisplay = config.features?.toolCallDisplay;
7440
7698
  const previousReasoningDisplay = config.features?.reasoningDisplay;
7441
7699
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
7442
- config = { ...config, ...nextConfig };
7700
+ // One consistent recursive patch policy across the live controller and the
7701
+ // init handle. See utils/config-merge.ts for the replace-leaf list and
7702
+ // explicit-undefined reset semantics.
7703
+ config = mergeConfigUpdate(config, nextConfig);
7443
7704
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
7444
7705
  applyFullHeightStyles();
7445
7706
  applyThemeVariables(mount, config);
@@ -7609,7 +7870,10 @@ export const createAgentExperience = (
7609
7870
  headerSubtitle.style.display = headerLayoutConfig.showSubtitle === false ? "none" : "";
7610
7871
  }
7611
7872
  if (closeButton) {
7612
- closeButton.style.display = headerLayoutConfig.showCloseButton === false ? "none" : "";
7873
+ // showCloseButton (defaulted true) filters on top of toggleability;
7874
+ // it must not reveal the close button on non-closeable panels.
7875
+ const showClose = isPanelToggleable() && headerLayoutConfig.showCloseButton !== false;
7876
+ closeButton.style.display = showClose ? "" : "none";
7613
7877
  }
7614
7878
  if (panelElements.clearChatButtonWrapper) {
7615
7879
  // showClearChat explicitly controls visibility when set
@@ -7670,7 +7934,7 @@ export const createAgentExperience = (
7670
7934
  refreshCloseButton();
7671
7935
 
7672
7936
  // Re-render messages if config affecting message rendering changed
7673
- const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
7937
+ const toolCallConfigChanged = JSON.stringify(config.toolCall) !== JSON.stringify(previousToolCallConfig);
7674
7938
  const messageActionsChanged = JSON.stringify(config.messageActions) !== JSON.stringify(previousMessageActions);
7675
7939
  const layoutMessagesChanged = JSON.stringify(config.layout?.messages) !== JSON.stringify(previousLayoutMessages);
7676
7940
  const loadingIndicatorChanged = config.loadingIndicator?.render !== previousLoadingIndicator?.render
@@ -7811,13 +8075,11 @@ export const createAgentExperience = (
7811
8075
  }
7812
8076
 
7813
8077
  if (closeButton) {
7814
- // Handle close button visibility from layout config
7815
- const layoutShowCloseButton = config.layout?.header?.showCloseButton;
7816
- if (layoutShowCloseButton === false) {
7817
- closeButton.style.display = "none";
7818
- } else {
7819
- closeButton.style.display = "";
7820
- }
8078
+ // showCloseButton (defaulted true) filters on top of toggleability;
8079
+ // it must not reveal the close button on non-closeable panels.
8080
+ const layoutShowCloseButton =
8081
+ isPanelToggleable() && config.layout?.header?.showCloseButton !== false;
8082
+ closeButton.style.display = layoutShowCloseButton ? "" : "none";
7821
8083
 
7822
8084
  const closeButtonSize = launcher.closeButtonSize ?? "32px";
7823
8085
  const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
@@ -7908,6 +8170,9 @@ export const createAgentExperience = (
7908
8170
  closeButton.innerHTML = "";
7909
8171
  const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
7910
8172
  if (iconSvg) {
8173
+ // display:block matches the builder; inline SVG baseline spacing
8174
+ // shifts the icon off-center.
8175
+ iconSvg.style.display = "block";
7911
8176
  closeButton.appendChild(iconSvg);
7912
8177
  } else {
7913
8178
  closeButton.textContent = closeButtonIconText;
@@ -8094,8 +8359,13 @@ export const createAgentExperience = (
8094
8359
  // the icon to match its 16px button.
8095
8360
  clearChatButton.innerHTML = "";
8096
8361
  const clearChatIconSize = isComposerBar() ? "14px" : "20px";
8097
- const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 2);
8362
+ // Stroke 1 matches the mount-time builder (header-parts.ts); a
8363
+ // different weight here makes the icon visibly bolden on update.
8364
+ const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 1);
8098
8365
  if (iconSvg) {
8366
+ // display:block matches the builder; inline SVG baseline spacing
8367
+ // shifts the icon off-center.
8368
+ iconSvg.style.display = "block";
8099
8369
  clearChatButton.appendChild(iconSvg);
8100
8370
  }
8101
8371
 
@@ -8298,10 +8568,16 @@ export const createAgentExperience = (
8298
8568
  micButton.style.minWidth = micIconSize;
8299
8569
  micButton.style.minHeight = micIconSize;
8300
8570
 
8301
- // Update icon
8302
- const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
8571
+ // Update icon; color chain and stroke 1.5 match the mount-time
8572
+ // builder (composer-parts.ts) so an unrelated update cannot restyle.
8573
+ const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor;
8303
8574
  micButton.innerHTML = "";
8304
- const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
8575
+ const micIconSvg = renderLucideIcon(
8576
+ micIconName,
8577
+ micIconSizeNum,
8578
+ iconColor || "currentColor",
8579
+ 1.5
8580
+ );
8305
8581
  if (micIconSvg) {
8306
8582
  micButton.appendChild(micIconSvg);
8307
8583
  } else {
@@ -8441,19 +8717,11 @@ export const createAgentExperience = (
8441
8717
  attachmentButton.style.minHeight = attachIconSize;
8442
8718
  attachmentButton.style.fontSize = "18px";
8443
8719
  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
- });
8720
+ // Appearance (bg / fg / border / radius / hover) is themed from the
8721
+ // shared `.persona-attachment-button` CSS rule via the
8722
+ // `--persona-button-ghost-*` tokens — matching the static
8723
+ // createAttachmentControls path. Only sizing stays inline here so
8724
+ // this runtime-created button restyles identically to the built-in one.
8457
8725
 
8458
8726
  const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
8459
8727
  if (attachIconSvg) {
@@ -8475,8 +8743,10 @@ export const createAgentExperience = (
8475
8743
  tooltip.textContent = attachTooltipText;
8476
8744
  attachmentButtonWrapper.appendChild(tooltip);
8477
8745
 
8478
- // Insert into left actions container
8479
- leftActions.append(attachmentButtonWrapper);
8746
+ // Insert into left actions container (fall back to the form when a
8747
+ // custom composer has no left cluster).
8748
+ if (leftActions) leftActions.append(attachmentButtonWrapper);
8749
+ else composerForm.appendChild(attachmentButtonWrapper);
8480
8750
 
8481
8751
  // Initialize attachment manager
8482
8752
  if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
@@ -8491,10 +8761,6 @@ export const createAgentExperience = (
8491
8761
  });
8492
8762
  }
8493
8763
 
8494
- // Create drop overlay if missing
8495
- if (!container.querySelector(".persona-attachment-drop-overlay")) {
8496
- container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
8497
- }
8498
8764
  } else {
8499
8765
  // Show existing attachment button and update config
8500
8766
  attachmentButtonWrapper.style.display = "";
@@ -8515,6 +8781,33 @@ export const createAgentExperience = (
8515
8781
  });
8516
8782
  }
8517
8783
  }
8784
+
8785
+ // Rebuild the overlay so live dropOverlay updates restyle it; visibility
8786
+ // is owned by the container's drop-active class, so this is drag-safe.
8787
+ container.querySelector(".persona-attachment-drop-overlay")?.remove();
8788
+ container.appendChild(buildDropOverlay(config.attachments?.dropOverlay));
8789
+
8790
+ // Re-render icon/tooltip so live buttonIconName/buttonTooltipText
8791
+ // updates apply; the button element itself is created once and kept.
8792
+ if (attachmentButton) {
8793
+ const attCfg = config.attachments ?? {};
8794
+ const tooltipText = attCfg.buttonTooltipText ?? "Attach file";
8795
+ attachmentButton.setAttribute("aria-label", tooltipText);
8796
+ attachmentButton.textContent = "";
8797
+ const btnSize = parseFloat(config.sendButton?.size ?? "40px") || 40;
8798
+ const iconSvg = renderLucideIcon(
8799
+ attCfg.buttonIconName ?? "paperclip",
8800
+ Math.round(btnSize * 0.6),
8801
+ "currentColor",
8802
+ 1.5
8803
+ );
8804
+ if (iconSvg) attachmentButton.appendChild(iconSvg);
8805
+ else attachmentButton.textContent = "📎";
8806
+ const attachTooltip = attachmentButtonWrapper?.querySelector(
8807
+ ".persona-send-button-tooltip"
8808
+ );
8809
+ if (attachTooltip) attachTooltip.textContent = tooltipText;
8810
+ }
8518
8811
  } else {
8519
8812
  // Hide attachment button if disabled
8520
8813
  if (attachmentButtonWrapper) {
@@ -8549,9 +8842,6 @@ export const createAgentExperience = (
8549
8842
  sendButton.style.fontSize = "18px";
8550
8843
  sendButton.style.lineHeight = "1";
8551
8844
 
8552
- // Clear existing content
8553
- sendButton.innerHTML = "";
8554
-
8555
8845
  // Set foreground color from config or theme token
8556
8846
  if (textColor) {
8557
8847
  sendButton.style.color = textColor;
@@ -8559,20 +8849,25 @@ export const createAgentExperience = (
8559
8849
  sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
8560
8850
  }
8561
8851
 
8562
- // Use Lucide icon if iconName is provided, otherwise fall back to iconText
8563
- if (iconName) {
8564
- const iconSize = parseFloat(buttonSize) || 24;
8565
- const iconColor = textColor?.trim() || "currentColor";
8566
- const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
8567
- if (iconSvg) {
8568
- sendButton.appendChild(iconSvg);
8852
+ // Skip the icon-content re-render while streaming: the button holds the
8853
+ // stop icon (owned by setSendButtonMode), and clobbering it here would
8854
+ // show the send arrow mid-stream while the aria-label still says stop.
8855
+ if (!session.isStreaming()) {
8856
+ sendButton.innerHTML = "";
8857
+ if (iconName) {
8858
+ const iconSize = parseFloat(buttonSize) || 24;
8859
+ const iconColor = textColor?.trim() || "currentColor";
8860
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
8861
+ if (iconSvg) {
8862
+ sendButton.appendChild(iconSvg);
8863
+ } else {
8864
+ sendButton.textContent = iconText;
8865
+ }
8569
8866
  } else {
8570
8867
  sendButton.textContent = iconText;
8571
8868
  }
8572
- } else {
8573
- sendButton.textContent = iconText;
8574
8869
  }
8575
-
8870
+
8576
8871
  // Update classes
8577
8872
  sendButton.className = "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer";
8578
8873