@jskit-ai/assistant-core 0.1.204 → 0.1.206

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.
Files changed (43) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
  4. package/package.json +19 -9
  5. package/src/client/conversation/AssistantConversationElement.vue +107 -41
  6. package/src/client/conversation/AssistantTranscript.vue +192 -18
  7. package/src/server/conversation/claudeClient.js +2 -1
  8. package/src/server/conversation/claudeTurn.js +16 -7
  9. package/src/server/conversation/codexConversations.js +94 -0
  10. package/src/server/conversation/codexProvider.js +15 -5
  11. package/src/server/conversation/codexProviderOwner.js +70 -47
  12. package/src/server/conversation/codexProviderSelection.js +5 -2
  13. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  14. package/src/server/conversation/codexTurn.js +10 -1
  15. package/src/server/conversation/codexTurnControl.js +1 -1
  16. package/src/server/conversation/codexTurnRecovery.js +21 -8
  17. package/src/server/conversation/openCodeRuntime.js +107 -64
  18. package/src/server/conversation/openCodeTurn.js +33 -16
  19. package/src/server/conversation/providers/api.js +1 -0
  20. package/src/server/conversation/providers/claudeDriver.js +13 -0
  21. package/src/server/conversation/providers/codex.js +90 -47
  22. package/src/server/conversation/providers/codexDriver.js +4 -1
  23. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  24. package/src/server/conversation/runtime.js +32 -5
  25. package/src/server/hosts/codexEvents.js +15 -0
  26. package/src/server/hosts/codexTurn.js +5 -0
  27. package/src/server/hosts/openCodeClient.js +4 -0
  28. package/src/server/lib/aiConnectionClient.js +9 -11
  29. package/test/aiConnectionClient.test.js +209 -0
  30. package/test/assistantScroll.browser.test.js +437 -9
  31. package/test/claudeClient.test.js +41 -0
  32. package/test/claudeConversationSession.test.js +78 -0
  33. package/test/claudeTurn.test.js +18 -0
  34. package/test/codexConversationSession.test.js +318 -0
  35. package/test/codexTurn.test.js +203 -2
  36. package/test/conversationConfiguration.test.js +27 -0
  37. package/test/conversationReplacement.test.js +76 -1
  38. package/test/conversationRuntime.test.js +173 -4
  39. package/test/conversationSession.test.js +139 -0
  40. package/test/conversationTools.test.js +17 -3
  41. package/test/opencodeConversationSession.test.js +200 -1
  42. package/test/packageMetadata.test.js +75 -0
  43. package/test/serviceToolCatalog.test.js +31 -0
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.204",
12
- "@jskit-ai/http-runtime": "0.1.224",
13
- "@jskit-ai/kernel": "0.1.226",
11
+ "@jskit-ai/assistant-core": "0.1.206",
12
+ "@jskit-ai/http-runtime": "0.1.226",
13
+ "@jskit-ai/kernel": "0.1.228",
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.43",
18
- "@jskit-ai/connectors-catalog": "0.1.43"
17
+ "@jskit-ai/connectors-core": "0.1.45",
18
+ "@jskit-ai/connectors-catalog": "0.1.45"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -4,8 +4,12 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
4
4
  import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
5
5
  import { mergeConversationStream } from "../../src/shared/conversation/streaming.js";
6
6
  const draft = ref("");
7
- const visible = ref(true);
7
+ const error = ref("");
8
+ const reloads = ref(0);
8
9
  const query = new URLSearchParams(location.search);
10
+ const visible = ref(!query.has("initial-hidden"));
11
+ const retainWhenHidden = query.has("retain-hidden");
12
+ const mounted = ref(true);
9
13
  const controls = query.has("controls");
10
14
  const deferredHistory = query.has("history");
11
15
  const pagedHistory = query.has("paged-history");
@@ -52,12 +56,16 @@ function finishHistory(error = "") {
52
56
  const adapter = computed(() => ({
53
57
  conversation: {
54
58
  visible: visible.value,
59
+ retainWhenHidden,
60
+ error: error.value,
61
+ errorReloadable: true,
55
62
  turns: mergeConversationStream(conversationTurnsFromMessages(messages.value), { messages: [liveMessage.value] }), scrollKey: scope.value,
56
63
  hasMoreBefore: hasMoreBefore.value, loadingMore: loadingMore.value, loadMoreError: loadMoreError.value
57
64
  },
58
65
  composer: { draft: draft.value, canSend: Boolean(draft.value.trim()), rows: 2 },
59
66
  actions: {
60
67
  setDraft(value) { draft.value = value; },
68
+ reload() { reloads.value += 1; error.value = ""; },
61
69
  submit() { draft.value = ""; },
62
70
  loadMore({ complete }) {
63
71
  historyRequests.value += 1;
@@ -81,6 +89,8 @@ const adapter = computed(() => ({
81
89
  <button @click="visible = !visible">Toggle visibility</button>
82
90
  <button @click="scope = scope === 'one' ? 'two' : 'one'">Change conversation</button>
83
91
  <button @click="messages.push({ id: `added-${messages.length}`, role: 'assistant', text: 'New reply. '.repeat(30), status: 'completed' })">Append reply</button>
92
+ <button v-if="query.has('update-errors')" @click="error = 'Conversation updates are temporarily unavailable. Reload to reconnect; the assistant may still be working.'">Show update error</button>
93
+ <button v-if="retainWhenHidden" @click="mounted = !mounted">Toggle mount</button>
84
94
  </nav>
85
95
  <nav v-if="deferredHistory">
86
96
  <button :disabled="!loadingMore" @click="finishHistory()">Complete history load</button>
@@ -88,7 +98,7 @@ const adapter = computed(() => ({
88
98
  <button @click="hasMoreBefore = false">Exhaust history</button>
89
99
  <output>History requests: {{ historyRequests }}</output>
90
100
  </nav>
91
- <AssistantConversationElement v-show="visible" :adapter="adapter" class="assistant-responsive-fixture__conversation" />
101
+ <AssistantConversationElement v-if="mounted" v-show="visible" :adapter="adapter" :data-error-reloads="reloads" class="assistant-responsive-fixture__conversation" />
92
102
  </main>
93
103
  </v-main>
94
104
  </v-app>
@@ -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
- <output>Avatar requested: {{ avatarSize }}</output>
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.204",
3
+ "version": "0.1.206",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -32,7 +32,9 @@
32
32
  "./server/codex-provider": "./src/server/hosts/codexProvider.js",
33
33
  "./server/native-history": "./src/server/hosts/nativeHistory.js",
34
34
  "./testing/native-codex": "./src/testing/nativeCodex.js",
35
- "./testing/native-opencode": "./src/testing/nativeOpenCode.js"
35
+ "./testing/native-opencode": "./src/testing/nativeOpenCode.js",
36
+ "./server/codex-client": "./src/server/conversation/codexClient.js",
37
+ "./client/conversation-submit": "./src/client/conversation/submitText.js"
36
38
  },
37
39
  "dependencies": {
38
40
  "@ai-sdk/anthropic": "^4.0.53",
@@ -45,8 +47,8 @@
45
47
  "@ai-sdk/perplexity": "^4.0.43",
46
48
  "@ai-sdk/togetherai": "^3.0.49",
47
49
  "@ai-sdk/xai": "^4.0.58",
48
- "@jskit-ai/resource-core": "0.1.168",
49
- "@jskit-ai/resource-crud-core": "0.1.168",
50
+ "@jskit-ai/resource-core": "0.1.170",
51
+ "@jskit-ai/resource-crud-core": "0.1.170",
50
52
  "@mdi/js": "^7.4.47",
51
53
  "@openrouter/ai-sdk-provider": "^3.0.0",
52
54
  "ai": "^7.0.101",
@@ -55,8 +57,8 @@
55
57
  "ws": "^8.20.1"
56
58
  },
57
59
  "peerDependencies": {
58
- "@jskit-ai/http-runtime": "0.1.224",
59
- "@jskit-ai/kernel": "0.1.226",
60
+ "@jskit-ai/http-runtime": "0.1.226",
61
+ "@jskit-ai/kernel": "0.1.228",
60
62
  "vue": "^3.5.13",
61
63
  "vuetify": "^4.0.0"
62
64
  },
@@ -126,15 +128,15 @@
126
128
  },
127
129
  {
128
130
  "subpath": "./server/codex-events",
129
- "summary": "Advanced native host: Codex event identities, thread-history projections and context-refresh facts for native hosts."
131
+ "summary": "Advanced native host: Original Codex event classification, text, status and identity helpers, thread-history projections and context-refresh facts."
130
132
  },
131
133
  {
132
134
  "subpath": "./server/codex-turn",
133
- "summary": "Advanced native host: Original Codex run, receipt, observation, goal, steering, control, scoped-turn and renewal coordination."
135
+ "summary": "Advanced native host: Original detached-turn watcher and status predicates, plus Codex run, receipt, observation, goal, steering, control, scoped-turn and renewal coordination."
134
136
  },
135
137
  {
136
138
  "subpath": "./server/opencode-client",
137
- "summary": "Advanced native host: OpenCode native history retirement for existing hosts."
139
+ "summary": "Advanced native host: Original OpenCode loopback client, bounded response and assistant text helpers, plus native history retirement."
138
140
  },
139
141
  {
140
142
  "subpath": "./server/claude-history",
@@ -187,6 +189,14 @@
187
189
  {
188
190
  "subpath": "./testing/native-opencode",
189
191
  "summary": "Testing: Original OpenCode loopback client for isolated host integration tests; tests own endpoints, authorization and cleanup."
192
+ },
193
+ {
194
+ "subpath": "./server/codex-client",
195
+ "summary": "Advanced native host: Original Codex JSON-RPC client and endpoint socket-path helper; hosts retain authorization, transport limits and native lifecycle coordination."
196
+ },
197
+ {
198
+ "subpath": "./client/conversation-submit",
199
+ "summary": "Optional UI: Original text submission helper forwards companion text through the application draft and canonical send action; also exported by ./client/conversation."
190
200
  }
191
201
  ],
192
202
  "capabilities": []
@@ -1,32 +1,74 @@
1
1
  <template>
2
2
  <section ref="container" class="assistant-conversation" :aria-label="label">
3
- <section v-if="$slots.avatar" class="assistant-conversation__avatar" aria-label="Conversation avatar">
4
- <div v-if="$slots.composer || !adapter.composer" ref="avatarControls" class="assistant-conversation__avatar-controls">
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
- ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
7
- aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
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
- <v-menu v-model="avatarMenuOpen" :activator="avatarControl?.$el">
11
- <v-list role="menu" aria-label="Avatar size">
12
- <v-list-item
13
- v-for="size in avatarSizes" :key="size.value" role="menuitem" :title="size.title"
14
- :active="size.value === avatarSize" @click="$emit('update:avatarSize', size.value)"
15
- />
16
- </v-list>
17
- </v-menu>
18
- <div v-if="avatarSize !== 'hidden'" class="assistant-conversation__avatar-visual" :style="{ height: `${avatarHeight}px` }">
19
- <slot name="avatar" :size="avatarSize" :height="avatarHeight" />
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 avatarControl = ref(null);
141
- const avatarMenuOpen = ref(false);
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
- { title: "Hidden", value: "hidden", height: 0 },
144
- { title: "Compact", value: "compact", height: 64 },
145
- { title: "Standard", value: "standard", height: 112 },
146
- { title: "Large", value: "large", height: 176 }
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
- const messageId = String(previewMessage?.messageId || previewMessage?.id || "").trim();
216
- const alreadyShown = messageId && (props.adapter.delivery?.find?.(messageId) || turns.some(turn => [
217
- turn.system, turn.user, turn.assistant, ...(turn.thinking || []), ...(turn.commentary || []), ...(turn.messages || [])
218
- ].some(message => String(message?.messageId || message?.id || "") === messageId)));
219
- if (messageId && previewMessage?.text && !alreadyShown) {
220
- const user = { messageId, role: "user", text: String(previewMessage.text) };
221
- turns = [...turns, { turnId: messageId, user, messages: [user], optimistic: { id: messageId, status: "pending" } }];
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-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
331
- .assistant-conversation__avatar { flex: 0 0 auto; min-width: 0; }
332
- .assistant-conversation__avatar-controls { display: flex; align-items: center; justify-content: flex-end; min-height: 48px; }
333
- .assistant-conversation__avatar-size { min-width: 48px; min-height: 48px; }
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; }