@runtypelabs/persona 4.7.0 → 4.9.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 +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
package/src/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
|
|
2
2
|
import { resolveSanitizer } from "./utils/sanitize";
|
|
3
3
|
import { stabilizeStreamingTables } from "./utils/streaming-table";
|
|
4
|
-
import {
|
|
4
|
+
import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parsers-loader";
|
|
5
5
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
|
|
6
6
|
import {
|
|
7
7
|
AgentWidgetConfig,
|
|
@@ -28,14 +28,25 @@ import {
|
|
|
28
28
|
VoiceStatus,
|
|
29
29
|
ReadAloudState,
|
|
30
30
|
PersonaArtifactRecord,
|
|
31
|
-
PersonaArtifactManualUpsert
|
|
31
|
+
PersonaArtifactManualUpsert,
|
|
32
|
+
PersonaArtifactFileMeta,
|
|
33
|
+
PersonaArtifactActionContext,
|
|
34
|
+
AgentWidgetContentSegment,
|
|
35
|
+
AgentWidgetContextMentionRef
|
|
32
36
|
} from "./types";
|
|
33
37
|
import { AttachmentManager } from "./utils/attachment-manager";
|
|
38
|
+
import {
|
|
39
|
+
createContextMentionOrchestrator,
|
|
40
|
+
type ContextMentionOrchestrator,
|
|
41
|
+
} from "./utils/context-mention-orchestrator";
|
|
42
|
+
import type { MentionSubmitBundle } from "./utils/context-mention-manager";
|
|
34
43
|
import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
|
|
35
44
|
import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
|
|
36
45
|
import { resolveTokenValue } from "./utils/tokens";
|
|
37
46
|
import { renderLucideIcon } from "./utils/icons";
|
|
38
47
|
import { createElement, createElementInDocument } from "./utils/dom";
|
|
48
|
+
import { downloadInfoFor } from "./utils/artifact-file";
|
|
49
|
+
import { artifactCopyText } from "./components/artifact-preview";
|
|
39
50
|
import { morphMessages } from "./utils/morph";
|
|
40
51
|
import { normalizeCopiedSelectionText } from "./utils/copy-selection";
|
|
41
52
|
import {
|
|
@@ -106,12 +117,18 @@ import { EventStreamStore } from "./utils/event-stream-store";
|
|
|
106
117
|
import { ThroughputTracker } from "./utils/throughput-tracker";
|
|
107
118
|
import { createEventStreamView } from "./components/event-stream-view";
|
|
108
119
|
import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
|
|
120
|
+
import {
|
|
121
|
+
hasLiveInlineArtifactBlock,
|
|
122
|
+
updateInlineArtifactBlocks
|
|
123
|
+
} from "./components/artifact-inline";
|
|
109
124
|
import {
|
|
110
125
|
artifactsSidebarEnabled,
|
|
111
126
|
applyArtifactLayoutCssVars,
|
|
112
127
|
applyArtifactPaneAppearance,
|
|
128
|
+
isArtifactPaneAppearanceDetached,
|
|
113
129
|
shouldExpandLauncherForArtifacts
|
|
114
130
|
} from "./utils/artifact-gate";
|
|
131
|
+
import { resolveArtifactDisplayMode } from "./utils/artifact-display";
|
|
115
132
|
import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
|
|
116
133
|
import { enhanceWithForms } from "./components/forms";
|
|
117
134
|
import { pluginRegistry } from "./plugins/registry";
|
|
@@ -139,6 +156,8 @@ import {
|
|
|
139
156
|
// Default localStorage key for chat history (automatically cleared on clear chat)
|
|
140
157
|
const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
|
|
141
158
|
const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
|
|
159
|
+
// Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
|
|
160
|
+
const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
|
|
142
161
|
|
|
143
162
|
const IMAGE_FILE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
|
|
144
163
|
"image/png": "png",
|
|
@@ -523,6 +542,35 @@ function buildDropOverlay(
|
|
|
523
542
|
return overlay;
|
|
524
543
|
}
|
|
525
544
|
|
|
545
|
+
// Deep-merge two mention context maps (`{ [sourceId]: { [itemId]: unknown } }`).
|
|
546
|
+
// A source contributing to both bundles keeps BOTH per-item maps; a shallow
|
|
547
|
+
// spread would let one sourceId's inner map wholesale replace the other's.
|
|
548
|
+
export const mergeMentionContext = (
|
|
549
|
+
a: MentionSubmitBundle["context"],
|
|
550
|
+
b: MentionSubmitBundle["context"]
|
|
551
|
+
): MentionSubmitBundle["context"] => {
|
|
552
|
+
const out: MentionSubmitBundle["context"] = { ...a };
|
|
553
|
+
for (const [sourceId, items] of Object.entries(b)) {
|
|
554
|
+
const existing = out[sourceId];
|
|
555
|
+
out[sourceId] = existing ? { ...existing, ...items } : items;
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// Concatenate the fulfilled halves of two finalize() results (either may be
|
|
561
|
+
// absent when its side rejected), deep-merging their per-source context.
|
|
562
|
+
export const mergeFinalizedMentions = (
|
|
563
|
+
bundles: MentionSubmitBundle[]
|
|
564
|
+
): MentionSubmitBundle =>
|
|
565
|
+
bundles.reduce<MentionSubmitBundle>(
|
|
566
|
+
(acc, b) => ({
|
|
567
|
+
blocks: [...acc.blocks, ...b.blocks],
|
|
568
|
+
contentParts: [...acc.contentParts, ...b.contentParts],
|
|
569
|
+
context: mergeMentionContext(acc.context, b.context),
|
|
570
|
+
}),
|
|
571
|
+
{ blocks: [], contentParts: [], context: {} }
|
|
572
|
+
);
|
|
573
|
+
|
|
526
574
|
export const createAgentExperience = (
|
|
527
575
|
mount: HTMLElement,
|
|
528
576
|
initialConfig?: AgentWidgetConfig,
|
|
@@ -828,9 +876,13 @@ export const createAgentExperience = (
|
|
|
828
876
|
header,
|
|
829
877
|
footer,
|
|
830
878
|
actionsRow: _actionsRow,
|
|
831
|
-
leftActions,
|
|
832
879
|
rightActions
|
|
833
880
|
} = panelElements;
|
|
881
|
+
// Nullable + reassignable: a plugin `replaceComposer` swaps the footer, and
|
|
882
|
+
// `bindComposerRefsFromFooter` must repoint this at the NEW left-action
|
|
883
|
+
// cluster (or null it so the composerForm fallback fires) — a stale ref would
|
|
884
|
+
// insert the mention/attachment buttons into the detached old subtree.
|
|
885
|
+
let leftActions: HTMLElement | null = panelElements.leftActions;
|
|
834
886
|
let setSendButtonMode = panelElements.setSendButtonMode;
|
|
835
887
|
|
|
836
888
|
// Use mutable references for mic button so we can update them dynamically
|
|
@@ -969,6 +1021,9 @@ export const createAgentExperience = (
|
|
|
969
1021
|
|
|
970
1022
|
// Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
|
|
971
1023
|
let attachmentManager: AttachmentManager | null = null;
|
|
1024
|
+
// Context mentions orchestrator (core, tiny); lazy-loads the heavy runtime on
|
|
1025
|
+
// first use. Null when `contextMentions` is disabled / has no sources.
|
|
1026
|
+
let mentionOrchestrator: ContextMentionOrchestrator | null = null;
|
|
972
1027
|
|
|
973
1028
|
/** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
|
|
974
1029
|
let composerVoiceBridge: (() => void) | null = null;
|
|
@@ -1239,6 +1294,10 @@ export const createAgentExperience = (
|
|
|
1239
1294
|
".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
|
|
1240
1295
|
);
|
|
1241
1296
|
if (ar) _actionsRow = ar;
|
|
1297
|
+
// Rebind the left-action cluster to the NEW footer (both composer builders
|
|
1298
|
+
// ship this class). Assign unconditionally: when a custom plugin composer
|
|
1299
|
+
// has no left cluster, null lets the button-insert fall back to the form.
|
|
1300
|
+
leftActions = pick<HTMLElement>(".persona-widget-composer__left-actions");
|
|
1242
1301
|
};
|
|
1243
1302
|
ensureComposerAttachmentSurface(footer);
|
|
1244
1303
|
bindComposerRefsFromFooter(footer);
|
|
@@ -1291,6 +1350,49 @@ export const createAgentExperience = (
|
|
|
1291
1350
|
container.appendChild(overlay);
|
|
1292
1351
|
}
|
|
1293
1352
|
|
|
1353
|
+
// Context mentions: render the affordance button + chip row eagerly (so the
|
|
1354
|
+
// feature is discoverable) and lazy-load the heavy runtime on first use. The
|
|
1355
|
+
// orchestrator returns null when disabled or sourceless.
|
|
1356
|
+
const mentionPrefetch = () => mentionOrchestrator?.prefetch();
|
|
1357
|
+
if (config.contextMentions?.enabled && textarea) {
|
|
1358
|
+
// Slash-command dispatch (prompt macros write text / submit; client actions
|
|
1359
|
+
// read/replace the value) and submission are owned by the composer input
|
|
1360
|
+
// surface itself — the mention runtime builds a textarea (chip) or
|
|
1361
|
+
// contenteditable (inline) adapter from this textarea. Broader host actions
|
|
1362
|
+
// (clear transcript, theme) are still wired via closures over the controller.
|
|
1363
|
+
mentionOrchestrator = createContextMentionOrchestrator({
|
|
1364
|
+
config,
|
|
1365
|
+
textarea,
|
|
1366
|
+
anchor: composerForm ?? textarea,
|
|
1367
|
+
getMessages: () => session.getMessages(),
|
|
1368
|
+
// The engine chunk creates the mention live regions in this container on
|
|
1369
|
+
// mount, keeping the live-region helper out of the core bundle.
|
|
1370
|
+
liveRegionHost: container,
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
if (mentionOrchestrator) {
|
|
1374
|
+
// Chip row sits directly above the textarea.
|
|
1375
|
+
const ta = textarea;
|
|
1376
|
+
ta.parentElement?.insertBefore(mentionOrchestrator.contextRow, ta);
|
|
1377
|
+
// Each channel's "add context" affordance is a secondary control that
|
|
1378
|
+
// augments the outgoing message, so it joins the LEFT action cluster
|
|
1379
|
+
// (beside the attachment button) — never the right cluster with mic +
|
|
1380
|
+
// send. Buttons are inserted leftmost in channel order so they read as
|
|
1381
|
+
// "add to my message" and stay clear of the primary send action. Both
|
|
1382
|
+
// composer builders (full + pill) always ship `leftActions`; the form
|
|
1383
|
+
// fallback is purely defensive.
|
|
1384
|
+
const buttons = mentionOrchestrator.affordanceButtons;
|
|
1385
|
+
for (let i = buttons.length - 1; i >= 0; i--) {
|
|
1386
|
+
const btn = buttons[i];
|
|
1387
|
+
if (leftActions) leftActions.insertBefore(btn, leftActions.firstChild);
|
|
1388
|
+
else composerForm?.appendChild(btn);
|
|
1389
|
+
}
|
|
1390
|
+
// The focus-prefetch listener (warm the chunk on first focus so the first
|
|
1391
|
+
// `@` is instant) is registered through the shared `composerListeners`
|
|
1392
|
+
// registry below, so it survives the inline contenteditable swap too.
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1294
1396
|
// Slot system: allow custom content injection into specific regions
|
|
1295
1397
|
const renderSlots = () => {
|
|
1296
1398
|
const slots = config.layout?.slots ?? {};
|
|
@@ -1670,32 +1772,66 @@ export const createAgentExperience = (
|
|
|
1670
1772
|
|
|
1671
1773
|
let artifactPaneApi: ArtifactPaneApi | null = null;
|
|
1672
1774
|
let artifactPanelResizeObs: ResizeObserver | null = null;
|
|
1775
|
+
// Re-runs panel chrome when the split-chrome mode flips (pane
|
|
1776
|
+
// open/close/appearance change). Assigned once applyFullHeightStyles exists.
|
|
1777
|
+
let syncPanelChrome: () => void = () => {};
|
|
1778
|
+
let appliedSplitMode: "none" | "welded" | "detached" = "none";
|
|
1779
|
+
// Resolved welded outer-right radius stashed by applyFullHeightStyles and
|
|
1780
|
+
// applied in syncPanelChrome (after applyArtifactPaneAppearance, which no
|
|
1781
|
+
// longer manages the var, so the two never fight). Null clears it.
|
|
1782
|
+
let weldedOuterRadius: string | null = null;
|
|
1673
1783
|
let lastArtifactsState: {
|
|
1674
1784
|
artifacts: PersonaArtifactRecord[];
|
|
1675
1785
|
selectedId: string | null;
|
|
1676
1786
|
} = { artifacts: [], selectedId: null };
|
|
1677
1787
|
let artifactsPaneUserHidden = false;
|
|
1788
|
+
// Runtime-only expand state: pane fills the split root and the chat column
|
|
1789
|
+
// hides. Reset whenever the pane stops being visible (syncArtifactPane).
|
|
1790
|
+
let artifactPaneExpanded = false;
|
|
1791
|
+
// Set when the expansion came from an inline block's Expand button: that is
|
|
1792
|
+
// an explicit "fullscreen this file" request, so it survives the
|
|
1793
|
+
// showExpandToggle gate below (which otherwise collapses the pane for hosts
|
|
1794
|
+
// without the toolbar toggle). Cleared whenever the pane collapses or hides.
|
|
1795
|
+
let artifactPaneExpandedPinned = false;
|
|
1796
|
+
// Whether the user explicitly opened the pane (card click, inline Expand,
|
|
1797
|
+
// showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
|
|
1798
|
+
// artifacts whose resolved display mode is "panel": "card" keeps the card as
|
|
1799
|
+
// the only affordance and "inline" renders in the transcript. "inline" no
|
|
1800
|
+
// longer *auto*-opens the pane, but its Expand control is a deliberate,
|
|
1801
|
+
// user-driven open (setting artifactsPaneUserOpened, same as a card click);
|
|
1802
|
+
// every mode still writes the session artifact registry (download /
|
|
1803
|
+
// getArtifacts / hydration).
|
|
1804
|
+
let artifactsPaneUserOpened = false;
|
|
1805
|
+
const artifactPaneCanShow = () =>
|
|
1806
|
+
artifactsPaneUserOpened ||
|
|
1807
|
+
lastArtifactsState.artifacts.some(
|
|
1808
|
+
(a) =>
|
|
1809
|
+
resolveArtifactDisplayMode(config.features?.artifacts, a.artifactType) === "panel"
|
|
1810
|
+
);
|
|
1811
|
+
const artifactPaneVisible = () =>
|
|
1812
|
+
lastArtifactsState.artifacts.length > 0 &&
|
|
1813
|
+
!artifactsPaneUserHidden &&
|
|
1814
|
+
artifactPaneCanShow();
|
|
1678
1815
|
const sessionRef: { current: AgentWidgetSession | null } = { current: null };
|
|
1679
1816
|
|
|
1680
|
-
//
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
if (!artifactId) return;
|
|
1689
|
-
// Let integrator intercept
|
|
1690
|
-
const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
|
|
1691
|
-
if (dlPrevented === true) return;
|
|
1692
|
-
// Try session state first, fall back to content stored in the card's rawContent props
|
|
1817
|
+
// Resolve an artifact's content for card actions (download + custom actions).
|
|
1818
|
+
// Tries the live session registry first, then falls back to the content
|
|
1819
|
+
// persisted in the card message's rawContent props (session state is gone
|
|
1820
|
+
// after a page refresh). `fromEl` is the clicked element inside the card.
|
|
1821
|
+
const resolveCardArtifactContent = (
|
|
1822
|
+
fromEl: HTMLElement,
|
|
1823
|
+
artifactId: string
|
|
1824
|
+
): { markdown?: string; title: string; file?: PersonaArtifactFileMeta; artifactType: string } => {
|
|
1693
1825
|
const artifact = session.getArtifactById(artifactId);
|
|
1694
1826
|
let markdown = artifact?.markdown;
|
|
1695
1827
|
let title = artifact?.title || 'artifact';
|
|
1828
|
+
let file: PersonaArtifactFileMeta | undefined = artifact?.file;
|
|
1829
|
+
let artifactType: string = artifact?.artifactType ?? 'markdown';
|
|
1696
1830
|
if (!markdown) {
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1831
|
+
// Match both reference cards and inline blocks: inline hydration embeds
|
|
1832
|
+
// the markdown / file source in the same message rawContent, so the parse
|
|
1833
|
+
// below resolves content for inline copy / custom actions after a refresh.
|
|
1834
|
+
const cardEl = fromEl.closest('[data-open-artifact], [data-artifact-inline]');
|
|
1699
1835
|
const msgEl = cardEl?.closest('[data-message-id]');
|
|
1700
1836
|
const msgId = msgEl?.getAttribute('data-message-id');
|
|
1701
1837
|
if (msgId) {
|
|
@@ -1706,23 +1842,159 @@ export const createAgentExperience = (
|
|
|
1706
1842
|
const parsed = JSON.parse(msg.rawContent);
|
|
1707
1843
|
markdown = parsed?.props?.markdown;
|
|
1708
1844
|
title = parsed?.props?.title || title;
|
|
1845
|
+
if (parsed?.props?.file && typeof parsed.props.file === 'object') {
|
|
1846
|
+
file = parsed.props.file as PersonaArtifactFileMeta;
|
|
1847
|
+
}
|
|
1848
|
+
if (!artifact && typeof parsed?.props?.artifactType === 'string') {
|
|
1849
|
+
artifactType = parsed.props.artifactType;
|
|
1850
|
+
}
|
|
1709
1851
|
} catch { /* ignore */ }
|
|
1710
1852
|
}
|
|
1711
1853
|
}
|
|
1712
1854
|
}
|
|
1855
|
+
return { markdown, title, file, artifactType };
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
// Click delegation for artifact download buttons
|
|
1859
|
+
messagesWrapper.addEventListener('click', (event) => {
|
|
1860
|
+
const target = event.target as HTMLElement;
|
|
1861
|
+
const dlBtn = target.closest('[data-download-artifact]') as HTMLElement;
|
|
1862
|
+
if (!dlBtn) return;
|
|
1863
|
+
event.preventDefault();
|
|
1864
|
+
event.stopPropagation();
|
|
1865
|
+
const artifactId = dlBtn.getAttribute('data-download-artifact');
|
|
1866
|
+
if (!artifactId) return;
|
|
1867
|
+
// Let integrator intercept
|
|
1868
|
+
const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
|
|
1869
|
+
if (dlPrevented === true) return;
|
|
1870
|
+
const { markdown, title, file } = resolveCardArtifactContent(dlBtn, artifactId);
|
|
1713
1871
|
if (!markdown) return;
|
|
1714
|
-
|
|
1872
|
+
// File artifacts download the raw unfenced source under their real name/MIME;
|
|
1873
|
+
// non-file markdown artifacts keep the legacy `<title>.md` / text/markdown path.
|
|
1874
|
+
const { filename, mime, content } = downloadInfoFor({ title, markdown, file });
|
|
1875
|
+
const blob = new Blob([content], { type: mime });
|
|
1715
1876
|
const url = URL.createObjectURL(blob);
|
|
1716
1877
|
const a = document.createElement('a');
|
|
1717
1878
|
a.href = url;
|
|
1718
|
-
a.download =
|
|
1879
|
+
a.download = filename;
|
|
1719
1880
|
a.click();
|
|
1720
1881
|
URL.revokeObjectURL(url);
|
|
1721
1882
|
});
|
|
1722
1883
|
|
|
1884
|
+
// Click delegation for integrator-supplied card action buttons. Actions are
|
|
1885
|
+
// looked up by id from fresh config at click time so live config updates
|
|
1886
|
+
// apply. Like the download listener, its stopPropagation() cannot block the
|
|
1887
|
+
// card-open listener on the same element, so that listener skips these too.
|
|
1888
|
+
messagesWrapper.addEventListener('click', (event) => {
|
|
1889
|
+
const target = event.target as HTMLElement;
|
|
1890
|
+
const actionBtn = target.closest('[data-artifact-custom-action]') as HTMLElement;
|
|
1891
|
+
if (!actionBtn) return;
|
|
1892
|
+
event.preventDefault();
|
|
1893
|
+
event.stopPropagation();
|
|
1894
|
+
const actionId = actionBtn.getAttribute('data-artifact-custom-action');
|
|
1895
|
+
if (!actionId) return;
|
|
1896
|
+
// The same attribute serves cards and inline chrome; resolve which surface
|
|
1897
|
+
// the button lives in so the action comes from the matching config list and
|
|
1898
|
+
// the artifact id from the matching container attribute.
|
|
1899
|
+
const inlineEl = actionBtn.closest('[data-artifact-inline]');
|
|
1900
|
+
const cardEl = inlineEl ? null : actionBtn.closest('[data-open-artifact]');
|
|
1901
|
+
const artifactId = inlineEl
|
|
1902
|
+
? inlineEl.getAttribute('data-artifact-inline')
|
|
1903
|
+
: cardEl?.getAttribute('data-open-artifact') ?? null;
|
|
1904
|
+
const actionList = inlineEl
|
|
1905
|
+
? config.features?.artifacts?.inlineActions
|
|
1906
|
+
: config.features?.artifacts?.cardActions;
|
|
1907
|
+
const action = actionList?.find((a) => a.id === actionId);
|
|
1908
|
+
if (!action) return;
|
|
1909
|
+
const { markdown, title, file, artifactType } = resolveCardArtifactContent(actionBtn, artifactId ?? '');
|
|
1910
|
+
const ctx: PersonaArtifactActionContext = { artifactId, title, artifactType, markdown, file };
|
|
1911
|
+
try {
|
|
1912
|
+
void Promise.resolve(action.onClick(ctx)).catch(() => {});
|
|
1913
|
+
} catch {
|
|
1914
|
+
/* ignore */
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// Click delegation for the inline chrome Copy button. Like the download /
|
|
1919
|
+
// custom-action listeners, it resolves content from the live registry first,
|
|
1920
|
+
// then falls back to the persisted inline props after a refresh, and its
|
|
1921
|
+
// stopPropagation() cannot block a second listener on the same element (there
|
|
1922
|
+
// is none here, but the pattern is kept consistent).
|
|
1923
|
+
messagesWrapper.addEventListener('click', (event) => {
|
|
1924
|
+
const target = event.target as HTMLElement;
|
|
1925
|
+
const copyBtn = target.closest('[data-copy-artifact]') as HTMLElement;
|
|
1926
|
+
if (!copyBtn) return;
|
|
1927
|
+
event.preventDefault();
|
|
1928
|
+
event.stopPropagation();
|
|
1929
|
+
const artifactId = copyBtn.getAttribute('data-copy-artifact');
|
|
1930
|
+
if (!artifactId) return;
|
|
1931
|
+
// Prefer the live record (covers component JSON); fall back to the persisted
|
|
1932
|
+
// markdown / file source parsed from the inline block's message rawContent.
|
|
1933
|
+
const artifact = session.getArtifactById(artifactId);
|
|
1934
|
+
let text = '';
|
|
1935
|
+
if (artifact) {
|
|
1936
|
+
text = artifactCopyText(artifact);
|
|
1937
|
+
} else {
|
|
1938
|
+
const { markdown, file, artifactType } = resolveCardArtifactContent(copyBtn, artifactId);
|
|
1939
|
+
if (artifactType === 'markdown') {
|
|
1940
|
+
text = artifactCopyText({
|
|
1941
|
+
id: artifactId,
|
|
1942
|
+
artifactType: 'markdown',
|
|
1943
|
+
status: 'complete',
|
|
1944
|
+
markdown: markdown ?? '',
|
|
1945
|
+
...(file ? { file } : {})
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
if (!text) return;
|
|
1950
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
1951
|
+
// Lightweight feedback: swap the copy glyph for a check briefly.
|
|
1952
|
+
const checkIcon = renderLucideIcon('check', 16, 'currentColor', 2);
|
|
1953
|
+
if (checkIcon) {
|
|
1954
|
+
copyBtn.replaceChildren(checkIcon);
|
|
1955
|
+
setTimeout(() => {
|
|
1956
|
+
const copyIcon = renderLucideIcon('copy', 16, 'currentColor', 2);
|
|
1957
|
+
if (copyIcon) copyBtn.replaceChildren(copyIcon);
|
|
1958
|
+
}, 1500);
|
|
1959
|
+
}
|
|
1960
|
+
}).catch(() => { /* ignore */ });
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
// Click delegation for the inline chrome Expand button: open this artifact in
|
|
1964
|
+
// the pane. Fires onArtifactAction({ type: "open" }) first so hosts can
|
|
1965
|
+
// intercept, then mirrors the card-open path — except the pane opens
|
|
1966
|
+
// expanded (fullscreen), never split: the inline block already shows the
|
|
1967
|
+
// full preview at chat width, so a split pane would only duplicate it, and
|
|
1968
|
+
// the click means "expand this file". The pinned flag keeps it expanded
|
|
1969
|
+
// even when layout.showExpandToggle is off; Close is the exit there.
|
|
1970
|
+
messagesWrapper.addEventListener('click', (event) => {
|
|
1971
|
+
const target = event.target as HTMLElement;
|
|
1972
|
+
const expandBtn = target.closest('[data-expand-artifact-inline]') as HTMLElement;
|
|
1973
|
+
if (!expandBtn) return;
|
|
1974
|
+
event.preventDefault();
|
|
1975
|
+
event.stopPropagation();
|
|
1976
|
+
const artifactId = expandBtn.getAttribute('data-expand-artifact-inline');
|
|
1977
|
+
if (!artifactId) return;
|
|
1978
|
+
const openPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'open', artifactId });
|
|
1979
|
+
if (openPrevented === true) return;
|
|
1980
|
+
artifactsPaneUserHidden = false;
|
|
1981
|
+
// Expand is an explicit open: it overrides the "inline" auto-open
|
|
1982
|
+
// suppression for as long as artifacts exist (same as a card click).
|
|
1983
|
+
artifactsPaneUserOpened = true;
|
|
1984
|
+
artifactPaneExpanded = true;
|
|
1985
|
+
artifactPaneExpandedPinned = true;
|
|
1986
|
+
session.selectArtifact(artifactId);
|
|
1987
|
+
syncArtifactPane();
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1723
1990
|
// Click delegation for artifact reference cards
|
|
1724
1991
|
messagesWrapper.addEventListener('click', (event) => {
|
|
1725
1992
|
const target = event.target as HTMLElement;
|
|
1993
|
+
// Download and custom-action clicks are handled by the listeners above;
|
|
1994
|
+
// their stopPropagation() cannot block a second listener on the same
|
|
1995
|
+
// element, so skip them here explicitly or the card would also open the panel.
|
|
1996
|
+
if (target.closest('[data-download-artifact]')) return;
|
|
1997
|
+
if (target.closest('[data-artifact-custom-action]')) return;
|
|
1726
1998
|
const card = target.closest('[data-open-artifact]') as HTMLElement;
|
|
1727
1999
|
if (!card) return;
|
|
1728
2000
|
const artifactId = card.getAttribute('data-open-artifact');
|
|
@@ -1733,15 +2005,24 @@ export const createAgentExperience = (
|
|
|
1733
2005
|
event.preventDefault();
|
|
1734
2006
|
event.stopPropagation();
|
|
1735
2007
|
artifactsPaneUserHidden = false;
|
|
2008
|
+
// Card click is an explicit open: it overrides the "card"/"inline"
|
|
2009
|
+
// auto-open suppression for as long as artifacts exist.
|
|
2010
|
+
artifactsPaneUserOpened = true;
|
|
1736
2011
|
session.selectArtifact(artifactId);
|
|
1737
2012
|
syncArtifactPane();
|
|
1738
2013
|
});
|
|
1739
2014
|
|
|
1740
|
-
// Keyboard support for artifact cards
|
|
2015
|
+
// Keyboard support for artifact cards and the clip-mode inline expand hitbox
|
|
2016
|
+
// (both expose a data-* attribute on a focusable element; Enter/Space forward
|
|
2017
|
+
// to the element's own click, which the delegated click handlers above own).
|
|
1741
2018
|
messagesWrapper.addEventListener('keydown', (event) => {
|
|
1742
2019
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
1743
2020
|
const target = event.target as HTMLElement;
|
|
1744
|
-
if (
|
|
2021
|
+
if (
|
|
2022
|
+
!target.hasAttribute('data-open-artifact') &&
|
|
2023
|
+
!target.hasAttribute('data-expand-artifact-inline')
|
|
2024
|
+
)
|
|
2025
|
+
return;
|
|
1745
2026
|
event.preventDefault();
|
|
1746
2027
|
target.click();
|
|
1747
2028
|
});
|
|
@@ -2130,6 +2411,12 @@ export const createAgentExperience = (
|
|
|
2130
2411
|
let artifactResizeUnbind: (() => void) | null = null;
|
|
2131
2412
|
let artifactResizeDocEnd: (() => void) | null = null;
|
|
2132
2413
|
let reconcileArtifactResize: () => void = () => {};
|
|
2414
|
+
// Expanded-state transition tracking: stash the resizer's inline width/maxWidth
|
|
2415
|
+
// (which would otherwise beat the expanded class) once per enter, restore once
|
|
2416
|
+
// per leave.
|
|
2417
|
+
let artifactExpandedApplied = false;
|
|
2418
|
+
let artifactStashedWidth = "";
|
|
2419
|
+
let artifactStashedMaxWidth = "";
|
|
2133
2420
|
|
|
2134
2421
|
function stopArtifactResizePointer() {
|
|
2135
2422
|
artifactResizeDocEnd?.();
|
|
@@ -2139,7 +2426,9 @@ export const createAgentExperience = (
|
|
|
2139
2426
|
/** Flush split: overlay handle on the seam so it does not consume flex gap (extension + resizable). */
|
|
2140
2427
|
const positionExtensionArtifactResizeHandle = () => {
|
|
2141
2428
|
if (!artifactSplitRoot || !artifactResizeHandle) return;
|
|
2142
|
-
|
|
2429
|
+
// Welded splits (panel + seamless) have gap 0, so the handle overlays the seam
|
|
2430
|
+
// instead of consuming flex width; detached keeps a real gap and a flex handle.
|
|
2431
|
+
const ext = mount.classList.contains("persona-artifact-welded-split");
|
|
2143
2432
|
const ownerWin = mount.ownerDocument.defaultView ?? window;
|
|
2144
2433
|
const mobile = ownerWin.innerWidth <= 640;
|
|
2145
2434
|
if (!ext || mount.classList.contains("persona-artifact-narrow-host") || mobile) {
|
|
@@ -2159,13 +2448,45 @@ export const createAgentExperience = (
|
|
|
2159
2448
|
artifactResizeHandle.style.bottom = "0";
|
|
2160
2449
|
artifactResizeHandle.style.width = `${hitW}px`;
|
|
2161
2450
|
artifactResizeHandle.style.zIndex = "5";
|
|
2162
|
-
|
|
2451
|
+
// Center the overlay on the seam: with an explicit welded gap the seam sits
|
|
2452
|
+
// half a gap past the chat's right edge (welded default gap is 0).
|
|
2453
|
+
const gapPx = readFlexGapPx(artifactSplitRoot, ownerWin);
|
|
2454
|
+
const left = chat.offsetWidth + gapPx / 2 - hitW / 2;
|
|
2163
2455
|
artifactResizeHandle.style.left = `${Math.max(0, left)}px`;
|
|
2164
2456
|
};
|
|
2165
2457
|
|
|
2166
2458
|
/** No-op until artifact pane is created; replaced below when artifacts are enabled. */
|
|
2167
2459
|
let applyLauncherArtifactPanelWidth: () => void = () => {};
|
|
2168
2460
|
|
|
2461
|
+
// Mobile fullscreen renders a flush, chrome-less panel, so the split chrome
|
|
2462
|
+
// must stand down there. This is the union of the two fullscreen predicates
|
|
2463
|
+
// applyFullHeightStyles/recalcPanelHeight use: launcher fullscreen
|
|
2464
|
+
// (shouldGoFullscreen) and the docked-host variant (dockedHostFullscreen).
|
|
2465
|
+
// Unlike the 641px split breakpoint, it honors the configurable mobileBreakpoint.
|
|
2466
|
+
const isMobileFullscreenActive = (): boolean => {
|
|
2467
|
+
const win = mount.ownerDocument.defaultView ?? window;
|
|
2468
|
+
const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
|
|
2469
|
+
const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
|
|
2470
|
+
if (!mobileFullscreen || win.innerWidth > mobileBreakpoint) return false;
|
|
2471
|
+
return launcherEnabled || isDockedMountMode(config);
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
// Desktop side-by-side split chrome mode. 'welded' = panel|seamless (one card,
|
|
2475
|
+
// border+radius wrap both columns); 'detached' = two floating cards; 'none' when
|
|
2476
|
+
// the pane is closed, the narrow-host drawer, or mobile fullscreen.
|
|
2477
|
+
const splitChromeMode = (): "none" | "welded" | "detached" => {
|
|
2478
|
+
if (!artifactsSidebarEnabled(config)) return "none";
|
|
2479
|
+
if (!artifactPaneVisible()) return "none";
|
|
2480
|
+
if (mount.classList.contains("persona-artifact-narrow-host")) return "none";
|
|
2481
|
+
// A fullscreen panel is flush; welded/detached corner chrome would fight it.
|
|
2482
|
+
if (isMobileFullscreenActive()) return "none";
|
|
2483
|
+
const win = mount.ownerDocument.defaultView ?? window;
|
|
2484
|
+
// Must match the artifact split media queries in widget.css (drawer at
|
|
2485
|
+
// max-width:640, split at min-width:641); mobileBreakpoint does not move it.
|
|
2486
|
+
if (win.innerWidth < ARTIFACT_SPLIT_DESKTOP_MIN) return "none";
|
|
2487
|
+
return isArtifactPaneAppearanceDetached(config) ? "detached" : "welded";
|
|
2488
|
+
};
|
|
2489
|
+
|
|
2169
2490
|
const syncArtifactPane = () => {
|
|
2170
2491
|
if (!artifactPaneApi || !artifactsSidebarEnabled(config)) return;
|
|
2171
2492
|
applyArtifactLayoutCssVars(mount, config);
|
|
@@ -2174,16 +2495,79 @@ export const createAgentExperience = (
|
|
|
2174
2495
|
const threshold = config.features?.artifacts?.layout?.narrowHostMaxWidth ?? 520;
|
|
2175
2496
|
const w = panel.getBoundingClientRect().width || 0;
|
|
2176
2497
|
mount.classList.toggle("persona-artifact-narrow-host", w > 0 && w <= threshold);
|
|
2498
|
+
// Thread the single source of truth for "pane surface is shown" into the
|
|
2499
|
+
// pane BEFORE update() so it renders lazily: in inline/card display modes
|
|
2500
|
+
// the pane stays hidden and must not build a second sandboxed artifact
|
|
2501
|
+
// iframe (which would execute artifact scripts twice). The pane records
|
|
2502
|
+
// state while hidden and renders on the next reveal. Set first so an
|
|
2503
|
+
// update() that arrives while hidden is skipped, not rendered-then-hidden.
|
|
2504
|
+
const paneVisible = artifactPaneVisible();
|
|
2505
|
+
artifactPaneApi.setVisible(paneVisible);
|
|
2177
2506
|
artifactPaneApi.update(lastArtifactsState);
|
|
2178
2507
|
if (artifactsPaneUserHidden) {
|
|
2179
2508
|
artifactPaneApi.setMobileOpen(false);
|
|
2180
2509
|
artifactPaneApi.element.classList.add("persona-hidden");
|
|
2181
2510
|
artifactPaneApi.backdrop?.classList.add("persona-hidden");
|
|
2182
|
-
|
|
2511
|
+
artifactPaneExpanded = false;
|
|
2512
|
+
artifactPaneExpandedPinned = false;
|
|
2513
|
+
} else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
|
|
2183
2514
|
// User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
|
|
2184
2515
|
// and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
|
|
2516
|
+
// Artifacts whose display mode is "card" or "inline" don't auto-open the
|
|
2517
|
+
// pane (artifactPaneCanShow); it stays hidden until an explicit open or
|
|
2518
|
+
// until a "panel"-mode artifact arrives.
|
|
2185
2519
|
artifactPaneApi.element.classList.remove("persona-hidden");
|
|
2186
2520
|
artifactPaneApi.setMobileOpen(true);
|
|
2521
|
+
} else {
|
|
2522
|
+
// The pane's own update() unhides itself whenever records exist
|
|
2523
|
+
// (applyLayoutVisibility), so re-assert the gate here: card/inline-mode
|
|
2524
|
+
// artifacts keep the pane hidden until an explicit open.
|
|
2525
|
+
artifactPaneApi.setMobileOpen(false);
|
|
2526
|
+
artifactPaneApi.element.classList.add("persona-hidden");
|
|
2527
|
+
artifactPaneApi.backdrop?.classList.add("persona-hidden");
|
|
2528
|
+
artifactPaneExpanded = false;
|
|
2529
|
+
artifactPaneExpandedPinned = false;
|
|
2530
|
+
}
|
|
2531
|
+
// Re-read the toggle config on every sync so a live config.update() can
|
|
2532
|
+
// reveal/remove the button (the pane itself is built once). Disabling the
|
|
2533
|
+
// toggle while expanded also collapses the pane — unless the expansion is
|
|
2534
|
+
// pinned (inline Expand): that fullscreen request stands on its own and
|
|
2535
|
+
// exits via Close.
|
|
2536
|
+
const expandToggleEnabled = config.features?.artifacts?.layout?.showExpandToggle === true;
|
|
2537
|
+
artifactPaneApi.setExpandToggleVisible(expandToggleEnabled);
|
|
2538
|
+
artifactPaneApi.setCopyButtonVisible(
|
|
2539
|
+
config.features?.artifacts?.layout?.showCopyButton === true
|
|
2540
|
+
);
|
|
2541
|
+
artifactPaneApi.setCustomActions(config.features?.artifacts?.toolbarActions ?? []);
|
|
2542
|
+
artifactPaneApi.setTabFade(config.features?.artifacts?.layout?.tabFade);
|
|
2543
|
+
artifactPaneApi.setRenderTabBar(config.features?.artifacts?.renderTabBar);
|
|
2544
|
+
if (!expandToggleEnabled && !artifactPaneExpandedPinned) artifactPaneExpanded = false;
|
|
2545
|
+
// Run the resizer stash/restore once per expanded-state transition: the
|
|
2546
|
+
// resizer's inline width/maxWidth beats the expanded class, so clear it while
|
|
2547
|
+
// expanded and put it back on collapse.
|
|
2548
|
+
if (artifactPaneExpanded !== artifactExpandedApplied) {
|
|
2549
|
+
const paneEl = artifactPaneApi.element;
|
|
2550
|
+
if (artifactPaneExpanded) {
|
|
2551
|
+
artifactStashedWidth = paneEl.style.width;
|
|
2552
|
+
artifactStashedMaxWidth = paneEl.style.maxWidth;
|
|
2553
|
+
paneEl.style.removeProperty("width");
|
|
2554
|
+
paneEl.style.removeProperty("max-width");
|
|
2555
|
+
} else {
|
|
2556
|
+
if (artifactStashedWidth) paneEl.style.width = artifactStashedWidth;
|
|
2557
|
+
if (artifactStashedMaxWidth) paneEl.style.maxWidth = artifactStashedMaxWidth;
|
|
2558
|
+
artifactStashedWidth = "";
|
|
2559
|
+
artifactStashedMaxWidth = "";
|
|
2560
|
+
}
|
|
2561
|
+
artifactExpandedApplied = artifactPaneExpanded;
|
|
2562
|
+
}
|
|
2563
|
+
mount.classList.toggle("persona-artifact-expanded", artifactPaneExpanded);
|
|
2564
|
+
artifactPaneApi.setExpanded(artifactPaneExpanded);
|
|
2565
|
+
// Recompute panel chrome when the split mode changes (pane open/close or
|
|
2566
|
+
// appearance) so the welded border and detached shadow suppression land.
|
|
2567
|
+
const modeNow = splitChromeMode();
|
|
2568
|
+
if (modeNow !== appliedSplitMode) {
|
|
2569
|
+
appliedSplitMode = modeNow;
|
|
2570
|
+
syncPanelChrome();
|
|
2187
2571
|
}
|
|
2188
2572
|
reconcileArtifactResize();
|
|
2189
2573
|
};
|
|
@@ -2204,6 +2588,22 @@ export const createAgentExperience = (
|
|
|
2204
2588
|
onDismiss: () => {
|
|
2205
2589
|
artifactsPaneUserHidden = true;
|
|
2206
2590
|
syncArtifactPane();
|
|
2591
|
+
},
|
|
2592
|
+
onToggleExpand: () => {
|
|
2593
|
+
const next = !artifactPaneExpanded;
|
|
2594
|
+
const artifactId =
|
|
2595
|
+
lastArtifactsState.selectedId ??
|
|
2596
|
+
lastArtifactsState.artifacts[lastArtifactsState.artifacts.length - 1]?.id ??
|
|
2597
|
+
null;
|
|
2598
|
+
const prevented = config.features?.artifacts?.onArtifactAction?.({
|
|
2599
|
+
type: "expand",
|
|
2600
|
+
artifactId,
|
|
2601
|
+
expanded: next,
|
|
2602
|
+
});
|
|
2603
|
+
if (prevented === true) return;
|
|
2604
|
+
artifactPaneExpanded = next;
|
|
2605
|
+
if (!next) artifactPaneExpandedPinned = false;
|
|
2606
|
+
syncArtifactPane();
|
|
2207
2607
|
}
|
|
2208
2608
|
});
|
|
2209
2609
|
artifactPaneApi.element.classList.add("persona-hidden");
|
|
@@ -2246,6 +2646,7 @@ export const createAgentExperience = (
|
|
|
2246
2646
|
const onPointerDown = (e: PointerEvent) => {
|
|
2247
2647
|
if (!artifactPaneApi || e.button !== 0) return;
|
|
2248
2648
|
if (mount.classList.contains("persona-artifact-narrow-host")) return;
|
|
2649
|
+
if (mount.classList.contains("persona-artifact-expanded")) return;
|
|
2249
2650
|
if (win.innerWidth <= 640) return;
|
|
2250
2651
|
e.preventDefault();
|
|
2251
2652
|
stopArtifactResizePointer();
|
|
@@ -2254,9 +2655,13 @@ export const createAgentExperience = (
|
|
|
2254
2655
|
const layout = config.features?.artifacts?.layout;
|
|
2255
2656
|
const onMove = (ev: PointerEvent) => {
|
|
2256
2657
|
const splitW = artifactSplitRoot!.getBoundingClientRect().width;
|
|
2257
|
-
const
|
|
2258
|
-
|
|
2259
|
-
|
|
2658
|
+
const weldedChrome = mount.classList.contains("persona-artifact-welded-split");
|
|
2659
|
+
// Read the effective flex gap either way: welded defaults to 0 but an
|
|
2660
|
+
// explicit splitGap opens a real gap that still consumes row width.
|
|
2661
|
+
const gapPx = readFlexGapPx(artifactSplitRoot!, win);
|
|
2662
|
+
// Welded overlays the handle on the seam (out of flow), so it consumes
|
|
2663
|
+
// no width; detached keeps it as an in-flow flex child.
|
|
2664
|
+
const handleW = weldedChrome ? 0 : handle.getBoundingClientRect().width || 6;
|
|
2260
2665
|
// Handle is left of the artifact: drag left widens artifact, drag right narrows it.
|
|
2261
2666
|
const next = startW - (ev.clientX - startX);
|
|
2262
2667
|
const clamped = resolveArtifactPaneWidthPx(
|
|
@@ -2301,8 +2706,7 @@ export const createAgentExperience = (
|
|
|
2301
2706
|
};
|
|
2302
2707
|
}
|
|
2303
2708
|
if (artifactResizeHandle) {
|
|
2304
|
-
const has =
|
|
2305
|
-
lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
|
|
2709
|
+
const has = artifactPaneVisible();
|
|
2306
2710
|
artifactResizeHandle.classList.toggle("persona-hidden", !has);
|
|
2307
2711
|
positionExtensionArtifactResizeHandle();
|
|
2308
2712
|
}
|
|
@@ -2323,8 +2727,7 @@ export const createAgentExperience = (
|
|
|
2323
2727
|
const expanded =
|
|
2324
2728
|
config.features?.artifacts?.layout?.expandedPanelWidth ??
|
|
2325
2729
|
"min(720px, calc(100vw - 24px))";
|
|
2326
|
-
const hasVisible =
|
|
2327
|
-
lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
|
|
2730
|
+
const hasVisible = artifactPaneVisible();
|
|
2328
2731
|
if (hasVisible) {
|
|
2329
2732
|
panel.style.width = expanded;
|
|
2330
2733
|
panel.style.maxWidth = expanded;
|
|
@@ -2342,21 +2745,23 @@ export const createAgentExperience = (
|
|
|
2342
2745
|
}
|
|
2343
2746
|
} else {
|
|
2344
2747
|
panel.appendChild(container);
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// Composer-bar mode: the pill (footer) and peek banner live in a
|
|
2751
|
+
// viewport-fixed sibling of the wrapper (`pillRoot`) so they're
|
|
2752
|
+
// independent of both the wrapper's geometry transitions and the panel's
|
|
2753
|
+
// optional artifact split layout. Critical for modal mode: the wrapper
|
|
2754
|
+
// there has `transform: translate(-50%, -50%)`, which would establish a
|
|
2755
|
+
// containing block trapping any `position: fixed` descendant.
|
|
2756
|
+
//
|
|
2757
|
+
// Order inside pillRoot: peekBanner (slim row above pill) → footer (pill).
|
|
2758
|
+
// pillRoot's `gap` spaces them; the peek is hidden by default until ui.ts
|
|
2759
|
+
// toggles `.persona-pill-peek--visible` based on streaming/hover/open state.
|
|
2760
|
+
if (isComposerBar() && pillRoot) {
|
|
2761
|
+
if (panelElements.peekBanner) {
|
|
2762
|
+
pillRoot.appendChild(panelElements.peekBanner);
|
|
2763
|
+
}
|
|
2764
|
+
pillRoot.appendChild(footer);
|
|
2360
2765
|
}
|
|
2361
2766
|
mount.appendChild(wrapper);
|
|
2362
2767
|
// pillRoot is mounted *after* wrapper so it naturally stacks on top
|
|
@@ -2415,6 +2820,8 @@ export const createAgentExperience = (
|
|
|
2415
2820
|
const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
2416
2821
|
/** Script-tag / div embed: launcher off, host supplies a sized mount. */
|
|
2417
2822
|
const isInlineEmbed = config.launcher?.enabled === false;
|
|
2823
|
+
/** Detached appearance: inset card over a canvas instead of flush chrome. */
|
|
2824
|
+
const isDetached = config.launcher?.detachedPanel === true;
|
|
2418
2825
|
const panelPartial = config.theme?.components?.panel;
|
|
2419
2826
|
const activeTheme = getActiveTheme(config);
|
|
2420
2827
|
const resolvePanelChrome = (raw: string | undefined, fallback: string): string => {
|
|
@@ -2429,33 +2836,105 @@ export const createAgentExperience = (
|
|
|
2429
2836
|
const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
|
|
2430
2837
|
const isMobileViewport = ownerWindow.innerWidth <= mobileBreakpoint;
|
|
2431
2838
|
const shouldGoFullscreen = mobileFullscreen && isMobileViewport && launcherEnabled;
|
|
2839
|
+
// Docked host layout forces fullscreen on mobile even when the launcher is
|
|
2840
|
+
// off (host-layout pins the slot fixed and clears its chrome). Suppress the
|
|
2841
|
+
// detached card so it never paints chrome over a flush fullscreen panel.
|
|
2842
|
+
const dockedHostFullscreen = dockedMode && mobileFullscreen && isMobileViewport;
|
|
2432
2843
|
|
|
2433
2844
|
// Determine panel styling based on mode, with theme overrides
|
|
2434
2845
|
const position = config.launcher?.position ?? 'bottom-left';
|
|
2435
2846
|
const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
|
|
2436
2847
|
const overlayZIndex = config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX;
|
|
2437
2848
|
|
|
2849
|
+
// Card chrome defaults (floating look): reused to restore detached chrome.
|
|
2850
|
+
// Defaults chain through the aliases themeToCssVariables emits so explicit
|
|
2851
|
+
// theme.components.panel overrides and these defaults never diverge.
|
|
2852
|
+
const cardBorder = 'var(--persona-panel-border, 1px solid var(--persona-border))';
|
|
2853
|
+
const cardShadow = 'var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))';
|
|
2854
|
+
const cardRadius = 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
|
|
2855
|
+
/** Detached restores card chrome except when a host layout goes fullscreen. */
|
|
2856
|
+
const detachedCard = isDetached && !shouldGoFullscreen && !dockedHostFullscreen;
|
|
2857
|
+
// Stamp reflects rendered chrome: cleared when a fullscreen host layout
|
|
2858
|
+
// suppresses the card, so the attribute never lies to the detached CSS.
|
|
2859
|
+
if (detachedCard) {
|
|
2860
|
+
mount.setAttribute("data-persona-panel-detached", "true");
|
|
2861
|
+
} else {
|
|
2862
|
+
mount.removeAttribute("data-persona-panel-detached");
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2438
2865
|
// Default values based on mode
|
|
2439
|
-
let defaultPanelBorder =
|
|
2440
|
-
|
|
2441
|
-
? 'none'
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2866
|
+
let defaultPanelBorder = detachedCard
|
|
2867
|
+
? cardBorder
|
|
2868
|
+
: (sidebarMode || shouldGoFullscreen) ? 'none' : cardBorder;
|
|
2869
|
+
let defaultPanelShadow = detachedCard
|
|
2870
|
+
? cardShadow
|
|
2871
|
+
: shouldGoFullscreen
|
|
2872
|
+
? 'none'
|
|
2873
|
+
: sidebarMode
|
|
2874
|
+
? (isLeftSidebar ? 'var(--persona-palette-shadows-sidebar-left, 2px 0 12px rgba(0, 0, 0, 0.08))' : 'var(--persona-palette-shadows-sidebar-right, -2px 0 12px rgba(0, 0, 0, 0.08))')
|
|
2875
|
+
// Flush inline embeds fill their container: no elevation by default
|
|
2876
|
+
// (detachedPanel or components.panel.shadow opts back in).
|
|
2877
|
+
: isInlineEmbed ? 'none' : cardShadow;
|
|
2878
|
+
|
|
2879
|
+
if (dockedMode && !shouldGoFullscreen && !detachedCard) {
|
|
2447
2880
|
defaultPanelShadow = 'none';
|
|
2448
2881
|
defaultPanelBorder = 'none';
|
|
2449
2882
|
}
|
|
2450
|
-
const defaultPanelBorderRadius =
|
|
2451
|
-
?
|
|
2452
|
-
:
|
|
2883
|
+
const defaultPanelBorderRadius = detachedCard
|
|
2884
|
+
? cardRadius
|
|
2885
|
+
: (sidebarMode || shouldGoFullscreen) ? '0' : cardRadius;
|
|
2453
2886
|
|
|
2454
2887
|
// Apply theme overrides or defaults (components.panel.*)
|
|
2455
2888
|
const panelBorder = resolvePanelChrome(panelPartial?.border, defaultPanelBorder);
|
|
2456
2889
|
const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
|
|
2457
2890
|
const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
|
|
2458
2891
|
|
|
2892
|
+
// Split chrome: 'welded' folds the card border onto the outer panel so it
|
|
2893
|
+
// wraps both columns as one card (shadow/radius already on the panel);
|
|
2894
|
+
// 'detached' pulls them into two individually carded columns.
|
|
2895
|
+
const splitMode = splitChromeMode();
|
|
2896
|
+
const detachedSplitActive = splitMode === "detached";
|
|
2897
|
+
const weldedSplitActive = splitMode === "welded";
|
|
2898
|
+
mount.classList.toggle("persona-artifact-detached-split", detachedSplitActive);
|
|
2899
|
+
mount.classList.toggle("persona-artifact-welded-split", weldedSplitActive);
|
|
2900
|
+
const chatCardShadow =
|
|
2901
|
+
'var(--persona-artifact-chat-shadow, var(--persona-artifact-pane-shadow, var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))))';
|
|
2902
|
+
// Welded moves the chat card border onto the panel, so the chat column goes
|
|
2903
|
+
// borderless; detached cards it individually; otherwise it keeps the border.
|
|
2904
|
+
// 'flush' drops the chat card so the chat is flat flush background and only
|
|
2905
|
+
// the pane floats; 'card' (default) keeps two matched cards. Flush is a
|
|
2906
|
+
// steady state: it applies whether or not a pane is open, so opening or
|
|
2907
|
+
// closing an artifact never flips the chat chrome. Gated to inline embeds
|
|
2908
|
+
// (not docked), matching the card-mode perimeter inset: the flush model only
|
|
2909
|
+
// fits the container-filling embed. Floating/docked/sidebar keep the card look.
|
|
2910
|
+
const rawChatSurface = config.features?.artifacts?.layout?.chatSurface;
|
|
2911
|
+
const resolvedChatSurface = rawChatSurface === "flush" ? "flush" : "card";
|
|
2912
|
+
const chatFlush =
|
|
2913
|
+
resolvedChatSurface === "flush" &&
|
|
2914
|
+
artifactsSidebarEnabled(config) &&
|
|
2915
|
+
isArtifactPaneAppearanceDetached(config) &&
|
|
2916
|
+
isInlineEmbed &&
|
|
2917
|
+
!dockedMode;
|
|
2918
|
+
mount.classList.toggle("persona-artifact-chat-flush", chatFlush);
|
|
2919
|
+
// Flat means flat: flush suppresses the outer panel shadow in every state.
|
|
2920
|
+
const outerPanelShadow = detachedSplitActive || chatFlush ? 'none' : panelShadow;
|
|
2921
|
+
let chatContainerBorder = detachedSplitActive
|
|
2922
|
+
? cardBorder
|
|
2923
|
+
: weldedSplitActive
|
|
2924
|
+
? 'none'
|
|
2925
|
+
: panelBorder;
|
|
2926
|
+
let chatContainerRadius = detachedSplitActive ? cardRadius : panelBorderRadius;
|
|
2927
|
+
if (chatFlush) {
|
|
2928
|
+
chatContainerBorder = 'none';
|
|
2929
|
+
chatContainerRadius = '0';
|
|
2930
|
+
}
|
|
2931
|
+
// Flush fills the container flush, so the outer panel squares off by default;
|
|
2932
|
+
// the pane keeps its own rounded radius. An explicit panel.borderRadius wins.
|
|
2933
|
+
const panelRadiusExplicit =
|
|
2934
|
+
panelPartial?.borderRadius != null && panelPartial.borderRadius !== '';
|
|
2935
|
+
const appliedPanelRadius =
|
|
2936
|
+
chatFlush && !panelRadiusExplicit ? '0' : panelBorderRadius;
|
|
2937
|
+
|
|
2459
2938
|
// Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
|
|
2460
2939
|
// `min-height: 0` / `overflow-y: auto` that make the messages area a
|
|
2461
2940
|
// scroll container. Between the reset and the mode-specific reapply,
|
|
@@ -2575,7 +3054,7 @@ export const createAgentExperience = (
|
|
|
2575
3054
|
}
|
|
2576
3055
|
} else if (dockedMode) {
|
|
2577
3056
|
const dockReveal = resolveDockConfig(config).reveal;
|
|
2578
|
-
if (dockReveal === "emerge") {
|
|
3057
|
+
if (dockReveal === "emerge" && !isDetached) {
|
|
2579
3058
|
const dw = resolveDockConfig(config).width;
|
|
2580
3059
|
panel.style.width = dw;
|
|
2581
3060
|
panel.style.maxWidth = dw;
|
|
@@ -2590,12 +3069,38 @@ export const createAgentExperience = (
|
|
|
2590
3069
|
// Box-shadow is applied to panel (parent) instead of container to avoid
|
|
2591
3070
|
// rendering artifacts when container has overflow:hidden + border-radius
|
|
2592
3071
|
// Panel also gets border-radius to make the shadow follow the rounded corners
|
|
2593
|
-
panel.style.boxShadow =
|
|
2594
|
-
panel.style.borderRadius =
|
|
2595
|
-
|
|
2596
|
-
|
|
3072
|
+
panel.style.boxShadow = outerPanelShadow;
|
|
3073
|
+
panel.style.borderRadius = appliedPanelRadius;
|
|
3074
|
+
if (detachedSplitActive) panel.style.border = 'none';
|
|
3075
|
+
else if (weldedSplitActive) panel.style.border = panelBorder;
|
|
3076
|
+
container.style.border = chatContainerBorder;
|
|
3077
|
+
container.style.borderRadius = chatContainerRadius;
|
|
3078
|
+
container.style.boxShadow = detachedSplitActive && !chatFlush ? chatCardShadow : '';
|
|
3079
|
+
// Flush chat has no card surface of its own: override the class-painted
|
|
3080
|
+
// container/body/footer backgrounds so the wrapper's canvas shows through.
|
|
3081
|
+
// The footer's top hairline is chat-card chrome, so it goes too. Element
|
|
3082
|
+
// surfaces (bubbles, cards, composer input) keep their own backgrounds.
|
|
3083
|
+
if (chatFlush) {
|
|
3084
|
+
container.style.background = 'transparent';
|
|
3085
|
+
body.style.background = 'transparent';
|
|
3086
|
+
footer.style.background = 'transparent';
|
|
3087
|
+
footer.style.borderTop = 'none';
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// Welded outer-right radius: derive the pane's outer corners from the same
|
|
3091
|
+
// resolved value as the chat card's left corners so a custom
|
|
3092
|
+
// components.panel.borderRadius stays symmetric. Explicit
|
|
3093
|
+
// unifiedSplitOuterRadius / paneBorderRadius still win. Applied in
|
|
3094
|
+
// syncPanelChrome after applyArtifactPaneAppearance (which no longer owns it).
|
|
3095
|
+
if (weldedSplitActive) {
|
|
3096
|
+
const wl = config.features?.artifacts?.layout;
|
|
3097
|
+
weldedOuterRadius =
|
|
3098
|
+
wl?.unifiedSplitOuterRadius?.trim() || wl?.paneBorderRadius?.trim() || panelBorderRadius;
|
|
3099
|
+
} else {
|
|
3100
|
+
weldedOuterRadius = null;
|
|
3101
|
+
}
|
|
2597
3102
|
|
|
2598
|
-
if (dockedMode && !shouldGoFullscreen && panelPartial?.border === undefined) {
|
|
3103
|
+
if (dockedMode && !shouldGoFullscreen && !detachedCard && !detachedSplitActive && !weldedSplitActive && panelPartial?.border === undefined) {
|
|
2599
3104
|
container.style.border = 'none';
|
|
2600
3105
|
const dockSide = resolveDockConfig(config).side;
|
|
2601
3106
|
if (dockSide === 'right') {
|
|
@@ -2605,6 +3110,19 @@ export const createAgentExperience = (
|
|
|
2605
3110
|
}
|
|
2606
3111
|
}
|
|
2607
3112
|
|
|
3113
|
+
// Docked + welded: the welded chrome owner is the outer panel, and docked
|
|
3114
|
+
// mode resolves its border to none, so add the dock-facing hairline there so
|
|
3115
|
+
// the split still separates from the host page. Mirrors the flush block's
|
|
3116
|
+
// side choice (right dock => left edge faces the page).
|
|
3117
|
+
if (dockedMode && !shouldGoFullscreen && weldedSplitActive && panelPartial?.border === undefined) {
|
|
3118
|
+
const dockSide = resolveDockConfig(config).side;
|
|
3119
|
+
if (dockSide === 'right') {
|
|
3120
|
+
panel.style.borderLeft = '1px solid var(--persona-border)';
|
|
3121
|
+
} else {
|
|
3122
|
+
panel.style.borderRight = '1px solid var(--persona-border)';
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
2608
3126
|
if (fullHeight) {
|
|
2609
3127
|
// Mount container
|
|
2610
3128
|
mount.style.display = 'flex';
|
|
@@ -2635,8 +3153,10 @@ export const createAgentExperience = (
|
|
|
2635
3153
|
panel.style.minHeight = '0';
|
|
2636
3154
|
panel.style.maxHeight = '100%';
|
|
2637
3155
|
panel.style.height = '100%';
|
|
2638
|
-
|
|
2639
|
-
|
|
3156
|
+
// Detached split: each card clips its own content and carries its own
|
|
3157
|
+
// shadow, so panel overflow:hidden must not clip the cards' shadows.
|
|
3158
|
+
if (!detachedSplitActive) panel.style.overflow = 'hidden';
|
|
3159
|
+
|
|
2640
3160
|
// Main container
|
|
2641
3161
|
container.style.display = 'flex';
|
|
2642
3162
|
container.style.flexDirection = 'column';
|
|
@@ -2653,7 +3173,19 @@ export const createAgentExperience = (
|
|
|
2653
3173
|
// Footer (composer) - should not shrink
|
|
2654
3174
|
footer.style.flexShrink = '0';
|
|
2655
3175
|
}
|
|
2656
|
-
|
|
3176
|
+
|
|
3177
|
+
// Inline embed: pad the wrapper so the canvas shows around the card when the
|
|
3178
|
+
// panel is a detached card OR a detached split is open (insets the whole split
|
|
3179
|
+
// from the container edges on all sides). Docked owns its inset via
|
|
3180
|
+
// host-layout, so exclude it (no double gap).
|
|
3181
|
+
// Flush chat must be flush to the container, so it skips the perimeter inset;
|
|
3182
|
+
// the wrapper still paints the canvas so one token colors the whole backdrop,
|
|
3183
|
+
// in every state (flush is steady, so idle must match pane-open).
|
|
3184
|
+
if (isInlineEmbed && (detachedCard || detachedSplitActive || chatFlush) && !dockedMode) {
|
|
3185
|
+
if (!chatFlush) wrapper.style.padding = 'var(--persona-panel-inset)';
|
|
3186
|
+
wrapper.style.background = 'var(--persona-panel-canvas-bg)';
|
|
3187
|
+
}
|
|
3188
|
+
|
|
2657
3189
|
// Handle positioning classes based on mode
|
|
2658
3190
|
// First remove all position classes to reset state
|
|
2659
3191
|
wrapper.classList.remove(
|
|
@@ -2670,22 +3202,40 @@ export const createAgentExperience = (
|
|
|
2670
3202
|
// Apply sidebar-specific styles
|
|
2671
3203
|
if (sidebarMode) {
|
|
2672
3204
|
const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
|
|
2673
|
-
|
|
2674
|
-
// Wrapper - fixed position
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
3205
|
+
|
|
3206
|
+
// Wrapper - fixed position. Detached insets the card off the edges and
|
|
3207
|
+
// shrinks its height by the inset on both ends; flush hugs the edges.
|
|
3208
|
+
if (isDetached) {
|
|
3209
|
+
wrapper.style.cssText = `
|
|
3210
|
+
position: fixed !important;
|
|
3211
|
+
top: var(--persona-panel-inset) !important;
|
|
3212
|
+
bottom: var(--persona-panel-inset) !important;
|
|
3213
|
+
width: ${sidebarWidth} !important;
|
|
3214
|
+
height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
|
|
3215
|
+
max-height: calc(100vh - (2 * var(--persona-panel-inset))) !important;
|
|
3216
|
+
margin: 0 !important;
|
|
3217
|
+
padding: 0 !important;
|
|
3218
|
+
display: flex !important;
|
|
3219
|
+
flex-direction: column !important;
|
|
3220
|
+
z-index: ${overlayZIndex} !important;
|
|
3221
|
+
${isLeftSidebar ? 'left: var(--persona-panel-inset) !important; right: auto !important;' : 'left: auto !important; right: var(--persona-panel-inset) !important;'}
|
|
3222
|
+
`;
|
|
3223
|
+
} else {
|
|
3224
|
+
wrapper.style.cssText = `
|
|
3225
|
+
position: fixed !important;
|
|
3226
|
+
top: 0 !important;
|
|
3227
|
+
bottom: 0 !important;
|
|
3228
|
+
width: ${sidebarWidth} !important;
|
|
3229
|
+
height: 100vh !important;
|
|
3230
|
+
max-height: 100vh !important;
|
|
3231
|
+
margin: 0 !important;
|
|
3232
|
+
padding: 0 !important;
|
|
3233
|
+
display: flex !important;
|
|
3234
|
+
flex-direction: column !important;
|
|
3235
|
+
z-index: ${overlayZIndex} !important;
|
|
3236
|
+
${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
|
|
3237
|
+
`;
|
|
3238
|
+
}
|
|
2689
3239
|
|
|
2690
3240
|
// Panel - fill wrapper (override inline width/max-width from panel.ts)
|
|
2691
3241
|
// Box-shadow is on panel to avoid rendering artifacts with container's overflow:hidden
|
|
@@ -2701,15 +3251,16 @@ export const createAgentExperience = (
|
|
|
2701
3251
|
min-height: 0 !important;
|
|
2702
3252
|
margin: 0 !important;
|
|
2703
3253
|
padding: 0 !important;
|
|
2704
|
-
box-shadow: ${
|
|
2705
|
-
border-radius: ${
|
|
3254
|
+
box-shadow: ${outerPanelShadow} !important;
|
|
3255
|
+
border-radius: ${appliedPanelRadius} !important;
|
|
3256
|
+
${detachedSplitActive ? 'border: none !important;' : weldedSplitActive ? `border: ${panelBorder} !important;` : ''}
|
|
2706
3257
|
`;
|
|
2707
3258
|
// Force override any inline width/maxWidth that may be set elsewhere
|
|
2708
3259
|
panel.style.setProperty('width', '100%', 'important');
|
|
2709
3260
|
panel.style.setProperty('max-width', '100%', 'important');
|
|
2710
|
-
|
|
2711
|
-
// Container - apply configurable styles with sidebar layout
|
|
2712
|
-
//
|
|
3261
|
+
|
|
3262
|
+
// Container - apply configurable styles with sidebar layout. Box-shadow is
|
|
3263
|
+
// normally on panel, but a detached split cards the chat column here.
|
|
2713
3264
|
container.style.cssText = `
|
|
2714
3265
|
display: flex !important;
|
|
2715
3266
|
flex-direction: column !important;
|
|
@@ -2719,8 +3270,10 @@ export const createAgentExperience = (
|
|
|
2719
3270
|
min-height: 0 !important;
|
|
2720
3271
|
max-height: 100% !important;
|
|
2721
3272
|
overflow: hidden !important;
|
|
2722
|
-
border-radius: ${
|
|
2723
|
-
border: ${
|
|
3273
|
+
border-radius: ${chatContainerRadius} !important;
|
|
3274
|
+
border: ${chatContainerBorder} !important;
|
|
3275
|
+
${detachedSplitActive && !chatFlush ? `box-shadow: ${chatCardShadow} !important;` : ''}
|
|
3276
|
+
${chatFlush ? 'background: transparent !important;' : ''}
|
|
2724
3277
|
`;
|
|
2725
3278
|
|
|
2726
3279
|
// Remove footer border in sidebar mode
|
|
@@ -2728,6 +3281,7 @@ export const createAgentExperience = (
|
|
|
2728
3281
|
flex-shrink: 0 !important;
|
|
2729
3282
|
border-top: none !important;
|
|
2730
3283
|
padding: 8px 16px 12px 16px !important;
|
|
3284
|
+
${chatFlush ? 'background: transparent !important;' : ''}
|
|
2731
3285
|
`;
|
|
2732
3286
|
}
|
|
2733
3287
|
|
|
@@ -2752,6 +3306,22 @@ export const createAgentExperience = (
|
|
|
2752
3306
|
applyArtifactLayoutCssVars(mount, config);
|
|
2753
3307
|
applyArtifactPaneAppearance(mount, config);
|
|
2754
3308
|
|
|
3309
|
+
// applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
|
|
3310
|
+
// artifact layout vars after it, mirroring the init sequence above.
|
|
3311
|
+
syncPanelChrome = () => {
|
|
3312
|
+
applyFullHeightStyles();
|
|
3313
|
+
applyThemeVariables(mount, config);
|
|
3314
|
+
applyArtifactLayoutCssVars(mount, config);
|
|
3315
|
+
applyArtifactPaneAppearance(mount, config);
|
|
3316
|
+
// Owned here so it lands after applyArtifactPaneAppearance and derives from
|
|
3317
|
+
// the same resolved panel radius the chat card uses (see applyFullHeightStyles).
|
|
3318
|
+
if (weldedOuterRadius) {
|
|
3319
|
+
mount.style.setProperty("--persona-artifact-welded-outer-radius", weldedOuterRadius);
|
|
3320
|
+
} else {
|
|
3321
|
+
mount.style.removeProperty("--persona-artifact-welded-outer-radius");
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
|
|
2755
3325
|
const destroyCallbacks: Array<() => void> = [];
|
|
2756
3326
|
// Clean up the document-level digit-key shortcut listener registered earlier.
|
|
2757
3327
|
destroyCallbacks.push(() => {
|
|
@@ -4001,15 +4571,57 @@ export const createAgentExperience = (
|
|
|
4001
4571
|
if (directive) {
|
|
4002
4572
|
const lastFp = lastComponentDirectiveFingerprint.get(message.id);
|
|
4003
4573
|
const needsRebuild = lastFp !== fingerprint;
|
|
4004
|
-
|
|
4574
|
+
// Wrap only when the global default allows it AND the component has
|
|
4575
|
+
// not opted out of bubble chrome (e.g. the artifact card carries its
|
|
4576
|
+
// own border, so it renders bare).
|
|
4577
|
+
const wrapChrome =
|
|
4578
|
+
config.wrapComponentDirectiveInBubble !== false &&
|
|
4579
|
+
componentRegistry.getOptions(directive.component)?.bubbleChrome !== false;
|
|
4005
4580
|
let liveBubble: HTMLElement | null = null;
|
|
4006
4581
|
|
|
4007
4582
|
if (needsRebuild) {
|
|
4008
|
-
|
|
4583
|
+
let componentBubble = renderComponentDirective(directive, {
|
|
4009
4584
|
config,
|
|
4010
4585
|
message,
|
|
4011
4586
|
transform
|
|
4012
4587
|
});
|
|
4588
|
+
// Reuse a mounted inline artifact block instead of the fresh
|
|
4589
|
+
// render: replacing it would cut the collapse-to-card animation
|
|
4590
|
+
// short and reload live file-preview iframes.
|
|
4591
|
+
if (
|
|
4592
|
+
componentBubble &&
|
|
4593
|
+
directive.component === "PersonaArtifactInline"
|
|
4594
|
+
) {
|
|
4595
|
+
const fresh = componentBubble.hasAttribute(
|
|
4596
|
+
"data-artifact-inline"
|
|
4597
|
+
)
|
|
4598
|
+
? componentBubble
|
|
4599
|
+
: componentBubble.querySelector<HTMLElement>(
|
|
4600
|
+
"[data-artifact-inline]"
|
|
4601
|
+
);
|
|
4602
|
+
const artifactId =
|
|
4603
|
+
fresh?.getAttribute("data-artifact-inline") ?? "";
|
|
4604
|
+
const escapedId =
|
|
4605
|
+
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
4606
|
+
? CSS.escape(artifactId)
|
|
4607
|
+
: artifactId;
|
|
4608
|
+
const live = artifactId
|
|
4609
|
+
? (container
|
|
4610
|
+
.querySelector<HTMLElement>(`#wrapper-${message.id}`)
|
|
4611
|
+
?.querySelector<HTMLElement>(
|
|
4612
|
+
`[data-artifact-inline="${escapedId}"]`
|
|
4613
|
+
) ?? null)
|
|
4614
|
+
: null;
|
|
4615
|
+
if (
|
|
4616
|
+
fresh &&
|
|
4617
|
+
live &&
|
|
4618
|
+
live !== fresh &&
|
|
4619
|
+
hasLiveInlineArtifactBlock(live)
|
|
4620
|
+
) {
|
|
4621
|
+
if (fresh === componentBubble) componentBubble = live;
|
|
4622
|
+
else fresh.replaceWith(live);
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4013
4625
|
if (componentBubble) {
|
|
4014
4626
|
if (wrapChrome) {
|
|
4015
4627
|
const componentWrapper = document.createElement("div");
|
|
@@ -4175,6 +4787,12 @@ export const createAgentExperience = (
|
|
|
4175
4787
|
currentGroup.push(message);
|
|
4176
4788
|
return;
|
|
4177
4789
|
}
|
|
4790
|
+
// A hidden reasoning row does not create a visible break in the
|
|
4791
|
+
// transcript, so it should not split an otherwise contiguous tool
|
|
4792
|
+
// sequence into separate groups.
|
|
4793
|
+
if (message.variant === "reasoning" && !showReasoning) {
|
|
4794
|
+
return;
|
|
4795
|
+
}
|
|
4178
4796
|
if (currentGroup.length > 1) {
|
|
4179
4797
|
toolGroups.push(currentGroup);
|
|
4180
4798
|
}
|
|
@@ -4229,11 +4847,19 @@ export const createAgentExperience = (
|
|
|
4229
4847
|
const stack = document.createElement("div");
|
|
4230
4848
|
stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
|
|
4231
4849
|
|
|
4232
|
-
|
|
4850
|
+
const summaryOnly = config.features?.toolCallDisplay?.groupedMode === "summary";
|
|
4851
|
+
groupContainer.appendChild(summary);
|
|
4852
|
+
if (!summaryOnly) {
|
|
4853
|
+
groupContainer.appendChild(stack);
|
|
4854
|
+
}
|
|
4233
4855
|
groupWrapper.appendChild(groupContainer);
|
|
4234
4856
|
wrappers[0].before(groupWrapper);
|
|
4235
4857
|
|
|
4236
4858
|
wrappers.forEach((wrapper, wrapperIndex) => {
|
|
4859
|
+
if (summaryOnly) {
|
|
4860
|
+
wrapper.remove();
|
|
4861
|
+
return;
|
|
4862
|
+
}
|
|
4237
4863
|
const item = document.createElement("div");
|
|
4238
4864
|
item.className = "persona-tool-group-item persona-relative";
|
|
4239
4865
|
item.setAttribute("data-persona-tool-group-item", "true");
|
|
@@ -5297,6 +5923,12 @@ export const createAgentExperience = (
|
|
|
5297
5923
|
if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
|
|
5298
5924
|
}
|
|
5299
5925
|
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
5926
|
+
// Freshly (re)built inline artifact blocks render from their persisted
|
|
5927
|
+
// props; sync them with the live registry so a block created after the
|
|
5928
|
+
// last onArtifactsState emission still shows current content.
|
|
5929
|
+
updateInlineArtifactBlocks(messagesWrapper, lastArtifactsState.artifacts, {
|
|
5930
|
+
suppressTransition: isStreaming,
|
|
5931
|
+
});
|
|
5300
5932
|
ensureToolElapsedTimer();
|
|
5301
5933
|
renderSuggestions(messages);
|
|
5302
5934
|
scheduleAutoScroll(!isStreaming);
|
|
@@ -5467,6 +6099,20 @@ export const createAgentExperience = (
|
|
|
5467
6099
|
},
|
|
5468
6100
|
onArtifactsState(state) {
|
|
5469
6101
|
lastArtifactsState = state;
|
|
6102
|
+
// A cleared registry ends any explicit-open override: the next artifact
|
|
6103
|
+
// decides pane visibility purely from its own display mode.
|
|
6104
|
+
if (state.artifacts.length === 0) {
|
|
6105
|
+
artifactsPaneUserOpened = false;
|
|
6106
|
+
}
|
|
6107
|
+
// Route streaming registry updates (artifact_delta / artifact_complete)
|
|
6108
|
+
// into any inline artifact blocks in the transcript. Suppress the
|
|
6109
|
+
// streaming→complete View Transition while the session is still
|
|
6110
|
+
// streaming: it captures the whole document, and cross-fading a stale
|
|
6111
|
+
// snapshot over still-moving message text reads as ghosting/motion blur
|
|
6112
|
+
// on the transcript.
|
|
6113
|
+
updateInlineArtifactBlocks(messagesWrapper, state.artifacts, {
|
|
6114
|
+
suppressTransition: isStreaming,
|
|
6115
|
+
});
|
|
5470
6116
|
syncArtifactPane();
|
|
5471
6117
|
persistState();
|
|
5472
6118
|
},
|
|
@@ -5618,26 +6264,93 @@ export const createAgentExperience = (
|
|
|
5618
6264
|
setOpenState(true, "auto");
|
|
5619
6265
|
};
|
|
5620
6266
|
|
|
5621
|
-
|
|
5622
|
-
|
|
6267
|
+
// Combine `@`-chip mentions with an inline server-command's context bundle so
|
|
6268
|
+
// both reach the message in one `mentions` payload. Either side may be null.
|
|
6269
|
+
type SubmitMentions = NonNullable<
|
|
6270
|
+
ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
|
|
6271
|
+
>;
|
|
6272
|
+
const mergeSubmitMentions = (
|
|
6273
|
+
a: SubmitMentions | null,
|
|
6274
|
+
b: SubmitMentions | null
|
|
6275
|
+
): SubmitMentions | null => {
|
|
6276
|
+
if (!a) return b;
|
|
6277
|
+
if (!b) return a;
|
|
6278
|
+
return {
|
|
6279
|
+
refs: [...a.refs, ...b.refs],
|
|
6280
|
+
// allSettled, not all: one side rejecting must not discard the other
|
|
6281
|
+
// side's already-resolved context (the bubble already echoed its chips).
|
|
6282
|
+
finalize: async () => {
|
|
6283
|
+
const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
|
|
6284
|
+
const fulfilled: MentionSubmitBundle[] = [];
|
|
6285
|
+
if (ra.status === "fulfilled") fulfilled.push(ra.value);
|
|
6286
|
+
else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
|
|
6287
|
+
if (rb.status === "fulfilled") fulfilled.push(rb.value);
|
|
6288
|
+
else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
|
|
6289
|
+
return mergeFinalizedMentions(fulfilled);
|
|
6290
|
+
},
|
|
6291
|
+
};
|
|
6292
|
+
};
|
|
5623
6293
|
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
6294
|
+
// Inline-mode composer element: after the contenteditable swap, `textarea`
|
|
6295
|
+
// additionally exposes `getInlineMessageFields()` (built in the inline chunk,
|
|
6296
|
+
// which owns the document model — the core never imports it). Structural shape
|
|
6297
|
+
// only, so no runtime import crosses the bundle boundary.
|
|
6298
|
+
type InlineComposerFieldsHost = {
|
|
6299
|
+
getInlineMessageFields?: () => {
|
|
6300
|
+
content: string;
|
|
6301
|
+
contextMentions: AgentWidgetContextMentionRef[];
|
|
6302
|
+
contentSegments: AgentWidgetContentSegment[];
|
|
6303
|
+
};
|
|
6304
|
+
};
|
|
6305
|
+
// Read the ordered display segments from the live inline composer, but only
|
|
6306
|
+
// when at least one mention is present: a plain-text inline message must keep
|
|
6307
|
+
// the normal markdown-rendered bubble (segments bypass that path), so we return
|
|
6308
|
+
// undefined for pure prose and let the bubble render `content` as usual.
|
|
6309
|
+
const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
|
|
6310
|
+
const host = textarea as unknown as InlineComposerFieldsHost;
|
|
6311
|
+
const fields = host.getInlineMessageFields?.();
|
|
6312
|
+
if (!fields) return undefined;
|
|
6313
|
+
const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
|
|
6314
|
+
return hasMention ? fields.contentSegments : undefined;
|
|
6315
|
+
};
|
|
5635
6316
|
|
|
6317
|
+
// Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
|
|
6318
|
+
// lazy chunk load + host resolve) before the composer clears, and
|
|
6319
|
+
// `isStreaming()` is still false in that window, so a second Enter would
|
|
6320
|
+
// otherwise dispatch the same text/command twice.
|
|
6321
|
+
let submitInFlight = false;
|
|
6322
|
+
|
|
6323
|
+
const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
|
|
5636
6324
|
const value = textarea.value.trim();
|
|
5637
6325
|
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
5638
6326
|
|
|
5639
|
-
//
|
|
5640
|
-
|
|
6327
|
+
// Inline slash command (Slack-style): every `command:"server"` plus any
|
|
6328
|
+
// arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
|
|
6329
|
+
// text to send, and an action sends nothing at all.
|
|
6330
|
+
const inline = value
|
|
6331
|
+
? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
|
|
6332
|
+
: null;
|
|
6333
|
+
|
|
6334
|
+
if (inline?.kind === "action") {
|
|
6335
|
+
// Ran in the browser; nothing to send. Clear the composer + any chips.
|
|
6336
|
+
textarea.value = "";
|
|
6337
|
+
textarea.style.height = "auto";
|
|
6338
|
+
resetHistoryNavigation();
|
|
6339
|
+
mentionOrchestrator?.clear();
|
|
6340
|
+
return;
|
|
6341
|
+
}
|
|
6342
|
+
|
|
6343
|
+
// Gather `@`-chip mentions synchronously (detaches chips + captures composer
|
|
6344
|
+
// text before clearing); `finalize()` resolves them inside `sendMessage`.
|
|
6345
|
+
const chipMentions = mentionOrchestrator?.collectForSubmit() ?? null;
|
|
6346
|
+
const serverMentions = inline?.kind === "server" ? inline.mentions : null;
|
|
6347
|
+
const mentions = mergeSubmitMentions(chipMentions, serverMentions);
|
|
6348
|
+
// A prompt command replaces the outgoing text with its resolved macro.
|
|
6349
|
+
const sendText = inline?.kind === "prompt" ? inline.sendText : value;
|
|
6350
|
+
|
|
6351
|
+
const hasChips = !!chipMentions && chipMentions.refs.length > 0;
|
|
6352
|
+
// Must have text, attachments, chips, or an inline server command's context.
|
|
6353
|
+
if (!sendText && !hasAttachments && !hasChips && !serverMentions) return;
|
|
5641
6354
|
|
|
5642
6355
|
maybeExpandComposerBar();
|
|
5643
6356
|
|
|
@@ -5648,22 +6361,65 @@ export const createAgentExperience = (
|
|
|
5648
6361
|
// Add image parts first
|
|
5649
6362
|
contentParts.push(...attachmentManager!.getContentParts());
|
|
5650
6363
|
// Add text part if there's text
|
|
5651
|
-
if (
|
|
5652
|
-
contentParts.push(createTextPart(
|
|
6364
|
+
if (sendText) {
|
|
6365
|
+
contentParts.push(createTextPart(sendText));
|
|
5653
6366
|
}
|
|
5654
6367
|
}
|
|
5655
6368
|
|
|
6369
|
+
// Capture the inline display segments BEFORE clearing the composer (clearing
|
|
6370
|
+
// rebuilds the document as empty text). Only set when a `prompt` macro didn't
|
|
6371
|
+
// replace the outgoing text — a macro's segments no longer match `sendText`.
|
|
6372
|
+
const contentSegments =
|
|
6373
|
+
inline?.kind === "prompt" ? undefined : readInlineContentSegments();
|
|
6374
|
+
|
|
5656
6375
|
textarea.value = "";
|
|
5657
6376
|
textarea.style.height = "auto"; // Reset height after clearing
|
|
5658
6377
|
resetHistoryNavigation();
|
|
5659
6378
|
|
|
5660
|
-
// Send message with optional content parts
|
|
5661
|
-
session.sendMessage(
|
|
6379
|
+
// Send message with optional content parts + mentions
|
|
6380
|
+
session.sendMessage(sendText, {
|
|
6381
|
+
contentParts,
|
|
6382
|
+
mentions: mentions ?? undefined,
|
|
6383
|
+
contentSegments,
|
|
6384
|
+
viaVoice: submitOptions?.viaVoice,
|
|
6385
|
+
});
|
|
5662
6386
|
|
|
5663
|
-
// Clear attachments after sending
|
|
6387
|
+
// Clear attachments + mention chips after sending
|
|
5664
6388
|
if (hasAttachments) {
|
|
5665
6389
|
attachmentManager!.clearAttachments();
|
|
5666
6390
|
}
|
|
6391
|
+
if (chipMentions) {
|
|
6392
|
+
mentionOrchestrator?.clear();
|
|
6393
|
+
}
|
|
6394
|
+
};
|
|
6395
|
+
|
|
6396
|
+
const doSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
|
|
6397
|
+
if (submitInFlight) return;
|
|
6398
|
+
submitInFlight = true;
|
|
6399
|
+
try {
|
|
6400
|
+
await performSubmit(submitOptions);
|
|
6401
|
+
} finally {
|
|
6402
|
+
submitInFlight = false;
|
|
6403
|
+
}
|
|
6404
|
+
};
|
|
6405
|
+
|
|
6406
|
+
const handleSubmit = (event: Event) => {
|
|
6407
|
+
event.preventDefault();
|
|
6408
|
+
|
|
6409
|
+
// While a response is streaming, the submit button acts as a stop button.
|
|
6410
|
+
// Abort the in-flight stream and leave textarea contents / attachments
|
|
6411
|
+
// intact so the user can edit and resend without retyping.
|
|
6412
|
+
if (session.isStreaming()) {
|
|
6413
|
+
session.cancel();
|
|
6414
|
+
// Cancelling emits no terminal/error SSE frame, so reset the throughput
|
|
6415
|
+
// tracker (as clear-chat does) to avoid a stale `running` row lingering.
|
|
6416
|
+
throughputTracker?.reset();
|
|
6417
|
+
eventStreamView?.update();
|
|
6418
|
+
return;
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6421
|
+
if (submitInFlight) return;
|
|
6422
|
+
void doSubmit();
|
|
5667
6423
|
};
|
|
5668
6424
|
|
|
5669
6425
|
// --- Composer message-history navigation (Up/Down arrows) ---
|
|
@@ -5702,15 +6458,30 @@ export const createAgentExperience = (
|
|
|
5702
6458
|
textarea.setSelectionRange(end, end);
|
|
5703
6459
|
};
|
|
5704
6460
|
|
|
5705
|
-
const handleComposerInput = () => {
|
|
5706
|
-
//
|
|
6461
|
+
const handleComposerInput = (event: Event) => {
|
|
6462
|
+
// The synthetic input from history recall (guarded by `suppressHistoryReset`)
|
|
6463
|
+
// is not user typing: it must neither open the mention menu (an undefined
|
|
6464
|
+
// inputType reads as menu-opening) nor exit history-navigation mode.
|
|
5707
6465
|
if (suppressHistoryReset) return;
|
|
6466
|
+
// Drive the mention menu (open/update/close) + lazy-load on first trigger.
|
|
6467
|
+
// Skip while an IME composition is active: the intermediate value isn't the
|
|
6468
|
+
// user's committed text, so it must not open or filter the menu.
|
|
6469
|
+
if (!(event as InputEvent).isComposing) {
|
|
6470
|
+
mentionOrchestrator?.handleInput((event as InputEvent).inputType ?? undefined);
|
|
6471
|
+
}
|
|
6472
|
+
// A real edit leaves history-navigation mode.
|
|
5708
6473
|
resetHistoryNavigation();
|
|
5709
6474
|
};
|
|
5710
6475
|
|
|
5711
6476
|
const handleComposerKeydown = (event: KeyboardEvent) => {
|
|
5712
6477
|
if (!textarea) return;
|
|
5713
6478
|
|
|
6479
|
+
// Mention menu takes precedence when open (↑/↓ nav, Enter/Tab select, Esc
|
|
6480
|
+
// close) and handles Backspace-removes-last-chip on an empty composer. One
|
|
6481
|
+
// handler, no competing capture-phase listener. Skip during IME composition
|
|
6482
|
+
// so Enter-to-confirm-composition isn't swallowed as a menu selection.
|
|
6483
|
+
if (!event.isComposing && mentionOrchestrator?.handleKeydown(event)) return;
|
|
6484
|
+
|
|
5714
6485
|
// Up/Down: walk through previously sent user messages.
|
|
5715
6486
|
if (
|
|
5716
6487
|
historyNavigationEnabled() &&
|
|
@@ -5748,6 +6519,12 @@ export const createAgentExperience = (
|
|
|
5748
6519
|
event.preventDefault();
|
|
5749
6520
|
return;
|
|
5750
6521
|
}
|
|
6522
|
+
// A submit is already awaiting its async pre-send work; a second Enter in
|
|
6523
|
+
// that window would dispatch the same text twice.
|
|
6524
|
+
if (submitInFlight) {
|
|
6525
|
+
event.preventDefault();
|
|
6526
|
+
return;
|
|
6527
|
+
}
|
|
5751
6528
|
resetHistoryNavigation();
|
|
5752
6529
|
event.preventDefault();
|
|
5753
6530
|
sendButton.click();
|
|
@@ -5853,9 +6630,11 @@ export const createAgentExperience = (
|
|
|
5853
6630
|
const finalValue = textarea.value.trim();
|
|
5854
6631
|
if (finalValue && speechRecognition && isRecording) {
|
|
5855
6632
|
stopVoiceRecognition();
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
6633
|
+
// Route through the normal submit path so mentions are collected +
|
|
6634
|
+
// cleared like a manual send (the transcript already lives in the
|
|
6635
|
+
// composer). Sending directly would leave stale tracked mention
|
|
6636
|
+
// context to attach to the next unrelated message.
|
|
6637
|
+
void doSubmit({ viaVoice: true });
|
|
5859
6638
|
}
|
|
5860
6639
|
}, pauseDuration);
|
|
5861
6640
|
}
|
|
@@ -5873,9 +6652,9 @@ export const createAgentExperience = (
|
|
|
5873
6652
|
if (isRecording) {
|
|
5874
6653
|
const finalValue = textarea.value.trim();
|
|
5875
6654
|
if (finalValue && finalValue !== initialText.trim()) {
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
6655
|
+
// Route through the normal submit path (mentions collect + clear), same
|
|
6656
|
+
// as the pause-timer branch above.
|
|
6657
|
+
void doSubmit({ viaVoice: true });
|
|
5879
6658
|
}
|
|
5880
6659
|
stopVoiceRecognition();
|
|
5881
6660
|
}
|
|
@@ -6396,18 +7175,36 @@ export const createAgentExperience = (
|
|
|
6396
7175
|
|
|
6397
7176
|
try {
|
|
6398
7177
|
if (shouldGoFullscreen) {
|
|
6399
|
-
|
|
6400
|
-
|
|
7178
|
+
// syncPanelChrome re-applies theme + artifact layout vars that the
|
|
7179
|
+
// cssText reset in applyFullHeightStyles wipes; bare re-style flickers.
|
|
7180
|
+
syncPanelChrome();
|
|
7181
|
+
appliedSplitMode = splitChromeMode();
|
|
6401
7182
|
return;
|
|
6402
7183
|
}
|
|
6403
7184
|
|
|
6404
7185
|
// Exiting mobile fullscreen (e.g., orientation change to landscape): reset all styles
|
|
7186
|
+
let chromeResynced = false;
|
|
6405
7187
|
if (wasMobileFullscreen) {
|
|
6406
7188
|
wasMobileFullscreen = false;
|
|
6407
|
-
|
|
6408
|
-
|
|
7189
|
+
syncPanelChrome();
|
|
7190
|
+
chromeResynced = true;
|
|
6409
7191
|
}
|
|
6410
7192
|
|
|
7193
|
+
// Width-only resize can cross the 640 split boundary without changing
|
|
7194
|
+
// panel geometry (pane ResizeObserver stays silent) or hitting the
|
|
7195
|
+
// fullscreen branches, so resync chrome when the split mode flips.
|
|
7196
|
+
const modeNow = splitChromeMode();
|
|
7197
|
+
if (!chromeResynced && modeNow !== appliedSplitMode) {
|
|
7198
|
+
syncPanelChrome();
|
|
7199
|
+
chromeResynced = true;
|
|
7200
|
+
}
|
|
7201
|
+
appliedSplitMode = modeNow;
|
|
7202
|
+
// syncPanelChrome does not touch the resize handle, so a width-only flip
|
|
7203
|
+
// between welded (seam overlay) and detached/flex handle would leave it in
|
|
7204
|
+
// the old mode. reconcileArtifactResize re-places it; it never re-enters
|
|
7205
|
+
// syncArtifactPane, so no recursion. No-op when artifacts are disabled.
|
|
7206
|
+
if (chromeResynced) reconcileArtifactResize();
|
|
7207
|
+
|
|
6411
7208
|
if (!launcherEnabled && !dockedMode) {
|
|
6412
7209
|
panel.style.height = "";
|
|
6413
7210
|
panel.style.width = "";
|
|
@@ -6739,9 +7536,43 @@ export const createAgentExperience = (
|
|
|
6739
7536
|
if (composerForm) {
|
|
6740
7537
|
composerForm.addEventListener("submit", handleSubmit);
|
|
6741
7538
|
}
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
7539
|
+
|
|
7540
|
+
// Single registry of composer listeners. The initial attach and the inline
|
|
7541
|
+
// contenteditable swap-reattach both consume this array, so a listener added
|
|
7542
|
+
// here is mechanically included in both — no hand-maintained enumeration to
|
|
7543
|
+
// drift. Add new composer listeners here, not as ad-hoc addEventListener calls.
|
|
7544
|
+
type ComposerListener = [event: string, handler: (event: Event) => void];
|
|
7545
|
+
const composerListeners: ComposerListener[] = [
|
|
7546
|
+
["keydown", handleComposerKeydown as unknown as (event: Event) => void],
|
|
7547
|
+
["input", handleComposerInput as (event: Event) => void],
|
|
7548
|
+
["paste", handleInputPaste as unknown as (event: Event) => void],
|
|
7549
|
+
// Warm the mention chunk on first focus so the first `@` is instant.
|
|
7550
|
+
["focus", mentionPrefetch as (event: Event) => void],
|
|
7551
|
+
];
|
|
7552
|
+
const attachComposerListeners = (el: HTMLElement | null): void => {
|
|
7553
|
+
if (!el) return;
|
|
7554
|
+
for (const [event, handler] of composerListeners) {
|
|
7555
|
+
el.addEventListener(event, handler);
|
|
7556
|
+
}
|
|
7557
|
+
};
|
|
7558
|
+
const detachComposerListeners = (el: HTMLElement | null): void => {
|
|
7559
|
+
if (!el) return;
|
|
7560
|
+
for (const [event, handler] of composerListeners) {
|
|
7561
|
+
el.removeEventListener(event, handler);
|
|
7562
|
+
}
|
|
7563
|
+
};
|
|
7564
|
+
|
|
7565
|
+
attachComposerListeners(textarea);
|
|
7566
|
+
|
|
7567
|
+
// Inline mention mode swaps the textarea for a contenteditable surface (loaded
|
|
7568
|
+
// lazily). When that happens, move the composer listeners onto the new element
|
|
7569
|
+
// and repoint `textarea` (the swapped element shims the textarea API the rest of
|
|
7570
|
+
// the composer code relies on). Fires immediately if the swap already occurred.
|
|
7571
|
+
mentionOrchestrator?.onComposerSwap((next, prev) => {
|
|
7572
|
+
detachComposerListeners(prev);
|
|
7573
|
+
textarea = next as unknown as HTMLTextAreaElement;
|
|
7574
|
+
attachComposerListeners(next);
|
|
7575
|
+
});
|
|
6745
7576
|
|
|
6746
7577
|
const escStopDoc = mount.ownerDocument ?? document;
|
|
6747
7578
|
escStopDoc.addEventListener("keydown", handleEscStop, true);
|
|
@@ -6822,10 +7653,9 @@ export const createAgentExperience = (
|
|
|
6822
7653
|
if (composerForm) {
|
|
6823
7654
|
composerForm.removeEventListener("submit", handleSubmit);
|
|
6824
7655
|
}
|
|
6825
|
-
textarea
|
|
6826
|
-
textarea?.removeEventListener("input", handleComposerInput);
|
|
6827
|
-
textarea?.removeEventListener("paste", handleInputPaste);
|
|
7656
|
+
detachComposerListeners(textarea);
|
|
6828
7657
|
escStopDoc.removeEventListener("keydown", handleEscStop, true);
|
|
7658
|
+
mentionOrchestrator?.destroy();
|
|
6829
7659
|
});
|
|
6830
7660
|
|
|
6831
7661
|
destroyCallbacks.push(() => {
|
|
@@ -7867,19 +8697,11 @@ export const createAgentExperience = (
|
|
|
7867
8697
|
attachmentButton.style.minHeight = attachIconSize;
|
|
7868
8698
|
attachmentButton.style.fontSize = "18px";
|
|
7869
8699
|
attachmentButton.style.lineHeight = "1";
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
// Add hover effect via mouseenter/mouseleave
|
|
7877
|
-
attachmentButton.addEventListener("mouseenter", () => {
|
|
7878
|
-
attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
|
|
7879
|
-
});
|
|
7880
|
-
attachmentButton.addEventListener("mouseleave", () => {
|
|
7881
|
-
attachmentButton!.style.backgroundColor = "transparent";
|
|
7882
|
-
});
|
|
8700
|
+
// Appearance (bg / fg / border / radius / hover) is themed from the
|
|
8701
|
+
// shared `.persona-attachment-button` CSS rule via the
|
|
8702
|
+
// `--persona-button-ghost-*` tokens — matching the static
|
|
8703
|
+
// createAttachmentControls path. Only sizing stays inline here so
|
|
8704
|
+
// this runtime-created button restyles identically to the built-in one.
|
|
7883
8705
|
|
|
7884
8706
|
const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
|
|
7885
8707
|
if (attachIconSvg) {
|
|
@@ -7901,8 +8723,10 @@ export const createAgentExperience = (
|
|
|
7901
8723
|
tooltip.textContent = attachTooltipText;
|
|
7902
8724
|
attachmentButtonWrapper.appendChild(tooltip);
|
|
7903
8725
|
|
|
7904
|
-
// Insert into left actions container
|
|
7905
|
-
|
|
8726
|
+
// Insert into left actions container (fall back to the form when a
|
|
8727
|
+
// custom composer has no left cluster).
|
|
8728
|
+
if (leftActions) leftActions.append(attachmentButtonWrapper);
|
|
8729
|
+
else composerForm.appendChild(attachmentButtonWrapper);
|
|
7906
8730
|
|
|
7907
8731
|
// Initialize attachment manager
|
|
7908
8732
|
if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
|
|
@@ -8385,6 +9209,7 @@ export const createAgentExperience = (
|
|
|
8385
9209
|
showArtifacts(): void {
|
|
8386
9210
|
if (!artifactsSidebarEnabled(config)) return;
|
|
8387
9211
|
artifactsPaneUserHidden = false;
|
|
9212
|
+
artifactsPaneUserOpened = true;
|
|
8388
9213
|
syncArtifactPane();
|
|
8389
9214
|
artifactPaneApi?.setMobileOpen(true);
|
|
8390
9215
|
},
|
|
@@ -8395,8 +9220,20 @@ export const createAgentExperience = (
|
|
|
8395
9220
|
},
|
|
8396
9221
|
upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord | null {
|
|
8397
9222
|
if (!artifactsSidebarEnabled(config)) return null;
|
|
8398
|
-
// Programmatic
|
|
8399
|
-
|
|
9223
|
+
// Programmatic upserts match the streamed UX: only "panel"-mode
|
|
9224
|
+
// artifacts auto-open the pane (overriding a previous Close), while
|
|
9225
|
+
// "card"/"inline" stay calm — the injected transcript block is the
|
|
9226
|
+
// affordance. Independent of `transcript: false`: pane-only callers
|
|
9227
|
+
// (e.g. the theme editor preview) rely on the panel-default surfacing;
|
|
9228
|
+
// callers that want the pane in a non-panel mode call showArtifacts().
|
|
9229
|
+
const mode = resolveArtifactDisplayMode(
|
|
9230
|
+
config.features?.artifacts,
|
|
9231
|
+
manual.artifactType
|
|
9232
|
+
);
|
|
9233
|
+
if (mode === "panel") {
|
|
9234
|
+
artifactsPaneUserHidden = false;
|
|
9235
|
+
artifactsPaneUserOpened = true;
|
|
9236
|
+
}
|
|
8400
9237
|
return session.upsertArtifact(manual);
|
|
8401
9238
|
},
|
|
8402
9239
|
selectArtifact(id: string): void {
|
|
@@ -8790,18 +9627,20 @@ export const createAgentExperience = (
|
|
|
8790
9627
|
// bust the message cache and re-render so they pick up real markdown. Bumping
|
|
8791
9628
|
// `configVersion` + clearing the cache is required because the message
|
|
8792
9629
|
// content is unchanged, so the fingerprint cache would otherwise reuse the
|
|
8793
|
-
// stale escaped wrappers.
|
|
9630
|
+
// stale escaped wrappers. `onMarkdownParsersReady` no-ops when the parsers are
|
|
9631
|
+
// already loaded (the ESM build, and the CDN build after the first load), so
|
|
9632
|
+
// the `markdownReadyAtInit` guard is redundant — kept only to skip the
|
|
9633
|
+
// subscription bookkeeping on the common eager path.
|
|
8794
9634
|
if (!markdownReadyAtInit) {
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
});
|
|
9635
|
+
const unsubscribeParsersReady = onMarkdownParsersReady(() => {
|
|
9636
|
+
if (!session) return;
|
|
9637
|
+
configVersion++;
|
|
9638
|
+
messageCache.clear();
|
|
9639
|
+
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
9640
|
+
});
|
|
9641
|
+
// Drop the subscription on teardown so a late chunk resolution can't clear
|
|
9642
|
+
// the cache and render into a detached `messagesWrapper`.
|
|
9643
|
+
destroyCallbacks.push(unsubscribeParsersReady);
|
|
8805
9644
|
}
|
|
8806
9645
|
|
|
8807
9646
|
return controller;
|