@runtypelabs/persona 3.17.0 → 3.19.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 (61) hide show
  1. package/README.md +143 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
package/src/ui.ts CHANGED
@@ -42,13 +42,18 @@ import {
42
42
  } from "./utils/auto-follow";
43
43
  import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
44
  import {
45
+ applyStreamBuffer,
46
+ createSkeletonPlaceholder,
47
+ createStreamCaret,
45
48
  detachAllPlugins,
46
49
  ensurePluginActive,
50
+ resolveStreamAnimation,
47
51
  resolveStreamAnimationPlugin,
52
+ wrapStreamAnimation,
48
53
  } from "./utils/stream-animation";
49
54
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
50
55
  import { acquireScrollLock } from "./utils/scroll-lock";
51
- import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
56
+ import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
52
57
  import { createLauncherButton } from "./components/launcher";
53
58
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
54
59
  import { HEADER_THEME_CSS } from "./components/header-builder";
@@ -59,6 +64,20 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
59
64
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
60
65
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
61
66
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
67
+ import {
68
+ buildStructuredAnswers,
69
+ ensureAskUserQuestionSheet,
70
+ getCurrentIndex,
71
+ getQuestionCount,
72
+ getSelectedLabels,
73
+ isAskUserQuestionMessage,
74
+ isGroupedSheet,
75
+ navigateToPage,
76
+ parseAskUserQuestionPayload,
77
+ readAnswersFromSheet,
78
+ removeAskUserQuestionSheet,
79
+ setCurrentAnswer,
80
+ } from "./components/ask-user-question-bubble";
62
81
  import { formatElapsedMs } from "./utils/formatting";
63
82
  import { createApprovalBubble } from "./components/approval-bubble";
64
83
  import { createSuggestions } from "./components/suggestions";
@@ -328,6 +347,10 @@ type Controller = {
328
347
  upsertArtifact: (manual: PersonaArtifactManualUpsert) => PersonaArtifactRecord | null;
329
348
  selectArtifact: (id: string) => void;
330
349
  clearArtifacts: () => void;
350
+ /** Read current artifacts (useful on init to rebuild host-side tab state after hydration). */
351
+ getArtifacts: () => PersonaArtifactRecord[];
352
+ /** Read the currently selected artifact id (paired with `getArtifacts`). */
353
+ getSelectedArtifactId: () => string | null;
331
354
  /**
332
355
  * Focus the chat input. Returns true if focus succeeded, false if panel is closed
333
356
  * (launcher mode) or textarea is unavailable.
@@ -467,8 +490,14 @@ export const createAgentExperience = (
467
490
  }
468
491
  const eventBus = createEventBus<AgentWidgetControllerEventMap>();
469
492
 
470
- const storageAdapter: AgentWidgetStorageAdapter =
471
- config.storageAdapter ?? createLocalStorageAdapter();
493
+ // When persistState is explicitly false, message-history persistence is
494
+ // disabled — including any user-supplied storageAdapter. This is the strict
495
+ // kill-switch semantic; pass `persistState: true` (or omit it) to opt in.
496
+ const messagePersistenceDisabled = config.persistState === false;
497
+ const storageAdapter: AgentWidgetStorageAdapter | null =
498
+ messagePersistenceDisabled
499
+ ? null
500
+ : (config.storageAdapter ?? createLocalStorageAdapter());
472
501
  let persistentMetadata: Record<string, unknown> = {};
473
502
  let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
474
503
 
@@ -517,6 +546,13 @@ export const createAgentExperience = (
517
546
  if (processedState.messages?.length) {
518
547
  config = { ...config, initialMessages: processedState.messages };
519
548
  }
549
+ if (processedState.artifacts?.length) {
550
+ config = {
551
+ ...config,
552
+ initialArtifacts: processedState.artifacts,
553
+ initialSelectedArtifactId: processedState.selectedArtifactId ?? null
554
+ };
555
+ }
520
556
  }
521
557
  } catch (error) {
522
558
  if (typeof console !== "undefined") {
@@ -575,7 +611,15 @@ export const createAgentExperience = (
575
611
  let prevLauncherEnabled = launcherEnabled;
576
612
  let prevHeaderLayout = config.layout?.header?.layout;
577
613
  let wasMobileFullscreen = false;
578
- let open = launcherEnabled ? autoExpand : true;
614
+ // Composer-bar mode behaves like a launcher-enabled panel for state/toggle
615
+ // purposes (open/close maps to expand/collapse) but does not render a
616
+ // launcher button. `isPanelToggleable()` covers both modes; checks that
617
+ // gate the launcher button itself stay on the raw `launcherEnabled` flag.
618
+ const isComposerBar = () => isComposerBarMountMode(config);
619
+ const isPanelToggleable = () => launcherEnabled || isComposerBar();
620
+ // Composer-bar starts collapsed (open=false). Inline embed (no launcher)
621
+ // is always open. Launcher mode honors `autoExpand`.
622
+ let open = isComposerBar() ? false : (launcherEnabled ? autoExpand : true);
579
623
 
580
624
  // Track pending resubmit state for injection-triggered resubmit
581
625
  // When a handler returns resubmit: true, we wait for injectAssistantMessage()
@@ -682,8 +726,8 @@ export const createAgentExperience = (
682
726
  }
683
727
  }
684
728
 
685
- const { wrapper, panel } = createWrapper(config);
686
- const panelElements = buildPanel(config, launcherEnabled);
729
+ const { wrapper, panel, pillRoot } = createWrapper(config);
730
+ const panelElements = buildPanel(config, isPanelToggleable());
687
731
  let {
688
732
  container,
689
733
  body,
@@ -773,7 +817,7 @@ export const createAgentExperience = (
773
817
  const customHeader = headerPlugin.renderHeader({
774
818
  config,
775
819
  defaultRenderer: () => {
776
- const headerElements = buildHeader({ config, showClose: launcherEnabled });
820
+ const headerElements = buildHeader({ config, showClose: isPanelToggleable() });
777
821
  attachHeaderToContainer(container, headerElements, config);
778
822
  return headerElements.header;
779
823
  },
@@ -926,6 +970,9 @@ export const createAgentExperience = (
926
970
  const value = text.trim();
927
971
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
928
972
  if (!value && !hasAttachments) return;
973
+ // Mirror the default composer's auto-expand behavior so plugin
974
+ // composers do not silently submit while the panel stays collapsed.
975
+ maybeExpandComposerBar();
929
976
  let contentParts: ContentPart[] | undefined;
930
977
  if (hasAttachments) {
931
978
  contentParts = [];
@@ -998,19 +1045,35 @@ export const createAgentExperience = (
998
1045
  ensureComposerAttachmentSurface(footer);
999
1046
  bindComposerRefsFromFooter(footer);
1000
1047
 
1001
- // Apply contentMaxWidth to composer form, suggestions, and attachment previews if configured
1002
- const contentMaxWidth = config.layout?.contentMaxWidth;
1003
- if (contentMaxWidth && composerForm) {
1048
+ // Apply contentMaxWidth to composer form, suggestions, and attachment
1049
+ // previews if configured. In composer-bar mode, fall back to
1050
+ // `composerBar.contentMaxWidth` (default `720px`) when no explicit
1051
+ // `layout.contentMaxWidth` is set, so the expanded panel's content
1052
+ // centers horizontally without the host having to wire it up.
1053
+ const contentMaxWidth =
1054
+ config.layout?.contentMaxWidth ??
1055
+ (isComposerBar() ? config.launcher?.composerBar?.contentMaxWidth ?? "720px" : undefined);
1056
+ if (contentMaxWidth) {
1057
+ messagesWrapper.style.maxWidth = contentMaxWidth;
1058
+ messagesWrapper.style.marginLeft = "auto";
1059
+ messagesWrapper.style.marginRight = "auto";
1060
+ messagesWrapper.style.width = "100%";
1061
+ }
1062
+ // The pill IS the composer in composer-bar mode and should match the
1063
+ // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
1064
+ // contentMaxWidth (which is a centered-column convention for the
1065
+ // expanded panel's body, not the pill input itself).
1066
+ if (contentMaxWidth && composerForm && !isComposerBar()) {
1004
1067
  composerForm.style.maxWidth = contentMaxWidth;
1005
1068
  composerForm.style.marginLeft = "auto";
1006
1069
  composerForm.style.marginRight = "auto";
1007
1070
  }
1008
- if (contentMaxWidth && suggestions) {
1071
+ if (contentMaxWidth && suggestions && !isComposerBar()) {
1009
1072
  suggestions.style.maxWidth = contentMaxWidth;
1010
1073
  suggestions.style.marginLeft = "auto";
1011
1074
  suggestions.style.marginRight = "auto";
1012
1075
  }
1013
- if (contentMaxWidth && attachmentPreviewsContainer) {
1076
+ if (contentMaxWidth && attachmentPreviewsContainer && !isComposerBar()) {
1014
1077
  attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
1015
1078
  attachmentPreviewsContainer.style.marginLeft = "auto";
1016
1079
  attachmentPreviewsContainer.style.marginRight = "auto";
@@ -1408,6 +1471,385 @@ export const createAgentExperience = (
1408
1471
  target.click();
1409
1472
  });
1410
1473
 
1474
+ // --- ask_user_question sheet interaction ---
1475
+ // Event delegation for the answer-pill sheet that mounts in the composer
1476
+ // overlay. Handles pill pick (single), multi-select toggle + submit, free-
1477
+ // text pill expansion + submit, and dismissal. Selection becomes a regular
1478
+ // user message via session.sendMessage so the agent resumes on the next turn.
1479
+ const askUserOverlay = panelElements.composerOverlay;
1480
+
1481
+ const submitAskUserAnswer = (
1482
+ sheet: HTMLElement,
1483
+ text: string,
1484
+ meta: {
1485
+ source: "pick" | "multi" | "free-text" | "submit-all";
1486
+ values?: string[];
1487
+ structured?: Record<string, string | string[]>;
1488
+ }
1489
+ ): void => {
1490
+ const trimmed = text.trim();
1491
+ if (!trimmed || !sessionRef.current) return;
1492
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1493
+ const isFreeText = meta.source === "free-text";
1494
+
1495
+ // Dispatch before removing the sheet so listeners can still query DOM state.
1496
+ mount.dispatchEvent(
1497
+ new CustomEvent("persona:askUserQuestion:answered", {
1498
+ detail: {
1499
+ toolUseId: toolCallId,
1500
+ answer: trimmed,
1501
+ answers: meta.structured,
1502
+ values: meta.values ?? (meta.source === "multi" ? trimmed.split(", ") : [trimmed]),
1503
+ isFreeText,
1504
+ source: meta.source,
1505
+ },
1506
+ bubbles: true,
1507
+ composed: true,
1508
+ })
1509
+ );
1510
+
1511
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1512
+
1513
+ // Branch: LOCAL-tool pause (step_await) resumes via /resume with structured
1514
+ // toolOutputs; legacy path sends as a plain user message.
1515
+ const sourceMessage = sessionRef.current
1516
+ .getMessages()
1517
+ .find((m) => m.toolCall?.id === toolCallId);
1518
+ if (sourceMessage?.agentMetadata?.awaitingLocalTool) {
1519
+ sessionRef.current.resolveAskUserQuestion(sourceMessage, meta.structured ?? trimmed);
1520
+ } else {
1521
+ sessionRef.current.sendMessage(trimmed);
1522
+ }
1523
+ };
1524
+
1525
+ /**
1526
+ * Persist in-progress grouped-question answers + page index back to the
1527
+ * source message so a refresh restores the user's spot.
1528
+ */
1529
+ const persistGroupedProgress = (sheet: HTMLElement): void => {
1530
+ const session = sessionRef.current;
1531
+ if (!session) return;
1532
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1533
+ const sourceMessage = session.getMessages().find((m) => m.toolCall?.id === toolCallId);
1534
+ if (!sourceMessage) return;
1535
+ session.persistAskUserQuestionProgress(sourceMessage, {
1536
+ answers: buildStructuredAnswers(sheet, sourceMessage),
1537
+ currentIndex: getCurrentIndex(sheet),
1538
+ });
1539
+ };
1540
+
1541
+ /**
1542
+ * Build a one-line summary string for the legacy `answer` field on the
1543
+ * answered event when submit-all fires from a grouped sheet.
1544
+ */
1545
+ const stringifyStructured = (answers: Record<string, string | string[]>): string => {
1546
+ return Object.entries(answers)
1547
+ .map(([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`)
1548
+ .join(" | ");
1549
+ };
1550
+
1551
+ /**
1552
+ * If `groupedAutoAdvance` is enabled (default) and we're not on the final
1553
+ * page, advance one step. The final page never auto-submits — users always
1554
+ * confirm with an explicit Submit-all click so they can review.
1555
+ */
1556
+ const maybeAutoAdvance = (sheet: HTMLElement): void => {
1557
+ if (config.features?.askUserQuestion?.groupedAutoAdvance === false) return;
1558
+ const idx = getCurrentIndex(sheet);
1559
+ const count = getQuestionCount(sheet);
1560
+ if (idx >= count - 1) return;
1561
+ const sourceMessage = sessionRef.current
1562
+ ?.getMessages()
1563
+ .find((m) => m.toolCall?.id === sheet.getAttribute("data-tool-call-id"));
1564
+ if (!sourceMessage) return;
1565
+ navigateToPage(sheet, sourceMessage, config, idx + 1);
1566
+ persistGroupedProgress(sheet);
1567
+ };
1568
+
1569
+ askUserOverlay.addEventListener("click", (event) => {
1570
+ const target = event.target as HTMLElement;
1571
+ const trigger = target.closest<HTMLElement>("[data-ask-user-action]");
1572
+ if (!trigger) return;
1573
+ const sheet = trigger.closest<HTMLElement>("[data-persona-ask-sheet-for]");
1574
+ if (!sheet) return;
1575
+
1576
+ const action = trigger.getAttribute("data-ask-user-action");
1577
+ event.preventDefault();
1578
+ event.stopPropagation();
1579
+
1580
+ if (action === "dismiss") {
1581
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1582
+ mount.dispatchEvent(
1583
+ new CustomEvent("persona:askUserQuestion:dismissed", {
1584
+ detail: { toolUseId: toolCallId },
1585
+ bubbles: true,
1586
+ composed: true,
1587
+ })
1588
+ );
1589
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1590
+
1591
+ // Best-effort: if this sheet corresponds to a LOCAL-awaiting tool,
1592
+ // unblock the paused execution with a sentinel answer so the server
1593
+ // doesn't sit in waiting_for_local forever. Fire-and-forget — errors
1594
+ // are surfaced to the onError callback. Flip the answered flag first
1595
+ // so a racing render pass doesn't re-mount the sheet mid-dismissal.
1596
+ const sourceMessage = sessionRef.current
1597
+ ?.getMessages()
1598
+ .find((m) => m.toolCall?.id === toolCallId);
1599
+ if (sourceMessage?.agentMetadata?.awaitingLocalTool) {
1600
+ sessionRef.current?.markAskUserQuestionResolved(sourceMessage);
1601
+ sessionRef.current?.resolveAskUserQuestion(sourceMessage, "(dismissed)");
1602
+ }
1603
+ return;
1604
+ }
1605
+
1606
+ if (action === "pick") {
1607
+ const label = trigger.getAttribute("data-option-label");
1608
+ if (!label) return;
1609
+ const multiSelect = sheet.getAttribute("data-multi-select") === "true";
1610
+ const grouped = isGroupedSheet(sheet);
1611
+
1612
+ if (grouped && multiSelect) {
1613
+ const stored = readAnswersFromSheet(sheet)[getCurrentIndex(sheet)];
1614
+ const set = new Set<string>(Array.isArray(stored) ? stored : []);
1615
+ if (set.has(label)) set.delete(label);
1616
+ else set.add(label);
1617
+ setCurrentAnswer(sheet, Array.from(set));
1618
+ persistGroupedProgress(sheet);
1619
+ return;
1620
+ }
1621
+
1622
+ if (grouped) {
1623
+ setCurrentAnswer(sheet, label);
1624
+ persistGroupedProgress(sheet);
1625
+ maybeAutoAdvance(sheet);
1626
+ return;
1627
+ }
1628
+
1629
+ // 1-question modes — preserve original UX.
1630
+ if (multiSelect) {
1631
+ const pressed = trigger.getAttribute("aria-pressed") === "true";
1632
+ trigger.setAttribute("aria-pressed", pressed ? "false" : "true");
1633
+ trigger.classList.toggle("persona-ask-pill-selected", !pressed);
1634
+ const submitBtn = sheet.querySelector<HTMLButtonElement>(
1635
+ '[data-ask-user-action="submit-multi"]'
1636
+ );
1637
+ if (submitBtn) {
1638
+ submitBtn.disabled = getSelectedLabels(sheet).length === 0;
1639
+ }
1640
+ return;
1641
+ }
1642
+ submitAskUserAnswer(sheet, label, { source: "pick", values: [label] });
1643
+ return;
1644
+ }
1645
+
1646
+ if (action === "submit-multi") {
1647
+ const labels = getSelectedLabels(sheet);
1648
+ if (labels.length === 0) return;
1649
+ submitAskUserAnswer(sheet, labels.join(", "), {
1650
+ source: "multi",
1651
+ values: labels,
1652
+ });
1653
+ return;
1654
+ }
1655
+
1656
+ if (action === "open-free-text") {
1657
+ const row = sheet.querySelector<HTMLElement>('[data-ask-free-text-row="true"]');
1658
+ if (row) {
1659
+ row.classList.remove("persona-hidden");
1660
+ const input = row.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1661
+ input?.focus();
1662
+ }
1663
+ return;
1664
+ }
1665
+
1666
+ if (action === "focus-free-text") {
1667
+ // Rows-layout Other row: input lives inside the row container itself.
1668
+ // Native click on the input already focuses it; this branch handles
1669
+ // clicks on the badge or row chrome AND digit-shortcut activations.
1670
+ const input = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1671
+ input?.focus();
1672
+ return;
1673
+ }
1674
+
1675
+ if (action === "submit-free-text") {
1676
+ const input = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1677
+ const text = input?.value ?? "";
1678
+ if (!text.trim()) return;
1679
+ if (isGroupedSheet(sheet)) {
1680
+ setCurrentAnswer(sheet, text.trim());
1681
+ persistGroupedProgress(sheet);
1682
+ maybeAutoAdvance(sheet);
1683
+ return;
1684
+ }
1685
+ submitAskUserAnswer(sheet, text, { source: "free-text" });
1686
+ return;
1687
+ }
1688
+
1689
+ if (action === "next" || action === "back") {
1690
+ if (!sessionRef.current) return;
1691
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1692
+ const sourceMessage = sessionRef.current
1693
+ .getMessages()
1694
+ .find((m) => m.toolCall?.id === toolCallId);
1695
+ if (!sourceMessage) return;
1696
+ // Flush any unsubmitted free-text input as the current answer.
1697
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1698
+ const pending = freeInput?.value?.trim() ?? "";
1699
+ if (pending) {
1700
+ const stored = readAnswersFromSheet(sheet)[getCurrentIndex(sheet)];
1701
+ if (typeof stored !== "string" || stored !== pending) {
1702
+ setCurrentAnswer(sheet, pending);
1703
+ }
1704
+ }
1705
+ const direction = action === "next" ? 1 : -1;
1706
+ const nextIdx = getCurrentIndex(sheet) + direction;
1707
+ navigateToPage(sheet, sourceMessage, config, nextIdx);
1708
+ persistGroupedProgress(sheet);
1709
+ return;
1710
+ }
1711
+
1712
+ if (action === "submit-all") {
1713
+ if (!sessionRef.current) return;
1714
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1715
+ const sourceMessage = sessionRef.current
1716
+ .getMessages()
1717
+ .find((m) => m.toolCall?.id === toolCallId);
1718
+ if (!sourceMessage) return;
1719
+ // Flush any pending free-text on the final page first.
1720
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1721
+ const pending = freeInput?.value?.trim() ?? "";
1722
+ if (pending) setCurrentAnswer(sheet, pending);
1723
+
1724
+ const structured = buildStructuredAnswers(sheet, sourceMessage);
1725
+ // Persist final answers to message metadata BEFORE resolving so the
1726
+ // answered-state review card (which reads `agentMetadata
1727
+ // .askUserQuestionAnswers`) shows the user's actual picks instead of
1728
+ // "(skipped)" placeholders. Without this, any answer set only via the
1729
+ // pending-flush above (or via paths that bypassed the per-pick persist
1730
+ // hook) would be missing from the transcript review even though it
1731
+ // landed in the structured payload sent to the agent.
1732
+ sessionRef.current.persistAskUserQuestionProgress(sourceMessage, {
1733
+ answers: structured,
1734
+ currentIndex: getCurrentIndex(sheet),
1735
+ });
1736
+ const summary = stringifyStructured(structured);
1737
+ submitAskUserAnswer(sheet, summary || "(submitted)", {
1738
+ source: "submit-all",
1739
+ structured,
1740
+ });
1741
+ return;
1742
+ }
1743
+
1744
+ if (action === "skip") {
1745
+ if (!sessionRef.current) return;
1746
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1747
+ const sourceMessage = sessionRef.current
1748
+ .getMessages()
1749
+ .find((m) => m.toolCall?.id === toolCallId);
1750
+ if (!sourceMessage) return;
1751
+
1752
+ const grouped = isGroupedSheet(sheet);
1753
+ const idx = getCurrentIndex(sheet);
1754
+ const count = getQuestionCount(sheet);
1755
+ const isFinal = idx >= count - 1;
1756
+
1757
+ // Single-question payloads behave like dismiss.
1758
+ if (!grouped) {
1759
+ mount.dispatchEvent(
1760
+ new CustomEvent("persona:askUserQuestion:dismissed", {
1761
+ detail: { toolUseId: toolCallId },
1762
+ bubbles: true,
1763
+ composed: true,
1764
+ })
1765
+ );
1766
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1767
+ if (sourceMessage.agentMetadata?.awaitingLocalTool) {
1768
+ sessionRef.current.markAskUserQuestionResolved(sourceMessage);
1769
+ sessionRef.current.resolveAskUserQuestion(sourceMessage, "(dismissed)");
1770
+ }
1771
+ return;
1772
+ }
1773
+
1774
+ // Drop the current question's answer (if any) so it's absent from the
1775
+ // resolved Record. setCurrentAnswer with an empty string deletes the
1776
+ // index from the in-memory map.
1777
+ setCurrentAnswer(sheet, "");
1778
+ // Also clear any unsubmitted free-text on this page.
1779
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1780
+ if (freeInput) freeInput.value = "";
1781
+
1782
+ if (isFinal) {
1783
+ // Submit with whatever has been recorded so far.
1784
+ const structured = buildStructuredAnswers(sheet, sourceMessage);
1785
+ const summary = stringifyStructured(structured);
1786
+ submitAskUserAnswer(sheet, summary || "(skipped)", {
1787
+ source: "submit-all",
1788
+ structured,
1789
+ });
1790
+ return;
1791
+ }
1792
+
1793
+ // Intermediate page: advance one step without recording.
1794
+ navigateToPage(sheet, sourceMessage, config, idx + 1);
1795
+ persistGroupedProgress(sheet);
1796
+ return;
1797
+ }
1798
+ });
1799
+
1800
+ // Enter on the free-text input → submit. Stays on the overlay because the
1801
+ // event target IS the input, which lives inside the overlay subtree.
1802
+ askUserOverlay.addEventListener("keydown", (event) => {
1803
+ if (event.key !== "Enter") return;
1804
+ const target = event.target as HTMLElement;
1805
+ const input = target as HTMLInputElement;
1806
+ if (!input.matches?.('[data-ask-free-text-input="true"]')) return;
1807
+ const sheet = input.closest<HTMLElement>("[data-persona-ask-sheet-for]");
1808
+ if (!sheet) return;
1809
+ event.preventDefault();
1810
+ const text = input.value;
1811
+ if (!text.trim()) return;
1812
+ if (isGroupedSheet(sheet)) {
1813
+ setCurrentAnswer(sheet, text.trim());
1814
+ persistGroupedProgress(sheet);
1815
+ maybeAutoAdvance(sheet);
1816
+ return;
1817
+ }
1818
+ submitAskUserAnswer(sheet, text, { source: "free-text" });
1819
+ });
1820
+
1821
+ // Digit 1–9 → pick option N on the current rows-layout single-select page.
1822
+ // Listens on `document` so the shortcut fires regardless of where focus
1823
+ // currently sits (host page body, panel chrome, anywhere). The handler
1824
+ // gates strictly: only fires when an active sheet is mounted in our
1825
+ // overlay, and bails when focus is on any input/textarea/contenteditable
1826
+ // (covers the free-text input, the chat composer, and any host-page input).
1827
+ const handleAskUserDigitKey = (event: KeyboardEvent): void => {
1828
+ if (!/^[1-9]$/.test(event.key)) return;
1829
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
1830
+ const target = event.target as HTMLElement | null;
1831
+ if (
1832
+ target?.tagName === "INPUT" ||
1833
+ target?.tagName === "TEXTAREA" ||
1834
+ target?.isContentEditable
1835
+ ) {
1836
+ return;
1837
+ }
1838
+ const sheet = askUserOverlay.querySelector<HTMLElement>("[data-persona-ask-sheet-for]");
1839
+ if (!sheet) return;
1840
+ if (sheet.getAttribute("data-ask-layout") !== "rows") return;
1841
+ if (sheet.getAttribute("data-multi-select") === "true") return;
1842
+ const n = Number(event.key);
1843
+ const pills = sheet.querySelectorAll<HTMLElement>(
1844
+ '[data-ask-pill-list="true"] [data-ask-user-action="pick"], [data-ask-pill-list="true"] [data-ask-user-action="focus-free-text"]'
1845
+ );
1846
+ const target_pill = pills[n - 1];
1847
+ if (!target_pill) return;
1848
+ event.preventDefault();
1849
+ target_pill.click();
1850
+ };
1851
+ document.addEventListener("keydown", handleAskUserDigitKey);
1852
+
1411
1853
  let artifactSplitRoot: HTMLElement | null = null;
1412
1854
  let artifactResizeHandle: HTMLElement | null = null;
1413
1855
  let artifactResizeUnbind: (() => void) | null = null;
@@ -1625,12 +2067,74 @@ export const createAgentExperience = (
1625
2067
  }
1626
2068
  } else {
1627
2069
  panel.appendChild(container);
2070
+ // Composer-bar mode: the pill (footer) and peek banner live in a
2071
+ // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2072
+ // independent of the wrapper's geometry transitions. Critical for
2073
+ // modal mode — the wrapper there has `transform: translate(-50%, -50%)`
2074
+ // which would establish a containing block trapping any `position: fixed`
2075
+ // descendant. Order inside pillRoot: peekBanner (slim row above pill)
2076
+ // → footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
2077
+ // default until ui.ts toggles `.persona-pill-peek--visible` based on
2078
+ // streaming/hover/open state via syncComposerBarPeek().
2079
+ if (isComposerBar() && pillRoot) {
2080
+ if (panelElements.peekBanner) {
2081
+ pillRoot.appendChild(panelElements.peekBanner);
2082
+ }
2083
+ pillRoot.appendChild(footer);
2084
+ }
1628
2085
  }
1629
2086
  mount.appendChild(wrapper);
2087
+ // pillRoot is mounted *after* wrapper so it naturally stacks on top
2088
+ // when both share the same z-index (e.g. fullscreen mode where the
2089
+ // pill should float above the chat panel chrome).
2090
+ if (pillRoot) {
2091
+ mount.appendChild(pillRoot);
2092
+ }
1630
2093
 
1631
2094
  // Apply full-height and sidebar styles if enabled
1632
2095
  // This ensures the widget fills its container height with proper flex layout
1633
2096
  const applyFullHeightStyles = () => {
2097
+ // Composer-bar mode owns its own sizing/chrome. Geometry comes from
2098
+ // `applyComposerBarGeometry()` (per-state inline on the wrapper), the
2099
+ // pill carries its own chrome via `.persona-pill-composer`, and the
2100
+ // expanded chat panel chrome (border + radius + shadow + bg) is painted
2101
+ // inline on the `container` (NOT the panel — the panel is a transparent
2102
+ // flex column with a gap so the pill renders as a sibling below the
2103
+ // chrome). Same theme contract as floating mode
2104
+ // (`theme.components.panel.{shadow,border,borderRadius}`); collapsed
2105
+ // clears it (container is hidden via display:none anyway), expanded
2106
+ // re-applies it, with the `fullscreen` variant intentionally chrome-less.
2107
+ if (isComposerBar()) {
2108
+ panel.style.width = "100%";
2109
+ panel.style.maxWidth = "100%";
2110
+ const cb = config.launcher?.composerBar ?? {};
2111
+ const isExpanded = wrapper.dataset.state === "expanded";
2112
+ const expandedSize = cb.expandedSize ?? "anchored";
2113
+ const wantsChrome = isExpanded && expandedSize !== "fullscreen";
2114
+ if (!wantsChrome) {
2115
+ container.style.background = "";
2116
+ container.style.border = "";
2117
+ container.style.borderRadius = "";
2118
+ container.style.overflow = "";
2119
+ container.style.boxShadow = "";
2120
+ return;
2121
+ }
2122
+ const panelPartial = config.theme?.components?.panel;
2123
+ const activeTheme = getActiveTheme(config);
2124
+ const resolveCb = (raw: string | undefined, fallback: string): string => {
2125
+ if (raw == null || raw === "") return fallback;
2126
+ return resolveTokenValue(activeTheme, raw) ?? raw;
2127
+ };
2128
+ const defaultBorder = "1px solid var(--persona-border)";
2129
+ const defaultShadow = "var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25))";
2130
+ const defaultRadius = "var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))";
2131
+ container.style.background = "var(--persona-surface, #ffffff)";
2132
+ container.style.border = resolveCb(panelPartial?.border, defaultBorder);
2133
+ container.style.borderRadius = resolveCb(panelPartial?.borderRadius, defaultRadius);
2134
+ container.style.boxShadow = resolveCb(panelPartial?.shadow, defaultShadow);
2135
+ container.style.overflow = "hidden";
2136
+ return;
2137
+ }
1634
2138
  const dockedMode = isDockedMountMode(config);
1635
2139
  const sidebarMode = config.launcher?.sidebarMode ?? false;
1636
2140
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -1965,6 +2469,10 @@ export const createAgentExperience = (
1965
2469
  applyArtifactPaneAppearance(mount, config);
1966
2470
 
1967
2471
  const destroyCallbacks: Array<() => void> = [];
2472
+ // Clean up the document-level digit-key shortcut listener registered earlier.
2473
+ destroyCallbacks.push(() => {
2474
+ document.removeEventListener("keydown", handleAskUserDigitKey);
2475
+ });
1968
2476
 
1969
2477
  let teardownHostStacking: (() => void) | null = null;
1970
2478
  let releaseScrollLock: (() => void) | null = null;
@@ -2056,6 +2564,16 @@ export const createAgentExperience = (
2056
2564
  let session: AgentWidgetSession;
2057
2565
  let isStreaming = false;
2058
2566
  const messageCache = createMessageCache();
2567
+ // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
2568
+ // bubble for, per message id. Lets us skip unnecessary rebuilds across
2569
+ // re-renders so user state inside the plugin (typed text, focus) survives.
2570
+ const lastAskBubbleFingerprint = new Map<string, string>();
2571
+ // Same idea for component-directive bubbles (registered custom components
2572
+ // rendered from JSON directives). The renderer's element is injected into the
2573
+ // live DOM post-morph so its event listeners survive; this map gates the
2574
+ // expensive rebuild on fingerprint change so user state inside the rendered
2575
+ // component (e.g. partially-filled form inputs) is not wiped on every pass.
2576
+ const lastComponentDirectiveFingerprint = new Map<string, string>();
2059
2577
  let configVersion = 0;
2060
2578
  const autoFollow = createFollowStateController();
2061
2579
  let lastScrollTop = 0;
@@ -2139,7 +2657,9 @@ export const createAgentExperience = (
2139
2657
 
2140
2658
  const payload = {
2141
2659
  messages,
2142
- metadata: persistentMetadata
2660
+ metadata: persistentMetadata,
2661
+ artifacts: lastArtifactsState.artifacts,
2662
+ selectedArtifactId: lastArtifactsState.selectedId
2143
2663
  };
2144
2664
  try {
2145
2665
  const result = storageAdapter.save(payload);
@@ -2398,15 +2918,93 @@ export const createAgentExperience = (
2398
2918
 
2399
2919
  // Track active message IDs for cache pruning
2400
2920
  const activeMessageIds = new Set<string>();
2921
+ // Track ask_user_question tool-call ids whose bubbles were rendered this
2922
+ // pass — used to prune stale sheets from the composer overlay afterward.
2923
+ const liveAskToolIds = new Set<string>();
2924
+
2925
+ // Plugins that render `ask_user_question` typically attach DOM listeners
2926
+ // directly to their buttons. The wrapper cache uses `cloneNode(true)` and
2927
+ // idiomorph inserts new nodes via `document.importNode` — both strip
2928
+ // listeners. For plugin-handled ask messages we therefore append an empty
2929
+ // stub during the morph pass and hydrate the live plugin bubble into the
2930
+ // morphed wrapper afterward (see post-morph loop below). The stub carries
2931
+ // `data-preserve-runtime` so subsequent passes leave the live wrapper
2932
+ // (with its listener-bearing bubble) untouched.
2933
+ const hasAskPlugin = plugins.some((p) => p.renderAskUserQuestion);
2934
+ type AskPluginHydrate = {
2935
+ messageId: string;
2936
+ fingerprint: string;
2937
+ bubble: HTMLElement | null;
2938
+ };
2939
+ const askPluginHydrate: AskPluginHydrate[] = [];
2940
+
2941
+ // Component-directive bubbles use the same stub-and-hydrate pattern as
2942
+ // ask_user_question plugins: the renderer's HTMLElement is built live and
2943
+ // injected into the morphed wrapper afterward, so listeners attached via
2944
+ // `addEventListener` (e.g. form `submit` handlers) survive transcript
2945
+ // morphs. `bubble: null` means the fingerprint matched a previous pass and
2946
+ // the live wrapper is reused as-is.
2947
+ type ComponentDirectiveHydrate = {
2948
+ messageId: string;
2949
+ fingerprint: string;
2950
+ bubble: HTMLElement | null;
2951
+ };
2952
+ const componentDirectiveHydrate: ComponentDirectiveHydrate[] = [];
2953
+ const componentStreamingEnabled = config.enableComponentStreaming !== false;
2401
2954
 
2402
2955
  messages.forEach((message) => {
2403
2956
  activeMessageIds.add(message.id);
2404
2957
 
2405
- // Fingerprint cache: skip re-rendering unchanged messages
2406
- const fingerprint = computeMessageFingerprint(message, configVersion);
2407
- const cachedWrapper = getCachedWrapper(messageCache, message.id, fingerprint);
2958
+ const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
2959
+ const hasDirectiveBubble =
2960
+ !askWithPlugin &&
2961
+ message.role === "assistant" &&
2962
+ !message.variant &&
2963
+ componentStreamingEnabled &&
2964
+ hasComponentDirective(message);
2965
+
2966
+ // If a message previously rendered as a directive bubble but no longer
2967
+ // does (e.g. content was rewritten), strip `data-preserve-runtime` from
2968
+ // the live wrapper so the next morph can replace it.
2969
+ if (!hasDirectiveBubble && lastComponentDirectiveFingerprint.has(message.id)) {
2970
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
2971
+ existing?.removeAttribute("data-preserve-runtime");
2972
+ lastComponentDirectiveFingerprint.delete(message.id);
2973
+ }
2974
+
2975
+ // Fingerprint cache: skip re-rendering unchanged messages. Append the
2976
+ // ask-user-question answered/answers state so flipping `askUserQuestionAnswered`
2977
+ // (or accumulating answers) busts both the wrapper cache and the plugin's
2978
+ // `lastAskBubbleFingerprint` check, forcing a re-render of the review UX.
2979
+ const askMeta = isAskUserQuestionMessage(message)
2980
+ ? `:${message.agentMetadata?.askUserQuestionAnswered ? "a" : "u"}:${
2981
+ message.agentMetadata?.askUserQuestionAnswers
2982
+ ? Object.keys(message.agentMetadata.askUserQuestionAnswers).length
2983
+ : 0
2984
+ }`
2985
+ : "";
2986
+ const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
2987
+ const cachedWrapper = (askWithPlugin || hasDirectiveBubble)
2988
+ ? null
2989
+ : getCachedWrapper(messageCache, message.id, fingerprint);
2408
2990
  if (cachedWrapper) {
2409
2991
  tempContainer.appendChild(cachedWrapper.cloneNode(true));
2992
+ // Keep the overlay sheet alive only while the server is actively
2993
+ // waiting on the user (awaitingLocalTool === true). Before step_await
2994
+ // fires, or after the answer resumes the flow, omit from
2995
+ // liveAskToolIds so the prune loop below removes any stale DOM sheet.
2996
+ // Guards against lingering skeleton sheets from tool_start events
2997
+ // that never get a matching step_await (e.g. LLM-hallucinated trailing
2998
+ // ask_user_question calls at end-of-turn).
2999
+ if (
3000
+ isAskUserQuestionMessage(message) &&
3001
+ message.toolCall?.id &&
3002
+ message.agentMetadata?.awaitingLocalTool === true &&
3003
+ !message.agentMetadata?.askUserQuestionAnswered
3004
+ ) {
3005
+ liveAskToolIds.add(message.toolCall.id);
3006
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
3007
+ }
2410
3008
  return;
2411
3009
  }
2412
3010
 
@@ -2432,7 +3030,111 @@ export const createAgentExperience = (
2432
3030
  // Get message layout config
2433
3031
  const messageLayoutConfig = config.layout?.messages;
2434
3032
 
2435
- if (matchingPlugin) {
3033
+ // ask_user_question has two rendering modes while waiting for an answer:
3034
+ // 1. Plugin `renderAskUserQuestion` — returns an inline transcript
3035
+ // element with its own UI; the composer-overlay sheet is suppressed.
3036
+ // 2. Built-in composer-overlay answer-pill sheet — no transcript stub.
3037
+ // Plugins win when they return a non-null element; otherwise fall
3038
+ // through to the built-in overlay.
3039
+ //
3040
+ // Once answered, the original tool message is suppressed entirely from
3041
+ // the transcript. `session.resolveAskUserQuestion` injects one assistant
3042
+ // bubble per question and one user bubble per answer (skipped questions
3043
+ // become an italic `*Skipped*` user bubble), so the transcript reads
3044
+ // like a normal Q→A conversation. Plugins do not render the answered
3045
+ // state.
3046
+ if (
3047
+ isAskUserQuestionMessage(message) &&
3048
+ message.agentMetadata?.askUserQuestionAnswered === true
3049
+ ) {
3050
+ // Drop any previously-mounted plugin bubble so the morph pass
3051
+ // removes the now-stale interactive sheet.
3052
+ lastAskBubbleFingerprint.delete(message.id);
3053
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
3054
+ existing?.removeAttribute("data-preserve-runtime");
3055
+ return;
3056
+ }
3057
+
3058
+ if (
3059
+ isAskUserQuestionMessage(message) &&
3060
+ config.features?.askUserQuestion?.enabled !== false
3061
+ ) {
3062
+ const askPlugin = plugins.find((p) => typeof p.renderAskUserQuestion === "function");
3063
+ if (askPlugin && sessionRef.current) {
3064
+ const lastFp = lastAskBubbleFingerprint.get(message.id);
3065
+ // Whether to actually call the plugin renderer this pass. We do it
3066
+ // on first sight of this message, or when its fingerprint changed
3067
+ // (e.g. payload streamed in more options). Otherwise we rely on the
3068
+ // already-mounted bubble in `container`.
3069
+ const needsRebuild = lastFp !== fingerprint;
3070
+
3071
+ let pluginBubble: HTMLElement | null = null;
3072
+ if (needsRebuild) {
3073
+ const { payload, complete } = parseAskUserQuestionPayload(message);
3074
+ const messageId = message.id;
3075
+ const liveMessage = (): AgentWidgetMessage | undefined =>
3076
+ sessionRef.current?.getMessages().find((m) => m.id === messageId);
3077
+ pluginBubble = askPlugin.renderAskUserQuestion!({
3078
+ message,
3079
+ payload,
3080
+ complete,
3081
+ resolve: (answer) => {
3082
+ const live = liveMessage();
3083
+ if (live) sessionRef.current?.resolveAskUserQuestion(live, answer);
3084
+ },
3085
+ dismiss: () => {
3086
+ const live = liveMessage();
3087
+ if (live?.agentMetadata?.awaitingLocalTool) {
3088
+ sessionRef.current?.markAskUserQuestionResolved(live);
3089
+ sessionRef.current?.resolveAskUserQuestion(live, "(dismissed)");
3090
+ }
3091
+ },
3092
+ config,
3093
+ });
3094
+ }
3095
+
3096
+ // If the plugin opted out (returned null on a fresh build) AND we
3097
+ // have no previously-mounted bubble for this message, fall back to
3098
+ // the built-in overlay sheet. If we already have a mounted bubble
3099
+ // and the plugin didn't run this pass (cached), keep using it.
3100
+ const previouslyMounted = lastFp != null;
3101
+ if (needsRebuild && pluginBubble === null && !previouslyMounted) {
3102
+ if (
3103
+ message.agentMetadata?.awaitingLocalTool === true &&
3104
+ !message.agentMetadata?.askUserQuestionAnswered
3105
+ ) {
3106
+ liveAskToolIds.add(message.toolCall!.id);
3107
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
3108
+ }
3109
+ return;
3110
+ }
3111
+
3112
+ // Append a stub wrapper for the morph pass; hydrate the real bubble
3113
+ // into it post-morph so its event listeners survive.
3114
+ const stub = document.createElement("div");
3115
+ stub.className = "persona-flex";
3116
+ stub.id = `wrapper-${message.id}`;
3117
+ stub.setAttribute("data-wrapper-id", message.id);
3118
+ stub.setAttribute("data-ask-plugin-stub", "true");
3119
+ stub.setAttribute("data-preserve-runtime", "true");
3120
+ tempContainer.appendChild(stub);
3121
+ askPluginHydrate.push({
3122
+ messageId: message.id,
3123
+ fingerprint,
3124
+ bubble: pluginBubble,
3125
+ });
3126
+ return;
3127
+ } else {
3128
+ if (
3129
+ message.agentMetadata?.awaitingLocalTool === true &&
3130
+ !message.agentMetadata?.askUserQuestionAnswered
3131
+ ) {
3132
+ liveAskToolIds.add(message.toolCall!.id);
3133
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
3134
+ }
3135
+ return;
3136
+ }
3137
+ } else if (matchingPlugin) {
2436
3138
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
2437
3139
  if (!showReasoning) return;
2438
3140
  bubble = matchingPlugin.renderReasoning({
@@ -2479,19 +3181,26 @@ export const createAgentExperience = (
2479
3181
  }
2480
3182
  }
2481
3183
 
2482
- // Check for component directive if no plugin handled it
2483
- if (!bubble && message.role === "assistant" && !message.variant) {
2484
- const enableComponentStreaming = config.enableComponentStreaming !== false; // Default to true
2485
- if (enableComponentStreaming && hasComponentDirective(message)) {
2486
- const directive = extractComponentDirectiveFromMessage(message);
2487
- if (directive) {
3184
+ // Check for component directive if no plugin handled it. We use the
3185
+ // same stub-and-hydrate trick as ask_user_question plugins (see comment
3186
+ // above `componentDirectiveHydrate`): build the live element with its
3187
+ // listeners, append a stub for the morph pass, then inject the live
3188
+ // element into the morphed wrapper afterward.
3189
+ if (!bubble && hasDirectiveBubble) {
3190
+ const directive = extractComponentDirectiveFromMessage(message);
3191
+ if (directive) {
3192
+ const lastFp = lastComponentDirectiveFingerprint.get(message.id);
3193
+ const needsRebuild = lastFp !== fingerprint;
3194
+ const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
3195
+ let liveBubble: HTMLElement | null = null;
3196
+
3197
+ if (needsRebuild) {
2488
3198
  const componentBubble = renderComponentDirective(directive, {
2489
3199
  config,
2490
3200
  message,
2491
3201
  transform
2492
3202
  });
2493
3203
  if (componentBubble) {
2494
- const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
2495
3204
  if (wrapChrome) {
2496
3205
  const componentWrapper = document.createElement("div");
2497
3206
  componentWrapper.className = [
@@ -2519,7 +3228,7 @@ export const createAgentExperience = (
2519
3228
  }
2520
3229
 
2521
3230
  componentWrapper.appendChild(componentBubble);
2522
- bubble = componentWrapper;
3231
+ liveBubble = componentWrapper;
2523
3232
  } else {
2524
3233
  const stack = document.createElement("div");
2525
3234
  stack.className =
@@ -2542,10 +3251,33 @@ export const createAgentExperience = (
2542
3251
  }
2543
3252
 
2544
3253
  stack.appendChild(componentBubble);
2545
- bubble = stack;
3254
+ liveBubble = stack;
2546
3255
  }
2547
3256
  }
2548
3257
  }
3258
+
3259
+ // If the directive is registered (live bubble built or already
3260
+ // mounted from a previous pass), use the stub-and-hydrate path.
3261
+ // Otherwise fall through to the standard render path so the message
3262
+ // text is at least visible.
3263
+ if (liveBubble || lastFp != null) {
3264
+ const stub = document.createElement("div");
3265
+ stub.className = "persona-flex";
3266
+ stub.id = `wrapper-${message.id}`;
3267
+ stub.setAttribute("data-wrapper-id", message.id);
3268
+ stub.setAttribute("data-component-directive-stub", "true");
3269
+ stub.setAttribute("data-preserve-runtime", "true");
3270
+ if (!wrapChrome) {
3271
+ stub.classList.add("persona-w-full");
3272
+ }
3273
+ tempContainer.appendChild(stub);
3274
+ componentDirectiveHydrate.push({
3275
+ messageId: message.id,
3276
+ fingerprint,
3277
+ bubble: liveBubble
3278
+ });
3279
+ return;
3280
+ }
2549
3281
  }
2550
3282
  }
2551
3283
 
@@ -2610,6 +3342,20 @@ export const createAgentExperience = (
2610
3342
  tempContainer.appendChild(wrapper);
2611
3343
  });
2612
3344
 
3345
+ // Prune any ask_user_question sheets whose source message is no longer in
3346
+ // the message list (e.g. after clearChat or a splice).
3347
+ if (panelElements.composerOverlay) {
3348
+ const sheets = panelElements.composerOverlay.querySelectorAll<HTMLElement>(
3349
+ "[data-persona-ask-sheet-for]"
3350
+ );
3351
+ sheets.forEach((sheet) => {
3352
+ const id = sheet.getAttribute("data-persona-ask-sheet-for");
3353
+ if (id && !liveAskToolIds.has(id)) {
3354
+ removeAskUserQuestionSheet(panelElements.composerOverlay, id);
3355
+ }
3356
+ });
3357
+ }
3358
+
2613
3359
  if (config.features?.toolCallDisplay?.grouped) {
2614
3360
  const toolGroups: AgentWidgetMessage[][] = [];
2615
3361
  let currentGroup: AgentWidgetMessage[] = [];
@@ -2839,13 +3585,544 @@ export const createAgentExperience = (
2839
3585
 
2840
3586
  // Use idiomorph to morph the container contents
2841
3587
  morphMessages(container, tempContainer);
3588
+
3589
+ // Hydrate plugin-rendered ask-question bubbles into their stub wrappers.
3590
+ // Idiomorph imports new nodes via `document.importNode`, which strips
3591
+ // listeners — so we built only an empty stub during morph and now inject
3592
+ // the real, listener-bearing bubble directly into the live DOM.
3593
+ if (askPluginHydrate.length > 0) {
3594
+ for (const { messageId, fingerprint, bubble } of askPluginHydrate) {
3595
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
3596
+ if (!wrapper) continue;
3597
+ if (bubble === null) {
3598
+ // No fresh bubble built this pass — either the plugin opted out
3599
+ // and a previously-mounted bubble already lives here (preserved by
3600
+ // `data-preserve-runtime`), or we skipped the rebuild because the
3601
+ // fingerprint matched. Either way, leave the live wrapper alone.
3602
+ continue;
3603
+ }
3604
+ wrapper.replaceChildren(bubble);
3605
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
3606
+ lastAskBubbleFingerprint.set(messageId, fingerprint);
3607
+ }
3608
+ }
3609
+
3610
+ // Drop fingerprints for messages that are no longer present so a future
3611
+ // re-appearance triggers a fresh plugin render.
3612
+ if (lastAskBubbleFingerprint.size > 0) {
3613
+ for (const id of lastAskBubbleFingerprint.keys()) {
3614
+ if (!activeMessageIds.has(id)) lastAskBubbleFingerprint.delete(id);
3615
+ }
3616
+ }
3617
+
3618
+ // Hydrate component-directive bubbles into their stub wrappers, mirroring
3619
+ // the ask-question hydration above.
3620
+ if (componentDirectiveHydrate.length > 0) {
3621
+ for (const { messageId, fingerprint, bubble } of componentDirectiveHydrate) {
3622
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
3623
+ if (!wrapper) continue;
3624
+ if (bubble === null) {
3625
+ // Fingerprint matched the previous pass — the live wrapper (kept
3626
+ // alive by `data-preserve-runtime`) still holds the listener-bearing
3627
+ // bubble from a prior render. Leave it untouched.
3628
+ continue;
3629
+ }
3630
+ wrapper.replaceChildren(bubble);
3631
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
3632
+ lastComponentDirectiveFingerprint.set(messageId, fingerprint);
3633
+ }
3634
+ }
3635
+
3636
+ if (lastComponentDirectiveFingerprint.size > 0) {
3637
+ for (const id of lastComponentDirectiveFingerprint.keys()) {
3638
+ if (!activeMessageIds.has(id)) lastComponentDirectiveFingerprint.delete(id);
3639
+ }
3640
+ }
2842
3641
  };
2843
3642
 
2844
3643
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
2845
3644
  const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
2846
3645
 
3646
+ /**
3647
+ * Composer-bar outside-click dismiss. While the chat is expanded, clicking
3648
+ * anywhere outside the wrapper (i.e. NOT inside the chat panel chrome and
3649
+ * NOT inside the pill) collapses back to just the pill. Uses `pointerdown`
3650
+ * + capture so we run before host-page click handlers (and before any
3651
+ * stop-propagation upstream); composedPath() includes the shadow DOM
3652
+ * subtree, so clicks inside the wrapper (which lives in the shadow root)
3653
+ * are correctly identified as inside.
3654
+ */
3655
+ let composerBarOutsideClickListener: ((e: PointerEvent) => void) | null = null;
3656
+
3657
+ const attachComposerBarOutsideClickDismiss = () => {
3658
+ if (composerBarOutsideClickListener) return;
3659
+ const listener: (e: PointerEvent) => void = (event) => {
3660
+ const path = event.composedPath();
3661
+ // pillRoot is a viewport-fixed sibling of the wrapper, so a click on
3662
+ // the pill or peek wouldn't be in `wrapper`'s composedPath even
3663
+ // though it's logically "inside" the widget.
3664
+ if (path.includes(wrapper)) return;
3665
+ if (pillRoot && path.includes(pillRoot)) return;
3666
+ setOpenState(false, "user");
3667
+ };
3668
+ composerBarOutsideClickListener = listener;
3669
+ const targetDoc = mount.ownerDocument ?? document;
3670
+ targetDoc.addEventListener("pointerdown", listener, true);
3671
+ };
3672
+
3673
+ const detachComposerBarOutsideClickDismiss = () => {
3674
+ if (!composerBarOutsideClickListener) return;
3675
+ const targetDoc = mount.ownerDocument ?? document;
3676
+ targetDoc.removeEventListener(
3677
+ "pointerdown",
3678
+ composerBarOutsideClickListener,
3679
+ true
3680
+ );
3681
+ composerBarOutsideClickListener = null;
3682
+ };
3683
+
3684
+ destroyCallbacks.push(() => detachComposerBarOutsideClickDismiss());
3685
+
3686
+ /**
3687
+ * Composer-bar ESC dismiss. While the chat is expanded, pressing Escape
3688
+ * collapses back to just the pill — same end state as outside-click.
3689
+ * Matches the WAI-ARIA dialog pattern (modal mode is literally a dialog)
3690
+ * and the dominant chat-widget convention (Intercom, Drift, Crisp).
3691
+ * Guards on `event.isComposing` so dismissing an IME suggestion doesn't
3692
+ * also collapse the panel.
3693
+ */
3694
+ let composerBarEscapeListener: ((e: KeyboardEvent) => void) | null = null;
3695
+
3696
+ const attachComposerBarEscapeDismiss = () => {
3697
+ if (composerBarEscapeListener) return;
3698
+ const listener: (e: KeyboardEvent) => void = (event) => {
3699
+ if (event.key !== "Escape") return;
3700
+ if (event.isComposing) return;
3701
+ setOpenState(false, "user");
3702
+ };
3703
+ composerBarEscapeListener = listener;
3704
+ const targetDoc = mount.ownerDocument ?? document;
3705
+ targetDoc.addEventListener("keydown", listener, true);
3706
+ };
3707
+
3708
+ const detachComposerBarEscapeDismiss = () => {
3709
+ if (!composerBarEscapeListener) return;
3710
+ const targetDoc = mount.ownerDocument ?? document;
3711
+ targetDoc.removeEventListener(
3712
+ "keydown",
3713
+ composerBarEscapeListener,
3714
+ true
3715
+ );
3716
+ composerBarEscapeListener = null;
3717
+ };
3718
+
3719
+ destroyCallbacks.push(() => detachComposerBarEscapeDismiss());
3720
+
3721
+ /**
3722
+ * Composer-bar "peek" affordance — a chrome-less row above the pill that
3723
+ * shows a chat-bubble icon, the trailing 100 chars of the most recent
3724
+ * assistant message, and a chevron-up. It is the user's path back into the
3725
+ * expanded chat from the collapsed pill.
3726
+ *
3727
+ * Visible when (collapsed) AND (there is an assistant message with content)
3728
+ * AND (`isStreaming` OR `composerHovered`). Otherwise hidden. The hover
3729
+ * zone is the whole `panel` (not just the pill) so the cursor moving
3730
+ * between the pill and the peek doesn't trigger fade-out.
3731
+ *
3732
+ * Driven from a single `syncComposerBarPeek()` invoked from
3733
+ * `onMessagesChanged`, `onStreamingChanged`, `updateOpenState`, the
3734
+ * pointerenter/pointerleave on `panel`, and once at end-of-init.
3735
+ */
3736
+ let composerHovered = false;
3737
+ // Track which peek-plugins we've already attached for this widget root.
3738
+ // `ensurePluginActive` is idempotent, but the call is guarded behind a flag
3739
+ // so we don't pay the lookup cost on every chunk.
3740
+ const peekActivatedPlugins = new Set<string>();
3741
+
3742
+ /**
3743
+ * Resolve the effective stream animation feature for the peek surface.
3744
+ * `composerBar.peek.streamAnimation` overrides; otherwise the peek inherits
3745
+ * `features.streamAnimation` so the surface for devs is consistent across
3746
+ * the main bubble and the peek banner.
3747
+ */
3748
+ const resolvePeekStreamAnimationFeature = () => {
3749
+ const peekFeature = config.launcher?.composerBar?.peek?.streamAnimation;
3750
+ if (peekFeature) return peekFeature;
3751
+ return config.features?.streamAnimation;
3752
+ };
3753
+
3754
+ const syncComposerBarPeek = () => {
3755
+ if (!isComposerBar()) return;
3756
+ const peekBanner = panelElements.peekBanner;
3757
+ const peekTextNode = panelElements.peekTextNode;
3758
+ if (!peekBanner || !peekTextNode) return;
3759
+
3760
+ if (open) {
3761
+ peekBanner.classList.remove("persona-pill-peek--visible");
3762
+ return;
3763
+ }
3764
+
3765
+ const messages = session?.getMessages() ?? [];
3766
+ let lastAssistant: AgentWidgetMessage | undefined;
3767
+ for (let i = messages.length - 1; i >= 0; i--) {
3768
+ const m = messages[i];
3769
+ if (m.role === "assistant" && m.content) {
3770
+ lastAssistant = m;
3771
+ break;
3772
+ }
3773
+ }
3774
+ if (!lastAssistant) {
3775
+ peekBanner.classList.remove("persona-pill-peek--visible");
3776
+ return;
3777
+ }
3778
+
3779
+ const text = lastAssistant.content;
3780
+ const streaming = Boolean(lastAssistant.streaming);
3781
+
3782
+ // Resolve the same animation surface used by the main bubble. The peek
3783
+ // ignores `bubbleClass` (carve-out: peek has no bubble) but honors
3784
+ // `containerClass`, `wrap`, `useCaret`, `buffer`, `placeholder`,
3785
+ // `speed`/`duration`, and custom plugins.
3786
+ const feature = resolvePeekStreamAnimationFeature();
3787
+ const streamAnimation = resolveStreamAnimation(feature);
3788
+ const plugin =
3789
+ streamAnimation.type !== "none"
3790
+ ? resolveStreamAnimationPlugin(streamAnimation.type, feature?.plugins)
3791
+ : null;
3792
+ const pluginStillAnimating =
3793
+ plugin?.isAnimating?.(lastAssistant) === true;
3794
+ const animationActive =
3795
+ plugin !== null && (streaming || pluginStillAnimating);
3796
+
3797
+ if (animationActive && plugin && !peekActivatedPlugins.has(plugin.name)) {
3798
+ ensurePluginActive(plugin, mount);
3799
+ peekActivatedPlugins.add(plugin.name);
3800
+ }
3801
+
3802
+ // Manage `containerClass` on the peek text node. We track which class is
3803
+ // currently applied so a config swap (or animation deactivating after
3804
+ // stream completion) cleans up the previous class instead of stacking.
3805
+ const desiredContainerClass =
3806
+ animationActive && plugin?.containerClass ? plugin.containerClass : null;
3807
+ const currentContainerClass =
3808
+ peekTextNode.dataset.personaPeekStreamClass ?? null;
3809
+ if (currentContainerClass && currentContainerClass !== desiredContainerClass) {
3810
+ peekTextNode.classList.remove(currentContainerClass);
3811
+ delete peekTextNode.dataset.personaPeekStreamClass;
3812
+ }
3813
+ if (desiredContainerClass && currentContainerClass !== desiredContainerClass) {
3814
+ peekTextNode.classList.add(desiredContainerClass);
3815
+ peekTextNode.dataset.personaPeekStreamClass = desiredContainerClass;
3816
+ }
3817
+
3818
+ if (animationActive) {
3819
+ peekTextNode.style.setProperty(
3820
+ "--persona-stream-step",
3821
+ `${streamAnimation.speed}ms`
3822
+ );
3823
+ peekTextNode.style.setProperty(
3824
+ "--persona-stream-duration",
3825
+ `${streamAnimation.duration}ms`
3826
+ );
3827
+ } else {
3828
+ peekTextNode.style.removeProperty("--persona-stream-step");
3829
+ peekTextNode.style.removeProperty("--persona-stream-duration");
3830
+ }
3831
+
3832
+ // Apply buffering (word/line/plugin custom). If the buffer trims content
3833
+ // to empty AND the placeholder is "skeleton", show the skeleton — that's
3834
+ // the "line buffer between completions" affordance. Otherwise no
3835
+ // pre-content placeholder on the peek (a typing-dots indicator inside a
3836
+ // 1-line ticker would feel cramped).
3837
+ const buffered = animationActive
3838
+ ? applyStreamBuffer(text, streamAnimation.buffer, plugin, lastAssistant, streaming)
3839
+ : text;
3840
+
3841
+ const skeletonEnabled =
3842
+ animationActive && streamAnimation.placeholder === "skeleton";
3843
+ const showSkeletonOnly =
3844
+ skeletonEnabled && streaming && (!buffered || !buffered.trim());
3845
+
3846
+ if (showSkeletonOnly) {
3847
+ // Replace text node contents with just a peek-sized skeleton bar. The
3848
+ // bar carries `data-preserve-animation` so idiomorph keeps its shimmer
3849
+ // running across morph passes.
3850
+ const tempContainer = document.createElement("div");
3851
+ const skeleton = createSkeletonPlaceholder();
3852
+ skeleton.classList.add("persona-pill-peek__skeleton");
3853
+ tempContainer.appendChild(skeleton);
3854
+ morphMessages(peekTextNode, tempContainer);
3855
+ } else {
3856
+ // Trailing 100 chars; for animated modes we keep the slice but use
3857
+ // ABSOLUTE indices so per-char/per-word span IDs stay stable as the
3858
+ // window shifts each chunk — idiomorph then preserves animations on
3859
+ // already-revealed units instead of restarting them. Plain "none" mode
3860
+ // keeps the legacy `…` ellipsis prefix for visual continuity with the
3861
+ // pre-animation behavior.
3862
+ const sliceStart = Math.max(0, buffered.length - 100);
3863
+ const slice = buffered.length > 100 ? buffered.slice(-100) : buffered;
3864
+ const escaped = escapeHtml(slice);
3865
+
3866
+ if (!animationActive || !plugin) {
3867
+ const preview = buffered.length > 100 ? `…${slice}` : slice;
3868
+ if (peekTextNode.textContent !== preview) {
3869
+ peekTextNode.textContent = preview;
3870
+ }
3871
+ } else {
3872
+ let html = escaped;
3873
+ if (plugin.wrap === "char" || plugin.wrap === "word") {
3874
+ html = wrapStreamAnimation(
3875
+ escaped,
3876
+ plugin.wrap,
3877
+ // Namespace span IDs to the peek surface so they don't collide
3878
+ // with the main bubble's spans for the same message id.
3879
+ `peek-${lastAssistant.id}`,
3880
+ { skipTags: plugin.skipTags, startIndex: sliceStart }
3881
+ );
3882
+ }
3883
+
3884
+ const tempContainer = document.createElement("div");
3885
+ tempContainer.innerHTML = html;
3886
+
3887
+ if (plugin.useCaret && slice.length > 0) {
3888
+ const caret = createStreamCaret();
3889
+ const spans = tempContainer.querySelectorAll(
3890
+ ".persona-stream-char, .persona-stream-word"
3891
+ );
3892
+ const lastSpan = spans[spans.length - 1];
3893
+ if (lastSpan?.parentNode) {
3894
+ lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
3895
+ } else {
3896
+ tempContainer.appendChild(caret);
3897
+ }
3898
+ }
3899
+
3900
+ morphMessages(peekTextNode, tempContainer);
3901
+
3902
+ // Fire the plugin's per-render hook so glyph-cycle / wipe / custom
3903
+ // plugins get a chance to mutate the peek's spans the same way they
3904
+ // mutate the main bubble's. The carve-out: `bubble` here is the peek
3905
+ // banner root, not a message bubble — plugins that target
3906
+ // `bubbleClass` should no-op on that surface.
3907
+ plugin.onAfterRender?.({
3908
+ container: peekTextNode,
3909
+ bubble: peekBanner,
3910
+ messageId: lastAssistant.id,
3911
+ message: lastAssistant,
3912
+ speed: streamAnimation.speed,
3913
+ duration: streamAnimation.duration,
3914
+ });
3915
+ }
3916
+ }
3917
+
3918
+ const shouldShow = isStreaming || composerHovered;
3919
+ peekBanner.classList.toggle("persona-pill-peek--visible", shouldShow);
3920
+ };
3921
+
3922
+ if (isComposerBar()) {
3923
+ const peekBanner = panelElements.peekBanner;
3924
+ if (peekBanner) {
3925
+ // pointerdown (not click) so this competes correctly with the
3926
+ // outside-click listener (also pointerdown, capture phase). The
3927
+ // outside-click composedPath check passes for events inside `wrapper`
3928
+ // or `pillRoot` (peek's parent), so the peek can stop propagation
3929
+ // here without breaking dismissal.
3930
+ const onPeekPointerDown = (e: PointerEvent) => {
3931
+ e.preventDefault();
3932
+ e.stopPropagation();
3933
+ setOpenState(true, "user");
3934
+ };
3935
+ peekBanner.addEventListener("pointerdown", onPeekPointerDown);
3936
+ destroyCallbacks.push(() => {
3937
+ peekBanner.removeEventListener("pointerdown", onPeekPointerDown);
3938
+ });
3939
+ }
3940
+
3941
+ const onPanelPointerEnter = () => {
3942
+ if (composerHovered) return;
3943
+ composerHovered = true;
3944
+ syncComposerBarPeek();
3945
+ };
3946
+ const onPanelPointerLeave = () => {
3947
+ if (!composerHovered) return;
3948
+ composerHovered = false;
3949
+ syncComposerBarPeek();
3950
+ };
3951
+ panel.addEventListener("pointerenter", onPanelPointerEnter);
3952
+ panel.addEventListener("pointerleave", onPanelPointerLeave);
3953
+ destroyCallbacks.push(() => {
3954
+ panel.removeEventListener("pointerenter", onPanelPointerEnter);
3955
+ panel.removeEventListener("pointerleave", onPanelPointerLeave);
3956
+ });
3957
+
3958
+ // pillRoot now hosts the pill + peek as viewport-level siblings, so the
3959
+ // panel's pointerenter/leave above no longer fires when the cursor is
3960
+ // over the pill area. Mirror the handlers onto pillRoot so hovering
3961
+ // either surface still drives `composerHovered`. Both handlers are
3962
+ // idempotent against the shared flag, so cross-traffic between panel
3963
+ // and pillRoot doesn't cause spurious flips.
3964
+ if (pillRoot) {
3965
+ pillRoot.addEventListener("pointerenter", onPanelPointerEnter);
3966
+ pillRoot.addEventListener("pointerleave", onPanelPointerLeave);
3967
+ destroyCallbacks.push(() => {
3968
+ pillRoot.removeEventListener("pointerenter", onPanelPointerEnter);
3969
+ pillRoot.removeEventListener("pointerleave", onPanelPointerLeave);
3970
+ });
3971
+ }
3972
+ }
3973
+
3974
+ /**
3975
+ * Composer-bar geometry, owned in one place so collapsed → expanded (and
3976
+ * back) transitions don't leave stale inline styles from a previous state.
3977
+ * `createWrapper` no longer sets any geometry; everything flows through
3978
+ * here.
3979
+ *
3980
+ * Width is expressed as `width: <configured>; max-width: calc(100vw -
3981
+ * 32px)`. The two combine such that `width` wins on wide viewports and
3982
+ * `max-width` clamps on narrow ones — same effect as `min(...)` but
3983
+ * jsdom-compatible. `100vw` is always the viewport, so the containing-
3984
+ * block edge case (host with `transform`/`filter` causing `100%` to
3985
+ * resolve against the host instead of the viewport) is neutralized.
3986
+ */
3987
+ const applyComposerBarGeometry = (isOpen: boolean) => {
3988
+ const cb = config.launcher?.composerBar ?? {};
3989
+ const expandedSize = cb.expandedSize ?? "anchored";
3990
+ const bottomOffset = cb.bottomOffset ?? "16px";
3991
+ // No hardcoded default — when undefined, CSS media queries provide the
3992
+ // responsive width (90vw / 70vw / 50vw at <640 / <1024 / >=1024) on
3993
+ // pillRoot.
3994
+ const collapsedMaxWidth = cb.collapsedMaxWidth;
3995
+ const expandedMaxWidth = cb.expandedMaxWidth ?? "880px";
3996
+ const expandedTopOffset = cb.expandedTopOffset ?? "5vh";
3997
+ const modalMaxWidth = cb.modalMaxWidth ?? "880px";
3998
+ const modalMaxHeight = cb.modalMaxHeight ?? "min(90vh, 800px)";
3999
+ const viewportClamp = "calc(100vw - 32px)";
4000
+ // Static fallback for the pill area's height (pill + 8px gap + peek
4001
+ // slack). Anchored mode uses this to compute the wrapper's bottom edge
4002
+ // so the chat panel chrome doesn't overlap the pill below. Defer
4003
+ // ResizeObserver-based dynamic sizing until we see a real misalignment.
4004
+ const pillAreaClearance = "var(--persona-pill-area-height, 80px)";
4005
+
4006
+ // Reset everything geometry-related so each branch sets exactly what it
4007
+ // needs. Using empty strings drops the inline declaration entirely so
4008
+ // CSS rules can take over (relevant for fullscreen).
4009
+ const s = wrapper.style;
4010
+ s.left = "";
4011
+ s.right = "";
4012
+ s.top = "";
4013
+ s.bottom = "";
4014
+ s.transform = "";
4015
+ s.width = "";
4016
+ s.maxWidth = "";
4017
+ s.height = "";
4018
+ s.maxHeight = "";
4019
+
4020
+ // pillRoot owns its own geometry (bottom offset + collapsed width
4021
+ // override). Reset and re-apply per-config every call so config edits
4022
+ // (e.g. via the demo's mode-switch) propagate cleanly.
4023
+ if (pillRoot) {
4024
+ const ps = pillRoot.style;
4025
+ ps.bottom = bottomOffset;
4026
+ // CSS media queries handle responsive width when no override is set.
4027
+ ps.width = collapsedMaxWidth ?? "";
4028
+ }
4029
+
4030
+ if (!isOpen) {
4031
+ // Collapsed: wrapper has nothing visible to render — the container
4032
+ // inside is `display: none` (via CSS keyed on `[data-state="collapsed"]`)
4033
+ // and the pill lives in pillRoot. Leave wrapper geometry empty so it
4034
+ // collapses to a zero-size positioning frame at the default fixed
4035
+ // origin. The container's fade-in keyframe handles the perceptible
4036
+ // expand animation, so there's no chrome to lose during this state.
4037
+ return;
4038
+ }
4039
+
4040
+ if (expandedSize === "fullscreen") {
4041
+ // Leave inline styles cleared so the CSS rule for fullscreen takes over.
4042
+ return;
4043
+ }
4044
+
4045
+ if (expandedSize === "modal") {
4046
+ s.top = "50%";
4047
+ s.left = "50%";
4048
+ s.transform = "translate(-50%, -50%)";
4049
+ s.bottom = "auto";
4050
+ s.right = "auto";
4051
+ s.width = modalMaxWidth;
4052
+ s.maxWidth = viewportClamp;
4053
+ s.maxHeight = modalMaxHeight;
4054
+ s.height = modalMaxHeight;
4055
+ return;
4056
+ }
4057
+
4058
+ // Default: anchored — pill stays at the viewport bottom (in pillRoot);
4059
+ // wrapper's bottom edge clears the pill area so the chrome doesn't
4060
+ // overlap it.
4061
+ s.left = "50%";
4062
+ s.transform = "translateX(-50%)";
4063
+ s.bottom = `calc(${bottomOffset} + ${pillAreaClearance})`;
4064
+ s.top = expandedTopOffset;
4065
+ s.width = expandedMaxWidth;
4066
+ s.maxWidth = viewportClamp;
4067
+ };
4068
+
2847
4069
  const updateOpenState = () => {
2848
- if (!launcherEnabled) return;
4070
+ if (!isPanelToggleable()) return;
4071
+
4072
+ // Composer-bar mode morphs the wrapper between collapsed pill and
4073
+ // expanded panel via data-attrs + per-state inline geometry. The chat
4074
+ // body and header are hidden in the collapsed state so only the
4075
+ // composer footer remains visible in the pill.
4076
+ if (isComposerBar()) {
4077
+ const cb = config.launcher?.composerBar ?? {};
4078
+ const expandedSize = cb.expandedSize ?? "anchored";
4079
+ const nextState = open ? "expanded" : "collapsed";
4080
+ wrapper.dataset.state = nextState;
4081
+ wrapper.dataset.expandedSize = expandedSize;
4082
+ // pillRoot mirrors wrapper's state attributes so CSS rules keyed off
4083
+ // [data-state] / [data-expanded-size] cascade to pill + peek even
4084
+ // though they live outside the wrapper subtree.
4085
+ if (pillRoot) {
4086
+ pillRoot.dataset.state = nextState;
4087
+ pillRoot.dataset.expandedSize = expandedSize;
4088
+ }
4089
+ wrapper.style.removeProperty("display");
4090
+ wrapper.classList.remove("persona-pointer-events-none", "persona-opacity-0");
4091
+ panel.classList.remove(
4092
+ "persona-scale-95",
4093
+ "persona-opacity-0",
4094
+ "persona-scale-100",
4095
+ "persona-opacity-100"
4096
+ );
4097
+
4098
+ applyComposerBarGeometry(open);
4099
+
4100
+ // Toggle the entire container (chat chrome + body + close button) so
4101
+ // the collapsed pill only shows the footer (which lives as a SIBLING
4102
+ // of the container in the panel — see panel.appendChild(footer) above).
4103
+ // The footer is always visible / interactive.
4104
+ container.style.display = open ? "flex" : "none";
4105
+
4106
+ // Re-run chrome application now that data-state has flipped: collapsed
4107
+ // clears container chrome (pill stands alone), expanded paints it via
4108
+ // the same theme.components.panel.* contract as floating mode.
4109
+ applyFullHeightStyles();
4110
+
4111
+ // Outside-click dismiss: while expanded, clicking anywhere outside the
4112
+ // wrapper (panel chrome + pill) collapses back to just the pill.
4113
+ if (open) {
4114
+ attachComposerBarOutsideClickDismiss();
4115
+ attachComposerBarEscapeDismiss();
4116
+ } else {
4117
+ detachComposerBarOutsideClickDismiss();
4118
+ detachComposerBarEscapeDismiss();
4119
+ }
4120
+ // Peek banner is hidden when expanded (`open === true` short-circuits
4121
+ // visibility); re-sync so collapsing back re-evaluates immediately.
4122
+ syncComposerBarPeek();
4123
+ return;
4124
+ }
4125
+
2849
4126
  const dockedMode = isDockedMountMode(config);
2850
4127
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
2851
4128
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
@@ -2900,7 +4177,7 @@ export const createAgentExperience = (
2900
4177
  };
2901
4178
 
2902
4179
  const setOpenState = (nextOpen: boolean, source: "user" | "auto" | "api" | "system" = "user") => {
2903
- if (!launcherEnabled) return;
4180
+ if (!isPanelToggleable()) return;
2904
4181
  if (open === nextOpen) return;
2905
4182
 
2906
4183
  const prevOpen = open;
@@ -2915,7 +4192,13 @@ export const createAgentExperience = (
2915
4192
  const mb = config.launcher?.mobileBreakpoint ?? 640;
2916
4193
  const isMobile = ow.innerWidth <= mb;
2917
4194
  const dockedMF = isDockedMountMode(config) && mf && isMobile;
2918
- return sm || (mf && isMobile && launcherEnabled) || dockedMF;
4195
+ // Composer-bar in expanded fullscreen mode covers the viewport — lock
4196
+ // background scroll and elevate host stacking to match other
4197
+ // viewport-covering modes (mobile fullscreen, sidebar).
4198
+ const composerBarFS =
4199
+ isComposerBar() &&
4200
+ (config.launcher?.composerBar?.expandedSize ?? "fullscreen") === "fullscreen";
4201
+ return sm || (mf && isMobile && launcherEnabled) || dockedMF || composerBarFS;
2919
4202
  })();
2920
4203
 
2921
4204
  if (open && isViewportCovering) {
@@ -3108,6 +4391,10 @@ export const createAgentExperience = (
3108
4391
 
3109
4392
  voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
3110
4393
  persistState(messages);
4394
+ // Composer-bar peek: re-render the trailing-100-char preview and
4395
+ // re-evaluate visibility (a new message may make it eligible to show
4396
+ // during streaming, or update the preview text on each token).
4397
+ syncComposerBarPeek();
3111
4398
  },
3112
4399
  onStatusChanged(status) {
3113
4400
  const currentStatusConfig = config.statusIndicator ?? {};
@@ -3130,6 +4417,9 @@ export const createAgentExperience = (
3130
4417
  if (!streaming) {
3131
4418
  scheduleAutoScroll(true);
3132
4419
  }
4420
+ // Composer-bar peek: streaming state is one of the two visibility
4421
+ // triggers (the other is composer hover), so re-evaluate now.
4422
+ syncComposerBarPeek();
3133
4423
  },
3134
4424
  onVoiceStatusChanged(status: VoiceStatus) {
3135
4425
  if (config.voiceRecognition?.provider?.type !== 'runtype') return;
@@ -3165,6 +4455,7 @@ export const createAgentExperience = (
3165
4455
  onArtifactsState(state) {
3166
4456
  lastArtifactsState = state;
3167
4457
  syncArtifactPane();
4458
+ persistState();
3168
4459
  }
3169
4460
  });
3170
4461
 
@@ -3217,6 +4508,12 @@ export const createAgentExperience = (
3217
4508
  if (state.messages?.length) {
3218
4509
  session.hydrateMessages(state.messages);
3219
4510
  }
4511
+ if (state.artifacts?.length) {
4512
+ session.hydrateArtifacts(
4513
+ state.artifacts,
4514
+ state.selectedArtifactId ?? null
4515
+ );
4516
+ }
3220
4517
  })
3221
4518
  .catch((error) => {
3222
4519
  if (typeof console !== "undefined") {
@@ -3226,6 +4523,18 @@ export const createAgentExperience = (
3226
4523
  });
3227
4524
  }
3228
4525
 
4526
+ // Centralized so both the default composer (`handleSubmit`) and the plugin
4527
+ // composer (`renderComposer.onSubmit`) auto-expand the composer-bar wrapper
4528
+ // when a message is sent while the panel is collapsed. Without a single
4529
+ // helper the two submit paths drift over time.
4530
+ const maybeExpandComposerBar = () => {
4531
+ if (!isComposerBar()) return;
4532
+ if (open) return;
4533
+ const expandOnSubmit = config.launcher?.composerBar?.expandOnSubmit ?? true;
4534
+ if (!expandOnSubmit) return;
4535
+ setOpenState(true, "auto");
4536
+ };
4537
+
3229
4538
  const handleSubmit = (event: Event) => {
3230
4539
  event.preventDefault();
3231
4540
 
@@ -3243,6 +4552,8 @@ export const createAgentExperience = (
3243
4552
  // Must have text or attachments to send
3244
4553
  if (!value && !hasAttachments) return;
3245
4554
 
4555
+ maybeExpandComposerBar();
4556
+
3246
4557
  // Build content parts if there are attachments
3247
4558
  let contentParts: ContentPart[] | undefined;
3248
4559
  if (hasAttachments) {
@@ -3830,7 +5141,9 @@ export const createAgentExperience = (
3830
5141
  let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
3831
5142
  let customLauncherElement: HTMLElement | null = null;
3832
5143
 
3833
- if (launcherEnabled) {
5144
+ // Composer-bar mode is launcher-less by design: the persistent pill IS the
5145
+ // entry point, so skip creating any launcher button (default or plugin).
5146
+ if (launcherEnabled && !isComposerBar()) {
3834
5147
  const launcherPlugin = plugins.find(p => p.renderLauncher);
3835
5148
  if (launcherPlugin?.renderLauncher) {
3836
5149
  const customLauncher = launcherPlugin.renderLauncher({
@@ -3845,7 +5158,7 @@ export const createAgentExperience = (
3845
5158
  customLauncherElement = customLauncher;
3846
5159
  }
3847
5160
  }
3848
-
5161
+
3849
5162
  // Use custom launcher if provided, otherwise use default
3850
5163
  if (!customLauncherElement) {
3851
5164
  launcherButtonInstance = createLauncherButton(config, toggleOpen);
@@ -3865,7 +5178,9 @@ export const createAgentExperience = (
3865
5178
  maybeRestoreVoiceFromMetadata();
3866
5179
 
3867
5180
  if (autoFocusInput) {
3868
- if (!launcherEnabled) {
5181
+ // Composer-bar's pill exposes the textarea immediately, so focus it on
5182
+ // init like the inline embed does — even though the panel is collapsed.
5183
+ if (!launcherEnabled || isComposerBar()) {
3869
5184
  setTimeout(() => maybeFocusInput(), 0);
3870
5185
  } else if (open) {
3871
5186
  setTimeout(() => maybeFocusInput(), 200);
@@ -3873,6 +5188,16 @@ export const createAgentExperience = (
3873
5188
  }
3874
5189
 
3875
5190
  const recalcPanelHeight = () => {
5191
+ // Composer-bar mode lets CSS own all sizing — collapsed pill is auto-sized
5192
+ // by the footer; expanded fullscreen/modal are driven by CSS attribute
5193
+ // selectors plus inline maxWidth/maxHeight set in updateOpenState. JS
5194
+ // sizing here would fight the morph transitions.
5195
+ if (isComposerBar()) {
5196
+ updateScrollToBottomButtonOffset();
5197
+ updateOpenState();
5198
+ return;
5199
+ }
5200
+
3876
5201
  const dockedMode = isDockedMountMode(config);
3877
5202
  const sidebarMode = config.launcher?.sidebarMode ?? false;
3878
5203
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -4044,7 +5369,7 @@ export const createAgentExperience = (
4044
5369
  closeButton.removeEventListener("click", closeHandler);
4045
5370
  closeHandler = null;
4046
5371
  }
4047
- if (launcherEnabled) {
5372
+ if (isPanelToggleable()) {
4048
5373
  closeButton.style.display = "";
4049
5374
  closeHandler = () => {
4050
5375
  setOpenState(false, "user");
@@ -4068,6 +5393,9 @@ export const createAgentExperience = (
4068
5393
  messageCache.clear();
4069
5394
  resumeAutoScroll();
4070
5395
 
5396
+ // Drop any open ask_user_question sheets — their source messages are gone.
5397
+ removeAskUserQuestionSheet(panelElements.composerOverlay);
5398
+
4071
5399
  // Always clear the default localStorage key
4072
5400
  try {
4073
5401
  localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
@@ -4388,12 +5716,12 @@ export const createAgentExperience = (
4388
5716
  // Rebuild header with new layout
4389
5717
  const newHeaderElements = headerLayoutConfig
4390
5718
  ? buildHeaderWithLayout(config, headerLayoutConfig, {
4391
- showClose: launcherEnabled,
5719
+ showClose: isPanelToggleable(),
4392
5720
  onClose: () => setOpenState(false, "user")
4393
5721
  })
4394
5722
  : buildHeader({
4395
5723
  config,
4396
- showClose: launcherEnabled,
5724
+ showClose: isPanelToggleable(),
4397
5725
  onClose: () => setOpenState(false, "user")
4398
5726
  });
4399
5727
 
@@ -4794,9 +6122,15 @@ export const createAgentExperience = (
4794
6122
  if (clearChatButtonWrapper) {
4795
6123
  clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
4796
6124
 
4797
- // When clear chat is hidden, close button needs ml-auto to stay right-aligned
6125
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned.
6126
+ // Composer-bar mode positions the close button absolutely, so the
6127
+ // ml-auto layout shim doesn't apply and is skipped below.
4798
6128
  const { closeButtonWrapper } = panelElements;
4799
- if (closeButtonWrapper && !closeButtonWrapper.classList.contains("persona-absolute")) {
6129
+ if (
6130
+ !isComposerBar() &&
6131
+ closeButtonWrapper &&
6132
+ !closeButtonWrapper.classList.contains("persona-absolute")
6133
+ ) {
4800
6134
  if (shouldShowClearChat) {
4801
6135
  closeButtonWrapper.classList.remove("persona-ml-auto");
4802
6136
  } else {
@@ -4804,11 +6138,14 @@ export const createAgentExperience = (
4804
6138
  }
4805
6139
  }
4806
6140
 
4807
- // Update placement if changed
6141
+ // Update placement if changed. Composer-bar mode owns the clear
6142
+ // button's position via panel.ts (absolute, top-right next to ×)
6143
+ // and must not get reshuffled into the floating launcher's
6144
+ // header strip.
4808
6145
  const isTopRight = clearChatPlacement === "top-right";
4809
6146
  const currentlyTopRight = clearChatButtonWrapper.classList.contains("persona-absolute");
4810
6147
 
4811
- if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
6148
+ if (!isComposerBar() && isTopRight !== currentlyTopRight && shouldShowClearChat) {
4812
6149
  clearChatButtonWrapper.remove();
4813
6150
 
4814
6151
  if (isTopRight) {
@@ -4849,10 +6186,14 @@ export const createAgentExperience = (
4849
6186
  }
4850
6187
 
4851
6188
  if (shouldShowClearChat) {
4852
- // Update size
4853
- const clearChatSize = clearChatConfig.size ?? "32px";
4854
- clearChatButton.style.height = clearChatSize;
4855
- clearChatButton.style.width = clearChatSize;
6189
+ // Update size — composer-bar mode owns its sizing (16px to match
6190
+ // the close icon), so leave size alone there. Floating-launcher
6191
+ // and other modes still honor `launcher.clearChat.size`.
6192
+ if (!isComposerBar()) {
6193
+ const clearChatSize = clearChatConfig.size ?? "32px";
6194
+ clearChatButton.style.height = clearChatSize;
6195
+ clearChatButton.style.width = clearChatSize;
6196
+ }
4856
6197
 
4857
6198
  // Update icon
4858
6199
  const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
@@ -4861,9 +6202,11 @@ export const createAgentExperience = (
4861
6202
  clearChatButton.style.color =
4862
6203
  clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
4863
6204
 
4864
- // Clear existing icon and render new one
6205
+ // Clear existing icon and render new one. Composer-bar shrinks
6206
+ // the icon to match its 16px button.
4865
6207
  clearChatButton.innerHTML = "";
4866
- const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 2);
6208
+ const clearChatIconSize = isComposerBar() ? "14px" : "20px";
6209
+ const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 2);
4867
6210
  if (iconSvg) {
4868
6211
  clearChatButton.appendChild(iconSvg);
4869
6212
  }
@@ -5426,8 +6769,13 @@ export const createAgentExperience = (
5426
6769
  tooltip.style.display = "none";
5427
6770
  }
5428
6771
 
5429
- // Update contentMaxWidth on messages wrapper and composer
5430
- const updatedContentMaxWidth = config.layout?.contentMaxWidth;
6772
+ // Update contentMaxWidth on messages wrapper and composer. Same
6773
+ // composer-bar fallback as the initial read above.
6774
+ const updatedContentMaxWidth =
6775
+ config.layout?.contentMaxWidth ??
6776
+ (isComposerBar()
6777
+ ? config.launcher?.composerBar?.contentMaxWidth ?? "720px"
6778
+ : undefined);
5431
6779
  if (updatedContentMaxWidth) {
5432
6780
  messagesWrapper.style.maxWidth = updatedContentMaxWidth;
5433
6781
  messagesWrapper.style.marginLeft = "auto";
@@ -5486,15 +6834,15 @@ export const createAgentExperience = (
5486
6834
  statusText.classList.add(alignClass);
5487
6835
  },
5488
6836
  open() {
5489
- if (!launcherEnabled) return;
6837
+ if (!isPanelToggleable()) return;
5490
6838
  setOpenState(true, "api");
5491
6839
  },
5492
6840
  close() {
5493
- if (!launcherEnabled) return;
6841
+ if (!isPanelToggleable()) return;
5494
6842
  setOpenState(false, "api");
5495
6843
  },
5496
6844
  toggle() {
5497
- if (!launcherEnabled) return;
6845
+ if (!isPanelToggleable()) return;
5498
6846
  setOpenState(!open, "api");
5499
6847
  },
5500
6848
  clearChat() {
@@ -5561,8 +6909,8 @@ export const createAgentExperience = (
5561
6909
  if (!textarea) return false;
5562
6910
  if (session.isStreaming()) return false;
5563
6911
 
5564
- // Auto-open widget if closed and launcher is enabled
5565
- if (!open && launcherEnabled) {
6912
+ // Auto-open widget if closed and the panel is toggleable
6913
+ if (!open && isPanelToggleable()) {
5566
6914
  setOpenState(true, "system");
5567
6915
  }
5568
6916
 
@@ -5577,8 +6925,8 @@ export const createAgentExperience = (
5577
6925
  const valueToSubmit = message?.trim() || textarea.value.trim();
5578
6926
  if (!valueToSubmit) return false;
5579
6927
 
5580
- // Auto-open widget if closed and launcher is enabled
5581
- if (!open && launcherEnabled) {
6928
+ // Auto-open widget if closed and the panel is toggleable
6929
+ if (!open && isPanelToggleable()) {
5582
6930
  setOpenState(true, "system");
5583
6931
  }
5584
6932
 
@@ -5591,7 +6939,7 @@ export const createAgentExperience = (
5591
6939
  if (session.isStreaming()) return false;
5592
6940
  if (config.voiceRecognition?.provider?.type === 'runtype') {
5593
6941
  if (session.isVoiceActive()) return true;
5594
- if (!open && launcherEnabled) setOpenState(true, "system");
6942
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
5595
6943
  voiceState.manuallyDeactivated = false;
5596
6944
  persistVoiceMetadata();
5597
6945
  session.toggleVoice().then(() => {
@@ -5604,7 +6952,7 @@ export const createAgentExperience = (
5604
6952
  if (isRecording) return true;
5605
6953
  const SpeechRecognitionClass = getSpeechRecognitionClass();
5606
6954
  if (!SpeechRecognitionClass) return false;
5607
- if (!open && launcherEnabled) setOpenState(true, "system");
6955
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
5608
6956
  voiceState.manuallyDeactivated = false;
5609
6957
  persistVoiceMetadata();
5610
6958
  startVoiceRecognition("user");
@@ -5630,15 +6978,15 @@ export const createAgentExperience = (
5630
6978
  return true;
5631
6979
  },
5632
6980
  injectMessage(options: InjectMessageOptions): AgentWidgetMessage {
5633
- // Auto-open widget if closed and launcher is enabled
5634
- if (!open && launcherEnabled) {
6981
+ // Auto-open widget if closed and the panel is toggleable
6982
+ if (!open && isPanelToggleable()) {
5635
6983
  setOpenState(true, "system");
5636
6984
  }
5637
6985
  return session.injectMessage(options);
5638
6986
  },
5639
6987
  injectAssistantMessage(options: InjectAssistantMessageOptions): AgentWidgetMessage {
5640
- // Auto-open widget if closed and launcher is enabled
5641
- if (!open && launcherEnabled) {
6988
+ // Auto-open widget if closed and the panel is toggleable
6989
+ if (!open && isPanelToggleable()) {
5642
6990
  setOpenState(true, "system");
5643
6991
  }
5644
6992
  const result = session.injectAssistantMessage(options);
@@ -5663,29 +7011,29 @@ export const createAgentExperience = (
5663
7011
  return result;
5664
7012
  },
5665
7013
  injectUserMessage(options: InjectUserMessageOptions): AgentWidgetMessage {
5666
- // Auto-open widget if closed and launcher is enabled
5667
- if (!open && launcherEnabled) {
7014
+ // Auto-open widget if closed and the panel is toggleable
7015
+ if (!open && isPanelToggleable()) {
5668
7016
  setOpenState(true, "system");
5669
7017
  }
5670
7018
  return session.injectUserMessage(options);
5671
7019
  },
5672
7020
  injectSystemMessage(options: InjectSystemMessageOptions): AgentWidgetMessage {
5673
- // Auto-open widget if closed and launcher is enabled
5674
- if (!open && launcherEnabled) {
7021
+ // Auto-open widget if closed and the panel is toggleable
7022
+ if (!open && isPanelToggleable()) {
5675
7023
  setOpenState(true, "system");
5676
7024
  }
5677
7025
  return session.injectSystemMessage(options);
5678
7026
  },
5679
7027
  injectMessageBatch(optionsList: InjectMessageOptions[]): AgentWidgetMessage[] {
5680
- if (!open && launcherEnabled) {
7028
+ if (!open && isPanelToggleable()) {
5681
7029
  setOpenState(true, "system");
5682
7030
  }
5683
7031
  return session.injectMessageBatch(optionsList);
5684
7032
  },
5685
7033
  /** @deprecated Use injectMessage() instead */
5686
7034
  injectTestMessage(event: AgentWidgetEvent) {
5687
- // Auto-open widget if closed and launcher is enabled
5688
- if (!open && launcherEnabled) {
7035
+ // Auto-open widget if closed and the panel is toggleable
7036
+ if (!open && isPanelToggleable()) {
5689
7037
  setOpenState(true, "system");
5690
7038
  }
5691
7039
  session.injectTestEvent(event);
@@ -5743,8 +7091,16 @@ export const createAgentExperience = (
5743
7091
  if (!artifactsSidebarEnabled(config)) return;
5744
7092
  session.clearArtifacts();
5745
7093
  },
7094
+ getArtifacts(): PersonaArtifactRecord[] {
7095
+ return session?.getArtifacts() ?? [];
7096
+ },
7097
+ getSelectedArtifactId(): string | null {
7098
+ return session?.getSelectedArtifactId() ?? null;
7099
+ },
5746
7100
  focusInput(): boolean {
5747
- if (launcherEnabled && !open) return false;
7101
+ // Composer-bar's textarea is always reachable in the collapsed pill,
7102
+ // so don't gate focus behind `open` for that mode.
7103
+ if (launcherEnabled && !open && !isComposerBar()) return false;
5748
7104
  if (!textarea) return false;
5749
7105
  textarea.focus();
5750
7106
  return true;
@@ -5781,14 +7137,14 @@ export const createAgentExperience = (
5781
7137
  },
5782
7138
  // State query methods
5783
7139
  isOpen(): boolean {
5784
- return launcherEnabled && open;
7140
+ return isPanelToggleable() && open;
5785
7141
  },
5786
7142
  isVoiceActive(): boolean {
5787
7143
  return voiceState.active;
5788
7144
  },
5789
7145
  getState(): AgentWidgetStateSnapshot {
5790
7146
  return {
5791
- open: launcherEnabled && open,
7147
+ open: isPanelToggleable() && open,
5792
7148
  launcherEnabled,
5793
7149
  voiceActive: voiceState.active,
5794
7150
  streaming: session.isStreaming()
@@ -5796,8 +7152,8 @@ export const createAgentExperience = (
5796
7152
  },
5797
7153
  // Feedback methods (CSAT/NPS)
5798
7154
  showCSATFeedback(options?: Partial<CSATFeedbackOptions>) {
5799
- // Auto-open widget if closed and launcher is enabled
5800
- if (!open && launcherEnabled) {
7155
+ // Auto-open widget if closed and the panel is toggleable
7156
+ if (!open && isPanelToggleable()) {
5801
7157
  setOpenState(true, "system");
5802
7158
  }
5803
7159
 
@@ -5823,8 +7179,8 @@ export const createAgentExperience = (
5823
7179
  feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
5824
7180
  },
5825
7181
  showNPSFeedback(options?: Partial<NPSFeedbackOptions>) {
5826
- // Auto-open widget if closed and launcher is enabled
5827
- if (!open && launcherEnabled) {
7182
+ // Auto-open widget if closed and the panel is toggleable
7183
+ if (!open && isPanelToggleable()) {
5828
7184
  setOpenState(true, "system");
5829
7185
  }
5830
7186
 
@@ -5862,6 +7218,7 @@ export const createAgentExperience = (
5862
7218
  }
5863
7219
  destroyCallbacks.forEach((cb) => cb());
5864
7220
  wrapper.remove();
7221
+ pillRoot?.remove();
5865
7222
  launcherButtonInstance?.destroy();
5866
7223
  customLauncherElement?.remove();
5867
7224
  if (closeHandler) {
@@ -5984,7 +7341,7 @@ export const createAgentExperience = (
5984
7341
  // ============================================================================
5985
7342
  const persistConfig = normalizePersistStateConfig(config.persistState);
5986
7343
 
5987
- if (persistConfig && launcherEnabled) {
7344
+ if (persistConfig && isPanelToggleable()) {
5988
7345
  const storage = getPersistStorage(persistConfig.storage!);
5989
7346
  const openKey = `${persistConfig.keyPrefix}widget-open`;
5990
7347
  const voiceKey = `${persistConfig.keyPrefix}widget-voice`;
@@ -6063,10 +7420,16 @@ export const createAgentExperience = (
6063
7420
  // If onStateLoaded signalled open: true, open the panel after init.
6064
7421
  // Mirrors the same setTimeout(0) pattern used by persistState restore so both
6065
7422
  // can fire independently without interfering with each other.
6066
- if (shouldOpenAfterStateLoaded && launcherEnabled) {
7423
+ if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
6067
7424
  setTimeout(() => { controller.open(); }, 0);
6068
7425
  }
6069
7426
 
7427
+ // Initial sync of the composer-bar peek banner so it reflects any
7428
+ // restored history. Subsequent updates flow through `onMessagesChanged`,
7429
+ // `onStreamingChanged`, `updateOpenState`, and pointerenter/leave on
7430
+ // the panel.
7431
+ syncComposerBarPeek();
7432
+
6070
7433
  return controller;
6071
7434
  };
6072
7435