@runtypelabs/persona 4.6.1 → 4.8.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 (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. 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,7 +28,9 @@ import {
28
28
  VoiceStatus,
29
29
  ReadAloudState,
30
30
  PersonaArtifactRecord,
31
- PersonaArtifactManualUpsert
31
+ PersonaArtifactManualUpsert,
32
+ PersonaArtifactFileMeta,
33
+ PersonaArtifactActionContext
32
34
  } from "./types";
33
35
  import { AttachmentManager } from "./utils/attachment-manager";
34
36
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
@@ -36,6 +38,8 @@ import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./util
36
38
  import { resolveTokenValue } from "./utils/tokens";
37
39
  import { renderLucideIcon } from "./utils/icons";
38
40
  import { createElement, createElementInDocument } from "./utils/dom";
41
+ import { downloadInfoFor } from "./utils/artifact-file";
42
+ import { artifactCopyText } from "./components/artifact-preview";
39
43
  import { morphMessages } from "./utils/morph";
40
44
  import { normalizeCopiedSelectionText } from "./utils/copy-selection";
41
45
  import {
@@ -106,12 +110,18 @@ import { EventStreamStore } from "./utils/event-stream-store";
106
110
  import { ThroughputTracker } from "./utils/throughput-tracker";
107
111
  import { createEventStreamView } from "./components/event-stream-view";
108
112
  import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
113
+ import {
114
+ hasLiveInlineArtifactBlock,
115
+ updateInlineArtifactBlocks
116
+ } from "./components/artifact-inline";
109
117
  import {
110
118
  artifactsSidebarEnabled,
111
119
  applyArtifactLayoutCssVars,
112
120
  applyArtifactPaneAppearance,
121
+ isArtifactPaneAppearanceDetached,
113
122
  shouldExpandLauncherForArtifacts
114
123
  } from "./utils/artifact-gate";
124
+ import { resolveArtifactDisplayMode } from "./utils/artifact-display";
115
125
  import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
116
126
  import { enhanceWithForms } from "./components/forms";
117
127
  import { pluginRegistry } from "./plugins/registry";
@@ -139,6 +149,8 @@ import {
139
149
  // Default localStorage key for chat history (automatically cleared on clear chat)
140
150
  const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
141
151
  const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
152
+ // Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
153
+ const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
142
154
 
143
155
  const IMAGE_FILE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
144
156
  "image/png": "png",
@@ -1670,32 +1682,66 @@ export const createAgentExperience = (
1670
1682
 
1671
1683
  let artifactPaneApi: ArtifactPaneApi | null = null;
1672
1684
  let artifactPanelResizeObs: ResizeObserver | null = null;
1685
+ // Re-runs panel chrome when the split-chrome mode flips (pane
1686
+ // open/close/appearance change). Assigned once applyFullHeightStyles exists.
1687
+ let syncPanelChrome: () => void = () => {};
1688
+ let appliedSplitMode: "none" | "welded" | "detached" = "none";
1689
+ // Resolved welded outer-right radius stashed by applyFullHeightStyles and
1690
+ // applied in syncPanelChrome (after applyArtifactPaneAppearance, which no
1691
+ // longer manages the var, so the two never fight). Null clears it.
1692
+ let weldedOuterRadius: string | null = null;
1673
1693
  let lastArtifactsState: {
1674
1694
  artifacts: PersonaArtifactRecord[];
1675
1695
  selectedId: string | null;
1676
1696
  } = { artifacts: [], selectedId: null };
1677
1697
  let artifactsPaneUserHidden = false;
1698
+ // Runtime-only expand state: pane fills the split root and the chat column
1699
+ // hides. Reset whenever the pane stops being visible (syncArtifactPane).
1700
+ let artifactPaneExpanded = false;
1701
+ // Set when the expansion came from an inline block's Expand button: that is
1702
+ // an explicit "fullscreen this file" request, so it survives the
1703
+ // showExpandToggle gate below (which otherwise collapses the pane for hosts
1704
+ // without the toolbar toggle). Cleared whenever the pane collapses or hides.
1705
+ let artifactPaneExpandedPinned = false;
1706
+ // Whether the user explicitly opened the pane (card click, inline Expand,
1707
+ // showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
1708
+ // artifacts whose resolved display mode is "panel": "card" keeps the card as
1709
+ // the only affordance and "inline" renders in the transcript. "inline" no
1710
+ // longer *auto*-opens the pane, but its Expand control is a deliberate,
1711
+ // user-driven open (setting artifactsPaneUserOpened, same as a card click);
1712
+ // every mode still writes the session artifact registry (download /
1713
+ // getArtifacts / hydration).
1714
+ let artifactsPaneUserOpened = false;
1715
+ const artifactPaneCanShow = () =>
1716
+ artifactsPaneUserOpened ||
1717
+ lastArtifactsState.artifacts.some(
1718
+ (a) =>
1719
+ resolveArtifactDisplayMode(config.features?.artifacts, a.artifactType) === "panel"
1720
+ );
1721
+ const artifactPaneVisible = () =>
1722
+ lastArtifactsState.artifacts.length > 0 &&
1723
+ !artifactsPaneUserHidden &&
1724
+ artifactPaneCanShow();
1678
1725
  const sessionRef: { current: AgentWidgetSession | null } = { current: null };
1679
1726
 
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
1727
+ // Resolve an artifact's content for card actions (download + custom actions).
1728
+ // Tries the live session registry first, then falls back to the content
1729
+ // persisted in the card message's rawContent props (session state is gone
1730
+ // after a page refresh). `fromEl` is the clicked element inside the card.
1731
+ const resolveCardArtifactContent = (
1732
+ fromEl: HTMLElement,
1733
+ artifactId: string
1734
+ ): { markdown?: string; title: string; file?: PersonaArtifactFileMeta; artifactType: string } => {
1693
1735
  const artifact = session.getArtifactById(artifactId);
1694
1736
  let markdown = artifact?.markdown;
1695
1737
  let title = artifact?.title || 'artifact';
1738
+ let file: PersonaArtifactFileMeta | undefined = artifact?.file;
1739
+ let artifactType: string = artifact?.artifactType ?? 'markdown';
1696
1740
  if (!markdown) {
1697
- // After page refresh, session state is gone: read from the persisted card message
1698
- const cardEl = dlBtn.closest('[data-open-artifact]');
1741
+ // Match both reference cards and inline blocks: inline hydration embeds
1742
+ // the markdown / file source in the same message rawContent, so the parse
1743
+ // below resolves content for inline copy / custom actions after a refresh.
1744
+ const cardEl = fromEl.closest('[data-open-artifact], [data-artifact-inline]');
1699
1745
  const msgEl = cardEl?.closest('[data-message-id]');
1700
1746
  const msgId = msgEl?.getAttribute('data-message-id');
1701
1747
  if (msgId) {
@@ -1706,23 +1752,159 @@ export const createAgentExperience = (
1706
1752
  const parsed = JSON.parse(msg.rawContent);
1707
1753
  markdown = parsed?.props?.markdown;
1708
1754
  title = parsed?.props?.title || title;
1755
+ if (parsed?.props?.file && typeof parsed.props.file === 'object') {
1756
+ file = parsed.props.file as PersonaArtifactFileMeta;
1757
+ }
1758
+ if (!artifact && typeof parsed?.props?.artifactType === 'string') {
1759
+ artifactType = parsed.props.artifactType;
1760
+ }
1709
1761
  } catch { /* ignore */ }
1710
1762
  }
1711
1763
  }
1712
1764
  }
1765
+ return { markdown, title, file, artifactType };
1766
+ };
1767
+
1768
+ // Click delegation for artifact download buttons
1769
+ messagesWrapper.addEventListener('click', (event) => {
1770
+ const target = event.target as HTMLElement;
1771
+ const dlBtn = target.closest('[data-download-artifact]') as HTMLElement;
1772
+ if (!dlBtn) return;
1773
+ event.preventDefault();
1774
+ event.stopPropagation();
1775
+ const artifactId = dlBtn.getAttribute('data-download-artifact');
1776
+ if (!artifactId) return;
1777
+ // Let integrator intercept
1778
+ const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
1779
+ if (dlPrevented === true) return;
1780
+ const { markdown, title, file } = resolveCardArtifactContent(dlBtn, artifactId);
1713
1781
  if (!markdown) return;
1714
- const blob = new Blob([markdown], { type: 'text/markdown' });
1782
+ // File artifacts download the raw unfenced source under their real name/MIME;
1783
+ // non-file markdown artifacts keep the legacy `<title>.md` / text/markdown path.
1784
+ const { filename, mime, content } = downloadInfoFor({ title, markdown, file });
1785
+ const blob = new Blob([content], { type: mime });
1715
1786
  const url = URL.createObjectURL(blob);
1716
1787
  const a = document.createElement('a');
1717
1788
  a.href = url;
1718
- a.download = `${title}.md`;
1789
+ a.download = filename;
1719
1790
  a.click();
1720
1791
  URL.revokeObjectURL(url);
1721
1792
  });
1722
1793
 
1794
+ // Click delegation for integrator-supplied card action buttons. Actions are
1795
+ // looked up by id from fresh config at click time so live config updates
1796
+ // apply. Like the download listener, its stopPropagation() cannot block the
1797
+ // card-open listener on the same element, so that listener skips these too.
1798
+ messagesWrapper.addEventListener('click', (event) => {
1799
+ const target = event.target as HTMLElement;
1800
+ const actionBtn = target.closest('[data-artifact-custom-action]') as HTMLElement;
1801
+ if (!actionBtn) return;
1802
+ event.preventDefault();
1803
+ event.stopPropagation();
1804
+ const actionId = actionBtn.getAttribute('data-artifact-custom-action');
1805
+ if (!actionId) return;
1806
+ // The same attribute serves cards and inline chrome; resolve which surface
1807
+ // the button lives in so the action comes from the matching config list and
1808
+ // the artifact id from the matching container attribute.
1809
+ const inlineEl = actionBtn.closest('[data-artifact-inline]');
1810
+ const cardEl = inlineEl ? null : actionBtn.closest('[data-open-artifact]');
1811
+ const artifactId = inlineEl
1812
+ ? inlineEl.getAttribute('data-artifact-inline')
1813
+ : cardEl?.getAttribute('data-open-artifact') ?? null;
1814
+ const actionList = inlineEl
1815
+ ? config.features?.artifacts?.inlineActions
1816
+ : config.features?.artifacts?.cardActions;
1817
+ const action = actionList?.find((a) => a.id === actionId);
1818
+ if (!action) return;
1819
+ const { markdown, title, file, artifactType } = resolveCardArtifactContent(actionBtn, artifactId ?? '');
1820
+ const ctx: PersonaArtifactActionContext = { artifactId, title, artifactType, markdown, file };
1821
+ try {
1822
+ void Promise.resolve(action.onClick(ctx)).catch(() => {});
1823
+ } catch {
1824
+ /* ignore */
1825
+ }
1826
+ });
1827
+
1828
+ // Click delegation for the inline chrome Copy button. Like the download /
1829
+ // custom-action listeners, it resolves content from the live registry first,
1830
+ // then falls back to the persisted inline props after a refresh, and its
1831
+ // stopPropagation() cannot block a second listener on the same element (there
1832
+ // is none here, but the pattern is kept consistent).
1833
+ messagesWrapper.addEventListener('click', (event) => {
1834
+ const target = event.target as HTMLElement;
1835
+ const copyBtn = target.closest('[data-copy-artifact]') as HTMLElement;
1836
+ if (!copyBtn) return;
1837
+ event.preventDefault();
1838
+ event.stopPropagation();
1839
+ const artifactId = copyBtn.getAttribute('data-copy-artifact');
1840
+ if (!artifactId) return;
1841
+ // Prefer the live record (covers component JSON); fall back to the persisted
1842
+ // markdown / file source parsed from the inline block's message rawContent.
1843
+ const artifact = session.getArtifactById(artifactId);
1844
+ let text = '';
1845
+ if (artifact) {
1846
+ text = artifactCopyText(artifact);
1847
+ } else {
1848
+ const { markdown, file, artifactType } = resolveCardArtifactContent(copyBtn, artifactId);
1849
+ if (artifactType === 'markdown') {
1850
+ text = artifactCopyText({
1851
+ id: artifactId,
1852
+ artifactType: 'markdown',
1853
+ status: 'complete',
1854
+ markdown: markdown ?? '',
1855
+ ...(file ? { file } : {})
1856
+ });
1857
+ }
1858
+ }
1859
+ if (!text) return;
1860
+ navigator.clipboard.writeText(text).then(() => {
1861
+ // Lightweight feedback: swap the copy glyph for a check briefly.
1862
+ const checkIcon = renderLucideIcon('check', 16, 'currentColor', 2);
1863
+ if (checkIcon) {
1864
+ copyBtn.replaceChildren(checkIcon);
1865
+ setTimeout(() => {
1866
+ const copyIcon = renderLucideIcon('copy', 16, 'currentColor', 2);
1867
+ if (copyIcon) copyBtn.replaceChildren(copyIcon);
1868
+ }, 1500);
1869
+ }
1870
+ }).catch(() => { /* ignore */ });
1871
+ });
1872
+
1873
+ // Click delegation for the inline chrome Expand button: open this artifact in
1874
+ // the pane. Fires onArtifactAction({ type: "open" }) first so hosts can
1875
+ // intercept, then mirrors the card-open path — except the pane opens
1876
+ // expanded (fullscreen), never split: the inline block already shows the
1877
+ // full preview at chat width, so a split pane would only duplicate it, and
1878
+ // the click means "expand this file". The pinned flag keeps it expanded
1879
+ // even when layout.showExpandToggle is off; Close is the exit there.
1880
+ messagesWrapper.addEventListener('click', (event) => {
1881
+ const target = event.target as HTMLElement;
1882
+ const expandBtn = target.closest('[data-expand-artifact-inline]') as HTMLElement;
1883
+ if (!expandBtn) return;
1884
+ event.preventDefault();
1885
+ event.stopPropagation();
1886
+ const artifactId = expandBtn.getAttribute('data-expand-artifact-inline');
1887
+ if (!artifactId) return;
1888
+ const openPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'open', artifactId });
1889
+ if (openPrevented === true) return;
1890
+ artifactsPaneUserHidden = false;
1891
+ // Expand is an explicit open: it overrides the "inline" auto-open
1892
+ // suppression for as long as artifacts exist (same as a card click).
1893
+ artifactsPaneUserOpened = true;
1894
+ artifactPaneExpanded = true;
1895
+ artifactPaneExpandedPinned = true;
1896
+ session.selectArtifact(artifactId);
1897
+ syncArtifactPane();
1898
+ });
1899
+
1723
1900
  // Click delegation for artifact reference cards
1724
1901
  messagesWrapper.addEventListener('click', (event) => {
1725
1902
  const target = event.target as HTMLElement;
1903
+ // Download and custom-action clicks are handled by the listeners above;
1904
+ // their stopPropagation() cannot block a second listener on the same
1905
+ // element, so skip them here explicitly or the card would also open the panel.
1906
+ if (target.closest('[data-download-artifact]')) return;
1907
+ if (target.closest('[data-artifact-custom-action]')) return;
1726
1908
  const card = target.closest('[data-open-artifact]') as HTMLElement;
1727
1909
  if (!card) return;
1728
1910
  const artifactId = card.getAttribute('data-open-artifact');
@@ -1733,15 +1915,24 @@ export const createAgentExperience = (
1733
1915
  event.preventDefault();
1734
1916
  event.stopPropagation();
1735
1917
  artifactsPaneUserHidden = false;
1918
+ // Card click is an explicit open: it overrides the "card"/"inline"
1919
+ // auto-open suppression for as long as artifacts exist.
1920
+ artifactsPaneUserOpened = true;
1736
1921
  session.selectArtifact(artifactId);
1737
1922
  syncArtifactPane();
1738
1923
  });
1739
1924
 
1740
- // Keyboard support for artifact cards
1925
+ // Keyboard support for artifact cards and the clip-mode inline expand hitbox
1926
+ // (both expose a data-* attribute on a focusable element; Enter/Space forward
1927
+ // to the element's own click, which the delegated click handlers above own).
1741
1928
  messagesWrapper.addEventListener('keydown', (event) => {
1742
1929
  if (event.key !== 'Enter' && event.key !== ' ') return;
1743
1930
  const target = event.target as HTMLElement;
1744
- if (!target.hasAttribute('data-open-artifact')) return;
1931
+ if (
1932
+ !target.hasAttribute('data-open-artifact') &&
1933
+ !target.hasAttribute('data-expand-artifact-inline')
1934
+ )
1935
+ return;
1745
1936
  event.preventDefault();
1746
1937
  target.click();
1747
1938
  });
@@ -2130,6 +2321,12 @@ export const createAgentExperience = (
2130
2321
  let artifactResizeUnbind: (() => void) | null = null;
2131
2322
  let artifactResizeDocEnd: (() => void) | null = null;
2132
2323
  let reconcileArtifactResize: () => void = () => {};
2324
+ // Expanded-state transition tracking: stash the resizer's inline width/maxWidth
2325
+ // (which would otherwise beat the expanded class) once per enter, restore once
2326
+ // per leave.
2327
+ let artifactExpandedApplied = false;
2328
+ let artifactStashedWidth = "";
2329
+ let artifactStashedMaxWidth = "";
2133
2330
 
2134
2331
  function stopArtifactResizePointer() {
2135
2332
  artifactResizeDocEnd?.();
@@ -2139,7 +2336,9 @@ export const createAgentExperience = (
2139
2336
  /** Flush split: overlay handle on the seam so it does not consume flex gap (extension + resizable). */
2140
2337
  const positionExtensionArtifactResizeHandle = () => {
2141
2338
  if (!artifactSplitRoot || !artifactResizeHandle) return;
2142
- const ext = mount.classList.contains("persona-artifact-appearance-seamless");
2339
+ // Welded splits (panel + seamless) have gap 0, so the handle overlays the seam
2340
+ // instead of consuming flex width; detached keeps a real gap and a flex handle.
2341
+ const ext = mount.classList.contains("persona-artifact-welded-split");
2143
2342
  const ownerWin = mount.ownerDocument.defaultView ?? window;
2144
2343
  const mobile = ownerWin.innerWidth <= 640;
2145
2344
  if (!ext || mount.classList.contains("persona-artifact-narrow-host") || mobile) {
@@ -2159,13 +2358,45 @@ export const createAgentExperience = (
2159
2358
  artifactResizeHandle.style.bottom = "0";
2160
2359
  artifactResizeHandle.style.width = `${hitW}px`;
2161
2360
  artifactResizeHandle.style.zIndex = "5";
2162
- const left = chat.offsetWidth - hitW / 2;
2361
+ // Center the overlay on the seam: with an explicit welded gap the seam sits
2362
+ // half a gap past the chat's right edge (welded default gap is 0).
2363
+ const gapPx = readFlexGapPx(artifactSplitRoot, ownerWin);
2364
+ const left = chat.offsetWidth + gapPx / 2 - hitW / 2;
2163
2365
  artifactResizeHandle.style.left = `${Math.max(0, left)}px`;
2164
2366
  };
2165
2367
 
2166
2368
  /** No-op until artifact pane is created; replaced below when artifacts are enabled. */
2167
2369
  let applyLauncherArtifactPanelWidth: () => void = () => {};
2168
2370
 
2371
+ // Mobile fullscreen renders a flush, chrome-less panel, so the split chrome
2372
+ // must stand down there. This is the union of the two fullscreen predicates
2373
+ // applyFullHeightStyles/recalcPanelHeight use: launcher fullscreen
2374
+ // (shouldGoFullscreen) and the docked-host variant (dockedHostFullscreen).
2375
+ // Unlike the 641px split breakpoint, it honors the configurable mobileBreakpoint.
2376
+ const isMobileFullscreenActive = (): boolean => {
2377
+ const win = mount.ownerDocument.defaultView ?? window;
2378
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
2379
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
2380
+ if (!mobileFullscreen || win.innerWidth > mobileBreakpoint) return false;
2381
+ return launcherEnabled || isDockedMountMode(config);
2382
+ };
2383
+
2384
+ // Desktop side-by-side split chrome mode. 'welded' = panel|seamless (one card,
2385
+ // border+radius wrap both columns); 'detached' = two floating cards; 'none' when
2386
+ // the pane is closed, the narrow-host drawer, or mobile fullscreen.
2387
+ const splitChromeMode = (): "none" | "welded" | "detached" => {
2388
+ if (!artifactsSidebarEnabled(config)) return "none";
2389
+ if (!artifactPaneVisible()) return "none";
2390
+ if (mount.classList.contains("persona-artifact-narrow-host")) return "none";
2391
+ // A fullscreen panel is flush; welded/detached corner chrome would fight it.
2392
+ if (isMobileFullscreenActive()) return "none";
2393
+ const win = mount.ownerDocument.defaultView ?? window;
2394
+ // Must match the artifact split media queries in widget.css (drawer at
2395
+ // max-width:640, split at min-width:641); mobileBreakpoint does not move it.
2396
+ if (win.innerWidth < ARTIFACT_SPLIT_DESKTOP_MIN) return "none";
2397
+ return isArtifactPaneAppearanceDetached(config) ? "detached" : "welded";
2398
+ };
2399
+
2169
2400
  const syncArtifactPane = () => {
2170
2401
  if (!artifactPaneApi || !artifactsSidebarEnabled(config)) return;
2171
2402
  applyArtifactLayoutCssVars(mount, config);
@@ -2174,16 +2405,79 @@ export const createAgentExperience = (
2174
2405
  const threshold = config.features?.artifacts?.layout?.narrowHostMaxWidth ?? 520;
2175
2406
  const w = panel.getBoundingClientRect().width || 0;
2176
2407
  mount.classList.toggle("persona-artifact-narrow-host", w > 0 && w <= threshold);
2408
+ // Thread the single source of truth for "pane surface is shown" into the
2409
+ // pane BEFORE update() so it renders lazily: in inline/card display modes
2410
+ // the pane stays hidden and must not build a second sandboxed artifact
2411
+ // iframe (which would execute artifact scripts twice). The pane records
2412
+ // state while hidden and renders on the next reveal. Set first so an
2413
+ // update() that arrives while hidden is skipped, not rendered-then-hidden.
2414
+ const paneVisible = artifactPaneVisible();
2415
+ artifactPaneApi.setVisible(paneVisible);
2177
2416
  artifactPaneApi.update(lastArtifactsState);
2178
2417
  if (artifactsPaneUserHidden) {
2179
2418
  artifactPaneApi.setMobileOpen(false);
2180
2419
  artifactPaneApi.element.classList.add("persona-hidden");
2181
2420
  artifactPaneApi.backdrop?.classList.add("persona-hidden");
2182
- } else if (lastArtifactsState.artifacts.length > 0) {
2421
+ artifactPaneExpanded = false;
2422
+ artifactPaneExpandedPinned = false;
2423
+ } else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
2183
2424
  // User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
2184
2425
  // and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
2426
+ // Artifacts whose display mode is "card" or "inline" don't auto-open the
2427
+ // pane (artifactPaneCanShow); it stays hidden until an explicit open or
2428
+ // until a "panel"-mode artifact arrives.
2185
2429
  artifactPaneApi.element.classList.remove("persona-hidden");
2186
2430
  artifactPaneApi.setMobileOpen(true);
2431
+ } else {
2432
+ // The pane's own update() unhides itself whenever records exist
2433
+ // (applyLayoutVisibility), so re-assert the gate here: card/inline-mode
2434
+ // artifacts keep the pane hidden until an explicit open.
2435
+ artifactPaneApi.setMobileOpen(false);
2436
+ artifactPaneApi.element.classList.add("persona-hidden");
2437
+ artifactPaneApi.backdrop?.classList.add("persona-hidden");
2438
+ artifactPaneExpanded = false;
2439
+ artifactPaneExpandedPinned = false;
2440
+ }
2441
+ // Re-read the toggle config on every sync so a live config.update() can
2442
+ // reveal/remove the button (the pane itself is built once). Disabling the
2443
+ // toggle while expanded also collapses the pane — unless the expansion is
2444
+ // pinned (inline Expand): that fullscreen request stands on its own and
2445
+ // exits via Close.
2446
+ const expandToggleEnabled = config.features?.artifacts?.layout?.showExpandToggle === true;
2447
+ artifactPaneApi.setExpandToggleVisible(expandToggleEnabled);
2448
+ artifactPaneApi.setCopyButtonVisible(
2449
+ config.features?.artifacts?.layout?.showCopyButton === true
2450
+ );
2451
+ artifactPaneApi.setCustomActions(config.features?.artifacts?.toolbarActions ?? []);
2452
+ artifactPaneApi.setTabFade(config.features?.artifacts?.layout?.tabFade);
2453
+ artifactPaneApi.setRenderTabBar(config.features?.artifacts?.renderTabBar);
2454
+ if (!expandToggleEnabled && !artifactPaneExpandedPinned) artifactPaneExpanded = false;
2455
+ // Run the resizer stash/restore once per expanded-state transition: the
2456
+ // resizer's inline width/maxWidth beats the expanded class, so clear it while
2457
+ // expanded and put it back on collapse.
2458
+ if (artifactPaneExpanded !== artifactExpandedApplied) {
2459
+ const paneEl = artifactPaneApi.element;
2460
+ if (artifactPaneExpanded) {
2461
+ artifactStashedWidth = paneEl.style.width;
2462
+ artifactStashedMaxWidth = paneEl.style.maxWidth;
2463
+ paneEl.style.removeProperty("width");
2464
+ paneEl.style.removeProperty("max-width");
2465
+ } else {
2466
+ if (artifactStashedWidth) paneEl.style.width = artifactStashedWidth;
2467
+ if (artifactStashedMaxWidth) paneEl.style.maxWidth = artifactStashedMaxWidth;
2468
+ artifactStashedWidth = "";
2469
+ artifactStashedMaxWidth = "";
2470
+ }
2471
+ artifactExpandedApplied = artifactPaneExpanded;
2472
+ }
2473
+ mount.classList.toggle("persona-artifact-expanded", artifactPaneExpanded);
2474
+ artifactPaneApi.setExpanded(artifactPaneExpanded);
2475
+ // Recompute panel chrome when the split mode changes (pane open/close or
2476
+ // appearance) so the welded border and detached shadow suppression land.
2477
+ const modeNow = splitChromeMode();
2478
+ if (modeNow !== appliedSplitMode) {
2479
+ appliedSplitMode = modeNow;
2480
+ syncPanelChrome();
2187
2481
  }
2188
2482
  reconcileArtifactResize();
2189
2483
  };
@@ -2204,6 +2498,22 @@ export const createAgentExperience = (
2204
2498
  onDismiss: () => {
2205
2499
  artifactsPaneUserHidden = true;
2206
2500
  syncArtifactPane();
2501
+ },
2502
+ onToggleExpand: () => {
2503
+ const next = !artifactPaneExpanded;
2504
+ const artifactId =
2505
+ lastArtifactsState.selectedId ??
2506
+ lastArtifactsState.artifacts[lastArtifactsState.artifacts.length - 1]?.id ??
2507
+ null;
2508
+ const prevented = config.features?.artifacts?.onArtifactAction?.({
2509
+ type: "expand",
2510
+ artifactId,
2511
+ expanded: next,
2512
+ });
2513
+ if (prevented === true) return;
2514
+ artifactPaneExpanded = next;
2515
+ if (!next) artifactPaneExpandedPinned = false;
2516
+ syncArtifactPane();
2207
2517
  }
2208
2518
  });
2209
2519
  artifactPaneApi.element.classList.add("persona-hidden");
@@ -2246,6 +2556,7 @@ export const createAgentExperience = (
2246
2556
  const onPointerDown = (e: PointerEvent) => {
2247
2557
  if (!artifactPaneApi || e.button !== 0) return;
2248
2558
  if (mount.classList.contains("persona-artifact-narrow-host")) return;
2559
+ if (mount.classList.contains("persona-artifact-expanded")) return;
2249
2560
  if (win.innerWidth <= 640) return;
2250
2561
  e.preventDefault();
2251
2562
  stopArtifactResizePointer();
@@ -2254,9 +2565,13 @@ export const createAgentExperience = (
2254
2565
  const layout = config.features?.artifacts?.layout;
2255
2566
  const onMove = (ev: PointerEvent) => {
2256
2567
  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;
2568
+ const weldedChrome = mount.classList.contains("persona-artifact-welded-split");
2569
+ // Read the effective flex gap either way: welded defaults to 0 but an
2570
+ // explicit splitGap opens a real gap that still consumes row width.
2571
+ const gapPx = readFlexGapPx(artifactSplitRoot!, win);
2572
+ // Welded overlays the handle on the seam (out of flow), so it consumes
2573
+ // no width; detached keeps it as an in-flow flex child.
2574
+ const handleW = weldedChrome ? 0 : handle.getBoundingClientRect().width || 6;
2260
2575
  // Handle is left of the artifact: drag left widens artifact, drag right narrows it.
2261
2576
  const next = startW - (ev.clientX - startX);
2262
2577
  const clamped = resolveArtifactPaneWidthPx(
@@ -2301,8 +2616,7 @@ export const createAgentExperience = (
2301
2616
  };
2302
2617
  }
2303
2618
  if (artifactResizeHandle) {
2304
- const has =
2305
- lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
2619
+ const has = artifactPaneVisible();
2306
2620
  artifactResizeHandle.classList.toggle("persona-hidden", !has);
2307
2621
  positionExtensionArtifactResizeHandle();
2308
2622
  }
@@ -2323,8 +2637,7 @@ export const createAgentExperience = (
2323
2637
  const expanded =
2324
2638
  config.features?.artifacts?.layout?.expandedPanelWidth ??
2325
2639
  "min(720px, calc(100vw - 24px))";
2326
- const hasVisible =
2327
- lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
2640
+ const hasVisible = artifactPaneVisible();
2328
2641
  if (hasVisible) {
2329
2642
  panel.style.width = expanded;
2330
2643
  panel.style.maxWidth = expanded;
@@ -2342,21 +2655,23 @@ export const createAgentExperience = (
2342
2655
  }
2343
2656
  } else {
2344
2657
  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
- }
2658
+ }
2659
+
2660
+ // Composer-bar mode: the pill (footer) and peek banner live in a
2661
+ // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2662
+ // independent of both the wrapper's geometry transitions and the panel's
2663
+ // optional artifact split layout. Critical for modal mode: the wrapper
2664
+ // there has `transform: translate(-50%, -50%)`, which would establish a
2665
+ // containing block trapping any `position: fixed` descendant.
2666
+ //
2667
+ // Order inside pillRoot: peekBanner (slim row above pill) footer (pill).
2668
+ // pillRoot's `gap` spaces them; the peek is hidden by default until ui.ts
2669
+ // toggles `.persona-pill-peek--visible` based on streaming/hover/open state.
2670
+ if (isComposerBar() && pillRoot) {
2671
+ if (panelElements.peekBanner) {
2672
+ pillRoot.appendChild(panelElements.peekBanner);
2673
+ }
2674
+ pillRoot.appendChild(footer);
2360
2675
  }
2361
2676
  mount.appendChild(wrapper);
2362
2677
  // pillRoot is mounted *after* wrapper so it naturally stacks on top
@@ -2415,6 +2730,8 @@ export const createAgentExperience = (
2415
2730
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
2416
2731
  /** Script-tag / div embed: launcher off, host supplies a sized mount. */
2417
2732
  const isInlineEmbed = config.launcher?.enabled === false;
2733
+ /** Detached appearance: inset card over a canvas instead of flush chrome. */
2734
+ const isDetached = config.launcher?.detachedPanel === true;
2418
2735
  const panelPartial = config.theme?.components?.panel;
2419
2736
  const activeTheme = getActiveTheme(config);
2420
2737
  const resolvePanelChrome = (raw: string | undefined, fallback: string): string => {
@@ -2429,33 +2746,105 @@ export const createAgentExperience = (
2429
2746
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
2430
2747
  const isMobileViewport = ownerWindow.innerWidth <= mobileBreakpoint;
2431
2748
  const shouldGoFullscreen = mobileFullscreen && isMobileViewport && launcherEnabled;
2749
+ // Docked host layout forces fullscreen on mobile even when the launcher is
2750
+ // off (host-layout pins the slot fixed and clears its chrome). Suppress the
2751
+ // detached card so it never paints chrome over a flush fullscreen panel.
2752
+ const dockedHostFullscreen = dockedMode && mobileFullscreen && isMobileViewport;
2432
2753
 
2433
2754
  // Determine panel styling based on mode, with theme overrides
2434
2755
  const position = config.launcher?.position ?? 'bottom-left';
2435
2756
  const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
2436
2757
  const overlayZIndex = config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX;
2437
2758
 
2759
+ // Card chrome defaults (floating look): reused to restore detached chrome.
2760
+ // Defaults chain through the aliases themeToCssVariables emits so explicit
2761
+ // theme.components.panel overrides and these defaults never diverge.
2762
+ const cardBorder = 'var(--persona-panel-border, 1px solid var(--persona-border))';
2763
+ const cardShadow = 'var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))';
2764
+ const cardRadius = 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
2765
+ /** Detached restores card chrome except when a host layout goes fullscreen. */
2766
+ const detachedCard = isDetached && !shouldGoFullscreen && !dockedHostFullscreen;
2767
+ // Stamp reflects rendered chrome: cleared when a fullscreen host layout
2768
+ // suppresses the card, so the attribute never lies to the detached CSS.
2769
+ if (detachedCard) {
2770
+ mount.setAttribute("data-persona-panel-detached", "true");
2771
+ } else {
2772
+ mount.removeAttribute("data-persona-panel-detached");
2773
+ }
2774
+
2438
2775
  // 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) {
2776
+ let defaultPanelBorder = detachedCard
2777
+ ? cardBorder
2778
+ : (sidebarMode || shouldGoFullscreen) ? 'none' : cardBorder;
2779
+ let defaultPanelShadow = detachedCard
2780
+ ? cardShadow
2781
+ : shouldGoFullscreen
2782
+ ? 'none'
2783
+ : sidebarMode
2784
+ ? (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))')
2785
+ // Flush inline embeds fill their container: no elevation by default
2786
+ // (detachedPanel or components.panel.shadow opts back in).
2787
+ : isInlineEmbed ? 'none' : cardShadow;
2788
+
2789
+ if (dockedMode && !shouldGoFullscreen && !detachedCard) {
2447
2790
  defaultPanelShadow = 'none';
2448
2791
  defaultPanelBorder = 'none';
2449
2792
  }
2450
- const defaultPanelBorderRadius = (sidebarMode || shouldGoFullscreen)
2451
- ? '0'
2452
- : 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
2793
+ const defaultPanelBorderRadius = detachedCard
2794
+ ? cardRadius
2795
+ : (sidebarMode || shouldGoFullscreen) ? '0' : cardRadius;
2453
2796
 
2454
2797
  // Apply theme overrides or defaults (components.panel.*)
2455
2798
  const panelBorder = resolvePanelChrome(panelPartial?.border, defaultPanelBorder);
2456
2799
  const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
2457
2800
  const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
2458
2801
 
2802
+ // Split chrome: 'welded' folds the card border onto the outer panel so it
2803
+ // wraps both columns as one card (shadow/radius already on the panel);
2804
+ // 'detached' pulls them into two individually carded columns.
2805
+ const splitMode = splitChromeMode();
2806
+ const detachedSplitActive = splitMode === "detached";
2807
+ const weldedSplitActive = splitMode === "welded";
2808
+ mount.classList.toggle("persona-artifact-detached-split", detachedSplitActive);
2809
+ mount.classList.toggle("persona-artifact-welded-split", weldedSplitActive);
2810
+ const chatCardShadow =
2811
+ '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)))))';
2812
+ // Welded moves the chat card border onto the panel, so the chat column goes
2813
+ // borderless; detached cards it individually; otherwise it keeps the border.
2814
+ // 'flush' drops the chat card so the chat is flat flush background and only
2815
+ // the pane floats; 'card' (default) keeps two matched cards. Flush is a
2816
+ // steady state: it applies whether or not a pane is open, so opening or
2817
+ // closing an artifact never flips the chat chrome. Gated to inline embeds
2818
+ // (not docked), matching the card-mode perimeter inset: the flush model only
2819
+ // fits the container-filling embed. Floating/docked/sidebar keep the card look.
2820
+ const rawChatSurface = config.features?.artifacts?.layout?.chatSurface;
2821
+ const resolvedChatSurface = rawChatSurface === "flush" ? "flush" : "card";
2822
+ const chatFlush =
2823
+ resolvedChatSurface === "flush" &&
2824
+ artifactsSidebarEnabled(config) &&
2825
+ isArtifactPaneAppearanceDetached(config) &&
2826
+ isInlineEmbed &&
2827
+ !dockedMode;
2828
+ mount.classList.toggle("persona-artifact-chat-flush", chatFlush);
2829
+ // Flat means flat: flush suppresses the outer panel shadow in every state.
2830
+ const outerPanelShadow = detachedSplitActive || chatFlush ? 'none' : panelShadow;
2831
+ let chatContainerBorder = detachedSplitActive
2832
+ ? cardBorder
2833
+ : weldedSplitActive
2834
+ ? 'none'
2835
+ : panelBorder;
2836
+ let chatContainerRadius = detachedSplitActive ? cardRadius : panelBorderRadius;
2837
+ if (chatFlush) {
2838
+ chatContainerBorder = 'none';
2839
+ chatContainerRadius = '0';
2840
+ }
2841
+ // Flush fills the container flush, so the outer panel squares off by default;
2842
+ // the pane keeps its own rounded radius. An explicit panel.borderRadius wins.
2843
+ const panelRadiusExplicit =
2844
+ panelPartial?.borderRadius != null && panelPartial.borderRadius !== '';
2845
+ const appliedPanelRadius =
2846
+ chatFlush && !panelRadiusExplicit ? '0' : panelBorderRadius;
2847
+
2459
2848
  // Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
2460
2849
  // `min-height: 0` / `overflow-y: auto` that make the messages area a
2461
2850
  // scroll container. Between the reset and the mode-specific reapply,
@@ -2575,7 +2964,7 @@ export const createAgentExperience = (
2575
2964
  }
2576
2965
  } else if (dockedMode) {
2577
2966
  const dockReveal = resolveDockConfig(config).reveal;
2578
- if (dockReveal === "emerge") {
2967
+ if (dockReveal === "emerge" && !isDetached) {
2579
2968
  const dw = resolveDockConfig(config).width;
2580
2969
  panel.style.width = dw;
2581
2970
  panel.style.maxWidth = dw;
@@ -2590,12 +2979,38 @@ export const createAgentExperience = (
2590
2979
  // Box-shadow is applied to panel (parent) instead of container to avoid
2591
2980
  // rendering artifacts when container has overflow:hidden + border-radius
2592
2981
  // 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;
2982
+ panel.style.boxShadow = outerPanelShadow;
2983
+ panel.style.borderRadius = appliedPanelRadius;
2984
+ if (detachedSplitActive) panel.style.border = 'none';
2985
+ else if (weldedSplitActive) panel.style.border = panelBorder;
2986
+ container.style.border = chatContainerBorder;
2987
+ container.style.borderRadius = chatContainerRadius;
2988
+ container.style.boxShadow = detachedSplitActive && !chatFlush ? chatCardShadow : '';
2989
+ // Flush chat has no card surface of its own: override the class-painted
2990
+ // container/body/footer backgrounds so the wrapper's canvas shows through.
2991
+ // The footer's top hairline is chat-card chrome, so it goes too. Element
2992
+ // surfaces (bubbles, cards, composer input) keep their own backgrounds.
2993
+ if (chatFlush) {
2994
+ container.style.background = 'transparent';
2995
+ body.style.background = 'transparent';
2996
+ footer.style.background = 'transparent';
2997
+ footer.style.borderTop = 'none';
2998
+ }
2999
+
3000
+ // Welded outer-right radius: derive the pane's outer corners from the same
3001
+ // resolved value as the chat card's left corners so a custom
3002
+ // components.panel.borderRadius stays symmetric. Explicit
3003
+ // unifiedSplitOuterRadius / paneBorderRadius still win. Applied in
3004
+ // syncPanelChrome after applyArtifactPaneAppearance (which no longer owns it).
3005
+ if (weldedSplitActive) {
3006
+ const wl = config.features?.artifacts?.layout;
3007
+ weldedOuterRadius =
3008
+ wl?.unifiedSplitOuterRadius?.trim() || wl?.paneBorderRadius?.trim() || panelBorderRadius;
3009
+ } else {
3010
+ weldedOuterRadius = null;
3011
+ }
2597
3012
 
2598
- if (dockedMode && !shouldGoFullscreen && panelPartial?.border === undefined) {
3013
+ if (dockedMode && !shouldGoFullscreen && !detachedCard && !detachedSplitActive && !weldedSplitActive && panelPartial?.border === undefined) {
2599
3014
  container.style.border = 'none';
2600
3015
  const dockSide = resolveDockConfig(config).side;
2601
3016
  if (dockSide === 'right') {
@@ -2605,6 +3020,19 @@ export const createAgentExperience = (
2605
3020
  }
2606
3021
  }
2607
3022
 
3023
+ // Docked + welded: the welded chrome owner is the outer panel, and docked
3024
+ // mode resolves its border to none, so add the dock-facing hairline there so
3025
+ // the split still separates from the host page. Mirrors the flush block's
3026
+ // side choice (right dock => left edge faces the page).
3027
+ if (dockedMode && !shouldGoFullscreen && weldedSplitActive && panelPartial?.border === undefined) {
3028
+ const dockSide = resolveDockConfig(config).side;
3029
+ if (dockSide === 'right') {
3030
+ panel.style.borderLeft = '1px solid var(--persona-border)';
3031
+ } else {
3032
+ panel.style.borderRight = '1px solid var(--persona-border)';
3033
+ }
3034
+ }
3035
+
2608
3036
  if (fullHeight) {
2609
3037
  // Mount container
2610
3038
  mount.style.display = 'flex';
@@ -2635,8 +3063,10 @@ export const createAgentExperience = (
2635
3063
  panel.style.minHeight = '0';
2636
3064
  panel.style.maxHeight = '100%';
2637
3065
  panel.style.height = '100%';
2638
- panel.style.overflow = 'hidden';
2639
-
3066
+ // Detached split: each card clips its own content and carries its own
3067
+ // shadow, so panel overflow:hidden must not clip the cards' shadows.
3068
+ if (!detachedSplitActive) panel.style.overflow = 'hidden';
3069
+
2640
3070
  // Main container
2641
3071
  container.style.display = 'flex';
2642
3072
  container.style.flexDirection = 'column';
@@ -2653,7 +3083,19 @@ export const createAgentExperience = (
2653
3083
  // Footer (composer) - should not shrink
2654
3084
  footer.style.flexShrink = '0';
2655
3085
  }
2656
-
3086
+
3087
+ // Inline embed: pad the wrapper so the canvas shows around the card when the
3088
+ // panel is a detached card OR a detached split is open (insets the whole split
3089
+ // from the container edges on all sides). Docked owns its inset via
3090
+ // host-layout, so exclude it (no double gap).
3091
+ // Flush chat must be flush to the container, so it skips the perimeter inset;
3092
+ // the wrapper still paints the canvas so one token colors the whole backdrop,
3093
+ // in every state (flush is steady, so idle must match pane-open).
3094
+ if (isInlineEmbed && (detachedCard || detachedSplitActive || chatFlush) && !dockedMode) {
3095
+ if (!chatFlush) wrapper.style.padding = 'var(--persona-panel-inset)';
3096
+ wrapper.style.background = 'var(--persona-panel-canvas-bg)';
3097
+ }
3098
+
2657
3099
  // Handle positioning classes based on mode
2658
3100
  // First remove all position classes to reset state
2659
3101
  wrapper.classList.remove(
@@ -2670,22 +3112,40 @@ export const createAgentExperience = (
2670
3112
  // Apply sidebar-specific styles
2671
3113
  if (sidebarMode) {
2672
3114
  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
- `;
3115
+
3116
+ // Wrapper - fixed position. Detached insets the card off the edges and
3117
+ // shrinks its height by the inset on both ends; flush hugs the edges.
3118
+ if (isDetached) {
3119
+ wrapper.style.cssText = `
3120
+ position: fixed !important;
3121
+ top: var(--persona-panel-inset) !important;
3122
+ bottom: var(--persona-panel-inset) !important;
3123
+ width: ${sidebarWidth} !important;
3124
+ height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
3125
+ max-height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
3126
+ margin: 0 !important;
3127
+ padding: 0 !important;
3128
+ display: flex !important;
3129
+ flex-direction: column !important;
3130
+ z-index: ${overlayZIndex} !important;
3131
+ ${isLeftSidebar ? 'left: var(--persona-panel-inset) !important; right: auto !important;' : 'left: auto !important; right: var(--persona-panel-inset) !important;'}
3132
+ `;
3133
+ } else {
3134
+ wrapper.style.cssText = `
3135
+ position: fixed !important;
3136
+ top: 0 !important;
3137
+ bottom: 0 !important;
3138
+ width: ${sidebarWidth} !important;
3139
+ height: 100vh !important;
3140
+ max-height: 100vh !important;
3141
+ margin: 0 !important;
3142
+ padding: 0 !important;
3143
+ display: flex !important;
3144
+ flex-direction: column !important;
3145
+ z-index: ${overlayZIndex} !important;
3146
+ ${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
3147
+ `;
3148
+ }
2689
3149
 
2690
3150
  // Panel - fill wrapper (override inline width/max-width from panel.ts)
2691
3151
  // Box-shadow is on panel to avoid rendering artifacts with container's overflow:hidden
@@ -2701,15 +3161,16 @@ export const createAgentExperience = (
2701
3161
  min-height: 0 !important;
2702
3162
  margin: 0 !important;
2703
3163
  padding: 0 !important;
2704
- box-shadow: ${panelShadow} !important;
2705
- border-radius: ${panelBorderRadius} !important;
3164
+ box-shadow: ${outerPanelShadow} !important;
3165
+ border-radius: ${appliedPanelRadius} !important;
3166
+ ${detachedSplitActive ? 'border: none !important;' : weldedSplitActive ? `border: ${panelBorder} !important;` : ''}
2706
3167
  `;
2707
3168
  // Force override any inline width/maxWidth that may be set elsewhere
2708
3169
  panel.style.setProperty('width', '100%', 'important');
2709
3170
  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
3171
+
3172
+ // Container - apply configurable styles with sidebar layout. Box-shadow is
3173
+ // normally on panel, but a detached split cards the chat column here.
2713
3174
  container.style.cssText = `
2714
3175
  display: flex !important;
2715
3176
  flex-direction: column !important;
@@ -2719,8 +3180,10 @@ export const createAgentExperience = (
2719
3180
  min-height: 0 !important;
2720
3181
  max-height: 100% !important;
2721
3182
  overflow: hidden !important;
2722
- border-radius: ${panelBorderRadius} !important;
2723
- border: ${panelBorder} !important;
3183
+ border-radius: ${chatContainerRadius} !important;
3184
+ border: ${chatContainerBorder} !important;
3185
+ ${detachedSplitActive && !chatFlush ? `box-shadow: ${chatCardShadow} !important;` : ''}
3186
+ ${chatFlush ? 'background: transparent !important;' : ''}
2724
3187
  `;
2725
3188
 
2726
3189
  // Remove footer border in sidebar mode
@@ -2728,6 +3191,7 @@ export const createAgentExperience = (
2728
3191
  flex-shrink: 0 !important;
2729
3192
  border-top: none !important;
2730
3193
  padding: 8px 16px 12px 16px !important;
3194
+ ${chatFlush ? 'background: transparent !important;' : ''}
2731
3195
  `;
2732
3196
  }
2733
3197
 
@@ -2752,6 +3216,22 @@ export const createAgentExperience = (
2752
3216
  applyArtifactLayoutCssVars(mount, config);
2753
3217
  applyArtifactPaneAppearance(mount, config);
2754
3218
 
3219
+ // applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
3220
+ // artifact layout vars after it, mirroring the init sequence above.
3221
+ syncPanelChrome = () => {
3222
+ applyFullHeightStyles();
3223
+ applyThemeVariables(mount, config);
3224
+ applyArtifactLayoutCssVars(mount, config);
3225
+ applyArtifactPaneAppearance(mount, config);
3226
+ // Owned here so it lands after applyArtifactPaneAppearance and derives from
3227
+ // the same resolved panel radius the chat card uses (see applyFullHeightStyles).
3228
+ if (weldedOuterRadius) {
3229
+ mount.style.setProperty("--persona-artifact-welded-outer-radius", weldedOuterRadius);
3230
+ } else {
3231
+ mount.style.removeProperty("--persona-artifact-welded-outer-radius");
3232
+ }
3233
+ };
3234
+
2755
3235
  const destroyCallbacks: Array<() => void> = [];
2756
3236
  // Clean up the document-level digit-key shortcut listener registered earlier.
2757
3237
  destroyCallbacks.push(() => {
@@ -3017,6 +3497,48 @@ export const createAgentExperience = (
3017
3497
  ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
3018
3498
  : [];
3019
3499
 
3500
+ let storageMutationTail: Promise<void> | null = null;
3501
+
3502
+ const reportStorageMutationError = (label: string, error: unknown) => {
3503
+ if (typeof console !== "undefined") {
3504
+ // eslint-disable-next-line no-console
3505
+ console.error(label, error);
3506
+ }
3507
+ };
3508
+
3509
+ const runStorageMutation = (
3510
+ mutation: () => void | Promise<void>,
3511
+ errorLabel: string
3512
+ ) => {
3513
+ const run = (): Promise<void> | null => {
3514
+ try {
3515
+ const result = mutation();
3516
+ if (!result || typeof (result as PromiseLike<void>).then !== "function") {
3517
+ return null;
3518
+ }
3519
+ return Promise.resolve(result).catch((error) => {
3520
+ reportStorageMutationError(errorLabel, error);
3521
+ });
3522
+ } catch (error) {
3523
+ reportStorageMutationError(errorLabel, error);
3524
+ return null;
3525
+ }
3526
+ };
3527
+
3528
+ const prior = storageMutationTail;
3529
+ const next = prior
3530
+ ? prior.then(() => run() ?? undefined)
3531
+ : run();
3532
+ if (!next) return;
3533
+
3534
+ const tail = next.finally(() => {
3535
+ if (storageMutationTail === tail) {
3536
+ storageMutationTail = null;
3537
+ }
3538
+ });
3539
+ storageMutationTail = tail;
3540
+ };
3541
+
3020
3542
  function persistState(messagesOverride?: AgentWidgetMessage[]) {
3021
3543
  if (!storageAdapter?.save) return;
3022
3544
 
@@ -3033,22 +3555,10 @@ export const createAgentExperience = (
3033
3555
  artifacts: lastArtifactsState.artifacts,
3034
3556
  selectedArtifactId: lastArtifactsState.selectedId
3035
3557
  };
3036
- try {
3037
- const result = storageAdapter.save(payload);
3038
- if (result instanceof Promise) {
3039
- result.catch((error) => {
3040
- if (typeof console !== "undefined") {
3041
- // eslint-disable-next-line no-console
3042
- console.error("[AgentWidget] Failed to persist state:", error);
3043
- }
3044
- });
3045
- }
3046
- } catch (error) {
3047
- if (typeof console !== "undefined") {
3048
- // eslint-disable-next-line no-console
3049
- console.error("[AgentWidget] Failed to persist state:", error);
3050
- }
3051
- }
3558
+ runStorageMutation(
3559
+ () => storageAdapter.save!(payload),
3560
+ "[AgentWidget] Failed to persist state:"
3561
+ );
3052
3562
  }
3053
3563
 
3054
3564
  // Track ongoing smooth scroll animation
@@ -3971,15 +4481,57 @@ export const createAgentExperience = (
3971
4481
  if (directive) {
3972
4482
  const lastFp = lastComponentDirectiveFingerprint.get(message.id);
3973
4483
  const needsRebuild = lastFp !== fingerprint;
3974
- const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
4484
+ // Wrap only when the global default allows it AND the component has
4485
+ // not opted out of bubble chrome (e.g. the artifact card carries its
4486
+ // own border, so it renders bare).
4487
+ const wrapChrome =
4488
+ config.wrapComponentDirectiveInBubble !== false &&
4489
+ componentRegistry.getOptions(directive.component)?.bubbleChrome !== false;
3975
4490
  let liveBubble: HTMLElement | null = null;
3976
4491
 
3977
4492
  if (needsRebuild) {
3978
- const componentBubble = renderComponentDirective(directive, {
4493
+ let componentBubble = renderComponentDirective(directive, {
3979
4494
  config,
3980
4495
  message,
3981
4496
  transform
3982
4497
  });
4498
+ // Reuse a mounted inline artifact block instead of the fresh
4499
+ // render: replacing it would cut the collapse-to-card animation
4500
+ // short and reload live file-preview iframes.
4501
+ if (
4502
+ componentBubble &&
4503
+ directive.component === "PersonaArtifactInline"
4504
+ ) {
4505
+ const fresh = componentBubble.hasAttribute(
4506
+ "data-artifact-inline"
4507
+ )
4508
+ ? componentBubble
4509
+ : componentBubble.querySelector<HTMLElement>(
4510
+ "[data-artifact-inline]"
4511
+ );
4512
+ const artifactId =
4513
+ fresh?.getAttribute("data-artifact-inline") ?? "";
4514
+ const escapedId =
4515
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
4516
+ ? CSS.escape(artifactId)
4517
+ : artifactId;
4518
+ const live = artifactId
4519
+ ? (container
4520
+ .querySelector<HTMLElement>(`#wrapper-${message.id}`)
4521
+ ?.querySelector<HTMLElement>(
4522
+ `[data-artifact-inline="${escapedId}"]`
4523
+ ) ?? null)
4524
+ : null;
4525
+ if (
4526
+ fresh &&
4527
+ live &&
4528
+ live !== fresh &&
4529
+ hasLiveInlineArtifactBlock(live)
4530
+ ) {
4531
+ if (fresh === componentBubble) componentBubble = live;
4532
+ else fresh.replaceWith(live);
4533
+ }
4534
+ }
3983
4535
  if (componentBubble) {
3984
4536
  if (wrapChrome) {
3985
4537
  const componentWrapper = document.createElement("div");
@@ -4145,6 +4697,12 @@ export const createAgentExperience = (
4145
4697
  currentGroup.push(message);
4146
4698
  return;
4147
4699
  }
4700
+ // A hidden reasoning row does not create a visible break in the
4701
+ // transcript, so it should not split an otherwise contiguous tool
4702
+ // sequence into separate groups.
4703
+ if (message.variant === "reasoning" && !showReasoning) {
4704
+ return;
4705
+ }
4148
4706
  if (currentGroup.length > 1) {
4149
4707
  toolGroups.push(currentGroup);
4150
4708
  }
@@ -4199,11 +4757,19 @@ export const createAgentExperience = (
4199
4757
  const stack = document.createElement("div");
4200
4758
  stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
4201
4759
 
4202
- groupContainer.append(summary, stack);
4760
+ const summaryOnly = config.features?.toolCallDisplay?.groupedMode === "summary";
4761
+ groupContainer.appendChild(summary);
4762
+ if (!summaryOnly) {
4763
+ groupContainer.appendChild(stack);
4764
+ }
4203
4765
  groupWrapper.appendChild(groupContainer);
4204
4766
  wrappers[0].before(groupWrapper);
4205
4767
 
4206
4768
  wrappers.forEach((wrapper, wrapperIndex) => {
4769
+ if (summaryOnly) {
4770
+ wrapper.remove();
4771
+ return;
4772
+ }
4207
4773
  const item = document.createElement("div");
4208
4774
  item.className = "persona-tool-group-item persona-relative";
4209
4775
  item.setAttribute("data-persona-tool-group-item", "true");
@@ -5181,67 +5747,186 @@ export const createAgentExperience = (
5181
5747
  }, 100);
5182
5748
  };
5183
5749
 
5184
- session = new AgentWidgetSession(config, {
5185
- onMessagesChanged(messages) {
5186
- renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
5187
- // Start elapsed timer if any active tool has a live duration span
5188
- ensureToolElapsedTimer();
5189
- // Re-render suggestions: agent chips vs config chips, one shared rule.
5190
- // Pass messages directly to avoid calling session.getMessages() during construction
5191
- renderSuggestions(messages);
5192
- scheduleAutoScroll(!isStreaming);
5193
- trackMessages(messages);
5194
-
5195
- const lastUserMessage = [...messages]
5196
- .reverse()
5197
- .find((msg) => msg.role === "user");
5198
- const lastAssistantMessage = [...messages]
5199
- .reverse()
5200
- .find((msg) => msg.role === "assistant");
5201
-
5202
- // Scroll-on-send / anchor-top. Seeded so restored history (constructor
5203
- // initialMessages and async storage hydration) never reads as a fresh
5204
- // send; clearing the chat resets any anchor spacer.
5205
- if (messages.length === 0) {
5206
- resetAnchorState();
5207
- // Cleared: nothing anchored, so re-arm the no-anchor follow fallback.
5208
- followFallbackActive = true;
5209
- currentTurnAnchored = false;
5210
- }
5211
- if (!scrollSendSeeded || suppressScrollSend) {
5212
- scrollSendSeeded = true;
5213
- lastSentUserMessageId = lastUserMessage?.id ?? null;
5214
- // Seed assistant-turn tracking too, so restored history doesn't read
5215
- // as a fresh assistant turn and trigger the no-anchor fallback.
5216
- lastHandledAssistantId = lastAssistantMessage?.id ?? null;
5217
- } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
5218
- lastSentUserMessageId = lastUserMessage.id;
5219
- handleUserMessageSent(lastUserMessage.id);
5220
- } else if (
5221
- lastAssistantMessage &&
5222
- lastAssistantMessage.id !== lastHandledAssistantId
5223
- ) {
5224
- // A new assistant turn with no fresh user send: the anchor-top
5225
- // no-anchor fallback (proactive/injected/resubmit/first-load streaming).
5226
- handleAssistantTurnStarted();
5227
- }
5228
- if (lastAssistantMessage) {
5229
- lastHandledAssistantId = lastAssistantMessage.id;
5750
+ const isDeepEqual = (left: unknown, right: unknown): boolean => {
5751
+ if (Object.is(left, right)) return true;
5752
+ if (left === null || right === null) return false;
5753
+ if (typeof left !== "object" || typeof right !== "object") return false;
5754
+ if (Array.isArray(left) || Array.isArray(right)) {
5755
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
5756
+ return false;
5230
5757
  }
5758
+ return left.every((value, index) => isDeepEqual(value, right[index]));
5759
+ }
5760
+ const leftRecord = left as Record<string, unknown>;
5761
+ const rightRecord = right as Record<string, unknown>;
5762
+ const leftKeys = Object.keys(leftRecord);
5763
+ const rightKeys = Object.keys(rightRecord);
5764
+ return (
5765
+ leftKeys.length === rightKeys.length &&
5766
+ leftKeys.every(
5767
+ (key) =>
5768
+ Object.prototype.hasOwnProperty.call(rightRecord, key) &&
5769
+ isDeepEqual(leftRecord[key], rightRecord[key])
5770
+ )
5771
+ );
5772
+ };
5773
+
5774
+ const withoutStreamingText = (message: AgentWidgetMessage) => {
5775
+ const { content: _content, rawContent: _rawContent, llmContent: _llmContent, ...rest } = message;
5776
+ return rest;
5777
+ };
5778
+
5779
+ const isPlainStreamingAssistant = (message: AgentWidgetMessage) =>
5780
+ message.role === "assistant" &&
5781
+ message.streaming === true &&
5782
+ !message.variant &&
5783
+ !message.toolCall &&
5784
+ !message.tools &&
5785
+ !message.approval &&
5786
+ !message.reasoning &&
5787
+ !message.contentParts &&
5788
+ !message.stopReason &&
5789
+ !hasComponentDirective(message);
5790
+
5791
+ const isPureStreamingTextProgression = (
5792
+ previous: AgentWidgetMessage[],
5793
+ next: AgentWidgetMessage[],
5794
+ candidate: { index: number; id: string }
5795
+ ) => {
5796
+ if (previous.length !== next.length) return false;
5797
+ const before = previous[candidate.index];
5798
+ const after = next[candidate.index];
5799
+ if (!before || !after || before.id !== candidate.id || after.id !== candidate.id) {
5800
+ return false;
5801
+ }
5802
+ const textChanged =
5803
+ !Object.is(before.content, after.content) ||
5804
+ !Object.is(before.rawContent, after.rawContent) ||
5805
+ !Object.is(before.llmContent, after.llmContent);
5806
+ return (
5807
+ textChanged &&
5808
+ isPlainStreamingAssistant(before) &&
5809
+ isPlainStreamingAssistant(after) &&
5810
+ isDeepEqual(withoutStreamingText(before), withoutStreamingText(after))
5811
+ );
5812
+ };
5813
+
5814
+ let lastAppliedMessages: AgentWidgetMessage[] | null = null;
5815
+ let activeStreamingTextCandidate: { index: number; id: string } | null = null;
5816
+ let pendingStreamingTextMessages: AgentWidgetMessage[] | null = null;
5817
+ let streamingTextRAF: number | null = null;
5818
+
5819
+ const applyMessagesChanged = (messages: AgentWidgetMessage[]) => {
5820
+ lastAppliedMessages = messages;
5821
+ let lastUserMessage: AgentWidgetMessage | undefined;
5822
+ let lastAssistantMessage: AgentWidgetMessage | undefined;
5823
+ activeStreamingTextCandidate = null;
5824
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
5825
+ const message = messages[index];
5826
+ if (!lastUserMessage && message.role === "user") lastUserMessage = message;
5827
+ if (!lastAssistantMessage && message.role === "assistant") {
5828
+ lastAssistantMessage = message;
5829
+ }
5830
+ if (!activeStreamingTextCandidate && isPlainStreamingAssistant(message)) {
5831
+ activeStreamingTextCandidate = { index, id: message.id };
5832
+ }
5833
+ if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
5834
+ }
5835
+ renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
5836
+ // Freshly (re)built inline artifact blocks render from their persisted
5837
+ // props; sync them with the live registry so a block created after the
5838
+ // last onArtifactsState emission still shows current content.
5839
+ updateInlineArtifactBlocks(messagesWrapper, lastArtifactsState.artifacts, {
5840
+ suppressTransition: isStreaming,
5841
+ });
5842
+ ensureToolElapsedTimer();
5843
+ renderSuggestions(messages);
5844
+ scheduleAutoScroll(!isStreaming);
5845
+ trackMessages(messages);
5846
+
5847
+ if (messages.length === 0) {
5848
+ resetAnchorState();
5849
+ followFallbackActive = true;
5850
+ currentTurnAnchored = false;
5851
+ }
5852
+ if (!scrollSendSeeded || suppressScrollSend) {
5853
+ scrollSendSeeded = true;
5854
+ lastSentUserMessageId = lastUserMessage?.id ?? null;
5855
+ lastHandledAssistantId = lastAssistantMessage?.id ?? null;
5856
+ } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
5857
+ lastSentUserMessageId = lastUserMessage.id;
5858
+ handleUserMessageSent(lastUserMessage.id);
5859
+ } else if (
5860
+ lastAssistantMessage &&
5861
+ lastAssistantMessage.id !== lastHandledAssistantId
5862
+ ) {
5863
+ handleAssistantTurnStarted();
5864
+ }
5865
+ if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
5231
5866
 
5232
- // Emit user:message event when a new user message is detected
5233
- const prevLastUserMessageId = voiceState.lastUserMessageId;
5234
- if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
5235
- voiceState.lastUserMessageId = lastUserMessage.id;
5236
- eventBus.emit("user:message", lastUserMessage);
5867
+ const prevLastUserMessageId = voiceState.lastUserMessageId;
5868
+ if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
5869
+ voiceState.lastUserMessageId = lastUserMessage.id;
5870
+ eventBus.emit("user:message", lastUserMessage);
5871
+ }
5872
+
5873
+ voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
5874
+ persistState(messages);
5875
+ syncComposerBarPeek();
5876
+ };
5877
+
5878
+ const flushPendingStreamingText = () => {
5879
+ if (streamingTextRAF !== null) {
5880
+ cancelAnimationFrame(streamingTextRAF);
5881
+ streamingTextRAF = null;
5882
+ }
5883
+ const pending = pendingStreamingTextMessages;
5884
+ pendingStreamingTextMessages = null;
5885
+ if (pending) applyMessagesChanged(pending);
5886
+ };
5887
+
5888
+ const discardPendingStreamingText = () => {
5889
+ if (streamingTextRAF !== null) cancelAnimationFrame(streamingTextRAF);
5890
+ streamingTextRAF = null;
5891
+ pendingStreamingTextMessages = null;
5892
+ };
5893
+
5894
+ const handleMessagesChanged = (messages: AgentWidgetMessage[]) => {
5895
+ const comparison = pendingStreamingTextMessages ?? lastAppliedMessages;
5896
+ if (
5897
+ isStreaming &&
5898
+ comparison &&
5899
+ activeStreamingTextCandidate &&
5900
+ isPureStreamingTextProgression(
5901
+ comparison,
5902
+ messages,
5903
+ activeStreamingTextCandidate
5904
+ )
5905
+ ) {
5906
+ pendingStreamingTextMessages = messages;
5907
+ if (streamingTextRAF === null) {
5908
+ streamingTextRAF = requestAnimationFrame(() => {
5909
+ streamingTextRAF = null;
5910
+ const pending = pendingStreamingTextMessages;
5911
+ pendingStreamingTextMessages = null;
5912
+ if (pending) applyMessagesChanged(pending);
5913
+ });
5237
5914
  }
5915
+ return;
5916
+ }
5238
5917
 
5239
- voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
5240
- persistState(messages);
5241
- // Composer-bar peek: re-render the trailing-100-char preview and
5242
- // re-evaluate visibility (a new message may make it eligible to show
5243
- // during streaming, or update the preview text on each token).
5244
- syncComposerBarPeek();
5918
+ if (messages.length === 0) {
5919
+ discardPendingStreamingText();
5920
+ applyMessagesChanged(messages);
5921
+ return;
5922
+ }
5923
+ flushPendingStreamingText();
5924
+ applyMessagesChanged(messages);
5925
+ };
5926
+
5927
+ session = new AgentWidgetSession(config, {
5928
+ onMessagesChanged(messages) {
5929
+ handleMessagesChanged(messages);
5245
5930
  },
5246
5931
  onStatusChanged(status) {
5247
5932
  const currentStatusConfig = config.statusIndicator ?? {};
@@ -5257,6 +5942,13 @@ export const createAgentExperience = (
5257
5942
  applyStatusToElement(statusText, getCurrentStatusText(status), currentStatusConfig, status);
5258
5943
  },
5259
5944
  onStreamingChanged(streaming) {
5945
+ if (!streaming) {
5946
+ if (session?.getMessages().length === 0) {
5947
+ discardPendingStreamingText();
5948
+ } else {
5949
+ flushPendingStreamingText();
5950
+ }
5951
+ }
5260
5952
  isStreaming = streaming;
5261
5953
  setComposerDisabled(streaming);
5262
5954
  // Re-render messages to show/hide typing indicator
@@ -5317,6 +6009,20 @@ export const createAgentExperience = (
5317
6009
  },
5318
6010
  onArtifactsState(state) {
5319
6011
  lastArtifactsState = state;
6012
+ // A cleared registry ends any explicit-open override: the next artifact
6013
+ // decides pane visibility purely from its own display mode.
6014
+ if (state.artifacts.length === 0) {
6015
+ artifactsPaneUserOpened = false;
6016
+ }
6017
+ // Route streaming registry updates (artifact_delta / artifact_complete)
6018
+ // into any inline artifact blocks in the transcript. Suppress the
6019
+ // streaming→complete View Transition while the session is still
6020
+ // streaming: it captures the whole document, and cross-fading a stale
6021
+ // snapshot over still-moving message text reads as ghosting/motion blur
6022
+ // on the transcript.
6023
+ updateInlineArtifactBlocks(messagesWrapper, state.artifacts, {
6024
+ suppressTransition: isStreaming,
6025
+ });
5320
6026
  syncArtifactPane();
5321
6027
  persistState();
5322
6028
  },
@@ -6246,17 +6952,35 @@ export const createAgentExperience = (
6246
6952
 
6247
6953
  try {
6248
6954
  if (shouldGoFullscreen) {
6249
- applyFullHeightStyles();
6250
- applyThemeVariables(mount, config);
6955
+ // syncPanelChrome re-applies theme + artifact layout vars that the
6956
+ // cssText reset in applyFullHeightStyles wipes; bare re-style flickers.
6957
+ syncPanelChrome();
6958
+ appliedSplitMode = splitChromeMode();
6251
6959
  return;
6252
6960
  }
6253
6961
 
6254
6962
  // Exiting mobile fullscreen (e.g., orientation change to landscape): reset all styles
6963
+ let chromeResynced = false;
6255
6964
  if (wasMobileFullscreen) {
6256
6965
  wasMobileFullscreen = false;
6257
- applyFullHeightStyles();
6258
- applyThemeVariables(mount, config);
6966
+ syncPanelChrome();
6967
+ chromeResynced = true;
6968
+ }
6969
+
6970
+ // Width-only resize can cross the 640 split boundary without changing
6971
+ // panel geometry (pane ResizeObserver stays silent) or hitting the
6972
+ // fullscreen branches, so resync chrome when the split mode flips.
6973
+ const modeNow = splitChromeMode();
6974
+ if (!chromeResynced && modeNow !== appliedSplitMode) {
6975
+ syncPanelChrome();
6976
+ chromeResynced = true;
6259
6977
  }
6978
+ appliedSplitMode = modeNow;
6979
+ // syncPanelChrome does not touch the resize handle, so a width-only flip
6980
+ // between welded (seam overlay) and detached/flex handle would leave it in
6981
+ // the old mode. reconcileArtifactResize re-places it; it never re-enters
6982
+ // syncArtifactPane, so no recursion. No-op when artifacts are disabled.
6983
+ if (chromeResynced) reconcileArtifactResize();
6260
6984
 
6261
6985
  if (!launcherEnabled && !dockedMode) {
6262
6986
  panel.style.height = "";
@@ -6569,22 +7293,10 @@ export const createAgentExperience = (
6569
7293
  window.dispatchEvent(clearEvent);
6570
7294
 
6571
7295
  if (storageAdapter?.clear) {
6572
- try {
6573
- const result = storageAdapter.clear();
6574
- if (result instanceof Promise) {
6575
- result.catch((error) => {
6576
- if (typeof console !== "undefined") {
6577
- // eslint-disable-next-line no-console
6578
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
6579
- }
6580
- });
6581
- }
6582
- } catch (error) {
6583
- if (typeof console !== "undefined") {
6584
- // eslint-disable-next-line no-console
6585
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
6586
- }
6587
- }
7296
+ runStorageMutation(
7297
+ () => storageAdapter.clear!(),
7298
+ "[AgentWidget] Failed to clear storage adapter:"
7299
+ );
6588
7300
  }
6589
7301
  persistentMetadata = {};
6590
7302
  actionManager.syncFromMetadata();
@@ -8060,22 +8772,10 @@ export const createAgentExperience = (
8060
8772
  window.dispatchEvent(clearEvent);
8061
8773
 
8062
8774
  if (storageAdapter?.clear) {
8063
- try {
8064
- const result = storageAdapter.clear();
8065
- if (result instanceof Promise) {
8066
- result.catch((error) => {
8067
- if (typeof console !== "undefined") {
8068
- // eslint-disable-next-line no-console
8069
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
8070
- }
8071
- });
8072
- }
8073
- } catch (error) {
8074
- if (typeof console !== "undefined") {
8075
- // eslint-disable-next-line no-console
8076
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
8077
- }
8078
- }
8775
+ runStorageMutation(
8776
+ () => storageAdapter.clear!(),
8777
+ "[AgentWidget] Failed to clear storage adapter:"
8778
+ );
8079
8779
  }
8080
8780
  persistentMetadata = {};
8081
8781
  actionManager.syncFromMetadata();
@@ -8259,6 +8959,7 @@ export const createAgentExperience = (
8259
8959
  showArtifacts(): void {
8260
8960
  if (!artifactsSidebarEnabled(config)) return;
8261
8961
  artifactsPaneUserHidden = false;
8962
+ artifactsPaneUserOpened = true;
8262
8963
  syncArtifactPane();
8263
8964
  artifactPaneApi?.setMobileOpen(true);
8264
8965
  },
@@ -8269,8 +8970,20 @@ export const createAgentExperience = (
8269
8970
  },
8270
8971
  upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord | null {
8271
8972
  if (!artifactsSidebarEnabled(config)) return null;
8272
- // Programmatic adds should surface the pane even if the user previously hit Close.
8273
- artifactsPaneUserHidden = false;
8973
+ // Programmatic upserts match the streamed UX: only "panel"-mode
8974
+ // artifacts auto-open the pane (overriding a previous Close), while
8975
+ // "card"/"inline" stay calm — the injected transcript block is the
8976
+ // affordance. Independent of `transcript: false`: pane-only callers
8977
+ // (e.g. the theme editor preview) rely on the panel-default surfacing;
8978
+ // callers that want the pane in a non-panel mode call showArtifacts().
8979
+ const mode = resolveArtifactDisplayMode(
8980
+ config.features?.artifacts,
8981
+ manual.artifactType
8982
+ );
8983
+ if (mode === "panel") {
8984
+ artifactsPaneUserHidden = false;
8985
+ artifactsPaneUserOpened = true;
8986
+ }
8274
8987
  return session.upsertArtifact(manual);
8275
8988
  },
8276
8989
  selectArtifact(id: string): void {
@@ -8436,6 +9149,10 @@ export const createAgentExperience = (
8436
9149
  return session.submitNPSFeedback(rating, comment);
8437
9150
  },
8438
9151
  destroy() {
9152
+ // Commit the latest coalesced transcript while the live DOM and storage
9153
+ // pipeline still exist, then let the normal teardown cancel any work
9154
+ // scheduled by that final apply.
9155
+ flushPendingStreamingText();
8439
9156
  if (toolElapsedTimerId != null) {
8440
9157
  clearInterval(toolElapsedTimerId);
8441
9158
  toolElapsedTimerId = null;
@@ -8660,18 +9377,20 @@ export const createAgentExperience = (
8660
9377
  // bust the message cache and re-render so they pick up real markdown. Bumping
8661
9378
  // `configVersion` + clearing the cache is required because the message
8662
9379
  // content is unchanged, so the fingerprint cache would otherwise reuse the
8663
- // stale escaped wrappers. No-op for the ESM build (parsers ready at init).
9380
+ // stale escaped wrappers. `onMarkdownParsersReady` no-ops when the parsers are
9381
+ // already loaded (the ESM build, and the CDN build after the first load), so
9382
+ // the `markdownReadyAtInit` guard is redundant — kept only to skip the
9383
+ // subscription bookkeeping on the common eager path.
8664
9384
  if (!markdownReadyAtInit) {
8665
- loadMarkdownParsers()
8666
- .then(() => {
8667
- if (!session) return;
8668
- configVersion++;
8669
- messageCache.clear();
8670
- renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
8671
- })
8672
- .catch(() => {
8673
- /* chunk failed to load (e.g. ad blocker): keep the escaped fallback */
8674
- });
9385
+ const unsubscribeParsersReady = onMarkdownParsersReady(() => {
9386
+ if (!session) return;
9387
+ configVersion++;
9388
+ messageCache.clear();
9389
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
9390
+ });
9391
+ // Drop the subscription on teardown so a late chunk resolution can't clear
9392
+ // the cache and render into a detached `messagesWrapper`.
9393
+ destroyCallbacks.push(unsubscribeParsersReady);
8675
9394
  }
8676
9395
 
8677
9396
  return controller;