@jskit-ai/assistant-core 0.1.172 → 0.1.174

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.174",
12
+ "@jskit-ai/http-runtime": "0.1.194",
13
+ "@jskit-ai/kernel": "0.1.196",
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.13",
18
+ "@jskit-ai/connectors-catalog": "0.1.13"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -2,23 +2,54 @@
2
2
  import { computed, ref } from "vue";
3
3
  import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
4
  import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
5
+ import { mergeConversationStream } from "../../src/shared/conversation/streaming.js";
5
6
  const draft = ref("");
6
- const controls = new URLSearchParams(location.search).has("controls");
7
+ const query = new URLSearchParams(location.search);
8
+ const controls = query.has("controls");
9
+ const deferredHistory = query.has("history");
10
+ const streaming = query.has("streaming");
11
+ const liveText = ref("");
12
+ const liveMessage = computed(() => ({ messageId: "stream-answer", role: "assistant", text: liveText.value, status: "inProgress" }));
7
13
  const scope = ref("one");
14
+ const loadingMore = ref(false);
15
+ const loadMoreError = ref("");
16
+ const historyRequests = ref(0);
17
+ const hasMoreBefore = ref(controls);
18
+ let completeHistory = null;
8
19
  const messages = ref(Array.from({ length: 70 }, (_, index) => ({
9
20
  id: `message-${index}`, role: index % 2 ? "assistant" : "user", status: "completed",
10
21
  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.`
11
22
  })));
12
23
  if (controls) messages.value[0].text = messages.value[0].text.repeat(4);
24
+ if (streaming) messages.value.push({ id: "stream-question", role: "user", text: "Show the answer as it arrives." });
25
+ function finishStream() {
26
+ messages.value.push({ ...liveMessage.value, status: "completed" });
27
+ liveText.value = "";
28
+ }
29
+ function finishHistory(error = "") {
30
+ loadMoreError.value = error;
31
+ if (!error) {
32
+ messages.value.unshift({ id: `older-${historyRequests.value}`, role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
33
+ }
34
+ loadingMore.value = false;
35
+ completeHistory({ changed: !error });
36
+ completeHistory = null;
37
+ }
13
38
  const adapter = computed(() => ({
14
- conversation: { turns: conversationTurnsFromMessages(messages.value), scrollKey: scope.value, hasMoreBefore: controls },
39
+ conversation: {
40
+ turns: mergeConversationStream(conversationTurnsFromMessages(messages.value), { messages: [liveMessage.value] }), scrollKey: scope.value,
41
+ hasMoreBefore: hasMoreBefore.value, loadingMore: loadingMore.value, loadMoreError: loadMoreError.value
42
+ },
15
43
  composer: { draft: draft.value, canSend: Boolean(draft.value.trim()), rows: 2 },
16
44
  actions: {
17
45
  setDraft(value) { draft.value = value; },
18
46
  submit() { draft.value = ""; },
19
47
  loadMore({ complete }) {
20
- messages.value.unshift({ id: "older", role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
21
- complete({ changed: true });
48
+ historyRequests.value += 1;
49
+ loadingMore.value = true;
50
+ loadMoreError.value = "";
51
+ completeHistory = complete;
52
+ if (!deferredHistory) finishHistory();
22
53
  }
23
54
  }
24
55
  }));
@@ -27,10 +58,20 @@ const adapter = computed(() => ({
27
58
  <v-app>
28
59
  <v-main>
29
60
  <main class="assistant-responsive-fixture">
61
+ <nav v-if="streaming">
62
+ <button @click="liveText += 'Growing answer. '">Receive text</button>
63
+ <button @click="finishStream">Finish answer</button>
64
+ </nav>
30
65
  <nav v-if="controls">
31
66
  <button @click="scope = scope === 'one' ? 'two' : 'one'">Change conversation</button>
32
67
  <button @click="messages.push({ id: `added-${messages.length}`, role: 'assistant', text: 'New reply. '.repeat(30), status: 'completed' })">Append reply</button>
33
68
  </nav>
69
+ <nav v-if="deferredHistory">
70
+ <button :disabled="!loadingMore" @click="finishHistory()">Complete history load</button>
71
+ <button :disabled="!loadingMore" @click="finishHistory('History unavailable')">Fail history load</button>
72
+ <button @click="hasMoreBefore = false">Exhaust history</button>
73
+ <output>History requests: {{ historyRequests }}</output>
74
+ </nav>
34
75
  <AssistantConversationElement :adapter="adapter" class="assistant-responsive-fixture__conversation" />
35
76
  </main>
36
77
  </v-main>
@@ -39,6 +80,6 @@ const adapter = computed(() => ({
39
80
  <style>
40
81
  html, body, #app { height: 100%; margin: 0; min-height: 0; }
41
82
  .assistant-responsive-fixture { display: flex; flex-direction: column; height: 100dvh; min-height: 0; padding: .5rem; }
42
- .assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; gap: 1rem; }
83
+ .assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 1rem; }
43
84
  .assistant-responsive-fixture__conversation { flex: 1 1 auto; }
44
85
  </style>
@@ -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.174",
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.138",
37
+ "@jskit-ai/resource-crud-core": "0.1.138",
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.194",
47
+ "@jskit-ai/kernel": "0.1.196",
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
@@ -18,6 +18,7 @@ const emit = defineEmits(["dismiss"]);
18
18
  <div class="assistant-questions__question-fields-header">
19
19
  <v-btn
20
20
  aria-label="Answer normally instead"
21
+ class="ml-auto"
21
22
  color="primary"
22
23
  :prepend-icon="mdiPencilOutline"
23
24
  size="small"
@@ -99,15 +100,6 @@ const emit = defineEmits(["dismiss"]);
99
100
  min-width: 0;
100
101
  }
101
102
 
102
- .assistant-questions__question-fields-header > span {
103
- min-width: 0;
104
- overflow-wrap: anywhere;
105
- }
106
-
107
- .assistant-questions__question-fields-header :deep(.v-btn) {
108
- margin-left: auto;
109
- }
110
-
111
103
  .assistant-questions__question-field {
112
104
  min-width: 0;
113
105
  }
@@ -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);
@@ -383,7 +380,9 @@ let initialScrollVersion = 0;
383
380
  let loadMoreScrollSnapshot = null;
384
381
  let loadMoreRequestVersion = 0;
385
382
  let pendingTailFollow = false;
383
+ let previousScrollTop = 0;
386
384
  const USER_SCROLL_INTENT_RESET_MS = 600;
385
+ const LOAD_MORE_THRESHOLD_PX = 160;
387
386
  const KEYBOARD_SCROLL_KEYS = new Set([
388
387
  " ",
389
388
  "ArrowDown",
@@ -530,66 +529,49 @@ function conversationMessageKey(message = {}, index = 0) {
530
529
  ].join(":");
531
530
  }
532
531
 
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;
532
+ // Storage rows are not visual boundaries. Only an intervening message ends a
533
+ // reasoning group, including when history is prepended or execution continues.
534
+ const displayEntries = computed(() => {
535
+ const entries = [];
536
+ for (const [index, turn] of props.turns.entries()) {
537
+ const turnId = String(turn.turnId || index + 1);
538
+ const messages = [
539
+ turn.system && { ...turn.system, role: "system" },
540
+ turn.user && { ...turn.user, role: "user" },
541
+ ...conversationAgentMessages(turn)
542
+ ].filter(Boolean);
543
+ for (const [messageIndex, message] of messages.entries()) {
544
+ const role = String(message.role || "").trim();
545
+ const key = `${turnId}:${conversationMessageKey(message, messageIndex)}`;
546
+ const displayed = role === "thinking" ? displayThinkingMessage(message) : displayMessage(message, {
547
+ allowNumberedQuestions: role !== "user" && role !== "system",
548
+ preserveParagraphLineBreaks: role === "user",
549
+ previewUserMessage: role === "user"
550
+ }, key);
551
+ if (!displayed) continue;
552
+ const previous = entries.at(-1);
553
+ if (role === "thinking" && previous?.role === "thinking") {
554
+ previous.messages.push({ ...displayed, key });
555
+ } else {
556
+ entries.push({
557
+ key, role, turn: { ...turn, turnId }, message: displayed,
558
+ ...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
559
+ });
550
560
  }
551
- timeline.push({
552
- key: `thinking:${keyedMessage.key}`,
553
- messages: [keyedMessage],
554
- role
555
- });
556
- continue;
557
561
  }
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
562
  }
566
- return timeline;
567
- }
563
+ // Anchor a progress group to the following message, or to the transcript end.
564
+ // Appending progress or prepending history then preserves its expansion state.
565
+ for (const [index, entry] of entries.entries()) {
566
+ if (entry.role === "thinking") entry.key = `progress:${entries[index + 1]?.key || "tail"}`;
567
+ }
568
+ return entries;
569
+ });
568
570
 
569
571
  function handleLongTextLinkClick(payload) {
570
572
  emit("link-click", payload);
571
573
  }
572
574
 
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
575
  function userMessageCollapsible(message = null) {
594
576
  return Array.isArray(message?.previewBlocks);
595
577
  }
@@ -618,11 +600,11 @@ function toggleUserMessage(turn = {}) {
618
600
 
619
601
  const loadingIndicatorVisible = computed(() => Boolean(
620
602
  props.loading &&
621
- !displayTurns.value.length
603
+ !displayEntries.value.length
622
604
  ));
623
605
  const initialScrollPending = computed(() => Boolean(
624
606
  props.visible &&
625
- displayTurns.value.length &&
607
+ displayEntries.value.length &&
626
608
  !initialScrollSettled.value
627
609
  ));
628
610
 
@@ -638,40 +620,24 @@ function messageScrollKey(message = null) {
638
620
  ].join("/");
639
621
  }
640
622
 
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
623
  const timelineScrollTrigger = computed(() => [
668
624
  props.visible ? "visible" : "hidden",
669
625
  props.error ? "error" : "body",
670
626
  loadingIndicatorVisible.value ? "loading" : "ready",
671
- displayTurns.value.length ? "has-turns" : "empty",
627
+ displayEntries.value.length ? "has-messages" : "empty",
672
628
  props.scrollKey
673
629
  ].join(":"));
674
- const latestRenderedTailKey = computed(() => latestRenderedTailScrollKey(displayTurns.value));
630
+ const latestRenderedTailKey = computed(() => {
631
+ const entry = displayEntries.value.at(-1);
632
+ if (!entry) return "";
633
+ return [
634
+ entry.key,
635
+ isWorking.value,
636
+ entry.role === "thinking" ? entry.messages.map(messageScrollKey).join("+") : messageScrollKey(entry.message),
637
+ entry.turn.optimistic?.status || "",
638
+ entry.turn.optimistic?.error || ""
639
+ ].join(":");
640
+ });
675
641
  const autoScrollEnabled = computed(() => Boolean(
676
642
  props.visible &&
677
643
  followingLatest.value
@@ -802,6 +768,13 @@ function clearLiveBottomScroll() {
802
768
 
803
769
  function updateLatestFollowFromScroll(event = {}) {
804
770
  const target = event?.currentTarget || bodyElement.value;
771
+ const scrollTop = target?.scrollTop || 0;
772
+ const scrolledUp = scrollTop < previousScrollTop;
773
+ previousScrollTop = scrollTop;
774
+ // Keep following the reader while the request is pending, before rows prepend.
775
+ if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
776
+ loadMoreScrollSnapshot.scrollTop = scrollTop;
777
+ }
805
778
  const shouldFollow = scrollElementNearBottom(target);
806
779
  if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
807
780
  return;
@@ -812,14 +785,20 @@ function updateLatestFollowFromScroll(event = {}) {
812
785
  resumePendingTailFollow();
813
786
  return;
814
787
  }
815
- if (!shouldFollow) {
816
- clearLiveBottomScroll();
817
- clearScheduledScrolls();
788
+ clearLiveBottomScroll();
789
+ clearScheduledScrolls();
790
+ if (
791
+ scrolledUp &&
792
+ initialScrollSettled.value &&
793
+ scrollTop <= LOAD_MORE_THRESHOLD_PX &&
794
+ !props.loadMoreError
795
+ ) {
796
+ requestLoadMore();
818
797
  }
819
798
  }
820
799
 
821
800
  function requestLoadMore() {
822
- if (!props.hasMoreBefore || props.loadingMore) {
801
+ if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
823
802
  return;
824
803
  }
825
804
  followingLatest.value = false;
@@ -863,7 +842,8 @@ async function completeLoadMoreRequest(version, changed) {
863
842
  }
864
843
  const element = bodyElement.value;
865
844
  if (element) {
866
- element.scrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
845
+ previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
846
+ element.scrollTop = previousScrollTop;
867
847
  }
868
848
  clearLoadMoreScrollSnapshot();
869
849
  }
@@ -39,13 +39,9 @@ function assistantAttachmentFilesFromDropEvent(event) {
39
39
  return assistantAttachmentFiles(dataTransfer?.files);
40
40
  }
41
41
 
42
- function assistantAttachmentFilesFromClipboardItems(items = []) {
43
- return assistantAttachmentFilesFromTransferItems(items);
44
- }
45
-
46
42
  function assistantAttachmentFilesFromPasteEvent(event) {
47
43
  const clipboardData = event?.clipboardData;
48
- const itemFiles = assistantAttachmentFilesFromClipboardItems(clipboardData?.items);
44
+ const itemFiles = assistantAttachmentFilesFromTransferItems(clipboardData?.items);
49
45
  if (itemFiles.length > 0) {
50
46
  return itemFiles;
51
47
  }
@@ -105,13 +101,6 @@ function attachmentIdentity(attachment = {}) {
105
101
  );
106
102
  }
107
103
 
108
- async function deliverUploadedAttachments(onUploaded, uploaded = []) {
109
- if (uploaded.length > 0) {
110
- return await onUploaded(uploaded);
111
- }
112
- return null;
113
- }
114
-
115
104
  const ASSISTANT_ATTACHMENT_MAX_BYTES = 100_000_000;
116
105
  const ASSISTANT_ATTACHMENT_MAX_ITEMS = 10;
117
106
  const ASSISTANT_ATTACHMENT_UPLOAD_CONCURRENCY = 2;
@@ -400,7 +389,7 @@ function useAssistantAttachments({
400
389
  attachments.value.push(receipt);
401
390
  }
402
391
  try {
403
- const handoff = await deliverUploadedAttachments(onUploaded, [receipt]);
392
+ const handoff = await onUploaded([receipt]);
404
393
  const accepted = handoff?.accepted === true;
405
394
  if (row.activeHandoffToken !== token || !row.retained || row.phase === "cancelled") {
406
395
  removeReadyReceipt(row);
@@ -347,6 +347,23 @@ function classifyCodexAppServerEvent(notification = {}) {
347
347
  turnId: codexAppServerNotificationTurnId(notification)
348
348
  };
349
349
 
350
+ if (method === "item/agentMessage/delta") {
351
+ return {
352
+ ...base,
353
+ kind: "assistant_delta",
354
+ delta: typeof codexAppServerNotificationParams(notification).delta === "string"
355
+ ? codexAppServerNotificationParams(notification).delta : ""
356
+ };
357
+ }
358
+
359
+ if (method === "item/started" && itemType === "agentMessage") {
360
+ return {
361
+ ...base,
362
+ kind: "assistant_started",
363
+ role: phase === "commentary" ? "commentary" : "assistant"
364
+ };
365
+ }
366
+
350
367
  if (method === "error") {
351
368
  return {
352
369
  ...base,
@@ -1,2 +1,3 @@
1
1
  export { createConversationTranscript } from "./transcript.js";
2
2
  export { createMemoryConversationStorage } from "./memoryStorage.js";
3
+ export { createConversationStreams } from "./streams.js";
@@ -505,7 +505,16 @@ function createOpenCodeServerClient({
505
505
  });
506
506
  }
507
507
 
508
+ /** Read text snapshots without trimming the whitespace at a streaming boundary. */
509
+ function openCodeAssistantMessageText(message = {}) {
510
+ const values = [message.text, ...(message.content || [])
511
+ .filter((part) => part?.type === "text").map((part) => part.text)]
512
+ .filter((value) => typeof value === "string" && value.trim());
513
+ return [...new Set(values)].join("\n\n");
514
+ }
515
+
508
516
  export {
517
+ openCodeAssistantMessageText,
509
518
  OPENCODE_RESPONSE_LIMIT_BYTES,
510
519
  createOpenCodeServerClient,
511
520
  readBoundedResponse
@@ -0,0 +1,52 @@
1
+ /** Transient provider output. Applications own scope, admission and final persistence. */
2
+ export function createConversationStreams({ clock = () => new Date() } = {}) {
3
+ const streams = new Map();
4
+ let revision = 0;
5
+
6
+ function read(scope) {
7
+ const stream = streams.get(scope);
8
+ return {
9
+ revision,
10
+ messages: stream ? [...stream.messages.values()].map((message) => ({ ...message })) : []
11
+ };
12
+ }
13
+
14
+ function update(scope, { turnId, messageId, role = "assistant", text, delta, at } = {}) {
15
+ if (!turnId || !messageId || !["assistant", "commentary"].includes(role)) return null;
16
+ let stream = streams.get(scope);
17
+ if (!stream || stream.turnId !== turnId) {
18
+ stream = { turnId, messages: new Map(), completed: new Set() };
19
+ streams.set(scope, stream);
20
+ }
21
+ if (stream.completed.has(messageId)) return null;
22
+ const previous = stream.messages.get(messageId);
23
+ const next = {
24
+ messageId,
25
+ role: previous?.role || role,
26
+ at: previous?.at || at || clock().toISOString(),
27
+ text: typeof delta === "string" ? (previous?.text || "") + delta : (text ?? previous?.text ?? ""),
28
+ status: "inProgress"
29
+ };
30
+ if (previous?.text === next.text && previous.role === next.role) return null;
31
+ stream.messages.set(messageId, next);
32
+ revision += 1;
33
+ return read(scope);
34
+ }
35
+
36
+ function complete(scope, messageId) {
37
+ const stream = streams.get(scope);
38
+ if (!stream) return read(scope);
39
+ stream.completed.add(messageId);
40
+ stream.messages.delete(messageId);
41
+ revision += 1;
42
+ return read(scope);
43
+ }
44
+
45
+ function clear(scope) {
46
+ streams.delete(scope);
47
+ revision += 1;
48
+ return read(scope);
49
+ }
50
+
51
+ return { read, update, complete, clear };
52
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./turns.js";
2
+ export * from "./streaming.js";
2
3
  export * from "./longTextBlocks.js";
3
4
  export * from "./numberedQuestionSugar.js";
4
5
  export * from "./answerChoiceSugar.js";
@@ -0,0 +1,24 @@
1
+ function messagesForTurn(turn) {
2
+ return turn.messages || [turn.system, turn.user, ...(turn.thinking || []), ...(turn.commentary || []), turn.assistant].filter(Boolean);
3
+ }
4
+
5
+ /** Overlay live provider output without changing the saved transcript. */
6
+ export function mergeConversationStream(turns = [], snapshot = {}) {
7
+ const savedIds = new Set(turns.flatMap((turn) => messagesForTurn(turn).map((message) => message.messageId)));
8
+ const messages = (snapshot?.messages || []).filter((message) => message.text && !savedIds.has(message.messageId));
9
+ if (!messages.length) return turns;
10
+ const result = [...turns];
11
+ for (const message of messages) {
12
+ const tail = result.at(-1);
13
+ const turn = tail && !tail.assistant && !tail.system
14
+ ? { ...tail, messages: [...messagesForTurn(tail)] }
15
+ : { turnId: `stream:${message.messageId}`, messages: [] };
16
+ if (turn.turnId === tail?.turnId) result[result.length - 1] = turn;
17
+ else result.push(turn);
18
+ turn.messages.push(message);
19
+ turn.pending = true;
20
+ if (message.role === "assistant") turn.assistant = message;
21
+ else turn.commentary = [...(turn.commentary || []), message];
22
+ }
23
+ return result;
24
+ }
@@ -22,6 +22,111 @@ const VIEWPORTS = Object.freeze([
22
22
  Object.freeze({ name: "drawer", width: 360, height: 600 })
23
23
  ]);
24
24
 
25
+ test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
26
+ skip: !RUN_BROWSER_TEST, timeout: 90_000
27
+ }, async () => {
28
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
29
+ const browser = await chromium.launch(createChromiumLaunchOptions());
30
+ try {
31
+ for (const width of [390, 800, 1365]) {
32
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
33
+ await page.goto(`${vite.baseURL}/?streaming=1`);
34
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
35
+ await input.fill("Keep this draft");
36
+ await input.evaluate(element => element.setSelectionRange(2, 5));
37
+ const receive = () => page.getByRole("button", { name: "Receive text" }).evaluate(element => element.click());
38
+ await receive();
39
+ const answer = page.locator(".assistant-transcript__body").getByText("Growing answer.", { exact: true });
40
+ await expect(answer).toHaveCount(1);
41
+ await receive();
42
+ await expect(page.getByText("Growing answer. Growing answer.", { exact: true })).toHaveCount(1);
43
+ await expect(input).toBeFocused();
44
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 5]);
45
+ await page.getByRole("button", { name: "Finish answer" }).evaluate(element => element.click());
46
+ await expect(page.getByText("Growing answer. Growing answer.", { exact: true })).toHaveCount(1);
47
+ await expect(input).toHaveValue("Keep this draft");
48
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
49
+ await page.close();
50
+ }
51
+ } finally {
52
+ await browser.close();
53
+ await stopProcess(vite);
54
+ }
55
+ });
56
+
57
+ test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
58
+ skip: !RUN_BROWSER_TEST,
59
+ timeout: 90_000
60
+ }, async () => {
61
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
62
+ const browser = await chromium.launch(createChromiumLaunchOptions());
63
+ try {
64
+ for (const width of [390, 800, 1365]) {
65
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
66
+ const errors = [];
67
+ page.on("pageerror", error => errors.push(error.message));
68
+ await page.goto(`${vite.baseURL}/?progress=1`);
69
+ const groups = page.locator(".assistant-progress");
70
+ const summaries = page.locator(".assistant-progress__message");
71
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
72
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
73
+ await expect(groups).toHaveCount(1);
74
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
75
+ await input.fill("Keep my draft");
76
+ await input.evaluate(element => element.setSelectionRange(2, 5));
77
+ await external("Change storage rows");
78
+ await expect(groups).toHaveCount(1);
79
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
80
+ await external("Change storage rows");
81
+ await external("Toggle working");
82
+ await expect(summaries).toHaveCount(0);
83
+ await expect(page.getByRole("button", { name: "Show all 3 progress updates" })).toBeVisible();
84
+ await external("Toggle working");
85
+ await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
86
+ await external("Show all 3 progress updates");
87
+ await expect(summaries).toHaveCount(3);
88
+ await external("Load older messages");
89
+ await expect(groups).toHaveCount(1);
90
+ await expect(summaries).toHaveText(["Older reasoning", "Reasoning 1", "Reasoning 2", "Reasoning 3"]);
91
+ await external("Append thinking");
92
+ await expect(summaries).toHaveCount(5);
93
+ await expect(input).toBeFocused();
94
+ await expect(input).toHaveValue("Keep my draft");
95
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 5]);
96
+ await external("Show latest 2 progress updates");
97
+ await external("Toggle working");
98
+ await expect(summaries).toHaveCount(0);
99
+ await external("Show all 5 progress updates");
100
+ await external("Change conversation");
101
+ await expect(summaries).toHaveCount(0);
102
+ await external("Toggle working");
103
+
104
+ // Every visible non-reasoning message separates groups, regardless of rows.
105
+ for (const [index, role] of ["commentary", "assistant", "user", "system"].entries()) {
106
+ await external(`Append ${role}`);
107
+ await expect(summaries).toHaveCount(0);
108
+ await external("Append thinking");
109
+ await external("Append thinking");
110
+ await expect(groups).toHaveCount(index + 2);
111
+ await expect(summaries).toHaveCount(2);
112
+ }
113
+ await external("Change storage rows");
114
+ await expect(groups).toHaveCount(5);
115
+ await expect(summaries).toHaveCount(2);
116
+ const bodyText = await page.locator(".assistant-transcript__body").textContent();
117
+ assert.ok(bodyText.indexOf("commentary 6") < bodyText.indexOf("assistant 9"));
118
+ assert.ok(bodyText.indexOf("assistant 9") < bodyText.indexOf("user 12"));
119
+ assert.ok(bodyText.indexOf("user 12") < bodyText.indexOf("system 15"));
120
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
121
+ assert.deepEqual(errors, []);
122
+ await page.close();
123
+ }
124
+ } finally {
125
+ await browser.close();
126
+ await stopProcess(vite);
127
+ }
128
+ });
129
+
25
130
  async function assertResponsiveAssistant(page, viewport) {
26
131
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
27
132
  await page.goto("/");
@@ -86,8 +191,8 @@ test("conversation scrolling preserves history anchors and resets expansion on s
86
191
  await page.goto(`${vite.baseURL}/?controls=1`);
87
192
  const body = page.locator(".assistant-transcript__body");
88
193
  await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
89
- await body.hover();
90
- await page.mouse.wheel(0, -100_000);
194
+ // Keep the explicit button path covered independently of automatic loading.
195
+ await body.evaluate(element => { element.scrollTop = 0; });
91
196
  await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBe(0);
92
197
  await page.getByRole("button", { name: "Read more", exact: true }).click();
93
198
  await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
@@ -114,6 +219,99 @@ test("conversation scrolling preserves history anchors and resets expansion on s
114
219
  }
115
220
  });
116
221
 
222
+ test("upward scrolling loads older history once and preserves retry and selection behavior", {
223
+ skip: !RUN_BROWSER_TEST,
224
+ timeout: 90_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 touch = width < 1000;
231
+ const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
232
+ const errors = [];
233
+ page.on("pageerror", error => errors.push(error.message));
234
+ await page.goto(`${vite.baseURL}/?controls=1&history=1`);
235
+ const body = page.locator(".assistant-transcript__body");
236
+ const requests = page.locator("output");
237
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
238
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
239
+ await expect(requests).toHaveText("History requests: 0");
240
+ // Detach from initial follow before positioning above the history boundary.
241
+ await body.hover();
242
+ await page.mouse.wheel(0, -100);
243
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
244
+ await body.evaluate(element => { element.scrollTop = 420; });
245
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
246
+ await expect(requests).toHaveText("History requests: 0");
247
+ if (touch) {
248
+ const bounds = await body.boundingBox();
249
+ const cdp = await page.context().newCDPSession(page);
250
+ const x = bounds.x + bounds.width / 2;
251
+ const y = bounds.y + 40;
252
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
253
+ for (let offset = 20; offset <= 380; offset += 20) {
254
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
255
+ await page.evaluate(() => new Promise(requestAnimationFrame));
256
+ }
257
+ const stopped = body.evaluate(element => new Promise(resolve => {
258
+ element.addEventListener("scrollend", () => resolve(), { once: true });
259
+ }));
260
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
261
+ await stopped;
262
+ await cdp.detach();
263
+ } else {
264
+ await body.hover();
265
+ await page.mouse.wheel(0, -380);
266
+ }
267
+ const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
268
+ await expect(pending).toBeDisabled();
269
+ await expect(requests).toHaveText("History requests: 1");
270
+ // Repeated events while waiting must not issue another request.
271
+ await body.evaluate(element => {
272
+ element.dispatchEvent(new Event("scroll"));
273
+ element.dispatchEvent(new Event("scroll"));
274
+ });
275
+ const original = page.getByText("Conversation line 1:", { exact: false }).first();
276
+ const originalTop = (await original.boundingBox()).y;
277
+ await external("Complete history load");
278
+ await expect(pending).toHaveCount(0);
279
+ await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
280
+ await expect(requests).toHaveText("History requests: 1");
281
+
282
+ // Keyboard navigation also requests older history.
283
+ await body.focus();
284
+ await body.press("Home");
285
+ await expect(requests).toHaveText("History requests: 2");
286
+ await external("Fail history load");
287
+ await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
288
+ await body.press("ArrowDown");
289
+ await body.press("Home");
290
+ await expect(requests).toHaveText("History requests: 2");
291
+ await page.getByRole("button", { name: "Load older messages", exact: true }).click();
292
+ await expect(requests).toHaveText("History requests: 3");
293
+ await external("Complete history load");
294
+ await expect(page.getByText("History unavailable", { exact: true })).toHaveCount(0);
295
+
296
+ await external("Change conversation");
297
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
298
+ await expect(requests).toHaveText("History requests: 3");
299
+ await external("Exhaust history");
300
+ await body.focus();
301
+ await body.press("Home");
302
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
303
+ await expect(requests).toHaveText("History requests: 3");
304
+ await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
305
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
306
+ assert.deepEqual(errors, []);
307
+ await page.close();
308
+ }
309
+ } finally {
310
+ await browser.close();
311
+ await stopProcess(vite);
312
+ }
313
+ });
314
+
117
315
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
118
316
  skip: !RUN_BROWSER_TEST,
119
317
  timeout: 60_000
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createConversationStreams } from "../src/server/conversation/streams.js";
4
+ import { classifyCodexAppServerEvent } from "../src/server/conversation/codexEvents.js";
5
+ import { openCodeAssistantMessageText } from "../src/server/conversation/openCodeClient.js";
6
+ import { mergeConversationStream } from "../src/shared/conversation/streaming.js";
7
+
8
+ test("Codex chunks retain whitespace and item identity; completed items reject late chunks", () => {
9
+ const streams = createConversationStreams();
10
+ const params = { threadId: "thread", turnId: "turn", itemId: "answer" };
11
+ const started = classifyCodexAppServerEvent({ method: "item/started", params: {
12
+ ...params, item: { id: "answer", type: "agentMessage", phase: "final_answer" }
13
+ } });
14
+ assert.equal(started.kind, "assistant_started");
15
+ streams.update("session", { ...started, messageId: started.itemId });
16
+ for (const delta of ["Hello", " ", "world", "\n\n", "```js\n x()\n``` "]) {
17
+ const event = classifyCodexAppServerEvent({ method: "item/agentMessage/delta", params: { ...params, delta } });
18
+ assert.equal(event.kind, "assistant_delta");
19
+ assert.equal(event.delta, delta);
20
+ streams.update("session", { ...event, messageId: event.itemId });
21
+ }
22
+ const snapshot = streams.read("session");
23
+ assert.equal(snapshot.messages[0].text, "Hello world\n\n```js\n x()\n``` ");
24
+ assert.equal(snapshot.messages[0].status, "inProgress");
25
+ snapshot.messages[0].text = "Mutation must not change the stream";
26
+ assert.match(streams.read("session").messages[0].text, /^Hello world/);
27
+ const completed = streams.complete("session", "answer");
28
+ assert.deepEqual(completed.messages, []);
29
+ assert.equal(streams.update("session", { ...params, messageId: "answer", delta: "late" }), null);
30
+ assert.deepEqual(streams.read("other-session").messages, []);
31
+ });
32
+
33
+ test("OpenCode text snapshots replace partial text and unchanged snapshots emit nothing", () => {
34
+ const streams = createConversationStreams();
35
+ const message = { turnId: "turn", messageId: "answer" };
36
+ const text = openCodeAssistantMessageText({ content: [{ type: "reasoning", text: "private" }, { type: "text", text: "Hello " }] });
37
+ assert.equal(text, "Hello ");
38
+ streams.update("one", { ...message, text });
39
+ assert.equal(streams.update("one", { ...message, text }), null);
40
+ const next = streams.update("one", { ...message, text: "Hello world" });
41
+ assert.equal(next.messages[0].text, "Hello world");
42
+ assert.deepEqual(streams.clear("one").messages, []);
43
+ streams.update("one", { ...message, turnId: "next", text: "New answer" });
44
+ assert.equal(streams.read("one").messages.length, 1);
45
+ });
46
+
47
+ test("commentary keeps its role and saved answers replace live text without mutating history", () => {
48
+ const streams = createConversationStreams();
49
+ const started = classifyCodexAppServerEvent({ method: "item/started", params: {
50
+ threadId: "thread", turnId: "turn", item: { id: "comment", type: "agentMessage", phase: "commentary" }
51
+ } });
52
+ streams.update("one", { ...started, messageId: started.itemId });
53
+ streams.update("one", { turnId: "turn", messageId: "comment", delta: "Checking" });
54
+ assert.equal(streams.read("one").messages[0].role, "commentary");
55
+ streams.complete("one", "comment");
56
+ streams.update("one", { turnId: "turn", messageId: "answer", text: "Partial" });
57
+ const user = { role: "user", messageId: "user", text: "Question" };
58
+ const turns = [{ turnId: "000001", user, messages: [user] }];
59
+ const live = mergeConversationStream(turns, streams.read("one"));
60
+ assert.equal(live[0].turnId, "000001");
61
+ assert.equal(live[0].assistant.text, "Partial");
62
+ assert.equal(live[0].pending, true);
63
+ assert.equal(turns[0].messages.length, 1);
64
+ const final = { role: "assistant", messageId: "answer", text: "Final answer" };
65
+ const saved = [{ ...turns[0], assistant: final, messages: [user, final] }];
66
+ assert.equal(mergeConversationStream(saved, streams.read("one")), saved);
67
+ const withoutMessages = [{ turnId: "000001", user, assistant: final }];
68
+ assert.equal(mergeConversationStream(withoutMessages, streams.read("one")), withoutMessages);
69
+ assert.equal(mergeConversationStream([{ turnId: "000001", user }], streams.read("one"))[0].messages[0], user);
70
+ });