@jskit-ai/assistant-core 0.1.180 → 0.1.181

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,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.180",
12
- "@jskit-ai/http-runtime": "0.1.200",
13
- "@jskit-ai/kernel": "0.1.202",
11
+ "@jskit-ai/assistant-core": "0.1.181",
12
+ "@jskit-ai/http-runtime": "0.1.201",
13
+ "@jskit-ai/kernel": "0.1.203",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.19",
18
- "@jskit-ai/connectors-catalog": "0.1.19"
17
+ "@jskit-ai/connectors-core": "0.1.20",
18
+ "@jskit-ai/connectors-catalog": "0.1.20"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -7,6 +7,7 @@ const draft = ref("");
7
7
  const query = new URLSearchParams(location.search);
8
8
  const controls = query.has("controls");
9
9
  const deferredHistory = query.has("history");
10
+ const pagedHistory = query.has("paged-history");
10
11
  const streaming = query.has("streaming");
11
12
  const liveText = ref("");
12
13
  const liveMessage = computed(() => ({ messageId: "stream-answer", role: "assistant", text: liveText.value, status: "inProgress" }));
@@ -16,11 +17,18 @@ const loadMoreError = ref("");
16
17
  const historyRequests = ref(0);
17
18
  const hasMoreBefore = ref(controls);
18
19
  let completeHistory = null;
20
+ function historyPage(first) {
21
+ return Array.from({ length: 20 }, (_, index) => ["user", "assistant"].map(role => ({
22
+ id: `${role}-${first + index}`, role, status: "completed",
23
+ text: `${role} message ${first + index}. A deliberately detailed update keeps the conversation tall enough to exercise the real overflow container.`
24
+ }))).flat();
25
+ }
19
26
  const messages = ref(Array.from({ length: 70 }, (_, index) => ({
20
27
  id: `message-${index}`, role: index % 2 ? "assistant" : "user", status: "completed",
21
28
  text: `Conversation line ${index + 1}: responsive scroll containment keeps the composer visible.\n\nMore text with **formatting**, a [link](https://example.com), and a_long_word_that_must_wrap_in_narrow_panes_without_overflow.`
22
29
  })));
23
30
  if (controls) messages.value[0].text = messages.value[0].text.repeat(4);
31
+ if (pagedHistory) messages.value = historyPage(21);
24
32
  if (streaming) messages.value.push({ id: "stream-question", role: "user", text: "Show the answer as it arrives." });
25
33
  function finishStream() {
26
34
  messages.value.push({ ...liveMessage.value, status: "completed" });
@@ -29,7 +37,12 @@ function finishStream() {
29
37
  function finishHistory(error = "") {
30
38
  loadMoreError.value = error;
31
39
  if (!error) {
32
- messages.value.unshift({ id: `older-${historyRequests.value}`, role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
40
+ if (pagedHistory) {
41
+ messages.value.unshift(...historyPage(1));
42
+ hasMoreBefore.value = false;
43
+ } else {
44
+ messages.value.unshift({ id: `older-${historyRequests.value}`, role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
45
+ }
33
46
  }
34
47
  loadingMore.value = false;
35
48
  completeHistory({ changed: !error });
@@ -57,7 +70,7 @@ const adapter = computed(() => ({
57
70
  <template>
58
71
  <v-app>
59
72
  <v-main>
60
- <main class="assistant-responsive-fixture">
73
+ <main class="assistant-responsive-fixture" :class="{ 'assistant-responsive-fixture--paged': pagedHistory }">
61
74
  <nav v-if="streaming">
62
75
  <button @click="liveText += 'Growing answer. '">Receive text</button>
63
76
  <button @click="finishStream">Finish answer</button>
@@ -80,6 +93,7 @@ const adapter = computed(() => ({
80
93
  <style>
81
94
  html, body, #app { height: 100%; margin: 0; min-height: 0; }
82
95
  .assistant-responsive-fixture { display: flex; flex-direction: column; height: 100dvh; min-height: 0; padding: .5rem; }
96
+ .assistant-responsive-fixture--paged { width: min(420px, 100%); margin: auto; }
83
97
  .assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 1rem; }
84
98
  .assistant-responsive-fixture__conversation { flex: 1 1 auto; }
85
99
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.180",
3
+ "version": "0.1.181",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -34,8 +34,8 @@
34
34
  "@ai-sdk/perplexity": "^4.0.43",
35
35
  "@ai-sdk/togetherai": "^3.0.49",
36
36
  "@ai-sdk/xai": "^4.0.58",
37
- "@jskit-ai/resource-core": "0.1.144",
38
- "@jskit-ai/resource-crud-core": "0.1.144",
37
+ "@jskit-ai/resource-core": "0.1.145",
38
+ "@jskit-ai/resource-crud-core": "0.1.145",
39
39
  "@mdi/js": "^7.4.47",
40
40
  "@openrouter/ai-sdk-provider": "^3.0.0",
41
41
  "ai": "^7.0.101",
@@ -44,8 +44,8 @@
44
44
  "ws": "^8.20.1"
45
45
  },
46
46
  "peerDependencies": {
47
- "@jskit-ai/http-runtime": "0.1.200",
48
- "@jskit-ai/kernel": "0.1.202",
47
+ "@jskit-ai/http-runtime": "0.1.201",
48
+ "@jskit-ai/kernel": "0.1.203",
49
49
  "vue": "^3.5.13",
50
50
  "vuetify": "^4.0.0"
51
51
  },
@@ -774,6 +774,7 @@ function updateLatestFollowFromScroll(event = {}) {
774
774
  // Keep following the reader while the request is pending, before rows prepend.
775
775
  if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
776
776
  loadMoreScrollSnapshot.scrollTop = scrollTop;
777
+ loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
777
778
  }
778
779
  const shouldFollow = scrollElementNearBottom(target);
779
780
  if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
@@ -797,6 +798,17 @@ function updateLatestFollowFromScroll(event = {}) {
797
798
  }
798
799
  }
799
800
 
801
+ function visibleHistoryAnchor(element) {
802
+ const top = element.getBoundingClientRect().top;
803
+ for (const turn of element.querySelectorAll(".assistant-transcript__turn")) {
804
+ const bounds = turn.getBoundingClientRect();
805
+ if (bounds.bottom > top) {
806
+ return { element: turn, offset: bounds.top - top };
807
+ }
808
+ }
809
+ return null;
810
+ }
811
+
800
812
  function requestLoadMore() {
801
813
  if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
802
814
  return;
@@ -809,6 +821,7 @@ function requestLoadMore() {
809
821
  loadMoreRequestVersion = version;
810
822
  loadMoreScrollSnapshot = element
811
823
  ? {
824
+ anchor: visibleHistoryAnchor(element),
812
825
  scrollHeight: element.scrollHeight,
813
826
  scrollKey: props.scrollKey,
814
827
  scrollTop: element.scrollTop,
@@ -842,7 +855,12 @@ async function completeLoadMoreRequest(version, changed) {
842
855
  }
843
856
  const element = bodyElement.value;
844
857
  if (element) {
845
- previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
858
+ // Offscreen turns use estimated heights. Preserve the visible turn instead
859
+ // of moving by the total height difference, which can change during layout.
860
+ const anchor = snapshot.anchor;
861
+ previousScrollTop = anchor && element.contains(anchor.element)
862
+ ? element.scrollTop + anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset
863
+ : snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
846
864
  element.scrollTop = previousScrollTop;
847
865
  }
848
866
  clearLoadMoreScrollSnapshot();
@@ -219,6 +219,41 @@ test("conversation scrolling preserves history anchors and resets expansion on s
219
219
  }
220
220
  });
221
221
 
222
+ test("a delayed final history page preserves the visible message at every width", {
223
+ skip: !RUN_BROWSER_TEST,
224
+ timeout: 60_000
225
+ }, async () => {
226
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
227
+ const browser = await chromium.launch(createChromiumLaunchOptions());
228
+ try {
229
+ for (const width of [390, 800, 1365]) {
230
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
231
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&paged-history=1`);
232
+ const body = page.locator(".assistant-transcript__body");
233
+ await expect(body).toBeVisible();
234
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
235
+ await body.evaluate(element => {
236
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
237
+ element.scrollTop = 0;
238
+ element.dispatchEvent(new Event("scroll", { bubbles: true }));
239
+ });
240
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toBeDisabled();
241
+ const anchor = page.getByText("user message 21.", { exact: false });
242
+ const before = (await anchor.boundingBox()).y;
243
+ await page.getByRole("button", { name: "Complete history load", exact: true }).evaluate(element => element.click());
244
+ await expect(page.getByText("user message 1.", { exact: false })).toHaveCount(1);
245
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toHaveCount(0);
246
+ await expect.poll(async () => Math.abs((await anchor.boundingBox()).y - before)).toBeLessThan(2);
247
+ await expect(page.locator("output")).toHaveText("History requests: 1");
248
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
249
+ await page.close();
250
+ }
251
+ } finally {
252
+ await browser.close();
253
+ await stopProcess(vite);
254
+ }
255
+ });
256
+
222
257
  test("upward scrolling loads older history once and preserves retry and selection behavior", {
223
258
  skip: !RUN_BROWSER_TEST,
224
259
  timeout: 90_000
@@ -249,14 +284,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
249
284
  const cdp = await page.context().newCDPSession(page);
250
285
  const x = bounds.x + bounds.width / 2;
251
286
  const y = bounds.y + 40;
287
+ const stopped = body.evaluate(element => new Promise(resolve => {
288
+ element.addEventListener("scrollend", () => resolve(), { once: true });
289
+ }));
252
290
  await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
253
291
  for (let offset = 20; offset <= 380; offset += 20) {
254
292
  await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
255
293
  await page.evaluate(() => new Promise(requestAnimationFrame));
256
294
  }
257
- const stopped = body.evaluate(element => new Promise(resolve => {
258
- element.addEventListener("scrollend", () => resolve(), { once: true });
259
- }));
260
295
  await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
261
296
  await stopped;
262
297
  await cdp.detach();