@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
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,
@@ -25,6 +29,25 @@ export type {
25
29
  // Context provider types (e.g. for config.contextProviders)
26
30
  AgentWidgetContextProvider,
27
31
  AgentWidgetContextProviderContext,
32
+ // Context mention types (e.g. for config.contextMentions)
33
+ AgentWidgetContextMentionConfig,
34
+ AgentWidgetContextMentionSource,
35
+ AgentWidgetContextMentionItem,
36
+ AgentWidgetContextMentionPayload,
37
+ AgentWidgetContextMentionRef,
38
+ AgentWidgetContextMentionSearchContext,
39
+ AgentWidgetContextMentionResolveContext,
40
+ AgentWidgetContextMentionMenuRenderContext,
41
+ AgentWidgetContextMentionItemRenderContext,
42
+ AgentWidgetContextMentionChipRenderContext,
43
+ AgentWidgetContextMentionTokenRenderContext,
44
+ // Per-mention LLM block formatter entry (config.contextMentions.llmFormat)
45
+ AgentWidgetMentionLlmEntry,
46
+ // Skills / slash-commands (config.contextMentions.triggers)
47
+ AgentWidgetMentionTriggerChannel,
48
+ AgentWidgetMentionTriggerPosition,
49
+ AgentWidgetContextMentionCommandContext,
50
+ AgentWidgetContextMentionComposerCapability,
28
51
  AgentWidgetCustomFetch,
29
52
  AgentWidgetSSEEventParser,
30
53
  AgentWidgetSSEEventResult,
@@ -188,6 +211,17 @@ export {
188
211
  createDirectivePostprocessor
189
212
  } from "./postprocessors";
190
213
  export type { MarkdownProcessorOptions } from "./postprocessors";
214
+ // Markdown parser-readiness escape hatch. On the IIFE/CDN build `marked` +
215
+ // `DOMPurify` load lazily, so content injected right after init can render as
216
+ // escaped plain text until the chunk resolves. Hosts can `await
217
+ // loadMarkdownParsers()` before injecting, or `onMarkdownParsersReady(cb)` to be
218
+ // told when they land (both resolve/no-op instantly on the npm build, where the
219
+ // parsers are bundled eagerly).
220
+ export {
221
+ loadMarkdownParsers,
222
+ onMarkdownParsersReady
223
+ } from "./markdown-parsers-loader";
224
+ export type { MarkdownParsersModule } from "./markdown-parsers-loader";
191
225
  export {
192
226
  createDefaultSanitizer,
193
227
  resolveSanitizer
@@ -211,6 +245,13 @@ export {
211
245
  fileToImagePart,
212
246
  validateImageFile
213
247
  } from "./utils/content";
248
+ // Context mention helpers (for building config.contextMentions.sources)
249
+ export {
250
+ defaultMentionFilter,
251
+ createStaticMentionSource,
252
+ createSlashCommandsSource,
253
+ } from "./utils/mention-matcher";
254
+ export type { SlashCommandDefinition } from "./utils/mention-matcher";
214
255
  export {
215
256
  collectEnrichedPageContext,
216
257
  formatEnrichedContext,
@@ -361,7 +402,11 @@ export type {
361
402
 
362
403
  // Component system exports
363
404
  export { componentRegistry } from "./components/registry";
364
- export type { ComponentRenderer, ComponentContext } from "./components/registry";
405
+ export type {
406
+ ComponentRenderer,
407
+ ComponentContext,
408
+ ComponentRegistrationOptions
409
+ } from "./components/registry";
365
410
  export {
366
411
  createComponentStreamParser,
367
412
  isComponentDirectiveType
@@ -137,3 +137,54 @@ setRuntypeTtsLoader(() => {
137
137
  }
138
138
  return import(/* @vite-ignore */ chunkUrl);
139
139
  });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Deferred Context Mentions loading.
143
+ //
144
+ // This bundle is built with `./context-mentions-entry` external (see
145
+ // `tsup.global.config.ts`): the mention controller/manager/menu runtime is kept
146
+ // out of the CDN payload. Register a loader that imports the self-contained
147
+ // `context-mentions.js` chunk from a sibling URL; the orchestrator calls it on
148
+ // the first `@`/affordance-button interaction (and can prefetch on composer
149
+ // focus) only when `contextMentions.enabled`. Same pattern as the chunks above.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ import { setContextMentionsLoader } from "./context-mentions-loader";
153
+ import { setContextMentionsInlineLoader } from "./context-mentions-inline-loader";
154
+
155
+ setContextMentionsLoader(() => {
156
+ const chunkUrl = widgetScriptSrc?.replace(
157
+ /index\.global\.js($|\?)/,
158
+ "context-mentions.js$1",
159
+ );
160
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
161
+ return Promise.reject(
162
+ new Error(
163
+ "Could not derive the context-mentions.js URL from the widget script URL " +
164
+ `(${widgetScriptSrc ?? "unavailable"}). Self-hosted deployments that ` +
165
+ "rename index.global.js should host context-mentions.js alongside it.",
166
+ ),
167
+ );
168
+ }
169
+ return import(/* @vite-ignore */ chunkUrl);
170
+ });
171
+
172
+ // Sibling loader for the inline-mention contenteditable chunk, loaded on composer
173
+ // mount when `contextMentions.display === "inline"`. Same sibling-URL scheme.
174
+ setContextMentionsInlineLoader(() => {
175
+ const chunkUrl = widgetScriptSrc?.replace(
176
+ /index\.global\.js($|\?)/,
177
+ "context-mentions-inline.js$1",
178
+ );
179
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
180
+ return Promise.reject(
181
+ new Error(
182
+ "Could not derive the context-mentions-inline.js URL from the widget " +
183
+ `script URL (${widgetScriptSrc ?? "unavailable"}). Self-hosted ` +
184
+ "deployments that rename index.global.js should host " +
185
+ "context-mentions-inline.js alongside it.",
186
+ ),
187
+ );
188
+ }
189
+ return import(/* @vite-ignore */ chunkUrl);
190
+ });
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
+ });
@@ -1,3 +1,4 @@
1
+ import { createChunkLoader } from "./utils/chunk-loader";
1
2
  import type { Marked } from "marked";
2
3
  import type DOMPurify from "dompurify";
3
4
 
@@ -6,12 +7,86 @@ export type MarkdownParsersModule = {
6
7
  DOMPurify: typeof DOMPurify;
7
8
  };
8
9
 
9
- let loader: (() => Promise<MarkdownParsersModule>) | null = null;
10
- let moduleCache: MarkdownParsersModule | null = null;
11
- let loadPromise: Promise<MarkdownParsersModule> | null = null;
10
+ // Memoization + rejection-retry semantics live in `createChunkLoader`. The
11
+ // ESM/CJS fallback imports `./markdown-parsers-entry` directly; the IIFE/CDN
12
+ // build registers a loader that fetches the `markdown-parsers.js` chunk.
13
+ const { setLoader, load, provide, getSync } =
14
+ createChunkLoader<MarkdownParsersModule>({
15
+ fallbackImport: () => import("./markdown-parsers-entry"),
16
+ });
17
+
18
+ // Surfaces that want to self-heal when the lazy `markdown-parsers.js` chunk
19
+ // lands. See `onMarkdownParsersReady` below for why this is centralized.
20
+ const readySubscribers = new Set<() => void>();
21
+
22
+ // Fan out to everyone waiting to re-render, exactly once per subscriber.
23
+ // Called from BOTH the eager (`provideMarkdownParsers`) and lazy
24
+ // (`loadMarkdownParsers`) paths so a subscriber can't miss the transition.
25
+ const notifyParsersReady = (): void => {
26
+ // Snapshot + clear first: fire-once semantics, and a callback that re-subscribes
27
+ // (it won't — `onMarkdownParsersReady` no-ops once ready) can't loop.
28
+ const subs = [...readySubscribers];
29
+ readySubscribers.clear();
30
+ for (const cb of subs) {
31
+ // One bad subscriber must not starve the others (or leave messages escaped).
32
+ try {
33
+ cb();
34
+ } catch {
35
+ /* subscriber threw: swallow so the remaining re-renders still run */
36
+ }
37
+ }
38
+ };
39
+
40
+ export const setMarkdownParsersLoader = setLoader;
41
+
42
+ // On a failed chunk load (404, ad blocker, offline), `createChunkLoader`
43
+ // resets its cached promise so a later call retries. Pending subscribers are
44
+ // KEPT — a surface that rendered escaped during the failed attempt must still
45
+ // heal when a retry (kicked by a new subscriber or an explicit
46
+ // loadMarkdownParsers() call) succeeds; dropping them left that surface
47
+ // escaped forever after a transient failure. They're only retained while the
48
+ // parsers stay unloaded — success releases them (fire-once in
49
+ // `notifyParsersReady`), and callers can unsubscribe.
50
+ export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
51
+ const cached = getSync();
52
+ if (cached) return Promise.resolve(cached);
53
+ return load().then((mod) => {
54
+ notifyParsersReady();
55
+ return mod;
56
+ });
57
+ };
12
58
 
13
- export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>) => {
14
- loader = l;
59
+ /**
60
+ * Register `cb` to run once the markdown parsers (marked + DOMPurify) become
61
+ * available, i.e. when the lazy `markdown-parsers.js` chunk resolves on the
62
+ * IIFE/CDN build. Returns an unsubscribe function.
63
+ *
64
+ * This exists so every markdown render surface (chat messages, artifact pane,
65
+ * and any future one) shares a SINGLE self-heal path instead of each wiring its
66
+ * own `loadMarkdownParsers().then(reRender)`. Before this, a new surface that
67
+ * forgot to do that rendered escaped plain text until a user interaction forced
68
+ * a re-render — the recurring first-render bug (chat messages, then the artifact
69
+ * pane) this centralizes away.
70
+ *
71
+ * If the parsers are ALREADY loaded, `cb` is not scheduled and a no-op
72
+ * unsubscribe is returned: the caller's first render already used real markdown,
73
+ * so there is nothing to heal (this is the ESM/CJS build's steady state, and the
74
+ * CDN build's state after the first chunk load). Registering also kicks the load
75
+ * so a surface that renders before anything else triggers it still heals.
76
+ * Fires at most once per subscription.
77
+ */
78
+ export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
79
+ if (getSync()) return () => {};
80
+ readySubscribers.add(cb);
81
+ // Ensure the chunk is actually being fetched; harmless if already in flight.
82
+ // Swallow rejection here — on failure the subscriber stays registered (see
83
+ // the note on `loadMarkdownParsers`) and the surface keeps its escaped
84
+ // fallback until a retry succeeds; this catch only avoids an unhandled
85
+ // rejection.
86
+ void loadMarkdownParsers().catch(() => {});
87
+ return () => {
88
+ readySubscribers.delete(cb);
89
+ };
15
90
  };
16
91
 
17
92
  /**
@@ -23,27 +98,8 @@ export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>
23
98
  * it lazy-loads the `markdown-parsers.js` chunk instead.
24
99
  */
25
100
  export const provideMarkdownParsers = (mod: MarkdownParsersModule): void => {
26
- moduleCache = mod;
27
- };
28
-
29
- export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
30
- if (moduleCache) return Promise.resolve(moduleCache);
31
- if (loadPromise) return loadPromise;
32
- if (!loader) {
33
- // Fallback for regular ESM/CJS consumers (they import directly)
34
- loadPromise = import("./markdown-parsers-entry").then((mod) => {
35
- moduleCache = mod;
36
- return mod;
37
- });
38
- return loadPromise;
39
- }
40
- loadPromise = loader().then((mod) => {
41
- moduleCache = mod;
42
- return mod;
43
- });
44
- return loadPromise;
101
+ provide(mod);
102
+ notifyParsersReady();
45
103
  };
46
104
 
47
- export const getMarkdownParsersSync = (): MarkdownParsersModule | null => {
48
- return moduleCache;
49
- };
105
+ export const getMarkdownParsersSync = getSync;
@@ -188,6 +188,46 @@ describe("createPopover", () => {
188
188
  expect(content.getRootNode()).toBe(shadow);
189
189
  });
190
190
 
191
+ it("anchors vertically to a verticalOffset when placement is top-start", () => {
192
+ const { anchor, content } = setup();
193
+ anchor.getBoundingClientRect = () =>
194
+ ({
195
+ left: 100,
196
+ right: 400,
197
+ top: 200,
198
+ bottom: 260,
199
+ width: 300,
200
+ height: 60,
201
+ x: 100,
202
+ y: 200,
203
+ toJSON: () => ({}),
204
+ }) as DOMRect;
205
+ content.getBoundingClientRect = () =>
206
+ ({
207
+ width: 180,
208
+ height: 100,
209
+ left: 0,
210
+ right: 180,
211
+ top: 0,
212
+ bottom: 100,
213
+ x: 0,
214
+ y: 0,
215
+ toJSON: () => ({}),
216
+ }) as DOMRect;
217
+
218
+ const popover = createPopover({
219
+ anchor,
220
+ content,
221
+ placement: "top-start",
222
+ offset: 6,
223
+ verticalOffset: () => 40,
224
+ });
225
+ popover.open();
226
+
227
+ // anchorTop = 200 + 40 = 240 → top = 240 - 6 - 100 = 134
228
+ expect(content.style.top).toBe("134px");
229
+ });
230
+
191
231
  it("destroy removes content and detaches listeners", () => {
192
232
  vi.useFakeTimers();
193
233
  const onDismiss = vi.fn();
package/src/plugin-kit.ts CHANGED
@@ -117,6 +117,23 @@ export interface PopoverOptions {
117
117
  offset?: number;
118
118
  /** Set `content`'s `min-width` to the anchor's width. Default `false`. */
119
119
  matchAnchorWidth?: boolean;
120
+ /**
121
+ * Horizontal anchoring override for `*-start` placements. Returns the desired
122
+ * left offset in px measured from the anchor's LEFT edge (e.g. the `@` trigger's
123
+ * x within the composer), or `null` to left-align with the anchor as usual. When
124
+ * this option is present the content is content-sized and capped to the anchor's
125
+ * width, and the resolved left is clamped so the content never overflows the
126
+ * anchor's left/right edges (Slack-style: a near-right trigger shifts the menu
127
+ * left to fit). Omit it entirely for plain anchor-left/right placement.
128
+ */
129
+ horizontalOffset?: () => number | null;
130
+ /**
131
+ * Vertical anchoring override for `top-*` placements. Returns the desired
132
+ * anchor point's top offset in px measured from the anchor's TOP edge (e.g.
133
+ * the trigger line's top within the composer), or `null` to use the anchor's
134
+ * top edge as usual.
135
+ */
136
+ verticalOffset?: () => number | null;
120
137
  /**
121
138
  * Inline `z-index` for `content`. Default `2147483000` so it overlays the rest
122
139
  * of the widget. Pass `null` to leave z-index to your own CSS.
@@ -175,6 +192,8 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
175
192
  placement = "bottom-start",
176
193
  offset = 6,
177
194
  matchAnchorWidth = false,
195
+ horizontalOffset,
196
+ verticalOffset,
178
197
  zIndex = 2147483000,
179
198
  onOpen,
180
199
  onDismiss,
@@ -189,16 +208,31 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
189
208
  const rect = anchor.getBoundingClientRect();
190
209
  content.style.position = "fixed";
191
210
  if (matchAnchorWidth) content.style.minWidth = `${rect.width}px`;
211
+ // Trigger-anchored mode: content is sized to its own contents but never wider
212
+ // than the composer, so it can shift horizontally within the composer's span.
213
+ if (horizontalOffset) content.style.maxWidth = `${rect.width}px`;
214
+
215
+ // Single content measurement per reposition (after width constraints apply).
216
+ const contentRect = content.getBoundingClientRect();
192
217
 
218
+ const offY = verticalOffset?.() ?? null;
219
+ const anchorTop = offY != null ? rect.top + offY : rect.top;
193
220
  const top =
194
221
  placement === "top-start" || placement === "top-end"
195
- ? rect.top - offset - content.getBoundingClientRect().height
222
+ ? anchorTop - offset - contentRect.height
196
223
  : rect.bottom + offset;
197
224
 
198
- const left =
199
- placement === "bottom-end" || placement === "top-end"
200
- ? rect.right - content.getBoundingClientRect().width
201
- : rect.left;
225
+ const isEnd = placement === "bottom-end" || placement === "top-end";
226
+ // Default: anchor left (or anchor right for `*-end`). Trigger-anchored mode
227
+ // (offset present and non-null) overrides with the clamped trigger position so
228
+ // the menu never overflows the anchor's edges — a near-right trigger shifts it
229
+ // left to fit (Slack-style). A null offset falls back to the default.
230
+ let left = isEnd ? rect.right - contentRect.width : rect.left;
231
+ const off = horizontalOffset?.() ?? null;
232
+ if (off != null) {
233
+ const maxLeft = Math.max(rect.left, rect.right - contentRect.width);
234
+ left = Math.min(Math.max(rect.left + off, rect.left), maxLeft);
235
+ }
202
236
 
203
237
  content.style.top = `${top}px`;
204
238
  content.style.left = `${left}px`;