@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
|
@@ -39,6 +39,26 @@ function clearDocumentToolbarLayoutVars(mount: HTMLElement): void {
|
|
|
39
39
|
mount.style.removeProperty("--persona-artifact-doc-toggle-active-border");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const ARTIFACT_APPEARANCE_MODES = ["panel", "seamless", "detached"] as const;
|
|
43
|
+
type ArtifactAppearance = (typeof ARTIFACT_APPEARANCE_MODES)[number];
|
|
44
|
+
|
|
45
|
+
/** Resolve the effective pane appearance, applying the detached-panel coordinated default. */
|
|
46
|
+
function resolveArtifactPaneAppearance(config: AgentWidgetConfig): ArtifactAppearance {
|
|
47
|
+
const raw = config.features?.artifacts?.layout?.paneAppearance;
|
|
48
|
+
if (raw && (ARTIFACT_APPEARANCE_MODES as readonly string[]).includes(raw)) {
|
|
49
|
+
return raw as ArtifactAppearance;
|
|
50
|
+
}
|
|
51
|
+
if (raw) return "panel";
|
|
52
|
+
// Coordinated default: a detached panel gets a detached pane unless overridden.
|
|
53
|
+
return config.launcher?.detachedPanel ? "detached" : "panel";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when the effective pane appearance resolves to "detached" (explicit or coordinated default). */
|
|
57
|
+
export function isArtifactPaneAppearanceDetached(config: AgentWidgetConfig | undefined): boolean {
|
|
58
|
+
if (!config || !artifactsSidebarEnabled(config)) return false;
|
|
59
|
+
return resolveArtifactPaneAppearance(config) === "detached";
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidgetConfig): void {
|
|
43
63
|
if (!artifactsSidebarEnabled(config)) {
|
|
44
64
|
mount.style.removeProperty("--persona-artifact-split-gap");
|
|
@@ -52,7 +72,12 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
|
|
|
52
72
|
return;
|
|
53
73
|
}
|
|
54
74
|
const l = config.features?.artifacts?.layout;
|
|
55
|
-
|
|
75
|
+
// Detached shows the canvas between columns; panel/seamless weld at gap 0.
|
|
76
|
+
const gapDefault =
|
|
77
|
+
resolveArtifactPaneAppearance(config) === "detached"
|
|
78
|
+
? "var(--persona-panel-inset)"
|
|
79
|
+
: "0";
|
|
80
|
+
mount.style.setProperty("--persona-artifact-split-gap", l?.splitGap ?? gapDefault);
|
|
56
81
|
mount.style.setProperty("--persona-artifact-pane-width", l?.paneWidth ?? "40%");
|
|
57
82
|
mount.style.setProperty("--persona-artifact-pane-max-width", l?.paneMaxWidth ?? "28rem");
|
|
58
83
|
if (l?.paneMinWidth) {
|
|
@@ -95,22 +120,18 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
|
|
|
95
120
|
applyArtifactPaneBorderTheme(mount, config);
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
/** Toggle root classes for artifact pane appearance, radius, shadow, and unified chrome. */
|
|
123
|
+
/** Toggle root classes for artifact pane appearance, radius, and shadow vars. */
|
|
101
124
|
export function applyArtifactPaneAppearance(mount: HTMLElement, config: AgentWidgetConfig): void {
|
|
102
125
|
for (const m of ARTIFACT_APPEARANCE_MODES) {
|
|
103
126
|
mount.classList.remove(`persona-artifact-appearance-${m}`);
|
|
104
127
|
}
|
|
105
|
-
mount.classList.remove("persona-artifact-unified-split");
|
|
106
128
|
mount.style.removeProperty("--persona-artifact-pane-radius");
|
|
107
129
|
mount.style.removeProperty("--persona-artifact-pane-shadow");
|
|
108
|
-
mount.style.removeProperty("--persona-artifact-
|
|
130
|
+
mount.style.removeProperty("--persona-artifact-chat-shadow");
|
|
109
131
|
if (!artifactsSidebarEnabled(config)) return;
|
|
110
132
|
|
|
111
133
|
const layout = config.features?.artifacts?.layout;
|
|
112
|
-
const
|
|
113
|
-
const mode = (ARTIFACT_APPEARANCE_MODES as readonly string[]).includes(raw) ? raw : "panel";
|
|
134
|
+
const mode = resolveArtifactPaneAppearance(config);
|
|
114
135
|
mount.classList.add(`persona-artifact-appearance-${mode}`);
|
|
115
136
|
|
|
116
137
|
const radius = layout?.paneBorderRadius?.trim();
|
|
@@ -123,13 +144,17 @@ export function applyArtifactPaneAppearance(mount: HTMLElement, config: AgentWid
|
|
|
123
144
|
mount.style.setProperty("--persona-artifact-pane-shadow", shadow);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
147
|
+
// Chat card gains its own front shadow lookup so a detached split can flatten
|
|
148
|
+
// the chat column while the pane stays raised. Unset falls back to the pane chain.
|
|
149
|
+
const chatShadow = layout?.chatShadow?.trim();
|
|
150
|
+
if (chatShadow) {
|
|
151
|
+
mount.style.setProperty("--persona-artifact-chat-shadow", chatShadow);
|
|
132
152
|
}
|
|
153
|
+
|
|
154
|
+
// Panel/seamless splits weld by default; `unifiedSplitChrome` is a deprecated
|
|
155
|
+
// no-op. The welded outer-right radius (`--persona-artifact-welded-outer-radius`)
|
|
156
|
+
// is owned by ui.ts syncPanelChrome so it derives from the same resolved panel
|
|
157
|
+
// radius as the chat card; `unifiedSplitOuterRadius` / `paneBorderRadius` win there.
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
/** Widen floating panel when artifacts show (default true); `false` opts out. */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AgentWidgetArtifactsFeature } from "../types";
|
|
2
|
+
import { appendCharSpans } from "./tool-loading-animation";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply the artifact "Generating …" streaming animation to a status element.
|
|
6
|
+
*
|
|
7
|
+
* Shared by the artifact reference card and the inline chrome so both surfaces
|
|
8
|
+
* honor `features.artifacts.loadingAnimation` (and the color / duration knobs)
|
|
9
|
+
* identically. Character-by-character modes (`shimmer`, `shimmer-color`,
|
|
10
|
+
* `rainbow`) wrap each character in a `persona-tool-char` span; `pulse` fades
|
|
11
|
+
* the whole element; `none` renders plain text.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns the element and its reset — pass a fresh (or cleared) span so
|
|
14
|
+
* stale animation classes from a previous streaming render do not linger.
|
|
15
|
+
*/
|
|
16
|
+
export function applyArtifactLoadingStatus(
|
|
17
|
+
statusText: HTMLElement,
|
|
18
|
+
text: string,
|
|
19
|
+
artifactsCfg: AgentWidgetArtifactsFeature | undefined
|
|
20
|
+
): void {
|
|
21
|
+
const loadingAnimation = artifactsCfg?.loadingAnimation ?? "shimmer";
|
|
22
|
+
const duration = artifactsCfg?.loadingAnimationDuration ?? 2000;
|
|
23
|
+
|
|
24
|
+
if (loadingAnimation === "none") {
|
|
25
|
+
statusText.textContent = text;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (loadingAnimation === "pulse") {
|
|
30
|
+
statusText.setAttribute("data-preserve-animation", "true");
|
|
31
|
+
statusText.classList.add("persona-tool-loading-pulse");
|
|
32
|
+
statusText.style.setProperty("--persona-tool-anim-duration", `${duration}ms`);
|
|
33
|
+
statusText.textContent = text;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
statusText.setAttribute("data-preserve-animation", "true");
|
|
38
|
+
statusText.classList.add(`persona-tool-loading-${loadingAnimation}`);
|
|
39
|
+
statusText.style.setProperty("--persona-tool-anim-duration", `${duration}ms`);
|
|
40
|
+
if (loadingAnimation === "shimmer-color") {
|
|
41
|
+
if (artifactsCfg?.loadingAnimationColor) {
|
|
42
|
+
statusText.style.setProperty(
|
|
43
|
+
"--persona-tool-anim-color",
|
|
44
|
+
artifactsCfg.loadingAnimationColor
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (artifactsCfg?.loadingAnimationSecondaryColor) {
|
|
48
|
+
statusText.style.setProperty(
|
|
49
|
+
"--persona-tool-anim-secondary-color",
|
|
50
|
+
artifactsCfg.loadingAnimationSecondaryColor
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
appendCharSpans(statusText, text, 0);
|
|
55
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentWidgetArtifactsFeature,
|
|
3
|
+
PersonaArtifactRecord,
|
|
4
|
+
PersonaArtifactStatusLabelContext
|
|
5
|
+
} from "../types";
|
|
6
|
+
import { createElement } from "./dom";
|
|
7
|
+
import { fileTypeLabel } from "./artifact-file";
|
|
8
|
+
import { applyArtifactLoadingStatus } from "./artifact-loading-status";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared resolver + applier for the artifact streaming status label
|
|
12
|
+
* (`features.artifacts.statusLabel`).
|
|
13
|
+
*
|
|
14
|
+
* Three surfaces render the "Generating …" status while an artifact streams:
|
|
15
|
+
* the reference card status line, the inline chrome meta span, and the inline
|
|
16
|
+
* streaming status body. All three resolve their text through
|
|
17
|
+
* {@link resolveArtifactStatusLabel} and paint it through
|
|
18
|
+
* {@link applyArtifactStatus}, so a host `statusLabel` string / function reads
|
|
19
|
+
* identically everywhere.
|
|
20
|
+
*
|
|
21
|
+
* Animation-stability contract: the animated label span is (re)built only when
|
|
22
|
+
* its text changes, so a per-delta detail update (live counters) never restarts
|
|
23
|
+
* the shimmer/pulse/typewriter animation. `applyArtifactStatus` enforces this by
|
|
24
|
+
* tracking the last-applied label text on the label span and by keeping the
|
|
25
|
+
* plain detail span as a separate sibling the animation never touches.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type ArtifactStatusSurface = "card" | "inline-chrome" | "status-body";
|
|
29
|
+
|
|
30
|
+
export type ResolvedArtifactStatus = { label: string; detail?: string };
|
|
31
|
+
|
|
32
|
+
/** Class on the animated label span managed inside a status container. */
|
|
33
|
+
export const STATUS_LABEL_CLASS = "persona-artifact-status-label";
|
|
34
|
+
/** Class on the plain, freely-updating detail span. */
|
|
35
|
+
export const STATUS_DETAIL_CLASS = "persona-artifact-status-detail";
|
|
36
|
+
/** Attribute recording the last label text applied to the label span. */
|
|
37
|
+
const APPLIED_LABEL_ATTR = "data-artifact-status-label";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* First-seen streaming timestamp per artifact id, so `elapsedMs` starts near 0
|
|
41
|
+
* and a completed → re-streamed artifact restarts cleanly. Set on the first
|
|
42
|
+
* streaming resolve; cleared on complete (see {@link clearArtifactStatusTracking})
|
|
43
|
+
* so the map cannot grow without bound.
|
|
44
|
+
*/
|
|
45
|
+
const firstSeenAt = new Map<string, number>();
|
|
46
|
+
|
|
47
|
+
const now = (): number =>
|
|
48
|
+
typeof performance !== "undefined" && typeof performance.now === "function"
|
|
49
|
+
? performance.now()
|
|
50
|
+
: Date.now();
|
|
51
|
+
|
|
52
|
+
/** Drop the elapsed-time tracking for an artifact. Call on complete. */
|
|
53
|
+
export function clearArtifactStatusTracking(id: string): void {
|
|
54
|
+
if (id) firstSeenAt.delete(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Type subject used to build the default label; mirrors the card/chrome logic. */
|
|
58
|
+
function typeLabelFor(record: PersonaArtifactRecord): string {
|
|
59
|
+
const file = record.artifactType === "markdown" ? record.file : undefined;
|
|
60
|
+
if (file) return fileTypeLabel(file);
|
|
61
|
+
return record.artifactType === "component" ? "Component" : "Document";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the streaming status label + optional detail for one surface.
|
|
66
|
+
*
|
|
67
|
+
* - `statusLabel` unset → the default `Generating <type>...`.
|
|
68
|
+
* - string → that string as the label (no detail).
|
|
69
|
+
* - function → called with a per-surface context; a string return is the label,
|
|
70
|
+
* an object return is `{ label, detail }`. A throw falls back to the default.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveArtifactStatusLabel(
|
|
73
|
+
record: PersonaArtifactRecord,
|
|
74
|
+
cfg: AgentWidgetArtifactsFeature | undefined,
|
|
75
|
+
surface: ArtifactStatusSurface
|
|
76
|
+
): ResolvedArtifactStatus {
|
|
77
|
+
const typeLabel = typeLabelFor(record);
|
|
78
|
+
const defaultLabel = `Generating ${typeLabel.toLowerCase()}...`;
|
|
79
|
+
|
|
80
|
+
const statusLabel = cfg?.statusLabel;
|
|
81
|
+
if (typeof statusLabel === "string") {
|
|
82
|
+
return { label: statusLabel };
|
|
83
|
+
}
|
|
84
|
+
if (typeof statusLabel !== "function") {
|
|
85
|
+
return { label: defaultLabel };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Track first-seen only while streaming (the surfaces only resolve while
|
|
89
|
+
// streaming); elapsedMs measures from that instant.
|
|
90
|
+
const id = record.id;
|
|
91
|
+
let firstSeen = firstSeenAt.get(id);
|
|
92
|
+
if (record.status !== "complete" && firstSeen === undefined) {
|
|
93
|
+
firstSeen = now();
|
|
94
|
+
firstSeenAt.set(id, firstSeen);
|
|
95
|
+
}
|
|
96
|
+
const elapsedMs = firstSeen === undefined ? 0 : Math.max(0, now() - firstSeen);
|
|
97
|
+
|
|
98
|
+
const isComponent = record.artifactType === "component";
|
|
99
|
+
const markdown =
|
|
100
|
+
!isComponent && typeof record.markdown === "string" ? record.markdown : "";
|
|
101
|
+
const chars = isComponent ? 0 : markdown.length;
|
|
102
|
+
const lines = isComponent || markdown === "" ? 0 : markdown.split("\n").length;
|
|
103
|
+
const file = record.artifactType === "markdown" ? record.file : undefined;
|
|
104
|
+
|
|
105
|
+
const ctx: PersonaArtifactStatusLabelContext = {
|
|
106
|
+
artifactId: id,
|
|
107
|
+
artifactType: record.artifactType,
|
|
108
|
+
title: record.title,
|
|
109
|
+
typeLabel,
|
|
110
|
+
file,
|
|
111
|
+
chars,
|
|
112
|
+
lines,
|
|
113
|
+
elapsedMs,
|
|
114
|
+
// Lazy: only materialize the accumulated source if the host asks for it.
|
|
115
|
+
content: () => (isComponent ? "" : markdown),
|
|
116
|
+
surface
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const result = statusLabel(ctx);
|
|
121
|
+
if (typeof result === "string") return { label: result };
|
|
122
|
+
if (
|
|
123
|
+
result &&
|
|
124
|
+
typeof result === "object" &&
|
|
125
|
+
typeof result.label === "string"
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
label: result.label,
|
|
129
|
+
detail: typeof result.detail === "string" ? result.detail : undefined
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// A malformed return (null/number/etc.) falls back like a throw.
|
|
133
|
+
return { label: defaultLabel };
|
|
134
|
+
} catch {
|
|
135
|
+
// A bad host callback must never break rendering.
|
|
136
|
+
return { label: defaultLabel };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Restore the label span to its base state before re-applying the animation. */
|
|
141
|
+
function resetLabelSpan(labelEl: HTMLElement): void {
|
|
142
|
+
labelEl.className = STATUS_LABEL_CLASS;
|
|
143
|
+
labelEl.removeAttribute("data-preserve-animation");
|
|
144
|
+
labelEl.style.removeProperty("--persona-tool-anim-duration");
|
|
145
|
+
labelEl.style.removeProperty("--persona-tool-anim-color");
|
|
146
|
+
labelEl.style.removeProperty("--persona-tool-anim-secondary-color");
|
|
147
|
+
labelEl.replaceChildren();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Paint a resolved status into `container`, which owns exactly two children: an
|
|
152
|
+
* animated label span and an optional plain detail span.
|
|
153
|
+
*
|
|
154
|
+
* The label span is rebuilt (and its animation re-applied) ONLY when its text
|
|
155
|
+
* differs from the last application, so a detail-only update never restarts the
|
|
156
|
+
* loading animation. The detail span updates its `textContent` freely and is
|
|
157
|
+
* removed when the resolved detail is empty.
|
|
158
|
+
*/
|
|
159
|
+
export function applyArtifactStatus(
|
|
160
|
+
container: HTMLElement,
|
|
161
|
+
resolved: ResolvedArtifactStatus,
|
|
162
|
+
cfg: AgentWidgetArtifactsFeature | undefined
|
|
163
|
+
): void {
|
|
164
|
+
let labelEl = container.querySelector<HTMLElement>(
|
|
165
|
+
`:scope > .${STATUS_LABEL_CLASS}`
|
|
166
|
+
);
|
|
167
|
+
if (!labelEl) {
|
|
168
|
+
labelEl = createElement("span", STATUS_LABEL_CLASS);
|
|
169
|
+
container.appendChild(labelEl);
|
|
170
|
+
}
|
|
171
|
+
if (labelEl.getAttribute(APPLIED_LABEL_ATTR) !== resolved.label) {
|
|
172
|
+
resetLabelSpan(labelEl);
|
|
173
|
+
applyArtifactLoadingStatus(labelEl, resolved.label, cfg);
|
|
174
|
+
labelEl.setAttribute(APPLIED_LABEL_ATTR, resolved.label);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let detailEl = container.querySelector<HTMLElement>(
|
|
178
|
+
`:scope > .${STATUS_DETAIL_CLASS}`
|
|
179
|
+
);
|
|
180
|
+
const detail = resolved.detail;
|
|
181
|
+
if (detail) {
|
|
182
|
+
if (!detailEl) {
|
|
183
|
+
detailEl = createElement("span", STATUS_DETAIL_CLASS);
|
|
184
|
+
container.appendChild(detailEl);
|
|
185
|
+
}
|
|
186
|
+
if (detailEl.textContent !== detail) detailEl.textContent = detail;
|
|
187
|
+
} else if (detailEl) {
|
|
188
|
+
detailEl.remove();
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/utils/buttons.ts
CHANGED
|
@@ -149,6 +149,8 @@ export interface ToggleGroupItem {
|
|
|
149
149
|
icon?: string;
|
|
150
150
|
/** Accessible label for the button. */
|
|
151
151
|
label: string;
|
|
152
|
+
/** Extra CSS class(es) appended after "persona-icon-btn". */
|
|
153
|
+
className?: string;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/** Options for {@link createToggleGroup}. */
|
|
@@ -202,6 +204,7 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
|
|
|
202
204
|
btn = createIconButton({
|
|
203
205
|
icon: item.icon,
|
|
204
206
|
label: item.label,
|
|
207
|
+
className: item.className,
|
|
205
208
|
onClick: () => {
|
|
206
209
|
currentId = item.id;
|
|
207
210
|
updatePressed();
|
|
@@ -209,7 +212,10 @@ export function createToggleGroup(options: CreateToggleGroupOptions): ToggleGrou
|
|
|
209
212
|
},
|
|
210
213
|
});
|
|
211
214
|
} else {
|
|
212
|
-
btn = createElement(
|
|
215
|
+
btn = createElement(
|
|
216
|
+
"button",
|
|
217
|
+
"persona-icon-btn" + (item.className ? " " + item.className : ""),
|
|
218
|
+
);
|
|
213
219
|
btn.type = "button";
|
|
214
220
|
btn.setAttribute("aria-label", item.label);
|
|
215
221
|
btn.title = item.label;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
highlightCode,
|
|
6
|
+
resolveHighlightLanguage,
|
|
7
|
+
MAX_HIGHLIGHT_LENGTH,
|
|
8
|
+
} from "./code-highlight";
|
|
9
|
+
|
|
10
|
+
/** Reconstruct the source from a highlighted fragment (invariant helper). */
|
|
11
|
+
const textOf = (frag: DocumentFragment): string => {
|
|
12
|
+
const holder = document.createElement("div");
|
|
13
|
+
holder.appendChild(frag.cloneNode(true));
|
|
14
|
+
return holder.textContent ?? "";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Collect token classes present in a highlighted fragment. */
|
|
18
|
+
const tokenClasses = (frag: DocumentFragment): string[] => {
|
|
19
|
+
const holder = document.createElement("div");
|
|
20
|
+
holder.appendChild(frag.cloneNode(true));
|
|
21
|
+
return Array.from(holder.querySelectorAll("span[class^='persona-code-token-']"))
|
|
22
|
+
.map((el) => el.className);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const classFor = (source: string, lang: string, snippet: string): string | null => {
|
|
26
|
+
const holder = document.createElement("div");
|
|
27
|
+
holder.appendChild(highlightCode(source, lang));
|
|
28
|
+
const spans = Array.from(
|
|
29
|
+
holder.querySelectorAll("span[class^='persona-code-token-']")
|
|
30
|
+
);
|
|
31
|
+
const hit = spans.find((el) => el.textContent === snippet);
|
|
32
|
+
return hit ? hit.className : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("resolveHighlightLanguage", () => {
|
|
36
|
+
it("prefers explicit language and maps aliases", () => {
|
|
37
|
+
expect(resolveHighlightLanguage("html")).toBe("html");
|
|
38
|
+
expect(resolveHighlightLanguage("htm")).toBe("html");
|
|
39
|
+
expect(resolveHighlightLanguage("mjs")).toBe("js");
|
|
40
|
+
expect(resolveHighlightLanguage("tsx")).toBe("js");
|
|
41
|
+
expect(resolveHighlightLanguage("typescript")).toBe("js");
|
|
42
|
+
expect(resolveHighlightLanguage("jsonc")).toBe("json");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to the file extension", () => {
|
|
46
|
+
expect(resolveHighlightLanguage(undefined, "src/app.css")).toBe("css");
|
|
47
|
+
expect(resolveHighlightLanguage(undefined, "a/b/main.ts")).toBe("js");
|
|
48
|
+
expect(resolveHighlightLanguage(undefined, "index.html")).toBe("html");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null for unknown languages and extensionless paths", () => {
|
|
52
|
+
expect(resolveHighlightLanguage("rust")).toBeNull();
|
|
53
|
+
expect(resolveHighlightLanguage(undefined, "Makefile")).toBeNull();
|
|
54
|
+
expect(resolveHighlightLanguage(undefined)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("highlightCode token classification", () => {
|
|
59
|
+
it("classifies js keywords, strings, numbers, and comments", () => {
|
|
60
|
+
expect(classFor("const x = 1;", "js", "const")).toBe(
|
|
61
|
+
"persona-code-token-keyword"
|
|
62
|
+
);
|
|
63
|
+
expect(classFor('const s = "hi";', "js", '"hi"')).toBe(
|
|
64
|
+
"persona-code-token-string"
|
|
65
|
+
);
|
|
66
|
+
expect(classFor("const n = 42;", "js", "42")).toBe(
|
|
67
|
+
"persona-code-token-number"
|
|
68
|
+
);
|
|
69
|
+
expect(classFor("// note\nx", "js", "// note")).toBe(
|
|
70
|
+
"persona-code-token-comment"
|
|
71
|
+
);
|
|
72
|
+
expect(classFor("const b = true;", "js", "true")).toBe(
|
|
73
|
+
"persona-code-token-keyword"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("classifies json keys as property and values as string", () => {
|
|
78
|
+
const src = '{"name": "cat", "n": 3, "ok": true}';
|
|
79
|
+
expect(classFor(src, "json", '"name"')).toBe("persona-code-token-property");
|
|
80
|
+
expect(classFor(src, "json", '"cat"')).toBe("persona-code-token-string");
|
|
81
|
+
expect(classFor(src, "json", "3")).toBe("persona-code-token-number");
|
|
82
|
+
expect(classFor(src, "json", "true")).toBe("persona-code-token-keyword");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("classifies css properties, values, at-rules, and comments", () => {
|
|
86
|
+
const src = "@media all { .a { color: #fff; margin: 4px; } } /* c */";
|
|
87
|
+
expect(classFor(src, "css", "color")).toBe("persona-code-token-property");
|
|
88
|
+
expect(classFor(src, "css", "#fff")).toBe("persona-code-token-number");
|
|
89
|
+
expect(classFor(src, "css", "@media")).toBe("persona-code-token-keyword");
|
|
90
|
+
expect(classFor(src, "css", "/* c */")).toBe("persona-code-token-comment");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("classifies html tags, attributes, attribute values, and comments", () => {
|
|
94
|
+
const src = '<a href="x">hi</a><!-- c -->';
|
|
95
|
+
expect(classFor(src, "html", "href")).toBe("persona-code-token-attr");
|
|
96
|
+
expect(classFor(src, "html", '"x"')).toBe("persona-code-token-string");
|
|
97
|
+
expect(classFor(src, "html", "<!-- c -->")).toBe(
|
|
98
|
+
"persona-code-token-comment"
|
|
99
|
+
);
|
|
100
|
+
// Tag bracket + name.
|
|
101
|
+
expect(classFor(src, "html", "<a")).toBe("persona-code-token-tag");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("highlights embedded <script> bodies with the js tokenizer", () => {
|
|
105
|
+
const src = "<script>const y = 2;</script>";
|
|
106
|
+
expect(classFor(src, "html", "const")).toBe("persona-code-token-keyword");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("highlightCode line + newline invariants", () => {
|
|
111
|
+
it("splits a multi-line block comment across lines without newlines in spans", () => {
|
|
112
|
+
const src = "/* line one\nline two */\nvar x;";
|
|
113
|
+
const frag = highlightCode(src, "js");
|
|
114
|
+
const holder = document.createElement("div");
|
|
115
|
+
holder.appendChild(frag.cloneNode(true));
|
|
116
|
+
const commentSpans = holder.querySelectorAll(".persona-code-token-comment");
|
|
117
|
+
// The block comment spans two lines → two comment spans, neither with "\n".
|
|
118
|
+
expect(commentSpans.length).toBe(2);
|
|
119
|
+
commentSpans.forEach((el) => expect(el.textContent).not.toContain("\n"));
|
|
120
|
+
// No token span anywhere contains a newline.
|
|
121
|
+
holder
|
|
122
|
+
.querySelectorAll("span[class^='persona-code-token-']")
|
|
123
|
+
.forEach((el) => expect(el.textContent).not.toContain("\n"));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reconstructs the source verbatim (textContent === source)", () => {
|
|
127
|
+
for (const [src, lang] of [
|
|
128
|
+
["const x = 1;\nconst y = 2;\n", "js"],
|
|
129
|
+
["line1\nline2\nline3", "js"],
|
|
130
|
+
["a\n\nb\n", "js"],
|
|
131
|
+
['{"k": "v"}\n', "json"],
|
|
132
|
+
["<div>\n <span>hi</span>\n</div>\n", "html"],
|
|
133
|
+
["", "js"],
|
|
134
|
+
] as const) {
|
|
135
|
+
expect(textOf(highlightCode(src, lang))).toBe(src);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("does not emit a phantom numbered line for a trailing newline", () => {
|
|
140
|
+
const holder = document.createElement("div");
|
|
141
|
+
holder.appendChild(highlightCode("a\nb\n", "js"));
|
|
142
|
+
// "a\nb\n" → 2 numbered lines, trailing newline preserved in textContent.
|
|
143
|
+
expect(holder.querySelectorAll(".persona-code-line").length).toBe(2);
|
|
144
|
+
expect(holder.textContent).toBe("a\nb\n");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps one line for wholly empty source", () => {
|
|
148
|
+
const holder = document.createElement("div");
|
|
149
|
+
holder.appendChild(highlightCode("", "js"));
|
|
150
|
+
expect(holder.querySelectorAll(".persona-code-line").length).toBe(1);
|
|
151
|
+
expect(holder.textContent).toBe("");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("highlightCode fallbacks and safety", () => {
|
|
156
|
+
it("returns plain line-numbered text for an unknown language", () => {
|
|
157
|
+
const frag = highlightCode("const x = 1;", "rust");
|
|
158
|
+
expect(tokenClasses(frag)).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips tokenization for oversized sources", () => {
|
|
162
|
+
const big = "const x = 1;\n".repeat(
|
|
163
|
+
Math.ceil((MAX_HIGHLIGHT_LENGTH + 100) / 13)
|
|
164
|
+
);
|
|
165
|
+
expect(big.length).toBeGreaterThan(MAX_HIGHLIGHT_LENGTH);
|
|
166
|
+
const frag = highlightCode(big, "js");
|
|
167
|
+
expect(tokenClasses(frag)).toEqual([]);
|
|
168
|
+
// Still line-numbered (line spans present) and verbatim.
|
|
169
|
+
const holder = document.createElement("div");
|
|
170
|
+
holder.appendChild(frag);
|
|
171
|
+
expect(holder.querySelector(".persona-code-line")).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("never emits any element other than spans for hostile input", () => {
|
|
175
|
+
// XSS-shaped content embedded as a JS string literal.
|
|
176
|
+
const src = 'const html = "<img src=x onerror=alert(1)>";';
|
|
177
|
+
const holder = document.createElement("div");
|
|
178
|
+
holder.appendChild(highlightCode(src, "js"));
|
|
179
|
+
// Only <span> elements exist; no <img> (or anything else) was created.
|
|
180
|
+
const all = Array.from(holder.querySelectorAll("*"));
|
|
181
|
+
expect(all.every((el) => el.tagName === "SPAN")).toBe(true);
|
|
182
|
+
expect(holder.querySelector("img")).toBeNull();
|
|
183
|
+
// The dangerous text survives as inert text content.
|
|
184
|
+
expect(holder.textContent).toContain("<img src=x onerror=alert(1)>");
|
|
185
|
+
});
|
|
186
|
+
});
|