@jskit-ai/assistant-core 0.2.3 → 0.2.4

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.3",
11
+ "@jskit-ai/assistant-core": "0.2.4",
12
12
  "@jskit-ai/http-runtime": "0.2.1",
13
13
  "@jskit-ai/kernel": "0.2.1",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -447,6 +447,7 @@ let retainedReaderScrollSnapshot = null;
447
447
  let loadMoreRequestVersion = 0;
448
448
  let pendingTailFollow = false;
449
449
  let previousScrollTop = 0;
450
+ let previousScrollHeight = 0;
450
451
  let userScrollIntentUpward = false;
451
452
  let lastTouchY = null;
452
453
  const USER_SCROLL_INTENT_RESET_MS = 600;
@@ -881,11 +882,17 @@ function updateLatestFollowFromScroll(event = {}) {
881
882
  const target = event?.currentTarget || bodyElement.value;
882
883
  if (retainedReaderScrollSnapshot?.restoring) {
883
884
  previousScrollTop = target?.scrollTop || 0;
885
+ previousScrollHeight = target?.scrollHeight || 0;
884
886
  return;
885
887
  }
886
888
  const scrollTop = target?.scrollTop || 0;
889
+ const scrollHeight = target?.scrollHeight || 0;
887
890
  const scrolledUp = scrollTop < previousScrollTop;
891
+ // Layout changes such as scroll anchoring move scrollTop together with the
892
+ // content height; moving up over unchanged content is the reader's own.
893
+ const readerMovedUp = scrolledUp && scrollHeight === previousScrollHeight;
888
894
  previousScrollTop = scrollTop;
895
+ previousScrollHeight = scrollHeight;
889
896
  // Keep following the reader while the request is pending, before rows prepend.
890
897
  if (loadMoreScrollSnapshot && !loadMoreScrollSnapshot.completed) {
891
898
  loadMoreScrollSnapshot.scrollTop = scrollTop;
@@ -897,7 +904,7 @@ function updateLatestFollowFromScroll(event = {}) {
897
904
  const userScrollsUp = userScrollIntent.value && userScrollIntentUpward;
898
905
  const shouldFollow = scrollElementNearBottom(target) &&
899
906
  !(userScrollsUp && scrollDistanceFromBottom(target) > 1);
900
- if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
907
+ if (!shouldFollow && !userScrollIntent.value && !readerMovedUp && followingLatest.value) {
901
908
  return;
902
909
  }
903
910
  followingLatest.value = shouldFollow;
@@ -51,6 +51,30 @@ async function scrollGesture(body, lastEventType, perform) {
51
51
  await body.evaluate(element => element.gestureScrollEnded);
52
52
  }
53
53
 
54
+ // Chromium animates a keyboard scroll towards a target fixed at the key press
55
+ // while content-visibility rows keep changing the transcript's height, so the
56
+ // animation can stop short of the end or not move at all. Keep the real key
57
+ // event for the transcript's own handling, replace only the browser's scroll
58
+ // with a move to `target` ("end", "top" or a pixel offset), and wait until the
59
+ // transcript has handled that scroll.
60
+ async function pressScrollKey(body, key, target) {
61
+ await body.evaluate((element, [scrollKey, scrollTarget]) => {
62
+ element.keyScrolled = new Promise(resolve => {
63
+ element.addEventListener("keydown", function move(event) {
64
+ if (event.key !== scrollKey) return;
65
+ element.removeEventListener("keydown", move);
66
+ event.preventDefault();
67
+ element.addEventListener("scroll", () => resolve(), { once: true });
68
+ element.scrollTop = scrollTarget === "end"
69
+ ? element.scrollHeight
70
+ : scrollTarget === "top" ? 0 : element.scrollTop + scrollTarget;
71
+ });
72
+ });
73
+ }, [key, target]);
74
+ await body.press(key);
75
+ await body.evaluate(element => element.keyScrolled);
76
+ }
77
+
54
78
  const VIEWPORTS = Object.freeze([
55
79
  Object.freeze({ name: "phone", width: 390, height: 844 }),
56
80
  Object.freeze({ name: "compact", width: 800, height: 900 }),
@@ -428,13 +452,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
428
452
  await expect(requests).toHaveText("History requests: 1");
429
453
 
430
454
  // Keyboard navigation also requests older history.
431
- await body.focus();
432
- await body.press("Home");
455
+ await pressScrollKey(body, "Home", "top");
433
456
  await expect(requests).toHaveText("History requests: 2");
434
457
  await external("Fail history load");
435
458
  await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
436
- await body.press("ArrowDown");
437
- await body.press("Home");
459
+ await pressScrollKey(body, "ArrowDown", 40);
460
+ await pressScrollKey(body, "Home", "top");
438
461
  await expect(requests).toHaveText("History requests: 2");
439
462
  await page.getByRole("button", { name: "Load older messages", exact: true }).click();
440
463
  await expect(requests).toHaveText("History requests: 3");
@@ -445,16 +468,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
445
468
  await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
446
469
  await expect(requests).toHaveText("History requests: 3");
447
470
  await external("Exhaust history");
448
- // Removing the history control re-anchors the scroller, which would
449
- // cancel a keyboard scroll already in flight; press Home once it settles.
471
+ // Removing the history control re-anchors the scroller; press Home once
472
+ // it settles.
450
473
  await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
451
474
  await settleLayout(body);
452
- await expect.poll(async () => {
453
- await body.focus();
454
- return body.evaluate(element => document.activeElement === element);
455
- }).toBe(true);
456
- await body.press("Home");
457
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
475
+ await pressScrollKey(body, "Home", "top");
476
+ expect(await body.evaluate(element => element.scrollTop)).toBe(0);
458
477
  await expect(requests).toHaveText("History requests: 3");
459
478
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
460
479
  assert.deepEqual(errors, []);
@@ -519,24 +538,21 @@ test("a reader's own upward scrolling leaves the latest message from its first f
519
538
  expect(await fromBottom()).toBeGreaterThan(1);
520
539
 
521
540
  // Returning to the end resumes following new replies.
522
- await body.press("End");
523
- await expect.poll(fromBottom).toBeLessThanOrEqual(1);
541
+ await pressScrollKey(body, "End", "end");
524
542
  await appendReply();
525
543
  expect(await fromBottom()).toBeLessThanOrEqual(1);
526
544
 
527
- // The first frame of an upward scroll lands inside the follow threshold;
528
- // the rest of it (a fling or smooth scroll) moves the reader into history
529
- // without further input events.
530
- await body.press("ArrowUp");
531
- await expect.poll(fromBottom).toBeGreaterThan(1);
532
- await settleLayout(body);
533
- expect(await fromBottom()).toBeLessThanOrEqual(48);
545
+ // The first frame of an upward keyboard scroll lands inside the follow
546
+ // threshold; the rest of it (a fling or smooth scroll) moves the reader
547
+ // into history without further input events.
548
+ await pressScrollKey(body, "ArrowUp", -10);
549
+ const firstFrame = await fromBottom();
550
+ assert.ok(firstFrame > 1 && firstFrame <= 48, `first frame ${firstFrame}px from the end`);
534
551
  await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
535
552
  await settleLayout(body);
536
553
  await appendReply();
537
554
  expect(await fromBottom()).toBeGreaterThan(1_000);
538
- await body.press("End");
539
- await expect.poll(fromBottom).toBeLessThanOrEqual(1);
555
+ await pressScrollKey(body, "End", "end");
540
556
  await appendReply();
541
557
  expect(await fromBottom()).toBeLessThanOrEqual(1);
542
558
 
@@ -546,8 +562,7 @@ test("a reader's own upward scrolling leaves the latest message from its first f
546
562
  await body.evaluate(element => {
547
563
  element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
548
564
  });
549
- await body.press("Home");
550
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
565
+ await pressScrollKey(body, "Home", "top");
551
566
  await appendReply();
552
567
  expect(await body.evaluate(element => element.scrollTop)).toBe(0);
553
568
  assert.deepEqual(errors, []);
@@ -559,6 +574,57 @@ test("a reader's own upward scrolling leaves the latest message from its first f
559
574
  }
560
575
  });
561
576
 
577
+ test("a reader moved away from the end without a scroll direction stays there", {
578
+ skip: !RUN_BROWSER_TEST,
579
+ timeout: 60_000
580
+ }, async () => {
581
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
582
+ const browser = await chromium.launch(createChromiumLaunchOptions());
583
+ try {
584
+ const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
585
+ const errors = [];
586
+ page.on("pageerror", error => errors.push(error.message));
587
+ await page.goto(`${vite.baseURL}/?controls=1&history=1`);
588
+ const body = page.locator(".assistant-transcript__body");
589
+ const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
590
+ // Move the reader and wait until the transcript has handled the scroll.
591
+ const moveReader = target => body.evaluate((element, by) => new Promise(resolve => {
592
+ element.addEventListener("scroll", () => resolve(), { once: true });
593
+ element.scrollTop = by === "end" ? element.scrollHeight : element.scrollTop + by;
594
+ }), target);
595
+ const appendReply = async () => {
596
+ const replies = page.getByText("New reply.", { exact: false });
597
+ const count = await replies.count();
598
+ await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
599
+ await expect(replies).toHaveCount(count + 1);
600
+ await settleLayout(body);
601
+ };
602
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
603
+ await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
604
+ await settleLayout(body);
605
+ expect(await fromBottom()).toBeLessThanOrEqual(1);
606
+
607
+ // A scrollbar drag starts on the scroller itself and carries no scroll
608
+ // direction; its frames, the first inside the follow threshold, then move
609
+ // the reader without further input. Headless Chromium hides scrollbars, so
610
+ // the drag's pointerdown is dispatched directly.
611
+ await body.evaluate(element => element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
612
+ await moveReader(-10);
613
+ await moveReader(-300);
614
+ await appendReply();
615
+ expect(await fromBottom()).toBeGreaterThan(300);
616
+
617
+ // Returning to the end resumes following new replies.
618
+ await moveReader("end");
619
+ await appendReply();
620
+ expect(await fromBottom()).toBeLessThanOrEqual(1);
621
+ assert.deepEqual(errors, []);
622
+ } finally {
623
+ await browser.close();
624
+ await stopProcess(vite);
625
+ }
626
+ });
627
+
562
628
  test("retained hidden transcripts preserve readers and follow only at the latest message", {
563
629
  skip: !RUN_BROWSER_TEST,
564
630
  timeout: 90_000