@runtypelabs/persona 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. 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
- mount.style.setProperty("--persona-artifact-split-gap", l?.splitGap ?? "0.5rem");
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
- const ARTIFACT_APPEARANCE_MODES = ["panel", "seamless"] as const;
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-unified-outer-radius");
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 raw = layout?.paneAppearance ?? "panel";
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
- if (layout?.unifiedSplitChrome === true) {
127
- mount.classList.add("persona-artifact-unified-split");
128
- const outer = layout.unifiedSplitOuterRadius?.trim() || radius;
129
- if (outer) {
130
- mount.style.setProperty("--persona-artifact-unified-outer-radius", outer);
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
+ }
@@ -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("button", "persona-icon-btn");
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,97 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createChunkLoader } from "./chunk-loader";
3
+
4
+ type FakeModule = { value: number };
5
+
6
+ describe("createChunkLoader", () => {
7
+ it("uses the fallback import when no loader is registered", async () => {
8
+ const mod: FakeModule = { value: 1 };
9
+ const fallbackImport = vi.fn(async () => mod);
10
+ const { load } = createChunkLoader<FakeModule>({ fallbackImport });
11
+
12
+ await expect(load()).resolves.toBe(mod);
13
+ expect(fallbackImport).toHaveBeenCalledTimes(1);
14
+ });
15
+
16
+ it("memoizes the resolved module and shares the in-flight promise", async () => {
17
+ const mod: FakeModule = { value: 2 };
18
+ const loader = vi.fn(async () => mod);
19
+ const { setLoader, load } = createChunkLoader<FakeModule>({
20
+ fallbackImport: async () => ({ value: -1 }),
21
+ });
22
+ setLoader(loader);
23
+
24
+ // Concurrent callers share one import.
25
+ const [a, b] = await Promise.all([load(), load()]);
26
+ expect(a).toBe(mod);
27
+ expect(b).toBe(mod);
28
+ // A later call returns the cached module without importing again.
29
+ await expect(load()).resolves.toBe(mod);
30
+ expect(loader).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it("retries after a rejected load instead of caching the failure", async () => {
34
+ const mod: FakeModule = { value: 3 };
35
+ const loader = vi
36
+ .fn<() => Promise<FakeModule>>()
37
+ .mockRejectedValueOnce(new Error("network"))
38
+ .mockResolvedValueOnce(mod);
39
+ const { setLoader, load } = createChunkLoader<FakeModule>({
40
+ fallbackImport: async () => ({ value: -1 }),
41
+ });
42
+ setLoader(loader);
43
+
44
+ await expect(load()).rejects.toThrow("network");
45
+ // The failed promise was cleared, so a later call retries and resolves.
46
+ await expect(load()).resolves.toBe(mod);
47
+ expect(loader).toHaveBeenCalledTimes(2);
48
+ // Once resolved, the module is cached (no third loader call).
49
+ await expect(load()).resolves.toBe(mod);
50
+ expect(loader).toHaveBeenCalledTimes(2);
51
+ });
52
+
53
+ it("does not invalidate the cache on setLoader by default", async () => {
54
+ const first: FakeModule = { value: 4 };
55
+ const second: FakeModule = { value: 5 };
56
+ const { setLoader, load } = createChunkLoader<FakeModule>({
57
+ fallbackImport: async () => ({ value: -1 }),
58
+ });
59
+
60
+ setLoader(async () => first);
61
+ await expect(load()).resolves.toBe(first);
62
+ // Registering a new loader after a resolved load has no effect: cache holds.
63
+ setLoader(async () => second);
64
+ await expect(load()).resolves.toBe(first);
65
+ });
66
+
67
+ it("invalidates the cache on setLoader when resetOnSetLoader is set", async () => {
68
+ const first: FakeModule = { value: 6 };
69
+ const second: FakeModule = { value: 7 };
70
+ const { setLoader, load } = createChunkLoader<FakeModule>({
71
+ fallbackImport: async () => ({ value: -1 }),
72
+ resetOnSetLoader: true,
73
+ });
74
+
75
+ setLoader(async () => first);
76
+ await expect(load()).resolves.toBe(first);
77
+ // A swapped loader takes effect: the previous module is discarded.
78
+ setLoader(async () => second);
79
+ await expect(load()).resolves.toBe(second);
80
+ });
81
+
82
+ it("provide() seeds the cache and getSync() reads it", async () => {
83
+ const mod: FakeModule = { value: 8 };
84
+ const loader = vi.fn(async () => ({ value: -1 }));
85
+ const { setLoader, load, provide, getSync } = createChunkLoader<FakeModule>({
86
+ fallbackImport: async () => ({ value: -1 }),
87
+ });
88
+ setLoader(loader);
89
+
90
+ expect(getSync()).toBeNull();
91
+ provide(mod);
92
+ expect(getSync()).toBe(mod);
93
+ // A provided module short-circuits load(): the loader is never called.
94
+ await expect(load()).resolves.toBe(mod);
95
+ expect(loader).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Generic memoized loader for a lazy code-split chunk.
3
+ *
4
+ * Backs the near-identical loader indirections in the core/eager bundle
5
+ * (`context-mentions-loader.ts`, `context-mentions-inline-loader.ts`,
6
+ * `markdown-parsers-loader.ts`): each registers a runtime loader (IIFE/CDN
7
+ * sibling-URL chunk) and falls back to an external/relative dynamic import for
8
+ * ESM/CJS consumers. The shared semantics are:
9
+ *
10
+ * - `load()` memoizes the resolved module (`moduleCache`) and the in-flight
11
+ * promise (`loadPromise`) so concurrent callers share one import.
12
+ * - A rejected load clears `loadPromise` so a later call retries after a
13
+ * transient failure (one dropped fetch must not disable the feature for the
14
+ * whole session); the current caller still sees the rejection.
15
+ * - The registered loader wins over `fallbackImport`; if no loader is
16
+ * registered, `fallbackImport` is used.
17
+ *
18
+ * IMPORTANT: this module must import nothing beyond types. It sits in the core
19
+ * bundle, so any value import here could drag chunk code back into core and
20
+ * defeat the split (see `context-mentions-bundle.test.ts`).
21
+ */
22
+
23
+ export type ChunkLoaderOptions<T> = {
24
+ /**
25
+ * Dynamic import used when no runtime loader has been registered. Keep the
26
+ * `import(...)` literal in the calling module (not here) so the bundler
27
+ * resolves it as an external subpath / relative chunk at that call site.
28
+ */
29
+ fallbackImport: () => Promise<T>;
30
+ /**
31
+ * When true, registering a new loader clears the cached module and any
32
+ * in-flight promise, so a swapped loader (test injection) takes effect
33
+ * deterministically. Production registers its loader once before any load, so
34
+ * this only matters mid-session.
35
+ */
36
+ resetOnSetLoader?: boolean;
37
+ };
38
+
39
+ export type ChunkLoader<T> = {
40
+ setLoader: (l: () => Promise<T>) => void;
41
+ load: () => Promise<T>;
42
+ /** Register a resolved module synchronously (eager builds that bundle it). */
43
+ provide: (mod: T) => void;
44
+ /** The cached module, or null if not loaded/provided yet. */
45
+ getSync: () => T | null;
46
+ };
47
+
48
+ export const createChunkLoader = <T>(
49
+ options: ChunkLoaderOptions<T>
50
+ ): ChunkLoader<T> => {
51
+ const { fallbackImport, resetOnSetLoader = false } = options;
52
+
53
+ let loader: (() => Promise<T>) | null = null;
54
+ let moduleCache: T | null = null;
55
+ let loadPromise: Promise<T> | null = null;
56
+
57
+ const setLoader = (l: () => Promise<T>): void => {
58
+ loader = l;
59
+ if (resetOnSetLoader) {
60
+ moduleCache = null;
61
+ loadPromise = null;
62
+ }
63
+ };
64
+
65
+ const load = (): Promise<T> => {
66
+ if (moduleCache) return Promise.resolve(moduleCache);
67
+ if (loadPromise) return loadPromise;
68
+ const importChunk = loader ?? fallbackImport;
69
+ loadPromise = importChunk()
70
+ .then((mod) => {
71
+ moduleCache = mod;
72
+ return mod;
73
+ })
74
+ .catch((err) => {
75
+ loadPromise = null;
76
+ throw err;
77
+ });
78
+ return loadPromise;
79
+ };
80
+
81
+ const provide = (mod: T): void => {
82
+ moduleCache = mod;
83
+ };
84
+
85
+ const getSync = (): T | null => moduleCache;
86
+
87
+ return { setLoader, load, provide, getSync };
88
+ };