@runtypelabs/persona 4.11.1 → 4.13.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/dist/codegen.cjs +13 -13
- package/dist/codegen.d.cts +9 -0
- package/dist/codegen.d.ts +9 -0
- package/dist/codegen.js +13 -13
- package/dist/index.cjs +45 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.global.js +34 -34
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/theme-editor-preview.cjs +44 -44
- package/dist/theme-editor-preview.js +33 -33
- package/package.json +1 -1
- package/src/codegen.test.ts +43 -0
- package/src/install.test.ts +83 -0
- package/src/install.ts +48 -2
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +115 -28
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
- package/src/utils/code-generators.ts +30 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
package/src/codegen.test.ts
CHANGED
|
@@ -23,6 +23,49 @@ describe("codegen subpath", () => {
|
|
|
23
23
|
expect(code).not.toContain("@latest");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
describe("cdnBase option", () => {
|
|
27
|
+
const cdnBase = "https://cdn.runtype.com/persona/latest";
|
|
28
|
+
|
|
29
|
+
it("emits the custom base in every script format", () => {
|
|
30
|
+
expect(fromSubpath(config, "script-installer", { cdnBase })).toContain(
|
|
31
|
+
`src="${cdnBase}/install.global.js"`
|
|
32
|
+
);
|
|
33
|
+
const manual = fromSubpath(config, "script-manual", { cdnBase });
|
|
34
|
+
expect(manual).toContain(`href="${cdnBase}/widget.css"`);
|
|
35
|
+
expect(manual).toContain(`src="${cdnBase}/index.global.js"`);
|
|
36
|
+
expect(fromSubpath(config, "script-advanced", { cdnBase })).toContain(
|
|
37
|
+
`var CDN_BASE = '${cdnBase}';`
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("tolerates a trailing slash", () => {
|
|
42
|
+
const code = fromSubpath(config, "script-installer", { cdnBase: `${cdnBase}/` });
|
|
43
|
+
expect(code).toContain(`src="${cdnBase}/install.global.js"`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("percent-encodes characters that could break the emitted JS/HTML contexts", () => {
|
|
47
|
+
const hostile = "https://cdn.example.com/x'\"\\`/persona";
|
|
48
|
+
const encoded = "https://cdn.example.com/x%27%22%5C%60/persona";
|
|
49
|
+
// script-advanced emits into a single-quoted JS literal
|
|
50
|
+
expect(fromSubpath(config, "script-advanced", { cdnBase: hostile })).toContain(
|
|
51
|
+
`var CDN_BASE = '${encoded}';`
|
|
52
|
+
);
|
|
53
|
+
// script-installer / script-manual emit into double-quoted HTML attributes
|
|
54
|
+
expect(fromSubpath(config, "script-installer", { cdnBase: hostile })).toContain(
|
|
55
|
+
`src="${encoded}/install.global.js"`
|
|
56
|
+
);
|
|
57
|
+
expect(fromSubpath(config, "script-manual", { cdnBase: hostile })).toContain(
|
|
58
|
+
`href="${encoded}/widget.css"`
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("leaves no jsDelivr URL behind when a custom base is set", () => {
|
|
63
|
+
for (const format of ["script-installer", "script-manual", "script-advanced"] as const) {
|
|
64
|
+
expect(fromSubpath(config, format, { cdnBase })).not.toContain("jsdelivr");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
26
69
|
describe("mount target option", () => {
|
|
27
70
|
it("defaults to body when no target is given", () => {
|
|
28
71
|
expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
|
package/src/install.test.ts
CHANGED
|
@@ -343,6 +343,89 @@ describe("install.ts: deferral gate", () => {
|
|
|
343
343
|
});
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
+
describe("install.ts: CDN base derivation", () => {
|
|
347
|
+
// The installer captures document.currentScript.src synchronously at module
|
|
348
|
+
// evaluation; fake it with an own-property override (jsdom's getter lives on
|
|
349
|
+
// the prototype, so a configurable instance property shadows it cleanly).
|
|
350
|
+
function setCurrentScript(src: string) {
|
|
351
|
+
const script = document.createElement("script");
|
|
352
|
+
script.src = src;
|
|
353
|
+
Object.defineProperty(document, "currentScript", { value: script, configurable: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
afterEach(() => {
|
|
357
|
+
delete (document as any).currentScript;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run the deferred path with NO bundles provided so loadCSS/loadLauncher
|
|
362
|
+
* inject real (never-resolving in jsdom) elements whose URLs we can read.
|
|
363
|
+
*/
|
|
364
|
+
async function installedUrls(installerOptions: any) {
|
|
365
|
+
await install({ config: { apiUrl: "/api", launcher: { enabled: true } }, ...installerOptions });
|
|
366
|
+
await flush();
|
|
367
|
+
const css = document.head.querySelector("link[rel=\"stylesheet\"][data-persona]") as HTMLLinkElement | null;
|
|
368
|
+
const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
|
|
369
|
+
return { cssHref: css?.href ?? null, scripts };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it("derives asset URLs from the installer's own script directory (first-party CDN)", async () => {
|
|
373
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
374
|
+
const { cssHref, scripts } = await installedUrls({});
|
|
375
|
+
expect(cssHref).toBe("https://cdn.runtype.com/persona/latest/widget.css");
|
|
376
|
+
expect(scripts).toContain("https://cdn.runtype.com/persona/latest/launcher.global.js");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("keeps npm-CDN siblings on the installer's own pinned range (no @latest skew)", async () => {
|
|
380
|
+
setCurrentScript("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/install.global.js");
|
|
381
|
+
const { cssHref, scripts } = await installedUrls({});
|
|
382
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/widget.css");
|
|
383
|
+
expect(scripts).toContain("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/launcher.global.js");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("the full bundle URL follows the installer directory on the eager path", async () => {
|
|
387
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
388
|
+
markCssLoaded();
|
|
389
|
+
await install({ config: { apiUrl: "/api", launcher: { enabled: false } } });
|
|
390
|
+
await flush();
|
|
391
|
+
const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
|
|
392
|
+
expect(scripts).toContain("https://cdn.runtype.com/persona/latest/index.global.js");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("falls back to jsDelivr@latest when currentScript is unavailable", async () => {
|
|
396
|
+
// No setCurrentScript: module/inline contexts have no script src.
|
|
397
|
+
const { cssHref } = await installedUrls({});
|
|
398
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("falls back to the npm CDN for a non-http(s) installer source", async () => {
|
|
402
|
+
setCurrentScript("data:text/javascript,void%200");
|
|
403
|
+
const { cssHref } = await installedUrls({});
|
|
404
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("an explicit `version` opts back into npm-CDN URLs even when the src is derivable", async () => {
|
|
408
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
409
|
+
const { cssHref } = await installedUrls({ version: "1.2.3" });
|
|
410
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@1.2.3/dist/widget.css");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("an explicit `cdn` opts back into npm-CDN URLs even when the src is derivable", async () => {
|
|
414
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
415
|
+
const { cssHref } = await installedUrls({ cdn: "unpkg" });
|
|
416
|
+
expect(cssHref).toBe("https://unpkg.com/@runtypelabs/persona@latest/dist/widget.css");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("explicit cssUrl + jsUrl win over the derived installer base", async () => {
|
|
420
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
421
|
+
const { cssHref } = await installedUrls({
|
|
422
|
+
cssUrl: "https://assets.example.com/persona/widget.css",
|
|
423
|
+
jsUrl: "https://assets.example.com/persona/index.global.js",
|
|
424
|
+
});
|
|
425
|
+
expect(cssHref).toBe("https://assets.example.com/persona/widget.css");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
346
429
|
describe("install.ts: onError", () => {
|
|
347
430
|
it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
|
|
348
431
|
markCssLoaded();
|
package/src/install.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
export {};
|
|
8
8
|
|
|
9
9
|
interface SiteAgentInstallConfig {
|
|
10
|
+
// Setting `version` or `cdn` explicitly opts into npm-CDN asset URLs
|
|
11
|
+
// (jsDelivr/unpkg). When neither is set, assets load from the directory the
|
|
12
|
+
// installer script itself was served from — see getCdnBase().
|
|
10
13
|
version?: string;
|
|
11
14
|
cdn?: "unpkg" | "jsdelivr";
|
|
12
15
|
cssUrl?: string;
|
|
@@ -137,6 +140,10 @@ declare global {
|
|
|
137
140
|
// Get config from script attributes (must be called synchronously during script execution)
|
|
138
141
|
const scriptConfig = getConfigFromScript();
|
|
139
142
|
|
|
143
|
+
// The installer's own URL, captured now because document.currentScript is
|
|
144
|
+
// null once synchronous execution ends (and always inside modules).
|
|
145
|
+
const installerSrc = (document.currentScript as HTMLScriptElement | null)?.src || "";
|
|
146
|
+
|
|
140
147
|
// Merge script attributes with window config (script attributes take precedence)
|
|
141
148
|
const windowConfig: SiteAgentInstallConfig = window.siteAgentConfig || {};
|
|
142
149
|
const config: SiteAgentInstallConfig = { ...windowConfig, ...scriptConfig };
|
|
@@ -194,7 +201,28 @@ declare global {
|
|
|
194
201
|
const cdn = config.cdn || "jsdelivr";
|
|
195
202
|
const autoInit = config.autoInit !== false; // Default to true
|
|
196
203
|
|
|
197
|
-
//
|
|
204
|
+
// Directory the installer itself was served from. Every published layout
|
|
205
|
+
// (npm CDN dist/, first-party CDN, self-hosted copies) keeps the sibling
|
|
206
|
+
// assets next to install.global.js, so this base is valid wherever the
|
|
207
|
+
// installer loaded from — and always satisfies the same CSP/origin rules
|
|
208
|
+
// that let the installer through. Non-http(s) sources (inline, blob:) and
|
|
209
|
+
// module contexts (no currentScript) yield null.
|
|
210
|
+
const getInstallerBase = (): string | null => {
|
|
211
|
+
if (!installerSrc) return null;
|
|
212
|
+
try {
|
|
213
|
+
const url = new URL(installerSrc, document.baseURI);
|
|
214
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
215
|
+
return url.origin + url.pathname.slice(0, url.pathname.lastIndexOf("/"));
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Determine CDN base URL. Precedence:
|
|
222
|
+
// 1. explicit cssUrl + jsUrl
|
|
223
|
+
// 2. explicit `cdn` / `version` → npm CDN construction
|
|
224
|
+
// 3. the installer's own directory (default)
|
|
225
|
+
// 4. npm CDN construction (currentScript unavailable)
|
|
198
226
|
const getCdnBase = () => {
|
|
199
227
|
// For a custom URL override, derive the sibling launcher URL when the
|
|
200
228
|
// override mirrors the dist layout (…/index.global.js → …/launcher.global.js)
|
|
@@ -209,8 +237,26 @@ declare global {
|
|
|
209
237
|
};
|
|
210
238
|
}
|
|
211
239
|
|
|
240
|
+
// Default: load assets from wherever the installer was served, so a page
|
|
241
|
+
// whose CSP allows only its own CDN (e.g. cdn.runtype.com) never has
|
|
242
|
+
// sibling requests fan out to a third-party host. An explicit `cdn` or
|
|
243
|
+
// `version` keeps the documented npm-CDN behavior.
|
|
244
|
+
if (!config.cdn && !config.version) {
|
|
245
|
+
const selfBase = getInstallerBase();
|
|
246
|
+
if (selfBase) {
|
|
247
|
+
return {
|
|
248
|
+
cssUrl: `${selfBase}/widget.css`,
|
|
249
|
+
jsUrl: `${selfBase}/index.global.js`,
|
|
250
|
+
launcherUrl: `${selfBase}/launcher.global.js` as string | null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
212
255
|
const packageName = "@runtypelabs/persona";
|
|
213
|
-
|
|
256
|
+
// jsDelivr namespaces npm packages under /npm/; unpkg serves them at the root.
|
|
257
|
+
const basePath = cdn === "unpkg"
|
|
258
|
+
? `/${packageName}@${version}/dist`
|
|
259
|
+
: `/npm/${packageName}@${version}/dist`;
|
|
214
260
|
const host = cdn === "unpkg" ? "https://unpkg.com" : "https://cdn.jsdelivr.net";
|
|
215
261
|
|
|
216
262
|
return {
|
|
@@ -449,3 +449,368 @@ describe("scrollBehavior.announce (Principle 15)", () => {
|
|
|
449
449
|
controller.destroy();
|
|
450
450
|
});
|
|
451
451
|
});
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
// Anchor-top parks on the first UNREAD attention-worthy block of a turn:
|
|
455
|
+
// tool calls and reasoning are chrome and never targeted, so a response that
|
|
456
|
+
// opens with them scrolls them past rather than stranding the answer below
|
|
457
|
+
// the fold.
|
|
458
|
+
describe("anchor-top first-unread positioning", () => {
|
|
459
|
+
beforeEach(() => {
|
|
460
|
+
installRafMock();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const anchorTopConfig = () =>
|
|
464
|
+
baseConfig({
|
|
465
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const emitToolMessage = (
|
|
469
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
470
|
+
id: string
|
|
471
|
+
) => {
|
|
472
|
+
controller.injectTestMessage({
|
|
473
|
+
type: "message",
|
|
474
|
+
message: {
|
|
475
|
+
id,
|
|
476
|
+
role: "assistant",
|
|
477
|
+
content: "",
|
|
478
|
+
createdAt: CREATED_AT,
|
|
479
|
+
variant: "tool",
|
|
480
|
+
toolCall: { id, name: "search", status: "complete" },
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// An artifact / component block: substance lives in rawContent, `content`
|
|
486
|
+
// is empty. Testing text alone would silently skip these.
|
|
487
|
+
const emitArtifactMessage = (
|
|
488
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
489
|
+
id: string
|
|
490
|
+
) => {
|
|
491
|
+
controller.injectTestMessage({
|
|
492
|
+
type: "message",
|
|
493
|
+
message: {
|
|
494
|
+
id,
|
|
495
|
+
role: "assistant",
|
|
496
|
+
content: "",
|
|
497
|
+
createdAt: CREATED_AT,
|
|
498
|
+
rawContent: JSON.stringify({
|
|
499
|
+
text: "",
|
|
500
|
+
component: "PersonaArtifactInline",
|
|
501
|
+
props: { artifactId: "art-1", title: "Report" },
|
|
502
|
+
}),
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const setBubbleOffsetTop = (mount: HTMLElement, id: string, value: number) => {
|
|
508
|
+
const bubble = getScrollContainer(mount).querySelector<HTMLElement>(
|
|
509
|
+
`[data-message-id="${id}"]`
|
|
510
|
+
);
|
|
511
|
+
expect(bubble).not.toBeNull();
|
|
512
|
+
Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const startTurn = (
|
|
516
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
517
|
+
mount: HTMLElement,
|
|
518
|
+
raf: ReturnType<typeof installRafMock>
|
|
519
|
+
) => {
|
|
520
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
521
|
+
emitUserMessage(controller, "u1", "Where is my order?");
|
|
522
|
+
setBubbleOffsetTop(mount, "u1", 300);
|
|
523
|
+
raf.flush();
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
it("skips a run of tool calls and anchors the answer", () => {
|
|
527
|
+
const raf = installRafMock();
|
|
528
|
+
const mount = createMount();
|
|
529
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
530
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
531
|
+
scrollHeight: 1000,
|
|
532
|
+
clientHeight: 400,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
startTurn(controller, mount, raf);
|
|
536
|
+
expect(metrics.getScrollTop()).toBe(284); // parked on the sent message
|
|
537
|
+
|
|
538
|
+
for (let i = 0; i < 6; i += 1) emitToolMessage(controller, `t${i}`);
|
|
539
|
+
metrics.setScrollHeight(1500);
|
|
540
|
+
raf.flush();
|
|
541
|
+
// Tool calls are chrome: they never move the anchor.
|
|
542
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
543
|
+
|
|
544
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
545
|
+
setBubbleOffsetTop(mount, "a1", 700);
|
|
546
|
+
raf.flush();
|
|
547
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
548
|
+
|
|
549
|
+
controller.destroy();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("anchors an artifact block, whose content lives in rawContent", () => {
|
|
553
|
+
const raf = installRafMock();
|
|
554
|
+
const mount = createMount();
|
|
555
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
556
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
557
|
+
scrollHeight: 1000,
|
|
558
|
+
clientHeight: 400,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
startTurn(controller, mount, raf);
|
|
562
|
+
for (let i = 0; i < 3; i += 1) emitToolMessage(controller, `t${i}`);
|
|
563
|
+
metrics.setScrollHeight(1500);
|
|
564
|
+
raf.flush();
|
|
565
|
+
|
|
566
|
+
emitArtifactMessage(controller, "art-msg");
|
|
567
|
+
setBubbleOffsetTop(mount, "art-msg", 620);
|
|
568
|
+
raf.flush();
|
|
569
|
+
|
|
570
|
+
expect(metrics.getScrollTop()).toBe(604);
|
|
571
|
+
controller.destroy();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("holds the FIRST unread block when more text streams in after it", () => {
|
|
575
|
+
const raf = installRafMock();
|
|
576
|
+
const mount = createMount();
|
|
577
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
578
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
579
|
+
scrollHeight: 1000,
|
|
580
|
+
clientHeight: 400,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
startTurn(controller, mount, raf);
|
|
584
|
+
emitStreamingAssistant(controller, "a1", "First segment.");
|
|
585
|
+
setBubbleOffsetTop(mount, "a1", 500);
|
|
586
|
+
metrics.setScrollHeight(1400);
|
|
587
|
+
raf.flush();
|
|
588
|
+
expect(metrics.getScrollTop()).toBe(484);
|
|
589
|
+
|
|
590
|
+
// Interleaved: tool, then a second text segment. The anchor stays on the
|
|
591
|
+
// first unread block — later blocks stream in below it.
|
|
592
|
+
emitToolMessage(controller, "t0");
|
|
593
|
+
emitStreamingAssistant(controller, "a2", "Second segment.");
|
|
594
|
+
setBubbleOffsetTop(mount, "a2", 900);
|
|
595
|
+
metrics.setScrollHeight(1800);
|
|
596
|
+
raf.flush();
|
|
597
|
+
|
|
598
|
+
expect(metrics.getScrollTop()).toBe(484);
|
|
599
|
+
controller.destroy();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("stands down once the reader scrolls", () => {
|
|
603
|
+
const raf = installRafMock();
|
|
604
|
+
const mount = createMount();
|
|
605
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
606
|
+
const sc = getScrollContainer(mount);
|
|
607
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
608
|
+
|
|
609
|
+
startTurn(controller, mount, raf);
|
|
610
|
+
for (let i = 0; i < 6; i += 1) emitToolMessage(controller, `t${i}`);
|
|
611
|
+
metrics.setScrollHeight(1500);
|
|
612
|
+
raf.flush();
|
|
613
|
+
|
|
614
|
+
// The reader takes over: wheels down to watch the tools run. Engagement
|
|
615
|
+
// is keyed on real input, NOT on the scroll event (streaming content
|
|
616
|
+
// clamps and restores scrollTop on its own — see the regression test
|
|
617
|
+
// below), so the wheel is what must register here.
|
|
618
|
+
metrics.setScrollTop(520);
|
|
619
|
+
sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
|
|
620
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
621
|
+
|
|
622
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
623
|
+
setBubbleOffsetTop(mount, "a1", 700);
|
|
624
|
+
raf.flush();
|
|
625
|
+
|
|
626
|
+
// Their position wins: no re-anchor under them.
|
|
627
|
+
expect(metrics.getScrollTop()).toBe(520);
|
|
628
|
+
controller.destroy();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("re-arms on the next send after the reader scrolled away", () => {
|
|
632
|
+
const raf = installRafMock();
|
|
633
|
+
const mount = createMount();
|
|
634
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
635
|
+
const sc = getScrollContainer(mount);
|
|
636
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
637
|
+
|
|
638
|
+
startTurn(controller, mount, raf);
|
|
639
|
+
metrics.setScrollTop(520);
|
|
640
|
+
sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
|
|
641
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
642
|
+
|
|
643
|
+
// A new send is an explicit "take me along".
|
|
644
|
+
emitUserMessage(controller, "u2", "And the second order?");
|
|
645
|
+
setBubbleOffsetTop(mount, "u2", 600);
|
|
646
|
+
metrics.setScrollHeight(1400);
|
|
647
|
+
raf.flush();
|
|
648
|
+
|
|
649
|
+
emitStreamingAssistant(controller, "a2", "That one shipped Tuesday.");
|
|
650
|
+
setBubbleOffsetTop(mount, "a2", 800);
|
|
651
|
+
metrics.setScrollHeight(1600);
|
|
652
|
+
raf.flush();
|
|
653
|
+
|
|
654
|
+
expect(metrics.getScrollTop()).toBe(784);
|
|
655
|
+
controller.destroy();
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Two shapes the browser testbed covers but that are worth pinning down here,
|
|
660
|
+
// since they are where the policy's judgement calls actually bite.
|
|
661
|
+
describe("anchor-top first-unread — edge shapes", () => {
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
installRafMock();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const anchorTopConfig = () =>
|
|
667
|
+
baseConfig({
|
|
668
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const emitTool = (
|
|
672
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
673
|
+
id: string
|
|
674
|
+
) => {
|
|
675
|
+
controller.injectTestMessage({
|
|
676
|
+
type: "message",
|
|
677
|
+
message: {
|
|
678
|
+
id,
|
|
679
|
+
role: "assistant",
|
|
680
|
+
content: "",
|
|
681
|
+
createdAt: CREATED_AT,
|
|
682
|
+
variant: "tool",
|
|
683
|
+
toolCall: { id, name: "search", status: "complete" },
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const setTop = (mount: HTMLElement, id: string, value: number) => {
|
|
689
|
+
const bubble = getScrollContainer(mount).querySelector<HTMLElement>(
|
|
690
|
+
`[data-message-id="${id}"]`
|
|
691
|
+
);
|
|
692
|
+
expect(bubble).not.toBeNull();
|
|
693
|
+
Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// KNOWN TRADEOFF: a chatty preamble ("Let me look that up…") is itself the
|
|
697
|
+
// first unread block, so the anchor lands there and the tool run still
|
|
698
|
+
// pushes the real answer toward the fold. That is the policy behaving as
|
|
699
|
+
// specified — chronological, honest about what arrived first — but it is
|
|
700
|
+
// the case most likely to want revisiting.
|
|
701
|
+
it("anchors a preamble, not the answer that follows the tool run", () => {
|
|
702
|
+
const raf = installRafMock();
|
|
703
|
+
const mount = createMount();
|
|
704
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
705
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
706
|
+
scrollHeight: 1000,
|
|
707
|
+
clientHeight: 400,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
711
|
+
emitUserMessage(controller, "u1", "Look into my order");
|
|
712
|
+
setTop(mount, "u1", 300);
|
|
713
|
+
raf.flush();
|
|
714
|
+
|
|
715
|
+
emitStreamingAssistant(controller, "pre", "Sure — let me pull that up.");
|
|
716
|
+
setTop(mount, "pre", 420);
|
|
717
|
+
metrics.setScrollHeight(1200);
|
|
718
|
+
raf.flush();
|
|
719
|
+
expect(metrics.getScrollTop()).toBe(404);
|
|
720
|
+
|
|
721
|
+
for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
|
|
722
|
+
emitStreamingAssistant(controller, "answer", "Good news — it is on track.");
|
|
723
|
+
setTop(mount, "answer", 900);
|
|
724
|
+
metrics.setScrollHeight(1800);
|
|
725
|
+
raf.flush();
|
|
726
|
+
|
|
727
|
+
// Still parked on the preamble: the answer is NOT re-targeted.
|
|
728
|
+
expect(metrics.getScrollTop()).toBe(404);
|
|
729
|
+
controller.destroy();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("holds the sent message when a turn produces only tool calls", () => {
|
|
733
|
+
const raf = installRafMock();
|
|
734
|
+
const mount = createMount();
|
|
735
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
736
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
737
|
+
scrollHeight: 1000,
|
|
738
|
+
clientHeight: 400,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
742
|
+
emitUserMessage(controller, "u1", "Just refresh the tracking data");
|
|
743
|
+
setTop(mount, "u1", 300);
|
|
744
|
+
raf.flush();
|
|
745
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
746
|
+
|
|
747
|
+
for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
|
|
748
|
+
metrics.setScrollHeight(1600);
|
|
749
|
+
raf.flush();
|
|
750
|
+
|
|
751
|
+
// Nothing attention-worthy ever arrived: never chase the tool bubbles.
|
|
752
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
753
|
+
controller.destroy();
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Regression: streaming content under a pinned anchor makes the browser clamp
|
|
758
|
+
// scrollTop and then restore it — a matched pair of real scroll events with no
|
|
759
|
+
// user involved (observed live as -12 then +12 during a tool run). Treating
|
|
760
|
+
// those as reader intent silently disabled every later re-anchor, so a
|
|
761
|
+
// response that opened with tool calls stayed stuck on the sent message.
|
|
762
|
+
describe("anchor-top first-unread — layout scroll is not reader intent", () => {
|
|
763
|
+
beforeEach(() => {
|
|
764
|
+
installRafMock();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("still anchors the answer after a clamp/restore scroll pair", () => {
|
|
768
|
+
const raf = installRafMock();
|
|
769
|
+
const mount = createMount();
|
|
770
|
+
const controller = createAgentExperience(
|
|
771
|
+
mount,
|
|
772
|
+
baseConfig({
|
|
773
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
774
|
+
})
|
|
775
|
+
);
|
|
776
|
+
const sc = getScrollContainer(mount);
|
|
777
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
778
|
+
|
|
779
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
780
|
+
emitUserMessage(controller, "u1", "Where is my order?");
|
|
781
|
+
const userBubble = sc.querySelector<HTMLElement>('[data-message-id="u1"]');
|
|
782
|
+
Object.defineProperty(userBubble!, "offsetTop", { configurable: true, value: 300 });
|
|
783
|
+
raf.flush();
|
|
784
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
785
|
+
|
|
786
|
+
// Tool run: the spacer shrinks, scrollTop is clamped down, then the
|
|
787
|
+
// growing transcript restores it. Both are layout, not the reader.
|
|
788
|
+
for (let i = 0; i < 6; i += 1) {
|
|
789
|
+
controller.injectTestMessage({
|
|
790
|
+
type: "message",
|
|
791
|
+
message: {
|
|
792
|
+
id: `t${i}`,
|
|
793
|
+
role: "assistant",
|
|
794
|
+
content: "",
|
|
795
|
+
createdAt: CREATED_AT,
|
|
796
|
+
variant: "tool",
|
|
797
|
+
toolCall: { id: `t${i}`, name: "search", status: "complete" },
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
metrics.setScrollTop(272);
|
|
802
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
803
|
+
metrics.setScrollHeight(1500);
|
|
804
|
+
metrics.setScrollTop(284);
|
|
805
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
806
|
+
raf.flush();
|
|
807
|
+
|
|
808
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
809
|
+
const answer = sc.querySelector<HTMLElement>('[data-message-id="a1"]');
|
|
810
|
+
Object.defineProperty(answer!, "offsetTop", { configurable: true, value: 700 });
|
|
811
|
+
raf.flush();
|
|
812
|
+
|
|
813
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
814
|
+
controller.destroy();
|
|
815
|
+
});
|
|
816
|
+
});
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -991,7 +991,7 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
991
991
|
controller.destroy();
|
|
992
992
|
});
|
|
993
993
|
|
|
994
|
-
it("anchor-top mode pins the
|
|
994
|
+
it("anchor-top mode pins the turn's first unread block near the viewport top and never follows the stream", () => {
|
|
995
995
|
const raf = installRafMock();
|
|
996
996
|
const resize = installResizeObserverMock();
|
|
997
997
|
const mount = createMount();
|
|
@@ -1034,16 +1034,29 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
1034
1034
|
raf.flush();
|
|
1035
1035
|
expect(metrics.getScrollTop()).toBe(684);
|
|
1036
1036
|
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1037
|
+
// The response's first block is the turn's first unread output, so the
|
|
1038
|
+
// anchor hands off to it: target = 900 - 16 = 884, and with content at
|
|
1039
|
+
// 1284 - 84 = 1200 the spacer re-sizes to 884 + 400 - 1200 = 84.
|
|
1040
|
+
metrics.setScrollHeight(1284);
|
|
1039
1041
|
emitStreamingMessage(controller, "Streaming response");
|
|
1042
|
+
const streamBubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1043
|
+
`[data-message-id="${STREAM_MESSAGE_ID}"]`
|
|
1044
|
+
);
|
|
1045
|
+
expect(streamBubble).not.toBeNull();
|
|
1046
|
+
Object.defineProperty(streamBubble!, "offsetTop", { value: 900 });
|
|
1040
1047
|
raf.flush();
|
|
1041
|
-
expect(metrics.getScrollTop()).toBe(
|
|
1048
|
+
expect(metrics.getScrollTop()).toBe(884);
|
|
1049
|
+
|
|
1050
|
+
// Continuing to stream below that anchor never moves the viewport again.
|
|
1051
|
+
metrics.setScrollHeight(1324);
|
|
1052
|
+
emitStreamingMessage(controller, "Streaming response, now longer");
|
|
1053
|
+
raf.flush();
|
|
1054
|
+
expect(metrics.getScrollTop()).toBe(884);
|
|
1042
1055
|
|
|
1043
1056
|
// As real content grows, the spacer gives room back (shrink-only):
|
|
1044
|
-
// content grew from
|
|
1057
|
+
// content grew from 1200 to 1324 - 84 = 1240, so spacer 84 - 40 = 44.
|
|
1045
1058
|
resize.trigger();
|
|
1046
|
-
expect(spacer?.style.height).toBe("
|
|
1059
|
+
expect(spacer?.style.height).toBe("44px");
|
|
1047
1060
|
|
|
1048
1061
|
// Jumping to the latest abandons the anchor: the spacer is dropped so
|
|
1049
1062
|
// "bottom" is the real end of content.
|