@runtypelabs/persona 3.17.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 (43) hide show
  1. package/README.md +142 -0
  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 +300 -1
  11. package/dist/index.d.ts +300 -1
  12. package/dist/index.global.js +75 -75
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +47 -47
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +1432 -159
  17. package/dist/theme-editor.d.cts +218 -0
  18. package/dist/theme-editor.d.ts +218 -0
  19. package/dist/theme-editor.js +1432 -159
  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 +432 -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/messages.ts +33 -1
  30. package/src/components/panel.ts +41 -4
  31. package/src/defaults.ts +21 -0
  32. package/src/index.ts +16 -1
  33. package/src/plugins/types.ts +57 -0
  34. package/src/session.test.ts +183 -0
  35. package/src/session.ts +242 -3
  36. package/src/styles/widget.css +432 -0
  37. package/src/types/theme.ts +15 -0
  38. package/src/types.ts +150 -0
  39. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  40. package/src/ui.ts +631 -5
  41. package/src/utils/storage.ts +10 -2
  42. package/src/utils/theme.test.ts +36 -0
  43. package/src/utils/tokens.ts +23 -0
package/src/ui.ts CHANGED
@@ -59,6 +59,20 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
59
59
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
60
60
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
61
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";
62
76
  import { formatElapsedMs } from "./utils/formatting";
63
77
  import { createApprovalBubble } from "./components/approval-bubble";
64
78
  import { createSuggestions } from "./components/suggestions";
@@ -328,6 +342,10 @@ type Controller = {
328
342
  upsertArtifact: (manual: PersonaArtifactManualUpsert) => PersonaArtifactRecord | null;
329
343
  selectArtifact: (id: string) => void;
330
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;
331
349
  /**
332
350
  * Focus the chat input. Returns true if focus succeeded, false if panel is closed
333
351
  * (launcher mode) or textarea is unavailable.
@@ -517,6 +535,13 @@ export const createAgentExperience = (
517
535
  if (processedState.messages?.length) {
518
536
  config = { ...config, initialMessages: processedState.messages };
519
537
  }
538
+ if (processedState.artifacts?.length) {
539
+ config = {
540
+ ...config,
541
+ initialArtifacts: processedState.artifacts,
542
+ initialSelectedArtifactId: processedState.selectedArtifactId ?? null
543
+ };
544
+ }
520
545
  }
521
546
  } catch (error) {
522
547
  if (typeof console !== "undefined") {
@@ -1408,6 +1433,385 @@ export const createAgentExperience = (
1408
1433
  target.click();
1409
1434
  });
1410
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
+
1411
1815
  let artifactSplitRoot: HTMLElement | null = null;
1412
1816
  let artifactResizeHandle: HTMLElement | null = null;
1413
1817
  let artifactResizeUnbind: (() => void) | null = null;
@@ -1965,6 +2369,10 @@ export const createAgentExperience = (
1965
2369
  applyArtifactPaneAppearance(mount, config);
1966
2370
 
1967
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
+ });
1968
2376
 
1969
2377
  let teardownHostStacking: (() => void) | null = null;
1970
2378
  let releaseScrollLock: (() => void) | null = null;
@@ -2056,6 +2464,10 @@ export const createAgentExperience = (
2056
2464
  let session: AgentWidgetSession;
2057
2465
  let isStreaming = false;
2058
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>();
2059
2471
  let configVersion = 0;
2060
2472
  const autoFollow = createFollowStateController();
2061
2473
  let lastScrollTop = 0;
@@ -2139,7 +2551,9 @@ export const createAgentExperience = (
2139
2551
 
2140
2552
  const payload = {
2141
2553
  messages,
2142
- metadata: persistentMetadata
2554
+ metadata: persistentMetadata,
2555
+ artifacts: lastArtifactsState.artifacts,
2556
+ selectedArtifactId: lastArtifactsState.selectedId
2143
2557
  };
2144
2558
  try {
2145
2559
  const result = storageAdapter.save(payload);
@@ -2398,15 +2812,64 @@ export const createAgentExperience = (
2398
2812
 
2399
2813
  // Track active message IDs for cache pruning
2400
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[] = [];
2401
2834
 
2402
2835
  messages.forEach((message) => {
2403
2836
  activeMessageIds.add(message.id);
2404
2837
 
2405
- // Fingerprint cache: skip re-rendering unchanged messages
2406
- const fingerprint = computeMessageFingerprint(message, configVersion);
2407
- 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);
2408
2855
  if (cachedWrapper) {
2409
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
+ }
2410
2873
  return;
2411
2874
  }
2412
2875
 
@@ -2432,7 +2895,111 @@ export const createAgentExperience = (
2432
2895
  // Get message layout config
2433
2896
  const messageLayoutConfig = config.layout?.messages;
2434
2897
 
2435
- 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) {
2436
3003
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
2437
3004
  if (!showReasoning) return;
2438
3005
  bubble = matchingPlugin.renderReasoning({
@@ -2610,6 +3177,20 @@ export const createAgentExperience = (
2610
3177
  tempContainer.appendChild(wrapper);
2611
3178
  });
2612
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
+
2613
3194
  if (config.features?.toolCallDisplay?.grouped) {
2614
3195
  const toolGroups: AgentWidgetMessage[][] = [];
2615
3196
  let currentGroup: AgentWidgetMessage[] = [];
@@ -2839,6 +3420,35 @@ export const createAgentExperience = (
2839
3420
 
2840
3421
  // Use idiomorph to morph the container contents
2841
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
+ }
2842
3452
  };
2843
3453
 
2844
3454
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -3165,6 +3775,7 @@ export const createAgentExperience = (
3165
3775
  onArtifactsState(state) {
3166
3776
  lastArtifactsState = state;
3167
3777
  syncArtifactPane();
3778
+ persistState();
3168
3779
  }
3169
3780
  });
3170
3781
 
@@ -3217,6 +3828,12 @@ export const createAgentExperience = (
3217
3828
  if (state.messages?.length) {
3218
3829
  session.hydrateMessages(state.messages);
3219
3830
  }
3831
+ if (state.artifacts?.length) {
3832
+ session.hydrateArtifacts(
3833
+ state.artifacts,
3834
+ state.selectedArtifactId ?? null
3835
+ );
3836
+ }
3220
3837
  })
3221
3838
  .catch((error) => {
3222
3839
  if (typeof console !== "undefined") {
@@ -4068,6 +4685,9 @@ export const createAgentExperience = (
4068
4685
  messageCache.clear();
4069
4686
  resumeAutoScroll();
4070
4687
 
4688
+ // Drop any open ask_user_question sheets — their source messages are gone.
4689
+ removeAskUserQuestionSheet(panelElements.composerOverlay);
4690
+
4071
4691
  // Always clear the default localStorage key
4072
4692
  try {
4073
4693
  localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
@@ -5743,6 +6363,12 @@ export const createAgentExperience = (
5743
6363
  if (!artifactsSidebarEnabled(config)) return;
5744
6364
  session.clearArtifacts();
5745
6365
  },
6366
+ getArtifacts(): PersonaArtifactRecord[] {
6367
+ return session?.getArtifacts() ?? [];
6368
+ },
6369
+ getSelectedArtifactId(): string | null {
6370
+ return session?.getSelectedArtifactId() ?? null;
6371
+ },
5746
6372
  focusInput(): boolean {
5747
6373
  if (launcherEnabled && !open) return false;
5748
6374
  if (!textarea) return false;