@runtypelabs/persona 4.8.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 (117) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +59 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +622 -2
  26. package/dist/index.d.ts +622 -2
  27. package/dist/index.global.js +54 -46
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +59 -51
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +234 -55
  42. package/dist/theme-editor-preview.d.cts +473 -0
  43. package/dist/theme-editor-preview.d.ts +473 -0
  44. package/dist/theme-editor-preview.js +48 -42
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/composer-parts.ts +3 -12
  57. package/src/components/context-mention-button.test.ts +70 -0
  58. package/src/components/context-mention-button.ts +82 -0
  59. package/src/components/context-mention-chip.ts +134 -0
  60. package/src/components/context-mention-menu.test.ts +508 -0
  61. package/src/components/context-mention-menu.ts +0 -0
  62. package/src/components/message-bubble.test.ts +175 -0
  63. package/src/components/message-bubble.ts +177 -19
  64. package/src/components/panel.ts +7 -10
  65. package/src/context-mentions-bundle.test.ts +163 -0
  66. package/src/context-mentions-entry.ts +185 -0
  67. package/src/context-mentions-inline-entry.test.ts +136 -0
  68. package/src/context-mentions-inline-entry.ts +226 -0
  69. package/src/context-mentions-inline-loader.test.ts +30 -0
  70. package/src/context-mentions-inline-loader.ts +36 -0
  71. package/src/context-mentions-inline.ts +15 -0
  72. package/src/context-mentions-loader.ts +32 -0
  73. package/src/context-mentions.ts +16 -0
  74. package/src/index-core.ts +26 -0
  75. package/src/index-global.ts +51 -0
  76. package/src/markdown-parsers-loader.ts +35 -44
  77. package/src/plugin-kit.test.ts +40 -0
  78. package/src/plugin-kit.ts +39 -5
  79. package/src/session.mentions.test.ts +175 -0
  80. package/src/session.test.ts +52 -4
  81. package/src/session.ts +121 -5
  82. package/src/smart-dom-reader.test.ts +129 -2
  83. package/src/smart-dom-reader.ts +127 -1
  84. package/src/styles/context-mention-menu-css.ts +176 -0
  85. package/src/styles/widget.css +229 -126
  86. package/src/theme-editor/sections.ts +3 -3
  87. package/src/types/theme.ts +2 -0
  88. package/src/types.ts +525 -0
  89. package/src/ui.mention-submit.test.ts +235 -0
  90. package/src/ui.ts +301 -51
  91. package/src/utils/chunk-loader.test.ts +97 -0
  92. package/src/utils/chunk-loader.ts +88 -0
  93. package/src/utils/composer-contenteditable.test.ts +507 -0
  94. package/src/utils/composer-contenteditable.ts +626 -0
  95. package/src/utils/composer-document.test.ts +280 -0
  96. package/src/utils/composer-document.ts +293 -0
  97. package/src/utils/composer-history.test.ts +35 -7
  98. package/src/utils/composer-history.ts +30 -20
  99. package/src/utils/composer-input.ts +159 -0
  100. package/src/utils/context-mention-controller.test.ts +1215 -0
  101. package/src/utils/context-mention-controller.ts +1186 -0
  102. package/src/utils/context-mention-manager.test.ts +422 -0
  103. package/src/utils/context-mention-manager.ts +410 -0
  104. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  105. package/src/utils/context-mention-orchestrator.ts +348 -0
  106. package/src/utils/live-region.test.ts +108 -0
  107. package/src/utils/live-region.ts +94 -0
  108. package/src/utils/mention-channels.ts +63 -0
  109. package/src/utils/mention-llm-format.test.ts +91 -0
  110. package/src/utils/mention-llm-format.ts +79 -0
  111. package/src/utils/mention-matcher.test.ts +86 -0
  112. package/src/utils/mention-matcher.ts +221 -0
  113. package/src/utils/mention-token.ts +72 -0
  114. package/src/utils/mention-trigger.test.ts +155 -0
  115. package/src/utils/mention-trigger.ts +156 -0
  116. package/src/utils/theme.test.ts +28 -4
  117. package/src/utils/tokens.ts +27 -11
@@ -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,19 +7,22 @@ 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
+ });
12
17
 
13
18
  // Surfaces that want to self-heal when the lazy `markdown-parsers.js` chunk
14
19
  // lands. See `onMarkdownParsersReady` below for why this is centralized.
15
20
  const readySubscribers = new Set<() => void>();
16
21
 
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
22
+ // Fan out to everyone waiting to re-render, exactly once per subscriber.
23
+ // Called from BOTH the eager (`provideMarkdownParsers`) and lazy
19
24
  // (`loadMarkdownParsers`) paths so a subscriber can't miss the transition.
20
- const markParsersReady = (mod: MarkdownParsersModule): MarkdownParsersModule => {
21
- moduleCache = mod;
25
+ const notifyParsersReady = (): void => {
22
26
  // Snapshot + clear first: fire-once semantics, and a callback that re-subscribes
23
27
  // (it won't — `onMarkdownParsersReady` no-ops once ready) can't loop.
24
28
  const subs = [...readySubscribers];
@@ -31,11 +35,25 @@ const markParsersReady = (mod: MarkdownParsersModule): MarkdownParsersModule =>
31
35
  /* subscriber threw: swallow so the remaining re-renders still run */
32
36
  }
33
37
  }
34
- return mod;
35
38
  };
36
39
 
37
- export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>) => {
38
- loader = l;
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
+ });
39
57
  };
40
58
 
41
59
  /**
@@ -58,12 +76,13 @@ export const setMarkdownParsersLoader = (l: () => Promise<MarkdownParsersModule>
58
76
  * Fires at most once per subscription.
59
77
  */
60
78
  export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
61
- if (moduleCache) return () => {};
79
+ if (getSync()) return () => {};
62
80
  readySubscribers.add(cb);
63
81
  // Ensure the chunk is actually being fetched; harmless if already in flight.
64
82
  // 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.
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.
67
86
  void loadMarkdownParsers().catch(() => {});
68
87
  return () => {
69
88
  readySubscribers.delete(cb);
@@ -79,36 +98,8 @@ export const onMarkdownParsersReady = (cb: () => void): (() => void) => {
79
98
  * it lazy-loads the `markdown-parsers.js` chunk instead.
80
99
  */
81
100
  export const provideMarkdownParsers = (mod: MarkdownParsersModule): void => {
82
- markParsersReady(mod);
101
+ provide(mod);
102
+ notifyParsersReady();
83
103
  };
84
104
 
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;
98
- };
99
-
100
- export const loadMarkdownParsers = (): Promise<MarkdownParsersModule> => {
101
- if (moduleCache) return Promise.resolve(moduleCache);
102
- if (loadPromise) return loadPromise;
103
- if (!loader) {
104
- // Fallback for regular ESM/CJS consumers (they import directly)
105
- loadPromise = import("./markdown-parsers-entry").then(markParsersReady, onLoadFailure);
106
- return loadPromise;
107
- }
108
- loadPromise = loader().then(markParsersReady, onLoadFailure);
109
- return loadPromise;
110
- };
111
-
112
- export const getMarkdownParsersSync = (): MarkdownParsersModule | null => {
113
- return moduleCache;
114
- };
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`;
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AgentWidgetSession } from "./session";
3
+ import type {
4
+ AgentWidgetMessage,
5
+ AgentWidgetRequestPayload,
6
+ ContentPart,
7
+ } from "./types";
8
+ import type { MentionSubmitBundle } from "./context-mentions-entry";
9
+
10
+ /** A closed, empty SSE stream so `dispatch` completes with no events. */
11
+ function closedStream(): ReadableStream<Uint8Array> {
12
+ return new ReadableStream({ start: (c) => c.close() });
13
+ }
14
+
15
+ /**
16
+ * Regression coverage for the mention/command submit path:
17
+ * - an empty-text, chip-only submit must NOT render the "[Image]" fallback
18
+ * (that fallback is for image-only turns);
19
+ * - a `command: "server"` mention's `resolve().context` must ride to the
20
+ * backend under request `context.mentions` (the c1 dispatch channel).
21
+ */
22
+ describe("AgentWidgetSession — mention/command submit", () => {
23
+ function makeSession(captured: { payload?: AgentWidgetRequestPayload }) {
24
+ let messages: AgentWidgetMessage[] = [];
25
+ let status = "idle";
26
+ let streaming = false;
27
+ const session = new AgentWidgetSession(
28
+ {
29
+ apiUrl: "http://localhost:8000",
30
+ customFetch: async (_url, _init, payload) => {
31
+ captured.payload = payload;
32
+ return { ok: true, body: closedStream() } as unknown as Response;
33
+ },
34
+ },
35
+ {
36
+ onMessagesChanged: (m) => {
37
+ messages = m;
38
+ },
39
+ onStatusChanged: (s) => {
40
+ status = s;
41
+ },
42
+ onStreamingChanged: (s) => {
43
+ streaming = s;
44
+ },
45
+ onError: () => {},
46
+ }
47
+ );
48
+ return {
49
+ session,
50
+ user: () => messages.find((m) => m.role === "user"),
51
+ status: () => status,
52
+ streaming: () => streaming,
53
+ };
54
+ }
55
+
56
+ it("renders no [Image] fallback for a command-only submit and forwards context.mentions", async () => {
57
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
58
+ const { session, user } = makeSession(captured);
59
+
60
+ // A server skill: `/lookup 1042` resolved to structured context, namespaced
61
+ // `{ [sourceId]: { [itemId]: context } }` exactly as the manager builds it.
62
+ const context = { commands: { lookup: { intent: "lookup-order", orderId: "1042" } } };
63
+ const refs = [{ sourceId: "commands", itemId: "lookup", label: "lookup" }];
64
+ const bundle: MentionSubmitBundle = {
65
+ blocks: [],
66
+ contentParts: [],
67
+ context,
68
+ };
69
+
70
+ await session.sendMessage("", {
71
+ mentions: {
72
+ refs,
73
+ finalize: async () => bundle,
74
+ },
75
+ });
76
+
77
+ const msg = user();
78
+ expect(msg).toBeDefined();
79
+ // The bug: an empty-text, mention-only submit used to display "[Image]".
80
+ expect(msg!.content).toBe("");
81
+ expect(msg!.content).not.toBe("[Image]");
82
+ // The command chip echoes immediately as a ref.
83
+ expect(msg!.contextMentions).toEqual(refs);
84
+ // Structured server-command data is merged onto the message…
85
+ expect(msg!.mentionContext).toEqual(context);
86
+ // …and rides to the backend under request `context.mentions`.
87
+ expect(captured.payload?.context).toMatchObject({ mentions: context });
88
+ });
89
+
90
+ it("merges resolved @-mention llmContent into the dispatched snapshot", async () => {
91
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
92
+ const { session } = makeSession(captured);
93
+
94
+ await session.sendMessage("summarize this", {
95
+ mentions: {
96
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
97
+ finalize: async (): Promise<MentionSubmitBundle> => ({
98
+ blocks: ["```App.tsx\nFILE BODY\n```"],
99
+ contentParts: [],
100
+ context: {},
101
+ }),
102
+ },
103
+ });
104
+
105
+ const dispatched = (captured.payload?.messages ?? []).find(
106
+ (m) => m.role === "user"
107
+ );
108
+ // The client collapses `llmContent` into the payload `content` field via the
109
+ // content-priority chain, so the model must see the resolved file body
110
+ // appended to the typed prose — not just the raw "summarize this".
111
+ const sent = dispatched?.content;
112
+ expect(typeof sent).toBe("string");
113
+ expect(sent as string).toContain("FILE BODY");
114
+ expect(sent as string).toContain("summarize this");
115
+ // Mention blocks lead; the typed prose lands last (joined by a blank line).
116
+ expect(sent as string).toBe("```App.tsx\nFILE BODY\n```\n\nsummarize this");
117
+ });
118
+
119
+ it("still renders the [Image] fallback for an image-only submit", async () => {
120
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
121
+ const { session, user } = makeSession(captured);
122
+
123
+ const contentParts: ContentPart[] = [
124
+ { type: "image", image: "data:image/png;base64,abc123", mimeType: "image/png" },
125
+ ];
126
+ await session.sendMessage("", { contentParts });
127
+
128
+ expect(user()!.content).toBe("[Image]");
129
+ });
130
+
131
+ it("cancel() during a pending mention finalize aborts the turn without dispatching", async () => {
132
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
133
+ const { session, status, streaming } = makeSession(captured);
134
+
135
+ let releaseFinalize!: (bundle: MentionSubmitBundle) => void;
136
+ const pending = new Promise<MentionSubmitBundle>((resolve) => {
137
+ releaseFinalize = resolve;
138
+ });
139
+
140
+ const sent = session.sendMessage("summarize this", {
141
+ mentions: {
142
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
143
+ finalize: () => pending,
144
+ },
145
+ });
146
+
147
+ // Stop mid-resolution, then let finalize settle: dispatch must not fire.
148
+ session.cancel();
149
+ releaseFinalize({ blocks: ["```App.tsx\nBODY\n```"], contentParts: [], context: {} });
150
+ await sent;
151
+
152
+ expect(captured.payload).toBeUndefined();
153
+ expect(status()).toBe("idle");
154
+ expect(streaming()).toBe(false);
155
+ });
156
+
157
+ it("dispatches a normal mention submit once finalize resolves", async () => {
158
+ const captured: { payload?: AgentWidgetRequestPayload } = {};
159
+ const { session, streaming } = makeSession(captured);
160
+
161
+ await session.sendMessage("summarize this", {
162
+ mentions: {
163
+ refs: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
164
+ finalize: async (): Promise<MentionSubmitBundle> => ({
165
+ blocks: ["```App.tsx\nBODY\n```"],
166
+ contentParts: [],
167
+ context: {},
168
+ }),
169
+ },
170
+ });
171
+
172
+ expect(captured.payload).toBeDefined();
173
+ expect(streaming()).toBe(false);
174
+ });
175
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
3
- import { AgentWidgetMessage } from './types';
3
+ import { AgentWidgetMessage, AgentWidgetContentSegment } from './types';
4
4
 
5
5
  describe('AgentWidgetSession - Message Injection', () => {
6
6
  let session: AgentWidgetSession;
@@ -369,6 +369,51 @@ describe('AgentWidgetSession - Message Injection', () => {
369
369
  });
370
370
  });
371
371
 
372
+ describe('AgentWidgetSession - inline contentSegments', () => {
373
+ const originalFetch = global.fetch;
374
+ afterEach(() => {
375
+ global.fetch = originalFetch;
376
+ vi.restoreAllMocks();
377
+ });
378
+
379
+ const makeHangingSession = () => {
380
+ // A never-settling fetch keeps the turn in-flight so the appended user
381
+ // message can be inspected without a dispatch-error bubble replacing it.
382
+ global.fetch = vi.fn().mockImplementation(() => new Promise(() => {}));
383
+ return new AgentWidgetSession(
384
+ { apiUrl: 'http://example.invalid/chat' },
385
+ {
386
+ onMessagesChanged: () => {},
387
+ onStatusChanged: () => {},
388
+ onStreamingChanged: () => {},
389
+ }
390
+ );
391
+ };
392
+
393
+ it('stores ordered contentSegments on the sent user message', () => {
394
+ const session = makeHangingSession();
395
+ const segments: AgentWidgetContentSegment[] = [
396
+ { kind: 'text', text: 'Check ' },
397
+ { kind: 'mention', ref: { sourceId: 'files', itemId: 'app', label: 'App.tsx' } },
398
+ { kind: 'text', text: ' please' },
399
+ ];
400
+ void session.sendMessage('Check @App.tsx please', {
401
+ contentSegments: segments,
402
+ });
403
+ const userMsg = session.getMessages().find((m) => m.role === 'user');
404
+ // Segments must land on the stored (sequence-normalized) copy, not just the
405
+ // orphaned input literal — this is the fix for the dead inline-token branch.
406
+ expect(userMsg?.contentSegments).toEqual(segments);
407
+ });
408
+
409
+ it('omits contentSegments for a plain message', () => {
410
+ const session = makeHangingSession();
411
+ void session.sendMessage('plain message');
412
+ const userMsg = session.getMessages().find((m) => m.role === 'user');
413
+ expect(userMsg?.contentSegments).toBeUndefined();
414
+ });
415
+ });
416
+
372
417
  describe('AgentWidgetSession - cancel()', () => {
373
418
  const originalFetch = global.fetch;
374
419
 
@@ -404,9 +449,12 @@ describe('AgentWidgetSession - cancel()', () => {
404
449
 
405
450
  // Kick off the dispatch but don't await: we want it in-flight when we cancel.
406
451
  const dispatchPromise = session.sendMessage('Hello');
407
- // Let the session set up the AbortController and call fetch.
408
- await Promise.resolve();
409
- await Promise.resolve();
452
+ // Drain microtasks until the session has set up the AbortController and
453
+ // called fetch (payload building is async, so the exact tick count is not a
454
+ // contract — wait for the observable effect instead).
455
+ for (let i = 0; i < 20 && capturedSignal === null; i++) {
456
+ await Promise.resolve();
457
+ }
410
458
 
411
459
  expect(streaming).toBe(true);
412
460
  expect(session.isStreaming()).toBe(true);
package/src/session.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  generateUserMessageId,
28
28
  generateAssistantMessageId
29
29
  } from "./utils/message-id";
30
- import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "./utils/content";
30
+ import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, createTextPart, hasImages } from "./utils/content";
31
31
  import {
32
32
  buildArtifactRefRawContent,
33
33
  resolveArtifactDisplayMode
@@ -37,8 +37,11 @@ import type {
37
37
  VoiceStatus,
38
38
  VoiceConfig,
39
39
  ReadAloudState,
40
- SpeechEngine
40
+ SpeechEngine,
41
+ AgentWidgetContentSegment,
42
+ AgentWidgetContextMentionRef
41
43
  } from "./types";
44
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
42
45
  import {
43
46
  createVoiceProvider,
44
47
  isVoiceSupported,
@@ -1129,17 +1132,92 @@ export class AgentWidgetSession {
1129
1132
  });
1130
1133
  }
1131
1134
 
1135
+ /**
1136
+ * Resolve the gathered mentions and merge them into the user message's
1137
+ * model-visible content. Default path is `llmAppend` → `llmContent` (or a text
1138
+ * `contentPart` when parts are present, since `contentParts` wins the priority
1139
+ * chain); the opt-in structured `context` lands on `mentionContext` for the
1140
+ * client to namespace into the request `context`. Failures were already
1141
+ * dropped by `finalize()`.
1142
+ */
1143
+ private async applyMentionBundle(
1144
+ userMessage: AgentWidgetMessage,
1145
+ typedText: string,
1146
+ finalize: () => Promise<MentionSubmitBundle>
1147
+ ): Promise<void> {
1148
+ let bundle: MentionSubmitBundle;
1149
+ try {
1150
+ bundle = await finalize();
1151
+ } catch {
1152
+ return; // resolution failed entirely; send the typed text as-is
1153
+ }
1154
+
1155
+ // Blocks arrive pre-formatted from the mention layer (fenced/document/custom
1156
+ // per `contextMentions.llmFormat`); the session just joins them and appends
1157
+ // the typed prose last.
1158
+ const block = bundle.blocks.join("\n\n");
1159
+ const llmText = [block, typedText].filter(Boolean).join("\n\n");
1160
+
1161
+ const hasAttachments =
1162
+ Array.isArray(userMessage.contentParts) &&
1163
+ userMessage.contentParts.length > 0;
1164
+ const hasMentionParts = bundle.contentParts.length > 0;
1165
+
1166
+ if (hasAttachments) {
1167
+ // The typed text already rides as a content part; add the block + mention
1168
+ // parts ahead of the existing attachment parts (mentions first).
1169
+ const parts: ContentPart[] = [];
1170
+ if (block) parts.push(createTextPart(block));
1171
+ parts.push(...bundle.contentParts);
1172
+ parts.push(...userMessage.contentParts!);
1173
+ userMessage.contentParts = parts;
1174
+ } else if (hasMentionParts) {
1175
+ const parts: ContentPart[] = [];
1176
+ if (llmText) parts.push(createTextPart(llmText));
1177
+ parts.push(...bundle.contentParts);
1178
+ userMessage.contentParts = parts;
1179
+ } else if (block) {
1180
+ userMessage.llmContent = llmText;
1181
+ }
1182
+
1183
+ if (Object.keys(bundle.context).length > 0) {
1184
+ userMessage.mentionContext = bundle.context;
1185
+ }
1186
+ }
1187
+
1132
1188
  public async sendMessage(
1133
1189
  rawInput: string,
1134
1190
  options?: {
1135
1191
  viaVoice?: boolean;
1136
1192
  /** Multi-modal content parts (e.g., images) to include with the message */
1137
1193
  contentParts?: ContentPart[];
1194
+ /**
1195
+ * Context mentions gathered from the composer. `refs` echo immediately as
1196
+ * chips; `finalize()` resolves any pending/submit sources just before
1197
+ * dispatch (after the instant echo) and the bundle is merged into this
1198
+ * message's model-visible content.
1199
+ */
1200
+ mentions?: {
1201
+ refs: AgentWidgetContextMentionRef[];
1202
+ finalize: () => Promise<MentionSubmitBundle>;
1203
+ };
1204
+ /**
1205
+ * Ordered prose + mention segments for inline-mode display (`display:
1206
+ * "inline"`). Stored on the user message so its bubble re-renders `@tokens`
1207
+ * in place (and suppresses the chip row). Display/transcript only — the
1208
+ * model still sees resolved bodies via the `mentions` bundle above.
1209
+ */
1210
+ contentSegments?: AgentWidgetContentSegment[];
1138
1211
  }
1139
1212
  ) {
1140
1213
  const input = rawInput.trim();
1141
- // Allow sending if there's text OR attachments
1142
- if (!input && (!options?.contentParts || options.contentParts.length === 0)) return;
1214
+ // Allow sending if there's text OR attachments OR mentions
1215
+ if (
1216
+ !input &&
1217
+ (!options?.contentParts || options.contentParts.length === 0) &&
1218
+ (!options?.mentions || options.mentions.refs.length === 0)
1219
+ )
1220
+ return;
1143
1221
 
1144
1222
  this.stopSpeaking();
1145
1223
  this.abortController?.abort();
@@ -1160,25 +1238,63 @@ export class AgentWidgetSession {
1160
1238
  // the proxy path auto-generates a different id than `assistantMessageId`.
1161
1239
  this.activeAssistantMessageId = null;
1162
1240
 
1241
+ // Fallback display text ONLY when the sole content is image attachments.
1242
+ // A mention/command-only submit (empty text + a chip) must NOT read as
1243
+ // "[Image]"; it renders its chips with empty text instead.
1244
+ const imageOnlyFallback =
1245
+ options?.contentParts && hasImages(options.contentParts)
1246
+ ? IMAGE_ONLY_MESSAGE_FALLBACK_TEXT
1247
+ : "";
1248
+
1163
1249
  const userMessage: AgentWidgetMessage = {
1164
1250
  id: userMessageId,
1165
1251
  role: "user",
1166
- content: input || IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, // Display text (fallback if only images)
1252
+ content: input || imageOnlyFallback, // Display text (fallback if only images)
1167
1253
  createdAt: new Date().toISOString(),
1168
1254
  sequence: this.nextSequence(),
1169
1255
  viaVoice: options?.viaVoice || false,
1170
1256
  // Include contentParts if provided (for multi-modal messages)
1171
1257
  ...(options?.contentParts && options.contentParts.length > 0 && {
1172
1258
  contentParts: options.contentParts
1259
+ }),
1260
+ // Echo mention chips immediately (refs only; payloads merged below).
1261
+ ...(options?.mentions && options.mentions.refs.length > 0 && {
1262
+ contextMentions: options.mentions.refs
1263
+ }),
1264
+ // Inline mode: ordered display segments for in-prose `@token` rendering.
1265
+ ...(options?.contentSegments && options.contentSegments.length > 0 && {
1266
+ contentSegments: options.contentSegments
1173
1267
  })
1174
1268
  };
1175
1269
 
1176
1270
  this.appendMessage(userMessage);
1177
1271
  this.setStreaming(true);
1178
1272
 
1273
+ // Assign the fresh controller BEFORE the mention await so cancel() (or a
1274
+ // superseding sendMessage) during finalize() aborts THIS turn, not a stale
1275
+ // prior controller.
1179
1276
  const controller = new AbortController();
1180
1277
  this.abortController = controller;
1181
1278
 
1279
+ // Resolve + merge mentions AFTER the instant echo but BEFORE dispatch, so
1280
+ // the model sees the context while the user's bubble already rendered.
1281
+ // `appendMessage` stores a sequence-normalized COPY (see `ensureSequence`),
1282
+ // so mutate THAT live reference — not the orphaned `userMessage` literal —
1283
+ // or the merged `llmContent`/`contentParts`/`mentionContext` never reach the
1284
+ // dispatch snapshot below. Re-emit so any merged parts also reach the UI.
1285
+ if (options?.mentions) {
1286
+ const stored =
1287
+ this.messages.find((m) => m.id === userMessageId) ?? userMessage;
1288
+ await this.applyMentionBundle(stored, input, options.mentions.finalize);
1289
+ // A cancel() or new sendMessage during finalize aborted this controller
1290
+ // (and replaced/nulled the shared ref). Bail without dispatching and
1291
+ // leave whatever idle/streaming state that caller already set.
1292
+ if (controller.signal.aborted || this.abortController !== controller) {
1293
+ return;
1294
+ }
1295
+ this.callbacks.onMessagesChanged([...this.messages]);
1296
+ }
1297
+
1182
1298
  const snapshot = [...this.messages];
1183
1299
 
1184
1300
  try {