@runtypelabs/persona 4.7.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 (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
package/src/ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
2
  import { resolveSanitizer } from "./utils/sanitize";
3
3
  import { stabilizeStreamingTables } from "./utils/streaming-table";
4
- import { loadMarkdownParsers, getMarkdownParsersSync } from "./markdown-parsers-loader";
4
+ import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parsers-loader";
5
5
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
6
6
  import {
7
7
  AgentWidgetConfig,
@@ -28,14 +28,25 @@ import {
28
28
  VoiceStatus,
29
29
  ReadAloudState,
30
30
  PersonaArtifactRecord,
31
- PersonaArtifactManualUpsert
31
+ PersonaArtifactManualUpsert,
32
+ PersonaArtifactFileMeta,
33
+ PersonaArtifactActionContext,
34
+ AgentWidgetContentSegment,
35
+ AgentWidgetContextMentionRef
32
36
  } from "./types";
33
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";
34
43
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
35
44
  import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
36
45
  import { resolveTokenValue } from "./utils/tokens";
37
46
  import { renderLucideIcon } from "./utils/icons";
38
47
  import { createElement, createElementInDocument } from "./utils/dom";
48
+ import { downloadInfoFor } from "./utils/artifact-file";
49
+ import { artifactCopyText } from "./components/artifact-preview";
39
50
  import { morphMessages } from "./utils/morph";
40
51
  import { normalizeCopiedSelectionText } from "./utils/copy-selection";
41
52
  import {
@@ -106,12 +117,18 @@ import { EventStreamStore } from "./utils/event-stream-store";
106
117
  import { ThroughputTracker } from "./utils/throughput-tracker";
107
118
  import { createEventStreamView } from "./components/event-stream-view";
108
119
  import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
120
+ import {
121
+ hasLiveInlineArtifactBlock,
122
+ updateInlineArtifactBlocks
123
+ } from "./components/artifact-inline";
109
124
  import {
110
125
  artifactsSidebarEnabled,
111
126
  applyArtifactLayoutCssVars,
112
127
  applyArtifactPaneAppearance,
128
+ isArtifactPaneAppearanceDetached,
113
129
  shouldExpandLauncherForArtifacts
114
130
  } from "./utils/artifact-gate";
131
+ import { resolveArtifactDisplayMode } from "./utils/artifact-display";
115
132
  import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
116
133
  import { enhanceWithForms } from "./components/forms";
117
134
  import { pluginRegistry } from "./plugins/registry";
@@ -139,6 +156,8 @@ import {
139
156
  // Default localStorage key for chat history (automatically cleared on clear chat)
140
157
  const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
141
158
  const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
159
+ // Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
160
+ const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
142
161
 
143
162
  const IMAGE_FILE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
144
163
  "image/png": "png",
@@ -523,6 +542,35 @@ function buildDropOverlay(
523
542
  return overlay;
524
543
  }
525
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
+
526
574
  export const createAgentExperience = (
527
575
  mount: HTMLElement,
528
576
  initialConfig?: AgentWidgetConfig,
@@ -828,9 +876,13 @@ export const createAgentExperience = (
828
876
  header,
829
877
  footer,
830
878
  actionsRow: _actionsRow,
831
- leftActions,
832
879
  rightActions
833
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;
834
886
  let setSendButtonMode = panelElements.setSendButtonMode;
835
887
 
836
888
  // Use mutable references for mic button so we can update them dynamically
@@ -969,6 +1021,9 @@ export const createAgentExperience = (
969
1021
 
970
1022
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
971
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;
972
1027
 
973
1028
  /** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
974
1029
  let composerVoiceBridge: (() => void) | null = null;
@@ -1239,6 +1294,10 @@ export const createAgentExperience = (
1239
1294
  ".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
1240
1295
  );
1241
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");
1242
1301
  };
1243
1302
  ensureComposerAttachmentSurface(footer);
1244
1303
  bindComposerRefsFromFooter(footer);
@@ -1291,6 +1350,49 @@ export const createAgentExperience = (
1291
1350
  container.appendChild(overlay);
1292
1351
  }
1293
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
+
1294
1396
  // Slot system: allow custom content injection into specific regions
1295
1397
  const renderSlots = () => {
1296
1398
  const slots = config.layout?.slots ?? {};
@@ -1670,32 +1772,66 @@ export const createAgentExperience = (
1670
1772
 
1671
1773
  let artifactPaneApi: ArtifactPaneApi | null = null;
1672
1774
  let artifactPanelResizeObs: ResizeObserver | null = null;
1775
+ // Re-runs panel chrome when the split-chrome mode flips (pane
1776
+ // open/close/appearance change). Assigned once applyFullHeightStyles exists.
1777
+ let syncPanelChrome: () => void = () => {};
1778
+ let appliedSplitMode: "none" | "welded" | "detached" = "none";
1779
+ // Resolved welded outer-right radius stashed by applyFullHeightStyles and
1780
+ // applied in syncPanelChrome (after applyArtifactPaneAppearance, which no
1781
+ // longer manages the var, so the two never fight). Null clears it.
1782
+ let weldedOuterRadius: string | null = null;
1673
1783
  let lastArtifactsState: {
1674
1784
  artifacts: PersonaArtifactRecord[];
1675
1785
  selectedId: string | null;
1676
1786
  } = { artifacts: [], selectedId: null };
1677
1787
  let artifactsPaneUserHidden = false;
1788
+ // Runtime-only expand state: pane fills the split root and the chat column
1789
+ // hides. Reset whenever the pane stops being visible (syncArtifactPane).
1790
+ let artifactPaneExpanded = false;
1791
+ // Set when the expansion came from an inline block's Expand button: that is
1792
+ // an explicit "fullscreen this file" request, so it survives the
1793
+ // showExpandToggle gate below (which otherwise collapses the pane for hosts
1794
+ // without the toolbar toggle). Cleared whenever the pane collapses or hides.
1795
+ let artifactPaneExpandedPinned = false;
1796
+ // Whether the user explicitly opened the pane (card click, inline Expand,
1797
+ // showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
1798
+ // artifacts whose resolved display mode is "panel": "card" keeps the card as
1799
+ // the only affordance and "inline" renders in the transcript. "inline" no
1800
+ // longer *auto*-opens the pane, but its Expand control is a deliberate,
1801
+ // user-driven open (setting artifactsPaneUserOpened, same as a card click);
1802
+ // every mode still writes the session artifact registry (download /
1803
+ // getArtifacts / hydration).
1804
+ let artifactsPaneUserOpened = false;
1805
+ const artifactPaneCanShow = () =>
1806
+ artifactsPaneUserOpened ||
1807
+ lastArtifactsState.artifacts.some(
1808
+ (a) =>
1809
+ resolveArtifactDisplayMode(config.features?.artifacts, a.artifactType) === "panel"
1810
+ );
1811
+ const artifactPaneVisible = () =>
1812
+ lastArtifactsState.artifacts.length > 0 &&
1813
+ !artifactsPaneUserHidden &&
1814
+ artifactPaneCanShow();
1678
1815
  const sessionRef: { current: AgentWidgetSession | null } = { current: null };
1679
1816
 
1680
- // Click delegation for artifact download buttons
1681
- messagesWrapper.addEventListener('click', (event) => {
1682
- const target = event.target as HTMLElement;
1683
- const dlBtn = target.closest('[data-download-artifact]') as HTMLElement;
1684
- if (!dlBtn) return;
1685
- event.preventDefault();
1686
- event.stopPropagation();
1687
- const artifactId = dlBtn.getAttribute('data-download-artifact');
1688
- if (!artifactId) return;
1689
- // Let integrator intercept
1690
- const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
1691
- if (dlPrevented === true) return;
1692
- // Try session state first, fall back to content stored in the card's rawContent props
1817
+ // Resolve an artifact's content for card actions (download + custom actions).
1818
+ // Tries the live session registry first, then falls back to the content
1819
+ // persisted in the card message's rawContent props (session state is gone
1820
+ // after a page refresh). `fromEl` is the clicked element inside the card.
1821
+ const resolveCardArtifactContent = (
1822
+ fromEl: HTMLElement,
1823
+ artifactId: string
1824
+ ): { markdown?: string; title: string; file?: PersonaArtifactFileMeta; artifactType: string } => {
1693
1825
  const artifact = session.getArtifactById(artifactId);
1694
1826
  let markdown = artifact?.markdown;
1695
1827
  let title = artifact?.title || 'artifact';
1828
+ let file: PersonaArtifactFileMeta | undefined = artifact?.file;
1829
+ let artifactType: string = artifact?.artifactType ?? 'markdown';
1696
1830
  if (!markdown) {
1697
- // After page refresh, session state is gone: read from the persisted card message
1698
- const cardEl = dlBtn.closest('[data-open-artifact]');
1831
+ // Match both reference cards and inline blocks: inline hydration embeds
1832
+ // the markdown / file source in the same message rawContent, so the parse
1833
+ // below resolves content for inline copy / custom actions after a refresh.
1834
+ const cardEl = fromEl.closest('[data-open-artifact], [data-artifact-inline]');
1699
1835
  const msgEl = cardEl?.closest('[data-message-id]');
1700
1836
  const msgId = msgEl?.getAttribute('data-message-id');
1701
1837
  if (msgId) {
@@ -1706,23 +1842,159 @@ export const createAgentExperience = (
1706
1842
  const parsed = JSON.parse(msg.rawContent);
1707
1843
  markdown = parsed?.props?.markdown;
1708
1844
  title = parsed?.props?.title || title;
1845
+ if (parsed?.props?.file && typeof parsed.props.file === 'object') {
1846
+ file = parsed.props.file as PersonaArtifactFileMeta;
1847
+ }
1848
+ if (!artifact && typeof parsed?.props?.artifactType === 'string') {
1849
+ artifactType = parsed.props.artifactType;
1850
+ }
1709
1851
  } catch { /* ignore */ }
1710
1852
  }
1711
1853
  }
1712
1854
  }
1855
+ return { markdown, title, file, artifactType };
1856
+ };
1857
+
1858
+ // Click delegation for artifact download buttons
1859
+ messagesWrapper.addEventListener('click', (event) => {
1860
+ const target = event.target as HTMLElement;
1861
+ const dlBtn = target.closest('[data-download-artifact]') as HTMLElement;
1862
+ if (!dlBtn) return;
1863
+ event.preventDefault();
1864
+ event.stopPropagation();
1865
+ const artifactId = dlBtn.getAttribute('data-download-artifact');
1866
+ if (!artifactId) return;
1867
+ // Let integrator intercept
1868
+ const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
1869
+ if (dlPrevented === true) return;
1870
+ const { markdown, title, file } = resolveCardArtifactContent(dlBtn, artifactId);
1713
1871
  if (!markdown) return;
1714
- const blob = new Blob([markdown], { type: 'text/markdown' });
1872
+ // File artifacts download the raw unfenced source under their real name/MIME;
1873
+ // non-file markdown artifacts keep the legacy `<title>.md` / text/markdown path.
1874
+ const { filename, mime, content } = downloadInfoFor({ title, markdown, file });
1875
+ const blob = new Blob([content], { type: mime });
1715
1876
  const url = URL.createObjectURL(blob);
1716
1877
  const a = document.createElement('a');
1717
1878
  a.href = url;
1718
- a.download = `${title}.md`;
1879
+ a.download = filename;
1719
1880
  a.click();
1720
1881
  URL.revokeObjectURL(url);
1721
1882
  });
1722
1883
 
1884
+ // Click delegation for integrator-supplied card action buttons. Actions are
1885
+ // looked up by id from fresh config at click time so live config updates
1886
+ // apply. Like the download listener, its stopPropagation() cannot block the
1887
+ // card-open listener on the same element, so that listener skips these too.
1888
+ messagesWrapper.addEventListener('click', (event) => {
1889
+ const target = event.target as HTMLElement;
1890
+ const actionBtn = target.closest('[data-artifact-custom-action]') as HTMLElement;
1891
+ if (!actionBtn) return;
1892
+ event.preventDefault();
1893
+ event.stopPropagation();
1894
+ const actionId = actionBtn.getAttribute('data-artifact-custom-action');
1895
+ if (!actionId) return;
1896
+ // The same attribute serves cards and inline chrome; resolve which surface
1897
+ // the button lives in so the action comes from the matching config list and
1898
+ // the artifact id from the matching container attribute.
1899
+ const inlineEl = actionBtn.closest('[data-artifact-inline]');
1900
+ const cardEl = inlineEl ? null : actionBtn.closest('[data-open-artifact]');
1901
+ const artifactId = inlineEl
1902
+ ? inlineEl.getAttribute('data-artifact-inline')
1903
+ : cardEl?.getAttribute('data-open-artifact') ?? null;
1904
+ const actionList = inlineEl
1905
+ ? config.features?.artifacts?.inlineActions
1906
+ : config.features?.artifacts?.cardActions;
1907
+ const action = actionList?.find((a) => a.id === actionId);
1908
+ if (!action) return;
1909
+ const { markdown, title, file, artifactType } = resolveCardArtifactContent(actionBtn, artifactId ?? '');
1910
+ const ctx: PersonaArtifactActionContext = { artifactId, title, artifactType, markdown, file };
1911
+ try {
1912
+ void Promise.resolve(action.onClick(ctx)).catch(() => {});
1913
+ } catch {
1914
+ /* ignore */
1915
+ }
1916
+ });
1917
+
1918
+ // Click delegation for the inline chrome Copy button. Like the download /
1919
+ // custom-action listeners, it resolves content from the live registry first,
1920
+ // then falls back to the persisted inline props after a refresh, and its
1921
+ // stopPropagation() cannot block a second listener on the same element (there
1922
+ // is none here, but the pattern is kept consistent).
1923
+ messagesWrapper.addEventListener('click', (event) => {
1924
+ const target = event.target as HTMLElement;
1925
+ const copyBtn = target.closest('[data-copy-artifact]') as HTMLElement;
1926
+ if (!copyBtn) return;
1927
+ event.preventDefault();
1928
+ event.stopPropagation();
1929
+ const artifactId = copyBtn.getAttribute('data-copy-artifact');
1930
+ if (!artifactId) return;
1931
+ // Prefer the live record (covers component JSON); fall back to the persisted
1932
+ // markdown / file source parsed from the inline block's message rawContent.
1933
+ const artifact = session.getArtifactById(artifactId);
1934
+ let text = '';
1935
+ if (artifact) {
1936
+ text = artifactCopyText(artifact);
1937
+ } else {
1938
+ const { markdown, file, artifactType } = resolveCardArtifactContent(copyBtn, artifactId);
1939
+ if (artifactType === 'markdown') {
1940
+ text = artifactCopyText({
1941
+ id: artifactId,
1942
+ artifactType: 'markdown',
1943
+ status: 'complete',
1944
+ markdown: markdown ?? '',
1945
+ ...(file ? { file } : {})
1946
+ });
1947
+ }
1948
+ }
1949
+ if (!text) return;
1950
+ navigator.clipboard.writeText(text).then(() => {
1951
+ // Lightweight feedback: swap the copy glyph for a check briefly.
1952
+ const checkIcon = renderLucideIcon('check', 16, 'currentColor', 2);
1953
+ if (checkIcon) {
1954
+ copyBtn.replaceChildren(checkIcon);
1955
+ setTimeout(() => {
1956
+ const copyIcon = renderLucideIcon('copy', 16, 'currentColor', 2);
1957
+ if (copyIcon) copyBtn.replaceChildren(copyIcon);
1958
+ }, 1500);
1959
+ }
1960
+ }).catch(() => { /* ignore */ });
1961
+ });
1962
+
1963
+ // Click delegation for the inline chrome Expand button: open this artifact in
1964
+ // the pane. Fires onArtifactAction({ type: "open" }) first so hosts can
1965
+ // intercept, then mirrors the card-open path — except the pane opens
1966
+ // expanded (fullscreen), never split: the inline block already shows the
1967
+ // full preview at chat width, so a split pane would only duplicate it, and
1968
+ // the click means "expand this file". The pinned flag keeps it expanded
1969
+ // even when layout.showExpandToggle is off; Close is the exit there.
1970
+ messagesWrapper.addEventListener('click', (event) => {
1971
+ const target = event.target as HTMLElement;
1972
+ const expandBtn = target.closest('[data-expand-artifact-inline]') as HTMLElement;
1973
+ if (!expandBtn) return;
1974
+ event.preventDefault();
1975
+ event.stopPropagation();
1976
+ const artifactId = expandBtn.getAttribute('data-expand-artifact-inline');
1977
+ if (!artifactId) return;
1978
+ const openPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'open', artifactId });
1979
+ if (openPrevented === true) return;
1980
+ artifactsPaneUserHidden = false;
1981
+ // Expand is an explicit open: it overrides the "inline" auto-open
1982
+ // suppression for as long as artifacts exist (same as a card click).
1983
+ artifactsPaneUserOpened = true;
1984
+ artifactPaneExpanded = true;
1985
+ artifactPaneExpandedPinned = true;
1986
+ session.selectArtifact(artifactId);
1987
+ syncArtifactPane();
1988
+ });
1989
+
1723
1990
  // Click delegation for artifact reference cards
1724
1991
  messagesWrapper.addEventListener('click', (event) => {
1725
1992
  const target = event.target as HTMLElement;
1993
+ // Download and custom-action clicks are handled by the listeners above;
1994
+ // their stopPropagation() cannot block a second listener on the same
1995
+ // element, so skip them here explicitly or the card would also open the panel.
1996
+ if (target.closest('[data-download-artifact]')) return;
1997
+ if (target.closest('[data-artifact-custom-action]')) return;
1726
1998
  const card = target.closest('[data-open-artifact]') as HTMLElement;
1727
1999
  if (!card) return;
1728
2000
  const artifactId = card.getAttribute('data-open-artifact');
@@ -1733,15 +2005,24 @@ export const createAgentExperience = (
1733
2005
  event.preventDefault();
1734
2006
  event.stopPropagation();
1735
2007
  artifactsPaneUserHidden = false;
2008
+ // Card click is an explicit open: it overrides the "card"/"inline"
2009
+ // auto-open suppression for as long as artifacts exist.
2010
+ artifactsPaneUserOpened = true;
1736
2011
  session.selectArtifact(artifactId);
1737
2012
  syncArtifactPane();
1738
2013
  });
1739
2014
 
1740
- // Keyboard support for artifact cards
2015
+ // Keyboard support for artifact cards and the clip-mode inline expand hitbox
2016
+ // (both expose a data-* attribute on a focusable element; Enter/Space forward
2017
+ // to the element's own click, which the delegated click handlers above own).
1741
2018
  messagesWrapper.addEventListener('keydown', (event) => {
1742
2019
  if (event.key !== 'Enter' && event.key !== ' ') return;
1743
2020
  const target = event.target as HTMLElement;
1744
- if (!target.hasAttribute('data-open-artifact')) return;
2021
+ if (
2022
+ !target.hasAttribute('data-open-artifact') &&
2023
+ !target.hasAttribute('data-expand-artifact-inline')
2024
+ )
2025
+ return;
1745
2026
  event.preventDefault();
1746
2027
  target.click();
1747
2028
  });
@@ -2130,6 +2411,12 @@ export const createAgentExperience = (
2130
2411
  let artifactResizeUnbind: (() => void) | null = null;
2131
2412
  let artifactResizeDocEnd: (() => void) | null = null;
2132
2413
  let reconcileArtifactResize: () => void = () => {};
2414
+ // Expanded-state transition tracking: stash the resizer's inline width/maxWidth
2415
+ // (which would otherwise beat the expanded class) once per enter, restore once
2416
+ // per leave.
2417
+ let artifactExpandedApplied = false;
2418
+ let artifactStashedWidth = "";
2419
+ let artifactStashedMaxWidth = "";
2133
2420
 
2134
2421
  function stopArtifactResizePointer() {
2135
2422
  artifactResizeDocEnd?.();
@@ -2139,7 +2426,9 @@ export const createAgentExperience = (
2139
2426
  /** Flush split: overlay handle on the seam so it does not consume flex gap (extension + resizable). */
2140
2427
  const positionExtensionArtifactResizeHandle = () => {
2141
2428
  if (!artifactSplitRoot || !artifactResizeHandle) return;
2142
- const ext = mount.classList.contains("persona-artifact-appearance-seamless");
2429
+ // Welded splits (panel + seamless) have gap 0, so the handle overlays the seam
2430
+ // instead of consuming flex width; detached keeps a real gap and a flex handle.
2431
+ const ext = mount.classList.contains("persona-artifact-welded-split");
2143
2432
  const ownerWin = mount.ownerDocument.defaultView ?? window;
2144
2433
  const mobile = ownerWin.innerWidth <= 640;
2145
2434
  if (!ext || mount.classList.contains("persona-artifact-narrow-host") || mobile) {
@@ -2159,13 +2448,45 @@ export const createAgentExperience = (
2159
2448
  artifactResizeHandle.style.bottom = "0";
2160
2449
  artifactResizeHandle.style.width = `${hitW}px`;
2161
2450
  artifactResizeHandle.style.zIndex = "5";
2162
- const left = chat.offsetWidth - hitW / 2;
2451
+ // Center the overlay on the seam: with an explicit welded gap the seam sits
2452
+ // half a gap past the chat's right edge (welded default gap is 0).
2453
+ const gapPx = readFlexGapPx(artifactSplitRoot, ownerWin);
2454
+ const left = chat.offsetWidth + gapPx / 2 - hitW / 2;
2163
2455
  artifactResizeHandle.style.left = `${Math.max(0, left)}px`;
2164
2456
  };
2165
2457
 
2166
2458
  /** No-op until artifact pane is created; replaced below when artifacts are enabled. */
2167
2459
  let applyLauncherArtifactPanelWidth: () => void = () => {};
2168
2460
 
2461
+ // Mobile fullscreen renders a flush, chrome-less panel, so the split chrome
2462
+ // must stand down there. This is the union of the two fullscreen predicates
2463
+ // applyFullHeightStyles/recalcPanelHeight use: launcher fullscreen
2464
+ // (shouldGoFullscreen) and the docked-host variant (dockedHostFullscreen).
2465
+ // Unlike the 641px split breakpoint, it honors the configurable mobileBreakpoint.
2466
+ const isMobileFullscreenActive = (): boolean => {
2467
+ const win = mount.ownerDocument.defaultView ?? window;
2468
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
2469
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
2470
+ if (!mobileFullscreen || win.innerWidth > mobileBreakpoint) return false;
2471
+ return launcherEnabled || isDockedMountMode(config);
2472
+ };
2473
+
2474
+ // Desktop side-by-side split chrome mode. 'welded' = panel|seamless (one card,
2475
+ // border+radius wrap both columns); 'detached' = two floating cards; 'none' when
2476
+ // the pane is closed, the narrow-host drawer, or mobile fullscreen.
2477
+ const splitChromeMode = (): "none" | "welded" | "detached" => {
2478
+ if (!artifactsSidebarEnabled(config)) return "none";
2479
+ if (!artifactPaneVisible()) return "none";
2480
+ if (mount.classList.contains("persona-artifact-narrow-host")) return "none";
2481
+ // A fullscreen panel is flush; welded/detached corner chrome would fight it.
2482
+ if (isMobileFullscreenActive()) return "none";
2483
+ const win = mount.ownerDocument.defaultView ?? window;
2484
+ // Must match the artifact split media queries in widget.css (drawer at
2485
+ // max-width:640, split at min-width:641); mobileBreakpoint does not move it.
2486
+ if (win.innerWidth < ARTIFACT_SPLIT_DESKTOP_MIN) return "none";
2487
+ return isArtifactPaneAppearanceDetached(config) ? "detached" : "welded";
2488
+ };
2489
+
2169
2490
  const syncArtifactPane = () => {
2170
2491
  if (!artifactPaneApi || !artifactsSidebarEnabled(config)) return;
2171
2492
  applyArtifactLayoutCssVars(mount, config);
@@ -2174,16 +2495,79 @@ export const createAgentExperience = (
2174
2495
  const threshold = config.features?.artifacts?.layout?.narrowHostMaxWidth ?? 520;
2175
2496
  const w = panel.getBoundingClientRect().width || 0;
2176
2497
  mount.classList.toggle("persona-artifact-narrow-host", w > 0 && w <= threshold);
2498
+ // Thread the single source of truth for "pane surface is shown" into the
2499
+ // pane BEFORE update() so it renders lazily: in inline/card display modes
2500
+ // the pane stays hidden and must not build a second sandboxed artifact
2501
+ // iframe (which would execute artifact scripts twice). The pane records
2502
+ // state while hidden and renders on the next reveal. Set first so an
2503
+ // update() that arrives while hidden is skipped, not rendered-then-hidden.
2504
+ const paneVisible = artifactPaneVisible();
2505
+ artifactPaneApi.setVisible(paneVisible);
2177
2506
  artifactPaneApi.update(lastArtifactsState);
2178
2507
  if (artifactsPaneUserHidden) {
2179
2508
  artifactPaneApi.setMobileOpen(false);
2180
2509
  artifactPaneApi.element.classList.add("persona-hidden");
2181
2510
  artifactPaneApi.backdrop?.classList.add("persona-hidden");
2182
- } else if (lastArtifactsState.artifacts.length > 0) {
2511
+ artifactPaneExpanded = false;
2512
+ artifactPaneExpandedPinned = false;
2513
+ } else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
2183
2514
  // User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
2184
2515
  // and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
2516
+ // Artifacts whose display mode is "card" or "inline" don't auto-open the
2517
+ // pane (artifactPaneCanShow); it stays hidden until an explicit open or
2518
+ // until a "panel"-mode artifact arrives.
2185
2519
  artifactPaneApi.element.classList.remove("persona-hidden");
2186
2520
  artifactPaneApi.setMobileOpen(true);
2521
+ } else {
2522
+ // The pane's own update() unhides itself whenever records exist
2523
+ // (applyLayoutVisibility), so re-assert the gate here: card/inline-mode
2524
+ // artifacts keep the pane hidden until an explicit open.
2525
+ artifactPaneApi.setMobileOpen(false);
2526
+ artifactPaneApi.element.classList.add("persona-hidden");
2527
+ artifactPaneApi.backdrop?.classList.add("persona-hidden");
2528
+ artifactPaneExpanded = false;
2529
+ artifactPaneExpandedPinned = false;
2530
+ }
2531
+ // Re-read the toggle config on every sync so a live config.update() can
2532
+ // reveal/remove the button (the pane itself is built once). Disabling the
2533
+ // toggle while expanded also collapses the pane — unless the expansion is
2534
+ // pinned (inline Expand): that fullscreen request stands on its own and
2535
+ // exits via Close.
2536
+ const expandToggleEnabled = config.features?.artifacts?.layout?.showExpandToggle === true;
2537
+ artifactPaneApi.setExpandToggleVisible(expandToggleEnabled);
2538
+ artifactPaneApi.setCopyButtonVisible(
2539
+ config.features?.artifacts?.layout?.showCopyButton === true
2540
+ );
2541
+ artifactPaneApi.setCustomActions(config.features?.artifacts?.toolbarActions ?? []);
2542
+ artifactPaneApi.setTabFade(config.features?.artifacts?.layout?.tabFade);
2543
+ artifactPaneApi.setRenderTabBar(config.features?.artifacts?.renderTabBar);
2544
+ if (!expandToggleEnabled && !artifactPaneExpandedPinned) artifactPaneExpanded = false;
2545
+ // Run the resizer stash/restore once per expanded-state transition: the
2546
+ // resizer's inline width/maxWidth beats the expanded class, so clear it while
2547
+ // expanded and put it back on collapse.
2548
+ if (artifactPaneExpanded !== artifactExpandedApplied) {
2549
+ const paneEl = artifactPaneApi.element;
2550
+ if (artifactPaneExpanded) {
2551
+ artifactStashedWidth = paneEl.style.width;
2552
+ artifactStashedMaxWidth = paneEl.style.maxWidth;
2553
+ paneEl.style.removeProperty("width");
2554
+ paneEl.style.removeProperty("max-width");
2555
+ } else {
2556
+ if (artifactStashedWidth) paneEl.style.width = artifactStashedWidth;
2557
+ if (artifactStashedMaxWidth) paneEl.style.maxWidth = artifactStashedMaxWidth;
2558
+ artifactStashedWidth = "";
2559
+ artifactStashedMaxWidth = "";
2560
+ }
2561
+ artifactExpandedApplied = artifactPaneExpanded;
2562
+ }
2563
+ mount.classList.toggle("persona-artifact-expanded", artifactPaneExpanded);
2564
+ artifactPaneApi.setExpanded(artifactPaneExpanded);
2565
+ // Recompute panel chrome when the split mode changes (pane open/close or
2566
+ // appearance) so the welded border and detached shadow suppression land.
2567
+ const modeNow = splitChromeMode();
2568
+ if (modeNow !== appliedSplitMode) {
2569
+ appliedSplitMode = modeNow;
2570
+ syncPanelChrome();
2187
2571
  }
2188
2572
  reconcileArtifactResize();
2189
2573
  };
@@ -2204,6 +2588,22 @@ export const createAgentExperience = (
2204
2588
  onDismiss: () => {
2205
2589
  artifactsPaneUserHidden = true;
2206
2590
  syncArtifactPane();
2591
+ },
2592
+ onToggleExpand: () => {
2593
+ const next = !artifactPaneExpanded;
2594
+ const artifactId =
2595
+ lastArtifactsState.selectedId ??
2596
+ lastArtifactsState.artifacts[lastArtifactsState.artifacts.length - 1]?.id ??
2597
+ null;
2598
+ const prevented = config.features?.artifacts?.onArtifactAction?.({
2599
+ type: "expand",
2600
+ artifactId,
2601
+ expanded: next,
2602
+ });
2603
+ if (prevented === true) return;
2604
+ artifactPaneExpanded = next;
2605
+ if (!next) artifactPaneExpandedPinned = false;
2606
+ syncArtifactPane();
2207
2607
  }
2208
2608
  });
2209
2609
  artifactPaneApi.element.classList.add("persona-hidden");
@@ -2246,6 +2646,7 @@ export const createAgentExperience = (
2246
2646
  const onPointerDown = (e: PointerEvent) => {
2247
2647
  if (!artifactPaneApi || e.button !== 0) return;
2248
2648
  if (mount.classList.contains("persona-artifact-narrow-host")) return;
2649
+ if (mount.classList.contains("persona-artifact-expanded")) return;
2249
2650
  if (win.innerWidth <= 640) return;
2250
2651
  e.preventDefault();
2251
2652
  stopArtifactResizePointer();
@@ -2254,9 +2655,13 @@ export const createAgentExperience = (
2254
2655
  const layout = config.features?.artifacts?.layout;
2255
2656
  const onMove = (ev: PointerEvent) => {
2256
2657
  const splitW = artifactSplitRoot!.getBoundingClientRect().width;
2257
- const extensionChrome = mount.classList.contains("persona-artifact-appearance-seamless");
2258
- const gapPx = extensionChrome ? 0 : readFlexGapPx(artifactSplitRoot!, win);
2259
- const handleW = extensionChrome ? 0 : handle.getBoundingClientRect().width || 6;
2658
+ const weldedChrome = mount.classList.contains("persona-artifact-welded-split");
2659
+ // Read the effective flex gap either way: welded defaults to 0 but an
2660
+ // explicit splitGap opens a real gap that still consumes row width.
2661
+ const gapPx = readFlexGapPx(artifactSplitRoot!, win);
2662
+ // Welded overlays the handle on the seam (out of flow), so it consumes
2663
+ // no width; detached keeps it as an in-flow flex child.
2664
+ const handleW = weldedChrome ? 0 : handle.getBoundingClientRect().width || 6;
2260
2665
  // Handle is left of the artifact: drag left widens artifact, drag right narrows it.
2261
2666
  const next = startW - (ev.clientX - startX);
2262
2667
  const clamped = resolveArtifactPaneWidthPx(
@@ -2301,8 +2706,7 @@ export const createAgentExperience = (
2301
2706
  };
2302
2707
  }
2303
2708
  if (artifactResizeHandle) {
2304
- const has =
2305
- lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
2709
+ const has = artifactPaneVisible();
2306
2710
  artifactResizeHandle.classList.toggle("persona-hidden", !has);
2307
2711
  positionExtensionArtifactResizeHandle();
2308
2712
  }
@@ -2323,8 +2727,7 @@ export const createAgentExperience = (
2323
2727
  const expanded =
2324
2728
  config.features?.artifacts?.layout?.expandedPanelWidth ??
2325
2729
  "min(720px, calc(100vw - 24px))";
2326
- const hasVisible =
2327
- lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
2730
+ const hasVisible = artifactPaneVisible();
2328
2731
  if (hasVisible) {
2329
2732
  panel.style.width = expanded;
2330
2733
  panel.style.maxWidth = expanded;
@@ -2342,21 +2745,23 @@ export const createAgentExperience = (
2342
2745
  }
2343
2746
  } else {
2344
2747
  panel.appendChild(container);
2345
- // Composer-bar mode: the pill (footer) and peek banner live in a
2346
- // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2347
- // independent of the wrapper's geometry transitions. Critical for
2348
- // modal mode: the wrapper there has `transform: translate(-50%, -50%)`
2349
- // which would establish a containing block trapping any `position: fixed`
2350
- // descendant. Order inside pillRoot: peekBanner (slim row above pill)
2351
- // footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
2352
- // default until ui.ts toggles `.persona-pill-peek--visible` based on
2353
- // streaming/hover/open state via syncComposerBarPeek().
2354
- if (isComposerBar() && pillRoot) {
2355
- if (panelElements.peekBanner) {
2356
- pillRoot.appendChild(panelElements.peekBanner);
2357
- }
2358
- pillRoot.appendChild(footer);
2359
- }
2748
+ }
2749
+
2750
+ // Composer-bar mode: the pill (footer) and peek banner live in a
2751
+ // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2752
+ // independent of both the wrapper's geometry transitions and the panel's
2753
+ // optional artifact split layout. Critical for modal mode: the wrapper
2754
+ // there has `transform: translate(-50%, -50%)`, which would establish a
2755
+ // containing block trapping any `position: fixed` descendant.
2756
+ //
2757
+ // Order inside pillRoot: peekBanner (slim row above pill) footer (pill).
2758
+ // pillRoot's `gap` spaces them; the peek is hidden by default until ui.ts
2759
+ // toggles `.persona-pill-peek--visible` based on streaming/hover/open state.
2760
+ if (isComposerBar() && pillRoot) {
2761
+ if (panelElements.peekBanner) {
2762
+ pillRoot.appendChild(panelElements.peekBanner);
2763
+ }
2764
+ pillRoot.appendChild(footer);
2360
2765
  }
2361
2766
  mount.appendChild(wrapper);
2362
2767
  // pillRoot is mounted *after* wrapper so it naturally stacks on top
@@ -2415,6 +2820,8 @@ export const createAgentExperience = (
2415
2820
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
2416
2821
  /** Script-tag / div embed: launcher off, host supplies a sized mount. */
2417
2822
  const isInlineEmbed = config.launcher?.enabled === false;
2823
+ /** Detached appearance: inset card over a canvas instead of flush chrome. */
2824
+ const isDetached = config.launcher?.detachedPanel === true;
2418
2825
  const panelPartial = config.theme?.components?.panel;
2419
2826
  const activeTheme = getActiveTheme(config);
2420
2827
  const resolvePanelChrome = (raw: string | undefined, fallback: string): string => {
@@ -2429,33 +2836,105 @@ export const createAgentExperience = (
2429
2836
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
2430
2837
  const isMobileViewport = ownerWindow.innerWidth <= mobileBreakpoint;
2431
2838
  const shouldGoFullscreen = mobileFullscreen && isMobileViewport && launcherEnabled;
2839
+ // Docked host layout forces fullscreen on mobile even when the launcher is
2840
+ // off (host-layout pins the slot fixed and clears its chrome). Suppress the
2841
+ // detached card so it never paints chrome over a flush fullscreen panel.
2842
+ const dockedHostFullscreen = dockedMode && mobileFullscreen && isMobileViewport;
2432
2843
 
2433
2844
  // Determine panel styling based on mode, with theme overrides
2434
2845
  const position = config.launcher?.position ?? 'bottom-left';
2435
2846
  const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
2436
2847
  const overlayZIndex = config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX;
2437
2848
 
2849
+ // Card chrome defaults (floating look): reused to restore detached chrome.
2850
+ // Defaults chain through the aliases themeToCssVariables emits so explicit
2851
+ // theme.components.panel overrides and these defaults never diverge.
2852
+ const cardBorder = 'var(--persona-panel-border, 1px solid var(--persona-border))';
2853
+ const cardShadow = 'var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))';
2854
+ const cardRadius = 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
2855
+ /** Detached restores card chrome except when a host layout goes fullscreen. */
2856
+ const detachedCard = isDetached && !shouldGoFullscreen && !dockedHostFullscreen;
2857
+ // Stamp reflects rendered chrome: cleared when a fullscreen host layout
2858
+ // suppresses the card, so the attribute never lies to the detached CSS.
2859
+ if (detachedCard) {
2860
+ mount.setAttribute("data-persona-panel-detached", "true");
2861
+ } else {
2862
+ mount.removeAttribute("data-persona-panel-detached");
2863
+ }
2864
+
2438
2865
  // Default values based on mode
2439
- let defaultPanelBorder = (sidebarMode || shouldGoFullscreen) ? 'none' : '1px solid var(--persona-border)';
2440
- let defaultPanelShadow = shouldGoFullscreen
2441
- ? 'none'
2442
- : sidebarMode
2443
- ? (isLeftSidebar ? 'var(--persona-palette-shadows-sidebar-left, 2px 0 12px rgba(0, 0, 0, 0.08))' : 'var(--persona-palette-shadows-sidebar-right, -2px 0 12px rgba(0, 0, 0, 0.08))')
2444
- : 'var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25))';
2445
-
2446
- if (dockedMode && !shouldGoFullscreen) {
2866
+ let defaultPanelBorder = detachedCard
2867
+ ? cardBorder
2868
+ : (sidebarMode || shouldGoFullscreen) ? 'none' : cardBorder;
2869
+ let defaultPanelShadow = detachedCard
2870
+ ? cardShadow
2871
+ : shouldGoFullscreen
2872
+ ? 'none'
2873
+ : sidebarMode
2874
+ ? (isLeftSidebar ? 'var(--persona-palette-shadows-sidebar-left, 2px 0 12px rgba(0, 0, 0, 0.08))' : 'var(--persona-palette-shadows-sidebar-right, -2px 0 12px rgba(0, 0, 0, 0.08))')
2875
+ // Flush inline embeds fill their container: no elevation by default
2876
+ // (detachedPanel or components.panel.shadow opts back in).
2877
+ : isInlineEmbed ? 'none' : cardShadow;
2878
+
2879
+ if (dockedMode && !shouldGoFullscreen && !detachedCard) {
2447
2880
  defaultPanelShadow = 'none';
2448
2881
  defaultPanelBorder = 'none';
2449
2882
  }
2450
- const defaultPanelBorderRadius = (sidebarMode || shouldGoFullscreen)
2451
- ? '0'
2452
- : 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
2883
+ const defaultPanelBorderRadius = detachedCard
2884
+ ? cardRadius
2885
+ : (sidebarMode || shouldGoFullscreen) ? '0' : cardRadius;
2453
2886
 
2454
2887
  // Apply theme overrides or defaults (components.panel.*)
2455
2888
  const panelBorder = resolvePanelChrome(panelPartial?.border, defaultPanelBorder);
2456
2889
  const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
2457
2890
  const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
2458
2891
 
2892
+ // Split chrome: 'welded' folds the card border onto the outer panel so it
2893
+ // wraps both columns as one card (shadow/radius already on the panel);
2894
+ // 'detached' pulls them into two individually carded columns.
2895
+ const splitMode = splitChromeMode();
2896
+ const detachedSplitActive = splitMode === "detached";
2897
+ const weldedSplitActive = splitMode === "welded";
2898
+ mount.classList.toggle("persona-artifact-detached-split", detachedSplitActive);
2899
+ mount.classList.toggle("persona-artifact-welded-split", weldedSplitActive);
2900
+ const chatCardShadow =
2901
+ 'var(--persona-artifact-chat-shadow, var(--persona-artifact-pane-shadow, var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))))';
2902
+ // Welded moves the chat card border onto the panel, so the chat column goes
2903
+ // borderless; detached cards it individually; otherwise it keeps the border.
2904
+ // 'flush' drops the chat card so the chat is flat flush background and only
2905
+ // the pane floats; 'card' (default) keeps two matched cards. Flush is a
2906
+ // steady state: it applies whether or not a pane is open, so opening or
2907
+ // closing an artifact never flips the chat chrome. Gated to inline embeds
2908
+ // (not docked), matching the card-mode perimeter inset: the flush model only
2909
+ // fits the container-filling embed. Floating/docked/sidebar keep the card look.
2910
+ const rawChatSurface = config.features?.artifacts?.layout?.chatSurface;
2911
+ const resolvedChatSurface = rawChatSurface === "flush" ? "flush" : "card";
2912
+ const chatFlush =
2913
+ resolvedChatSurface === "flush" &&
2914
+ artifactsSidebarEnabled(config) &&
2915
+ isArtifactPaneAppearanceDetached(config) &&
2916
+ isInlineEmbed &&
2917
+ !dockedMode;
2918
+ mount.classList.toggle("persona-artifact-chat-flush", chatFlush);
2919
+ // Flat means flat: flush suppresses the outer panel shadow in every state.
2920
+ const outerPanelShadow = detachedSplitActive || chatFlush ? 'none' : panelShadow;
2921
+ let chatContainerBorder = detachedSplitActive
2922
+ ? cardBorder
2923
+ : weldedSplitActive
2924
+ ? 'none'
2925
+ : panelBorder;
2926
+ let chatContainerRadius = detachedSplitActive ? cardRadius : panelBorderRadius;
2927
+ if (chatFlush) {
2928
+ chatContainerBorder = 'none';
2929
+ chatContainerRadius = '0';
2930
+ }
2931
+ // Flush fills the container flush, so the outer panel squares off by default;
2932
+ // the pane keeps its own rounded radius. An explicit panel.borderRadius wins.
2933
+ const panelRadiusExplicit =
2934
+ panelPartial?.borderRadius != null && panelPartial.borderRadius !== '';
2935
+ const appliedPanelRadius =
2936
+ chatFlush && !panelRadiusExplicit ? '0' : panelBorderRadius;
2937
+
2459
2938
  // Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
2460
2939
  // `min-height: 0` / `overflow-y: auto` that make the messages area a
2461
2940
  // scroll container. Between the reset and the mode-specific reapply,
@@ -2575,7 +3054,7 @@ export const createAgentExperience = (
2575
3054
  }
2576
3055
  } else if (dockedMode) {
2577
3056
  const dockReveal = resolveDockConfig(config).reveal;
2578
- if (dockReveal === "emerge") {
3057
+ if (dockReveal === "emerge" && !isDetached) {
2579
3058
  const dw = resolveDockConfig(config).width;
2580
3059
  panel.style.width = dw;
2581
3060
  panel.style.maxWidth = dw;
@@ -2590,12 +3069,38 @@ export const createAgentExperience = (
2590
3069
  // Box-shadow is applied to panel (parent) instead of container to avoid
2591
3070
  // rendering artifacts when container has overflow:hidden + border-radius
2592
3071
  // Panel also gets border-radius to make the shadow follow the rounded corners
2593
- panel.style.boxShadow = panelShadow;
2594
- panel.style.borderRadius = panelBorderRadius;
2595
- container.style.border = panelBorder;
2596
- container.style.borderRadius = panelBorderRadius;
3072
+ panel.style.boxShadow = outerPanelShadow;
3073
+ panel.style.borderRadius = appliedPanelRadius;
3074
+ if (detachedSplitActive) panel.style.border = 'none';
3075
+ else if (weldedSplitActive) panel.style.border = panelBorder;
3076
+ container.style.border = chatContainerBorder;
3077
+ container.style.borderRadius = chatContainerRadius;
3078
+ container.style.boxShadow = detachedSplitActive && !chatFlush ? chatCardShadow : '';
3079
+ // Flush chat has no card surface of its own: override the class-painted
3080
+ // container/body/footer backgrounds so the wrapper's canvas shows through.
3081
+ // The footer's top hairline is chat-card chrome, so it goes too. Element
3082
+ // surfaces (bubbles, cards, composer input) keep their own backgrounds.
3083
+ if (chatFlush) {
3084
+ container.style.background = 'transparent';
3085
+ body.style.background = 'transparent';
3086
+ footer.style.background = 'transparent';
3087
+ footer.style.borderTop = 'none';
3088
+ }
3089
+
3090
+ // Welded outer-right radius: derive the pane's outer corners from the same
3091
+ // resolved value as the chat card's left corners so a custom
3092
+ // components.panel.borderRadius stays symmetric. Explicit
3093
+ // unifiedSplitOuterRadius / paneBorderRadius still win. Applied in
3094
+ // syncPanelChrome after applyArtifactPaneAppearance (which no longer owns it).
3095
+ if (weldedSplitActive) {
3096
+ const wl = config.features?.artifacts?.layout;
3097
+ weldedOuterRadius =
3098
+ wl?.unifiedSplitOuterRadius?.trim() || wl?.paneBorderRadius?.trim() || panelBorderRadius;
3099
+ } else {
3100
+ weldedOuterRadius = null;
3101
+ }
2597
3102
 
2598
- if (dockedMode && !shouldGoFullscreen && panelPartial?.border === undefined) {
3103
+ if (dockedMode && !shouldGoFullscreen && !detachedCard && !detachedSplitActive && !weldedSplitActive && panelPartial?.border === undefined) {
2599
3104
  container.style.border = 'none';
2600
3105
  const dockSide = resolveDockConfig(config).side;
2601
3106
  if (dockSide === 'right') {
@@ -2605,6 +3110,19 @@ export const createAgentExperience = (
2605
3110
  }
2606
3111
  }
2607
3112
 
3113
+ // Docked + welded: the welded chrome owner is the outer panel, and docked
3114
+ // mode resolves its border to none, so add the dock-facing hairline there so
3115
+ // the split still separates from the host page. Mirrors the flush block's
3116
+ // side choice (right dock => left edge faces the page).
3117
+ if (dockedMode && !shouldGoFullscreen && weldedSplitActive && panelPartial?.border === undefined) {
3118
+ const dockSide = resolveDockConfig(config).side;
3119
+ if (dockSide === 'right') {
3120
+ panel.style.borderLeft = '1px solid var(--persona-border)';
3121
+ } else {
3122
+ panel.style.borderRight = '1px solid var(--persona-border)';
3123
+ }
3124
+ }
3125
+
2608
3126
  if (fullHeight) {
2609
3127
  // Mount container
2610
3128
  mount.style.display = 'flex';
@@ -2635,8 +3153,10 @@ export const createAgentExperience = (
2635
3153
  panel.style.minHeight = '0';
2636
3154
  panel.style.maxHeight = '100%';
2637
3155
  panel.style.height = '100%';
2638
- panel.style.overflow = 'hidden';
2639
-
3156
+ // Detached split: each card clips its own content and carries its own
3157
+ // shadow, so panel overflow:hidden must not clip the cards' shadows.
3158
+ if (!detachedSplitActive) panel.style.overflow = 'hidden';
3159
+
2640
3160
  // Main container
2641
3161
  container.style.display = 'flex';
2642
3162
  container.style.flexDirection = 'column';
@@ -2653,7 +3173,19 @@ export const createAgentExperience = (
2653
3173
  // Footer (composer) - should not shrink
2654
3174
  footer.style.flexShrink = '0';
2655
3175
  }
2656
-
3176
+
3177
+ // Inline embed: pad the wrapper so the canvas shows around the card when the
3178
+ // panel is a detached card OR a detached split is open (insets the whole split
3179
+ // from the container edges on all sides). Docked owns its inset via
3180
+ // host-layout, so exclude it (no double gap).
3181
+ // Flush chat must be flush to the container, so it skips the perimeter inset;
3182
+ // the wrapper still paints the canvas so one token colors the whole backdrop,
3183
+ // in every state (flush is steady, so idle must match pane-open).
3184
+ if (isInlineEmbed && (detachedCard || detachedSplitActive || chatFlush) && !dockedMode) {
3185
+ if (!chatFlush) wrapper.style.padding = 'var(--persona-panel-inset)';
3186
+ wrapper.style.background = 'var(--persona-panel-canvas-bg)';
3187
+ }
3188
+
2657
3189
  // Handle positioning classes based on mode
2658
3190
  // First remove all position classes to reset state
2659
3191
  wrapper.classList.remove(
@@ -2670,22 +3202,40 @@ export const createAgentExperience = (
2670
3202
  // Apply sidebar-specific styles
2671
3203
  if (sidebarMode) {
2672
3204
  const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
2673
-
2674
- // Wrapper - fixed position, flush with edges
2675
- wrapper.style.cssText = `
2676
- position: fixed !important;
2677
- top: 0 !important;
2678
- bottom: 0 !important;
2679
- width: ${sidebarWidth} !important;
2680
- height: 100vh !important;
2681
- max-height: 100vh !important;
2682
- margin: 0 !important;
2683
- padding: 0 !important;
2684
- display: flex !important;
2685
- flex-direction: column !important;
2686
- z-index: ${overlayZIndex} !important;
2687
- ${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
2688
- `;
3205
+
3206
+ // Wrapper - fixed position. Detached insets the card off the edges and
3207
+ // shrinks its height by the inset on both ends; flush hugs the edges.
3208
+ if (isDetached) {
3209
+ wrapper.style.cssText = `
3210
+ position: fixed !important;
3211
+ top: var(--persona-panel-inset) !important;
3212
+ bottom: var(--persona-panel-inset) !important;
3213
+ width: ${sidebarWidth} !important;
3214
+ height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
3215
+ max-height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
3216
+ margin: 0 !important;
3217
+ padding: 0 !important;
3218
+ display: flex !important;
3219
+ flex-direction: column !important;
3220
+ z-index: ${overlayZIndex} !important;
3221
+ ${isLeftSidebar ? 'left: var(--persona-panel-inset) !important; right: auto !important;' : 'left: auto !important; right: var(--persona-panel-inset) !important;'}
3222
+ `;
3223
+ } else {
3224
+ wrapper.style.cssText = `
3225
+ position: fixed !important;
3226
+ top: 0 !important;
3227
+ bottom: 0 !important;
3228
+ width: ${sidebarWidth} !important;
3229
+ height: 100vh !important;
3230
+ max-height: 100vh !important;
3231
+ margin: 0 !important;
3232
+ padding: 0 !important;
3233
+ display: flex !important;
3234
+ flex-direction: column !important;
3235
+ z-index: ${overlayZIndex} !important;
3236
+ ${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
3237
+ `;
3238
+ }
2689
3239
 
2690
3240
  // Panel - fill wrapper (override inline width/max-width from panel.ts)
2691
3241
  // Box-shadow is on panel to avoid rendering artifacts with container's overflow:hidden
@@ -2701,15 +3251,16 @@ export const createAgentExperience = (
2701
3251
  min-height: 0 !important;
2702
3252
  margin: 0 !important;
2703
3253
  padding: 0 !important;
2704
- box-shadow: ${panelShadow} !important;
2705
- border-radius: ${panelBorderRadius} !important;
3254
+ box-shadow: ${outerPanelShadow} !important;
3255
+ border-radius: ${appliedPanelRadius} !important;
3256
+ ${detachedSplitActive ? 'border: none !important;' : weldedSplitActive ? `border: ${panelBorder} !important;` : ''}
2706
3257
  `;
2707
3258
  // Force override any inline width/maxWidth that may be set elsewhere
2708
3259
  panel.style.setProperty('width', '100%', 'important');
2709
3260
  panel.style.setProperty('max-width', '100%', 'important');
2710
-
2711
- // Container - apply configurable styles with sidebar layout
2712
- // Note: box-shadow is on panel, not container
3261
+
3262
+ // Container - apply configurable styles with sidebar layout. Box-shadow is
3263
+ // normally on panel, but a detached split cards the chat column here.
2713
3264
  container.style.cssText = `
2714
3265
  display: flex !important;
2715
3266
  flex-direction: column !important;
@@ -2719,8 +3270,10 @@ export const createAgentExperience = (
2719
3270
  min-height: 0 !important;
2720
3271
  max-height: 100% !important;
2721
3272
  overflow: hidden !important;
2722
- border-radius: ${panelBorderRadius} !important;
2723
- border: ${panelBorder} !important;
3273
+ border-radius: ${chatContainerRadius} !important;
3274
+ border: ${chatContainerBorder} !important;
3275
+ ${detachedSplitActive && !chatFlush ? `box-shadow: ${chatCardShadow} !important;` : ''}
3276
+ ${chatFlush ? 'background: transparent !important;' : ''}
2724
3277
  `;
2725
3278
 
2726
3279
  // Remove footer border in sidebar mode
@@ -2728,6 +3281,7 @@ export const createAgentExperience = (
2728
3281
  flex-shrink: 0 !important;
2729
3282
  border-top: none !important;
2730
3283
  padding: 8px 16px 12px 16px !important;
3284
+ ${chatFlush ? 'background: transparent !important;' : ''}
2731
3285
  `;
2732
3286
  }
2733
3287
 
@@ -2752,6 +3306,22 @@ export const createAgentExperience = (
2752
3306
  applyArtifactLayoutCssVars(mount, config);
2753
3307
  applyArtifactPaneAppearance(mount, config);
2754
3308
 
3309
+ // applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
3310
+ // artifact layout vars after it, mirroring the init sequence above.
3311
+ syncPanelChrome = () => {
3312
+ applyFullHeightStyles();
3313
+ applyThemeVariables(mount, config);
3314
+ applyArtifactLayoutCssVars(mount, config);
3315
+ applyArtifactPaneAppearance(mount, config);
3316
+ // Owned here so it lands after applyArtifactPaneAppearance and derives from
3317
+ // the same resolved panel radius the chat card uses (see applyFullHeightStyles).
3318
+ if (weldedOuterRadius) {
3319
+ mount.style.setProperty("--persona-artifact-welded-outer-radius", weldedOuterRadius);
3320
+ } else {
3321
+ mount.style.removeProperty("--persona-artifact-welded-outer-radius");
3322
+ }
3323
+ };
3324
+
2755
3325
  const destroyCallbacks: Array<() => void> = [];
2756
3326
  // Clean up the document-level digit-key shortcut listener registered earlier.
2757
3327
  destroyCallbacks.push(() => {
@@ -4001,15 +4571,57 @@ export const createAgentExperience = (
4001
4571
  if (directive) {
4002
4572
  const lastFp = lastComponentDirectiveFingerprint.get(message.id);
4003
4573
  const needsRebuild = lastFp !== fingerprint;
4004
- const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
4574
+ // Wrap only when the global default allows it AND the component has
4575
+ // not opted out of bubble chrome (e.g. the artifact card carries its
4576
+ // own border, so it renders bare).
4577
+ const wrapChrome =
4578
+ config.wrapComponentDirectiveInBubble !== false &&
4579
+ componentRegistry.getOptions(directive.component)?.bubbleChrome !== false;
4005
4580
  let liveBubble: HTMLElement | null = null;
4006
4581
 
4007
4582
  if (needsRebuild) {
4008
- const componentBubble = renderComponentDirective(directive, {
4583
+ let componentBubble = renderComponentDirective(directive, {
4009
4584
  config,
4010
4585
  message,
4011
4586
  transform
4012
4587
  });
4588
+ // Reuse a mounted inline artifact block instead of the fresh
4589
+ // render: replacing it would cut the collapse-to-card animation
4590
+ // short and reload live file-preview iframes.
4591
+ if (
4592
+ componentBubble &&
4593
+ directive.component === "PersonaArtifactInline"
4594
+ ) {
4595
+ const fresh = componentBubble.hasAttribute(
4596
+ "data-artifact-inline"
4597
+ )
4598
+ ? componentBubble
4599
+ : componentBubble.querySelector<HTMLElement>(
4600
+ "[data-artifact-inline]"
4601
+ );
4602
+ const artifactId =
4603
+ fresh?.getAttribute("data-artifact-inline") ?? "";
4604
+ const escapedId =
4605
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
4606
+ ? CSS.escape(artifactId)
4607
+ : artifactId;
4608
+ const live = artifactId
4609
+ ? (container
4610
+ .querySelector<HTMLElement>(`#wrapper-${message.id}`)
4611
+ ?.querySelector<HTMLElement>(
4612
+ `[data-artifact-inline="${escapedId}"]`
4613
+ ) ?? null)
4614
+ : null;
4615
+ if (
4616
+ fresh &&
4617
+ live &&
4618
+ live !== fresh &&
4619
+ hasLiveInlineArtifactBlock(live)
4620
+ ) {
4621
+ if (fresh === componentBubble) componentBubble = live;
4622
+ else fresh.replaceWith(live);
4623
+ }
4624
+ }
4013
4625
  if (componentBubble) {
4014
4626
  if (wrapChrome) {
4015
4627
  const componentWrapper = document.createElement("div");
@@ -4175,6 +4787,12 @@ export const createAgentExperience = (
4175
4787
  currentGroup.push(message);
4176
4788
  return;
4177
4789
  }
4790
+ // A hidden reasoning row does not create a visible break in the
4791
+ // transcript, so it should not split an otherwise contiguous tool
4792
+ // sequence into separate groups.
4793
+ if (message.variant === "reasoning" && !showReasoning) {
4794
+ return;
4795
+ }
4178
4796
  if (currentGroup.length > 1) {
4179
4797
  toolGroups.push(currentGroup);
4180
4798
  }
@@ -4229,11 +4847,19 @@ export const createAgentExperience = (
4229
4847
  const stack = document.createElement("div");
4230
4848
  stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
4231
4849
 
4232
- groupContainer.append(summary, stack);
4850
+ const summaryOnly = config.features?.toolCallDisplay?.groupedMode === "summary";
4851
+ groupContainer.appendChild(summary);
4852
+ if (!summaryOnly) {
4853
+ groupContainer.appendChild(stack);
4854
+ }
4233
4855
  groupWrapper.appendChild(groupContainer);
4234
4856
  wrappers[0].before(groupWrapper);
4235
4857
 
4236
4858
  wrappers.forEach((wrapper, wrapperIndex) => {
4859
+ if (summaryOnly) {
4860
+ wrapper.remove();
4861
+ return;
4862
+ }
4237
4863
  const item = document.createElement("div");
4238
4864
  item.className = "persona-tool-group-item persona-relative";
4239
4865
  item.setAttribute("data-persona-tool-group-item", "true");
@@ -5297,6 +5923,12 @@ export const createAgentExperience = (
5297
5923
  if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
5298
5924
  }
5299
5925
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
5926
+ // Freshly (re)built inline artifact blocks render from their persisted
5927
+ // props; sync them with the live registry so a block created after the
5928
+ // last onArtifactsState emission still shows current content.
5929
+ updateInlineArtifactBlocks(messagesWrapper, lastArtifactsState.artifacts, {
5930
+ suppressTransition: isStreaming,
5931
+ });
5300
5932
  ensureToolElapsedTimer();
5301
5933
  renderSuggestions(messages);
5302
5934
  scheduleAutoScroll(!isStreaming);
@@ -5467,6 +6099,20 @@ export const createAgentExperience = (
5467
6099
  },
5468
6100
  onArtifactsState(state) {
5469
6101
  lastArtifactsState = state;
6102
+ // A cleared registry ends any explicit-open override: the next artifact
6103
+ // decides pane visibility purely from its own display mode.
6104
+ if (state.artifacts.length === 0) {
6105
+ artifactsPaneUserOpened = false;
6106
+ }
6107
+ // Route streaming registry updates (artifact_delta / artifact_complete)
6108
+ // into any inline artifact blocks in the transcript. Suppress the
6109
+ // streaming→complete View Transition while the session is still
6110
+ // streaming: it captures the whole document, and cross-fading a stale
6111
+ // snapshot over still-moving message text reads as ghosting/motion blur
6112
+ // on the transcript.
6113
+ updateInlineArtifactBlocks(messagesWrapper, state.artifacts, {
6114
+ suppressTransition: isStreaming,
6115
+ });
5470
6116
  syncArtifactPane();
5471
6117
  persistState();
5472
6118
  },
@@ -5618,26 +6264,93 @@ export const createAgentExperience = (
5618
6264
  setOpenState(true, "auto");
5619
6265
  };
5620
6266
 
5621
- const handleSubmit = (event: Event) => {
5622
- 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
+ };
5623
6293
 
5624
- // While a response is streaming, the submit button acts as a stop button.
5625
- // Abort the in-flight stream and leave textarea contents / attachments
5626
- // intact so the user can edit and resend without retyping.
5627
- if (session.isStreaming()) {
5628
- session.cancel();
5629
- // Cancelling emits no terminal/error SSE frame, so reset the throughput
5630
- // tracker (as clear-chat does) to avoid a stale `running` row lingering.
5631
- throughputTracker?.reset();
5632
- eventStreamView?.update();
5633
- return;
5634
- }
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
+ };
5635
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 }) => {
5636
6324
  const value = textarea.value.trim();
5637
6325
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
5638
6326
 
5639
- // Must have text or attachments to send
5640
- 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;
5641
6354
 
5642
6355
  maybeExpandComposerBar();
5643
6356
 
@@ -5648,22 +6361,65 @@ export const createAgentExperience = (
5648
6361
  // Add image parts first
5649
6362
  contentParts.push(...attachmentManager!.getContentParts());
5650
6363
  // Add text part if there's text
5651
- if (value) {
5652
- contentParts.push(createTextPart(value));
6364
+ if (sendText) {
6365
+ contentParts.push(createTextPart(sendText));
5653
6366
  }
5654
6367
  }
5655
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
+
5656
6375
  textarea.value = "";
5657
6376
  textarea.style.height = "auto"; // Reset height after clearing
5658
6377
  resetHistoryNavigation();
5659
6378
 
5660
- // Send message with optional content parts
5661
- 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
+ });
5662
6386
 
5663
- // Clear attachments after sending
6387
+ // Clear attachments + mention chips after sending
5664
6388
  if (hasAttachments) {
5665
6389
  attachmentManager!.clearAttachments();
5666
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();
5667
6423
  };
5668
6424
 
5669
6425
  // --- Composer message-history navigation (Up/Down arrows) ---
@@ -5702,15 +6458,30 @@ export const createAgentExperience = (
5702
6458
  textarea.setSelectionRange(end, end);
5703
6459
  };
5704
6460
 
5705
- const handleComposerInput = () => {
5706
- // 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.
5707
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.
5708
6473
  resetHistoryNavigation();
5709
6474
  };
5710
6475
 
5711
6476
  const handleComposerKeydown = (event: KeyboardEvent) => {
5712
6477
  if (!textarea) return;
5713
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
+
5714
6485
  // Up/Down: walk through previously sent user messages.
5715
6486
  if (
5716
6487
  historyNavigationEnabled() &&
@@ -5748,6 +6519,12 @@ export const createAgentExperience = (
5748
6519
  event.preventDefault();
5749
6520
  return;
5750
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
+ }
5751
6528
  resetHistoryNavigation();
5752
6529
  event.preventDefault();
5753
6530
  sendButton.click();
@@ -5853,9 +6630,11 @@ export const createAgentExperience = (
5853
6630
  const finalValue = textarea.value.trim();
5854
6631
  if (finalValue && speechRecognition && isRecording) {
5855
6632
  stopVoiceRecognition();
5856
- textarea.value = "";
5857
- textarea.style.height = "auto"; // Reset height after clearing
5858
- 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 });
5859
6638
  }
5860
6639
  }, pauseDuration);
5861
6640
  }
@@ -5873,9 +6652,9 @@ export const createAgentExperience = (
5873
6652
  if (isRecording) {
5874
6653
  const finalValue = textarea.value.trim();
5875
6654
  if (finalValue && finalValue !== initialText.trim()) {
5876
- textarea.value = "";
5877
- textarea.style.height = "auto"; // Reset height after clearing
5878
- 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 });
5879
6658
  }
5880
6659
  stopVoiceRecognition();
5881
6660
  }
@@ -6396,18 +7175,36 @@ export const createAgentExperience = (
6396
7175
 
6397
7176
  try {
6398
7177
  if (shouldGoFullscreen) {
6399
- applyFullHeightStyles();
6400
- applyThemeVariables(mount, config);
7178
+ // syncPanelChrome re-applies theme + artifact layout vars that the
7179
+ // cssText reset in applyFullHeightStyles wipes; bare re-style flickers.
7180
+ syncPanelChrome();
7181
+ appliedSplitMode = splitChromeMode();
6401
7182
  return;
6402
7183
  }
6403
7184
 
6404
7185
  // Exiting mobile fullscreen (e.g., orientation change to landscape): reset all styles
7186
+ let chromeResynced = false;
6405
7187
  if (wasMobileFullscreen) {
6406
7188
  wasMobileFullscreen = false;
6407
- applyFullHeightStyles();
6408
- applyThemeVariables(mount, config);
7189
+ syncPanelChrome();
7190
+ chromeResynced = true;
6409
7191
  }
6410
7192
 
7193
+ // Width-only resize can cross the 640 split boundary without changing
7194
+ // panel geometry (pane ResizeObserver stays silent) or hitting the
7195
+ // fullscreen branches, so resync chrome when the split mode flips.
7196
+ const modeNow = splitChromeMode();
7197
+ if (!chromeResynced && modeNow !== appliedSplitMode) {
7198
+ syncPanelChrome();
7199
+ chromeResynced = true;
7200
+ }
7201
+ appliedSplitMode = modeNow;
7202
+ // syncPanelChrome does not touch the resize handle, so a width-only flip
7203
+ // between welded (seam overlay) and detached/flex handle would leave it in
7204
+ // the old mode. reconcileArtifactResize re-places it; it never re-enters
7205
+ // syncArtifactPane, so no recursion. No-op when artifacts are disabled.
7206
+ if (chromeResynced) reconcileArtifactResize();
7207
+
6411
7208
  if (!launcherEnabled && !dockedMode) {
6412
7209
  panel.style.height = "";
6413
7210
  panel.style.width = "";
@@ -6739,9 +7536,43 @@ export const createAgentExperience = (
6739
7536
  if (composerForm) {
6740
7537
  composerForm.addEventListener("submit", handleSubmit);
6741
7538
  }
6742
- textarea?.addEventListener("keydown", handleComposerKeydown);
6743
- textarea?.addEventListener("input", handleComposerInput);
6744
- 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
+ });
6745
7576
 
6746
7577
  const escStopDoc = mount.ownerDocument ?? document;
6747
7578
  escStopDoc.addEventListener("keydown", handleEscStop, true);
@@ -6822,10 +7653,9 @@ export const createAgentExperience = (
6822
7653
  if (composerForm) {
6823
7654
  composerForm.removeEventListener("submit", handleSubmit);
6824
7655
  }
6825
- textarea?.removeEventListener("keydown", handleComposerKeydown);
6826
- textarea?.removeEventListener("input", handleComposerInput);
6827
- textarea?.removeEventListener("paste", handleInputPaste);
7656
+ detachComposerListeners(textarea);
6828
7657
  escStopDoc.removeEventListener("keydown", handleEscStop, true);
7658
+ mentionOrchestrator?.destroy();
6829
7659
  });
6830
7660
 
6831
7661
  destroyCallbacks.push(() => {
@@ -7867,19 +8697,11 @@ export const createAgentExperience = (
7867
8697
  attachmentButton.style.minHeight = attachIconSize;
7868
8698
  attachmentButton.style.fontSize = "18px";
7869
8699
  attachmentButton.style.lineHeight = "1";
7870
- attachmentButton.style.backgroundColor = "transparent";
7871
- attachmentButton.style.color = "var(--persona-primary, #111827)";
7872
- attachmentButton.style.border = "none";
7873
- attachmentButton.style.borderRadius = "6px";
7874
- attachmentButton.style.transition = "background-color 0.15s ease";
7875
-
7876
- // Add hover effect via mouseenter/mouseleave
7877
- attachmentButton.addEventListener("mouseenter", () => {
7878
- attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
7879
- });
7880
- attachmentButton.addEventListener("mouseleave", () => {
7881
- attachmentButton!.style.backgroundColor = "transparent";
7882
- });
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.
7883
8705
 
7884
8706
  const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
7885
8707
  if (attachIconSvg) {
@@ -7901,8 +8723,10 @@ export const createAgentExperience = (
7901
8723
  tooltip.textContent = attachTooltipText;
7902
8724
  attachmentButtonWrapper.appendChild(tooltip);
7903
8725
 
7904
- // Insert into left actions container
7905
- 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);
7906
8730
 
7907
8731
  // Initialize attachment manager
7908
8732
  if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
@@ -8385,6 +9209,7 @@ export const createAgentExperience = (
8385
9209
  showArtifacts(): void {
8386
9210
  if (!artifactsSidebarEnabled(config)) return;
8387
9211
  artifactsPaneUserHidden = false;
9212
+ artifactsPaneUserOpened = true;
8388
9213
  syncArtifactPane();
8389
9214
  artifactPaneApi?.setMobileOpen(true);
8390
9215
  },
@@ -8395,8 +9220,20 @@ export const createAgentExperience = (
8395
9220
  },
8396
9221
  upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord | null {
8397
9222
  if (!artifactsSidebarEnabled(config)) return null;
8398
- // Programmatic adds should surface the pane even if the user previously hit Close.
8399
- artifactsPaneUserHidden = false;
9223
+ // Programmatic upserts match the streamed UX: only "panel"-mode
9224
+ // artifacts auto-open the pane (overriding a previous Close), while
9225
+ // "card"/"inline" stay calm — the injected transcript block is the
9226
+ // affordance. Independent of `transcript: false`: pane-only callers
9227
+ // (e.g. the theme editor preview) rely on the panel-default surfacing;
9228
+ // callers that want the pane in a non-panel mode call showArtifacts().
9229
+ const mode = resolveArtifactDisplayMode(
9230
+ config.features?.artifacts,
9231
+ manual.artifactType
9232
+ );
9233
+ if (mode === "panel") {
9234
+ artifactsPaneUserHidden = false;
9235
+ artifactsPaneUserOpened = true;
9236
+ }
8400
9237
  return session.upsertArtifact(manual);
8401
9238
  },
8402
9239
  selectArtifact(id: string): void {
@@ -8790,18 +9627,20 @@ export const createAgentExperience = (
8790
9627
  // bust the message cache and re-render so they pick up real markdown. Bumping
8791
9628
  // `configVersion` + clearing the cache is required because the message
8792
9629
  // content is unchanged, so the fingerprint cache would otherwise reuse the
8793
- // stale escaped wrappers. No-op for the ESM build (parsers ready at init).
9630
+ // stale escaped wrappers. `onMarkdownParsersReady` no-ops when the parsers are
9631
+ // already loaded (the ESM build, and the CDN build after the first load), so
9632
+ // the `markdownReadyAtInit` guard is redundant — kept only to skip the
9633
+ // subscription bookkeeping on the common eager path.
8794
9634
  if (!markdownReadyAtInit) {
8795
- loadMarkdownParsers()
8796
- .then(() => {
8797
- if (!session) return;
8798
- configVersion++;
8799
- messageCache.clear();
8800
- renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
8801
- })
8802
- .catch(() => {
8803
- /* chunk failed to load (e.g. ad blocker): keep the escaped fallback */
8804
- });
9635
+ const unsubscribeParsersReady = onMarkdownParsersReady(() => {
9636
+ if (!session) return;
9637
+ configVersion++;
9638
+ messageCache.clear();
9639
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
9640
+ });
9641
+ // Drop the subscription on teardown so a late chunk resolution can't clear
9642
+ // the cache and render into a detached `messagesWrapper`.
9643
+ destroyCallbacks.push(unsubscribeParsersReady);
8805
9644
  }
8806
9645
 
8807
9646
  return controller;