Commit 90080ce09 for llama.cpp
commit 90080ce094236542470311c484543d507564d549
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Mon Nov 2 12:34:17 2026 +0100
ui: keep queued steering messages in their own conversation (#31304)
A message queued while a conversation was busy was re-sent through
sendMessage, which targets the active conversation, so switching chats
before the turn ended delivered it into the wrong one. Re-send only when
the owning conversation is active; otherwise keep it queued there and
flush it when that conversation is opened again.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts
index d1a754aea..ec20ecd34 100644
--- a/tools/ui/src/lib/stores/chat/index.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/index.svelte.ts
@@ -337,7 +337,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
const pending = this.consumePendingMessage(convId);
- if (pending) await this.sendMessage(pending.content, pending.extras);
+ if (pending) await this.sendQueuedMessage(convId, pending);
}
consumePendingMessage(
@@ -416,6 +416,20 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return this.flows.editUserMessagePreserveResponses(messageId, newContent, newExtras);
}
+ /**
+ * Send the message left queued for `convId` once that conversation is
+ * active and idle (e.g. after navigating back to it).
+ */
+ async flushPendingMessage(convId: string): Promise<void> {
+ if (conversationsStore.activeConversation?.id !== convId) return;
+
+ if (this.isChatLoadingInternal(convId) || agenticStore.isRunning(convId)) return;
+
+ const pending = this.consumePendingMessage(convId);
+
+ if (pending) await this.sendMessage(pending.content, pending.extras);
+ }
+
getAddFilesHandler(): ((files: File[]) => void) | null {
return this.addFilesHandler;
}
@@ -1141,7 +1155,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
const pending = this.consumePendingMessage(convId);
if (pending) {
- this.sendMessage(pending.content, pending.extras);
+ void this.sendQueuedMessage(convId, pending);
}
return;
@@ -1268,7 +1282,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
const pending = agenticStore.consumePendingSteeringMessage(convId);
if (pending) {
- await this.sendMessage(pending.content, pending.extras);
+ await this.sendQueuedMessage(convId, pending);
}
return;
@@ -1333,7 +1347,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
const pending = this.consumePendingMessage(convId);
if (pending) {
- await this.sendMessage(pending.content, pending.extras);
+ await this.sendQueuedMessage(convId, pending);
}
},
onCompletionId: streamCallbacks.onCompletionId,
@@ -1555,6 +1569,24 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
+ /**
+ * Re-send a message queued while `convId` was busy. sendMessage targets the
+ * active conversation, so if the user navigated away the message stays
+ * queued on its own conversation until flushPendingMessage picks it up.
+ */
+ private async sendQueuedMessage(
+ convId: string,
+ msg: { content: string; extras?: DatabaseMessageExtra[] }
+ ): Promise<void> {
+ if (conversationsStore.activeConversation?.id !== convId) {
+ this.pendingMessages.set(convId, msg);
+
+ return;
+ }
+
+ await this.sendMessage(msg.content, msg.extras);
+ }
+
/**
* Streams a fresh summary of `source` into the compaction node and re-stamps
* the reduced context afterwards. `rollback` restores the row when nothing
diff --git a/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte b/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte
index c4a9eca2e..ce6c88ba7 100644
--- a/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte
+++ b/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte
@@ -106,6 +106,8 @@
chatStore.syncLoadingStateForChat(chatId);
// server probe (with localStorage fallback) and attach
await chatStore.discoverActiveStream(chatId);
+ // a message queued here may have finished its turn while another chat was open
+ await chatStore.flushPendingMessage(chatId);
if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
await handleUrlParams();
diff --git a/tools/ui/tests/unit/queued-message-isolation.test.ts b/tools/ui/tests/unit/queued-message-isolation.test.ts
new file mode 100644
index 000000000..32e0f9d68
--- /dev/null
+++ b/tools/ui/tests/unit/queued-message-isolation.test.ts
@@ -0,0 +1,81 @@
+// A message queued (steering or pending) while a conversation is busy belongs
+// to that conversation: when its turn ends while another chat is open, it must
+// stay queued there instead of being sent into the chat the user is viewing.
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/services/database.service', () => ({
+ DatabaseService: {
+ getConversation: vi.fn(),
+ getConversationMessages: vi.fn(),
+ updateConversation: vi.fn(),
+ updateCurrentNode: vi.fn()
+ }
+}));
+
+import { DatabaseService } from '$lib/services/database.service';
+import { chatStore } from '$lib/stores/chat/index.svelte';
+import { conversationsStore } from '$lib/stores/conversations/index.svelte';
+
+type QueuedMessage = { content: string };
+type QueueAccess = { sendQueuedMessage(convId: string, msg: QueuedMessage): Promise<void> };
+
+const sendQueuedMessage = (convId: string, msg: QueuedMessage) =>
+ (chatStore as unknown as QueueAccess).sendQueuedMessage(convId, msg);
+
+async function openConversation(id: string) {
+ vi.mocked(DatabaseService.getConversation).mockResolvedValue({
+ currNode: null,
+ id,
+ lastModified: 1000,
+ name: id
+ });
+ vi.mocked(DatabaseService.getConversationMessages).mockResolvedValue([]);
+
+ expect(await conversationsStore.loadConversation(id)).toBe(true);
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+ chatStore.clearPendingMessage('conv-a');
+ chatStore.clearPendingMessage('conv-b');
+});
+
+describe('queued message isolation', () => {
+ it('keeps the message on its own conversation when another one is active', async () => {
+ const send = vi.spyOn(chatStore, 'sendMessage').mockResolvedValue();
+
+ await openConversation('conv-b');
+ await sendQueuedMessage('conv-a', { content: 'steer a' });
+
+ expect(send).not.toHaveBeenCalled();
+ expect(chatStore.getPendingMessageContent('conv-a')).toBe('steer a');
+ expect(chatStore.getPendingMessageContent('conv-b')).toBeNull();
+ });
+
+ it('sends right away when its conversation is still active', async () => {
+ const send = vi.spyOn(chatStore, 'sendMessage').mockResolvedValue();
+
+ await openConversation('conv-a');
+ await sendQueuedMessage('conv-a', { content: 'steer a' });
+
+ expect(send).toHaveBeenCalledWith('steer a', undefined);
+ expect(chatStore.hasPendingMessage('conv-a')).toBe(false);
+ });
+
+ it('flushes the left-behind message only once its conversation is active again', async () => {
+ const send = vi.spyOn(chatStore, 'sendMessage').mockResolvedValue();
+
+ chatStore.injectPendingMessage('conv-a', 'steer a');
+ await openConversation('conv-b');
+ await chatStore.flushPendingMessage('conv-a');
+
+ expect(send).not.toHaveBeenCalled();
+
+ await openConversation('conv-a');
+ await chatStore.flushPendingMessage('conv-a');
+
+ expect(send).toHaveBeenCalledWith('steer a', undefined);
+ expect(chatStore.hasPendingMessage('conv-a')).toBe(false);
+ });
+});