@runtypelabs/persona 4.7.0 → 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 +877 -172
- package/dist/index.d.ts +877 -172
- package/dist/index.global.js +81 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +91 -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.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 +186 -1
- package/src/client.ts +92 -9
- 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/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.tool-display.test.ts +51 -0
- package/src/ui.ts +698 -109
- 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
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import type { ComponentContext, ComponentRenderer } from "./registry";
|
|
2
|
+
import type {
|
|
3
|
+
AgentWidgetArtifactsFeature,
|
|
4
|
+
PersonaArtifactFileMeta,
|
|
5
|
+
PersonaArtifactRecord
|
|
6
|
+
} from "../types";
|
|
7
|
+
import { createElement } from "../utils/dom";
|
|
8
|
+
import { basenameOf, fileKindOf, fileTypeLabel } from "../utils/artifact-file";
|
|
9
|
+
import {
|
|
10
|
+
applyArtifactStatus,
|
|
11
|
+
clearArtifactStatusTracking,
|
|
12
|
+
resolveArtifactStatusLabel
|
|
13
|
+
} from "../utils/artifact-status-label";
|
|
14
|
+
import { artifactRecordActionContext, buildArtifactActionButton } from "../utils/artifact-custom-actions";
|
|
15
|
+
import { createIconButton, createToggleGroup } from "../utils/buttons";
|
|
16
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
17
|
+
import {
|
|
18
|
+
renderArtifactPreviewBody,
|
|
19
|
+
runArtifactBodyTransition,
|
|
20
|
+
type ArtifactBodyLayout
|
|
21
|
+
} from "./artifact-preview";
|
|
22
|
+
import { PersonaArtifactCard } from "./artifact-card";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Built-in inline artifact block component (display: "inline").
|
|
26
|
+
*
|
|
27
|
+
* Renders the artifact preview body directly in the chat thread via the
|
|
28
|
+
* shared `renderArtifactPreviewBody` — the same surface the artifact pane
|
|
29
|
+
* uses — inside a file-preview chrome (title bar + toolbar) so an inline block
|
|
30
|
+
* reads like a document, not a naked body in a bordered box.
|
|
31
|
+
*
|
|
32
|
+
* Data flow:
|
|
33
|
+
* - The initial render builds a record from the message props (the same
|
|
34
|
+
* rawContent JSON-component shape the card uses). While streaming that is
|
|
35
|
+
* an empty/partial record; after `artifact_complete` the client embeds the
|
|
36
|
+
* accumulated markdown + file meta in the props, so a refreshed session
|
|
37
|
+
* re-renders the full preview from props alone (hydration path).
|
|
38
|
+
* - Live streaming updates come from the session artifact registry: the block
|
|
39
|
+
* registers an updater keyed on its root element, and the UI layer pushes
|
|
40
|
+
* every `onArtifactsState` emission through `updateInlineArtifactBlocks`,
|
|
41
|
+
* which routes the matching record into the preview handle's `update()` and
|
|
42
|
+
* the chrome's in-place `updateChrome()`.
|
|
43
|
+
*
|
|
44
|
+
* The chrome updates in place on the same stable root (pane-style), never a
|
|
45
|
+
* card-style remount — re-attaching the body would reload a live file-preview
|
|
46
|
+
* iframe. While streaming the chrome shows a streaming status and hides copy +
|
|
47
|
+
* custom actions; on complete it swaps the status for the type label and
|
|
48
|
+
* reveals the complete-gated actions. The chrome also carries an optional
|
|
49
|
+
* per-block rendered/source view toggle (availability-gated to file artifacts
|
|
50
|
+
* that have a rendered alternative to their source), which cross-fades the body
|
|
51
|
+
* between the preview and the raw highlighted source.
|
|
52
|
+
*
|
|
53
|
+
* Chrome + built-in actions are configurable via
|
|
54
|
+
* `config.features.artifacts.inlineChrome`
|
|
55
|
+
* (boolean or { showCopy, showExpand, showViewToggle })
|
|
56
|
+
* and `config.features.artifacts.inlineActions`. A full structural override is
|
|
57
|
+
* available via `config.features.artifacts.renderInline` (mirroring the card's
|
|
58
|
+
* `renderCard`), which short-circuits before the default renderer.
|
|
59
|
+
*
|
|
60
|
+
* Body layout is configured via `config.features.artifacts.inlineBody`,
|
|
61
|
+
* resolved here into a flat {@link ArtifactBodyLayout} that is threaded into the
|
|
62
|
+
* shared preview renderer (inline path only — the pane never passes it). This
|
|
63
|
+
* component owns the height model that surrounds that renderer: it sets the
|
|
64
|
+
* `--persona-artifact-inline-body-height` CSS var on the root per state, toggles
|
|
65
|
+
* the complete-state non-iframe height cap on the body wrapper, and wraps the
|
|
66
|
+
* streaming→complete swap in a View Transition when enabled. The default
|
|
67
|
+
* (fixed 320px streaming window, tail-follow, top fade, auto transition) changes
|
|
68
|
+
* the pre-existing grow-with-content streaming behavior; `inlineBody.height:
|
|
69
|
+
* "auto"` restores it.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
export type InlineArtifactUpdateOptions = {
|
|
73
|
+
/**
|
|
74
|
+
* Skip the streaming→complete View Transition for this update. The View
|
|
75
|
+
* Transition captures the whole document, so running it while the transcript
|
|
76
|
+
* is still streaming cross-fades a stale snapshot of the moving message text
|
|
77
|
+
* over the live text — which reads as ghosting/motion blur on the chat
|
|
78
|
+
* messages. The body swap still happens, just instantly.
|
|
79
|
+
*/
|
|
80
|
+
suppressTransition?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const inlineBlockUpdaters = new WeakMap<
|
|
84
|
+
HTMLElement,
|
|
85
|
+
(record: PersonaArtifactRecord, opts?: InlineArtifactUpdateOptions) => void
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Push the current session artifact registry state into every inline artifact
|
|
90
|
+
* block under `root`. Blocks whose artifact has no registry record (e.g. after
|
|
91
|
+
* a page refresh) are left on their props render — that render already carries
|
|
92
|
+
* the complete content once the client embedded it on `artifact_complete`.
|
|
93
|
+
*
|
|
94
|
+
* v1 tradeoff: an empty registry (e.g. `clearArtifacts()` mid-stream) is a
|
|
95
|
+
* no-op, so a block already showing pushed content stays frozen on it rather
|
|
96
|
+
* than reverting to its props render. Resetting to props on every clear would
|
|
97
|
+
* churn the common case and reload live file-preview iframes; accepted as-is.
|
|
98
|
+
*/
|
|
99
|
+
export function updateInlineArtifactBlocks(
|
|
100
|
+
root: HTMLElement,
|
|
101
|
+
artifacts: PersonaArtifactRecord[],
|
|
102
|
+
opts?: InlineArtifactUpdateOptions
|
|
103
|
+
): void {
|
|
104
|
+
if (artifacts.length === 0) return;
|
|
105
|
+
const byId = new Map(artifacts.map((a) => [a.id, a]));
|
|
106
|
+
root.querySelectorAll<HTMLElement>("[data-artifact-inline]").forEach((el) => {
|
|
107
|
+
const update = inlineBlockUpdaters.get(el);
|
|
108
|
+
if (!update) return;
|
|
109
|
+
const record = byId.get(el.getAttribute("data-artifact-inline") ?? "");
|
|
110
|
+
if (record) update(record, opts);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether `el` is an inline artifact block root with a live registry updater.
|
|
116
|
+
* Lets the message renderer (ui.ts) reuse a mounted block across bubble
|
|
117
|
+
* rebuilds instead of replacing it.
|
|
118
|
+
*/
|
|
119
|
+
export function hasLiveInlineArtifactBlock(el: HTMLElement): boolean {
|
|
120
|
+
return inlineBlockUpdaters.has(el);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Rebuild a preview record from the persisted block props (hydration path). */
|
|
124
|
+
function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord {
|
|
125
|
+
const id = typeof props.artifactId === "string" ? props.artifactId : "";
|
|
126
|
+
const title =
|
|
127
|
+
typeof props.title === "string" && props.title ? props.title : undefined;
|
|
128
|
+
const status = props.status === "streaming" ? "streaming" : "complete";
|
|
129
|
+
if (props.artifactType === "component") {
|
|
130
|
+
const embedded = props.componentProps;
|
|
131
|
+
const componentProps =
|
|
132
|
+
embedded && typeof embedded === "object" && !Array.isArray(embedded)
|
|
133
|
+
? (embedded as Record<string, unknown>)
|
|
134
|
+
: {};
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
artifactType: "component",
|
|
138
|
+
title,
|
|
139
|
+
status,
|
|
140
|
+
component: typeof props.component === "string" ? props.component : "",
|
|
141
|
+
props: componentProps
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const file =
|
|
145
|
+
props.file && typeof props.file === "object" && !Array.isArray(props.file)
|
|
146
|
+
? (props.file as PersonaArtifactFileMeta)
|
|
147
|
+
: undefined;
|
|
148
|
+
return {
|
|
149
|
+
id,
|
|
150
|
+
artifactType: "markdown",
|
|
151
|
+
title,
|
|
152
|
+
status,
|
|
153
|
+
markdown: typeof props.markdown === "string" ? props.markdown : "",
|
|
154
|
+
...(file ? { file } : {})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Title (basename for file artifacts) + type label, derived like the card. */
|
|
159
|
+
function chromeLabelsFor(record: PersonaArtifactRecord): {
|
|
160
|
+
title: string;
|
|
161
|
+
typeLabel: string;
|
|
162
|
+
} {
|
|
163
|
+
const file = record.artifactType === "markdown" ? record.file : undefined;
|
|
164
|
+
const title = file
|
|
165
|
+
? basenameOf(file.path)
|
|
166
|
+
: record.title && record.title.trim()
|
|
167
|
+
? record.title
|
|
168
|
+
: "Untitled artifact";
|
|
169
|
+
const typeLabel = file
|
|
170
|
+
? fileTypeLabel(file)
|
|
171
|
+
: record.artifactType === "component"
|
|
172
|
+
? "Component"
|
|
173
|
+
: "Document";
|
|
174
|
+
return { title, typeLabel };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the card component's prop shape from a live preview record, for the
|
|
179
|
+
* `completeDisplay: "card"` collapse. The card renderer reads title / status /
|
|
180
|
+
* type / id and (for markdown records) markdown + file meta; component records
|
|
181
|
+
* only need id + title + type. Mirrors the prop keys `recordFromProps` reads.
|
|
182
|
+
*/
|
|
183
|
+
function cardPropsFromRecord(
|
|
184
|
+
record: PersonaArtifactRecord
|
|
185
|
+
): Record<string, unknown> {
|
|
186
|
+
const props: Record<string, unknown> = {
|
|
187
|
+
artifactId: record.id,
|
|
188
|
+
title: record.title ?? "",
|
|
189
|
+
status: record.status,
|
|
190
|
+
artifactType: record.artifactType
|
|
191
|
+
};
|
|
192
|
+
if (record.artifactType === "markdown") {
|
|
193
|
+
if (typeof record.markdown === "string") props.markdown = record.markdown;
|
|
194
|
+
if (record.file) props.file = record.file;
|
|
195
|
+
}
|
|
196
|
+
return props;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Tuning knobs for the `completeDisplay: "card"` collapse handoff (see
|
|
201
|
+
* animateInlineCollapse below). All values are milliseconds unless noted.
|
|
202
|
+
*/
|
|
203
|
+
/** Phase 1: how long the streamed chrome + body fade out in place. */
|
|
204
|
+
const COLLAPSE_FADE_OUT_MS = 180;
|
|
205
|
+
/** Phase 2 height collapse: base duration before the distance term. */
|
|
206
|
+
const COLLAPSE_HEIGHT_BASE_MS = 240;
|
|
207
|
+
/** Phase 2 height collapse: extra ms per pixel of collapse distance. */
|
|
208
|
+
const COLLAPSE_HEIGHT_MS_PER_PX = 0.8;
|
|
209
|
+
/** Phase 2 height collapse: duration clamp (min/max). */
|
|
210
|
+
const COLLAPSE_HEIGHT_MIN_MS = 300;
|
|
211
|
+
const COLLAPSE_HEIGHT_MAX_MS = 500;
|
|
212
|
+
/** Phase 2 height collapse: easing (M3 standard curve). */
|
|
213
|
+
const COLLAPSE_HEIGHT_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
|
214
|
+
/** Card fade-in duration. */
|
|
215
|
+
const COLLAPSE_CARD_FADE_IN_MS = 240;
|
|
216
|
+
/**
|
|
217
|
+
* Card fade-in start, as a fraction of the height duration (0 = fade in with
|
|
218
|
+
* the collapse, 1 = only after the height has fully settled).
|
|
219
|
+
*/
|
|
220
|
+
const COLLAPSE_CARD_FADE_DELAY_FRACTION = 0.35;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Collapse animation for `completeDisplay: "card"`: fade the streamed chrome +
|
|
224
|
+
* body out under a locked height, then `swap()` in the card and collapse the
|
|
225
|
+
* height down to it while the card fades in. `onSettled` fires exactly once at
|
|
226
|
+
* the end so the caller can hand updater ownership back to the card. Timings
|
|
227
|
+
* live in the COLLAPSE_* constants above; the height duration scales with the
|
|
228
|
+
* collapse distance.
|
|
229
|
+
*
|
|
230
|
+
* Runs on the Web Animations API, not CSS transitions: the completion window
|
|
231
|
+
* re-renders the transcript several times (props embed, turn/execution
|
|
232
|
+
* completion, lazy-chunk rebuilds) and may reparent this root, and a DOM move
|
|
233
|
+
* cancels CSS transitions but not `element.animate()`.
|
|
234
|
+
*
|
|
235
|
+
* Degrades to an instant swap under `prefers-reduced-motion`, without layout
|
|
236
|
+
* (detached / zero-height measure in tests), or without `element.animate`.
|
|
237
|
+
* `swap()` and `onSettled()` always run.
|
|
238
|
+
*/
|
|
239
|
+
function animateInlineCollapse(
|
|
240
|
+
root: HTMLElement,
|
|
241
|
+
startHeight: number,
|
|
242
|
+
opts: { swap: () => void; onSettled: () => void }
|
|
243
|
+
): void {
|
|
244
|
+
const { swap, onSettled } = opts;
|
|
245
|
+
let reduceMotion = false;
|
|
246
|
+
try {
|
|
247
|
+
reduceMotion =
|
|
248
|
+
typeof window !== "undefined" &&
|
|
249
|
+
typeof window.matchMedia === "function" &&
|
|
250
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
251
|
+
} catch {
|
|
252
|
+
reduceMotion = false;
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
!startHeight ||
|
|
256
|
+
!root.isConnected ||
|
|
257
|
+
reduceMotion ||
|
|
258
|
+
typeof root.animate !== "function"
|
|
259
|
+
) {
|
|
260
|
+
swap();
|
|
261
|
+
onSettled();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Phase 1 — fade the streamed content out under a locked height. The lock
|
|
266
|
+
// is inline style (survives DOM moves); the fades are WAAPI (survive too).
|
|
267
|
+
root.style.height = `${startHeight}px`;
|
|
268
|
+
root.style.overflow = "hidden";
|
|
269
|
+
const fadeOuts: Animation[] = [];
|
|
270
|
+
for (const child of Array.from(root.children)) {
|
|
271
|
+
if (child instanceof HTMLElement) {
|
|
272
|
+
fadeOuts.push(
|
|
273
|
+
child.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
274
|
+
duration: COLLAPSE_FADE_OUT_MS,
|
|
275
|
+
easing: "ease-out",
|
|
276
|
+
fill: "forwards"
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
window.setTimeout(() => {
|
|
283
|
+
// The faded-out children are about to be replaced; drop their fills so
|
|
284
|
+
// the animations don't linger.
|
|
285
|
+
for (const anim of fadeOuts) anim.cancel();
|
|
286
|
+
swap();
|
|
287
|
+
const clearRoot = () => {
|
|
288
|
+
root.style.removeProperty("height");
|
|
289
|
+
root.style.removeProperty("overflow");
|
|
290
|
+
};
|
|
291
|
+
if (!root.isConnected) {
|
|
292
|
+
clearRoot();
|
|
293
|
+
onSettled();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Measure the card height without painting a frame: auto → read → relock.
|
|
298
|
+
root.style.height = "auto";
|
|
299
|
+
const endHeight = root.getBoundingClientRect().height;
|
|
300
|
+
root.style.height = `${startHeight}px`;
|
|
301
|
+
if (!endHeight || Math.abs(endHeight - startHeight) < 1) {
|
|
302
|
+
clearRoot();
|
|
303
|
+
onSettled();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Phase 2 — collapse the height while the card fades in.
|
|
308
|
+
const distance = Math.abs(startHeight - endHeight);
|
|
309
|
+
const duration = Math.round(
|
|
310
|
+
Math.min(
|
|
311
|
+
COLLAPSE_HEIGHT_MAX_MS,
|
|
312
|
+
Math.max(
|
|
313
|
+
COLLAPSE_HEIGHT_MIN_MS,
|
|
314
|
+
COLLAPSE_HEIGHT_BASE_MS + distance * COLLAPSE_HEIGHT_MS_PER_PX
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
const cardFadeDelay = Math.round(
|
|
319
|
+
duration * COLLAPSE_CARD_FADE_DELAY_FRACTION
|
|
320
|
+
);
|
|
321
|
+
const card =
|
|
322
|
+
root.firstElementChild instanceof HTMLElement
|
|
323
|
+
? root.firstElementChild
|
|
324
|
+
: null;
|
|
325
|
+
const cardFade = card
|
|
326
|
+
? card.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
327
|
+
duration: COLLAPSE_CARD_FADE_IN_MS,
|
|
328
|
+
delay: cardFadeDelay,
|
|
329
|
+
easing: "ease-out",
|
|
330
|
+
fill: "backwards"
|
|
331
|
+
})
|
|
332
|
+
: null;
|
|
333
|
+
const heightAnim = root.animate(
|
|
334
|
+
[{ height: `${startHeight}px` }, { height: `${endHeight}px` }],
|
|
335
|
+
{ duration, easing: COLLAPSE_HEIGHT_EASING }
|
|
336
|
+
);
|
|
337
|
+
// The animation overrides the inline lock while it plays; park the inline
|
|
338
|
+
// height on the end value so there is no jump when it finishes.
|
|
339
|
+
root.style.height = `${endHeight}px`;
|
|
340
|
+
|
|
341
|
+
let done = false;
|
|
342
|
+
const clear = () => {
|
|
343
|
+
if (done) return;
|
|
344
|
+
done = true;
|
|
345
|
+
clearRoot();
|
|
346
|
+
onSettled();
|
|
347
|
+
};
|
|
348
|
+
// Settle when BOTH animations are done (the card fade can outlast the
|
|
349
|
+
// height collapse); a cancelled animation rejects `finished`, which
|
|
350
|
+
// allSettled treats as done.
|
|
351
|
+
Promise.allSettled(
|
|
352
|
+
[heightAnim.finished, cardFade?.finished].filter(Boolean)
|
|
353
|
+
).then(clear);
|
|
354
|
+
// Fallback in case `finished` never resolves (interrupted layout, etc.).
|
|
355
|
+
// Covers whichever ends later: the height collapse or the card fade-in.
|
|
356
|
+
window.setTimeout(
|
|
357
|
+
clear,
|
|
358
|
+
Math.max(duration, cardFadeDelay + COLLAPSE_CARD_FADE_IN_MS) + 120
|
|
359
|
+
);
|
|
360
|
+
}, COLLAPSE_FADE_OUT_MS);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Resolve the inline chrome config knobs (chrome on by default). */
|
|
364
|
+
function resolveInlineChrome(cfg: AgentWidgetArtifactsFeature | undefined): {
|
|
365
|
+
chromeEnabled: boolean;
|
|
366
|
+
showCopy: boolean;
|
|
367
|
+
showExpand: boolean;
|
|
368
|
+
showViewToggle: boolean;
|
|
369
|
+
} {
|
|
370
|
+
const inlineChrome = cfg?.inlineChrome;
|
|
371
|
+
if (inlineChrome === false) {
|
|
372
|
+
return {
|
|
373
|
+
chromeEnabled: false,
|
|
374
|
+
showCopy: false,
|
|
375
|
+
showExpand: false,
|
|
376
|
+
showViewToggle: false
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const opts = typeof inlineChrome === "object" ? inlineChrome : undefined;
|
|
380
|
+
return {
|
|
381
|
+
chromeEnabled: true,
|
|
382
|
+
showCopy: opts?.showCopy !== false,
|
|
383
|
+
showExpand: opts?.showExpand !== false,
|
|
384
|
+
showViewToggle: opts?.showViewToggle !== false
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** CSS var carrying the current-state numeric body height (px); unset = "auto". */
|
|
389
|
+
const BODY_HEIGHT_VAR = "--persona-artifact-inline-body-height";
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolve `features.artifacts.inlineBody` into the flat {@link ArtifactBodyLayout}
|
|
393
|
+
* the shared preview renderer consumes. Defaults: 320px both states, source
|
|
394
|
+
* streaming view, rendered complete view, tail-follow on, top+bottom edge fade
|
|
395
|
+
* (each edge clip-gated at render time), auto transition.
|
|
396
|
+
*/
|
|
397
|
+
export function resolveInlineBody(
|
|
398
|
+
cfg: AgentWidgetArtifactsFeature | undefined
|
|
399
|
+
): ArtifactBodyLayout {
|
|
400
|
+
const b = cfg?.inlineBody;
|
|
401
|
+
const streamingView = b?.streamingView === "status" ? "status" : "source";
|
|
402
|
+
const viewMode = b?.viewMode === "source" ? "source" : "rendered";
|
|
403
|
+
|
|
404
|
+
let streamingHeight: number | "auto" = 320;
|
|
405
|
+
let completeHeight: number | "auto" = 320;
|
|
406
|
+
const h = b?.height;
|
|
407
|
+
if (typeof h === "number" || h === "auto") {
|
|
408
|
+
streamingHeight = h;
|
|
409
|
+
completeHeight = h;
|
|
410
|
+
} else if (h && typeof h === "object") {
|
|
411
|
+
streamingHeight = h.streaming ?? 320;
|
|
412
|
+
completeHeight = h.complete ?? 320;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Clip mode shows the top of the document in a static window: no tail-follow
|
|
416
|
+
// (followOutput is ignored — the window never scrolls), and its unset-fadeMask
|
|
417
|
+
// default flips to bottom-only (the top is always visible, so a top fade would
|
|
418
|
+
// never be correct). An explicit fadeMask still wins below.
|
|
419
|
+
const overflow = b?.overflow === "clip" ? "clip" : "scroll";
|
|
420
|
+
const followOutput = overflow === "clip" ? false : b?.followOutput !== false;
|
|
421
|
+
|
|
422
|
+
// fadeMask: when unset, the default depends on the overflow mode — scroll gets
|
|
423
|
+
// both edges (top + bottom), clip gets bottom-only. An explicit value (boolean
|
|
424
|
+
// or object) always wins over that mode default. Each edge is clip-gated at
|
|
425
|
+
// runtime in artifact-preview.ts `updateFadeClasses` (only shown when that edge
|
|
426
|
+
// is actually clipped), so with scroll+tail-follow pinned at the bottom the
|
|
427
|
+
// bottom fade stays visually inert until the reader scrolls up mid-stream.
|
|
428
|
+
let fadeTop: boolean;
|
|
429
|
+
let fadeBottom: boolean;
|
|
430
|
+
const fm = b?.fadeMask;
|
|
431
|
+
if (fm === true) {
|
|
432
|
+
fadeTop = true;
|
|
433
|
+
fadeBottom = true;
|
|
434
|
+
} else if (fm === false) {
|
|
435
|
+
fadeTop = false;
|
|
436
|
+
fadeBottom = false;
|
|
437
|
+
} else if (fm && typeof fm === "object") {
|
|
438
|
+
fadeTop = fm.top === true;
|
|
439
|
+
fadeBottom = fm.bottom === true;
|
|
440
|
+
} else if (overflow === "clip") {
|
|
441
|
+
fadeTop = false;
|
|
442
|
+
fadeBottom = true;
|
|
443
|
+
} else {
|
|
444
|
+
fadeTop = true;
|
|
445
|
+
fadeBottom = true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const transition = b?.transition === "none" ? "none" : "auto";
|
|
449
|
+
const completeDisplay = b?.completeDisplay === "card" ? "card" : "inline";
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
streamingView,
|
|
453
|
+
viewMode,
|
|
454
|
+
streamingHeight,
|
|
455
|
+
completeHeight,
|
|
456
|
+
followOutput,
|
|
457
|
+
overflow,
|
|
458
|
+
fadeTop,
|
|
459
|
+
fadeBottom,
|
|
460
|
+
transition,
|
|
461
|
+
completeDisplay
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderDefaultArtifactInline(
|
|
466
|
+
props: Record<string, unknown>,
|
|
467
|
+
context: ComponentContext
|
|
468
|
+
): HTMLElement {
|
|
469
|
+
const record = recordFromProps(props);
|
|
470
|
+
const artifactsCfg = context.config?.features?.artifacts;
|
|
471
|
+
const { chromeEnabled, showCopy, showExpand, showViewToggle } =
|
|
472
|
+
resolveInlineChrome(artifactsCfg);
|
|
473
|
+
const inlineActions = artifactsCfg?.inlineActions ?? [];
|
|
474
|
+
const bodyLayout = resolveInlineBody(artifactsCfg);
|
|
475
|
+
const collapseToCard = bodyLayout.completeDisplay === "card";
|
|
476
|
+
|
|
477
|
+
// Per-block rendered/source view toggle state. `null` = follow the configured
|
|
478
|
+
// default (`bodyLayout.viewMode`); a set value is the user's explicit choice
|
|
479
|
+
// for this block. Reset to `null` when content restarts streaming or a
|
|
480
|
+
// different artifact id arrives (see the block updater below) so hydrated/new
|
|
481
|
+
// content starts on the configured default. `currentRecord` tracks the latest
|
|
482
|
+
// record so the toggle's direct click handler re-renders against fresh content.
|
|
483
|
+
let userViewMode: "rendered" | "source" | null = null;
|
|
484
|
+
let currentRecord = record;
|
|
485
|
+
|
|
486
|
+
const root = createElement(
|
|
487
|
+
"div",
|
|
488
|
+
"persona-artifact-inline persona-w-full persona-max-w-full"
|
|
489
|
+
);
|
|
490
|
+
root.setAttribute("data-persona-theme-zone", "artifact-inline");
|
|
491
|
+
if (record.id) {
|
|
492
|
+
root.setAttribute("data-artifact-inline", record.id);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// completeDisplay: "card" — collapse the block to the compact reference card
|
|
496
|
+
// once the artifact completes. `PersonaArtifactCard` is the public card entry,
|
|
497
|
+
// so a host `features.artifacts.renderCard` override still applies. The card
|
|
498
|
+
// is mounted on the SAME root (data-artifact-inline keying + theme zone
|
|
499
|
+
// survive); the frame chrome styling is dropped via a modifier class because
|
|
500
|
+
// the card carries its own border/background. The registered updater becomes a
|
|
501
|
+
// card-sync updater so later records (title/status changes) re-render the card.
|
|
502
|
+
const renderCardChild = (rec: PersonaArtifactRecord): HTMLElement =>
|
|
503
|
+
PersonaArtifactCard(cardPropsFromRecord(rec), context);
|
|
504
|
+
const mountCard = (rec: PersonaArtifactRecord): void => {
|
|
505
|
+
root.classList.add("persona-artifact-inline--card");
|
|
506
|
+
root.style.removeProperty(BODY_HEIGHT_VAR);
|
|
507
|
+
root.replaceChildren(renderCardChild(rec));
|
|
508
|
+
inlineBlockUpdaters.set(root, (r) => {
|
|
509
|
+
root.replaceChildren(renderCardChild(r));
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Hydration: a block whose props arrive already-complete renders the card
|
|
514
|
+
// directly — no flash of the inline body, no collapse animation.
|
|
515
|
+
if (collapseToCard && record.status === "complete") {
|
|
516
|
+
mountCard(record);
|
|
517
|
+
return root;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const handle = renderArtifactPreviewBody(record, {
|
|
521
|
+
config: context.config,
|
|
522
|
+
bodyLayout,
|
|
523
|
+
// The user's per-block toggle wins over the configured default; `null`
|
|
524
|
+
// falls back to `bodyLayout.viewMode`, so with no toggle interaction this
|
|
525
|
+
// is behavior-identical to passing no `resolveViewMode` at all.
|
|
526
|
+
resolveViewMode: () => userViewMode ?? bodyLayout.viewMode
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Body wrapper is always present so the frame's padding lives here (chrome
|
|
530
|
+
// sits flush to the frame edge); required even when chrome is disabled.
|
|
531
|
+
const body = createElement("div", "persona-artifact-inline-body");
|
|
532
|
+
body.appendChild(handle.el);
|
|
533
|
+
|
|
534
|
+
// Height model (inlineBody): set the current-state numeric height as a CSS
|
|
535
|
+
// var on the root, then classify the rendered body (post-render — the shared
|
|
536
|
+
// renderer owns the pre/iframe/markdown branching, mirroring the pane's flush
|
|
537
|
+
// toggle):
|
|
538
|
+
// - code body → `persona-artifact-content-flush`, full-bleed like the pane's
|
|
539
|
+
// source view (gutter flush to the frame edge)
|
|
540
|
+
// - fill-capable body (fixed source window / iframe / status placeholder) →
|
|
541
|
+
// the wrapper owns the reserved height (border-box), so flush and padded
|
|
542
|
+
// states occupy the same outer box and every swap is layout neutral
|
|
543
|
+
// - flow body (rendered markdown, component) → complete-state max-height cap
|
|
544
|
+
const applyBodyHeight = (rec: PersonaArtifactRecord) => {
|
|
545
|
+
const streaming = rec.status !== "complete";
|
|
546
|
+
const h = streaming ? bodyLayout.streamingHeight : bodyLayout.completeHeight;
|
|
547
|
+
const numeric = typeof h === "number";
|
|
548
|
+
if (numeric) {
|
|
549
|
+
root.style.setProperty(BODY_HEIGHT_VAR, `${h}px`);
|
|
550
|
+
} else {
|
|
551
|
+
root.style.removeProperty(BODY_HEIGHT_VAR);
|
|
552
|
+
}
|
|
553
|
+
const hasCodePre = Boolean(handle.el.querySelector(".persona-code-pre"));
|
|
554
|
+
const fillCapable = Boolean(
|
|
555
|
+
handle.el.querySelector(
|
|
556
|
+
"iframe, .persona-artifact-source-window--fixed, .persona-artifact-status-view"
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
body.classList.toggle("persona-artifact-content-flush", hasCodePre);
|
|
560
|
+
body.classList.toggle(
|
|
561
|
+
"persona-artifact-inline-body--sized",
|
|
562
|
+
numeric && fillCapable
|
|
563
|
+
);
|
|
564
|
+
body.classList.toggle(
|
|
565
|
+
"persona-artifact-inline-body--cap",
|
|
566
|
+
!streaming && !fillCapable && typeof bodyLayout.completeHeight === "number"
|
|
567
|
+
);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// Body updater: set the height var, run the (optionally animated) swap on the
|
|
571
|
+
// streaming→complete boundary, then re-apply the complete-state cap.
|
|
572
|
+
let lastBodyStatus: string = record.status;
|
|
573
|
+
const runBodyUpdate = (
|
|
574
|
+
rec: PersonaArtifactRecord,
|
|
575
|
+
opts?: InlineArtifactUpdateOptions
|
|
576
|
+
) => {
|
|
577
|
+
const boundary = lastBodyStatus !== "complete" && rec.status === "complete";
|
|
578
|
+
if (boundary) {
|
|
579
|
+
// Pre-set the target (complete) height so the reserved streaming window
|
|
580
|
+
// and the completed body share a height and the swap is layout-neutral.
|
|
581
|
+
if (typeof bodyLayout.completeHeight === "number") {
|
|
582
|
+
root.style.setProperty(BODY_HEIGHT_VAR, `${bodyLayout.completeHeight}px`);
|
|
583
|
+
} else {
|
|
584
|
+
root.style.removeProperty(BODY_HEIGHT_VAR);
|
|
585
|
+
}
|
|
586
|
+
// Re-apply inside the swap too: with a live View Transition the swap
|
|
587
|
+
// callback runs after the snapshot, so the outer applyBodyHeight below
|
|
588
|
+
// would read the pre-swap DOM for iframe/window detection.
|
|
589
|
+
runArtifactBodyTransition(
|
|
590
|
+
body,
|
|
591
|
+
opts?.suppressTransition ? "none" : bodyLayout.transition,
|
|
592
|
+
rec.id,
|
|
593
|
+
() => {
|
|
594
|
+
handle.update(rec);
|
|
595
|
+
applyBodyHeight(rec);
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
} else {
|
|
599
|
+
handle.update(rec);
|
|
600
|
+
}
|
|
601
|
+
applyBodyHeight(rec);
|
|
602
|
+
lastBodyStatus = rec.status;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
applyBodyHeight(record);
|
|
606
|
+
|
|
607
|
+
// Clip-mode expand hitbox: in `inlineBody.overflow: "clip"` the body shows a
|
|
608
|
+
// static top-of-document window with no internal scroll, so — when the chrome's
|
|
609
|
+
// Expand control is enabled and the record has an id — the whole body doubles
|
|
610
|
+
// as the expand affordance (industry inline-card pattern). Reuse the existing
|
|
611
|
+
// `data-expand-artifact-inline` delegation in ui.ts (matched via closest(), so
|
|
612
|
+
// a body-level attribute works; Enter/Space are handled by the same keydown
|
|
613
|
+
// delegation). The aria-label is refreshed in updateChrome as the title
|
|
614
|
+
// streams in. When Expand is disabled the clip stays purely visual.
|
|
615
|
+
const clipExpandHitbox =
|
|
616
|
+
bodyLayout.overflow === "clip" && showExpand && !!record.id;
|
|
617
|
+
if (clipExpandHitbox) {
|
|
618
|
+
body.setAttribute("data-expand-artifact-inline", record.id);
|
|
619
|
+
body.setAttribute("role", "button");
|
|
620
|
+
body.setAttribute("tabindex", "0");
|
|
621
|
+
body.classList.add("persona-cursor-pointer");
|
|
622
|
+
body.setAttribute(
|
|
623
|
+
"aria-label",
|
|
624
|
+
`Open ${chromeLabelsFor(record).title} in panel`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!chromeEnabled) {
|
|
629
|
+
root.appendChild(body);
|
|
630
|
+
inlineBlockUpdaters.set(root, runBodyUpdate);
|
|
631
|
+
return root;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Chrome bar: [icon][title][type/status] … [inlineActions][copy][expand].
|
|
635
|
+
const chrome = createElement("div", "persona-artifact-inline-chrome");
|
|
636
|
+
chrome.setAttribute("data-persona-theme-zone", "artifact-inline-chrome");
|
|
637
|
+
|
|
638
|
+
const lead = createElement("div", "persona-artifact-inline-chrome-lead");
|
|
639
|
+
const iconWrap = createElement(
|
|
640
|
+
"span",
|
|
641
|
+
"persona-flex persona-items-center persona-flex-shrink-0"
|
|
642
|
+
);
|
|
643
|
+
const icon = renderLucideIcon("file-text", 16, "currentColor", 2);
|
|
644
|
+
if (icon) iconWrap.appendChild(icon);
|
|
645
|
+
const titleEl = createElement(
|
|
646
|
+
"span",
|
|
647
|
+
"persona-artifact-inline-title persona-truncate persona-min-w-0"
|
|
648
|
+
);
|
|
649
|
+
// Meta span toggles between the type label (complete) and the animated
|
|
650
|
+
// streaming status; rebuilt in place on every update.
|
|
651
|
+
const metaEl = createElement("span", "persona-artifact-inline-type");
|
|
652
|
+
lead.append(iconWrap, titleEl, metaEl);
|
|
653
|
+
|
|
654
|
+
const actions = createElement("div", "persona-artifact-inline-chrome-actions");
|
|
655
|
+
// Custom inline actions are rebuilt per update so per-artifact visible() gates
|
|
656
|
+
// re-evaluate as content materializes; they carry no direct listeners (clicks
|
|
657
|
+
// are delegated in ui.ts via data-artifact-custom-action, keyed to the nearest
|
|
658
|
+
// [data-artifact-inline] container).
|
|
659
|
+
const customActionsWrap = createElement(
|
|
660
|
+
"span",
|
|
661
|
+
"persona-flex persona-items-center persona-gap-1"
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const effectiveViewMode = (): "rendered" | "source" =>
|
|
665
|
+
userViewMode ?? bodyLayout.viewMode;
|
|
666
|
+
|
|
667
|
+
// Rendered/source view toggle: the same segmented toggle group the artifact
|
|
668
|
+
// pane uses (createToggleGroup), so the inline chrome matches the pane's UX.
|
|
669
|
+
// Both segments (eye "Rendered view", code-xml "Source") are always visible and
|
|
670
|
+
// the active one is highlighted. Created only when enabled; per-record
|
|
671
|
+
// availability is gated in updateChrome via persona-hidden on the whole group,
|
|
672
|
+
// like copy. updateChrome also syncs the selected segment on every update.
|
|
673
|
+
const viewToggle = showViewToggle
|
|
674
|
+
? createToggleGroup({
|
|
675
|
+
items: [
|
|
676
|
+
{
|
|
677
|
+
id: "rendered",
|
|
678
|
+
icon: "eye",
|
|
679
|
+
label: "Rendered view",
|
|
680
|
+
className: "persona-artifact-doc-icon-btn persona-artifact-view-btn"
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
id: "source",
|
|
684
|
+
icon: "code-xml",
|
|
685
|
+
label: "Source",
|
|
686
|
+
className: "persona-artifact-doc-icon-btn persona-artifact-code-btn"
|
|
687
|
+
}
|
|
688
|
+
],
|
|
689
|
+
selectedId: effectiveViewMode(),
|
|
690
|
+
className: "persona-artifact-toggle-group persona-flex-shrink-0",
|
|
691
|
+
// Direct handlers via the group's own click listeners (deliberate
|
|
692
|
+
// deviation from the delegated copy/expand pattern): the toggle is purely
|
|
693
|
+
// block-local state + a re-render of this block's own preview handle. It
|
|
694
|
+
// needs no session access, and the chrome element is stable for the
|
|
695
|
+
// block's lifetime (never remounted), so direct handlers are simpler and
|
|
696
|
+
// correct here — no delegation round-trip needed.
|
|
697
|
+
onSelect: (id) => {
|
|
698
|
+
const next = id === "source" ? "source" : "rendered";
|
|
699
|
+
// Selecting the already-active segment is a no-op (createToggleGroup
|
|
700
|
+
// still fires onSelect on a re-click of the active segment).
|
|
701
|
+
if (next === effectiveViewMode()) return;
|
|
702
|
+
userViewMode = next;
|
|
703
|
+
// Known tradeoff: toggling away from a live file-preview iframe drops it,
|
|
704
|
+
// and toggling back rebuilds it (srcdoc reload → iframe-internal state
|
|
705
|
+
// lost). Same behavior as the pane's toggle; we deliberately do not keep
|
|
706
|
+
// a detached iframe alive.
|
|
707
|
+
//
|
|
708
|
+
// Swap instantly — no runArtifactBodyTransition here. The View
|
|
709
|
+
// Transition exists for the streaming→complete swap; on a toggle
|
|
710
|
+
// click its document-wide snapshot crossfade would paint over the
|
|
711
|
+
// group's sliding-thumb animation (which plays inside the snapshot
|
|
712
|
+
// window and reads as a plain dissolve). The pane's toggle also
|
|
713
|
+
// swaps its body instantly, so this keeps the two in parity: the
|
|
714
|
+
// thumb slide IS the toggle's animation.
|
|
715
|
+
handle.update(currentRecord);
|
|
716
|
+
applyBodyHeight(currentRecord);
|
|
717
|
+
}
|
|
718
|
+
})
|
|
719
|
+
: null;
|
|
720
|
+
|
|
721
|
+
const copyBtn = showCopy
|
|
722
|
+
? createIconButton({
|
|
723
|
+
icon: "copy",
|
|
724
|
+
label: "Copy",
|
|
725
|
+
className: "persona-artifact-doc-icon-btn persona-flex-shrink-0"
|
|
726
|
+
})
|
|
727
|
+
: null;
|
|
728
|
+
if (copyBtn && record.id) {
|
|
729
|
+
// No direct listener: delegated in ui.ts via data-copy-artifact so the
|
|
730
|
+
// handler survives re-renders and page-refresh hydration.
|
|
731
|
+
copyBtn.setAttribute("data-copy-artifact", record.id);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const expandBtn = showExpand
|
|
735
|
+
? createIconButton({
|
|
736
|
+
icon: "maximize",
|
|
737
|
+
label: "Open in panel",
|
|
738
|
+
className: "persona-artifact-doc-icon-btn persona-flex-shrink-0"
|
|
739
|
+
})
|
|
740
|
+
: null;
|
|
741
|
+
if (expandBtn && record.id) {
|
|
742
|
+
// Delegated in ui.ts via data-expand-artifact-inline (opens the pane).
|
|
743
|
+
expandBtn.setAttribute("data-expand-artifact-inline", record.id);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
actions.appendChild(customActionsWrap);
|
|
747
|
+
if (viewToggle) actions.appendChild(viewToggle.element);
|
|
748
|
+
if (copyBtn) actions.appendChild(copyBtn);
|
|
749
|
+
if (expandBtn) actions.appendChild(expandBtn);
|
|
750
|
+
|
|
751
|
+
chrome.append(lead, actions);
|
|
752
|
+
root.append(chrome, body);
|
|
753
|
+
|
|
754
|
+
const updateChrome = (rec: PersonaArtifactRecord) => {
|
|
755
|
+
const { title, typeLabel } = chromeLabelsFor(rec);
|
|
756
|
+
titleEl.textContent = title;
|
|
757
|
+
titleEl.title = title;
|
|
758
|
+
// Keep the clip-mode body hitbox's label in sync with the streaming title.
|
|
759
|
+
if (clipExpandHitbox) {
|
|
760
|
+
body.setAttribute("aria-label", `Open ${title} in panel`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const streaming = rec.status !== "complete";
|
|
764
|
+
if (streaming) {
|
|
765
|
+
// Animation-stable: only switch the meta span into status mode (clearing
|
|
766
|
+
// its children) on the type→status transition. On subsequent deltas the
|
|
767
|
+
// span is left in place so applyArtifactStatus can reuse the animated
|
|
768
|
+
// label span (re-applied only when the label text changes) and update the
|
|
769
|
+
// plain detail span freely.
|
|
770
|
+
if (!metaEl.classList.contains("persona-artifact-inline-status")) {
|
|
771
|
+
metaEl.className = "persona-artifact-inline-status";
|
|
772
|
+
metaEl.removeAttribute("data-preserve-animation");
|
|
773
|
+
metaEl.replaceChildren();
|
|
774
|
+
}
|
|
775
|
+
const resolved = resolveArtifactStatusLabel(rec, artifactsCfg, "inline-chrome");
|
|
776
|
+
applyArtifactStatus(metaEl, resolved, artifactsCfg);
|
|
777
|
+
} else {
|
|
778
|
+
// Reset the meta span (className replace clears stale animation classes).
|
|
779
|
+
clearArtifactStatusTracking(rec.id);
|
|
780
|
+
metaEl.className = "persona-artifact-inline-type";
|
|
781
|
+
metaEl.removeAttribute("data-preserve-animation");
|
|
782
|
+
metaEl.replaceChildren();
|
|
783
|
+
metaEl.textContent = typeLabel;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Copy + custom actions only act on complete content; expand stays visible
|
|
787
|
+
// through streaming so it is always available.
|
|
788
|
+
if (copyBtn) copyBtn.classList.toggle("persona-hidden", streaming);
|
|
789
|
+
|
|
790
|
+
// View toggle availability: complete file artifacts that actually have a
|
|
791
|
+
// rendered alternative to their source. Plain markdown (no file meta) and
|
|
792
|
+
// component artifacts are hidden here (v1 decision: the pane toggle covers
|
|
793
|
+
// them). Also hidden when the host forced source-only via
|
|
794
|
+
// `inlineBody.viewMode: "source"` — there is no preview to switch to.
|
|
795
|
+
if (viewToggle) {
|
|
796
|
+
const file = rec.artifactType === "markdown" ? rec.file : undefined;
|
|
797
|
+
let canToggle = false;
|
|
798
|
+
if (!streaming && file && bodyLayout.viewMode !== "source") {
|
|
799
|
+
const kind = fileKindOf(file);
|
|
800
|
+
if (kind === "markdown") {
|
|
801
|
+
canToggle = true;
|
|
802
|
+
} else if (kind === "html" || kind === "svg") {
|
|
803
|
+
canToggle = artifactsCfg?.filePreview?.enabled !== false;
|
|
804
|
+
}
|
|
805
|
+
// kind "other" → source-only, no rendered alternative → stays hidden.
|
|
806
|
+
}
|
|
807
|
+
viewToggle.element.classList.toggle("persona-hidden", !canToggle);
|
|
808
|
+
// Sync the group's selected segment on every update: inlineBlockUpdaters
|
|
809
|
+
// resets userViewMode to null on re-stream / new id, so the group must snap
|
|
810
|
+
// back to the configured default here.
|
|
811
|
+
viewToggle.setSelected(effectiveViewMode());
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
customActionsWrap.replaceChildren();
|
|
815
|
+
if (!streaming && inlineActions.length > 0) {
|
|
816
|
+
const ctx = artifactRecordActionContext(rec);
|
|
817
|
+
if (ctx) {
|
|
818
|
+
for (const action of inlineActions) {
|
|
819
|
+
try {
|
|
820
|
+
if (action.visible === undefined || action.visible(ctx)) {
|
|
821
|
+
const btn = buildArtifactActionButton(action, { documentChrome: true });
|
|
822
|
+
btn.setAttribute("data-artifact-custom-action", action.id);
|
|
823
|
+
btn.classList.add("persona-flex-shrink-0");
|
|
824
|
+
customActionsWrap.appendChild(btn);
|
|
825
|
+
}
|
|
826
|
+
} catch {
|
|
827
|
+
// A single bad action must not take down the whole chrome.
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
updateChrome(record);
|
|
835
|
+
|
|
836
|
+
inlineBlockUpdaters.set(root, (rec, opts) => {
|
|
837
|
+
// completeDisplay: "card" — on the streaming→complete boundary, collapse the
|
|
838
|
+
// whole block (chrome + body) to the reference card on the same root instead
|
|
839
|
+
// of swapping the body. Measure the reserved streaming height first, then
|
|
840
|
+
// run the staged collapse (fade out → card mount → height collapse). The
|
|
841
|
+
// WAAPI collapse replaces the body's View Transition here, so
|
|
842
|
+
// `runBodyUpdate` is deliberately bypassed — it must not double-fire
|
|
843
|
+
// runArtifactBodyTransition for this boundary.
|
|
844
|
+
if (
|
|
845
|
+
collapseToCard &&
|
|
846
|
+
lastBodyStatus !== "complete" &&
|
|
847
|
+
rec.status === "complete"
|
|
848
|
+
) {
|
|
849
|
+
const startHeight = root.isConnected
|
|
850
|
+
? root.getBoundingClientRect().height
|
|
851
|
+
: 0;
|
|
852
|
+
lastBodyStatus = "complete";
|
|
853
|
+
// Buffer records for the whole animation — a card-sync replaceChildren
|
|
854
|
+
// mid-collapse would cut the card fade. onSettled hands ownership back
|
|
855
|
+
// to the card-sync updater.
|
|
856
|
+
let latest = rec;
|
|
857
|
+
let dirtySinceSwap = false;
|
|
858
|
+
const buffer = (r: PersonaArtifactRecord) => {
|
|
859
|
+
latest = r;
|
|
860
|
+
dirtySinceSwap = true;
|
|
861
|
+
};
|
|
862
|
+
inlineBlockUpdaters.set(root, buffer);
|
|
863
|
+
animateInlineCollapse(root, startHeight, {
|
|
864
|
+
swap: () => {
|
|
865
|
+
mountCard(latest);
|
|
866
|
+
dirtySinceSwap = false;
|
|
867
|
+
inlineBlockUpdaters.set(root, buffer);
|
|
868
|
+
},
|
|
869
|
+
onSettled: () => {
|
|
870
|
+
// mountCard re-registers the card-sync updater; skip the re-render
|
|
871
|
+
// when nothing changed while the animation ran.
|
|
872
|
+
if (dirtySinceSwap) {
|
|
873
|
+
mountCard(latest);
|
|
874
|
+
} else {
|
|
875
|
+
inlineBlockUpdaters.set(root, (r) => {
|
|
876
|
+
root.replaceChildren(renderCardChild(r));
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
// Reset the per-block view toggle when content restarts streaming or a
|
|
884
|
+
// different artifact id arrives, so hydrated/new content starts on the
|
|
885
|
+
// configured default. Must run before runBodyUpdate → handle.update reads
|
|
886
|
+
// userViewMode via resolveViewMode.
|
|
887
|
+
if (rec.status !== "complete" || rec.id !== currentRecord.id) {
|
|
888
|
+
userViewMode = null;
|
|
889
|
+
}
|
|
890
|
+
currentRecord = rec;
|
|
891
|
+
runBodyUpdate(rec, opts);
|
|
892
|
+
updateChrome(rec);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
return root;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export const PersonaArtifactInline: ComponentRenderer = (props, context) => {
|
|
899
|
+
const customRenderer = context?.config?.features?.artifacts?.renderInline;
|
|
900
|
+
if (customRenderer) {
|
|
901
|
+
const title =
|
|
902
|
+
typeof props.title === "string" && props.title
|
|
903
|
+
? props.title
|
|
904
|
+
: "Untitled artifact";
|
|
905
|
+
const artifactId =
|
|
906
|
+
typeof props.artifactId === "string" ? props.artifactId : "";
|
|
907
|
+
const status = props.status === "streaming" ? "streaming" : "complete";
|
|
908
|
+
const artifactType =
|
|
909
|
+
typeof props.artifactType === "string" ? props.artifactType : "markdown";
|
|
910
|
+
|
|
911
|
+
const result = customRenderer({
|
|
912
|
+
artifact: { artifactId, title, artifactType, status },
|
|
913
|
+
config: context.config,
|
|
914
|
+
defaultRenderer: () => renderDefaultArtifactInline(props, context)
|
|
915
|
+
});
|
|
916
|
+
if (result) return result;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return renderDefaultArtifactInline(props, context);
|
|
920
|
+
};
|