diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 669e847f0..eb1a20054 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -736,6 +736,13 @@ export const en = { }, ask: { streamInterrupted: "The answer stream was interrupted. Reopen the conversation to check its status.", + noActiveAnswer: "No active answer was found. You can send a new message.", + historyLoadFailed: "Could not load this conversation.", + retryHistory: "Retry", + loadingHistory: "Loading conversation…", + loadEarlierConversations: "Load earlier conversations", + conversationsLoadFailed: "Could not load conversations.", + retryConversations: "Retry", /* 新对话首屏问候:碑铭衬线,品牌名入句(标题不带句号) */ greeting: "Ask Utopia what it remembers", emptyTitle: "Chat", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index eb4560dcf..d3d309a90 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -677,6 +677,13 @@ export const zh: Strings = { }, ask: { streamInterrupted: "回答连接已中断,请重新打开会话查看状态。", + noActiveAnswer: "未发现正在生成的回答,你可以发送新消息。", + historyLoadFailed: "无法读取此会话。", + retryHistory: "重试", + loadingHistory: "正在读取会话…", + loadEarlierConversations: "加载更早的会话", + conversationsLoadFailed: "无法读取会话列表。", + retryConversations: "重试", greeting: "问问 Utopia 都记住了什么", emptyTitle: "对话", emptyBody: diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 432ff9853..3cedaa19d 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,8 +1,8 @@ /* Chat:agentic 对话(检索/图谱工具 + remember 记忆)。 会话持久化:左栏会话列表;上下文由服务端拼,前端只发 conversation_id + 新消息; 行动轨迹(steps)与引用(sources)随消息落库,历史回放与实时流共用渲染。 */ -import { memo, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -28,11 +28,13 @@ import { } from "lucide-react"; import { api, + ApiError, conversationsApi, reattachChat, streamChat, type ChatStep, type ConversationRow, + type ConversationMessage, type Source, } from "../api"; import { S } from "../i18n"; @@ -80,6 +82,15 @@ const DRAFT_KEY = "chat:draft"; * 正文里每个角标跟着重画 */ const NO_SOURCES: Source[] = []; +type ViewRequest = { kbId: string; id: string | null }; +const historyTurns = (messages: ConversationMessage[]): Turn[] => messages.map((m) => ({ + role: m.role, + content: m.content, + steps: m.steps.length ? m.steps : undefined, + sources: m.sources.length ? m.sources : undefined, +})); +const viewKey = (kbId: string, id: string | null) => `${kbId}/${id ?? ""}`; + export function Chat() { const kbId = useKbId(); const { kb, kbs, setKb } = useKb(); @@ -107,6 +118,39 @@ export function Chat() { const activeIdRef = useRef(null); // 已经结束的那些轮次,从库里读来。**进行中的那一次不在这里**——见下 const [turns, setTurns] = useState([]); + const [loadedKey, setLoadedKey] = useState(null); + const [idleHistoryKey, setIdleHistoryKey] = useState(null); + const [historyError, setHistoryError] = useState(null); + const [loadingHistory, setLoadingHistory] = useState(false); + // Object identity is the viewing epoch: A → B → A creates three owners. + // Generation handles live separately and continue after this view leaves. + const viewRequest = useRef({ kbId, id: routeConvId ?? null }); + const claimView = (id: string | null): ViewRequest => { + const request = { kbId, id }; + viewRequest.current = request; + return request; + }; + const ownsView = (request: ViewRequest) => viewRequest.current === request; + const previousRoute = useRef(viewKey(kbId, routeConvId ?? null)); + useLayoutEffect(() => { + const key = viewKey(kbId, routeConvId ?? null); + if (previousRoute.current === key) return; + previousRoute.current = key; + // 新建会话的 URL 同步也会走这里:旧 send owner 失效、activeIdRef 清空, + // 因而 route-sync 会调用 loadConversation。onConversation 已先 identify 生成句柄, + // loadConversation 必须先检查 liveAnswer.entry,直接认领,避免流中途读库覆盖。 + claimView(routeConvId ?? null); + activeIdRef.current = null; + setActiveId(routeConvId ?? null); + setTurns([]); + setLoadedKey(null); + setHistoryError(null); + setLoadingHistory(false); + }, [kbId, routeConvId]); + useLayoutEffect(() => () => { + viewRequest.current = { ...viewRequest.current }; + activeIdRef.current = null; // StrictMode's next setup must issue its own read. + }, []); const [input, setInput] = useState(() => sessionStorage.getItem(DRAFT_KEY) ?? ""); /* **按 URL 认领,不按 state。** 这个文件开头就写着「URL 是当前会话的唯一 事实来源」,而这里一度用了 `activeId`——它是 state,切走再回来时更新得 @@ -119,14 +163,14 @@ export function Chat() { // 跳过重渲染,别场逐字增长不再打扰当前会话 const liveHere = useSyncExternalStore( liveAnswer.subscribe, - () => liveAnswer.entry(kb?.id ?? null, currentId), + () => liveAnswer.entry(kbId || null, currentId), ); /* **是「这一场」在流,不是「有一场」在流。** 写成全局的话,另一场在生成时这一场的输入框也会变成停止按钮、发不出消息, 而且最后一轮会被当成还在流——引用于是被藏起来(那条判据见 TurnView)。 一个正在别处生成的回答不该改变这里的任何东西 */ const streaming = liveHere?.streaming ?? false; - const shown = liveHere ? liveHere.turns : turns; + const shown = liveHere ? liveHere.turns : loadedKey === viewKey(kbId, currentId) ? turns : []; const [scopeOpen, setScopeOpen] = useState(false); const [pendingDelete, setPendingDelete] = useState(null); // 会话搜索。**搜标题也搜正文**——人记得住的往往是问过的那句话 @@ -166,12 +210,21 @@ export function Chat() { }; }, [scopeOpen]); - const convs = useQuery({ - queryKey: ["conversations", kb?.id, convSearch], - queryFn: () => conversationsApi.list(kb!.id, convSearch), - enabled: !!kb, - placeholderData: (prev) => prev, + const convs = useInfiniteQuery({ + queryKey: ["conversations", kbId, convSearch], + queryFn: ({ pageParam }) => conversationsApi.list(kbId, convSearch, 30, pageParam), + initialPageParam: 0, + getNextPageParam: (last, pages) => { + const loaded = pages.reduce((count, page) => count + page.conversations.length, 0); + return last.conversations.length > 0 && loaded < last.total ? loaded : undefined; + }, + enabled: !!kbId && kb?.id === kbId, }); + // Updated conversations can move between offset pages. Deduplicate by identity; + // invalidation refetches the loaded page range rather than appending stale offsets. + const conversations = [...new Map( + (convs.data?.pages.flatMap((page) => page.conversations) ?? []).map((c) => [c.id, c]), + ).values()]; // 改标题:**就地编辑**,不弹对话框——改一个名字不值得打断整页 const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); @@ -190,24 +243,9 @@ export function Chat() { bottomRef.current?.scrollIntoView({ behavior: "instant" }); }, [shown]); - // 切库回到新会话(首次拿到 kb 不算切换——直刷 /chat/$id 时不能把 URL 冲掉) - const prevKbRef = useRef(null); - useEffect(() => { - const prev = prevKbRef.current; - prevKbRef.current = kb?.id ?? null; - if (prev && kb && prev !== kb.id) { - // **不 abort**:换库不该杀掉另一个库里正在写的回答,它落到那边的会话里 - activeIdRef.current = null; - setActiveId(null); - setTurns([]); - navigate({ to: "/kb/$kbId/chat", params: { kbId }, replace: true }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kb?.id]); - // 路由 → 会话装载;裸 /chat 还原本库上次会话(切页回来仍在原对话) useEffect(() => { - if (!kb) return; + if (!kb || kb.id !== kbId) return; if (!routeConvId) { const last = sessionStorage.getItem(lastKey(kb.id)); if (last) { @@ -222,7 +260,7 @@ export function Chat() { if (routeConvId === activeIdRef.current) return; // 流式新建会话后仅 URL 同步,勿重载 loadConversation(routeConvId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kb?.id, routeConvId]); + }, [kb?.id, kbId, routeConvId]); // 还原的草稿撑开输入框(高度平时由 onChange 维护) useEffect(() => { @@ -237,24 +275,30 @@ export function Chat() { queryClient.invalidateQueries({ queryKey: ["conversations", kb?.id] }); /** 列表点击只改 URL,装载由路由同步 effect 负责 */ - const openConversation = (id: string) => + const openConversation = (id: string) => { + if (id === currentId) return; + claimView(id); navigate({ to: "/kb/$kbId/chat/$conversationId", params: { kbId, conversationId: id }, }); + }; - /** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,什么都不发生。 */ - const attachIfRunning = (id: string, history: Turn[]) => { + /** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,补读一次已保存历史。 */ + const attachIfRunning = (id: string, history: Turn[], owner: ViewRequest) => { let abort = () => {}; let handle: LiveHandle | null = null; - const stop = reattachChat(kb!.id, id, { + let checkedIdle = false; + const stop = reattachChat(owner.kbId, id, { onConversation: () => {}, /* **快照到了才建这一轮。** 先摆一个空位再等回答的话,没有在跑的会话 上会闪一下空的助手气泡——而那是绝大多数情况。 快照是覆盖:它是那个回答此刻的全貌,不是增量 */ onSnapshot: (s) => { + if (!ownsView(owner)) { abort(); return; } + if (handle) return; handle = liveAnswer.begin( - kb!.id, + owner.kbId, id, [ ...history, @@ -277,15 +321,35 @@ export function Chat() { invalidateList(); }, onError: (message) => { + if (!handle && ownsView(owner)) setHistoryError(message); handle?.patchLast((t) => ({ ...t, error: message })); handle?.finish(); }, - onIdle: () => {}, + onIdle: async () => { + if (checkedIdle || handle || !ownsView(owner)) return; + checkedIdle = true; + try { + // The answer may have committed between the history read and attach. + // One read closes that handoff; never re-POST or recursively attach. + const { messages } = await conversationsApi.detail(owner.kbId, id); + if (!ownsView(owner)) return; + const refreshed = historyTurns(messages); + setTurns(refreshed); + setLoadedKey(viewKey(owner.kbId, id)); + setIdleHistoryKey(refreshed.at(-1)?.role === "user" ? viewKey(owner.kbId, id) : null); + } catch (error) { + if (ownsView(owner)) setHistoryError(error instanceof Error ? error.message : String(error)); + } + }, }); abort = stop; }; const loadConversation = async (id: string) => { + const owner = claimView(id); + setIdleHistoryKey(null); + setHistoryError(null); + setLoadingHistory(false); // 回到正在写的那一场:直接认领,别去库里读——库里要等它写完才有那一行 if (liveAnswer.entry(kb!.id, id)) { activeIdRef.current = id; @@ -294,35 +358,46 @@ export function Chat() { } activeIdRef.current = id; setActiveId(id); + setTurns([]); + setLoadedKey(null); + setLoadingHistory(true); try { - const { messages } = await conversationsApi.detail(kb!.id, id); - sessionStorage.setItem(lastKey(kb!.id), id); - const history: Turn[] = messages.map((m) => ({ - role: m.role, - content: m.content, - steps: m.steps.length ? m.steps : undefined, - sources: m.sources.length ? m.sources : undefined, - })); + const { messages } = await conversationsApi.detail(owner.kbId, id); + if (!ownsView(owner)) return; + sessionStorage.setItem(lastKey(owner.kbId), id); + const history = historyTurns(messages); setTurns(history); + setLoadedKey(viewKey(owner.kbId, id)); /* **刷新之后接回去。** 上面那个 store 只活在这一个页面里;刷新、 新标签页、换台机器都拿不到它,而服务端那边生成还在跑。问一句 「这个会话有没有在跑的」——没有是最常见的答案,代价是一次会 立刻回 `idle` 的请求。 最后一条是用户说的话时才问:那正好是「问了但还没答上」的形状 */ if (history[history.length - 1]?.role === "user") { - attachIfRunning(id, history); + attachIfRunning(id, history, owner); + } + } catch (error) { + if (!ownsView(owner)) return; + if (!(error instanceof ApiError && [401, 403, 404].includes(error.status))) { + setHistoryError(error instanceof Error ? error.message : String(error)); + return; } - } catch { // 失效链接(会话已删 / 属于别的库):安静回到新对话 sessionStorage.removeItem(lastKey(kb!.id)); activeIdRef.current = null; setActiveId(null); setTurns([]); - navigate({ to: "/kb/$kbId/chat", params: { kbId }, replace: true }); + navigate({ to: "/kb/$kbId/chat", params: { kbId: owner.kbId }, replace: true }); + } finally { + if (ownsView(owner)) setLoadingHistory(false); } }; const newChat = () => { + claimView(null); + setHistoryError(null); + setLoadingHistory(false); + setLoadedKey(null); // 同样不 abort:开一场新的不等于放弃上一场 if (kb) sessionStorage.removeItem(lastKey(kb.id)); activeIdRef.current = null; @@ -333,6 +408,7 @@ export function Chat() { }; const removeConversation = async (id: string) => { + const owner = viewRequest.current; await conversationsApi.remove(kb!.id, id); // 记号跟着会话走,否则这个 id 会一直留在浏览器的那张表里 convMarks.forget(id); @@ -340,12 +416,14 @@ export function Chat() { sessionStorage.removeItem(lastKey(kb!.id)); } invalidateList(); - if (id === activeId) newChat(); + if (ownsView(owner) && id === activeIdRef.current) newChat(); }; const send = () => { const q = input.trim(); - if (!q || streaming || !kb) return; + if (!q || streaming || !kb || kb.id !== kbId || loadingHistory || historyError) return; + const owner = claimView(activeId); + setIdleHistoryKey(null); setInput(""); sessionStorage.removeItem(DRAFT_KEY); if (inputRef.current) inputRef.current.style.height = "auto"; @@ -370,7 +448,9 @@ export function Chat() { { onConversation: (id) => { handle.identify(id); - // 先同步写 ref 再换 URL:路由同步 effect 因 id 相等而跳过重载,不打断流 + invalidateList(); + if (!ownsView(owner)) return; + // 先 identify 生成句柄再换 URL;layout effect 重置视图后,loadConversation 会认领该句柄。 activeIdRef.current = id; setActiveId(id); sessionStorage.setItem(lastKey(kb.id), id); @@ -379,7 +459,6 @@ export function Chat() { params: { kbId, conversationId: id }, replace: true, }); - invalidateList(); }, onSources: (sources) => handle.patchLast((t) => ({ ...t, sources })), onStep: (step) => @@ -467,7 +546,7 @@ export function Chat() { } onClick={() => { setScopeOpen(false); - if (k.id !== kb?.id) setKb(k.id); + if (k.id !== kb?.id) { claimView(null); setKb(k.id); } }} > {k.name} @@ -501,7 +580,7 @@ export function Chat() { variant={input.trim() ? "primary" : "secondary"} className="shrink-0" label={S.ask.send} - disabled={!input.trim()} + disabled={!input.trim() || loadingHistory || !!historyError} onClick={send} > @@ -558,7 +637,7 @@ export function Chat() { {recentOpen && (
- {(convs.data?.conversations ?? []).map((c: ConversationRow) => ( + {conversations.map((c: ConversationRow) => (
))} + {convs.isError && ( +
+

{S.ask.conversationsLoadFailed}

+ +
+ )} + {convs.hasNextPage && !convs.isFetchNextPageError && ( + + )} {/* 文字从 20 起:栏的 px-2(8)加行自己的 px-3(12),与「最近」和 上面每条会话的标题同一条线。写成 px-2 就落在 16,差那 4px 一眼看得出 */} - {convs.data?.conversations.length === 0 && ( + {convs.isSuccess && conversations.length === 0 && ( /* 34 = 行内距 12 + 图标 14 + 间距 8:这句话与上面每一条会话的 标题同一条竖线,而不是自己另起一列 */

@@ -662,7 +752,18 @@ export function Chat() { {/* 对话区:新对话首屏 = 问候 + 居中 composer(ChatGPT/Claude 惯例); 有消息后 composer 停靠底部 */}

- {shown.length === 0 ? ( + {idleHistoryKey === loadedKey && idleHistoryKey === viewKey(kbId, currentId) && !streaming && ( +

{S.ask.noActiveAnswer}

+ )} + {historyError ? ( +
+

{S.ask.historyLoadFailed}

+

{historyError}

+ +
+ ) : loadingHistory && !liveHere ? ( +
{S.ask.loadingHistory}
+ ) : shown.length === 0 ? ( /* 锚定上三分之一而非垂直居中:居中在高窗口下会显得下坠。 22vh + 顶部 chrome(~100px) ≈ 问候落在 37% 高度、composer 中心 ~49% */
diff --git a/web/tests/chat-pagination.test.mjs b/web/tests/chat-pagination.test.mjs new file mode 100644 index 000000000..2694b2d5a --- /dev/null +++ b/web/tests/chat-pagination.test.mjs @@ -0,0 +1,530 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { createServer } from "vite"; +// Uses the optional browser setup from rss-default.test.mjs; no app dependency. +const require = createRequire(import.meta.url); +const { chromium } = require( + process.env.CHAT_PLAYWRIGHT_PATH || "playwright-core", +); +const root = fileURLToPath(new URL("../", import.meta.url)); +const entry = ` +import React from 'react'; import {createRoot} from 'react-dom/client'; +import {QueryClient,QueryClientProvider} from '@tanstack/react-query'; +import {createRouter,createRootRoute,createRoute,RouterProvider,Outlet} from '@tanstack/react-router'; +import {Chat} from '/src/pages/Chat.tsx'; import {liveAnswer} from '/src/liveAnswer.ts'; +const parent=createRootRoute({component:Outlet}); +const routes=['/kb/$kbId/chat','/kb/$kbId/chat/$conversationId'].map(path=>createRoute({getParentRoute:()=>parent,path,component:Chat})); +const router=createRouter({routeTree:parent.addChildren(routes)}); +window.__go=to=>router.navigate({to}); window.__live=liveAnswer; +const client=new QueryClient({defaultOptions:{queries:{retry:false},mutations:{retry:false}}}); +window.__invalidate=()=>client.invalidateQueries({queryKey:["conversations"]}); +const tree=React.createElement(QueryClientProvider,{client},React.createElement(RouterProvider,{router})); +createRoot(document.getElementById('root')).render(location.search.includes('strict')?React.createElement(React.StrictMode,null,tree):tree); +`; +function plugin() { + return { + name: "chat-view-fixture", + enforce: "pre", + resolveId(id) { + if (id === "/chat-view-entry.js") return "\0chat-view-entry"; + }, + load(id) { + if (id === "\0chat-view-entry") return entry; + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url.startsWith("/kb/")) return next(); + try { + res.setHeader("content-type", "text/html"); + res.end( + await server.transformIndexHtml( + req.url, + '
', + ), + ); + } catch (e) { + next(e); + } + }); + }, + }; +} +const message = (content, role = "assistant") => ({ + role, + content, + steps: [], + sources: [], + created_at: "2026-01-01", +}); +const deferred = () => { + let resolve; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +test("real Chat conversation pagination", { timeout: 120000 }, async (t) => { + const browser = await chromium.launch({ + headless: true, + ...(process.env.CHAT_CHROMIUM_PATH + ? { executablePath: process.env.CHAT_CHROMIUM_PATH } + : {}), + }); + t.after(() => browser.close()); + const server = await createServer({ + root, + configFile: false, + plugins: [plugin()], + resolve: { alias: { "@": `${root}src` } }, + esbuild: { jsx: "automatic" }, + server: { host: "127.0.0.1", port: 0, hmr: false }, + }); + t.after(() => server.close()); + await server.listen(); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + async function open(path, custom) { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + const p = url.pathname; + if (await custom?.(route, p, url)) return; + if (p === "/api/v1/auth/me") + return route.fulfill({ json: { id: "user", is_admin: true } }); + if (p === "/api/v1/workspaces") + return route.fulfill({ json: [{ id: "ws", name: "workspace" }] }); + if (p === "/api/v1/workspaces/ws/kbs") + return route.fulfill({ + json: ["one", "two"].map((id) => ({ + id, + name: id, + workspace_id: "ws", + my_role: "owner", + })), + }); + if (p.endsWith("/readiness")) + return route.fulfill({ json: { has_chat_model: true } }); + if (p.endsWith("/conversations")) + return route.fulfill({ + json: { + conversations: ["a", "b"].map((id) => ({ + id, + title: `Conversation ${id}`, + created_at: "2026-01-01", + updated_at: "2026-01-01", + })), + total: 2, + }, + }); + if (p.endsWith("/stream")) + return route.fulfill({ + contentType: "text/event-stream", + body: "event: idle\ndata: {}\n\n", + }); + if (/\/conversations\/[ab]$/.test(p)) + return route.fulfill({ + json: { + messages: [ + message(`Answer ${p.endsWith("/a") ? "alpha" : "beta"}`), + ], + }, + }); + errors.push(`Unexpected ${p}`); + await route.fulfill({ + status: 500, + json: { error: "Unexpected request" }, + }); + }); + await page.goto(origin + path); + await page.waitForFunction(() => !!window.__go); + return { page, errors, close: () => context.close() }; + } + + await t.test( + "all 65 conversations are reachable through bounded pages", + async () => { + const offsets = []; + const rows = Array.from({ length: 65 }, (_, i) => ({ + id: `c${i}`, + title: `Conversation ${i}`, + created_at: "2026-01-01", + updated_at: "2026-01-01", + })); + const f = await open("/kb/one/chat", async (route, p, url) => { + if (p.endsWith("/conversations")) { + const offset = Number(url.searchParams.get("offset")); + const limit = Number(url.searchParams.get("limit")); + offsets.push(offset); + assert.equal(limit, 30); + await route.fulfill({ + json: { + conversations: rows.slice(offset, offset + limit), + total: rows.length, + }, + }); + return true; + } + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByText("Conversation 59", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByText("Conversation 64", { exact: true }).waitFor(); + assert.equal( + await f.page + .locator("aside") + .getByText(/^Conversation \d+$/) + .count(), + 65, + ); + assert.equal( + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .count(), + 0, + ); + assert.deepEqual(offsets, [0, 30, 60]); + } finally { + await f.close(); + } + }, + ); + + const rows = (n) => + Array.from({ length: n }, (_, i) => ({ + id: `c${i}`, + title: `Conversation ${i}`, + created_at: "2026-01-01", + updated_at: "2026-01-01", + })); + for (const total of [0, 30, 31, 60]) + await t.test(`page boundary ${total}`, async () => { + const f = await open("/kb/one/chat", async (route, p, url) => { + if (p.endsWith("/conversations")) { + const offset = Number(url.searchParams.get("offset")); + await route.fulfill({ + json: { + conversations: rows(total).slice(offset, offset + 30), + total, + }, + }); + return true; + } + }); + try { + if (total === 0) + await f.page + .getByText("No conversations yet.", { exact: true }) + .waitFor(); + else + await f.page + .getByText(`Conversation ${Math.min(total, 30) - 1}`, { + exact: true, + }) + .waitFor(); + if (total > 30) { + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page + .getByText(`Conversation ${total - 1}`, { exact: true }) + .waitFor(); + } + assert.equal( + await f.page + .locator("aside") + .getByText(/^Conversation \d+$/) + .count(), + total, + ); + assert.equal( + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .count(), + 0, + ); + } finally { + await f.close(); + } + }); + await t.test( + "failed next page retains loaded rows and retries the same offset", + async () => { + let attempts = 0; + const f = await open("/kb/one/chat", async (route, p, url) => { + if (p.endsWith("/conversations")) { + const offset = Number(url.searchParams.get("offset")); + if (offset === 30 && ++attempts === 1) + await route.fulfill({ + status: 500, + json: { error: "page failed" }, + }); + else + await route.fulfill({ + json: { + conversations: rows(35).slice(offset, offset + 30), + total: 35, + }, + }); + return true; + } + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByRole("alert").waitFor(); + assert.equal( + await f.page.getByText("Conversation 29", { exact: true }).count(), + 1, + ); + await f.page + .getByRole("button", { name: "Retry", exact: true }) + .click(); + await f.page.getByText("Conversation 34", { exact: true }).waitFor(); + assert.equal(attempts, 2); + } finally { + await f.close(); + } + }, + ); + await t.test("a late page cannot enter a changed search", async () => { + const pending = deferred(); + const f = await open("/kb/one/chat", async (route, p, url) => { + if (p.endsWith("/conversations")) { + if (url.searchParams.get("q")) + await route.fulfill({ + json: { + conversations: [ + { ...rows(1)[0], id: "filtered", title: "Filtered result" }, + ], + total: 1, + }, + }); + else if (url.searchParams.get("offset") === "30") + pending.resolve(route); + else + await route.fulfill({ json: { conversations: rows(30), total: 65 } }); + return true; + } + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + const old = await pending.promise; + await f.page.getByPlaceholder("Search chats").fill("filtered"); + await f.page.getByText("Filtered result", { exact: true }).waitFor(); + await old.fulfill({ + json: { conversations: rows(65).slice(30, 60), total: 65 }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("Conversation 30", { exact: true }).count(), + 0, + ); + assert.equal( + await f.page.getByText("Filtered result", { exact: true }).count(), + 1, + ); + } finally { + await f.close(); + } + }); + await t.test( + "overlapping pages deduplicate by ID but retain identical titles", + async () => { + const list = rows(35); + list[0].title = list[1].title = "Same title"; + const f = await open("/kb/one/chat", async (route, p, url) => { + if (p.endsWith("/conversations")) { + const second = url.searchParams.get("offset") === "30"; + await route.fulfill({ + json: { + conversations: second ? list.slice(29) : list.slice(0, 30), + total: 35, + }, + }); + return true; + } + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByText("Conversation 34", { exact: true }).waitFor(); + assert.equal( + await f.page.getByText("Conversation 29", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page.getByText("Same title", { exact: true }).count(), + 2, + ); + } finally { + await f.close(); + } + }, + ); + await t.test("search results continue within their filter", async () => { + const offsets = []; + const f = await open("/kb/one/chat", async (route, p, url) => { + if (!p.endsWith("/conversations")) return; + const offset = Number(url.searchParams.get("offset")); + const q = url.searchParams.get("q"); + if (q) offsets.push([q, offset]); + await route.fulfill({ + json: { + conversations: rows(q ? 35 : 1).slice(offset, offset + 30), + total: q ? 35 : 1, + }, + }); + return true; + }); + try { + await f.page.getByPlaceholder("Search chats").fill("needle"); + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByText("Conversation 34", { exact: true }).waitFor(); + assert.deepEqual(offsets, [ + ["needle", 0], + ["needle", 30], + ]); + } finally { + await f.close(); + } + }); + await t.test("a late page cannot enter another knowledge base", async () => { + const pending = deferred(); + const f = await open("/kb/one/chat", async (route, p, url) => { + if (!p.endsWith("/conversations")) return; + if (p.includes("/two/")) + await route.fulfill({ + json: { + conversations: [ + { + ...rows(1)[0], + id: "other", + title: "Other library conversation", + }, + ], + total: 1, + }, + }); + else if (url.searchParams.get("offset") === "30") pending.resolve(route); + else + await route.fulfill({ json: { conversations: rows(30), total: 65 } }); + return true; + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + const old = await pending.promise; + await f.page.evaluate(() => window.__go("/kb/two/chat")); + await f.page + .getByText("Other library conversation", { exact: true }) + .waitFor(); + await old.fulfill({ + json: { conversations: rows(65).slice(30, 60), total: 65 }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("Conversation 30", { exact: true }).count(), + 0, + ); + } finally { + await f.close(); + } + }); + await t.test( + "invalidation reloads all loaded pages after ordering changes", + async () => { + let list = rows(65); + const refreshed = []; + let moved = false; + const f = await open("/kb/one/chat", async (route, p, url) => { + if (!p.endsWith("/conversations")) return; + const offset = Number(url.searchParams.get("offset")); + if (moved) refreshed.push(offset); + await route.fulfill({ + json: { conversations: list.slice(offset, offset + 30), total: 65 }, + }); + return true; + }); + try { + await f.page.getByText("Conversation 29", { exact: true }).waitFor(); + await f.page + .getByRole("button", { + name: "Load earlier conversations", + exact: true, + }) + .click(); + await f.page.getByText("Conversation 59", { exact: true }).waitFor(); + list = [list[64], ...list.slice(0, 64)]; + moved = true; + await f.page.evaluate(() => window.__invalidate()); + await f.page.getByText("Conversation 64", { exact: true }).waitFor(); + assert.deepEqual(refreshed, [0, 30]); + assert.equal( + await f.page.getByText("Conversation 59", { exact: true }).count(), + 0, + ); + assert.equal( + await f.page + .locator("aside") + .getByText(/^Conversation \d+$/) + .count(), + 60, + ); + } finally { + await f.close(); + } + }, + ); +}); diff --git a/web/tests/chat-view.test.mjs b/web/tests/chat-view.test.mjs new file mode 100644 index 000000000..bc54b0eb6 --- /dev/null +++ b/web/tests/chat-view.test.mjs @@ -0,0 +1,650 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { createServer } from "vite"; +// From web/ after installing the app dependencies (no app dependency/lockfile changes): +// npm install --prefix /tmp/utopia-chat-browser-test --no-audit --no-fund --package-lock=false playwright-core@1.58.2 +// CHAT_PLAYWRIGHT_PATH=/tmp/utopia-chat-browser-test/node_modules/playwright-core CHAT_CHROMIUM_PATH="/path/to/chromium" node --test tests/chat-view.test.mjs +// Set CHAT_CHROMIUM_PATH to an installed Chrome/Chromium executable, e.g. +// /Applications/Google Chrome.app/Contents/MacOS/Google Chrome on macOS. +// These on-demand Node browser tests are separate from pnpm test (Vitest) and CI. +const require = createRequire(import.meta.url); +const { chromium } = require( + process.env.CHAT_PLAYWRIGHT_PATH || "playwright-core", +); +const root = fileURLToPath(new URL("../", import.meta.url)); +const entry = ` +import React from 'react'; import {createRoot} from 'react-dom/client'; +import {QueryClient,QueryClientProvider} from '@tanstack/react-query'; +import {createRouter,createRootRoute,createRoute,RouterProvider,Outlet} from '@tanstack/react-router'; +import {Chat} from '/src/pages/Chat.tsx'; import {liveAnswer} from '/src/liveAnswer.ts'; +const parent=createRootRoute({component:Outlet}); +const routes=['/kb/$kbId/chat','/kb/$kbId/chat/$conversationId'].map(path=>createRoute({getParentRoute:()=>parent,path,component:Chat})); +const router=createRouter({routeTree:parent.addChildren(routes)}); +window.__go=to=>router.navigate({to}); window.__live=liveAnswer; +const client=new QueryClient({defaultOptions:{queries:{retry:false},mutations:{retry:false}}}); +const tree=React.createElement(QueryClientProvider,{client},React.createElement(RouterProvider,{router})); +createRoot(document.getElementById('root')).render(location.search.includes('strict')?React.createElement(React.StrictMode,null,tree):tree); +`; +function plugin() { + return { + name: "chat-view-fixture", + enforce: "pre", + resolveId(id) { + if (id === "/chat-view-entry.js") return "\0chat-view-entry"; + }, + load(id) { + if (id === "\0chat-view-entry") return entry; + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url.startsWith("/kb/")) return next(); + try { + res.setHeader("content-type", "text/html"); + res.end( + await server.transformIndexHtml( + req.url, + '
', + ), + ); + } catch (e) { + next(e); + } + }); + }, + }; +} +const message = (content, role = "assistant") => ({ + role, + content, + steps: [], + sources: [], + created_at: "2026-01-01", +}); +const deferred = () => { + let resolve; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +test( + "real Chat view owns asynchronous work", + { timeout: 120000 }, + async (t) => { + const browser = await chromium.launch({ + headless: true, + ...(process.env.CHAT_CHROMIUM_PATH + ? { executablePath: process.env.CHAT_CHROMIUM_PATH } + : {}), + }); + t.after(() => browser.close()); + const server = await createServer({ + root, + configFile: false, + plugins: [plugin()], + resolve: { alias: { "@": `${root}src` } }, + esbuild: { jsx: "automatic" }, + server: { host: "127.0.0.1", port: 0, hmr: false }, + }); + t.after(() => server.close()); + await server.listen(); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + async function open(path, custom) { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + const p = url.pathname; + if (await custom?.(route, p, url)) return; + if (p === "/api/v1/auth/me") + return route.fulfill({ json: { id: "user", is_admin: true } }); + if (p === "/api/v1/workspaces") + return route.fulfill({ json: [{ id: "ws", name: "workspace" }] }); + if (p === "/api/v1/workspaces/ws/kbs") + return route.fulfill({ + json: ["one", "two"].map((id) => ({ + id, + name: id, + workspace_id: "ws", + my_role: "owner", + })), + }); + if (p.endsWith("/readiness")) + return route.fulfill({ json: { has_chat_model: true } }); + if (p.endsWith("/conversations")) + return route.fulfill({ + json: { + conversations: ["a", "b"].map((id) => ({ + id, + title: `Conversation ${id}`, + created_at: "2026-01-01", + updated_at: "2026-01-01", + })), + total: 2, + }, + }); + if (p.endsWith("/stream")) + return route.fulfill({ + contentType: "text/event-stream", + body: "event: idle\ndata: {}\n\n", + }); + if (/\/conversations\/[ab]$/.test(p)) + return route.fulfill({ + json: { + messages: [ + message(`Answer ${p.endsWith("/a") ? "alpha" : "beta"}`), + ], + }, + }); + errors.push(`Unexpected ${p}`); + await route.fulfill({ + status: 500, + json: { error: "Unexpected request" }, + }); + }); + await page.goto(origin + path); + await page.waitForFunction(() => !!window.__go); + return { page, errors, close: () => context.close() }; + } + for (const fail of [false, true]) + await t.test( + `late A ${fail ? "failure" : "success"} cannot replace B`, + async () => { + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + pending.resolve(route); + return true; + } + }); + try { + const a = await pending.promise; + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await a.fulfill( + fail + ? { status: 500, json: { error: "late failure" } } + : { json: { messages: [message("Late alpha")] } }, + ); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.match(f.page.url(), /\/chat\/b$/); + assert.equal( + await f.page.getByText("Answer beta", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page.getByText("Late alpha", { exact: true }).count(), + 0, + ); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); + await t.test("A to B to A rejects the first A response", async () => { + const pending = deferred(); + let count = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + if (++count === 1) { + pending.resolve(route); + return true; + } + await route.fulfill({ + json: { messages: [message("Newest alpha")] }, + }); + return true; + } + }); + try { + const old = await pending.promise; + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await f.page.evaluate(() => window.__go("/kb/one/chat/a")); + await f.page.getByText("Newest alpha", { exact: true }).waitFor(); + await old.fulfill({ json: { messages: [message("Obsolete alpha")] } }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("Newest alpha", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page.getByText("Obsolete alpha", { exact: true }).count(), + 0, + ); + } finally { + await f.close(); + } + }); + await t.test( + "late new conversation identity does not navigate away", + async () => { + const pending = deferred(); + const f = await open("/kb/one/chat", async (route, p) => { + if (p === "/api/v1/kbs/one/chat") { + pending.resolve(route); + return true; + } + }); + try { + await f.page.locator("textarea").fill("hello"); + await f.page.locator("textarea").press("Enter"); + const post = await pending.promise; + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await post.fulfill({ + contentType: "text/event-stream", + body: 'event: conversation\ndata: {"id":"late"}\n\nevent: delta\ndata: {"text":"Background answer"}\n\nevent: done\ndata: {}\n\n', + }); + await f.page.waitForFunction( + () => window.__live.entry("one", "late")?.streaming === false, + ); + assert.match(f.page.url(), /\/chat\/b$/); + assert.equal( + await f.page.evaluate( + () => window.__live.entry("one", "late").turns.at(-1).content, + ), + "Background answer", + ); + } finally { + await f.close(); + } + }, + ); + + await t.test("new chat rejects a late detail response", async () => { + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + pending.resolve(route); + return true; + } + }); + try { + const old = await pending.promise; + await f.page + .getByRole("button", { name: "New chat", exact: true }) + .click(); + await old.fulfill({ json: { messages: [message("Late alpha")] } }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.match(f.page.url(), /\/chat$/); + assert.equal( + await f.page.getByText("Late alpha", { exact: true }).count(), + 0, + ); + } finally { + await f.close(); + } + }); + await t.test( + "switching knowledge bases hides the previous transcript immediately", + async () => { + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p === "/api/v1/kbs/two/conversations/b") { + pending.resolve(route); + return true; + } + }); + try { + await f.page.getByText("Answer alpha", { exact: true }).waitFor(); + await f.page.evaluate(() => window.__go("/kb/two/chat/b")); + const next = await pending.promise; + assert.equal( + await f.page.getByText("Answer alpha", { exact: true }).count(), + 0, + ); + await next.fulfill({ + json: { messages: [message("Other library answer")] }, + }); + await f.page + .getByText("Other library answer", { exact: true }) + .waitFor(); + assert.match(f.page.url(), /\/two\/chat\/b$/); + } finally { + await f.close(); + } + }, + ); + await t.test( + "current read failure stays on the conversation and can be retried", + async () => { + let count = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + await route.fulfill( + ++count === 1 + ? { status: 500, json: { error: "temporary failure" } } + : { json: { messages: [message("Recovered history")] } }, + ); + return true; + } + }); + try { + await f.page.getByRole("alert").waitFor(); + assert.match(f.page.url(), /\/chat\/a$/); + await f.page + .getByRole("button", { name: "Retry", exact: true }) + .click(); + await f.page + .getByText("Recovered history", { exact: true }) + .waitFor(); + assert.equal(count, 2); + } finally { + await f.close(); + } + }, + ); + await t.test( + "missing conversation keeps the existing new-chat redirect", + async () => { + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + await route.fulfill({ status: 404, json: { error: "Not found" } }); + return true; + } + }); + try { + await f.page.waitForURL("**/kb/one/chat"); + assert.equal(await f.page.getByRole("alert").count(), 0); + } finally { + await f.close(); + } + }, + ); + await t.test( + "StrictMode loads history and initializes one reattachment", + async () => { + let streams = 0; + const f = await open("/kb/one/chat/a?strict", async (route, p) => { + if (p.endsWith("/conversations/a")) { + await route.fulfill({ + json: { messages: [message("Question", "user")] }, + }); + return true; + } + if (p.endsWith("/stream")) { + streams++; + await route.fulfill({ + contentType: "text/event-stream", + body: 'event: snapshot\ndata: {"content":"Restored answer","steps":[],"sources":[]}\n\nevent: done\ndata: {}\n\n', + }); + return true; + } + }); + try { + await f.page.getByText("Restored answer", { exact: true }).waitFor(); + assert.equal(streams, 1); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); + await t.test( + "late reattachment snapshot cannot create a stale live entry", + async () => { + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + await route.fulfill({ + json: { messages: [message("Question", "user")] }, + }); + return true; + } + if (p.endsWith("/conversations/a/stream")) { + pending.resolve(route); + return true; + } + }); + try { + const old = await pending.promise; + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await old.fulfill({ + contentType: "text/event-stream", + body: 'event: snapshot\ndata: {"content":"Late snapshot","steps":[],"sources":[]}\n\nevent: done\ndata: {}\n\n', + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.evaluate(() => window.__live.entry("one", "a")), + null, + ); + assert.equal( + await f.page.getByText("Answer beta", { exact: true }).count(), + 1, + ); + } finally { + await f.close(); + } + }, + ); + + await t.test( + "selecting the current conversation does not invalidate its pending read", + async () => { + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + pending.resolve(route); + return true; + } + }); + try { + const a = await pending.promise; + await f.page.getByText("Conversation a", { exact: true }).click(); + await a.fulfill({ json: { messages: [message("Current answer")] } }); + await f.page.getByText("Current answer", { exact: true }).waitFor(); + } finally { + await f.close(); + } + }, + ); + await t.test( + "idle reattachment rereads a just-completed history exactly once", + async () => { + let reads = 0, + posts = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill({ + json: { + messages: + reads === 1 + ? [message("Question", "user")] + : [ + message("Question", "user"), + message("Saved between reads"), + ], + }, + }); + return true; + } + if (p.endsWith("/chat") && route.request().method() === "POST") { + posts++; + return false; + } + }); + try { + await f.page + .getByText("Saved between reads", { exact: true }) + .waitFor(); + assert.equal(reads, 2); + assert.equal(posts, 0); + } finally { + await f.close(); + } + }, + ); + await t.test( + "idle plus unanswered history is bounded without resending", + async () => { + let reads = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill({ + json: { messages: [message("Still unanswered", "user")] }, + }); + return true; + } + }); + try { + await f.page + .getByText( + "No active answer was found. You can send a new message.", + { exact: true }, + ) + .waitFor(); + assert.equal(reads, 2); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); + await t.test( + "late idle refresh cannot overwrite another conversation", + async () => { + let reads = 0; + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + if (++reads === 1) + await route.fulfill({ + json: { messages: [message("Question", "user")] }, + }); + else pending.resolve(route); + return true; + } + }); + try { + const refill = await Promise.race([ + pending.promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("expected idle refresh request")), + 5000, + ), + ), + ]); + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await refill.fulfill({ + json: { messages: [message("Obsolete saved answer")] }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("Answer beta", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page + .getByText("Obsolete saved answer", { exact: true }) + .count(), + 0, + ); + } finally { + await f.close(); + } + }, + ); + await t.test( + "an idle refresh failure can be retried without a POST", + async () => { + let reads = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill( + reads === 2 + ? { status: 500, json: { error: "refresh failed" } } + : { + json: { + messages: + reads === 1 + ? [message("Question", "user")] + : [message("Recovered saved answer")], + }, + }, + ); + return true; + } + }); + try { + await f.page.getByRole("alert").waitFor(); + await f.page + .getByRole("button", { name: "Retry", exact: true }) + .click(); + await f.page + .getByText("Recovered saved answer", { exact: true }) + .waitFor(); + assert.equal(reads, 3); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); + + await t.test( + "a pending idle refresh cannot overwrite a new send", + async () => { + const pending = deferred(); + let reads = 0; + let posts = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + if (++reads === 1) + await route.fulfill({ + json: { messages: [message("Earlier question", "user")] }, + }); + else pending.resolve(route); + return true; + } + if (p.endsWith("/chat") && route.request().method() === "POST") { + posts++; + await route.fulfill({ + contentType: "text/event-stream", + body: 'event: conversation\ndata: {"id":"a"}\n\nevent: delta\ndata: {"text":"New answer"}\n\nevent: done\ndata: {}\n\n', + }); + return true; + } + }); + try { + const old = await pending.promise; + await f.page.getByPlaceholder("Ask anything…").fill("New question"); + await f.page.getByPlaceholder("Ask anything…").press("Enter"); + await f.page.getByText("New answer", { exact: true }).waitFor(); + await old.fulfill({ + json: { messages: [message("Obsolete refreshed answer")] }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("New answer", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page + .getByText("Obsolete refreshed answer", { exact: true }) + .count(), + 0, + ); + assert.equal(posts, 1); + } finally { + await f.close(); + } + }, + ); + }, +);