From 26cbf8943cd8731c3dfc869a6247db5269d5a8fa Mon Sep 17 00:00:00 2001 From: mlnps Date: Thu, 27 Aug 2026 17:03:59 +0200 Subject: [PATCH 1/4] fix: stop persisting empty assistant messages to the DB Assistant message rows were inserted into `chat_messages` immediately when the stream started, before any text arrived, then only patched via a debounced update on each delta. If a turn errored, was aborted, or ended without emitting any delta, the placeholder stayed empty forever (~2000 rows in production). A prior fix (#292) only filtered these out of the conversation history sent back to the LLM, it never stopped new empty rows from being written, since that insert happens in the frontend, a different code path than the one #292 touched. Now the placeholder lives in memory only (`createPendingMessageInMemory`) and is written to the DB in a single insert once the stream settles with real, non-whitespace content (`persistPendingMessageToDb`). Any other outcome, abort, error, or a stream that ends without [DONE], drops the placeholder instead (`removePendingMessageFromMemory`), so no DB row is ever created for a turn that never produced a real answer. --- apps/frontend/src/api/chat/get-completion.ts | 62 +++++- apps/frontend/src/store/use-chats-store.ts | 102 +++++++-- .../e2e/chat-message-persistence.spec.ts | 197 ++++++++++++++++++ 3 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 apps/frontend/tests/e2e/chat-message-persistence.spec.ts diff --git a/apps/frontend/src/api/chat/get-completion.ts b/apps/frontend/src/api/chat/get-completion.ts index 6b03e8791..f8e3ad562 100644 --- a/apps/frontend/src/api/chat/get-completion.ts +++ b/apps/frontend/src/api/chat/get-completion.ts @@ -57,7 +57,9 @@ export async function getCompletion( const { handleError } = useErrorStore.getState(); const { updateMessage, - addMessageToChat, + createPendingMessageInMemory, + persistPendingMessageToDb, + removePendingMessageFromMemory, selectedLlmModel, selectedChatTools, } = useChatsStore.getState(); @@ -79,6 +81,10 @@ export async function getCompletion( externalChatTools.includes(tool), ); + // Id of the optimistic placeholder, set once created — lets the catch + // block clean it up if the stream errors out. + let messageIdForCleanup: number | undefined; + try { // Abort any existing stream before starting a new one abortStreaming(); @@ -144,7 +150,9 @@ export async function getCompletion( return; } - const messageId = await addMessageToChat(currentChat, { + // Optimistic placeholder — persisted only once the stream settles + // (see `onFinish` below). + const localMessageId = createPendingMessageInMemory(currentChat, { content: "", type: "text", role: "assistant", @@ -156,6 +164,7 @@ export async function getCompletion( open_data_citations: null, external_tool_context: isExternalToolContext, }); + messageIdForCleanup = localMessageId; let currentText = ""; let documentCitations: number[] = []; @@ -168,7 +177,7 @@ export async function getCompletion( const writeMessage = () => updateMessage({ chat: currentChat, - messageId, + messageId: localMessageId, content: currentText, citations: documentCitations.length ? documentCitations : null, web_citations: webCitations.length ? webCitations : null, @@ -212,12 +221,43 @@ export async function getCompletion( openDataCitations = sources; writeMessage(); }, - onFinish: () => { + onFinish: async (wasSuccessful) => { setStatus("idle"); setStreamingAbortController(null); + + // Only persist if the stream finished cleanly with non-whitespace + // content — otherwise drop the placeholder. + if (wasSuccessful && currentText.trim()) { + try { + await persistPendingMessageToDb(currentChat, localMessageId, { + content: currentText, + type: "text", + role: "assistant", + allowed_document_ids: allowedDocumentIds, + allowed_folder_ids: selectedFolderIds, + citations: documentCitations.length ? documentCitations : null, + web_citations: webCitations.length ? webCitations : null, + parla_citations: parlaCitations.length ? parlaCitations : null, + open_data_citations: openDataCitations.length + ? openDataCitations + : null, + external_tool_context: isExternalToolContext, + }); + } catch (error) { + removePendingMessageFromMemory(currentChat, localMessageId); + handleError(error, span); + } + } else { + removePendingMessageFromMemory(currentChat, localMessageId); + } }, }); } catch (error) { + // Stream never settled — drop the placeholder if one was created. + if (messageIdForCleanup !== undefined) { + removePendingMessageFromMemory(currentChat, messageIdForCleanup); + } + // Only handle error if it's not an abort error const isUserAbort = error instanceof Error && error.name === "AbortError"; if (isUserAbort) { @@ -232,7 +272,7 @@ export async function getCompletion( } } -function processStreamLine( +async function processStreamLine( line: string, callbacks: { onTextDelta: (delta: string) => void; @@ -240,9 +280,9 @@ function processStreamLine( onWebCitations: (webCitationSources: WebCitationSource[]) => void; onParlaCitations: (sources: ParlaCitationSource[]) => void; onOpenDataCitations: (sources: OpenDataCitationSource[]) => void; - onFinish: () => void; + onFinish: (wasSuccessful: boolean) => void | Promise; }, -): boolean { +): Promise { if (!line.startsWith("data: ")) { return false; } @@ -250,7 +290,7 @@ function processStreamLine( const jsonStr = line.slice(6).trim(); if (jsonStr === "[DONE]") { - callbacks.onFinish(); + await callbacks.onFinish(true); return true; } @@ -299,7 +339,7 @@ async function parseStream( onWebCitations: (webCitationSources: WebCitationSource[]) => void; onParlaCitations: (sources: ParlaCitationSource[]) => void; onOpenDataCitations: (sources: OpenDataCitationSource[]) => void; - onFinish: () => void; + onFinish: (wasSuccessful: boolean) => void | Promise; }, ) { const reader = body.getReader(); @@ -318,7 +358,7 @@ async function parseStream( buffer = lines.pop() || ""; for (const line of lines) { - const isFinished = processStreamLine(line, callbacks); + const isFinished = await processStreamLine(line, callbacks); if (isFinished) { finishCalled = true; } @@ -333,6 +373,6 @@ async function parseStream( "stream was done before reaching the the last streaming line ([DONE])", ), ); - callbacks.onFinish(); + await callbacks.onFinish(false); } } diff --git a/apps/frontend/src/store/use-chats-store.ts b/apps/frontend/src/store/use-chats-store.ts index ab4eaa3c8..6b27bec42 100644 --- a/apps/frontend/src/store/use-chats-store.ts +++ b/apps/frontend/src/store/use-chats-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import type { ChatWithMessages, + ChatMessage, NewChatMessage, ChatTool, LlmModel, @@ -15,7 +16,6 @@ import { deleteChat as deleteChatFromDb } from "../api/chat/delete-chat.ts"; import { renameChat as renameChatInDb } from "../api/chat/rename-chat.ts"; import { getMessages as getMessagesFromDb } from "../api/message/get-messages.ts"; import { insertMessage as insertMessageIntoDb } from "../api/message/insert-message.ts"; -import { updateMessage as updateMessageInDb } from "../api/message/update-message.ts"; import { useErrorStore } from "./error-store.ts"; import type { WebCitationSource, @@ -26,10 +26,15 @@ import { useUserDocumentStore } from "./use-user-document-store.ts"; import { useUserFolderStore } from "./use-user-folder-store.ts"; import { usePublicDocumentsStore } from "./use-public-documents-store.ts"; -let updateMessageDebounceTimeout: ReturnType; let getChatsDebounceTimeout: ReturnType; let visibleInfoMessageTimeout: ReturnType; +/** + * Client-generated ids for not-yet-persisted messages. Always negative so + * they never collide with real (positive) DB ids. + */ +let nextLocalMessageId = -1; + export type VisibleChatInfoMessage = | { type: "toolDeactivated"; tools: ChatTool[] } | { type: "historyScoped" } @@ -60,6 +65,30 @@ interface ChatStore { chat: ChatWithMessages, chatMessage: NewChatMessage, ): Promise; + /** + * Creates a message in memory only, no DB write. Returns its local id, + * used until `persistPendingMessageToDb` or `removePendingMessageFromMemory`. + */ + createPendingMessageInMemory( + chat: ChatWithMessages, + chatMessage: NewChatMessage, + ): number; + /** + * Persists a local message to the DB and replaces it with the DB-assigned row. + */ + persistPendingMessageToDb( + chat: ChatWithMessages, + localMessageId: number, + chatMessage: NewChatMessage, + ): Promise; + /** + * Removes a message from memory only, no DB call — for messages that + * never made it to the DB. + */ + removePendingMessageFromMemory( + chat: ChatWithMessages, + messageId: number, + ): void; updateMessage(args: { chat: ChatWithMessages; messageId: number; @@ -332,8 +361,57 @@ export const useChatsStore = create()((set, get) => ({ }, /** - * Updates the content of a message - * and debounces updating the message in the database + * Creates a message in local store state only (no DB write). Used for + * optimistic assistant messages while a response is still streaming in, + * since we don't yet know whether the turn will succeed. + */ + createPendingMessageInMemory(givenChat, givenMessage) { + const localMessageId = nextLocalMessageId--; + + const message: ChatMessage = { + ...givenMessage, + id: localMessageId, + chat_id: givenChat.id, + created_at: new Date().toISOString(), + }; + + givenChat.messages.push(message); + + get().updateChats(givenChat); + + return localMessageId; + }, + + /** + * Persists a locally-tracked message to the DB (a single insert) and + * swaps the local placeholder for the DB-assigned row. + */ + async persistPendingMessageToDb(chat, localMessageId, chatMessage) { + const message = await insertMessageIntoDb(chat.id, chatMessage); + + const messageIndex = chat.messages.findIndex( + ({ id }) => id === localMessageId, + ); + if (messageIndex === -1) { + return; + } + + chat.messages[messageIndex] = message; + get().updateChats(chat); + }, + + /** + * Removes a message from local store state only (no DB call). + */ + removePendingMessageFromMemory(chat, messageId) { + chat.messages = chat.messages.filter(({ id }) => id !== messageId); + get().updateChats(chat); + }, + + /** + * Updates the content of a message in local store state only. + * Persistence happens separately once the stream settles + * (see `persistPendingMessageToDb` / `removePendingMessageFromMemory`). */ updateMessage: ({ chat, @@ -344,11 +422,7 @@ export const useChatsStore = create()((set, get) => ({ parla_citations, open_data_citations, }) => { - clearTimeout(updateMessageDebounceTimeout); - - const foundMessage = chat.messages.find( - (message) => message.id === messageId, - ); + const foundMessage = chat.messages.find(({ id }) => id === messageId); if (!foundMessage) { return; } @@ -359,16 +433,6 @@ export const useChatsStore = create()((set, get) => ({ foundMessage.parla_citations = parla_citations; foundMessage.open_data_citations = open_data_citations; get().updateChats(chat); - - updateMessageDebounceTimeout = setTimeout(async () => { - await updateMessageInDb(messageId, { - content, - citations, - web_citations, - parla_citations, - open_data_citations, - }); - }, 300); }, showInfoMessage(infoMessage: VisibleChatInfoMessage) { diff --git a/apps/frontend/tests/e2e/chat-message-persistence.spec.ts b/apps/frontend/tests/e2e/chat-message-persistence.spec.ts new file mode 100644 index 000000000..a90b1739a --- /dev/null +++ b/apps/frontend/tests/e2e/chat-message-persistence.spec.ts @@ -0,0 +1,197 @@ +import { Readable } from "node:stream"; +import { expect, test } from "@playwright/test"; +import { testWithMockedLlm } from "../fixtures/test-with-mocked-llm.ts"; +import { testWithLoggedInUser } from "../fixtures/test-with-logged-in-user.ts"; +import { sendAndWaitForLLMResponse } from "../fixtures/mock-llm.ts"; +import { supabaseAdminClient } from "../supabase.ts"; + +type PersistedMessage = { + role: string; + content: string; +}; + +async function getMessagesForLatestChat( + userId: string, +): Promise { + const { data: chats, error: chatsError } = await supabaseAdminClient + .from("chats") + .select("id") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1); + + if (chatsError) { + throw chatsError; + } + + const chatId = chats?.[0]?.id; + if (chatId === undefined) { + return []; + } + + const { data: messages, error: messagesError } = await supabaseAdminClient + .from("chat_messages") + .select("role, content") + .eq("chat_id", chatId) + .order("id", { ascending: true }); + + if (messagesError) { + throw messagesError; + } + + return messages ?? []; +} + +test.describe("Chat message persistence", () => { + testWithMockedLlm( + "a stream that ends abnormally (no [DONE]) leaves no empty assistant message behind", + async ({ page, account }) => { + await page.goto("/"); + + // SSE body that ends without the `data: [DONE]` trailer — simulates a + // dropped connection. `parseStream`'s fallback path should kick in and + // the optimistic assistant placeholder should never reach the DB. + await page.route("**/llm/just-chatting", async (route) => { + await route.fulfill({ + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8" }, + body: `data: ${JSON.stringify({ + type: "text-delta", + id: "1", + delta: "Partial answer", + })}\n\n`, + }); + }); + + await page.getByPlaceholder("Stellen Sie eine Frage").fill("hallo"); + await sendAndWaitForLLMResponse(page); + + // The local placeholder is dropped from the UI once the abnormal end + // is detected. + await expect( + page.getByTestId("assistant-message-markdown-container"), + ).toHaveCount(0); + + // Only the user's message was ever written to the database — no + // empty-content assistant row. + await expect + .poll(() => getMessagesForLatestChat(account.id)) + .toEqual([{ role: "user", content: "hallo" }]); + }, + ); + + testWithLoggedInUser( + "aborting a response before it finishes leaves no empty assistant message behind", + async ({ page, account }) => { + await page.goto("/"); + let hangingStream: Readable | undefined; + + // Mock the LLM API to hang after a partial response, so the user has + // time to click "stop" before the stream would ever settle naturally. + await page.route("**/llm/just-chatting", async (route) => { + hangingStream = new Readable({ + read() {}, + }); + hangingStream.push( + `data: ${JSON.stringify({ + type: "text-delta", + id: "1", + delta: "Partial ", + })}\n\n`, + ); + + await route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + }, + // @ts-expect-error Playwright Node accepts Readable for streaming bodies; public types omit it. + body: hangingStream, + }); + }); + + try { + await page.getByPlaceholder("Stellen Sie eine Frage").fill("hallo"); + await page.getByRole("button", { name: "Nachricht senden" }).click(); + + const stopButton = page.getByRole("button", { + name: "Textgenerierung stoppen", + }); + await expect(stopButton).toBeVisible(); + + await stopButton.click(); + + await expect( + page.getByRole("button", { name: "Nachricht senden" }), + ).toBeVisible(); + + // The aborted turn's optimistic placeholder is dropped from the UI... + await expect( + page.getByTestId("assistant-message-markdown-container"), + ).toHaveCount(0); + + // ...and only the user's message was ever written to the database. + await expect + .poll(() => getMessagesForLatestChat(account.id)) + .toEqual([{ role: "user", content: "hallo" }]); + } finally { + await page.unroute("**/llm/just-chatting"); + hangingStream?.destroy(); + } + }, + ); + + testWithMockedLlm( + "a stream that completes with only whitespace content leaves no empty assistant message behind", + async ({ page, account }) => { + await page.goto("/"); + + await page.route("**/llm/just-chatting", async (route) => { + await route.fulfill({ + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8" }, + body: `data: ${JSON.stringify({ + type: "text-delta", + id: "1", + delta: " \n\t ", + })}\n\ndata: [DONE]\n\n`, + }); + }); + + await page.getByPlaceholder("Stellen Sie eine Frage").fill("hallo"); + await sendAndWaitForLLMResponse(page); + + // The whitespace-only placeholder is dropped from the UI, same as a + // truly empty response. + await expect( + page.getByTestId("assistant-message-markdown-container"), + ).toHaveCount(0); + + // Only the user's message was ever written to the database — no + // whitespace-only assistant row. + await expect + .poll(() => getMessagesForLatestChat(account.id)) + .toEqual([{ role: "user", content: "hallo" }]); + }, + ); + + testWithMockedLlm( + "a successful, fully-streamed response persists the assistant message", + async ({ page, account }) => { + await page.goto("/"); + + await page.getByPlaceholder("Stellen Sie eine Frage").fill("hallo"); + await sendAndWaitForLLMResponse(page); + + const answer = page.getByTestId("assistant-message-markdown-container"); + await expect(answer).not.toBeEmpty(); + + await expect + .poll(() => getMessagesForLatestChat(account.id)) + .toEqual([ + { role: "user", content: "hallo" }, + { role: "assistant", content: "Test response." }, + ]); + }, + ); +}); From 95606eb1667a2c9a4b5b607e3a73bea0e83a8315 Mon Sep 17 00:00:00 2001 From: mlnps Date: Fri, 28 Aug 2026 09:12:55 +0200 Subject: [PATCH 2/4] fix: persist web/Parla/open-data citations when writing assistant messages --- apps/frontend/src/api/message/insert-message.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/frontend/src/api/message/insert-message.ts b/apps/frontend/src/api/message/insert-message.ts index 082edc45a..08d8bbab3 100644 --- a/apps/frontend/src/api/message/insert-message.ts +++ b/apps/frontend/src/api/message/insert-message.ts @@ -16,6 +16,9 @@ export async function insertMessage( allowed_document_ids: chatMessage.allowed_document_ids, allowed_folder_ids: chatMessage.allowed_folder_ids, citations: chatMessage.citations, + web_citations: chatMessage.web_citations, + parla_citations: chatMessage.parla_citations, + open_data_citations: chatMessage.open_data_citations, external_tool_context: chatMessage.external_tool_context, }) .select("*") From 2a5e4a160c041e0df5475ed3d8fb9f55a7be4dd3 Mon Sep 17 00:00:00 2001 From: mlnps Date: Thu, 3 Sep 2026 13:53:00 +0200 Subject: [PATCH 3/4] fix: prevent DOM remount and scroll race from async message persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keying the message list on `message.id` caused a full remount whenever an assistant message's id swapped from local to DB-assigned on persist. Added a stable `clientKey` field for the React key instead. That id-swap change also removed an incidental delay (an awaited DB insert) that a chat-switch scroll effect relied on to reliably jump to bottom before streamed content arrived. Without it, a new chat's first message raced a 1ms timer against the response — reproduced as a consistent webkit/Mobile Safari e2e failure. Fixed by having a chat's first message use the same "pin to top" behavior as any other new message, instead of racing that timer. Added e2e coverage for the untouched branch: opening an existing chat still scrolls to the bottom. --- apps/frontend/src/api/message/get-messages.ts | 5 ++- apps/frontend/src/common.ts | 2 + .../src/components/chat/chat-messages.tsx | 2 +- .../chat/hooks/use-chat-scrolling.tsx | 21 ++++++--- apps/frontend/src/store/use-chats-store.ts | 7 ++- apps/frontend/tests/e2e/chat.spec.ts | 44 +++++++++++++++++++ 6 files changed, 72 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/api/message/get-messages.ts b/apps/frontend/src/api/message/get-messages.ts index 20ce421e3..da2864ba0 100644 --- a/apps/frontend/src/api/message/get-messages.ts +++ b/apps/frontend/src/api/message/get-messages.ts @@ -26,5 +26,8 @@ export async function getMessages(chatId: number, signal: AbortSignal) { * as `Jsonb | null` in the DB, which does not exist in Typescript. * It actually is `number[] | null`, so we cast it here. */ - return data as ChatMessage[]; + return (data as ChatMessage[]).map((message) => ({ + ...message, + clientKey: message.id, + })); } diff --git a/apps/frontend/src/common.ts b/apps/frontend/src/common.ts index 4a1a5d110..6b6da482d 100644 --- a/apps/frontend/src/common.ts +++ b/apps/frontend/src/common.ts @@ -50,6 +50,8 @@ export type ChatMessage = { created_at: string; external_tool_context: boolean; id: number; + // Stable identity to avoid remounting the message's DOM node. + clientKey: number; role: string; type: string; }; diff --git a/apps/frontend/src/components/chat/chat-messages.tsx b/apps/frontend/src/components/chat/chat-messages.tsx index 55866c914..10cc63ba0 100644 --- a/apps/frontend/src/components/chat/chat-messages.tsx +++ b/apps/frontend/src/components/chat/chat-messages.tsx @@ -44,7 +44,7 @@ export const ChatMessages: React.FC = () => { className="flex w-full flex-col gap-y-1 lg:gap-y-3.5" > {messages.map((message) => ( - + ))} {isWaitingForResponse && (
diff --git a/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx b/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx index fac9963b9..e5b5afa2e 100644 --- a/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx +++ b/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx @@ -44,8 +44,11 @@ export function useChatScrolling( }, [visibleInfoMessage, containerRef, scrollToBottom]); /** - * Jump to the last message when a chat is opened or switched. - * Skipped when a search result asked us to scroll to a specific message. + * Jump to the last message when an existing chat is opened or switched. + * Skipped when a search result asked us to scroll to a specific message, + * and skipped when the chat id changed because it was *just created* by + * sending its first message — that case is a new message like any + * other and is handled by the "pin to top" effect below instead. * The small timeout lets the newly selected chat's messages render first. * Reads pendingScrollToMessage from getState so clearing it after a * search-scroll does not re-trigger this effect. @@ -54,9 +57,12 @@ export function useChatScrolling( if (useChatScrollingStore.getState().pendingScrollToMessage !== null) { return () => {}; } + if (userMessageCount === 1) { + return () => {}; + } const timer = setTimeout(() => scrollToBottom("auto"), 1); return () => clearTimeout(timer); - }, [currentChatId, scrollToBottom]); + }, [currentChatId, userMessageCount, scrollToBottom]); /** * Scroll to a specific message after opening a chat from search. @@ -71,7 +77,10 @@ export function useChatScrolling( /** * Scroll a newly sent user message to the top of the viewport. - * Skipped on a chat switch, where the effect above already jumps to the bottom. + * Skipped on a switch to an *existing* chat, where the effect above + * already jumps to the bottom — except when the chat id changed because + * it was just created by this very message (its first), which should + * still pin to top like any other new message. */ useLayoutEffect(() => { const hasChatIdChanged = previousChatId.current !== currentChatId; @@ -80,7 +89,9 @@ export function useChatScrolling( previousChatId.current = currentChatId; previousUserMessageCount.current = userMessageCount; - if (!hasChatIdChanged && hasNewUserMessage) { + const isFirstMessageInNewChat = hasChatIdChanged && userMessageCount === 1; + + if ((!hasChatIdChanged || isFirstMessageInNewChat) && hasNewUserMessage) { scrollNewMessageToTop(); } }, [currentChatId, userMessageCount, scrollNewMessageToTop]); diff --git a/apps/frontend/src/store/use-chats-store.ts b/apps/frontend/src/store/use-chats-store.ts index 6b27bec42..fff28039f 100644 --- a/apps/frontend/src/store/use-chats-store.ts +++ b/apps/frontend/src/store/use-chats-store.ts @@ -353,7 +353,7 @@ export const useChatsStore = create()((set, get) => ({ async addMessageToChat(givenChat, givenMessage) { const message = await insertMessageIntoDb(givenChat.id, givenMessage); - givenChat.messages.push(message); + givenChat.messages.push({ ...message, clientKey: message.id }); get().updateChats(givenChat); @@ -371,6 +371,7 @@ export const useChatsStore = create()((set, get) => ({ const message: ChatMessage = { ...givenMessage, id: localMessageId, + clientKey: localMessageId, chat_id: givenChat.id, created_at: new Date().toISOString(), }; @@ -396,7 +397,9 @@ export const useChatsStore = create()((set, get) => ({ return; } - chat.messages[messageIndex] = message; + // Keep the original clientKey (the local id) so React reconciles the + // same DOM node instead of remounting it — only `id` changes here. + chat.messages[messageIndex] = { ...message, clientKey: localMessageId }; get().updateChats(chat); }, diff --git a/apps/frontend/tests/e2e/chat.spec.ts b/apps/frontend/tests/e2e/chat.spec.ts index e8b393c4e..775a738da 100644 --- a/apps/frontend/tests/e2e/chat.spec.ts +++ b/apps/frontend/tests/e2e/chat.spec.ts @@ -23,6 +23,7 @@ import { testDesktopOnly } from "../fixtures/test-desktop-only.ts"; import { supabaseAdminClient, supabaseAnonClient } from "../supabase.ts"; import { testDesktopOnlyWithManyChats } from "../fixtures/test-desktop-only-with-many-chats.ts"; import { testWithLoggedInUser } from "../fixtures/test-with-logged-in-user.ts"; +import { testWithChatSearch } from "../fixtures/test-with-chat-search.ts"; test.describe("Chat", () => { testWithMockedLlm( @@ -1317,6 +1318,49 @@ test.describe("Chat", () => { }, ); + testWithChatSearch( + "Opening an existing chat with history scrolls to the bottom, not the top", + async ({ page, insertChat, insertMessages }) => { + const chatId = await insertChat( + "Alter Testchat", + new Date(Date.now() - 60_000), + ); + + const baseTime = new Date(Date.now() - 50_000); + const messages = Array.from({ length: 10 }, (_, index) => [ + { + role: "user" as const, + content: `Frage ${index + 1}: Was ist die Hauptstadt von Bundesland ${index + 1}? Lorem ipsum dolor sit amet.`, + createdAt: new Date(baseTime.getTime() + index * 2000), + }, + { + role: "assistant" as const, + content: `Antwort ${index + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.`, + createdAt: new Date(baseTime.getTime() + index * 2000 + 1000), + }, + ]).flat(); + await insertMessages(chatId, messages); + + await page.goto("/"); + + await page + .getByRole("complementary", { name: "Sidebar" }) + .getByRole("button", { name: "Alter Testchat", exact: true }) + .click(); + + const lastAnswer = page + .getByTestId("assistant-message-markdown-container") + .last(); + await expect(lastAnswer).toBeVisible(); + await expect(lastAnswer).toContainText("Antwort 10"); + + // Already at the bottom, so the scroll-to-bottom button should not appear. + await expect( + page.getByRole("button", { name: "Zum Ende des Chats scrollen" }), + ).not.toBeVisible(); + }, + ); + testWithMockedLlm( "Links in assistant messages open in new tab", async ({ page }) => { From 0b2f951cd1582358c524ad919a6da8c737557940 Mon Sep 17 00:00:00 2001 From: mlnps Date: Thu, 3 Sep 2026 16:11:07 +0200 Subject: [PATCH 4/4] fix: stop chat-switch scroll effect from re-running on every new message --- .../src/components/chat/hooks/use-chat-scrolling.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx b/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx index e5b5afa2e..2b1174878 100644 --- a/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx +++ b/apps/frontend/src/components/chat/hooks/use-chat-scrolling.tsx @@ -23,6 +23,8 @@ export function useChatScrolling( const previousChatId = useRef(currentChatId); const previousUserMessageCount = useRef(userMessageCount); + const latestUserMessageCount = useRef(userMessageCount); + latestUserMessageCount.current = userMessageCount; //Jump to the bottom when a transient info message (tool deactivated / history scoped) appears. useLayoutEffect(() => { @@ -57,12 +59,12 @@ export function useChatScrolling( if (useChatScrollingStore.getState().pendingScrollToMessage !== null) { return () => {}; } - if (userMessageCount === 1) { + if (latestUserMessageCount.current === 1) { return () => {}; } const timer = setTimeout(() => scrollToBottom("auto"), 1); return () => clearTimeout(timer); - }, [currentChatId, userMessageCount, scrollToBottom]); + }, [currentChatId, scrollToBottom]); /** * Scroll to a specific message after opening a chat from search.