@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.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +59 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +622 -2
- package/dist/index.d.ts +622 -2
- package/dist/index.global.js +54 -46
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +59 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +234 -55
- package/dist/theme-editor-preview.d.cts +473 -0
- package/dist/theme-editor-preview.d.ts +473 -0
- package/dist/theme-editor-preview.js +48 -42
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +26 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +229 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +525 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.ts +301 -51
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +28 -4
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
|
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 =
|
|
38
|
-
|
|
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 (
|
|
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
|
-
// `
|
|
66
|
-
// succeeds; this catch only avoids an unhandled
|
|
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
|
-
|
|
101
|
+
provide(mod);
|
|
102
|
+
notifyParsersReady();
|
|
83
103
|
};
|
|
84
104
|
|
|
85
|
-
|
|
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;
|
package/src/plugin-kit.test.ts
CHANGED
|
@@ -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
|
-
?
|
|
222
|
+
? anchorTop - offset - contentRect.height
|
|
196
223
|
: rect.bottom + offset;
|
|
197
224
|
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
});
|
package/src/session.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
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 (
|
|
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 ||
|
|
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 {
|