@jskit-ai/assistant-core 0.1.172 → 0.1.173

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.172",
12
- "@jskit-ai/http-runtime": "0.1.192",
13
- "@jskit-ai/kernel": "0.1.194",
11
+ "@jskit-ai/assistant-core": "0.1.173",
12
+ "@jskit-ai/http-runtime": "0.1.193",
13
+ "@jskit-ai/kernel": "0.1.195",
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.11",
18
- "@jskit-ai/connectors-catalog": "0.1.11"
17
+ "@jskit-ai/connectors-core": "0.1.12",
18
+ "@jskit-ai/connectors-catalog": "0.1.12"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -0,0 +1,61 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
+ import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
5
+
6
+ const working = ref(true);
7
+ const draft = ref("");
8
+ const scope = ref("first");
9
+ const combined = ref(false);
10
+ const messages = ref([1, 2, 3].map(index => ({
11
+ messageId: `progress-${index}`, role: "thinking", text: `Reasoning ${index}`
12
+ })));
13
+ let sequence = 3;
14
+ function append(role) {
15
+ sequence += 1;
16
+ messages.value.push({ messageId: `message-${sequence}`, role, text: `${role} ${sequence}` });
17
+ }
18
+ const adapter = computed(() => ({
19
+ conversation: {
20
+ working: working.value, scrollKey: scope.value, hasMoreBefore: true,
21
+ turns: combined.value
22
+ ? conversationTurnsFromMessages(messages.value)
23
+ : messages.value.map(message => ({
24
+ turnId: `row-${message.messageId}`,
25
+ ...(message.role === "system" || message.role === "user"
26
+ ? { [message.role]: { messageId: message.messageId, text: message.text } } : {}),
27
+ messages: [message]
28
+ }))
29
+ },
30
+ composer: { draft: draft.value, canSend: Boolean(draft.value), canStop: working.value },
31
+ actions: {
32
+ setDraft(value) { draft.value = value; },
33
+ stop() { working.value = false; },
34
+ loadMore({ complete }) {
35
+ messages.value.unshift({ messageId: `older-${sequence++}`, role: "thinking", text: "Older reasoning" });
36
+ complete({ changed: true });
37
+ }
38
+ }
39
+ }));
40
+ </script>
41
+ <template>
42
+ <v-app>
43
+ <v-main>
44
+ <main class="progress-fixture">
45
+ <nav>
46
+ <button @click="working = !working">Toggle working</button>
47
+ <button @click="combined = !combined">Change storage rows</button>
48
+ <button @click="scope = scope === 'first' ? 'second' : 'first'">Change conversation</button>
49
+ <button v-for="role in ['thinking', 'commentary', 'assistant', 'user', 'system']" :key="role" @click="append(role)">Append {{ role }}</button>
50
+ </nav>
51
+ <AssistantConversationElement :adapter="adapter" />
52
+ </main>
53
+ </v-main>
54
+ </v-app>
55
+ </template>
56
+ <style scoped>
57
+ .progress-fixture { display: flex; flex-direction: column; height: 100dvh; padding: 8px; }
58
+ nav { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
59
+ nav button { padding: 8px; min-height: 48px; border: 1px solid; }
60
+ .assistant-conversation { flex: 1; }
61
+ </style>
@@ -6,6 +6,7 @@ import * as directives from "vuetify/directives";
6
6
  import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
7
7
  import App from "./App.vue";
8
8
  import ConversationFixture from "./ConversationFixture.vue";
9
+ import ProgressFixture from "./ProgressFixture.vue";
9
10
 
10
11
  const vuetify = createVuetify({
11
12
  components,
@@ -20,6 +21,7 @@ const vuetify = createVuetify({
20
21
  }
21
22
  });
22
23
 
23
- createApp(new URLSearchParams(location.search).has("conversation") ? ConversationFixture : App)
24
+ const query = new URLSearchParams(location.search);
25
+ createApp(query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
24
26
  .use(vuetify)
25
27
  .mount("#app");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.172",
3
+ "version": "0.1.173",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -33,8 +33,8 @@
33
33
  "@ai-sdk/perplexity": "^4.0.43",
34
34
  "@ai-sdk/togetherai": "^3.0.49",
35
35
  "@ai-sdk/xai": "^4.0.58",
36
- "@jskit-ai/resource-core": "0.1.136",
37
- "@jskit-ai/resource-crud-core": "0.1.136",
36
+ "@jskit-ai/resource-core": "0.1.137",
37
+ "@jskit-ai/resource-crud-core": "0.1.137",
38
38
  "@mdi/js": "^7.4.47",
39
39
  "@openrouter/ai-sdk-provider": "^3.0.0",
40
40
  "ai": "^7.0.101",
@@ -43,8 +43,8 @@
43
43
  "ws": "^8.20.1"
44
44
  },
45
45
  "peerDependencies": {
46
- "@jskit-ai/http-runtime": "0.1.192",
47
- "@jskit-ai/kernel": "0.1.194",
46
+ "@jskit-ai/http-runtime": "0.1.193",
47
+ "@jskit-ai/kernel": "0.1.195",
48
48
  "vue": "^3.5.13",
49
49
  "vuetify": "^4.0.0"
50
50
  },
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <section class="assistant-conversation" :aria-label="label">
3
3
  <AssistantTranscript
4
- v-bind="adapter.conversation" class="assistant-conversation__transcript"
4
+ v-bind="adapter.conversation" :working="working" class="assistant-conversation__transcript"
5
5
  @load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
6
6
  @resend-turn="adapter.actions?.resend?.($event)" @cancel-turn="adapter.actions?.cancel?.($event)"
7
7
  @edit-turn="adapter.actions?.edit?.($event)" @link-click="adapter.actions?.openLink?.($event)"
@@ -115,9 +115,12 @@ const fileInput = ref(null);
115
115
  const modelsOpen = ref(false);
116
116
  const delivery = ref(null);
117
117
  const statusId = `assistant-status-${useId()}`;
118
+ const working = computed(() => props.adapter.conversation.working ?? Boolean(
119
+ props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
120
+ ));
118
121
  const activity = computed(() => props.adapter.activity ?? {
119
122
  label: props.adapter.composer?.stopPending ? "Stopping…"
120
- : props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
123
+ : working.value
121
124
  ? "Assistant is working…" : props.adapter.composer?.pending ? "Sending to assistant…" : ""
122
125
  });
123
126
  const suggestionPreview = computed(() => !props.adapter.composer?.draft && props.adapter.suggestions?.visible
@@ -98,12 +98,12 @@
98
98
  </div>
99
99
 
100
100
  <article
101
- v-for="turn in displayTurns"
102
- :key="turn.turnId"
101
+ v-for="entry in displayEntries"
102
+ :key="entry.key"
103
103
  class="assistant-transcript__turn"
104
104
  >
105
105
  <div
106
- v-if="turn.system"
106
+ v-if="entry.role === 'system'"
107
107
  class="assistant-transcript__system"
108
108
  >
109
109
  <v-icon
@@ -114,55 +114,55 @@
114
114
  <div class="assistant-transcript__system-body">
115
115
  <div class="assistant-transcript__system-meta">
116
116
  <span>{{ systemLabel }}</span>
117
- <time v-if="turn.system.displayAt">{{ turn.system.displayAt }}</time>
117
+ <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
118
118
  </div>
119
- <slot name="system-message" :message="turn.system"><LongTextPreviewBlocks compact :blocks="turn.system.blocks" @link-click="handleLongTextLinkClick" /></slot>
119
+ <slot name="system-message" :message="entry.message"><LongTextPreviewBlocks compact :blocks="entry.message.blocks" @link-click="handleLongTextLinkClick" /></slot>
120
120
  </div>
121
121
  </div>
122
122
 
123
123
  <div
124
- v-if="turn.user"
124
+ v-else-if="entry.role === 'user'"
125
125
  class="assistant-transcript__message-row assistant-transcript__message-row--user"
126
126
  >
127
127
  <div class="assistant-transcript__message assistant-transcript__message--user">
128
128
  <div
129
129
  class="assistant-transcript__user-content"
130
130
  >
131
- <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(turn) ? turn.user.text : userMessagePreviewText(turn.user.text) || turn.user.text }}</p>
131
+ <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
132
132
  <LongTextPreviewBlocks
133
133
  v-else
134
- :blocks="userMessageExpanded(turn) ? turn.user.blocks : (turn.user.previewBlocks || turn.user.blocks)"
134
+ :blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
135
135
  @link-click="handleLongTextLinkClick"
136
136
  />
137
137
  </div>
138
138
  <button
139
- v-if="userMessageCollapsible(turn.user)"
140
- :aria-expanded="userMessageExpanded(turn)"
139
+ v-if="userMessageCollapsible(entry.message)"
140
+ :aria-expanded="userMessageExpanded(entry.turn)"
141
141
  class="assistant-transcript__user-content-toggle"
142
142
  type="button"
143
- @click="toggleUserMessage(turn)"
143
+ @click="toggleUserMessage(entry.turn)"
144
144
  >
145
- {{ userMessageExpanded(turn) ? "Show less" : "Read more" }}
145
+ {{ userMessageExpanded(entry.turn) ? "Show less" : "Read more" }}
146
146
  </button>
147
- <slot name="attachments" :items="turn.user.attachments" :message="turn.user"><AssistantMessageAttachments :attachments="turn.user.attachments || []" /></slot>
147
+ <slot name="attachments" :items="entry.message.attachments" :message="entry.message"><AssistantMessageAttachments :attachments="entry.message.attachments || []" /></slot>
148
148
  <div
149
- v-if="turn.user.displayAt"
149
+ v-if="entry.message.displayAt"
150
150
  class="assistant-transcript__message-footer assistant-transcript__message-footer--user"
151
151
  >
152
- <time v-if="turn.user.displayAt">{{ turn.user.displayAt }}</time>
152
+ <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
153
153
  </div>
154
154
  <div
155
- v-if="turn.optimistic?.status === 'failed'"
155
+ v-if="entry.turn.optimistic?.status === 'failed'"
156
156
  class="assistant-transcript__optimistic-failure"
157
157
  >
158
- <span>{{ turn.optimistic.error || "Message could not be sent." }}</span>
158
+ <span>{{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
159
159
  <div class="assistant-transcript__optimistic-actions">
160
160
  <v-btn
161
161
  color="primary"
162
162
  size="x-small"
163
163
  type="button"
164
164
  variant="tonal"
165
- @click="emit('resend-turn', turn.optimistic.id)"
165
+ @click="emit('resend-turn', entry.turn.optimistic.id)"
166
166
  >
167
167
  Resend
168
168
  </v-btn>
@@ -170,7 +170,7 @@
170
170
  size="x-small"
171
171
  type="button"
172
172
  variant="text"
173
- @click="emit('cancel-turn', turn.optimistic.id)"
173
+ @click="emit('cancel-turn', entry.turn.optimistic.id)"
174
174
  >
175
175
  Cancel
176
176
  </v-btn>
@@ -178,7 +178,7 @@
178
178
  size="x-small"
179
179
  type="button"
180
180
  variant="text"
181
- @click="emit('edit-turn', turn.optimistic.id)"
181
+ @click="emit('edit-turn', entry.turn.optimistic.id)"
182
182
  >
183
183
  Edit
184
184
  </v-btn>
@@ -190,84 +190,79 @@
190
190
  </span>
191
191
  </div>
192
192
 
193
- <template
194
- v-for="entry in turn.agentTimeline"
195
- :key="entry.key"
193
+ <AssistantProgress
194
+ v-else-if="entry.role === 'thinking'"
195
+ :key="`${scrollKey}:${entry.key}`"
196
+ class="assistant-transcript__thinking"
197
+ :messages="entry.messages"
198
+ :pending="isWorking && entry === displayEntries.at(-1)"
199
+ :preview-limit="progressPreviewLimit"
200
+ :aria-label="`${assistantLabel} progress`"
201
+ />
202
+ <div
203
+ v-else
204
+ class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
205
+ :data-message-role="entry.role"
196
206
  >
197
- <AssistantProgress
198
- v-if="entry.role === 'thinking'"
199
- :key="`${turn.turnId}:${turn.pending ? 'active' : 'completed'}:${entry.key}`"
200
- class="assistant-transcript__thinking"
201
- :messages="entry.messages"
202
- :pending="turn.pending"
203
- :preview-limit="progressPreviewLimit"
204
- :aria-label="`${assistantLabel} progress`"
205
- />
206
- <div
207
- v-else
208
- class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
209
- :data-message-role="entry.role"
210
- >
211
- <div class="assistant-transcript__assistant-header">
212
- <span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
213
- <v-icon :icon="mdiRobotOutline" size="16" />
214
- </span>
215
- <div class="assistant-transcript__message-header">
216
- <span>{{ assistantLabel }}</span>
217
- </div>
207
+ <div class="assistant-transcript__assistant-header">
208
+ <span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
209
+ <v-icon :icon="mdiRobotOutline" size="16" />
210
+ </span>
211
+ <div class="assistant-transcript__message-header">
212
+ <span>{{ assistantLabel }}</span>
218
213
  </div>
219
- <div class="assistant-transcript__message assistant-transcript__message--assistant">
220
- <LongTextPreviewBlocks
221
- v-if="entry.message.blocks.length"
222
- :blocks="entry.message.blocks"
223
- @link-click="handleLongTextLinkClick"
224
- />
225
- <ol
226
- v-if="entry.message.questions.length"
227
- class="assistant-transcript__questions"
214
+ </div>
215
+ <div class="assistant-transcript__message assistant-transcript__message--assistant">
216
+ <LongTextPreviewBlocks
217
+ v-if="entry.message.blocks.length"
218
+ :blocks="entry.message.blocks"
219
+ @link-click="handleLongTextLinkClick"
220
+ />
221
+ <ol
222
+ v-if="entry.message.questions.length"
223
+ class="assistant-transcript__questions"
224
+ >
225
+ <li
226
+ v-for="question in entry.message.questions"
227
+ :key="question.name"
228
+ class="assistant-transcript__question"
228
229
  >
229
- <li
230
- v-for="question in entry.message.questions"
231
- :key="question.name"
232
- class="assistant-transcript__question"
233
- >
234
- <span class="assistant-transcript__question-number">{{ question.number }}</span>
235
- <div class="assistant-transcript__question-content">
236
- <span class="assistant-transcript__question-text">
230
+ <span class="assistant-transcript__question-number">{{ question.number }}</span>
231
+ <div class="assistant-transcript__question-content">
232
+ <span class="assistant-transcript__question-text">
233
+ <LongTextInlineParts
234
+ :text="question.label"
235
+ @link-click="handleLongTextLinkClick"
236
+ />
237
+ </span>
238
+ <ul
239
+ v-if="question.choices.length"
240
+ class="assistant-transcript__question-choices"
241
+ >
242
+ <li v-for="choice in question.choices" :key="choice.value">
237
243
  <LongTextInlineParts
238
- :text="question.label"
244
+ :text="choice.label"
239
245
  @link-click="handleLongTextLinkClick"
240
- />
241
- </span>
242
- <ul
243
- v-if="question.choices.length"
244
- class="assistant-transcript__question-choices"
245
- >
246
- <li v-for="choice in question.choices" :key="choice.value">
247
- <LongTextInlineParts
248
- :text="choice.label"
249
- @link-click="handleLongTextLinkClick"
250
- /><span v-if="choice.recommended"> · Recommended</span>
251
- </li>
252
- </ul>
253
- </div>
254
- </li>
255
- </ol>
256
- <LongTextPreviewBlocks
257
- v-if="entry.message.outroBlocks.length"
258
- :blocks="entry.message.outroBlocks"
259
- @link-click="handleLongTextLinkClick"
260
- />
261
- <slot name="message-actions" :message="entry.message" :turn="turn" />
262
- </div>
263
- <div
264
- v-if="entry.message.displayAt"
265
- class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
266
- >
267
- <time>{{ entry.message.displayAt }}</time>
268
- </div>
246
+ /><span v-if="choice.recommended"> · Recommended</span>
247
+ </li>
248
+ </ul>
249
+ </div>
250
+ </li>
251
+ </ol>
252
+ <LongTextPreviewBlocks
253
+ v-if="entry.message.outroBlocks.length"
254
+ :blocks="entry.message.outroBlocks"
255
+ @link-click="handleLongTextLinkClick"
256
+ />
257
+ <slot name="message-actions" :message="entry.message" :turn="entry.turn" />
258
+ </div>
259
+ <div
260
+ v-if="entry.message.displayAt"
261
+ class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
262
+ >
263
+ <time>{{ entry.message.displayAt }}</time>
269
264
  </div>
270
- </template>
265
+ </div>
271
266
  </article>
272
267
 
273
268
  <div
@@ -303,6 +298,7 @@ import {
303
298
 
304
299
  const props = defineProps({
305
300
  systemLabel: { type: String, default: "Status" },
301
+ working: { type: Boolean, default: undefined },
306
302
  progressPreviewLimit: { type: Number, default: 2 },
307
303
  userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
308
304
  assistantLabel: {
@@ -373,6 +369,7 @@ const DISPLAY_MESSAGE_CACHE_LIMIT = 500;
373
369
  const bodyElement = ref(null);
374
370
  const bottomElement = ref(null);
375
371
  const expandedUserMessages = ref(new Set());
372
+ const isWorking = computed(() => props.working ?? props.turns.some(turn => turn.pending));
376
373
  const followingLatest = ref(true);
377
374
  const initialScrollSettled = ref(false);
378
375
  const userScrollIntent = ref(false);
@@ -530,66 +527,49 @@ function conversationMessageKey(message = {}, index = 0) {
530
527
  ].join(":");
531
528
  }
532
529
 
533
- function displayAgentTimeline(turn = {}, turnKey = "") {
534
- const timeline = [];
535
- for (const [index, message] of conversationAgentMessages(turn).entries()) {
536
- const role = String(message?.role || "").trim();
537
- if (role === "thinking") {
538
- const displayed = displayThinkingMessage(message);
539
- if (!displayed) {
540
- continue;
541
- }
542
- const keyedMessage = {
543
- ...displayed,
544
- key: conversationMessageKey(message, index)
545
- };
546
- const previous = timeline.at(-1);
547
- if (previous?.role === "thinking") {
548
- previous.messages.push(keyedMessage);
549
- continue;
530
+ // Storage rows are not visual boundaries. Only an intervening message ends a
531
+ // reasoning group, including when history is prepended or execution continues.
532
+ const displayEntries = computed(() => {
533
+ const entries = [];
534
+ for (const [index, turn] of props.turns.entries()) {
535
+ const turnId = String(turn.turnId || index + 1);
536
+ const messages = [
537
+ turn.system && { ...turn.system, role: "system" },
538
+ turn.user && { ...turn.user, role: "user" },
539
+ ...conversationAgentMessages(turn)
540
+ ].filter(Boolean);
541
+ for (const [messageIndex, message] of messages.entries()) {
542
+ const role = String(message.role || "").trim();
543
+ const key = `${turnId}:${conversationMessageKey(message, messageIndex)}`;
544
+ const displayed = role === "thinking" ? displayThinkingMessage(message) : displayMessage(message, {
545
+ allowNumberedQuestions: role !== "user" && role !== "system",
546
+ preserveParagraphLineBreaks: role === "user",
547
+ previewUserMessage: role === "user"
548
+ }, key);
549
+ if (!displayed) continue;
550
+ const previous = entries.at(-1);
551
+ if (role === "thinking" && previous?.role === "thinking") {
552
+ previous.messages.push({ ...displayed, key });
553
+ } else {
554
+ entries.push({
555
+ key, role, turn: { ...turn, turnId }, message: displayed,
556
+ ...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
557
+ });
550
558
  }
551
- timeline.push({
552
- key: `thinking:${keyedMessage.key}`,
553
- messages: [keyedMessage],
554
- role
555
- });
556
- continue;
557
559
  }
558
- timeline.push({
559
- key: conversationMessageKey(message, index),
560
- message: displayMessage(message, {
561
- allowNumberedQuestions: true
562
- }, `${turnKey}:agent:${conversationMessageKey(message, index)}`),
563
- role
564
- });
565
560
  }
566
- return timeline;
567
- }
561
+ // Anchor a progress group to the following message, or to the transcript end.
562
+ // Appending progress or prepending history then preserves its expansion state.
563
+ for (const [index, entry] of entries.entries()) {
564
+ if (entry.role === "thinking") entry.key = `progress:${entries[index + 1]?.key || "tail"}`;
565
+ }
566
+ return entries;
567
+ });
568
568
 
569
569
  function handleLongTextLinkClick(payload) {
570
570
  emit("link-click", payload);
571
571
  }
572
572
 
573
- const displayTurns = computed(() => (Array.isArray(props.turns) ? props.turns : [])
574
- .map((turn, index) => {
575
- const turnId = String(turn.turnId || index + 1);
576
- return {
577
- ...turn,
578
- agentTimeline: displayAgentTimeline(turn, turnId),
579
- optimistic: turn.optimistic && typeof turn.optimistic === "object" && !Array.isArray(turn.optimistic)
580
- ? turn.optimistic
581
- : null,
582
- pending: turn.pending === true,
583
- system: displayMessage(turn.system, {}, `${turnId}:system`),
584
- turnId,
585
- user: displayMessage(turn.user, {
586
- preserveParagraphLineBreaks: true,
587
- previewUserMessage: true
588
- }, `${turnId}:user`)
589
- };
590
- })
591
- .filter((turn) => turn.system || turn.user || turn.agentTimeline.length));
592
-
593
573
  function userMessageCollapsible(message = null) {
594
574
  return Array.isArray(message?.previewBlocks);
595
575
  }
@@ -618,11 +598,11 @@ function toggleUserMessage(turn = {}) {
618
598
 
619
599
  const loadingIndicatorVisible = computed(() => Boolean(
620
600
  props.loading &&
621
- !displayTurns.value.length
601
+ !displayEntries.value.length
622
602
  ));
623
603
  const initialScrollPending = computed(() => Boolean(
624
604
  props.visible &&
625
- displayTurns.value.length &&
605
+ displayEntries.value.length &&
626
606
  !initialScrollSettled.value
627
607
  ));
628
608
 
@@ -638,40 +618,24 @@ function messageScrollKey(message = null) {
638
618
  ].join("/");
639
619
  }
640
620
 
641
- function agentTimelineScrollKey(timeline = []) {
642
- return (Array.isArray(timeline) ? timeline : []).map((entry) => (
643
- entry?.role === "thinking"
644
- ? (entry.messages || []).map(messageScrollKey).join("+")
645
- : messageScrollKey(entry?.message)
646
- )).join("|");
647
- }
648
-
649
- function latestRenderedTailScrollKey(turns = []) {
650
- const turn = Array.isArray(turns) ? turns.at(-1) : null;
651
- if (!turn) {
652
- return "";
653
- }
654
- const timeline = Array.isArray(turn.agentTimeline) ? turn.agentTimeline : [];
655
- return [
656
- turn.turnId,
657
- messageScrollKey(turn.system),
658
- messageScrollKey(turn.user),
659
- turn.optimistic?.id || "",
660
- turn.optimistic?.status || "",
661
- turn.optimistic?.error || "",
662
- turn.pending ? "pending" : "settled",
663
- agentTimelineScrollKey(timeline)
664
- ].join(":");
665
- }
666
-
667
621
  const timelineScrollTrigger = computed(() => [
668
622
  props.visible ? "visible" : "hidden",
669
623
  props.error ? "error" : "body",
670
624
  loadingIndicatorVisible.value ? "loading" : "ready",
671
- displayTurns.value.length ? "has-turns" : "empty",
625
+ displayEntries.value.length ? "has-messages" : "empty",
672
626
  props.scrollKey
673
627
  ].join(":"));
674
- const latestRenderedTailKey = computed(() => latestRenderedTailScrollKey(displayTurns.value));
628
+ const latestRenderedTailKey = computed(() => {
629
+ const entry = displayEntries.value.at(-1);
630
+ if (!entry) return "";
631
+ return [
632
+ entry.key,
633
+ isWorking.value,
634
+ entry.role === "thinking" ? entry.messages.map(messageScrollKey).join("+") : messageScrollKey(entry.message),
635
+ entry.turn.optimistic?.status || "",
636
+ entry.turn.optimistic?.error || ""
637
+ ].join(":");
638
+ });
675
639
  const autoScrollEnabled = computed(() => Boolean(
676
640
  props.visible &&
677
641
  followingLatest.value
@@ -22,6 +22,79 @@ const VIEWPORTS = Object.freeze([
22
22
  Object.freeze({ name: "drawer", width: 360, height: 600 })
23
23
  ]);
24
24
 
25
+ test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
26
+ skip: !RUN_BROWSER_TEST,
27
+ timeout: 90_000
28
+ }, async () => {
29
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
30
+ const browser = await chromium.launch(createChromiumLaunchOptions());
31
+ try {
32
+ for (const width of [390, 800, 1365]) {
33
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
34
+ const errors = [];
35
+ page.on("pageerror", error => errors.push(error.message));
36
+ await page.goto(`${vite.baseURL}/?progress=1`);
37
+ const groups = page.locator(".assistant-progress");
38
+ const summaries = page.locator(".assistant-progress__message");
39
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
40
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
41
+ await expect(groups).toHaveCount(1);
42
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
43
+ await input.fill("Keep my draft");
44
+ await input.evaluate(element => element.setSelectionRange(2, 5));
45
+ await external("Change storage rows");
46
+ await expect(groups).toHaveCount(1);
47
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
48
+ await external("Change storage rows");
49
+ await external("Toggle working");
50
+ await expect(summaries).toHaveCount(0);
51
+ await expect(page.getByRole("button", { name: "Show all 3 progress updates" })).toBeVisible();
52
+ await external("Toggle working");
53
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
54
+ await external("Show all 3 progress updates");
55
+ await expect(summaries).toHaveCount(3);
56
+ await external("Load older messages");
57
+ await expect(groups).toHaveCount(1);
58
+ await expect(summaries).toHaveText(["Older reasoning", "Reasoning 1", "Reasoning 2", "Reasoning 3"]);
59
+ await external("Append thinking");
60
+ await expect(summaries).toHaveCount(5);
61
+ await expect(input).toBeFocused();
62
+ await expect(input).toHaveValue("Keep my draft");
63
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 5]);
64
+ await external("Show latest 2 progress updates");
65
+ await external("Toggle working");
66
+ await expect(summaries).toHaveCount(0);
67
+ await external("Show all 5 progress updates");
68
+ await external("Change conversation");
69
+ await expect(summaries).toHaveCount(0);
70
+ await external("Toggle working");
71
+
72
+ // Every visible non-reasoning message separates groups, regardless of rows.
73
+ for (const [index, role] of ["commentary", "assistant", "user", "system"].entries()) {
74
+ await external(`Append ${role}`);
75
+ await expect(summaries).toHaveCount(0);
76
+ await external("Append thinking");
77
+ await external("Append thinking");
78
+ await expect(groups).toHaveCount(index + 2);
79
+ await expect(summaries).toHaveCount(2);
80
+ }
81
+ await external("Change storage rows");
82
+ await expect(groups).toHaveCount(5);
83
+ await expect(summaries).toHaveCount(2);
84
+ const bodyText = await page.locator(".assistant-transcript__body").textContent();
85
+ assert.ok(bodyText.indexOf("commentary 6") < bodyText.indexOf("assistant 9"));
86
+ assert.ok(bodyText.indexOf("assistant 9") < bodyText.indexOf("user 12"));
87
+ assert.ok(bodyText.indexOf("user 12") < bodyText.indexOf("system 15"));
88
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
89
+ assert.deepEqual(errors, []);
90
+ await page.close();
91
+ }
92
+ } finally {
93
+ await browser.close();
94
+ await stopProcess(vite);
95
+ }
96
+ });
97
+
25
98
  async function assertResponsiveAssistant(page, viewport) {
26
99
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
27
100
  await page.goto("/");