@jskit-ai/assistant-core 0.1.204 → 0.1.205
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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +107 -41
- package/src/client/conversation/AssistantTranscript.vue +15 -2
- package/src/server/conversation/claudeClient.js +2 -1
- package/test/assistantScroll.browser.test.js +182 -8
- package/test/claudeClient.test.js +41 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.205",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.225",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.227",
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.44",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.44"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, reactive, ref } from "vue";
|
|
3
|
+
import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
|
|
3
4
|
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
5
|
import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
|
|
5
6
|
import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
|
|
6
7
|
const phase = ref("idle");
|
|
8
|
+
const previewFixture = new URLSearchParams(location.search).has("preview");
|
|
9
|
+
const temporaryWords = ref("");
|
|
10
|
+
const temporaryPresent = ref(false);
|
|
11
|
+
const temporaryEditing = ref(false);
|
|
12
|
+
const newerWords = ref("");
|
|
13
|
+
const newerPresent = ref(false);
|
|
14
|
+
const newerEditing = ref(false);
|
|
15
|
+
const previewCanTake = ref(true);
|
|
16
|
+
const previewDelivery = createAssistantMessageDelivery();
|
|
7
17
|
const avatarFixture = new URLSearchParams(location.search).has("avatar");
|
|
18
|
+
const avatarToolsFixture = new URLSearchParams(location.search).has("avatarTools");
|
|
8
19
|
const showAvatar = ref(avatarFixture);
|
|
9
20
|
const avatarSize = ref("compact");
|
|
10
21
|
const short = ref(avatarFixture);
|
|
@@ -63,8 +74,57 @@ const goalState = reactive({
|
|
|
63
74
|
resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
|
|
64
75
|
});
|
|
65
76
|
const adapter = reactive({
|
|
77
|
+
delivery: previewFixture ? previewDelivery : undefined,
|
|
66
78
|
conversation: {
|
|
67
79
|
assistantLabel,
|
|
80
|
+
previewMessages: computed(() => {
|
|
81
|
+
const pending = adapter.conversation.previewMessage;
|
|
82
|
+
const previews = pending ? [pending] : [];
|
|
83
|
+
if (newerPresent.value) previews.push({ id: "newer", text: newerWords.value, actions: {
|
|
84
|
+
canDiscard: previewCanTake.value,
|
|
85
|
+
canEdit: previewCanTake.value && !temporaryPresent.value,
|
|
86
|
+
editing: newerEditing.value,
|
|
87
|
+
discard: id => { if (id === "newer" && previewCanTake.value) newerPresent.value = false; },
|
|
88
|
+
edit: id => { if (id === "newer" && previewCanTake.value && !temporaryPresent.value) newerEditing.value = true; },
|
|
89
|
+
update: (id, text) => { if (id === "newer" && newerEditing.value) newerWords.value = text; },
|
|
90
|
+
send: newerEditing.value ? id => {
|
|
91
|
+
if (id !== "newer" || !previewCanTake.value || !newerWords.value.trim()) return;
|
|
92
|
+
adapter.conversation.turns = [...adapter.conversation.turns, { turnId: id,
|
|
93
|
+
user: { messageId: id, role: "user", text: newerWords.value } }];
|
|
94
|
+
newerPresent.value = false;
|
|
95
|
+
} : null,
|
|
96
|
+
canSend: previewCanTake.value && Boolean(newerWords.value.trim()), sending: false
|
|
97
|
+
} });
|
|
98
|
+
return previews;
|
|
99
|
+
}),
|
|
100
|
+
previewMessage: computed(() => temporaryPresent.value ? {
|
|
101
|
+
id: "temporary", text: temporaryWords.value,
|
|
102
|
+
actions: {
|
|
103
|
+
canDiscard: previewCanTake.value,
|
|
104
|
+
canEdit: previewCanTake.value,
|
|
105
|
+
editing: temporaryEditing.value,
|
|
106
|
+
canSend: previewCanTake.value && Boolean(temporaryWords.value.trim()),
|
|
107
|
+
sending: false,
|
|
108
|
+
discard(id) {
|
|
109
|
+
if (id !== "temporary" || !previewCanTake.value) return;
|
|
110
|
+
temporaryPresent.value = false;
|
|
111
|
+
if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
|
|
112
|
+
},
|
|
113
|
+
edit(id) {
|
|
114
|
+
if (id !== "temporary" || !previewCanTake.value) return;
|
|
115
|
+
temporaryEditing.value = true;
|
|
116
|
+
if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
|
|
117
|
+
},
|
|
118
|
+
update(id, text) {
|
|
119
|
+
if (id === "temporary" && previewCanTake.value) temporaryWords.value = text;
|
|
120
|
+
},
|
|
121
|
+
send(id) {
|
|
122
|
+
if (id !== "temporary" || !previewCanTake.value || !temporaryWords.value.trim()) return;
|
|
123
|
+
adapter.conversation.turns = [{ turnId: id, user: { messageId: id, role: "user", text: temporaryWords.value } }];
|
|
124
|
+
temporaryPresent.value = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} : null),
|
|
68
128
|
turns: attribution ? [
|
|
69
129
|
{ turnId: "one", assistantLabel: "First assistant", assistantDetails: "First model",
|
|
70
130
|
assistant: { role: "assistant", text: "First answer" } },
|
|
@@ -76,7 +136,7 @@ const adapter = reactive({
|
|
|
76
136
|
assistant: { messageId: `answer-${index}`, outputId: `output-${index}`, role: "assistant",
|
|
77
137
|
text: `Support answer ${index + 1}: Keep your recovery email available.\n\nRead the **account instructions** and follow the [recovery link](https://example.com/help). A long message wraps inside this narrow conversation without taking space from the composer.` }
|
|
78
138
|
})) : [],
|
|
79
|
-
visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
|
|
139
|
+
visible: true, reloadable: avatarFixture, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
|
|
80
140
|
},
|
|
81
141
|
composer: {
|
|
82
142
|
draft,
|
|
@@ -111,12 +171,21 @@ const adapter = reactive({
|
|
|
111
171
|
<button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
|
|
112
172
|
<button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
|
|
113
173
|
<button @click="goalEnabled = !goalEnabled">Toggle goals</button>
|
|
174
|
+
<template v-if="previewFixture">
|
|
175
|
+
<button @click="temporaryWords = 'Exact temporary words'; temporaryPresent = true; temporaryEditing = false">Add temporary message</button>
|
|
176
|
+
<button @click="newerWords = 'Keep newer live words'; newerPresent = true; newerEditing = false">Add newer temporary message</button>
|
|
177
|
+
<button @click="previewCanTake = !previewCanTake">Toggle preview eligibility</button>
|
|
178
|
+
<button @click="previewDelivery.send({ message: temporaryWords, request: { text: temporaryWords, steer: true } }, { messageId: 'temporary', deliver: async () => ({ ok: false, error: 'No active turn; explicit Edit may author a new message.' }) })">Reject preview</button>
|
|
179
|
+
<button @click="adapter.conversation.turns = [{ turnId: 'temporary', user: { messageId: 'temporary', role: 'user', text: temporaryWords } }]">Accept preview</button>
|
|
180
|
+
<button @click="adapter.conversation.turns = [{ turnId: 'temporary', user: { messageId: 'temporary', role: 'user', text: temporaryWords, receipt: false } }]">Unadmitted preview</button>
|
|
181
|
+
</template>
|
|
114
182
|
<template v-if="avatarFixture">
|
|
115
183
|
<button @click="showAvatar = !showAvatar">Toggle avatar slot</button>
|
|
116
184
|
<button @click="short = !short">Resize height</button>
|
|
117
185
|
<button @click="showQuestions = !showQuestions">Toggle questions</button>
|
|
118
186
|
<button @click="attachments.status.value = 'An upload failed. Retry or remove it.'">Show attachment error</button>
|
|
119
|
-
<
|
|
187
|
+
<select v-model="avatarSize" aria-label="Avatar preference"><option v-for="size in ['hidden', 'compact', 'standard', 'large']" :key="size" :value="size">{{ size }}</option></select>
|
|
188
|
+
<output class="avatar-preference">Avatar requested: {{ avatarSize }}</output>
|
|
120
189
|
</template>
|
|
121
190
|
<output>{{ phase }}; submitted {{ submissions }}</output>
|
|
122
191
|
<output v-if="capabilitiesEnabled">Model: {{ appliedModel }}; sent files: {{ attachmentReceipts.map(item => item.fileName).join(', ') }}</output>
|
|
@@ -124,6 +193,12 @@ const adapter = reactive({
|
|
|
124
193
|
<main class="fixture" :class="{ narrow, 'fixture--avatar': avatarFixture, 'fixture--short': short }">
|
|
125
194
|
<AssistantConversationElement v-model:avatar-size="avatarSize" :adapter="adapter">
|
|
126
195
|
<template v-if="showAvatar" #avatar><svg viewBox="0 0 100 100" role="img" aria-label="Support assistant avatar"><circle cx="50" cy="50" r="45" fill="#6750a4" /><path d="M30 62Q50 80 70 62M35 40h1m28 0h1" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" /></svg></template>
|
|
196
|
+
<template v-if="avatarToolsFixture" #avatar-tools="scope">
|
|
197
|
+
<button class="fixture-avatar-tool" aria-label="Avatar tool" :data-size="scope.size" :data-height="scope.height">Tool</button>
|
|
198
|
+
</template>
|
|
199
|
+
<template v-if="avatarToolsFixture" #avatar-control="scope">
|
|
200
|
+
<button class="fixture-avatar-tool" aria-label="Retained avatar control" :data-size="scope.size">Mic</button>
|
|
201
|
+
</template>
|
|
127
202
|
<template v-if="customActivity" #activity="{ activity }"><strong>Custom activity: {{ activity.label }}</strong></template>
|
|
128
203
|
<template #composer-feedback><p v-if="feedback" class="feedback" role="status">Describe what you want to change.</p></template>
|
|
129
204
|
</AssistantConversationElement>
|
|
@@ -134,9 +209,11 @@ const adapter = reactive({
|
|
|
134
209
|
<style scoped>
|
|
135
210
|
.controls { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
|
136
211
|
.controls button { border: 1px solid; padding: 4px; }
|
|
212
|
+
.avatar-preference { flex: 0 0 14rem; }
|
|
137
213
|
.fixture { height: 70vh; width: min(900px, 100%); margin: auto; }
|
|
138
214
|
.fixture.narrow { width: min(320px, 100%); }
|
|
139
215
|
.fixture--avatar { width: min(320px, 100%); }
|
|
216
|
+
.fixture-avatar-tool { flex: 0 0 48px; min-width: 48px; min-height: 48px; }
|
|
140
217
|
.fixture--short { height: 420px; }
|
|
141
218
|
.feedback { flex: 1 1 24rem; min-width: 0; margin: 0; }
|
|
142
219
|
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.205",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
46
46
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
47
47
|
"@ai-sdk/xai": "^4.0.58",
|
|
48
|
-
"@jskit-ai/resource-core": "0.1.
|
|
49
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
48
|
+
"@jskit-ai/resource-core": "0.1.169",
|
|
49
|
+
"@jskit-ai/resource-crud-core": "0.1.169",
|
|
50
50
|
"@mdi/js": "^7.4.47",
|
|
51
51
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
52
52
|
"ai": "^7.0.101",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"ws": "^8.20.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
59
|
-
"@jskit-ai/kernel": "0.1.
|
|
58
|
+
"@jskit-ai/http-runtime": "0.1.225",
|
|
59
|
+
"@jskit-ai/kernel": "0.1.227",
|
|
60
60
|
"vue": "^3.5.13",
|
|
61
61
|
"vuetify": "^4.0.0"
|
|
62
62
|
},
|
|
@@ -1,32 +1,74 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<section ref="container" class="assistant-conversation" :aria-label="label">
|
|
3
|
-
<section
|
|
4
|
-
|
|
3
|
+
<section
|
|
4
|
+
v-if="$slots.avatar" class="assistant-conversation__avatar"
|
|
5
|
+
:class="{ 'assistant-conversation__avatar--hidden': avatarSize === 'hidden' }" aria-label="Conversation avatar"
|
|
6
|
+
>
|
|
7
|
+
<div v-show="avatarSize === 'hidden'" ref="avatarControls" class="assistant-conversation__avatar-controls">
|
|
8
|
+
<slot name="avatar-control" :size="avatarSize" :height="avatarHeight" />
|
|
5
9
|
<v-btn
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
:icon="mdiAccountCircleOutline" size="small"
|
|
11
|
+
variant="text" :width="44" :min-width="44" :min-height="44"
|
|
12
|
+
class="assistant-conversation__avatar-size"
|
|
13
|
+
aria-label="Show avatar" title="Show avatar"
|
|
14
|
+
:aria-expanded="avatarSize !== 'hidden'" @click="toggleAvatar"
|
|
15
|
+
>
|
|
16
|
+
<span class="assistant-conversation__avatar-size-disc">
|
|
17
|
+
<v-icon size="20" :icon="mdiAccountCircleOutline" />
|
|
18
|
+
</span>
|
|
19
|
+
</v-btn>
|
|
9
20
|
</div>
|
|
10
|
-
<
|
|
11
|
-
<v-
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
<div v-show="avatarSize !== 'hidden'" class="assistant-conversation__avatar-presentation">
|
|
22
|
+
<div v-if="avatarSize !== 'hidden'" class="assistant-conversation__avatar-visual" :style="{ height: `${avatarHeight}px`, width: `${avatarHeight}px` }">
|
|
23
|
+
<slot name="avatar" :size="avatarSize" :height="avatarHeight" />
|
|
24
|
+
</div>
|
|
25
|
+
<div class="assistant-conversation__avatar-tools">
|
|
26
|
+
<slot name="avatar-tools" :size="avatarSize" :height="avatarHeight" />
|
|
27
|
+
<v-btn
|
|
28
|
+
:icon="mdiMinus" size="small" variant="text" color="primary"
|
|
29
|
+
:width="40" :min-width="40" :min-height="44" class="assistant-conversation__avatar-size"
|
|
30
|
+
aria-label="Minimise avatar" title="Minimise avatar" :aria-expanded="true"
|
|
31
|
+
@click="toggleAvatar"
|
|
32
|
+
>
|
|
33
|
+
<span class="assistant-conversation__avatar-size-disc assistant-conversation__avatar-size-disc--minimise">
|
|
34
|
+
<v-icon size="20" :icon="mdiMinus" />
|
|
35
|
+
</span>
|
|
36
|
+
</v-btn>
|
|
37
|
+
</div>
|
|
20
38
|
</div>
|
|
21
39
|
</section>
|
|
22
40
|
<AssistantTranscript
|
|
23
|
-
v-bind="conversation" :working="working" class="assistant-conversation__transcript"
|
|
41
|
+
v-bind="conversation" :preview-message="adapter.conversation.previewMessage || null" :working="working" class="assistant-conversation__transcript"
|
|
24
42
|
@load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
|
|
25
43
|
@resend-turn="resend($event)" @cancel-turn="cancel($event)"
|
|
26
44
|
@check-delivery="adapter.actions?.checkDelivery?.($event)"
|
|
27
45
|
@edit-turn="edit($event)" @link-click="adapter.actions?.openLink?.($event)"
|
|
28
46
|
>
|
|
29
47
|
<template v-for="name in transcriptSlots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
|
|
48
|
+
<template #user-message-actions="scope">
|
|
49
|
+
<div v-if="scope.turn.preview && scope.turn.previewMessage?.actions" class="assistant-conversation__preview-actions">
|
|
50
|
+
<v-btn
|
|
51
|
+
v-if="scope.turn.previewMessage.actions.discard" :icon="mdiClose" size="small" variant="text"
|
|
52
|
+
aria-label="Discard unsent message" title="Discard unsent message"
|
|
53
|
+
:disabled="!scope.turn.previewMessage.actions.canDiscard"
|
|
54
|
+
@click="scope.turn.previewMessage.actions.discard(scope.turn.turnId)"
|
|
55
|
+
/>
|
|
56
|
+
<v-btn
|
|
57
|
+
v-if="scope.turn.previewMessage.actions.edit" :icon="mdiPencil" size="small" variant="text"
|
|
58
|
+
aria-label="Edit unsent message" title="Edit unsent message"
|
|
59
|
+
:disabled="!scope.turn.previewMessage.actions.canEdit"
|
|
60
|
+
@click="scope.turn.previewMessage.actions.edit(scope.turn.turnId)"
|
|
61
|
+
/>
|
|
62
|
+
<v-btn
|
|
63
|
+
v-if="scope.turn.previewMessage.actions.send" size="small" color="primary"
|
|
64
|
+
:disabled="!scope.turn.previewMessage.actions.canSend"
|
|
65
|
+
@click="scope.turn.previewMessage.actions.send(scope.turn.turnId)"
|
|
66
|
+
>
|
|
67
|
+
{{ scope.turn.previewMessage.actions.sending ? 'Sending…' : 'Send' }}
|
|
68
|
+
</v-btn>
|
|
69
|
+
</div>
|
|
70
|
+
<slot name="user-message-actions" v-bind="scope" />
|
|
71
|
+
</template>
|
|
30
72
|
</AssistantTranscript>
|
|
31
73
|
<div v-if="adapter.goal && adapter.goal.enabled !== false" class="assistant-conversation__goal">
|
|
32
74
|
<AssistantGoalControl :state="adapter.goal" />
|
|
@@ -99,10 +141,6 @@
|
|
|
99
141
|
@update:model-value="updateConfiguration({ ...configuration, [field.name]: $event })"
|
|
100
142
|
/>
|
|
101
143
|
</slot>
|
|
102
|
-
<v-btn
|
|
103
|
-
v-if="$slots.avatar" ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
|
|
104
|
-
aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
|
|
105
|
-
/>
|
|
106
144
|
<slot name="composer-tools" :adapter="adapter" />
|
|
107
145
|
<template #feedback>
|
|
108
146
|
<p v-if="attachmentsEnabled && adapter.attachments.status" role="alert" class="assistant-conversation__attachment-error">{{ adapter.attachments.status }}</p>
|
|
@@ -116,7 +154,7 @@
|
|
|
116
154
|
</template>
|
|
117
155
|
<script setup>
|
|
118
156
|
import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId, useSlots, watch } from "vue";
|
|
119
|
-
import { mdiAccountCircleOutline, mdiPaperclip } from "@mdi/js";
|
|
157
|
+
import { mdiAccountCircleOutline, mdiClose, mdiMinus, mdiPaperclip, mdiPencil } from "@mdi/js";
|
|
120
158
|
import AssistantTranscript from "./AssistantTranscript.vue";
|
|
121
159
|
import AssistantPromptInput from "./AssistantPromptInput.vue";
|
|
122
160
|
import AssistantComposerActions from "./AssistantComposerActions.vue";
|
|
@@ -133,17 +171,22 @@ const props = defineProps({
|
|
|
133
171
|
configurationFields: { type: Array, default: () => [] },
|
|
134
172
|
configurationMode: { type: String, default: "hidden", validator: (value) => ["hidden", "readonly", "editable"].includes(value) }
|
|
135
173
|
});
|
|
136
|
-
defineEmits(["update:avatarSize"]);
|
|
174
|
+
const emit = defineEmits(["update:avatarSize"]);
|
|
137
175
|
const slots = useSlots();
|
|
138
176
|
const container = ref(null);
|
|
139
177
|
const avatarControls = ref(null);
|
|
140
|
-
const
|
|
141
|
-
|
|
178
|
+
const visibleAvatarSize = ref("compact");
|
|
179
|
+
watch(() => props.avatarSize, (size) => {
|
|
180
|
+
if (size !== "hidden") visibleAvatarSize.value = size;
|
|
181
|
+
}, { immediate: true });
|
|
182
|
+
function toggleAvatar() {
|
|
183
|
+
emit("update:avatarSize", props.avatarSize === "hidden" ? visibleAvatarSize.value : "hidden");
|
|
184
|
+
}
|
|
142
185
|
const avatarSizes = [
|
|
143
|
-
{
|
|
144
|
-
{
|
|
145
|
-
{
|
|
146
|
-
{
|
|
186
|
+
{ value: "hidden", height: 0 },
|
|
187
|
+
{ value: "compact", height: 96 },
|
|
188
|
+
{ value: "standard", height: 112 },
|
|
189
|
+
{ value: "large", height: 176 }
|
|
147
190
|
];
|
|
148
191
|
const avatarSpace = ref(0);
|
|
149
192
|
const avatarHeight = computed(() => Math.min(avatarSpace.value, avatarSizes.find(size => size.value === props.avatarSize).height));
|
|
@@ -198,8 +241,8 @@ const delivery = ref(null);
|
|
|
198
241
|
const statusId = `assistant-status-${useId()}`;
|
|
199
242
|
const conversation = computed(() => {
|
|
200
243
|
const saved = props.adapter.conversation;
|
|
201
|
-
if (!props.adapter.delivery && !saved.previewMessage && !saved.interimReply) return saved;
|
|
202
|
-
const { previewMessage, interimReply, ...display } = saved;
|
|
244
|
+
if (!props.adapter.delivery && !saved.previewMessage && !saved.previewMessages?.length && !saved.interimReply) return saved;
|
|
245
|
+
const { previewMessage, previewMessages, interimReply, ...display } = saved;
|
|
203
246
|
let turns = props.adapter.delivery ? props.adapter.delivery.turns(saved.turns || []) : saved.turns || [];
|
|
204
247
|
if (interimReply?.text && interimReply.turnId) {
|
|
205
248
|
const assistant = { ...interimReply, messageId: interimReply.id, role: "assistant" };
|
|
@@ -212,13 +255,30 @@ const conversation = computed(() => {
|
|
|
212
255
|
(message.outputId || message.messageId) !== (interimReply.outputId || interimReply.id)).concat(assistant) } : {})
|
|
213
256
|
});
|
|
214
257
|
}
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
258
|
+
// This list projects existing capture/pending owners; it never queues delivery.
|
|
259
|
+
const previews = Array.isArray(previewMessages) ? previewMessages : previewMessage ? [previewMessage] : [];
|
|
260
|
+
for (const preview of previews) {
|
|
261
|
+
const messageId = String(preview?.messageId || preview?.id || "").trim();
|
|
262
|
+
const localDelivery = messageId && props.adapter.delivery?.find?.(messageId);
|
|
263
|
+
if ((!localDelivery || localDelivery.status === "failed") && preview?.actions?.send) {
|
|
264
|
+
// Keep one unadmitted bubble and its real status until explicit Edit/discard.
|
|
265
|
+
// A receipt:false user row is not acknowledgement of the pending words.
|
|
266
|
+
turns = turns.map(turn => {
|
|
267
|
+
const failed = turn.optimistic?.id === messageId && turn.optimistic.status === "failed";
|
|
268
|
+
const unadmitted = turn.user?.receipt === false && String(turn.user.messageId || turn.user.id || "") === messageId;
|
|
269
|
+
if (!failed && !unadmitted) return turn;
|
|
270
|
+
return { ...turn, preview: true, previewMessage: preview, user: { ...turn.user, text: String(preview.text || "") } };
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const alreadyShown = messageId && (localDelivery || turns.some(turn => [
|
|
274
|
+
turn.system, turn.user, turn.assistant, ...(turn.thinking || []), ...(turn.commentary || []), ...(turn.messages || [])
|
|
275
|
+
].some(message => String(message?.messageId || message?.id || "") === messageId &&
|
|
276
|
+
(message.receipt !== false || turn.preview))));
|
|
277
|
+
if (messageId && (preview?.text || preview?.actions?.editing) && !alreadyShown) {
|
|
278
|
+
const user = { messageId, role: "user", text: String(preview.text || "") };
|
|
279
|
+
turns = [...turns, { turnId: messageId, user, messages: [user], preview: true, previewMessage: preview,
|
|
280
|
+
optimistic: { id: messageId, status: "pending" } }];
|
|
281
|
+
}
|
|
222
282
|
}
|
|
223
283
|
// Transcription and interim replies are display only. The receipt watcher
|
|
224
284
|
// receives canonical turns, never these presentation or delivery overlays.
|
|
@@ -327,10 +387,16 @@ function updateConfiguration(value) {
|
|
|
327
387
|
defineExpose({ focus: () => input.value?.focus(), submit, stop });
|
|
328
388
|
</script>
|
|
329
389
|
<style scoped>
|
|
330
|
-
.assistant-
|
|
331
|
-
.assistant-
|
|
332
|
-
.assistant-conversation__avatar
|
|
333
|
-
.assistant-conversation__avatar-
|
|
390
|
+
.assistant-conversation__preview-actions { display: flex; justify-content: flex-end; }
|
|
391
|
+
.assistant-conversation { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
|
|
392
|
+
.assistant-conversation__avatar { position: absolute; top: 0; right: 0; z-index: 3; max-width: 100%; min-width: 0; }
|
|
393
|
+
.assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
|
|
394
|
+
.assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
|
|
395
|
+
.assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
|
|
396
|
+
.assistant-conversation__avatar-size-disc { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(var(--v-theme-on-surface), .08); }
|
|
397
|
+
.assistant-conversation__avatar-size-disc--minimise { background: rgba(var(--v-theme-primary), .12); }
|
|
398
|
+
.assistant-conversation__avatar-presentation { display: flex; flex-direction: column; align-items: center; min-width: 0; }
|
|
399
|
+
.assistant-conversation__avatar-tools { display: flex; align-items: center; gap: 0; }
|
|
334
400
|
.assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; }
|
|
335
401
|
.assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
|
|
336
402
|
.assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
|
|
@@ -136,7 +136,13 @@
|
|
|
136
136
|
<div
|
|
137
137
|
class="assistant-transcript__user-content"
|
|
138
138
|
>
|
|
139
|
-
<
|
|
139
|
+
<v-textarea
|
|
140
|
+
v-if="entry.turn.preview && previewForTurn(entry.turn)?.actions?.editing"
|
|
141
|
+
class="assistant-transcript__preview-editor" :model-value="previewForTurn(entry.turn).text" label="Review your message"
|
|
142
|
+
:disabled="!previewForTurn(entry.turn).actions.canEdit" density="compact" :rows="1" :max-rows="2"
|
|
143
|
+
auto-grow hide-details @update:model-value="previewForTurn(entry.turn).actions.update(entry.turn.turnId, $event)"
|
|
144
|
+
/>
|
|
145
|
+
<p v-else-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
|
|
140
146
|
<LongTextPreviewBlocks
|
|
141
147
|
v-else
|
|
142
148
|
:blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
|
|
@@ -160,12 +166,13 @@
|
|
|
160
166
|
<time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
|
|
161
167
|
<span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
|
|
162
168
|
</div>
|
|
169
|
+
<slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
|
|
163
170
|
<div
|
|
164
171
|
v-if="entry.turn.optimistic?.status === 'failed'"
|
|
165
172
|
class="assistant-transcript__optimistic-failure"
|
|
166
173
|
>
|
|
167
174
|
<span role="status">Failed: {{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
|
|
168
|
-
<div class="assistant-transcript__optimistic-actions">
|
|
175
|
+
<div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
|
|
169
176
|
<v-btn
|
|
170
177
|
class="assistant-transcript__delivery-action"
|
|
171
178
|
color="primary"
|
|
@@ -311,8 +318,14 @@ import {
|
|
|
311
318
|
normalizeThinkingMessageText
|
|
312
319
|
} from "../../shared/conversation/thinkingText.js";
|
|
313
320
|
|
|
321
|
+
function previewForTurn(turn) {
|
|
322
|
+
const preview = turn.previewMessage || props.previewMessage;
|
|
323
|
+
return (preview?.messageId || preview?.id) === turn.turnId ? preview : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
314
326
|
const props = defineProps({
|
|
315
327
|
systemLabel: { type: String, default: "Status" },
|
|
328
|
+
previewMessage: { type: Object, default: null },
|
|
316
329
|
working: { type: Boolean, default: undefined },
|
|
317
330
|
progressPreviewLimit: { type: Number, default: 2 },
|
|
318
331
|
userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
|
|
@@ -156,7 +156,8 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
|
|
|
156
156
|
|
|
157
157
|
const completion = (async () => {
|
|
158
158
|
try {
|
|
159
|
-
|
|
159
|
+
// Teardown belongs to this client after queued events finish, not the iterator.
|
|
160
|
+
for await (const frame of readClaudeJsonFrames(stream.iterator({ destroyOnReturn: false }))) {
|
|
160
161
|
if (frame.type === "control_response") {
|
|
161
162
|
const response = frame.response;
|
|
162
163
|
const pending = requests.get(response?.request_id);
|
|
@@ -630,17 +630,21 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
630
630
|
try {
|
|
631
631
|
for (const width of [390, 800, 1365]) {
|
|
632
632
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
633
|
-
await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&capabilities=1`);
|
|
633
|
+
await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&capabilities=1`);
|
|
634
634
|
const panel = page.locator(".fixture");
|
|
635
635
|
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
636
636
|
const body = page.locator(".assistant-transcript__body");
|
|
637
637
|
const artwork = page.locator(".assistant-conversation__avatar-visual");
|
|
638
|
-
const sizeControl = page.getByRole("button", { name:
|
|
638
|
+
const sizeControl = page.getByRole("button", { name: /^(Show|Minimise) avatar$/u });
|
|
639
|
+
await expect(page.getByRole("button", { name: "Avatar size", exact: true })).toHaveCount(0);
|
|
640
|
+
const avatarTool = page.getByRole("button", { name: "Avatar tool", exact: true, includeHidden: true });
|
|
641
|
+
await expect(sizeControl).toHaveCount(1);
|
|
642
|
+
assert.equal(await sizeControl.evaluate(element => Boolean(element.closest(".assistant-conversation__composer"))), false,
|
|
643
|
+
"Avatar visibility stays outside the original composer");
|
|
644
|
+
assert.equal(await avatarTool.evaluate(element => Boolean(element.closest(".assistant-conversation__avatar"))), true,
|
|
645
|
+
"Avatar tools share the artwork region outside the composer");
|
|
639
646
|
const selectSize = async name => {
|
|
640
|
-
await
|
|
641
|
-
await sizeControl.press("Enter");
|
|
642
|
-
await page.getByRole("menuitem", { name, exact: true }).click();
|
|
643
|
-
await expect(page.getByRole("menu", { name: "Avatar size", exact: true })).toBeHidden();
|
|
647
|
+
await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption(name.toLowerCase());
|
|
644
648
|
await expect(page.getByText(`Avatar requested: ${name.toLowerCase()}`, { exact: true })).toBeVisible();
|
|
645
649
|
};
|
|
646
650
|
const assertComposerFits = async () => {
|
|
@@ -648,23 +652,51 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
648
652
|
const send = await page.getByRole("button", { name: "Send", exact: true }).boundingBox();
|
|
649
653
|
const feedback = await page.getByText("Describe what you want to change.", { exact: true }).boundingBox();
|
|
650
654
|
const avatarButton = await sizeControl.boundingBox();
|
|
655
|
+
const toolBounds = await avatarTool.boundingBox();
|
|
651
656
|
assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
|
|
652
657
|
assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
|
|
653
658
|
assert.ok((await input.boundingBox()).height >= 40);
|
|
654
|
-
|
|
659
|
+
const avatarExpanded = await sizeControl.getAttribute("aria-expanded") === "true";
|
|
660
|
+
assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
|
|
661
|
+
assert.ok(avatarButton.height >= 44, "Avatar visibility retains its original target height");
|
|
662
|
+
assert.ok(toolBounds.width >= 48 && toolBounds.height >= 48, "Avatar tools retain a reachable touch target");
|
|
655
663
|
await expect.poll(async () => (await body.boundingBox()).height, { message: "The short container retains readable history" }).toBeGreaterThanOrEqual(100);
|
|
656
664
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
657
665
|
};
|
|
658
666
|
assert.equal(Math.round((await panel.boundingBox()).width), 320);
|
|
659
667
|
assert.equal(Math.round((await panel.boundingBox()).height), 420);
|
|
660
|
-
await expect(artwork).
|
|
668
|
+
await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(64);
|
|
669
|
+
assert.ok((await artwork.boundingBox()).height <= 96, "Compact artwork preserves the existing short-container clamp");
|
|
670
|
+
await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
|
|
671
|
+
await expect(artwork).toHaveCSS("height", "96px");
|
|
672
|
+
await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
|
|
673
|
+
await expect.poll(async () => (await panel.boundingBox()).height).toBe(420);
|
|
674
|
+
await expect(page.locator(".assistant-conversation__avatar")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
675
|
+
const chatBounds = await page.locator(".assistant-conversation").boundingBox();
|
|
676
|
+
const faceBounds = await artwork.boundingBox();
|
|
677
|
+
const hideBounds = await sizeControl.boundingBox();
|
|
678
|
+
assert.equal(faceBounds.y, chatBounds.y, "The face starts at the top of the chat without a controls row above it");
|
|
679
|
+
const avatarToolBounds = await avatarTool.boundingBox();
|
|
680
|
+
assert.ok(Math.abs(hideBounds.y + hideBounds.height / 2 - avatarToolBounds.y - avatarToolBounds.height / 2) < 1,
|
|
681
|
+
"The round minimise control shares the tools row below the face");
|
|
682
|
+
assert.equal(hideBounds.x, avatarToolBounds.x + avatarToolBounds.width, "Minimise directly follows the supplied tools without a gap");
|
|
683
|
+
await expect(sizeControl).toHaveText("");
|
|
684
|
+
await expect(sizeControl.locator('.assistant-conversation__avatar-size-disc')).toHaveCSS('border-radius', '50%');
|
|
661
685
|
await expect(page.locator('[data-message-role="assistant"]')).toHaveCount(20);
|
|
662
686
|
await page.evaluate(() => {
|
|
663
687
|
window.avatarElements = [document.querySelector(".assistant-conversation"), document.querySelector("textarea"), document.querySelector(".assistant-transcript__body")];
|
|
688
|
+
window.avatarTool = document.querySelector(".assistant-conversation__avatar-presentation .fixture-avatar-tool");
|
|
664
689
|
});
|
|
665
690
|
await input.fill("Keep my typed support question.");
|
|
666
691
|
await input.evaluate(element => element.setSelectionRange(5, 9));
|
|
692
|
+
const compactAvatarBounds = await artwork.boundingBox();
|
|
693
|
+
const composerBounds = [await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()];
|
|
694
|
+
const fixtureLayout = await panel.evaluate(element => ({
|
|
695
|
+
panel: element.getBoundingClientRect().toJSON(),
|
|
696
|
+
controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
|
|
697
|
+
}));
|
|
667
698
|
await body.evaluate(element => { element.scrollTop = 100; });
|
|
699
|
+
assert.deepEqual(await artwork.boundingBox(), compactAvatarBounds, "The avatar stays fixed within the chat while messages scroll behind it");
|
|
668
700
|
const before = await body.evaluate(element => element.scrollTop);
|
|
669
701
|
await selectSize("Large");
|
|
670
702
|
await expect.poll(async () => (await artwork.boundingBox()).height).toBeLessThan(176);
|
|
@@ -673,7 +705,35 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
673
705
|
await selectSize("Hidden");
|
|
674
706
|
await expect(artwork).toHaveCount(0);
|
|
675
707
|
await expect(sizeControl).toBeVisible();
|
|
708
|
+
const showBounds = await sizeControl.boundingBox();
|
|
709
|
+
assert.equal(showBounds.width, 44, "Collapsed Show retains its original wider target");
|
|
710
|
+
assert.ok(showBounds.height >= 44);
|
|
711
|
+
const collapsedControls = await page.locator(".assistant-conversation__avatar-controls").boundingBox();
|
|
712
|
+
assert.equal(collapsedControls.y, chatBounds.y, "The collapsed controls stay at the chat top");
|
|
713
|
+
assert.equal(showBounds.y + showBounds.height / 2, collapsedControls.y + collapsedControls.height / 2,
|
|
714
|
+
"Show aligns with the existing taller host control in the top row");
|
|
715
|
+
assert.equal(showBounds.x + showBounds.width, chatBounds.x + chatBounds.width, "Show stays at the chat right edge");
|
|
716
|
+
await expect(avatarTool).toHaveCount(1);
|
|
717
|
+
await expect(avatarTool).toBeHidden();
|
|
718
|
+
await expect(page.getByRole("button", { name: "Retained avatar control", exact: true })).toBeVisible();
|
|
719
|
+
assert.equal(await page.evaluate(() => window.avatarTool.isConnected), true, "Hiding retains the local tools target");
|
|
720
|
+
if (process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS) {
|
|
721
|
+
await page.screenshot({ path: path.join(process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS, `hidden-${width}.png`) });
|
|
722
|
+
}
|
|
723
|
+
assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
|
|
724
|
+
`Collapsing changes no original input or Send rectangle: ${JSON.stringify({ before: fixtureLayout, after: await panel.evaluate(element => ({
|
|
725
|
+
panel: element.getBoundingClientRect().toJSON(), controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
|
|
726
|
+
})) })}`);
|
|
727
|
+
await page.getByRole("button", { name: "Show avatar", exact: true }).click();
|
|
728
|
+
await expect(page.getByText("Avatar requested: large", { exact: true })).toBeVisible();
|
|
729
|
+
await expect(avatarTool).toBeVisible();
|
|
730
|
+
assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
|
|
731
|
+
"Showing the last visible size changes no original input or Send rectangle");
|
|
732
|
+
await page.getByRole("button", { name: "Minimise avatar", exact: true }).click();
|
|
676
733
|
await selectSize("Standard");
|
|
734
|
+
await expect(avatarTool).toBeVisible();
|
|
735
|
+
await expect(avatarTool).toHaveAttribute("data-size", "standard");
|
|
736
|
+
assert.equal(await avatarTool.evaluate(element => element === window.avatarTool), true, "Showing reuses the original tools node");
|
|
677
737
|
await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(0);
|
|
678
738
|
await selectSize("Large");
|
|
679
739
|
await input.focus();
|
|
@@ -721,3 +781,117 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
721
781
|
await stopProcess(vite);
|
|
722
782
|
}
|
|
723
783
|
});
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
test("unsent preview edits stay in their bubble and disappear for the canonical message", {
|
|
787
|
+
skip: !RUN_BROWSER_TEST, timeout: 60_000
|
|
788
|
+
}, async () => {
|
|
789
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
790
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
791
|
+
try {
|
|
792
|
+
const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
|
|
793
|
+
await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
|
|
794
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
795
|
+
const discard = page.getByRole("button", { name: "Discard unsent message", exact: true });
|
|
796
|
+
const edit = page.getByRole("button", { name: "Edit unsent message", exact: true });
|
|
797
|
+
await page.getByRole("button", { name: "External active", exact: true }).click();
|
|
798
|
+
await input.fill("Keep this typed draft");
|
|
799
|
+
await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
|
|
800
|
+
const bubble = page.locator(".assistant-transcript__message--user");
|
|
801
|
+
await expect(bubble).toContainText("Exact temporary words");
|
|
802
|
+
await expect(bubble.getByRole("button", { name: "Discard unsent message" })).toBeVisible();
|
|
803
|
+
await expect(edit).toBeEnabled();
|
|
804
|
+
await expect(discard).toBeEnabled();
|
|
805
|
+
await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
|
|
806
|
+
await expect(discard).toBeDisabled();
|
|
807
|
+
await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
|
|
808
|
+
await discard.click();
|
|
809
|
+
await expect(bubble).toHaveCount(0);
|
|
810
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
811
|
+
await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
|
|
812
|
+
await page.getByRole("button", { name: "Reject preview", exact: true }).click();
|
|
813
|
+
await expect(bubble).toHaveCount(1);
|
|
814
|
+
await expect(bubble).toContainText("No active turn; explicit Edit may author a new message.");
|
|
815
|
+
await expect(bubble.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
|
|
816
|
+
await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
|
|
817
|
+
await expect(edit).toBeEnabled();
|
|
818
|
+
await edit.click();
|
|
819
|
+
await expect(bubble).not.toContainText("No active turn; explicit Edit may author a new message.");
|
|
820
|
+
const editor = bubble.getByRole("textbox", { name: "Review your message" });
|
|
821
|
+
const send = bubble.getByRole("button", { name: "Send", exact: true });
|
|
822
|
+
await expect(editor).toHaveValue("Exact temporary words");
|
|
823
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
824
|
+
await expect(page.getByRole("textbox")).toHaveCount(2);
|
|
825
|
+
await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
|
|
826
|
+
await editor.fill("");
|
|
827
|
+
await expect(editor).toHaveValue("");
|
|
828
|
+
await expect(bubble).toHaveCount(1);
|
|
829
|
+
await expect(send).toBeDisabled();
|
|
830
|
+
await editor.fill("Corrected speech\nwith a second line");
|
|
831
|
+
await expect(send).toBeEnabled();
|
|
832
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
833
|
+
await send.click();
|
|
834
|
+
await expect(bubble).toContainText("Corrected speech");
|
|
835
|
+
await expect(editor).toHaveCount(0);
|
|
836
|
+
await expect(discard).toHaveCount(0);
|
|
837
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
838
|
+
await page.reload();
|
|
839
|
+
await input.fill("Keep this typed draft");
|
|
840
|
+
await page.getByRole("button", { name: "External active", exact: true }).click();
|
|
841
|
+
await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
|
|
842
|
+
await page.getByRole("button", { name: "Unadmitted preview" }).click();
|
|
843
|
+
await expect(bubble).toHaveCount(1);
|
|
844
|
+
await edit.click();
|
|
845
|
+
await expect(bubble.getByRole("textbox", { name: "Review your message" })).toHaveValue("Exact temporary words");
|
|
846
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
847
|
+
await page.getByRole("button", { name: "Accept preview" }).click();
|
|
848
|
+
await expect(bubble).toHaveCount(1);
|
|
849
|
+
await expect(bubble).toContainText("Exact temporary words");
|
|
850
|
+
await expect(discard).toHaveCount(0);
|
|
851
|
+
await expect(edit).toHaveCount(0);
|
|
852
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
853
|
+
await expect(page.locator("output").last()).toHaveText("active; submitted 0");
|
|
854
|
+
for (const recovery of ["edit", "discard"]) {
|
|
855
|
+
await page.reload();
|
|
856
|
+
await input.fill("Keep this typed draft");
|
|
857
|
+
await page.getByRole("button", { name: "External active", exact: true }).click();
|
|
858
|
+
await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
|
|
859
|
+
await page.getByRole("button", { name: "Reject preview", exact: true }).click();
|
|
860
|
+
await page.getByRole("button", { name: "Add newer temporary message", exact: true }).click();
|
|
861
|
+
await expect(bubble).toHaveCount(2);
|
|
862
|
+
const retained = bubble.first();
|
|
863
|
+
const newer = bubble.last();
|
|
864
|
+
await expect(retained).toContainText("No active turn; explicit Edit may author a new message.");
|
|
865
|
+
await expect(retained.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
|
|
866
|
+
await expect(newer).toContainText("Keep newer live words");
|
|
867
|
+
await expect(newer.getByRole("button", { name: "Edit unsent message", exact: true })).toBeDisabled();
|
|
868
|
+
await expect(newer.getByRole("button", { name: "Discard unsent message", exact: true })).toBeEnabled();
|
|
869
|
+
if (recovery === "edit") {
|
|
870
|
+
await retained.getByRole("button", { name: "Edit unsent message", exact: true }).click();
|
|
871
|
+
await expect(newer).toContainText("Keep newer live words");
|
|
872
|
+
const retainedEditor = retained.getByRole("textbox", { name: "Review your message" });
|
|
873
|
+
await expect(retainedEditor).toHaveValue("Exact temporary words");
|
|
874
|
+
await retainedEditor.fill("Edited retained words");
|
|
875
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
876
|
+
await retained.getByRole("button", { name: "Send", exact: true }).click();
|
|
877
|
+
await expect(retained).toContainText("Edited retained words");
|
|
878
|
+
await expect(retained.getByRole("button", { name: "Edit unsent message", exact: true })).toHaveCount(0);
|
|
879
|
+
await expect(bubble).toHaveCount(2);
|
|
880
|
+
await expect(newer).toContainText("Keep newer live words");
|
|
881
|
+
} else {
|
|
882
|
+
await retained.getByRole("button", { name: "Discard unsent message", exact: true }).click();
|
|
883
|
+
await expect(bubble).toHaveCount(1);
|
|
884
|
+
await expect(bubble).toContainText("Keep newer live words");
|
|
885
|
+
}
|
|
886
|
+
const current = bubble.last();
|
|
887
|
+
await expect(current.getByRole("button", { name: "Edit unsent message", exact: true })).toBeEnabled();
|
|
888
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
889
|
+
await current.getByRole("button", { name: "Discard unsent message", exact: true }).click();
|
|
890
|
+
await expect(bubble).toHaveCount(recovery === "edit" ? 1 : 0);
|
|
891
|
+
await expect(input).toHaveValue("Keep this typed draft");
|
|
892
|
+
}
|
|
893
|
+
} finally {
|
|
894
|
+
await browser.close();
|
|
895
|
+
await stopProcess(vite);
|
|
896
|
+
}
|
|
897
|
+
});
|
|
@@ -79,6 +79,47 @@ test("Claude control replies bypass slow event persistence while events remain o
|
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test("normal Claude EOF drains held events before closing the half-open stream", async () => {
|
|
83
|
+
const entered = Promise.withResolvers();
|
|
84
|
+
const release = Promise.withResolvers();
|
|
85
|
+
const ended = Promise.withResolvers();
|
|
86
|
+
const observed = [];
|
|
87
|
+
const failures = [];
|
|
88
|
+
const stream = fakeStream(() => {});
|
|
89
|
+
stream.once("end", ended.resolve);
|
|
90
|
+
const client = createClaudeJsonClient({
|
|
91
|
+
stream,
|
|
92
|
+
async onEvent(frame) {
|
|
93
|
+
if (frame.number === 1) {
|
|
94
|
+
entered.resolve();
|
|
95
|
+
await release.promise;
|
|
96
|
+
}
|
|
97
|
+
observed.push(frame.number);
|
|
98
|
+
},
|
|
99
|
+
onFailure(error) { failures.push(error); }
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
stream.push('{"type":"event","number":1}\n{"type":"event","number":2}\n');
|
|
103
|
+
stream.push(null);
|
|
104
|
+
await entered.promise;
|
|
105
|
+
await ended.promise;
|
|
106
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
107
|
+
assert.equal(stream.writableEnded, false, "EOF does not close the original writable side");
|
|
108
|
+
assert.equal(stream.destroyed, false, "The stream survives while event persistence is held");
|
|
109
|
+
assert.deepEqual(observed, []);
|
|
110
|
+
assert.deepEqual(failures, []);
|
|
111
|
+
release.resolve();
|
|
112
|
+
await client.completion;
|
|
113
|
+
assert.deepEqual(observed, [1, 2]);
|
|
114
|
+
assert.equal(failures.length, 1);
|
|
115
|
+
assert.equal(failures[0].code, "assistant_claude_observation_lost");
|
|
116
|
+
assert.equal(stream.destroyed, true, "The original client still owns final teardown");
|
|
117
|
+
} finally {
|
|
118
|
+
release.resolve();
|
|
119
|
+
client.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
82
123
|
test("Claude interrupt uses the common 30-second control deadline", async (t) => {
|
|
83
124
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
84
125
|
const client = createClaudeJsonClient({ stream: fakeStream(() => {}) });
|