@runtypelabs/persona 4.6.1 → 4.8.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/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-BsZtXPKK.d.cts} +43 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -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/codegen.cjs +12 -12
- package/dist/codegen.js +14 -14
- package/dist/index.cjs +91 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +918 -174
- package/dist/index.d.ts +918 -174
- package/dist/index.global.js +81 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +89 -66
- 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.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 +17 -17
- package/dist/smart-dom-reader.d.cts +753 -15
- package/dist/smart-dom-reader.d.ts +753 -15
- package/dist/smart-dom-reader.js +17 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +81 -58
- package/dist/theme-editor-preview.d.cts +761 -15
- package/dist/theme-editor-preview.d.ts +761 -15
- package/dist/theme-editor-preview.js +81 -58
- package/dist/theme-editor.cjs +6 -6
- package/dist/theme-editor.d.cts +753 -15
- package/dist/theme-editor.d.ts +753 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +74 -0
- package/dist/theme-reference.d.ts +74 -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 +2 -3
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +500 -1
- package/src/client.ts +285 -93
- 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/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/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +20 -1
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +74 -9
- 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/runtime/persist-state.test.ts +118 -0
- package/src/session.ts +76 -22
- package/src/styles/widget.css +773 -26
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +10 -2
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +77 -0
- package/src/types.ts +516 -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.scroll-additive.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +312 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +933 -214
- 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/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/icons.ts +2 -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 +48 -0
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +91 -0
- 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
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { createElement } from "../utils/dom";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import type {
|
|
3
|
+
AgentWidgetConfig,
|
|
4
|
+
PersonaArtifactRecord,
|
|
5
|
+
PersonaArtifactCustomAction,
|
|
6
|
+
} from "../types";
|
|
7
|
+
import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
|
|
8
|
+
import {
|
|
9
|
+
buildArtifactActionButton,
|
|
10
|
+
artifactRecordActionContext,
|
|
11
|
+
} from "../utils/artifact-custom-actions";
|
|
12
|
+
import {
|
|
13
|
+
renderArtifactPreviewBody,
|
|
14
|
+
artifactCopyText,
|
|
15
|
+
type ArtifactPreviewBodyHandle,
|
|
16
|
+
} from "./artifact-preview";
|
|
6
17
|
import { renderLucideIcon } from "../utils/icons";
|
|
7
18
|
import { createDropdownMenu, type DropdownMenuHandle } from "../utils/dropdown";
|
|
8
|
-
import { createIconButton, createLabelButton } from "../utils/buttons";
|
|
19
|
+
import { createIconButton, createLabelButton, createToggleGroup } from "../utils/buttons";
|
|
20
|
+
import { createRovingTablist } from "../utils/roving-tablist";
|
|
9
21
|
|
|
10
22
|
export type ArtifactPaneApi = {
|
|
11
23
|
element: HTMLElement;
|
|
@@ -13,22 +25,37 @@ export type ArtifactPaneApi = {
|
|
|
13
25
|
backdrop: HTMLElement | null;
|
|
14
26
|
update: (state: { artifacts: PersonaArtifactRecord[]; selectedId: string | null }) => void;
|
|
15
27
|
setMobileOpen: (open: boolean) => void;
|
|
28
|
+
/** Reflect the expanded state on the toolbar toggle (icon + label). */
|
|
29
|
+
setExpanded: (expanded: boolean) => void;
|
|
30
|
+
/** Show/hide the expand toggle; driven from the parent's config on every sync so live config updates apply. */
|
|
31
|
+
setExpandToggleVisible: (visible: boolean) => void;
|
|
32
|
+
/** Show/hide the default-preset copy button; no-op in the document preset (copy is always shown there). */
|
|
33
|
+
setCopyButtonVisible: (visible: boolean) => void;
|
|
34
|
+
/** Replace the toolbar custom-action list and re-render it; driven from the parent's config on every sync so live config updates apply. */
|
|
35
|
+
setCustomActions: (actions: PersonaArtifactCustomAction[]) => void;
|
|
36
|
+
/** Update the tab-strip edge fade live; driven from the parent's config on every sync. */
|
|
37
|
+
setTabFade: (cfg: boolean | { start?: boolean; end?: boolean } | undefined) => void;
|
|
38
|
+
/** Swap the tab bar live between the built-in strip (undefined) and a host renderTabBar; driven from the parent's config on every sync. */
|
|
39
|
+
setRenderTabBar: (
|
|
40
|
+
fn:
|
|
41
|
+
| ((ctx: {
|
|
42
|
+
records: PersonaArtifactRecord[];
|
|
43
|
+
selectedId: string | null;
|
|
44
|
+
onSelect: (id: string) => void;
|
|
45
|
+
}) => HTMLElement)
|
|
46
|
+
| undefined
|
|
47
|
+
) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Explicit "the pane surface is shown to the user" signal from the host
|
|
50
|
+
* (ui.ts), which owns the display-mode / expand / mobile-drawer routing.
|
|
51
|
+
* While hidden the pane records state on update() but does NOT build/refresh
|
|
52
|
+
* the preview DOM, so an inline/card artifact never spawns a second
|
|
53
|
+
* sandboxed iframe (which would execute artifact scripts a second time). On
|
|
54
|
+
* reveal the pane renders the current recorded state.
|
|
55
|
+
*/
|
|
56
|
+
setVisible: (visible: boolean) => void;
|
|
16
57
|
};
|
|
17
58
|
|
|
18
|
-
function fallbackComponentCard(sel: PersonaArtifactRecord): HTMLElement {
|
|
19
|
-
const card = createElement(
|
|
20
|
-
"div",
|
|
21
|
-
"persona-rounded-lg persona-border persona-border-persona-border persona-p-3 persona-text-persona-primary"
|
|
22
|
-
);
|
|
23
|
-
const title = createElement("div", "persona-font-semibold persona-text-sm persona-mb-2");
|
|
24
|
-
title.textContent = sel.component ? `Component: ${sel.component}` : "Component";
|
|
25
|
-
const pre = createElement("pre", "persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-overflow-x-auto");
|
|
26
|
-
pre.textContent = JSON.stringify(sel.props ?? {}, null, 2);
|
|
27
|
-
card.appendChild(title);
|
|
28
|
-
card.appendChild(pre);
|
|
29
|
-
return card;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
59
|
/**
|
|
33
60
|
* Right-hand artifact sidebar / mobile drawer content.
|
|
34
61
|
*/
|
|
@@ -38,19 +65,28 @@ export function createArtifactPane(
|
|
|
38
65
|
onSelect: (id: string) => void;
|
|
39
66
|
/** User closed the pane (mobile drawer or split sidebar): parent should persist “hidden until reopened”. */
|
|
40
67
|
onDismiss?: () => void;
|
|
68
|
+
/** User clicked the expand/collapse toggle: parent owns the state and calls back via setExpanded. */
|
|
69
|
+
onToggleExpand?: () => void;
|
|
41
70
|
}
|
|
42
71
|
): ArtifactPaneApi {
|
|
43
72
|
const layout = config.features?.artifacts?.layout;
|
|
44
73
|
const toolbarPreset = layout?.toolbarPreset ?? "default";
|
|
45
74
|
const documentChrome = toolbarPreset === "document";
|
|
75
|
+
const toolbarTitle = layout?.toolbarTitle ?? "Artifacts";
|
|
76
|
+
const closeButtonLabel = layout?.closeButtonLabel ?? "Close";
|
|
46
77
|
const panePadding = layout?.panePadding?.trim();
|
|
47
78
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
const tabFadeSize = layout?.tabFadeSize?.trim();
|
|
80
|
+
// Per-edge fade enablement is mutable so setTabFade can switch it live (the
|
|
81
|
+
// host re-reads config on every sync). false disables both edges; the object
|
|
82
|
+
// form gates each edge; true/undefined keeps both dynamic.
|
|
83
|
+
const resolveFade = (
|
|
84
|
+
cfg: boolean | { start?: boolean; end?: boolean } | undefined
|
|
85
|
+
) => ({
|
|
86
|
+
start: cfg === false ? false : typeof cfg === "object" && cfg ? cfg.start !== false : true,
|
|
87
|
+
end: cfg === false ? false : typeof cfg === "object" && cfg ? cfg.end !== false : true,
|
|
88
|
+
});
|
|
89
|
+
let { start: fadeStartEnabled, end: fadeEndEnabled } = resolveFade(layout?.tabFade);
|
|
54
90
|
|
|
55
91
|
const backdrop =
|
|
56
92
|
typeof document !== "undefined"
|
|
@@ -91,15 +127,9 @@ export function createArtifactPane(
|
|
|
91
127
|
toolbar.classList.add("persona-artifact-toolbar-document");
|
|
92
128
|
}
|
|
93
129
|
const titleEl = createElement("span", "persona-text-xs persona-font-medium persona-truncate");
|
|
94
|
-
titleEl.textContent =
|
|
130
|
+
titleEl.textContent = toolbarTitle;
|
|
95
131
|
|
|
96
|
-
const closeBtn =
|
|
97
|
-
"button",
|
|
98
|
-
"persona-rounded-md persona-border persona-border-persona-border persona-px-2 persona-py-1 persona-text-xs persona-bg-persona-surface"
|
|
99
|
-
);
|
|
100
|
-
closeBtn.type = "button";
|
|
101
|
-
closeBtn.textContent = "Close";
|
|
102
|
-
closeBtn.setAttribute("aria-label", "Close artifacts panel");
|
|
132
|
+
const closeBtn = createIconButton({ icon: "x", label: closeButtonLabel });
|
|
103
133
|
closeBtn.addEventListener("click", () => {
|
|
104
134
|
dismissLocalUi();
|
|
105
135
|
options.onDismiss?.();
|
|
@@ -107,13 +137,28 @@ export function createArtifactPane(
|
|
|
107
137
|
|
|
108
138
|
/** Document preset: view vs raw source */
|
|
109
139
|
let viewMode: "rendered" | "source" = "rendered";
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
const viewToggle = createToggleGroup({
|
|
141
|
+
items: [
|
|
142
|
+
{
|
|
143
|
+
id: "rendered",
|
|
144
|
+
icon: "eye",
|
|
145
|
+
label: "Rendered view",
|
|
146
|
+
className: documentChrome ? "persona-artifact-doc-icon-btn persona-artifact-view-btn" : undefined,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "source",
|
|
150
|
+
icon: "code-xml",
|
|
151
|
+
label: "Source",
|
|
152
|
+
className: documentChrome ? "persona-artifact-doc-icon-btn persona-artifact-code-btn" : undefined,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
selectedId: "rendered",
|
|
156
|
+
className: "persona-artifact-toggle-group persona-shrink-0",
|
|
157
|
+
onSelect: (id) => {
|
|
158
|
+
viewMode = id === "source" ? "source" : "rendered";
|
|
159
|
+
render();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
117
162
|
const actionsRight = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
|
|
118
163
|
const showCopyLabel = layout?.documentToolbarShowCopyLabel === true;
|
|
119
164
|
const showCopyChevron = layout?.documentToolbarShowCopyChevron === true;
|
|
@@ -152,15 +197,42 @@ export function createArtifactPane(
|
|
|
152
197
|
} else if (documentChrome) {
|
|
153
198
|
copyBtn = createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-icon-btn" });
|
|
154
199
|
} else {
|
|
200
|
+
// Always built (like the expand toggle) so a live config update can
|
|
201
|
+
// reveal it via setCopyButtonVisible; hidden unless layout opts in.
|
|
155
202
|
copyBtn = createIconButton({ icon: "copy", label: "Copy" });
|
|
203
|
+
if (layout?.showCopyButton !== true) {
|
|
204
|
+
copyBtn.classList.add("persona-hidden");
|
|
205
|
+
}
|
|
156
206
|
}
|
|
157
207
|
|
|
158
208
|
const refreshBtn = documentChrome
|
|
159
209
|
? createIconButton({ icon: "refresh-cw", label: "Refresh", className: "persona-artifact-doc-icon-btn" })
|
|
160
210
|
: createIconButton({ icon: "refresh-cw", label: "Refresh" });
|
|
161
211
|
const closeIconBtn = documentChrome
|
|
162
|
-
? createIconButton({ icon: "x", label:
|
|
163
|
-
: createIconButton({ icon: "x", label:
|
|
212
|
+
? createIconButton({ icon: "x", label: closeButtonLabel, className: "persona-artifact-doc-icon-btn" })
|
|
213
|
+
: createIconButton({ icon: "x", label: closeButtonLabel });
|
|
214
|
+
|
|
215
|
+
// Always built so a live config update can reveal it (the pane is created
|
|
216
|
+
// once; syncArtifactPane re-reads the config and drives setExpandToggleVisible).
|
|
217
|
+
const expandBtn = createIconButton({
|
|
218
|
+
icon: "maximize",
|
|
219
|
+
label: "Expand artifacts panel",
|
|
220
|
+
className: "persona-artifact-expand-btn" + (documentChrome ? " persona-artifact-doc-icon-btn" : ""),
|
|
221
|
+
onClick: () => options.onToggleExpand?.(),
|
|
222
|
+
});
|
|
223
|
+
if (layout?.showExpandToggle !== true) {
|
|
224
|
+
expandBtn.classList.add("persona-hidden");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Host container for integrator-supplied toolbar buttons. Built once and
|
|
228
|
+
// slotted into whichever preset renders; setCustomActions re-renders its
|
|
229
|
+
// children on every parent sync so live config updates apply.
|
|
230
|
+
const customActions = createElement(
|
|
231
|
+
"div",
|
|
232
|
+
"persona-flex persona-items-center persona-gap-1 persona-shrink-0 persona-artifact-toolbar-custom-actions"
|
|
233
|
+
);
|
|
234
|
+
let customActionList: PersonaArtifactCustomAction[] =
|
|
235
|
+
config.features?.artifacts?.toolbarActions ?? [];
|
|
164
236
|
|
|
165
237
|
const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
|
|
166
238
|
const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
|
|
@@ -173,16 +245,9 @@ export function createArtifactPane(
|
|
|
173
245
|
};
|
|
174
246
|
|
|
175
247
|
const defaultCopy = async () => {
|
|
176
|
-
const { markdown, jsonPayload } = getSelectedArtifactText();
|
|
177
248
|
const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
|
|
178
|
-
const text =
|
|
179
|
-
sel?.artifactType === "markdown"
|
|
180
|
-
? markdown
|
|
181
|
-
: sel
|
|
182
|
-
? jsonPayload
|
|
183
|
-
: "";
|
|
184
249
|
try {
|
|
185
|
-
await navigator.clipboard.writeText(
|
|
250
|
+
await navigator.clipboard.writeText(artifactCopyText(sel));
|
|
186
251
|
} catch {
|
|
187
252
|
/* ignore */
|
|
188
253
|
}
|
|
@@ -258,22 +323,6 @@ export function createArtifactPane(
|
|
|
258
323
|
options.onDismiss?.();
|
|
259
324
|
});
|
|
260
325
|
|
|
261
|
-
const syncViewToggleState = () => {
|
|
262
|
-
if (!documentChrome) return;
|
|
263
|
-
viewBtn.setAttribute("aria-pressed", viewMode === "rendered" ? "true" : "false");
|
|
264
|
-
codeBtn.setAttribute("aria-pressed", viewMode === "source" ? "true" : "false");
|
|
265
|
-
};
|
|
266
|
-
viewBtn.addEventListener("click", () => {
|
|
267
|
-
viewMode = "rendered";
|
|
268
|
-
syncViewToggleState();
|
|
269
|
-
render();
|
|
270
|
-
});
|
|
271
|
-
codeBtn.addEventListener("click", () => {
|
|
272
|
-
viewMode = "source";
|
|
273
|
-
syncViewToggleState();
|
|
274
|
-
render();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
326
|
const centerTitle = createElement(
|
|
278
327
|
"span",
|
|
279
328
|
"persona-min-w-0 persona-flex-1 persona-text-xs persona-font-medium persona-text-persona-primary persona-truncate persona-text-center md:persona-text-left"
|
|
@@ -281,17 +330,26 @@ export function createArtifactPane(
|
|
|
281
330
|
|
|
282
331
|
if (documentChrome) {
|
|
283
332
|
toolbar.replaceChildren();
|
|
284
|
-
leftTools.append(viewBtn, codeBtn);
|
|
285
333
|
if (copyWrap) {
|
|
286
334
|
actionsRight.append(copyWrap, refreshBtn, closeIconBtn);
|
|
287
335
|
} else {
|
|
288
336
|
actionsRight.append(copyBtn, refreshBtn, closeIconBtn);
|
|
289
337
|
}
|
|
290
|
-
|
|
291
|
-
|
|
338
|
+
// Order: copy, refresh, custom, expand, close.
|
|
339
|
+
actionsRight.insertBefore(customActions, closeIconBtn);
|
|
340
|
+
actionsRight.insertBefore(expandBtn, closeIconBtn);
|
|
341
|
+
toolbar.append(viewToggle.element, centerTitle, actionsRight);
|
|
292
342
|
} else {
|
|
343
|
+
// Group expand + Close so the toolbar's justify-between spaces
|
|
344
|
+
// [toggle] [title] [actions] instead of distributing each button.
|
|
345
|
+
const defaultActions = createElement(
|
|
346
|
+
"div",
|
|
347
|
+
"persona-flex persona-items-center persona-gap-1 persona-shrink-0"
|
|
348
|
+
);
|
|
349
|
+
// Order: title, copy, custom, expand, Close.
|
|
350
|
+
defaultActions.append(copyBtn, customActions, expandBtn, closeBtn);
|
|
293
351
|
toolbar.appendChild(titleEl);
|
|
294
|
-
toolbar.appendChild(
|
|
352
|
+
toolbar.appendChild(defaultActions);
|
|
295
353
|
}
|
|
296
354
|
|
|
297
355
|
if (panePadding) {
|
|
@@ -303,98 +361,288 @@ export function createArtifactPane(
|
|
|
303
361
|
"div",
|
|
304
362
|
"persona-artifact-list persona-shrink-0 persona-flex persona-gap-1 persona-overflow-x-auto persona-p-2 persona-border-b persona-border-persona-border"
|
|
305
363
|
);
|
|
364
|
+
if (tabFadeSize) {
|
|
365
|
+
list.style.setProperty("--persona-artifact-tab-fade-size", tabFadeSize);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Seam A: a full tab-bar replacement. Both the built-in strip and a custom
|
|
369
|
+
// mount are always built so renderTabBar can switch live (the host re-reads
|
|
370
|
+
// config on every sync); render() shows the active one and hides the other.
|
|
371
|
+
let currentRenderTabBar = config.features?.artifacts?.renderTabBar;
|
|
372
|
+
// Stable host for the replacement bar, mounted where the strip goes; hidden
|
|
373
|
+
// until a custom bar is active.
|
|
374
|
+
const customBarMount = createElement(
|
|
375
|
+
"div",
|
|
376
|
+
"persona-artifact-tab-custom persona-shrink-0 persona-border-b persona-border-persona-border persona-hidden"
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Accessible tablist behavior for the built-in strip (roles, roving tabindex,
|
|
380
|
+
// Arrow/Home/End, focus survival). The host owns a11y for a custom bar.
|
|
381
|
+
const tablist = createRovingTablist(list, {
|
|
382
|
+
onSelect: (index) => options.onSelect(records[index].id),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Toggle the tab-strip edge fades from scroll position. RTL reports a negative
|
|
386
|
+
// scrollLeft, so normalize against the max offset rather than trusting sign.
|
|
387
|
+
// Per-edge fade classes are gated by config.
|
|
388
|
+
const updateTabFades = (el: HTMLElement) => {
|
|
389
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
390
|
+
const overflow = max > 1;
|
|
391
|
+
const offset = Math.abs(el.scrollLeft);
|
|
392
|
+
el.classList.toggle(
|
|
393
|
+
"persona-artifact-tab-fade-start",
|
|
394
|
+
fadeStartEnabled && overflow && offset > 1
|
|
395
|
+
);
|
|
396
|
+
el.classList.toggle(
|
|
397
|
+
"persona-artifact-tab-fade-end",
|
|
398
|
+
fadeEndEnabled && overflow && offset < max - 1
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
// rAF-throttle the scroll-driven fade recompute (falls back to setTimeout).
|
|
402
|
+
let tabFadeRaf = 0;
|
|
403
|
+
const scheduleTabFades = () => {
|
|
404
|
+
if (tabFadeRaf) return;
|
|
405
|
+
const run = () => {
|
|
406
|
+
tabFadeRaf = 0;
|
|
407
|
+
updateTabFades(list);
|
|
408
|
+
};
|
|
409
|
+
tabFadeRaf =
|
|
410
|
+
typeof requestAnimationFrame === "function"
|
|
411
|
+
? requestAnimationFrame(run)
|
|
412
|
+
: (setTimeout(run, 0) as unknown as number);
|
|
413
|
+
};
|
|
414
|
+
list.addEventListener("scroll", () => scheduleTabFades(), { passive: true });
|
|
415
|
+
// Recompute on mid-stream tab additions and pane resizes.
|
|
416
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
417
|
+
new ResizeObserver(() => updateTabFades(list)).observe(list);
|
|
418
|
+
}
|
|
419
|
+
|
|
306
420
|
const content = createElement(
|
|
307
421
|
"div",
|
|
308
422
|
"persona-artifact-content persona-flex-1 persona-min-h-0 persona-overflow-y-auto persona-p-3"
|
|
309
423
|
);
|
|
310
424
|
if (panePadding) {
|
|
311
|
-
list
|
|
312
|
-
|
|
425
|
+
for (const el of [list, customBarMount]) {
|
|
426
|
+
el.style.paddingLeft = panePadding;
|
|
427
|
+
el.style.paddingRight = panePadding;
|
|
428
|
+
}
|
|
313
429
|
content.style.padding = panePadding;
|
|
314
430
|
}
|
|
315
431
|
|
|
316
432
|
shell.appendChild(toolbar);
|
|
317
433
|
shell.appendChild(list);
|
|
434
|
+
shell.appendChild(customBarMount);
|
|
318
435
|
shell.appendChild(content);
|
|
319
436
|
|
|
320
437
|
let records: PersonaArtifactRecord[] = [];
|
|
321
438
|
let selectedId: string | null = null;
|
|
322
439
|
let mobileOpen = false;
|
|
440
|
+
// Lazy-render gate. `visible` reflects the host's explicit setVisible() signal
|
|
441
|
+
// (never inferred from layout/offsetParent, so class-hiding + jsdom behave the
|
|
442
|
+
// same). It defaults to true so the pane renders eagerly when constructed
|
|
443
|
+
// directly (its historical behavior, and the path pane tests exercise); ui.ts
|
|
444
|
+
// always calls setVisible() before update() so the real widget drives it. When
|
|
445
|
+
// hidden, update() records state and sets `dirty` instead of rendering; the
|
|
446
|
+
// next reveal flushes the current state.
|
|
447
|
+
let visible = true;
|
|
448
|
+
let dirty = false;
|
|
449
|
+
// Track the last tab we auto-scrolled to so we only nudge the strip when the
|
|
450
|
+
// selection actually changes (not on every streaming re-render), which would
|
|
451
|
+
// otherwise fight a user who has scrolled the strip manually.
|
|
452
|
+
let lastScrolledTabId: string | null = null;
|
|
453
|
+
// Signature of the last renderTabBar invocation (record ids + selection) so a
|
|
454
|
+
// streaming re-render with an unchanged bar does not tear down the host bar.
|
|
455
|
+
let customBarSig = "";
|
|
456
|
+
|
|
457
|
+
// Shared preview body renderer (artifact-preview.ts). One handle serves the
|
|
458
|
+
// whole content area; it is updated with whichever record is selected and
|
|
459
|
+
// internally reuses the file-preview iframe across idle re-renders.
|
|
460
|
+
let preview: ArtifactPreviewBodyHandle | null = null;
|
|
461
|
+
let fileToggleMounted = false;
|
|
462
|
+
const resolveViewMode = (rec: PersonaArtifactRecord): "rendered" | "source" => {
|
|
463
|
+
const isFile = rec.artifactType === "markdown" && Boolean(rec.file);
|
|
464
|
+
// File artifacts honor the toggle in every toolbar preset; plain markdown
|
|
465
|
+
// only has a source view in the document toolbar preset.
|
|
466
|
+
if (isFile) return viewMode;
|
|
467
|
+
return documentChrome ? viewMode : "rendered";
|
|
468
|
+
};
|
|
469
|
+
// For the default (non-document) toolbar, mount the rendered/source toggle only
|
|
470
|
+
// while a previewable file artifact is selected. The document toolbar already
|
|
471
|
+
// carries the toggle permanently, so it is mounted once at build time. Here we
|
|
472
|
+
// add/remove the group from the toolbar as the selection gains or loses a
|
|
473
|
+
// previewable file (the toggle group owns its own buttons and aria-pressed state).
|
|
474
|
+
const updateFileToggleVisibility = (previewable: boolean) => {
|
|
475
|
+
if (documentChrome) return;
|
|
476
|
+
if (previewable && !fileToggleMounted) {
|
|
477
|
+
toolbar.insertBefore(viewToggle.element, titleEl);
|
|
478
|
+
fileToggleMounted = true;
|
|
479
|
+
} else if (!previewable && fileToggleMounted) {
|
|
480
|
+
viewToggle.element.remove();
|
|
481
|
+
fileToggleMounted = false;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Selection fallback shared with render(): explicit selection, else last record.
|
|
486
|
+
const currentSelectedRecord = (): PersonaArtifactRecord | undefined =>
|
|
487
|
+
(selectedId && records.find((x) => x.id === selectedId)) || records[records.length - 1];
|
|
488
|
+
|
|
489
|
+
// Rebuild the custom-action buttons. Called from render() (so per-artifact
|
|
490
|
+
// visible() gates re-evaluate when the selection changes) and from
|
|
491
|
+
// setCustomActions (so live config updates apply). The onClick context is
|
|
492
|
+
// resolved at click time, not here, so it reflects the current content.
|
|
493
|
+
const renderCustomActions = () => {
|
|
494
|
+
const ctx = artifactRecordActionContext(currentSelectedRecord());
|
|
495
|
+
if (!ctx) {
|
|
496
|
+
// No records: no meaningful context, so render nothing.
|
|
497
|
+
customActions.replaceChildren();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const buttons = customActionList
|
|
501
|
+
.filter((action) => action.visible === undefined || action.visible(ctx))
|
|
502
|
+
.map((action) =>
|
|
503
|
+
buildArtifactActionButton(action, {
|
|
504
|
+
documentChrome,
|
|
505
|
+
onClick: () => {
|
|
506
|
+
const c = artifactRecordActionContext(currentSelectedRecord());
|
|
507
|
+
if (!c) return;
|
|
508
|
+
try {
|
|
509
|
+
void Promise.resolve(action.onClick(c)).catch(() => {});
|
|
510
|
+
} catch {
|
|
511
|
+
/* ignore */
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
})
|
|
515
|
+
);
|
|
516
|
+
customActions.replaceChildren(...buttons);
|
|
517
|
+
};
|
|
323
518
|
|
|
324
519
|
const render = () => {
|
|
520
|
+
renderCustomActions();
|
|
325
521
|
const hideTabs = documentChrome && records.length <= 1;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
list.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
522
|
+
const useCustom = Boolean(currentRenderTabBar);
|
|
523
|
+
// Show the active bar and hide the other; both hide in the ≤1 document case.
|
|
524
|
+
list.classList.toggle("persona-hidden", hideTabs || useCustom);
|
|
525
|
+
customBarMount.classList.toggle("persona-hidden", hideTabs || !useCustom);
|
|
526
|
+
|
|
527
|
+
if (useCustom && currentRenderTabBar) {
|
|
528
|
+
// Signature-gate so unrelated streaming re-renders don't tear down the
|
|
529
|
+
// host bar and lose its internal state or focus.
|
|
530
|
+
const sig = records.map((r) => r.id).join("|") + " " + (selectedId ?? "");
|
|
531
|
+
if (sig !== customBarSig) {
|
|
532
|
+
customBarSig = sig;
|
|
533
|
+
const bar = currentRenderTabBar({
|
|
534
|
+
records,
|
|
535
|
+
selectedId,
|
|
536
|
+
onSelect: options.onSelect,
|
|
537
|
+
});
|
|
538
|
+
// A renderer may return the SAME element across invocations to keep its
|
|
539
|
+
// internal state (e.g. a roving tablist's keyboard focus on the selected
|
|
540
|
+
// tab). Only remount when the node actually changed; replaceChildren
|
|
541
|
+
// detaches the subtree, which would blur a focused tab and break arrow
|
|
542
|
+
// nav on every selection change.
|
|
543
|
+
if (customBarMount.firstElementChild !== bar) {
|
|
544
|
+
customBarMount.replaceChildren(bar);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
// Capture focus before replacing tab DOM so the controller restores the
|
|
549
|
+
// roving stop after the rebuild (keyboard nav dies otherwise).
|
|
550
|
+
tablist.beforeRender();
|
|
551
|
+
list.replaceChildren();
|
|
552
|
+
const tabEls: HTMLButtonElement[] = [];
|
|
553
|
+
let selectedIndex = -1;
|
|
554
|
+
for (const [index, r] of records.entries()) {
|
|
555
|
+
const tab = createElement(
|
|
556
|
+
"button",
|
|
557
|
+
"persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary"
|
|
558
|
+
);
|
|
559
|
+
tab.type = "button";
|
|
560
|
+
// Prefer the file basename over the full path so tabs stay readable
|
|
561
|
+
// (matches the toolbar title); keep the full path/title in a tooltip.
|
|
562
|
+
const fileMeta = r.artifactType === "markdown" ? r.file : undefined;
|
|
563
|
+
const label = fileMeta ? basenameOf(fileMeta.path) : r.title || r.id.slice(0, 8);
|
|
564
|
+
const tooltip = fileMeta?.path || r.title || label;
|
|
565
|
+
tab.textContent = label;
|
|
566
|
+
tab.title = tooltip;
|
|
567
|
+
tab.setAttribute("aria-label", tooltip);
|
|
568
|
+
if (r.id === selectedId) {
|
|
569
|
+
tab.classList.add("persona-bg-persona-container", "persona-border-persona-border");
|
|
570
|
+
selectedIndex = index;
|
|
571
|
+
}
|
|
572
|
+
tab.addEventListener("click", () => options.onSelect(r.id));
|
|
573
|
+
list.appendChild(tab);
|
|
574
|
+
tabEls.push(tab);
|
|
338
575
|
}
|
|
339
|
-
|
|
340
|
-
|
|
576
|
+
// The controller owns role/aria-selected/roving tabindex, keyboard nav,
|
|
577
|
+
// and focus restoration across this rebuild.
|
|
578
|
+
tablist.render(tabEls, selectedIndex);
|
|
579
|
+
|
|
580
|
+
// Keep the selected tab visible when the selection changes (e.g. a new
|
|
581
|
+
// artifact streams in and auto-selects). `inline: "nearest"` is a no-op
|
|
582
|
+
// when the tab is already visible, so this never yanks the strip needlessly.
|
|
583
|
+
if (selectedIndex >= 0 && selectedId !== lastScrolledTabId) {
|
|
584
|
+
lastScrolledTabId = selectedId;
|
|
585
|
+
const activeTab = tabEls[selectedIndex];
|
|
586
|
+
if (typeof activeTab.scrollIntoView === "function") {
|
|
587
|
+
activeTab.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Recompute the edge fades after tabs and the selection scroll settle.
|
|
592
|
+
updateTabFades(list);
|
|
341
593
|
}
|
|
342
594
|
|
|
343
|
-
content.replaceChildren();
|
|
344
595
|
const sel =
|
|
345
596
|
(selectedId && records.find((x) => x.id === selectedId)) ||
|
|
346
597
|
records[records.length - 1];
|
|
347
|
-
if (!sel)
|
|
598
|
+
if (!sel) {
|
|
599
|
+
content.replaceChildren();
|
|
600
|
+
preview = null;
|
|
601
|
+
updateFileToggleVisibility(false);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const selFile = sel.artifactType === "markdown" ? sel.file : undefined;
|
|
606
|
+
|
|
607
|
+
// Expose the rendered/source toggle for previewable file artifacts even
|
|
608
|
+
// outside the document toolbar preset.
|
|
609
|
+
updateFileToggleVisibility(Boolean(selFile));
|
|
348
610
|
|
|
349
611
|
if (documentChrome) {
|
|
350
|
-
const kind =
|
|
612
|
+
const kind = selFile
|
|
613
|
+
? fileTypeLabel(selFile)
|
|
614
|
+
: sel.artifactType === "markdown"
|
|
615
|
+
? "MD"
|
|
616
|
+
: sel.component ?? "Component";
|
|
351
617
|
const rawTitle = (sel.title || "Document").trim();
|
|
352
|
-
const baseTitle =
|
|
618
|
+
const baseTitle = selFile
|
|
619
|
+
? basenameOf(selFile.path)
|
|
620
|
+
: rawTitle.replace(/\s*·\s*MD\s*$/i, "").trim() || "Document";
|
|
353
621
|
centerTitle.textContent = `${baseTitle} · ${kind}`;
|
|
354
622
|
} else {
|
|
355
|
-
titleEl.textContent =
|
|
623
|
+
titleEl.textContent = selFile ? basenameOf(selFile.path) : toolbarTitle;
|
|
356
624
|
}
|
|
357
625
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
const wrap = createElement("div", "persona-text-sm persona-leading-relaxed persona-markdown-bubble");
|
|
369
|
-
wrap.innerHTML = toHtml(sel.markdown ?? "");
|
|
370
|
-
content.appendChild(wrap);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const renderer = sel.component ? componentRegistry.get(sel.component) : undefined;
|
|
375
|
-
if (renderer) {
|
|
376
|
-
const stubMessage: AgentWidgetMessage = {
|
|
377
|
-
id: sel.id,
|
|
378
|
-
role: "assistant",
|
|
379
|
-
content: "",
|
|
380
|
-
createdAt: new Date().toISOString()
|
|
381
|
-
};
|
|
382
|
-
const ctx: ComponentContext = {
|
|
383
|
-
message: stubMessage,
|
|
384
|
-
config,
|
|
385
|
-
updateProps: () => {}
|
|
386
|
-
};
|
|
387
|
-
try {
|
|
388
|
-
const el = renderer(sel.props ?? {}, ctx);
|
|
389
|
-
if (el) {
|
|
390
|
-
content.appendChild(el);
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
} catch {
|
|
394
|
-
/* fall through */
|
|
626
|
+
// Delegate the body to the shared preview renderer. Keep the handle's
|
|
627
|
+
// element attached across updates: re-attaching would reload a live
|
|
628
|
+
// file-preview iframe.
|
|
629
|
+
if (!preview) {
|
|
630
|
+
preview = renderArtifactPreviewBody(sel, { config, resolveViewMode });
|
|
631
|
+
content.replaceChildren(preview.el);
|
|
632
|
+
} else {
|
|
633
|
+
if (preview.el.parentElement !== content) {
|
|
634
|
+
content.replaceChildren(preview.el);
|
|
395
635
|
}
|
|
636
|
+
preview.update(sel);
|
|
396
637
|
}
|
|
397
|
-
|
|
638
|
+
// Source view fills the pane edge-to-edge (gutter flush left, no inset),
|
|
639
|
+
// so drop the content padding whenever the body rendered a code pre.
|
|
640
|
+
// Checked after the render rather than re-deriving the pre/markdown/iframe
|
|
641
|
+
// decision here — renderArtifactPreviewBody owns that branching.
|
|
642
|
+
content.classList.toggle(
|
|
643
|
+
"persona-artifact-content-flush",
|
|
644
|
+
Boolean(content.querySelector(".persona-code-pre"))
|
|
645
|
+
);
|
|
398
646
|
};
|
|
399
647
|
|
|
400
648
|
const applyLayoutVisibility = () => {
|
|
@@ -421,6 +669,15 @@ export function createArtifactPane(
|
|
|
421
669
|
}
|
|
422
670
|
};
|
|
423
671
|
|
|
672
|
+
// Build/refresh the visible DOM (tab strip, title, preview body) plus the
|
|
673
|
+
// layout chrome. Gated behind `visible` so we never create a preview iframe
|
|
674
|
+
// for a pane the user isn't looking at.
|
|
675
|
+
const flush = () => {
|
|
676
|
+
dirty = false;
|
|
677
|
+
render();
|
|
678
|
+
applyLayoutVisibility();
|
|
679
|
+
};
|
|
680
|
+
|
|
424
681
|
return {
|
|
425
682
|
element: shell,
|
|
426
683
|
backdrop,
|
|
@@ -433,8 +690,12 @@ export function createArtifactPane(
|
|
|
433
690
|
if (records.length > 0) {
|
|
434
691
|
mobileOpen = true;
|
|
435
692
|
}
|
|
436
|
-
|
|
437
|
-
|
|
693
|
+
// Record the latest state cheaply; only build the preview DOM when shown.
|
|
694
|
+
// While hidden (inline/card display modes), rendering here would spawn a
|
|
695
|
+
// second sandboxed srcdoc iframe alongside the inline transcript preview,
|
|
696
|
+
// executing artifact scripts twice. Defer the whole render() to reveal.
|
|
697
|
+
dirty = true;
|
|
698
|
+
if (visible) flush();
|
|
438
699
|
},
|
|
439
700
|
setMobileOpen(open: boolean) {
|
|
440
701
|
mobileOpen = open;
|
|
@@ -444,6 +705,54 @@ export function createArtifactPane(
|
|
|
444
705
|
} else {
|
|
445
706
|
applyLayoutVisibility();
|
|
446
707
|
}
|
|
708
|
+
},
|
|
709
|
+
setExpanded(expanded: boolean) {
|
|
710
|
+
// Swap the icon (the state signal) and update the accessible label. We
|
|
711
|
+
// deliberately avoid aria-pressed here: the icon-btn [aria-pressed] CSS
|
|
712
|
+
// would add unwanted active styling.
|
|
713
|
+
const svg = renderLucideIcon(expanded ? "minimize" : "maximize", 16, "currentColor", 2);
|
|
714
|
+
if (svg) expandBtn.replaceChildren(svg);
|
|
715
|
+
const label = expanded ? "Collapse artifacts panel" : "Expand artifacts panel";
|
|
716
|
+
expandBtn.setAttribute("aria-label", label);
|
|
717
|
+
expandBtn.title = label;
|
|
718
|
+
},
|
|
719
|
+
setExpandToggleVisible(visible: boolean) {
|
|
720
|
+
expandBtn.classList.toggle("persona-hidden", !visible);
|
|
721
|
+
},
|
|
722
|
+
setCopyButtonVisible(visible: boolean) {
|
|
723
|
+
if (documentChrome) return;
|
|
724
|
+
copyBtn.classList.toggle("persona-hidden", !visible);
|
|
725
|
+
},
|
|
726
|
+
setCustomActions(actions: PersonaArtifactCustomAction[]) {
|
|
727
|
+
customActionList = actions;
|
|
728
|
+
renderCustomActions();
|
|
729
|
+
},
|
|
730
|
+
setTabFade(cfg: boolean | { start?: boolean; end?: boolean } | undefined) {
|
|
731
|
+
const resolved = resolveFade(cfg);
|
|
732
|
+
if (resolved.start === fadeStartEnabled && resolved.end === fadeEndEnabled) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
fadeStartEnabled = resolved.start;
|
|
736
|
+
fadeEndEnabled = resolved.end;
|
|
737
|
+
updateTabFades(list);
|
|
738
|
+
},
|
|
739
|
+
setRenderTabBar(fn) {
|
|
740
|
+
if (fn === currentRenderTabBar) return;
|
|
741
|
+
currentRenderTabBar = fn;
|
|
742
|
+
// Force a custom-bar rebuild on the next render and re-render now so the
|
|
743
|
+
// switch (built-in <-> custom) applies live.
|
|
744
|
+
customBarSig = "";
|
|
745
|
+
render();
|
|
746
|
+
},
|
|
747
|
+
setVisible(next: boolean) {
|
|
748
|
+
if (next === visible) return;
|
|
749
|
+
visible = next;
|
|
750
|
+
// Reveal after lazy-skipped updates: render the CURRENT recorded state.
|
|
751
|
+
// Hiding never tears down an already-rendered preview — re-appending the
|
|
752
|
+
// iframe on re-open would reload it (a hard invariant; see the reuse
|
|
753
|
+
// comments in artifact-preview.ts). Laziness only blocks building NEW
|
|
754
|
+
// previews while hidden; an already-mounted one stays put.
|
|
755
|
+
if (next && dirty) flush();
|
|
447
756
|
}
|
|
448
757
|
};
|
|
449
758
|
}
|