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);
+	});
+});