@runtypelabs/persona 3.16.0 → 3.18.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 (71) hide show
  1. package/README.md +142 -0
  2. package/dist/animations/glyph-cycle.cjs +279 -0
  3. package/dist/animations/glyph-cycle.d.cts +5 -0
  4. package/dist/animations/glyph-cycle.d.ts +5 -0
  5. package/dist/animations/glyph-cycle.js +252 -0
  6. package/dist/animations/types-cwY5HaFD.d.cts +307 -0
  7. package/dist/animations/types-cwY5HaFD.d.ts +307 -0
  8. package/dist/animations/wipe.cjs +107 -0
  9. package/dist/animations/wipe.d.cts +5 -0
  10. package/dist/animations/wipe.d.ts +5 -0
  11. package/dist/animations/wipe.js +80 -0
  12. package/dist/index.cjs +49 -48
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +504 -1
  15. package/dist/index.d.ts +504 -1
  16. package/dist/index.global.js +143 -88
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +49 -48
  19. package/dist/index.js.map +1 -1
  20. package/dist/testing.cjs +85 -0
  21. package/dist/testing.d.cts +39 -0
  22. package/dist/testing.d.ts +39 -0
  23. package/dist/testing.js +56 -0
  24. package/dist/theme-editor.cjs +2095 -207
  25. package/dist/theme-editor.d.cts +432 -2
  26. package/dist/theme-editor.d.ts +432 -2
  27. package/dist/theme-editor.js +2093 -207
  28. package/dist/theme-reference.cjs +1 -1
  29. package/dist/theme-reference.d.cts +14 -0
  30. package/dist/theme-reference.d.ts +14 -0
  31. package/dist/widget.css +565 -0
  32. package/package.json +20 -3
  33. package/src/animations/glyph-cycle.ts +332 -0
  34. package/src/animations/wipe.ts +66 -0
  35. package/src/client.test.ts +275 -0
  36. package/src/client.ts +99 -0
  37. package/src/components/ask-user-question-bubble.test.ts +583 -0
  38. package/src/components/ask-user-question-bubble.ts +924 -0
  39. package/src/components/composer-builder.ts +61 -10
  40. package/src/components/message-bubble.test.ts +181 -2
  41. package/src/components/message-bubble.ts +209 -14
  42. package/src/components/messages.ts +33 -1
  43. package/src/components/panel.ts +45 -5
  44. package/src/defaults.ts +37 -0
  45. package/src/index-global.ts +31 -0
  46. package/src/index.ts +34 -1
  47. package/src/plugins/types.ts +57 -0
  48. package/src/session.test.ts +276 -1
  49. package/src/session.ts +247 -3
  50. package/src/styles/widget.css +565 -0
  51. package/src/testing/index.ts +11 -0
  52. package/src/testing/mock-stream.test.ts +80 -0
  53. package/src/testing/mock-stream.ts +94 -0
  54. package/src/testing.ts +2 -0
  55. package/src/theme-editor/index.ts +4 -0
  56. package/src/theme-editor/preview-utils.test.ts +60 -0
  57. package/src/theme-editor/preview-utils.ts +129 -0
  58. package/src/theme-editor/sections.test.ts +19 -0
  59. package/src/theme-editor/sections.ts +84 -1
  60. package/src/types/theme.ts +15 -0
  61. package/src/types.ts +360 -0
  62. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  63. package/src/ui.stop-button.test.ts +165 -0
  64. package/src/ui.ts +706 -11
  65. package/src/utils/message-fingerprint.ts +2 -0
  66. package/src/utils/morph.ts +7 -0
  67. package/src/utils/storage.ts +10 -2
  68. package/src/utils/stream-animation.test.ts +417 -0
  69. package/src/utils/stream-animation.ts +449 -0
  70. package/src/utils/theme.test.ts +36 -0
  71. package/src/utils/tokens.ts +23 -0
package/src/ui.ts CHANGED
@@ -41,6 +41,11 @@ import {
41
41
  resolveFollowStateFromWheel
42
42
  } from "./utils/auto-follow";
43
43
  import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
+ import {
45
+ detachAllPlugins,
46
+ ensurePluginActive,
47
+ resolveStreamAnimationPlugin,
48
+ } from "./utils/stream-animation";
44
49
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
45
50
  import { acquireScrollLock } from "./utils/scroll-lock";
46
51
  import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
@@ -54,6 +59,20 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
54
59
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
55
60
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
56
61
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
62
+ import {
63
+ buildStructuredAnswers,
64
+ ensureAskUserQuestionSheet,
65
+ getCurrentIndex,
66
+ getQuestionCount,
67
+ getSelectedLabels,
68
+ isAskUserQuestionMessage,
69
+ isGroupedSheet,
70
+ navigateToPage,
71
+ parseAskUserQuestionPayload,
72
+ readAnswersFromSheet,
73
+ removeAskUserQuestionSheet,
74
+ setCurrentAnswer,
75
+ } from "./components/ask-user-question-bubble";
57
76
  import { formatElapsedMs } from "./utils/formatting";
58
77
  import { createApprovalBubble } from "./components/approval-bubble";
59
78
  import { createSuggestions } from "./components/suggestions";
@@ -323,6 +342,10 @@ type Controller = {
323
342
  upsertArtifact: (manual: PersonaArtifactManualUpsert) => PersonaArtifactRecord | null;
324
343
  selectArtifact: (id: string) => void;
325
344
  clearArtifacts: () => void;
345
+ /** Read current artifacts (useful on init to rebuild host-side tab state after hydration). */
346
+ getArtifacts: () => PersonaArtifactRecord[];
347
+ /** Read the currently selected artifact id (paired with `getArtifacts`). */
348
+ getSelectedArtifactId: () => string | null;
326
349
  /**
327
350
  * Focus the chat input. Returns true if focus succeeded, false if panel is closed
328
351
  * (launcher mode) or textarea is unavailable.
@@ -512,6 +535,13 @@ export const createAgentExperience = (
512
535
  if (processedState.messages?.length) {
513
536
  config = { ...config, initialMessages: processedState.messages };
514
537
  }
538
+ if (processedState.artifacts?.length) {
539
+ config = {
540
+ ...config,
541
+ initialArtifacts: processedState.artifacts,
542
+ initialSelectedArtifactId: processedState.selectedArtifactId ?? null
543
+ };
544
+ }
515
545
  }
516
546
  } catch (error) {
517
547
  if (typeof console !== "undefined") {
@@ -701,6 +731,7 @@ export const createAgentExperience = (
701
731
  leftActions,
702
732
  rightActions
703
733
  } = panelElements;
734
+ let setSendButtonMode = panelElements.setSendButtonMode;
704
735
 
705
736
  // Use mutable references for mic button so we can update them dynamically
706
737
  let micButton: HTMLButtonElement | null = panelElements.micButton;
@@ -1402,6 +1433,385 @@ export const createAgentExperience = (
1402
1433
  target.click();
1403
1434
  });
1404
1435
 
1436
+ // --- ask_user_question sheet interaction ---
1437
+ // Event delegation for the answer-pill sheet that mounts in the composer
1438
+ // overlay. Handles pill pick (single), multi-select toggle + submit, free-
1439
+ // text pill expansion + submit, and dismissal. Selection becomes a regular
1440
+ // user message via session.sendMessage so the agent resumes on the next turn.
1441
+ const askUserOverlay = panelElements.composerOverlay;
1442
+
1443
+ const submitAskUserAnswer = (
1444
+ sheet: HTMLElement,
1445
+ text: string,
1446
+ meta: {
1447
+ source: "pick" | "multi" | "free-text" | "submit-all";
1448
+ values?: string[];
1449
+ structured?: Record<string, string | string[]>;
1450
+ }
1451
+ ): void => {
1452
+ const trimmed = text.trim();
1453
+ if (!trimmed || !sessionRef.current) return;
1454
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1455
+ const isFreeText = meta.source === "free-text";
1456
+
1457
+ // Dispatch before removing the sheet so listeners can still query DOM state.
1458
+ mount.dispatchEvent(
1459
+ new CustomEvent("persona:askUserQuestion:answered", {
1460
+ detail: {
1461
+ toolUseId: toolCallId,
1462
+ answer: trimmed,
1463
+ answers: meta.structured,
1464
+ values: meta.values ?? (meta.source === "multi" ? trimmed.split(", ") : [trimmed]),
1465
+ isFreeText,
1466
+ source: meta.source,
1467
+ },
1468
+ bubbles: true,
1469
+ composed: true,
1470
+ })
1471
+ );
1472
+
1473
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1474
+
1475
+ // Branch: LOCAL-tool pause (step_await) resumes via /resume with structured
1476
+ // toolOutputs; legacy path sends as a plain user message.
1477
+ const sourceMessage = sessionRef.current
1478
+ .getMessages()
1479
+ .find((m) => m.toolCall?.id === toolCallId);
1480
+ if (sourceMessage?.agentMetadata?.awaitingLocalTool) {
1481
+ sessionRef.current.resolveAskUserQuestion(sourceMessage, meta.structured ?? trimmed);
1482
+ } else {
1483
+ sessionRef.current.sendMessage(trimmed);
1484
+ }
1485
+ };
1486
+
1487
+ /**
1488
+ * Persist in-progress grouped-question answers + page index back to the
1489
+ * source message so a refresh restores the user's spot.
1490
+ */
1491
+ const persistGroupedProgress = (sheet: HTMLElement): void => {
1492
+ const session = sessionRef.current;
1493
+ if (!session) return;
1494
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1495
+ const sourceMessage = session.getMessages().find((m) => m.toolCall?.id === toolCallId);
1496
+ if (!sourceMessage) return;
1497
+ session.persistAskUserQuestionProgress(sourceMessage, {
1498
+ answers: buildStructuredAnswers(sheet, sourceMessage),
1499
+ currentIndex: getCurrentIndex(sheet),
1500
+ });
1501
+ };
1502
+
1503
+ /**
1504
+ * Build a one-line summary string for the legacy `answer` field on the
1505
+ * answered event when submit-all fires from a grouped sheet.
1506
+ */
1507
+ const stringifyStructured = (answers: Record<string, string | string[]>): string => {
1508
+ return Object.entries(answers)
1509
+ .map(([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`)
1510
+ .join(" | ");
1511
+ };
1512
+
1513
+ /**
1514
+ * If `groupedAutoAdvance` is enabled (default) and we're not on the final
1515
+ * page, advance one step. The final page never auto-submits — users always
1516
+ * confirm with an explicit Submit-all click so they can review.
1517
+ */
1518
+ const maybeAutoAdvance = (sheet: HTMLElement): void => {
1519
+ if (config.features?.askUserQuestion?.groupedAutoAdvance === false) return;
1520
+ const idx = getCurrentIndex(sheet);
1521
+ const count = getQuestionCount(sheet);
1522
+ if (idx >= count - 1) return;
1523
+ const sourceMessage = sessionRef.current
1524
+ ?.getMessages()
1525
+ .find((m) => m.toolCall?.id === sheet.getAttribute("data-tool-call-id"));
1526
+ if (!sourceMessage) return;
1527
+ navigateToPage(sheet, sourceMessage, config, idx + 1);
1528
+ persistGroupedProgress(sheet);
1529
+ };
1530
+
1531
+ askUserOverlay.addEventListener("click", (event) => {
1532
+ const target = event.target as HTMLElement;
1533
+ const trigger = target.closest<HTMLElement>("[data-ask-user-action]");
1534
+ if (!trigger) return;
1535
+ const sheet = trigger.closest<HTMLElement>("[data-persona-ask-sheet-for]");
1536
+ if (!sheet) return;
1537
+
1538
+ const action = trigger.getAttribute("data-ask-user-action");
1539
+ event.preventDefault();
1540
+ event.stopPropagation();
1541
+
1542
+ if (action === "dismiss") {
1543
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1544
+ mount.dispatchEvent(
1545
+ new CustomEvent("persona:askUserQuestion:dismissed", {
1546
+ detail: { toolUseId: toolCallId },
1547
+ bubbles: true,
1548
+ composed: true,
1549
+ })
1550
+ );
1551
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1552
+
1553
+ // Best-effort: if this sheet corresponds to a LOCAL-awaiting tool,
1554
+ // unblock the paused execution with a sentinel answer so the server
1555
+ // doesn't sit in waiting_for_local forever. Fire-and-forget — errors
1556
+ // are surfaced to the onError callback. Flip the answered flag first
1557
+ // so a racing render pass doesn't re-mount the sheet mid-dismissal.
1558
+ const sourceMessage = sessionRef.current
1559
+ ?.getMessages()
1560
+ .find((m) => m.toolCall?.id === toolCallId);
1561
+ if (sourceMessage?.agentMetadata?.awaitingLocalTool) {
1562
+ sessionRef.current?.markAskUserQuestionResolved(sourceMessage);
1563
+ sessionRef.current?.resolveAskUserQuestion(sourceMessage, "(dismissed)");
1564
+ }
1565
+ return;
1566
+ }
1567
+
1568
+ if (action === "pick") {
1569
+ const label = trigger.getAttribute("data-option-label");
1570
+ if (!label) return;
1571
+ const multiSelect = sheet.getAttribute("data-multi-select") === "true";
1572
+ const grouped = isGroupedSheet(sheet);
1573
+
1574
+ if (grouped && multiSelect) {
1575
+ const stored = readAnswersFromSheet(sheet)[getCurrentIndex(sheet)];
1576
+ const set = new Set<string>(Array.isArray(stored) ? stored : []);
1577
+ if (set.has(label)) set.delete(label);
1578
+ else set.add(label);
1579
+ setCurrentAnswer(sheet, Array.from(set));
1580
+ persistGroupedProgress(sheet);
1581
+ return;
1582
+ }
1583
+
1584
+ if (grouped) {
1585
+ setCurrentAnswer(sheet, label);
1586
+ persistGroupedProgress(sheet);
1587
+ maybeAutoAdvance(sheet);
1588
+ return;
1589
+ }
1590
+
1591
+ // 1-question modes — preserve original UX.
1592
+ if (multiSelect) {
1593
+ const pressed = trigger.getAttribute("aria-pressed") === "true";
1594
+ trigger.setAttribute("aria-pressed", pressed ? "false" : "true");
1595
+ trigger.classList.toggle("persona-ask-pill-selected", !pressed);
1596
+ const submitBtn = sheet.querySelector<HTMLButtonElement>(
1597
+ '[data-ask-user-action="submit-multi"]'
1598
+ );
1599
+ if (submitBtn) {
1600
+ submitBtn.disabled = getSelectedLabels(sheet).length === 0;
1601
+ }
1602
+ return;
1603
+ }
1604
+ submitAskUserAnswer(sheet, label, { source: "pick", values: [label] });
1605
+ return;
1606
+ }
1607
+
1608
+ if (action === "submit-multi") {
1609
+ const labels = getSelectedLabels(sheet);
1610
+ if (labels.length === 0) return;
1611
+ submitAskUserAnswer(sheet, labels.join(", "), {
1612
+ source: "multi",
1613
+ values: labels,
1614
+ });
1615
+ return;
1616
+ }
1617
+
1618
+ if (action === "open-free-text") {
1619
+ const row = sheet.querySelector<HTMLElement>('[data-ask-free-text-row="true"]');
1620
+ if (row) {
1621
+ row.classList.remove("persona-hidden");
1622
+ const input = row.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1623
+ input?.focus();
1624
+ }
1625
+ return;
1626
+ }
1627
+
1628
+ if (action === "focus-free-text") {
1629
+ // Rows-layout Other row: input lives inside the row container itself.
1630
+ // Native click on the input already focuses it; this branch handles
1631
+ // clicks on the badge or row chrome AND digit-shortcut activations.
1632
+ const input = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1633
+ input?.focus();
1634
+ return;
1635
+ }
1636
+
1637
+ if (action === "submit-free-text") {
1638
+ const input = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1639
+ const text = input?.value ?? "";
1640
+ if (!text.trim()) return;
1641
+ if (isGroupedSheet(sheet)) {
1642
+ setCurrentAnswer(sheet, text.trim());
1643
+ persistGroupedProgress(sheet);
1644
+ maybeAutoAdvance(sheet);
1645
+ return;
1646
+ }
1647
+ submitAskUserAnswer(sheet, text, { source: "free-text" });
1648
+ return;
1649
+ }
1650
+
1651
+ if (action === "next" || action === "back") {
1652
+ if (!sessionRef.current) return;
1653
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1654
+ const sourceMessage = sessionRef.current
1655
+ .getMessages()
1656
+ .find((m) => m.toolCall?.id === toolCallId);
1657
+ if (!sourceMessage) return;
1658
+ // Flush any unsubmitted free-text input as the current answer.
1659
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1660
+ const pending = freeInput?.value?.trim() ?? "";
1661
+ if (pending) {
1662
+ const stored = readAnswersFromSheet(sheet)[getCurrentIndex(sheet)];
1663
+ if (typeof stored !== "string" || stored !== pending) {
1664
+ setCurrentAnswer(sheet, pending);
1665
+ }
1666
+ }
1667
+ const direction = action === "next" ? 1 : -1;
1668
+ const nextIdx = getCurrentIndex(sheet) + direction;
1669
+ navigateToPage(sheet, sourceMessage, config, nextIdx);
1670
+ persistGroupedProgress(sheet);
1671
+ return;
1672
+ }
1673
+
1674
+ if (action === "submit-all") {
1675
+ if (!sessionRef.current) return;
1676
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1677
+ const sourceMessage = sessionRef.current
1678
+ .getMessages()
1679
+ .find((m) => m.toolCall?.id === toolCallId);
1680
+ if (!sourceMessage) return;
1681
+ // Flush any pending free-text on the final page first.
1682
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1683
+ const pending = freeInput?.value?.trim() ?? "";
1684
+ if (pending) setCurrentAnswer(sheet, pending);
1685
+
1686
+ const structured = buildStructuredAnswers(sheet, sourceMessage);
1687
+ // Persist final answers to message metadata BEFORE resolving so the
1688
+ // answered-state review card (which reads `agentMetadata
1689
+ // .askUserQuestionAnswers`) shows the user's actual picks instead of
1690
+ // "(skipped)" placeholders. Without this, any answer set only via the
1691
+ // pending-flush above (or via paths that bypassed the per-pick persist
1692
+ // hook) would be missing from the transcript review even though it
1693
+ // landed in the structured payload sent to the agent.
1694
+ sessionRef.current.persistAskUserQuestionProgress(sourceMessage, {
1695
+ answers: structured,
1696
+ currentIndex: getCurrentIndex(sheet),
1697
+ });
1698
+ const summary = stringifyStructured(structured);
1699
+ submitAskUserAnswer(sheet, summary || "(submitted)", {
1700
+ source: "submit-all",
1701
+ structured,
1702
+ });
1703
+ return;
1704
+ }
1705
+
1706
+ if (action === "skip") {
1707
+ if (!sessionRef.current) return;
1708
+ const toolCallId = sheet.getAttribute("data-tool-call-id") ?? "";
1709
+ const sourceMessage = sessionRef.current
1710
+ .getMessages()
1711
+ .find((m) => m.toolCall?.id === toolCallId);
1712
+ if (!sourceMessage) return;
1713
+
1714
+ const grouped = isGroupedSheet(sheet);
1715
+ const idx = getCurrentIndex(sheet);
1716
+ const count = getQuestionCount(sheet);
1717
+ const isFinal = idx >= count - 1;
1718
+
1719
+ // Single-question payloads behave like dismiss.
1720
+ if (!grouped) {
1721
+ mount.dispatchEvent(
1722
+ new CustomEvent("persona:askUserQuestion:dismissed", {
1723
+ detail: { toolUseId: toolCallId },
1724
+ bubbles: true,
1725
+ composed: true,
1726
+ })
1727
+ );
1728
+ removeAskUserQuestionSheet(askUserOverlay, toolCallId);
1729
+ if (sourceMessage.agentMetadata?.awaitingLocalTool) {
1730
+ sessionRef.current.markAskUserQuestionResolved(sourceMessage);
1731
+ sessionRef.current.resolveAskUserQuestion(sourceMessage, "(dismissed)");
1732
+ }
1733
+ return;
1734
+ }
1735
+
1736
+ // Drop the current question's answer (if any) so it's absent from the
1737
+ // resolved Record. setCurrentAnswer with an empty string deletes the
1738
+ // index from the in-memory map.
1739
+ setCurrentAnswer(sheet, "");
1740
+ // Also clear any unsubmitted free-text on this page.
1741
+ const freeInput = sheet.querySelector<HTMLInputElement>('[data-ask-free-text-input="true"]');
1742
+ if (freeInput) freeInput.value = "";
1743
+
1744
+ if (isFinal) {
1745
+ // Submit with whatever has been recorded so far.
1746
+ const structured = buildStructuredAnswers(sheet, sourceMessage);
1747
+ const summary = stringifyStructured(structured);
1748
+ submitAskUserAnswer(sheet, summary || "(skipped)", {
1749
+ source: "submit-all",
1750
+ structured,
1751
+ });
1752
+ return;
1753
+ }
1754
+
1755
+ // Intermediate page: advance one step without recording.
1756
+ navigateToPage(sheet, sourceMessage, config, idx + 1);
1757
+ persistGroupedProgress(sheet);
1758
+ return;
1759
+ }
1760
+ });
1761
+
1762
+ // Enter on the free-text input → submit. Stays on the overlay because the
1763
+ // event target IS the input, which lives inside the overlay subtree.
1764
+ askUserOverlay.addEventListener("keydown", (event) => {
1765
+ if (event.key !== "Enter") return;
1766
+ const target = event.target as HTMLElement;
1767
+ const input = target as HTMLInputElement;
1768
+ if (!input.matches?.('[data-ask-free-text-input="true"]')) return;
1769
+ const sheet = input.closest<HTMLElement>("[data-persona-ask-sheet-for]");
1770
+ if (!sheet) return;
1771
+ event.preventDefault();
1772
+ const text = input.value;
1773
+ if (!text.trim()) return;
1774
+ if (isGroupedSheet(sheet)) {
1775
+ setCurrentAnswer(sheet, text.trim());
1776
+ persistGroupedProgress(sheet);
1777
+ maybeAutoAdvance(sheet);
1778
+ return;
1779
+ }
1780
+ submitAskUserAnswer(sheet, text, { source: "free-text" });
1781
+ });
1782
+
1783
+ // Digit 1–9 → pick option N on the current rows-layout single-select page.
1784
+ // Listens on `document` so the shortcut fires regardless of where focus
1785
+ // currently sits (host page body, panel chrome, anywhere). The handler
1786
+ // gates strictly: only fires when an active sheet is mounted in our
1787
+ // overlay, and bails when focus is on any input/textarea/contenteditable
1788
+ // (covers the free-text input, the chat composer, and any host-page input).
1789
+ const handleAskUserDigitKey = (event: KeyboardEvent): void => {
1790
+ if (!/^[1-9]$/.test(event.key)) return;
1791
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
1792
+ const target = event.target as HTMLElement | null;
1793
+ if (
1794
+ target?.tagName === "INPUT" ||
1795
+ target?.tagName === "TEXTAREA" ||
1796
+ target?.isContentEditable
1797
+ ) {
1798
+ return;
1799
+ }
1800
+ const sheet = askUserOverlay.querySelector<HTMLElement>("[data-persona-ask-sheet-for]");
1801
+ if (!sheet) return;
1802
+ if (sheet.getAttribute("data-ask-layout") !== "rows") return;
1803
+ if (sheet.getAttribute("data-multi-select") === "true") return;
1804
+ const n = Number(event.key);
1805
+ const pills = sheet.querySelectorAll<HTMLElement>(
1806
+ '[data-ask-pill-list="true"] [data-ask-user-action="pick"], [data-ask-pill-list="true"] [data-ask-user-action="focus-free-text"]'
1807
+ );
1808
+ const target_pill = pills[n - 1];
1809
+ if (!target_pill) return;
1810
+ event.preventDefault();
1811
+ target_pill.click();
1812
+ };
1813
+ document.addEventListener("keydown", handleAskUserDigitKey);
1814
+
1405
1815
  let artifactSplitRoot: HTMLElement | null = null;
1406
1816
  let artifactResizeHandle: HTMLElement | null = null;
1407
1817
  let artifactResizeUnbind: (() => void) | null = null;
@@ -1671,6 +2081,16 @@ export const createAgentExperience = (
1671
2081
  const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
1672
2082
  const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
1673
2083
 
2084
+ // Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
2085
+ // `min-height: 0` / `overflow-y: auto` that make the messages area a
2086
+ // scroll container. Between the reset and the mode-specific reapply,
2087
+ // the body's clientHeight == scrollHeight momentarily, so the browser
2088
+ // clamps scrollTop to 0 — and a synchronous restore at the end of this
2089
+ // function runs before layout has reflowed, so the write is also
2090
+ // clamped. Defer the restore to the next frame, once the reapplied
2091
+ // styles have produced a scrollable container again.
2092
+ const prevBodyScrollTop = body.scrollTop;
2093
+
1674
2094
  // Reset all inline styles first to handle mode toggling
1675
2095
  // This ensures styles don't persist when switching between modes
1676
2096
  mount.style.cssText = '';
@@ -1679,6 +2099,18 @@ export const createAgentExperience = (
1679
2099
  container.style.cssText = '';
1680
2100
  body.style.cssText = '';
1681
2101
  footer.style.cssText = '';
2102
+
2103
+ const restoreBodyScrollTop = (): void => {
2104
+ if (prevBodyScrollTop <= 0) return;
2105
+ const ownerWindow = body.ownerDocument.defaultView ?? window;
2106
+ ownerWindow.requestAnimationFrame(() => {
2107
+ if (body.scrollTop === prevBodyScrollTop) return;
2108
+ // If scrollHeight collapsed (content actually shrank), don't fight it
2109
+ const maxScrollTop = body.scrollHeight - body.clientHeight;
2110
+ if (maxScrollTop <= 0) return;
2111
+ body.scrollTop = Math.min(prevBodyScrollTop, maxScrollTop);
2112
+ });
2113
+ };
1682
2114
 
1683
2115
  // Mobile fullscreen: fill entire viewport with no radius/shadow/margins
1684
2116
  if (shouldGoFullscreen) {
@@ -1742,6 +2174,7 @@ export const createAgentExperience = (
1742
2174
  footer.style.flexShrink = '0';
1743
2175
 
1744
2176
  wasMobileFullscreen = true;
2177
+ restoreBodyScrollTop();
1745
2178
  return; // Skip remaining mode logic
1746
2179
  }
1747
2180
 
@@ -1926,6 +2359,8 @@ export const createAgentExperience = (
1926
2359
  : '';
1927
2360
  wrapper.style.cssText += maxHeightStyles + paddingStyles + zIndexStyles;
1928
2361
  }
2362
+
2363
+ restoreBodyScrollTop();
1929
2364
  };
1930
2365
  applyFullHeightStyles();
1931
2366
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
@@ -1934,6 +2369,10 @@ export const createAgentExperience = (
1934
2369
  applyArtifactPaneAppearance(mount, config);
1935
2370
 
1936
2371
  const destroyCallbacks: Array<() => void> = [];
2372
+ // Clean up the document-level digit-key shortcut listener registered earlier.
2373
+ destroyCallbacks.push(() => {
2374
+ document.removeEventListener("keydown", handleAskUserDigitKey);
2375
+ });
1937
2376
 
1938
2377
  let teardownHostStacking: (() => void) | null = null;
1939
2378
  let releaseScrollLock: (() => void) | null = null;
@@ -2003,11 +2442,32 @@ export const createAgentExperience = (
2003
2442
  }
2004
2443
  });
2005
2444
 
2445
+ // Activate the stream-animation plugin for this widget instance. Plugins
2446
+ // with `styles` inject their CSS into the widget root once; plugins with
2447
+ // `onAttach` (e.g., glyph-cycle's MutationObserver for real glyph tick
2448
+ // loops) can register long-lived DOM listeners here. Detach callbacks are
2449
+ // deferred to widget destroy.
2450
+ const streamAnimationConfig = config.features?.streamAnimation;
2451
+ if (streamAnimationConfig?.type && streamAnimationConfig.type !== "none") {
2452
+ const plugin = resolveStreamAnimationPlugin(
2453
+ streamAnimationConfig.type,
2454
+ streamAnimationConfig.plugins
2455
+ );
2456
+ if (plugin) {
2457
+ ensurePluginActive(plugin, mount);
2458
+ destroyCallbacks.push(() => detachAllPlugins(mount));
2459
+ }
2460
+ }
2461
+
2006
2462
  const suggestionsManager = createSuggestions(suggestions);
2007
2463
  let closeHandler: (() => void) | null = null;
2008
2464
  let session: AgentWidgetSession;
2009
2465
  let isStreaming = false;
2010
2466
  const messageCache = createMessageCache();
2467
+ // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
2468
+ // bubble for, per message id. Lets us skip unnecessary rebuilds across
2469
+ // re-renders so user state inside the plugin (typed text, focus) survives.
2470
+ const lastAskBubbleFingerprint = new Map<string, string>();
2011
2471
  let configVersion = 0;
2012
2472
  const autoFollow = createFollowStateController();
2013
2473
  let lastScrollTop = 0;
@@ -2091,7 +2551,9 @@ export const createAgentExperience = (
2091
2551
 
2092
2552
  const payload = {
2093
2553
  messages,
2094
- metadata: persistentMetadata
2554
+ metadata: persistentMetadata,
2555
+ artifacts: lastArtifactsState.artifacts,
2556
+ selectedArtifactId: lastArtifactsState.selectedId
2095
2557
  };
2096
2558
  try {
2097
2559
  const result = storageAdapter.save(payload);
@@ -2350,15 +2812,64 @@ export const createAgentExperience = (
2350
2812
 
2351
2813
  // Track active message IDs for cache pruning
2352
2814
  const activeMessageIds = new Set<string>();
2815
+ // Track ask_user_question tool-call ids whose bubbles were rendered this
2816
+ // pass — used to prune stale sheets from the composer overlay afterward.
2817
+ const liveAskToolIds = new Set<string>();
2818
+
2819
+ // Plugins that render `ask_user_question` typically attach DOM listeners
2820
+ // directly to their buttons. The wrapper cache uses `cloneNode(true)` and
2821
+ // idiomorph inserts new nodes via `document.importNode` — both strip
2822
+ // listeners. For plugin-handled ask messages we therefore append an empty
2823
+ // stub during the morph pass and hydrate the live plugin bubble into the
2824
+ // morphed wrapper afterward (see post-morph loop below). The stub carries
2825
+ // `data-preserve-runtime` so subsequent passes leave the live wrapper
2826
+ // (with its listener-bearing bubble) untouched.
2827
+ const hasAskPlugin = plugins.some((p) => p.renderAskUserQuestion);
2828
+ type AskPluginHydrate = {
2829
+ messageId: string;
2830
+ fingerprint: string;
2831
+ bubble: HTMLElement | null;
2832
+ };
2833
+ const askPluginHydrate: AskPluginHydrate[] = [];
2353
2834
 
2354
2835
  messages.forEach((message) => {
2355
2836
  activeMessageIds.add(message.id);
2356
2837
 
2357
- // Fingerprint cache: skip re-rendering unchanged messages
2358
- const fingerprint = computeMessageFingerprint(message, configVersion);
2359
- const cachedWrapper = getCachedWrapper(messageCache, message.id, fingerprint);
2838
+ const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
2839
+
2840
+ // Fingerprint cache: skip re-rendering unchanged messages. Append the
2841
+ // ask-user-question answered/answers state so flipping `askUserQuestionAnswered`
2842
+ // (or accumulating answers) busts both the wrapper cache and the plugin's
2843
+ // `lastAskBubbleFingerprint` check, forcing a re-render of the review UX.
2844
+ const askMeta = isAskUserQuestionMessage(message)
2845
+ ? `:${message.agentMetadata?.askUserQuestionAnswered ? "a" : "u"}:${
2846
+ message.agentMetadata?.askUserQuestionAnswers
2847
+ ? Object.keys(message.agentMetadata.askUserQuestionAnswers).length
2848
+ : 0
2849
+ }`
2850
+ : "";
2851
+ const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
2852
+ const cachedWrapper = askWithPlugin
2853
+ ? null
2854
+ : getCachedWrapper(messageCache, message.id, fingerprint);
2360
2855
  if (cachedWrapper) {
2361
2856
  tempContainer.appendChild(cachedWrapper.cloneNode(true));
2857
+ // Keep the overlay sheet alive only while the server is actively
2858
+ // waiting on the user (awaitingLocalTool === true). Before step_await
2859
+ // fires, or after the answer resumes the flow, omit from
2860
+ // liveAskToolIds so the prune loop below removes any stale DOM sheet.
2861
+ // Guards against lingering skeleton sheets from tool_start events
2862
+ // that never get a matching step_await (e.g. LLM-hallucinated trailing
2863
+ // ask_user_question calls at end-of-turn).
2864
+ if (
2865
+ isAskUserQuestionMessage(message) &&
2866
+ message.toolCall?.id &&
2867
+ message.agentMetadata?.awaitingLocalTool === true &&
2868
+ !message.agentMetadata?.askUserQuestionAnswered
2869
+ ) {
2870
+ liveAskToolIds.add(message.toolCall.id);
2871
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
2872
+ }
2362
2873
  return;
2363
2874
  }
2364
2875
 
@@ -2384,7 +2895,111 @@ export const createAgentExperience = (
2384
2895
  // Get message layout config
2385
2896
  const messageLayoutConfig = config.layout?.messages;
2386
2897
 
2387
- if (matchingPlugin) {
2898
+ // ask_user_question has two rendering modes while waiting for an answer:
2899
+ // 1. Plugin `renderAskUserQuestion` — returns an inline transcript
2900
+ // element with its own UI; the composer-overlay sheet is suppressed.
2901
+ // 2. Built-in composer-overlay answer-pill sheet — no transcript stub.
2902
+ // Plugins win when they return a non-null element; otherwise fall
2903
+ // through to the built-in overlay.
2904
+ //
2905
+ // Once answered, the original tool message is suppressed entirely from
2906
+ // the transcript. `session.resolveAskUserQuestion` injects one assistant
2907
+ // bubble per question and one user bubble per answer (skipped questions
2908
+ // become an italic `*Skipped*` user bubble), so the transcript reads
2909
+ // like a normal Q→A conversation. Plugins do not render the answered
2910
+ // state.
2911
+ if (
2912
+ isAskUserQuestionMessage(message) &&
2913
+ message.agentMetadata?.askUserQuestionAnswered === true
2914
+ ) {
2915
+ // Drop any previously-mounted plugin bubble so the morph pass
2916
+ // removes the now-stale interactive sheet.
2917
+ lastAskBubbleFingerprint.delete(message.id);
2918
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
2919
+ existing?.removeAttribute("data-preserve-runtime");
2920
+ return;
2921
+ }
2922
+
2923
+ if (
2924
+ isAskUserQuestionMessage(message) &&
2925
+ config.features?.askUserQuestion?.enabled !== false
2926
+ ) {
2927
+ const askPlugin = plugins.find((p) => typeof p.renderAskUserQuestion === "function");
2928
+ if (askPlugin && sessionRef.current) {
2929
+ const lastFp = lastAskBubbleFingerprint.get(message.id);
2930
+ // Whether to actually call the plugin renderer this pass. We do it
2931
+ // on first sight of this message, or when its fingerprint changed
2932
+ // (e.g. payload streamed in more options). Otherwise we rely on the
2933
+ // already-mounted bubble in `container`.
2934
+ const needsRebuild = lastFp !== fingerprint;
2935
+
2936
+ let pluginBubble: HTMLElement | null = null;
2937
+ if (needsRebuild) {
2938
+ const { payload, complete } = parseAskUserQuestionPayload(message);
2939
+ const messageId = message.id;
2940
+ const liveMessage = (): AgentWidgetMessage | undefined =>
2941
+ sessionRef.current?.getMessages().find((m) => m.id === messageId);
2942
+ pluginBubble = askPlugin.renderAskUserQuestion!({
2943
+ message,
2944
+ payload,
2945
+ complete,
2946
+ resolve: (answer) => {
2947
+ const live = liveMessage();
2948
+ if (live) sessionRef.current?.resolveAskUserQuestion(live, answer);
2949
+ },
2950
+ dismiss: () => {
2951
+ const live = liveMessage();
2952
+ if (live?.agentMetadata?.awaitingLocalTool) {
2953
+ sessionRef.current?.markAskUserQuestionResolved(live);
2954
+ sessionRef.current?.resolveAskUserQuestion(live, "(dismissed)");
2955
+ }
2956
+ },
2957
+ config,
2958
+ });
2959
+ }
2960
+
2961
+ // If the plugin opted out (returned null on a fresh build) AND we
2962
+ // have no previously-mounted bubble for this message, fall back to
2963
+ // the built-in overlay sheet. If we already have a mounted bubble
2964
+ // and the plugin didn't run this pass (cached), keep using it.
2965
+ const previouslyMounted = lastFp != null;
2966
+ if (needsRebuild && pluginBubble === null && !previouslyMounted) {
2967
+ if (
2968
+ message.agentMetadata?.awaitingLocalTool === true &&
2969
+ !message.agentMetadata?.askUserQuestionAnswered
2970
+ ) {
2971
+ liveAskToolIds.add(message.toolCall!.id);
2972
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
2973
+ }
2974
+ return;
2975
+ }
2976
+
2977
+ // Append a stub wrapper for the morph pass; hydrate the real bubble
2978
+ // into it post-morph so its event listeners survive.
2979
+ const stub = document.createElement("div");
2980
+ stub.className = "persona-flex";
2981
+ stub.id = `wrapper-${message.id}`;
2982
+ stub.setAttribute("data-wrapper-id", message.id);
2983
+ stub.setAttribute("data-ask-plugin-stub", "true");
2984
+ stub.setAttribute("data-preserve-runtime", "true");
2985
+ tempContainer.appendChild(stub);
2986
+ askPluginHydrate.push({
2987
+ messageId: message.id,
2988
+ fingerprint,
2989
+ bubble: pluginBubble,
2990
+ });
2991
+ return;
2992
+ } else {
2993
+ if (
2994
+ message.agentMetadata?.awaitingLocalTool === true &&
2995
+ !message.agentMetadata?.askUserQuestionAnswered
2996
+ ) {
2997
+ liveAskToolIds.add(message.toolCall!.id);
2998
+ ensureAskUserQuestionSheet(message, config, panelElements.composerOverlay);
2999
+ }
3000
+ return;
3001
+ }
3002
+ } else if (matchingPlugin) {
2388
3003
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
2389
3004
  if (!showReasoning) return;
2390
3005
  bubble = matchingPlugin.renderReasoning({
@@ -2562,6 +3177,20 @@ export const createAgentExperience = (
2562
3177
  tempContainer.appendChild(wrapper);
2563
3178
  });
2564
3179
 
3180
+ // Prune any ask_user_question sheets whose source message is no longer in
3181
+ // the message list (e.g. after clearChat or a splice).
3182
+ if (panelElements.composerOverlay) {
3183
+ const sheets = panelElements.composerOverlay.querySelectorAll<HTMLElement>(
3184
+ "[data-persona-ask-sheet-for]"
3185
+ );
3186
+ sheets.forEach((sheet) => {
3187
+ const id = sheet.getAttribute("data-persona-ask-sheet-for");
3188
+ if (id && !liveAskToolIds.has(id)) {
3189
+ removeAskUserQuestionSheet(panelElements.composerOverlay, id);
3190
+ }
3191
+ });
3192
+ }
3193
+
2565
3194
  if (config.features?.toolCallDisplay?.grouped) {
2566
3195
  const toolGroups: AgentWidgetMessage[][] = [];
2567
3196
  let currentGroup: AgentWidgetMessage[] = [];
@@ -2791,6 +3420,35 @@ export const createAgentExperience = (
2791
3420
 
2792
3421
  // Use idiomorph to morph the container contents
2793
3422
  morphMessages(container, tempContainer);
3423
+
3424
+ // Hydrate plugin-rendered ask-question bubbles into their stub wrappers.
3425
+ // Idiomorph imports new nodes via `document.importNode`, which strips
3426
+ // listeners — so we built only an empty stub during morph and now inject
3427
+ // the real, listener-bearing bubble directly into the live DOM.
3428
+ if (askPluginHydrate.length > 0) {
3429
+ for (const { messageId, fingerprint, bubble } of askPluginHydrate) {
3430
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
3431
+ if (!wrapper) continue;
3432
+ if (bubble === null) {
3433
+ // No fresh bubble built this pass — either the plugin opted out
3434
+ // and a previously-mounted bubble already lives here (preserved by
3435
+ // `data-preserve-runtime`), or we skipped the rebuild because the
3436
+ // fingerprint matched. Either way, leave the live wrapper alone.
3437
+ continue;
3438
+ }
3439
+ wrapper.replaceChildren(bubble);
3440
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
3441
+ lastAskBubbleFingerprint.set(messageId, fingerprint);
3442
+ }
3443
+ }
3444
+
3445
+ // Drop fingerprints for messages that are no longer present so a future
3446
+ // re-appearance triggers a fresh plugin render.
3447
+ if (lastAskBubbleFingerprint.size > 0) {
3448
+ for (const id of lastAskBubbleFingerprint.keys()) {
3449
+ if (!activeMessageIds.has(id)) lastAskBubbleFingerprint.delete(id);
3450
+ }
3451
+ }
2794
3452
  };
2795
3453
 
2796
3454
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -2921,9 +3579,10 @@ export const createAgentExperience = (
2921
3579
  };
2922
3580
 
2923
3581
  const setComposerDisabled = (disabled: boolean) => {
2924
- // Keep textarea always enabled so users can type while streaming
2925
- // Only disable submit controls to prevent sending during streaming
2926
- sendButton.disabled = disabled;
3582
+ // The send button stays enabled while streaming it doubles as a stop
3583
+ // button. Ancillary controls (mic, suggestions, opt-in targets) still
3584
+ // disable so the user can't race a send against an in-flight stream.
3585
+ setSendButtonMode(disabled ? "stop" : "send");
2927
3586
  if (micButton) {
2928
3587
  micButton.disabled = disabled;
2929
3588
  }
@@ -2974,9 +3633,10 @@ export const createAgentExperience = (
2974
3633
  }
2975
3634
  }
2976
3635
 
2977
- // Only update send button text if NOT using icon mode
3636
+ // Only update send button text if NOT using icon mode. Skip while
3637
+ // streaming so we don't stomp on the "Stop" label.
2978
3638
  const useIcon = config.sendButton?.useIcon ?? false;
2979
- if (!useIcon) {
3639
+ if (!useIcon && !session?.isStreaming()) {
2980
3640
  sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
2981
3641
  }
2982
3642
 
@@ -3115,6 +3775,7 @@ export const createAgentExperience = (
3115
3775
  onArtifactsState(state) {
3116
3776
  lastArtifactsState = state;
3117
3777
  syncArtifactPane();
3778
+ persistState();
3118
3779
  }
3119
3780
  });
3120
3781
 
@@ -3167,6 +3828,12 @@ export const createAgentExperience = (
3167
3828
  if (state.messages?.length) {
3168
3829
  session.hydrateMessages(state.messages);
3169
3830
  }
3831
+ if (state.artifacts?.length) {
3832
+ session.hydrateArtifacts(
3833
+ state.artifacts,
3834
+ state.selectedArtifactId ?? null
3835
+ );
3836
+ }
3170
3837
  })
3171
3838
  .catch((error) => {
3172
3839
  if (typeof console !== "undefined") {
@@ -3178,6 +3845,15 @@ export const createAgentExperience = (
3178
3845
 
3179
3846
  const handleSubmit = (event: Event) => {
3180
3847
  event.preventDefault();
3848
+
3849
+ // While a response is streaming, the submit button acts as a stop button.
3850
+ // Abort the in-flight stream and leave textarea contents / attachments
3851
+ // intact so the user can edit and resend without retyping.
3852
+ if (session.isStreaming()) {
3853
+ session.cancel();
3854
+ return;
3855
+ }
3856
+
3181
3857
  const value = textarea.value.trim();
3182
3858
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
3183
3859
 
@@ -3914,16 +4590,26 @@ export const createAgentExperience = (
3914
4590
  }
3915
4591
 
3916
4592
  lastScrollTop = body.scrollTop;
4593
+ let lastScrollHeight = body.scrollHeight;
3917
4594
 
3918
4595
  const handleScroll = () => {
3919
4596
  const scrollTop = body.scrollTop;
4597
+ // When content mutates (e.g. stream-animation plugins re-rendering text),
4598
+ // scrollHeight can shrink and force the browser to clamp scrollTop downward.
4599
+ // That emits a scroll event with a negative delta that would otherwise be
4600
+ // misread as the user scrolling up, pausing auto-follow and flashing the
4601
+ // scroll-to-bottom button. Treat those as non-user events.
4602
+ const currentScrollHeight = body.scrollHeight;
4603
+ const scrollHeightShrank = currentScrollHeight < lastScrollHeight;
4604
+ lastScrollHeight = currentScrollHeight;
4605
+
3920
4606
  const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
3921
4607
  following: autoFollow.isFollowing(),
3922
4608
  currentScrollTop: scrollTop,
3923
4609
  lastScrollTop,
3924
4610
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3925
4611
  userScrollThreshold: USER_SCROLL_THRESHOLD,
3926
- isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
4612
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || scrollHeightShrank,
3927
4613
  pauseOnUpwardScroll: true,
3928
4614
  pauseWhenAwayFromBottom: false,
3929
4615
  resumeRequiresDownwardScroll: true
@@ -3999,6 +4685,9 @@ export const createAgentExperience = (
3999
4685
  messageCache.clear();
4000
4686
  resumeAutoScroll();
4001
4687
 
4688
+ // Drop any open ask_user_question sheets — their source messages are gone.
4689
+ removeAskUserQuestionSheet(panelElements.composerOverlay);
4690
+
4002
4691
  // Always clear the default localStorage key
4003
4692
  try {
4004
4693
  localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
@@ -5674,6 +6363,12 @@ export const createAgentExperience = (
5674
6363
  if (!artifactsSidebarEnabled(config)) return;
5675
6364
  session.clearArtifacts();
5676
6365
  },
6366
+ getArtifacts(): PersonaArtifactRecord[] {
6367
+ return session?.getArtifacts() ?? [];
6368
+ },
6369
+ getSelectedArtifactId(): string | null {
6370
+ return session?.getSelectedArtifactId() ?? null;
6371
+ },
5677
6372
  focusInput(): boolean {
5678
6373
  if (launcherEnabled && !open) return false;
5679
6374
  if (!textarea) return false;