@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.
- package/README.md +142 -0
- package/dist/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-cwY5HaFD.d.cts +307 -0
- package/dist/animations/types-cwY5HaFD.d.ts +307 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +504 -1
- package/dist/index.d.ts +504 -1
- package/dist/index.global.js +143 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +2095 -207
- package/dist/theme-editor.d.cts +432 -2
- package/dist/theme-editor.d.ts +432 -2
- package/dist/theme-editor.js +2093 -207
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +565 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +275 -0
- package/src/client.ts +99 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- package/src/components/composer-builder.ts +61 -10
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/messages.ts +33 -1
- package/src/components/panel.ts +45 -5
- package/src/defaults.ts +37 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +34 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +276 -1
- package/src/session.ts +247 -3
- package/src/styles/widget.css +565 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types/theme.ts +15 -0
- package/src/types.ts +360 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +706 -11
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
- package/src/utils/theme.test.ts +36 -0
- 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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2925
|
-
//
|
|
2926
|
-
|
|
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;
|