@jskit-ai/assistant-core 0.1.216 → 0.1.218

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.
@@ -75,3 +75,13 @@ database or files; the UI has no storage assumptions.
75
75
 
76
76
  See the JSKIT guide **Embeddable assistant conversations** for the complete
77
77
  client, storage and provider contracts and reusable storage-adapter checks.
78
+
79
+ An adapter that sets `conversation.cancellableDelivery` and supplies
80
+ `actions.cancel(messageId)` also exposes **Cancel** next to **Retry** for uncertain
81
+ delivery. The application owns that cancellation and preserves accepted history.
82
+ Standalone delivery continues to offer cancellation only for definite failures.
83
+ Direct `AssistantTranscript` consumers opt in with `cancellableDelivery`.
84
+
85
+ The shared transcript interleaves timestamped messages across storage and native
86
+ streaming turns. Equal timestamps keep source order; undated notices keep their
87
+ slots. Reasoning groups form after ordering, preserving original turn identities.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.216",
12
- "@jskit-ai/http-runtime": "0.1.236",
13
- "@jskit-ai/kernel": "0.1.238",
11
+ "@jskit-ai/assistant-core": "0.1.218",
12
+ "@jskit-ai/http-runtime": "0.1.238",
13
+ "@jskit-ai/kernel": "0.1.240",
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.55",
18
- "@jskit-ai/connectors-catalog": "0.1.55"
17
+ "@jskit-ai/connectors-core": "0.1.57",
18
+ "@jskit-ai/connectors-catalog": "0.1.57"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -3,6 +3,7 @@ import { reactive, ref } from "vue";
3
3
  import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
4
  import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
5
5
 
6
+ const recoveryFixture = new URLSearchParams(location.search).has("recovery");
6
7
  const draft = ref("");
7
8
  const turns = ref([]);
8
9
  const requests = ref([]);
@@ -29,11 +30,24 @@ function accept() {
29
30
  assistant: { text: "Accepted once." } }];
30
31
  pendingRequest.resolve({ ok: true });
31
32
  }
33
+ if (recoveryFixture) {
34
+ const at = minute => `2026-10-13T06:${minute}:00.000Z`;
35
+ turns.value = [
36
+ { turnId: "one", user: { messageId: "one", text: "Question one", at: at("01") } },
37
+ { turnId: "two", user: { messageId: "two", text: "Question two", at: at("03") } },
38
+ { turnId: "native", messages: [
39
+ { messageId: "reply-one", role: "assistant", text: "Reply one", at: at("02") },
40
+ { messageId: "reply-two", role: "assistant", text: "Reply two", at: at("04") }
41
+ ] }
42
+ ];
43
+ delivery.state.messages.push({ id: "uncertain", text: "Discard failed attempt", status: "uncertain", error: "Lost response", createdAt: at("05") });
44
+ }
32
45
  const adapter = reactive({
33
46
  delivery, attachments,
34
- conversation: { turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
47
+ conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
35
48
  composer: { draft, canSend: true, submitOnEnter: true, queueWhileSending },
36
49
  actions: {
50
+ ...(recoveryFixture ? { cancel: id => delivery.remove(id) } : {}),
37
51
  setDraft(value) { draft.value = value; }
38
52
  }
39
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.216",
3
+ "version": "0.1.218",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -47,8 +47,8 @@
47
47
  "@ai-sdk/perplexity": "^4.0.43",
48
48
  "@ai-sdk/togetherai": "^3.0.49",
49
49
  "@ai-sdk/xai": "^4.0.58",
50
- "@jskit-ai/resource-core": "0.1.180",
51
- "@jskit-ai/resource-crud-core": "0.1.180",
50
+ "@jskit-ai/resource-core": "0.1.182",
51
+ "@jskit-ai/resource-crud-core": "0.1.182",
52
52
  "@mdi/js": "^7.4.47",
53
53
  "@openrouter/ai-sdk-provider": "^3.0.0",
54
54
  "ai": "^7.0.101",
@@ -57,8 +57,8 @@
57
57
  "ws": "^8.20.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "@jskit-ai/http-runtime": "0.1.236",
61
- "@jskit-ai/kernel": "0.1.238",
60
+ "@jskit-ai/http-runtime": "0.1.238",
61
+ "@jskit-ai/kernel": "0.1.240",
62
62
  "vue": "^3.5.13",
63
63
  "vuetify": "^4.0.0"
64
64
  },
@@ -1,14 +1,15 @@
1
1
  <template>
2
- <div v-if="message.delivery" role="status">
3
- <p>{{ message.text }}</p>
4
- <p v-if="message.delivery.error" class="text-body-small">{{ message.delivery.error }}</p>
2
+ <div v-if="message.delivery" class="d-flex flex-wrap align-center ga-1 text-caption text-error" role="status">
3
+ <span>{{ message.delivery.error || message.text }}</span>
5
4
  <v-btn
6
- variant="tonal"
7
- min-height="48"
5
+ variant="text"
6
+ color="error"
7
+ size="x-small"
8
+ aria-label="Retry delivery check"
8
9
  :disabled="message.delivery.checking"
9
10
  @click="$emit('check-delivery', message.delivery.messageId)"
10
11
  >
11
- {{ message.delivery.checking ? 'Checking delivery…' : 'Check delivery' }}
12
+ {{ message.delivery.checking ? 'Checking…' : 'Retry' }}
12
13
  </v-btn>
13
14
  </div>
14
15
  <slot v-else>
@@ -139,8 +139,7 @@
139
139
  class="assistant-transcript__message assistant-transcript__message--user"
140
140
  :class="{
141
141
  'assistant-transcript__message--pending': entry.turn.optimistic?.status === 'pending',
142
- 'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed',
143
- 'assistant-transcript__message--unconfirmed': Boolean(entry.delivery)
142
+ 'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed'
144
143
  }"
145
144
  >
146
145
  <div
@@ -176,39 +175,38 @@
176
175
  <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
177
176
  <span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
178
177
  </div>
179
- <div v-if="entry.delivery" class="assistant-transcript__unconfirmed-delivery">
180
- <AssistantConversationStatus
181
- :message="{ ...entry.delivery, text: 'Delivery unconfirmed' }"
182
- @check-delivery="emit('check-delivery', $event)"
183
- />
184
- </div>
185
178
  <slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
186
179
  <div
187
- v-if="entry.turn.optimistic?.status === 'failed'"
180
+ v-if="entry.delivery || entry.turn.optimistic?.status === 'failed'"
188
181
  class="assistant-transcript__optimistic-failure"
189
182
  >
190
- <span role="status">Failed: {{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
183
+ <span role="status">{{ entry.delivery ? entry.delivery.delivery.error || 'Delivery unconfirmed.' : `Failed: ${entry.turn.optimistic.error || 'Message could not be sent.'}` }}</span>
191
184
  <div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
192
185
  <v-btn
193
186
  class="assistant-transcript__delivery-action"
194
- color="primary"
187
+ color="error"
195
188
  size="x-small"
196
189
  type="button"
197
- variant="tonal"
198
- @click="emit('resend-turn', entry.turn.optimistic.id)"
190
+ variant="text"
191
+ :disabled="entry.delivery?.delivery.checking"
192
+ :aria-label="entry.delivery ? 'Retry delivery check' : undefined"
193
+ :title="entry.delivery ? entry.delivery.delivery.error || 'Check delivery before resending.' : undefined"
194
+ @click="entry.delivery ? emit('check-delivery', entry.delivery.delivery.messageId) : emit('resend-turn', entry.turn.optimistic.id)"
199
195
  >
200
- Retry
196
+ {{ entry.delivery?.delivery.checking ? 'Checking…' : 'Retry' }}
201
197
  </v-btn>
202
198
  <v-btn
199
+ v-if="!entry.delivery || cancellableDelivery"
203
200
  class="assistant-transcript__delivery-action"
204
201
  size="x-small"
205
202
  type="button"
206
203
  variant="text"
207
- @click="emit('cancel-turn', entry.turn.optimistic.id)"
204
+ @click="emit('cancel-turn', entry.delivery?.delivery.messageId || entry.turn.optimistic.id)"
208
205
  >
209
206
  Cancel
210
207
  </v-btn>
211
208
  <v-btn
209
+ v-if="!entry.delivery"
212
210
  class="assistant-transcript__delivery-action"
213
211
  size="x-small"
214
212
  type="button"
@@ -340,6 +338,7 @@ function previewForTurn(turn) {
340
338
  }
341
339
 
342
340
  const props = defineProps({
341
+ cancellableDelivery: { type: Boolean, default: false },
343
342
  systemLabel: { type: String, default: "Status" },
344
343
  previewMessage: { type: Object, default: null },
345
344
  working: { type: Boolean, default: undefined },
@@ -588,7 +587,7 @@ function conversationMessageKey(message = {}, index = 0) {
588
587
  // Storage rows are not visual boundaries. Only an intervening message ends a
589
588
  // reasoning group, including when history is prepended or execution continues.
590
589
  const displayEntries = computed(() => {
591
- const entries = [];
590
+ const candidates = [];
592
591
  for (const [index, turn] of props.turns.entries()) {
593
592
  const turnId = String(turn.turnId || index + 1);
594
593
  // A delivery notice belongs to its exact user message, not the previous reply.
@@ -608,18 +607,21 @@ const displayEntries = computed(() => {
608
607
  previewUserMessage: role === "user"
609
608
  }, key);
610
609
  if (!displayed) continue;
611
- const previous = entries.at(-1);
612
- if (role === "thinking" && previous?.role === "thinking") {
613
- previous.messages.push({ ...displayed, key });
614
- } else {
615
- entries.push({
616
- key, role, turn: { ...turn, turnId }, message: displayed,
617
- delivery: role === "user" ? userDelivery : null,
618
- ...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
619
- });
620
- }
610
+ candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
611
+ delivery: role === "user" ? userDelivery : null });
621
612
  }
622
613
  }
614
+ // Native streaming turns and saved authored turns can arrive independently.
615
+ // Only dated entries participate; undated notices retain their original slot.
616
+ const dated = candidates.filter(entry => Number.isFinite(Date.parse(entry.message.at)))
617
+ .sort((a, b) => Date.parse(a.message.at) - Date.parse(b.message.at))[Symbol.iterator]();
618
+ const entries = [];
619
+ for (const candidate of candidates) {
620
+ const entry = Number.isFinite(Date.parse(candidate.message.at)) ? dated.next().value : candidate;
621
+ const previous = entries.at(-1);
622
+ if (entry.role === "thinking" && previous?.role === "thinking") previous.messages.push({ ...entry.message, key: entry.key });
623
+ else entries.push({ ...entry, ...(entry.role === "thinking" ? { messages: [{ ...entry.message, key: entry.key }] } : {}) });
624
+ }
623
625
  // Anchor a progress group to the following message, or to the transcript end.
624
626
  // Appending progress or prepending history then preserves its expansion state.
625
627
  for (const [index, entry] of entries.entries()) {
@@ -1442,26 +1444,6 @@ watch(() => props.visible, (visible) => {
1442
1444
  margin-top: -0.15rem;
1443
1445
  }
1444
1446
 
1445
- .assistant-transcript__message--user.assistant-transcript__message--unconfirmed {
1446
- background: rgba(var(--v-theme-warning), 0.08);
1447
- border: 1px solid rgba(var(--v-theme-warning), 0.6);
1448
- }
1449
-
1450
- .assistant-transcript__unconfirmed-delivery {
1451
- border-top: 1px solid rgba(var(--v-theme-warning), 0.4);
1452
- font-size: 0.875rem;
1453
- padding-top: 0.65rem;
1454
- }
1455
-
1456
- .assistant-transcript__unconfirmed-delivery :deep(p:first-child) {
1457
- font-weight: 700;
1458
- margin-bottom: 0.4rem;
1459
- }
1460
-
1461
- .assistant-transcript__unconfirmed-delivery :deep(p) {
1462
- overflow-wrap: anywhere;
1463
- }
1464
-
1465
1447
  .assistant-transcript__optimistic-failure {
1466
1448
  align-items: center;
1467
1449
  color: rgba(var(--v-theme-error), 0.92);
@@ -90,3 +90,32 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
90
90
  await stopProcess(vite);
91
91
  }
92
92
  });
93
+
94
+
95
+ test("chronological messages and application Cancel work on phone and desktop", {
96
+ skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
97
+ }, async () => {
98
+ const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
99
+ const browser = await chromium.launch(createChromiumLaunchOptions());
100
+ try {
101
+ for (const width of [390, 1365]) {
102
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
103
+ const errors = [];
104
+ page.on("pageerror", error => errors.push(error.message));
105
+ await page.goto(`${vite.baseURL}/?delivery=1&recovery=1`);
106
+ const rows = page.locator(".assistant-transcript__turn");
107
+ await expect(rows).toHaveCount(5);
108
+ for (const [index, text] of ["Question one", "Reply one", "Question two", "Reply two", "Discard failed attempt"].entries()) {
109
+ await expect(rows.nth(index)).toContainText(text);
110
+ }
111
+ await expect(page.getByRole("button", { name: "Retry delivery check" })).toBeVisible();
112
+ await page.screenshot({ path: `/tmp/sas-chat-recovery-${width}.png` });
113
+ await page.getByRole("button", { name: "Cancel", exact: true }).click();
114
+ await expect(rows).toHaveCount(4);
115
+ await expect(page.getByText("Discard failed attempt", { exact: true })).toHaveCount(0);
116
+ assert.deepEqual(errors, []);
117
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
118
+ await page.close();
119
+ }
120
+ } finally { await browser.close(); await stopProcess(vite); }
121
+ });