@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.
Files changed (117) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +877 -172
  16. package/dist/index.d.ts +877 -172
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +91 -68
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +186 -1
  56. package/src/client.ts +92 -9
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/session.ts +76 -22
  80. package/src/styles/widget.css +773 -26
  81. package/src/theme-editor/preview.ts +2 -0
  82. package/src/theme-editor/sections.test.ts +26 -1
  83. package/src/theme-editor/sections.ts +10 -2
  84. package/src/theme-reference.ts +2 -2
  85. package/src/tool-call-display-defaults.test.ts +1 -0
  86. package/src/types/theme.ts +77 -0
  87. package/src/types.ts +516 -17
  88. package/src/ui.artifact-pane-gating.test.ts +636 -0
  89. package/src/ui.component-directive.test.ts +104 -0
  90. package/src/ui.composer-bar.test.ts +60 -2
  91. package/src/ui.detached-panel.test.ts +1049 -0
  92. package/src/ui.tool-display.test.ts +51 -0
  93. package/src/ui.ts +698 -109
  94. package/src/utils/artifact-custom-actions.ts +128 -0
  95. package/src/utils/artifact-display.test.ts +42 -0
  96. package/src/utils/artifact-display.ts +84 -0
  97. package/src/utils/artifact-file.test.ts +116 -0
  98. package/src/utils/artifact-file.ts +117 -0
  99. package/src/utils/artifact-gate.test.ts +112 -5
  100. package/src/utils/artifact-gate.ts +39 -14
  101. package/src/utils/artifact-loading-status.ts +55 -0
  102. package/src/utils/artifact-status-label.ts +190 -0
  103. package/src/utils/buttons.ts +7 -1
  104. package/src/utils/code-highlight.test.ts +186 -0
  105. package/src/utils/code-highlight.ts +400 -0
  106. package/src/utils/icons.ts +2 -0
  107. package/src/utils/roving-tablist.test.ts +152 -0
  108. package/src/utils/roving-tablist.ts +111 -0
  109. package/src/utils/spinner.ts +45 -0
  110. package/src/utils/theme.test.ts +48 -0
  111. package/src/utils/theme.ts +7 -0
  112. package/src/utils/tokens.ts +91 -0
  113. package/src/utils/tool-loading-animation.test.ts +32 -0
  114. package/src/utils/tool-loading-animation.ts +24 -0
  115. package/dist/chunk-DFBSCFYN.js +0 -1
  116. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  117. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -26,9 +26,13 @@ describe("buildPillComposer (single-row pill composer)", () => {
26
26
  expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
27
27
  });
28
28
 
29
- it("hides suggestions and status text by default in pill mode", () => {
29
+ it("keeps suggestions available above the pill and hides status text", () => {
30
30
  const elements = buildPillComposer({ config: { apiUrl: "/api" } });
31
- expect(elements.suggestions.style.display).toBe("none");
31
+ const footerChildren = Array.from(elements.footer.children);
32
+ expect(elements.suggestions.style.display).toBe("");
33
+ expect(footerChildren.indexOf(elements.suggestions)).toBeLessThan(
34
+ footerChildren.indexOf(elements.composerForm)
35
+ );
32
36
  expect(elements.statusText.style.display).toBe("none");
33
37
  });
34
38
 
@@ -72,9 +72,10 @@ export const buildPillPeekBanner = (): PillPeekBanner => {
72
72
  * Returns the same `ComposerElements` shape as `buildComposer` so panel.ts
73
73
  * and ui.ts plumbing is unconditional past the choice of builder.
74
74
  *
75
- * Suggestions row + status text are built (so plugin code that mutates
76
- * them keeps working and `bindComposerRefsFromFooter` finds them) but are
77
- * `display: none` by default: pill UX is just textarea + 3 buttons.
75
+ * Suggestions row + status text are built so plugin code that mutates them
76
+ * keeps working and `bindComposerRefsFromFooter` finds them. CSS hides the
77
+ * suggestions while collapsed and reveals them above the pill when expanded;
78
+ * status text stays hidden because the pill UX has no room for it.
78
79
  *
79
80
  * Attachment previews float ABOVE the pill in their own row when
80
81
  * AttachmentManager toggles the previews container's `display` property
@@ -89,7 +90,6 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
89
90
  });
90
91
 
91
92
  const suggestions = createSuggestionsRow();
92
- suggestions.style.display = "none";
93
93
  const statusText = createStatusText(config);
94
94
  statusText.style.display = "none";
95
95
 
@@ -150,11 +150,11 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
150
150
 
151
151
  // Footer assembly:
152
152
  // [previews row, hidden until attachments exist]
153
+ // [suggestions, visible only while expanded]
153
154
  // [pill form]
154
- // [hidden suggestions]
155
155
  // [hidden status]
156
156
  if (attachment) footer.append(attachment.previewsContainer);
157
- footer.append(composerForm, suggestions, statusText);
157
+ footer.append(suggestions, composerForm, statusText);
158
158
 
159
159
  // The pill flattens left/right into the form's grid; there's no separate
160
160
  // wrapper. Surface the form itself as `actionsRow` to satisfy the
@@ -2,6 +2,7 @@ import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
3
3
  import { describeReasonStatus, computeReasoningElapsed, parseFormattedTemplate } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
+ import { appendCharSpans } from "../utils/tool-loading-animation";
5
6
 
6
7
  // Expansion state per widget instance
7
8
  export const reasoningExpansionState = new Set<string>();
@@ -156,19 +157,6 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
156
157
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
157
158
  const skipCustomElement = customSummary instanceof HTMLElement;
158
159
 
159
- // Helper: append text as individual animated character spans
160
- const appendCharSpans = (container: HTMLElement, text: string, startIndex: number): number => {
161
- let idx = startIndex;
162
- for (const char of text) {
163
- const span = createElement("span", "persona-tool-char");
164
- span.style.setProperty("--char-index", String(idx));
165
- span.textContent = char === " " ? "\u00A0" : char;
166
- container.appendChild(span);
167
- idx++;
168
- }
169
- return idx;
170
- };
171
-
172
160
  /**
173
161
  * Renders a template into the title element, handling:
174
162
  * - Inline formatting markers: **bold**, *italic*, ~dim~
@@ -1,5 +1,6 @@
1
1
  import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
2
2
  import { PersonaArtifactCard } from "./artifact-card";
3
+ import { PersonaArtifactInline } from "./artifact-inline";
3
4
 
4
5
  /**
5
6
  * Context provided to component renderers
@@ -21,20 +22,42 @@ export type ComponentRenderer = (
21
22
  context: ComponentContext
22
23
  ) => HTMLElement;
23
24
 
25
+ /**
26
+ * Per-component registration options
27
+ */
28
+ export interface ComponentRegistrationOptions {
29
+ /**
30
+ * When false, the component directive renders bare in the thread (no
31
+ * persona-message-bubble chrome).
32
+ * @default true
33
+ */
34
+ bubbleChrome?: boolean;
35
+ }
36
+
24
37
  /**
25
38
  * Component registry for managing custom components
26
39
  */
27
40
  class ComponentRegistry {
28
41
  private components: Map<string, ComponentRenderer> = new Map();
42
+ private options: Map<string, ComponentRegistrationOptions> = new Map();
29
43
 
30
44
  /**
31
45
  * Register a custom component
32
46
  */
33
- register(name: string, renderer: ComponentRenderer): void {
47
+ register(
48
+ name: string,
49
+ renderer: ComponentRenderer,
50
+ options?: ComponentRegistrationOptions
51
+ ): void {
34
52
  if (this.components.has(name)) {
35
53
  console.warn(`[ComponentRegistry] Component "${name}" is already registered. Overwriting.`);
36
54
  }
37
55
  this.components.set(name, renderer);
56
+ if (options) {
57
+ this.options.set(name, options);
58
+ } else {
59
+ this.options.delete(name);
60
+ }
38
61
  }
39
62
 
40
63
  /**
@@ -42,6 +65,7 @@ class ComponentRegistry {
42
65
  */
43
66
  unregister(name: string): void {
44
67
  this.components.delete(name);
68
+ this.options.delete(name);
45
69
  }
46
70
 
47
71
  /**
@@ -58,6 +82,13 @@ class ComponentRegistry {
58
82
  return this.components.has(name);
59
83
  }
60
84
 
85
+ /**
86
+ * Get the registration options for a component, if any were supplied
87
+ */
88
+ getOptions(name: string): ComponentRegistrationOptions | undefined {
89
+ return this.options.get(name);
90
+ }
91
+
61
92
  /**
62
93
  * Get all registered component names
63
94
  */
@@ -70,6 +101,7 @@ class ComponentRegistry {
70
101
  */
71
102
  clear(): void {
72
103
  this.components.clear();
104
+ this.options.clear();
73
105
  }
74
106
 
75
107
  /**
@@ -87,5 +119,8 @@ class ComponentRegistry {
87
119
  */
88
120
  export const componentRegistry = new ComponentRegistry();
89
121
 
90
- // Register built-in components
91
- componentRegistry.register("PersonaArtifactCard", PersonaArtifactCard);
122
+ // Register built-in components. The artifact card and inline block carry
123
+ // their own border and surface, so they render bare in the thread to avoid
124
+ // double-boxing.
125
+ componentRegistry.register("PersonaArtifactCard", PersonaArtifactCard, { bubbleChrome: false });
126
+ componentRegistry.register("PersonaArtifactInline", PersonaArtifactInline, { bubbleChrome: false });
@@ -2,6 +2,7 @@ import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
3
3
  import { formatUnknownValue, describeToolTitle, resolveToolHeaderText, computeToolElapsed, parseFormattedTemplate } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
+ import { appendCharSpans } from "../utils/tool-loading-animation";
5
6
 
6
7
  // Expansion state per widget instance
7
8
  export const toolExpansionState = new Set<string>();
@@ -259,19 +260,6 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
259
260
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
260
261
  const skipCustomElement = customSummary instanceof HTMLElement;
261
262
 
262
- // Helper: append text as individual animated character spans
263
- const appendCharSpans = (container: HTMLElement, text: string, startIndex: number): number => {
264
- let idx = startIndex;
265
- for (const char of text) {
266
- const span = createElement("span", "persona-tool-char");
267
- span.style.setProperty("--char-index", String(idx));
268
- span.textContent = char === " " ? "\u00A0" : char;
269
- container.appendChild(span);
270
- idx++;
271
- }
272
- return idx;
273
- };
274
-
275
263
  /**
276
264
  * Renders a template into the title element, handling:
277
265
  * - Inline formatting markers: **bold**, *italic*, ~dim~
package/src/defaults.ts CHANGED
@@ -144,6 +144,7 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
144
144
  collapsedMode: "tool-call",
145
145
  activePreview: false,
146
146
  grouped: false,
147
+ groupedMode: "stack",
147
148
  previewMaxLines: 3,
148
149
  expandable: true,
149
150
  loadingAnimation: "none",
@@ -22,6 +22,7 @@ export type RuntypeExecutionStreamEvent = ({
22
22
  totalSteps?: number;
23
23
  type: "execution_start";
24
24
  }) | ({
25
+ claudeManagedAgentId?: string;
25
26
  completedAt?: string;
26
27
  durationMs?: number;
27
28
  executionId: string;
@@ -66,6 +67,15 @@ export type RuntypeExecutionStreamEvent = ({
66
67
  completedAt?: string;
67
68
  content?: string;
68
69
  cost?: number;
70
+ estimatedContextBreakdown?: {
71
+ categories: Record<string, {
72
+ chars: number;
73
+ estimatedTokens: number;
74
+ }>;
75
+ contextWindowSize?: number;
76
+ estimatedTotalTokens: number;
77
+ estimatedUtilizationPercent?: number;
78
+ };
69
79
  executionId: string;
70
80
  fallback?: {
71
81
  attempts: Array<{
@@ -103,6 +113,7 @@ export type RuntypeExecutionStreamEvent = ({
103
113
  type: "step_start";
104
114
  } | ({
105
115
  completedAt?: string;
116
+ cost?: number;
106
117
  durationMs?: number;
107
118
  error?: string;
108
119
  executionId: string;
@@ -208,6 +219,11 @@ export type RuntypeExecutionStreamEvent = ({
208
219
  artifactType: "markdown" | "component";
209
220
  component?: string;
210
221
  executionId?: string;
222
+ file?: {
223
+ language?: string;
224
+ mimeType: string;
225
+ path: string;
226
+ };
211
227
  id: string;
212
228
  seq?: number;
213
229
  title?: string;
@@ -316,6 +332,14 @@ export type RuntypeExecutionStreamEvent = ({
316
332
  awaitReason?: string;
317
333
  awaitedAt?: string;
318
334
  crawlId?: string;
335
+ elicitation?: {
336
+ message: string;
337
+ mode: "form" | "url";
338
+ pauseCount?: number;
339
+ requestedSchema?: Record<string, unknown>;
340
+ serverName?: string;
341
+ url?: string;
342
+ };
319
343
  executionId: string;
320
344
  origin?: "webmcp" | "sdk";
321
345
  pageOrigin?: string;
@@ -416,6 +440,14 @@ export type RuntypeFlowSSEEvent = {
416
440
  awaitReason?: string;
417
441
  awaitedAt: string;
418
442
  crawlId?: string;
443
+ elicitation?: {
444
+ message: string;
445
+ mode: "form" | "url";
446
+ pauseCount?: number;
447
+ requestedSchema?: Record<string, unknown>;
448
+ serverName?: string;
449
+ url?: string;
450
+ };
419
451
  executionId?: string;
420
452
  flowId: string;
421
453
  origin?: "webmcp" | "sdk";
@@ -466,7 +498,7 @@ export type RuntypeFlowSSEEvent = {
466
498
  completedAt?: string;
467
499
  duration?: number;
468
500
  durationMs?: number;
469
- error?: string;
501
+ error?: string | null;
470
502
  executionId?: string;
471
503
  executionTime?: number;
472
504
  id?: string;
@@ -510,13 +542,21 @@ export type RuntypeFlowSSEEvent = {
510
542
  totalSteps: number;
511
543
  type: "step_skip";
512
544
  when: string;
513
- } | {
545
+ } | ({
546
+ elicitation?: {
547
+ message: string;
548
+ mode: "form" | "url";
549
+ pauseCount?: number;
550
+ requestedSchema?: Record<string, unknown>;
551
+ serverName?: string;
552
+ url?: string;
553
+ };
514
554
  executionId?: string;
515
555
  reason?: string;
516
556
  seq?: number;
517
557
  type: "step_await";
518
558
  [key: string]: unknown;
519
- } | ({
559
+ }) | ({
520
560
  agentContext?: {
521
561
  executionId: string;
522
562
  iteration: number;
@@ -735,6 +775,18 @@ export type RuntypeClientChatRequest = {
735
775
  export type RuntypeClientChatStreamEvent = RuntypeExecutionStreamEvent;
736
776
 
737
777
  export type RuntypeClientResumeRequest = {
778
+ clientTools?: Array<{
779
+ description: string;
780
+ name: string;
781
+ origin?: "webmcp" | "sdk";
782
+ pageOrigin?: string;
783
+ parametersSchema: {
784
+ type: "object";
785
+ [key: string]: unknown;
786
+ };
787
+ untrustedContentHint?: boolean;
788
+ }>;
789
+ clientToolsFingerprint?: string;
738
790
  executionId: string;
739
791
  messages?: Array<{
740
792
  content: string | (Array<{
package/src/index-core.ts CHANGED
@@ -10,8 +10,12 @@ export type {
10
10
  AgentWidgetFeatureFlags,
11
11
  AgentWidgetArtifactsFeature,
12
12
  AgentWidgetArtifactsLayoutConfig,
13
+ PersonaArtifactDisplayMode,
13
14
  PersonaArtifactKind,
14
15
  PersonaArtifactRecord,
16
+ PersonaArtifactActionContext,
17
+ PersonaArtifactStatusLabelContext,
18
+ PersonaArtifactCustomAction,
15
19
  PersonaArtifactManualUpsert,
16
20
  ArtifactConfigPayload,
17
21
  AgentWidgetInitOptions,
@@ -188,6 +192,17 @@ export {
188
192
  createDirectivePostprocessor
189
193
  } from "./postprocessors";
190
194
  export type { MarkdownProcessorOptions } from "./postprocessors";
195
+ // Markdown parser-readiness escape hatch. On the IIFE/CDN build `marked` +
196
+ // `DOMPurify` load lazily, so content injected right after init can render as
197
+ // escaped plain text until the chunk resolves. Hosts can `await
198
+ // loadMarkdownParsers()` before injecting, or `onMarkdownParsersReady(cb)` to be
199
+ // told when they land (both resolve/no-op instantly on the npm build, where the
200
+ // parsers are bundled eagerly).
201
+ export {
202
+ loadMarkdownParsers,
203
+ onMarkdownParsersReady
204
+ } from "./markdown-parsers-loader";
205
+ export type { MarkdownParsersModule } from "./markdown-parsers-loader";
191
206
  export {
192
207
  createDefaultSanitizer,
193
208
  resolveSanitizer
@@ -361,7 +376,11 @@ export type {
361
376
 
362
377
  // Component system exports
363
378
  export { componentRegistry } from "./components/registry";
364
- export type { ComponentRenderer, ComponentContext } from "./components/registry";
379
+ export type {
380
+ ComponentRenderer,
381
+ ComponentContext,
382
+ ComponentRegistrationOptions
383
+ } from "./components/registry";
365
384
  export {
366
385
  createComponentStreamParser,
367
386
  isComponentDirectiveType
package/src/index.ts CHANGED
@@ -37,3 +37,11 @@ export type {
37
37
  DemoCarouselOptions,
38
38
  DemoCarouselHandle
39
39
  } from "./components/demo-carousel";
40
+
41
+ // Accessible roving-tabindex tablist helper for custom artifact tab bars
42
+ // (features.artifacts.renderTabBar).
43
+ export { createRovingTablist } from "./utils/roving-tablist";
44
+ export type {
45
+ RovingTablistController,
46
+ RovingTablistOptions
47
+ } from "./utils/roving-tablist";
@@ -0,0 +1,158 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { MarkdownParsersModule } from "./markdown-parsers-loader";
3
+
4
+ /**
5
+ * Tests for the parser-ready subscription registry.
6
+ *
7
+ * The loader holds module-level singleton state (`moduleCache`, subscribers), and
8
+ * `vitest.setup.ts` eager-provides parsers into the shared graph — which would make
9
+ * `onMarkdownParsersReady` no-op everywhere. So each test imports a FRESH copy of
10
+ * the loader via `vi.resetModules()` + dynamic import, starting from the unloaded
11
+ * (IIFE/CDN) state, and drives the transition itself.
12
+ */
13
+
14
+ // A stand-in parsers module; the registry never touches its fields.
15
+ const FAKE_PARSERS = {} as unknown as MarkdownParsersModule;
16
+
17
+ const freshLoader = async () => {
18
+ vi.resetModules();
19
+ return import("./markdown-parsers-loader");
20
+ };
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ describe("onMarkdownParsersReady", () => {
27
+ it("fires a pending subscriber once when the lazy chunk resolves", async () => {
28
+ const loader = await freshLoader();
29
+ loader.setMarkdownParsersLoader(() => Promise.resolve(FAKE_PARSERS));
30
+
31
+ const cb = vi.fn();
32
+ loader.onMarkdownParsersReady(cb);
33
+ expect(cb).not.toHaveBeenCalled(); // not yet: chunk not loaded
34
+
35
+ await loader.loadMarkdownParsers();
36
+ expect(cb).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it("kicks the load itself so a lone surface still heals", async () => {
40
+ const loader = await freshLoader();
41
+ const load = vi.fn(() => Promise.resolve(FAKE_PARSERS));
42
+ loader.setMarkdownParsersLoader(load);
43
+
44
+ const cb = vi.fn();
45
+ // No explicit loadMarkdownParsers() call — subscribing must trigger it.
46
+ loader.onMarkdownParsersReady(cb);
47
+ await vi.waitFor(() => expect(cb).toHaveBeenCalledTimes(1));
48
+ expect(load).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("does not fire (and returns a no-op) when parsers are already loaded", async () => {
52
+ const loader = await freshLoader();
53
+ loader.provideMarkdownParsers(FAKE_PARSERS); // eager/ESM steady state
54
+
55
+ const cb = vi.fn();
56
+ const unsub = loader.onMarkdownParsersReady(cb);
57
+ await Promise.resolve();
58
+ expect(cb).not.toHaveBeenCalled();
59
+ expect(() => unsub()).not.toThrow();
60
+ });
61
+
62
+ it("unsubscribe prevents the callback from firing", async () => {
63
+ const loader = await freshLoader();
64
+ loader.setMarkdownParsersLoader(() => Promise.resolve(FAKE_PARSERS));
65
+
66
+ const cb = vi.fn();
67
+ const unsub = loader.onMarkdownParsersReady(cb);
68
+ unsub();
69
+
70
+ await loader.loadMarkdownParsers();
71
+ expect(cb).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("fires each subscriber exactly once (no re-fire on a later load call)", async () => {
75
+ const loader = await freshLoader();
76
+ loader.setMarkdownParsersLoader(() => Promise.resolve(FAKE_PARSERS));
77
+
78
+ const cb = vi.fn();
79
+ loader.onMarkdownParsersReady(cb);
80
+
81
+ await loader.loadMarkdownParsers();
82
+ await loader.loadMarkdownParsers(); // cached; must not re-notify
83
+ expect(cb).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it("provideMarkdownParsers (eager) flushes pending subscribers", async () => {
87
+ const loader = await freshLoader();
88
+ // Subscribe while still unloaded, then have the eager path provide.
89
+ loader.setMarkdownParsersLoader(() => new Promise(() => {})); // never resolves
90
+ const cb = vi.fn();
91
+ loader.onMarkdownParsersReady(cb);
92
+
93
+ loader.provideMarkdownParsers(FAKE_PARSERS);
94
+ expect(cb).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it("keeps pending subscribers across a failed chunk load so a successful retry heals them", async () => {
98
+ const loader = await freshLoader();
99
+ let attempt = 0;
100
+ loader.setMarkdownParsersLoader(() => {
101
+ attempt += 1;
102
+ return attempt === 1
103
+ ? Promise.reject(new Error("chunk 404"))
104
+ : Promise.resolve(FAKE_PARSERS);
105
+ });
106
+
107
+ const cb = vi.fn();
108
+ loader.onMarkdownParsersReady(cb);
109
+ await expect(loader.loadMarkdownParsers()).rejects.toThrow("chunk 404");
110
+ // Failed attempt: not fired, but still registered.
111
+ expect(cb).not.toHaveBeenCalled();
112
+
113
+ // A later subscribe re-triggers the load (rejected promise was not cached)
114
+ // and the retry heals BOTH the new subscriber and the one that rendered
115
+ // escaped during the failed attempt.
116
+ const cb2 = vi.fn();
117
+ loader.onMarkdownParsersReady(cb2);
118
+ await vi.waitFor(() => expect(cb2).toHaveBeenCalledTimes(1));
119
+ expect(cb).toHaveBeenCalledTimes(1);
120
+ expect(attempt).toBe(2);
121
+ });
122
+
123
+ it("unsubscribing after a failed load still prevents firing on the retry", async () => {
124
+ const loader = await freshLoader();
125
+ let attempt = 0;
126
+ loader.setMarkdownParsersLoader(() => {
127
+ attempt += 1;
128
+ return attempt === 1
129
+ ? Promise.reject(new Error("chunk 404"))
130
+ : Promise.resolve(FAKE_PARSERS);
131
+ });
132
+
133
+ const cb = vi.fn();
134
+ const unsub = loader.onMarkdownParsersReady(cb);
135
+ await expect(loader.loadMarkdownParsers()).rejects.toThrow("chunk 404");
136
+ unsub();
137
+
138
+ await loader.loadMarkdownParsers();
139
+ expect(cb).not.toHaveBeenCalled();
140
+ expect(attempt).toBe(2);
141
+ });
142
+
143
+ it("one throwing subscriber does not starve the others", async () => {
144
+ const loader = await freshLoader();
145
+ loader.setMarkdownParsersLoader(() => Promise.resolve(FAKE_PARSERS));
146
+
147
+ const boom = vi.fn(() => {
148
+ throw new Error("subscriber blew up");
149
+ });
150
+ const ok = vi.fn();
151
+ loader.onMarkdownParsersReady(boom);
152
+ loader.onMarkdownParsersReady(ok);
153
+
154
+ await loader.loadMarkdownParsers();
155
+ expect(boom).toHaveBeenCalledTimes(1);
156
+ expect(ok).toHaveBeenCalledTimes(1);
157
+ });
158
+ });
@@ -10,10 +10,66 @@ let loader: (() => Promise<MarkdownParsersModule>) | null = null;
10
10
  let moduleCache: MarkdownParsersModule | null = null;
11
11
  let loadPromise: Promise<MarkdownParsersModule> | null = null;
12
12
 
13
+ // Surfaces that want to self-heal when the lazy `markdown-parsers.js` chunk
14
+ // lands. See `onMarkdownParsersReady` below for why this is centralized.
15
+ const readySubscribers = new Set<() => void>();
16
+
17
+ // Flip `moduleCache` and fan out to everyone waiting to re-render, exactly once
18
+ // per subscriber. Called from BOTH the eager (`provideMarkdownParsers`) and lazy
19
+ // (`loadMarkdownParsers`) paths so a subscriber can't miss the transition.
20
+ const markParsersReady = (mod: MarkdownParsersModule): MarkdownParsersModule => {
21
+ moduleCache = mod;
22
+ // Snapshot + clear first: fire-once semantics, and a callback that re-subscribes
23
+ // (it won't — `onMarkdownParsersReady` no-ops once ready) can't loop.
24
+ const subs = [...readySubscribers];
25
+ readySubscribers.clear();
26
+ for (const cb of subs) {
27
+ // One bad subscriber must not starve the others (or leave messages escaped).
28
+ try {
29
+ cb();
30
+ } catch {
31
+ /* subscriber threw: swallow so the remaining re-renders still run */
32
+ }
33
+ }
34
+ return mod;
35
+ };
36
+
13
37
  export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>) => {
14
38
  loader = l;
15
39
  };
16
40
 
41
+ /**
42
+ * Register `cb` to run once the markdown parsers (marked + DOMPurify) become
43
+ * available, i.e. when the lazy `markdown-parsers.js` chunk resolves on the
44
+ * IIFE/CDN build. Returns an unsubscribe function.
45
+ *
46
+ * This exists so every markdown render surface (chat messages, artifact pane,
47
+ * and any future one) shares a SINGLE self-heal path instead of each wiring its
48
+ * own `loadMarkdownParsers().then(reRender)`. Before this, a new surface that
49
+ * forgot to do that rendered escaped plain text until a user interaction forced
50
+ * a re-render — the recurring first-render bug (chat messages, then the artifact
51
+ * pane) this centralizes away.
52
+ *
53
+ * If the parsers are ALREADY loaded, `cb` is not scheduled and a no-op
54
+ * unsubscribe is returned: the caller's first render already used real markdown,
55
+ * so there is nothing to heal (this is the ESM/CJS build's steady state, and the
56
+ * CDN build's state after the first chunk load). Registering also kicks the load
57
+ * so a surface that renders before anything else triggers it still heals.
58
+ * Fires at most once per subscription.
59
+ */
60
+ export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
61
+ if (moduleCache) return () => {};
62
+ readySubscribers.add(cb);
63
+ // Ensure the chunk is actually being fetched; harmless if already in flight.
64
+ // Swallow rejection here — on failure the subscriber stays registered (see
65
+ // `onLoadFailure`) and the surface keeps its escaped fallback until a retry
66
+ // succeeds; this catch only avoids an unhandled rejection.
67
+ void loadMarkdownParsers().catch(() => {});
68
+ return () => {
69
+ readySubscribers.delete(cb);
70
+ };
71
+ };
72
+
17
73
  /**
18
74
  * Register the parsers synchronously. Used by the ESM/CJS build (where `marked`
19
75
  * and `dompurify` are bundled directly via `markdown-parsers-eager.ts`), so
@@ -23,7 +79,22 @@ export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>
23
79
  * it lazy-loads the `markdown-parsers.js` chunk instead.
24
80
  */
25
81
  export const provideMarkdownParsers = (mod: MarkdownParsersModule): void => {
26
- moduleCache = mod;
82
+ markParsersReady(mod);
83
+ };
84
+
85
+ // On a failed chunk load (404, ad blocker, offline), don't cache the rejected
86
+ // promise: reset it so a later subscribe/load call can retry. Pending
87
+ // subscribers are KEPT — a surface that rendered escaped during the failed
88
+ // attempt must still heal when a retry (kicked by a new subscriber or an
89
+ // explicit loadMarkdownParsers() call) succeeds; dropping them here left that
90
+ // surface escaped forever after a transient failure. They're only retained
91
+ // while the parsers stay unloaded — success releases them (fire-once in
92
+ // `markParsersReady`), and callers can unsubscribe. On a permanently failing
93
+ // setup they persist for the page's life, which is the price of healing.
94
+ // Re-throw so awaiters of loadMarkdownParsers() still observe the rejection.
95
+ const onLoadFailure = (err: unknown): never => {
96
+ loadPromise = null;
97
+ throw err;
27
98
  };
28
99
 
29
100
  export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
@@ -31,16 +102,10 @@ export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
31
102
  if (loadPromise) return loadPromise;
32
103
  if (!loader) {
33
104
  // Fallback for regular ESM/CJS consumers (they import directly)
34
- loadPromise = import("./markdown-parsers-entry").then((mod) => {
35
- moduleCache = mod;
36
- return mod;
37
- });
105
+ loadPromise = import("./markdown-parsers-entry").then(markParsersReady, onLoadFailure);
38
106
  return loadPromise;
39
107
  }
40
- loadPromise = loader().then((mod) => {
41
- moduleCache = mod;
42
- return mod;
43
- });
108
+ loadPromise = loader().then(markParsersReady, onLoadFailure);
44
109
  return loadPromise;
45
110
  };
46
111