@jskit-ai/assistant-core 0.1.180 → 0.1.182

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.182",
12
+ "@jskit-ai/http-runtime": "0.1.202",
13
+ "@jskit-ai/kernel": "0.1.204",
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.21",
18
+ "@jskit-ai/connectors-catalog": "0.1.21"
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>
@@ -4,6 +4,8 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
4
4
  import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
5
5
  import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
6
6
  const phase = ref("idle");
7
+ const attribution = new URLSearchParams(location.search).has("attribution");
8
+ const assistantLabel = ref("Current assistant");
7
9
  const draft = ref("");
8
10
  const narrow = ref(false);
9
11
  const submissions = ref(0);
@@ -51,7 +53,17 @@ const goalState = reactive({
51
53
  resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
52
54
  });
53
55
  const adapter = reactive({
54
- conversation: { turns: [], visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture" },
56
+ conversation: {
57
+ assistantLabel,
58
+ turns: attribution ? [
59
+ { turnId: "one", assistantLabel: "First assistant", assistantDetails: "First model",
60
+ assistant: { role: "assistant", text: "First answer" } },
61
+ { turnId: "two", assistantLabel: "Second assistant", assistantDetails: "Second model",
62
+ assistant: { role: "assistant", text: "Second answer", assistantLabel: "Message assistant", assistantDetails: "Message model" } },
63
+ { turnId: "three", assistant: { role: "assistant", text: "Unattributed answer" } }
64
+ ] : [],
65
+ visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
66
+ },
55
67
  composer: {
56
68
  draft,
57
69
  rows: 2,
@@ -76,6 +88,7 @@ const adapter = reactive({
76
88
  <v-app>
77
89
  <v-main>
78
90
  <div class="controls">
91
+ <button v-if="attribution" @click="assistantLabel = 'Next assistant'">Change assistant</button>
79
92
  <button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
80
93
  <button @click="narrow = !narrow">Resize pane</button>
81
94
  <button @click="feedback = !feedback">Toggle action feedback</button>
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.182",
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.146",
38
+ "@jskit-ai/resource-crud-core": "0.1.146",
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.202",
48
+ "@jskit-ai/kernel": "0.1.204",
49
49
  "vue": "^3.5.13",
50
50
  "vuetify": "^4.0.0"
51
51
  },
@@ -197,7 +197,7 @@
197
197
  :messages="entry.messages"
198
198
  :pending="isWorking && entry === displayEntries.at(-1)"
199
199
  :preview-limit="progressPreviewLimit"
200
- :aria-label="`${assistantLabel} progress`"
200
+ :aria-label="`${entry.turn.assistantLabel || assistantLabel} progress`"
201
201
  />
202
202
  <div
203
203
  v-else
@@ -209,7 +209,9 @@
209
209
  <v-icon :icon="mdiRobotOutline" size="16" />
210
210
  </span>
211
211
  <div class="assistant-transcript__message-header">
212
- <span>{{ assistantLabel }}</span>
212
+ <span :title="entry.message.assistantDetails || entry.turn.assistantDetails || undefined">
213
+ {{ entry.message.assistantLabel || entry.turn.assistantLabel || assistantLabel }}
214
+ </span>
213
215
  </div>
214
216
  </div>
215
217
  <div class="assistant-transcript__message assistant-transcript__message--assistant">
@@ -774,6 +776,7 @@ function updateLatestFollowFromScroll(event = {}) {
774
776
  // Keep following the reader while the request is pending, before rows prepend.
775
777
  if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
776
778
  loadMoreScrollSnapshot.scrollTop = scrollTop;
779
+ loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
777
780
  }
778
781
  const shouldFollow = scrollElementNearBottom(target);
779
782
  if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
@@ -797,6 +800,17 @@ function updateLatestFollowFromScroll(event = {}) {
797
800
  }
798
801
  }
799
802
 
803
+ function visibleHistoryAnchor(element) {
804
+ const top = element.getBoundingClientRect().top;
805
+ for (const turn of element.querySelectorAll(".assistant-transcript__turn")) {
806
+ const bounds = turn.getBoundingClientRect();
807
+ if (bounds.bottom > top) {
808
+ return { element: turn, offset: bounds.top - top };
809
+ }
810
+ }
811
+ return null;
812
+ }
813
+
800
814
  function requestLoadMore() {
801
815
  if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
802
816
  return;
@@ -809,6 +823,7 @@ function requestLoadMore() {
809
823
  loadMoreRequestVersion = version;
810
824
  loadMoreScrollSnapshot = element
811
825
  ? {
826
+ anchor: visibleHistoryAnchor(element),
812
827
  scrollHeight: element.scrollHeight,
813
828
  scrollKey: props.scrollKey,
814
829
  scrollTop: element.scrollTop,
@@ -842,7 +857,12 @@ async function completeLoadMoreRequest(version, changed) {
842
857
  }
843
858
  const element = bodyElement.value;
844
859
  if (element) {
845
- previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
860
+ // Offscreen turns use estimated heights. Preserve the visible turn instead
861
+ // of moving by the total height difference, which can change during layout.
862
+ const anchor = snapshot.anchor;
863
+ previousScrollTop = anchor && element.contains(anchor.element)
864
+ ? element.scrollTop + anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset
865
+ : snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
846
866
  element.scrollTop = previousScrollTop;
847
867
  }
848
868
  clearLoadMoreScrollSnapshot();
@@ -22,6 +22,26 @@ const VIEWPORTS = Object.freeze([
22
22
  Object.freeze({ name: "drawer", width: 360, height: 600 })
23
23
  ]);
24
24
 
25
+ test("saved reply attribution remains when the current assistant changes", {
26
+ skip: !RUN_BROWSER_TEST, timeout: 60_000
27
+ }, async () => {
28
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
29
+ const browser = await chromium.launch(createChromiumLaunchOptions());
30
+ try {
31
+ const page = await browser.newPage();
32
+ await page.goto(`${vite.baseURL}/?conversation=1&attribution=1`);
33
+ const names = page.locator('[data-message-role="assistant"] .assistant-transcript__message-header span');
34
+ await expect(names).toHaveText(["First assistant", "Message assistant", "Current assistant"]);
35
+ await expect(names.nth(0)).toHaveAttribute("title", "First model");
36
+ await expect(names.nth(1)).toHaveAttribute("title", "Message model");
37
+ await page.getByRole("button", { name: "Change assistant", exact: true }).click();
38
+ await expect(names).toHaveText(["First assistant", "Message assistant", "Next assistant"]);
39
+ } finally {
40
+ await browser.close();
41
+ await stopProcess(vite);
42
+ }
43
+ });
44
+
25
45
  test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
26
46
  skip: !RUN_BROWSER_TEST, timeout: 90_000
27
47
  }, async () => {
@@ -219,6 +239,41 @@ test("conversation scrolling preserves history anchors and resets expansion on s
219
239
  }
220
240
  });
221
241
 
242
+ test("a delayed final history page preserves the visible message at every width", {
243
+ skip: !RUN_BROWSER_TEST,
244
+ timeout: 60_000
245
+ }, async () => {
246
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
247
+ const browser = await chromium.launch(createChromiumLaunchOptions());
248
+ try {
249
+ for (const width of [390, 800, 1365]) {
250
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
251
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&paged-history=1`);
252
+ const body = page.locator(".assistant-transcript__body");
253
+ await expect(body).toBeVisible();
254
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
255
+ await body.evaluate(element => {
256
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
257
+ element.scrollTop = 0;
258
+ element.dispatchEvent(new Event("scroll", { bubbles: true }));
259
+ });
260
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toBeDisabled();
261
+ const anchor = page.getByText("user message 21.", { exact: false });
262
+ const before = (await anchor.boundingBox()).y;
263
+ await page.getByRole("button", { name: "Complete history load", exact: true }).evaluate(element => element.click());
264
+ await expect(page.getByText("user message 1.", { exact: false })).toHaveCount(1);
265
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toHaveCount(0);
266
+ await expect.poll(async () => Math.abs((await anchor.boundingBox()).y - before)).toBeLessThan(2);
267
+ await expect(page.locator("output")).toHaveText("History requests: 1");
268
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
269
+ await page.close();
270
+ }
271
+ } finally {
272
+ await browser.close();
273
+ await stopProcess(vite);
274
+ }
275
+ });
276
+
222
277
  test("upward scrolling loads older history once and preserves retry and selection behavior", {
223
278
  skip: !RUN_BROWSER_TEST,
224
279
  timeout: 90_000
@@ -249,14 +304,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
249
304
  const cdp = await page.context().newCDPSession(page);
250
305
  const x = bounds.x + bounds.width / 2;
251
306
  const y = bounds.y + 40;
307
+ const stopped = body.evaluate(element => new Promise(resolve => {
308
+ element.addEventListener("scrollend", () => resolve(), { once: true });
309
+ }));
252
310
  await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
253
311
  for (let offset = 20; offset <= 380; offset += 20) {
254
312
  await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
255
313
  await page.evaluate(() => new Promise(requestAnimationFrame));
256
314
  }
257
- const stopped = body.evaluate(element => new Promise(resolve => {
258
- element.addEventListener("scrollend", () => resolve(), { once: true });
259
- }));
260
315
  await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
261
316
  await stopped;
262
317
  await cdp.detach();