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