diff --git a/.changeset/use-chat-hidden-activity.md b/.changeset/use-chat-hidden-activity.md new file mode 100644 index 0000000000..c024f62e61 --- /dev/null +++ b/.changeset/use-chat-hidden-activity.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-react': patch +--- + +`useChat` keeps working in a view hidden by React's ``. Hiding runs the hook's effect cleanups, and the deferred teardown disposed the client while React kept the hook, so the view came back with a dead client: a reply in flight never resumed and the thread did not load again. When the view shows again, `useChat` now replaces the disposed client with a new one that starts from the messages on screen, loads the thread and rejoins its run. diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index 99a5db52c4..582293a1b5 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -121,6 +121,44 @@ State values are plain React values. The hook renders again when they change. | `partial`, `final` | Structured output. Only when you pass `outputSchema`. | | `subagents` | Live child-agent runs. See [Subagents](../chat/subagents). | +### Hidden views + +You can keep a chat mounted while it is hidden, for example in ``. A hidden chat holds no connection to the server. + +When the chat shows again, `useChat` starts a new client for the same thread, with the messages that were on screen. With `persistence: true`, the client loads the thread again and joins the run that is in progress. + +```tsx +import { Activity, useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +function Chat() { + const { messages } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + persistence: true, + }); + + return

{messages.length} messages

; +} + +export function Tabs() { + const [tab, setTab] = useState<"chat" | "settings">("chat"); + + return ( +
+ + + + +
+ ); +} +``` + +To join a run, the server needs the `GET` handler from [Resumable Streams](../resumable-streams/overview). + ### Example: tool approval A tool with `needsApproval: true` pauses the run. The pending call shows up in `interrupts`. diff --git a/docs/config.json b/docs/config.json index 359b32483f..afbbbf20c2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -2010,7 +2010,7 @@ "label": "@tanstack/ai-react", "to": "api/ai-react", "addedAt": "2026-04-15", - "updatedAt": "2026-10-21" + "updatedAt": "2026-10-24" }, { "label": "@tanstack/ai-solid", diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index 1be4a291da..7a7f916afa 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -51,6 +51,16 @@ export function useChat< client: ChatClient timeout: ReturnType } | null>(null) + // A client the deferred teardown disposed while the hook stayed mounted: a + // hidden `` keeps state and memos, runs the effect cleanups, and + // runs the effects again when it shows. The teardown replaces the client, + // which starts from the messages it had on screen for the same thread, so + // the hook never renders a disposed client. + const disposedClientRef = useRef<{ + client: ChatClient + threadId: string | undefined + } | null>(null) + const [clientRevision, setClientRevision] = useState(0) // Track current options in a ref to avoid recreating client when options change const optionsRef = @@ -63,7 +73,11 @@ export function useChat< // restores the same conversation. User callbacks read latest options. const { client, initialization, serverSnapshot, startPersistenceHydration } = useMemo(() => { - const messagesToUse = options.initialMessages || [] + const disposed = disposedClientRef.current + const messagesToUse = + disposed && disposed.threadId === options.threadId + ? disposed.client.getMessages() + : options.initialMessages || [] const initialOptions = optionsRef.current const { persistence, release: releasePersistenceHydration } = @@ -184,7 +198,7 @@ export function useChat< serverSnapshot: capturedServerSnapshot, startPersistenceHydration: releasePersistenceHydration, } - }, [options.threadId]) + }, [options.threadId, clientRevision]) useEffect(() => { activeClientRef.current = client @@ -287,6 +301,9 @@ export function useChat< }, [client, startPersistenceHydration]) useEffect(() => { + disposedClientRef.current = null + // The options this client was made with, as committed alongside it. + const threadId = optionsRef.current.threadId if (cleanupDisposalRef.current?.client === client) { clearTimeout(cleanupDisposalRef.current.timeout) cleanupDisposalRef.current = null @@ -309,7 +326,9 @@ export function useChat< // later; calling `stop()` would abort a constructor rejoin // (`resumeInFlightRun`) and can wipe the durable resume pointer before // the first chunk. Real teardown lives in the deferred dispose path - // below, which only runs when the client is not remounted. + // below, which only runs when the client is not remounted. A hidden + // `` runs it too and keeps the hook: the teardown then replaces + // the disposed client. // Subscribe/unsubscribe on `options.live` is still owned by the // dedicated effect above for live toggles. const disposal = { @@ -321,6 +340,12 @@ export function useChat< client.stop() } client.dispose() + // Replace it only if the hook has no newer client: on a new + // threadId the new client is active by now, and stays. + if (activeClientRef.current === null) { + disposedClientRef.current = { client, threadId } + setClientRevision((revision) => revision + 1) + } if (cleanupDisposalRef.current === disposal) { cleanupDisposalRef.current = null } diff --git a/packages/ai-react/tests/use-chat.test.ts b/packages/ai-react/tests/use-chat.test.ts index 8bea6343f4..48d800298a 100644 --- a/packages/ai-react/tests/use-chat.test.ts +++ b/packages/ai-react/tests/use-chat.test.ts @@ -2,9 +2,11 @@ import { EventType } from '@tanstack/ai' import { ChatClient } from '@tanstack/ai-client' import { act, render, renderHook, waitFor } from '@testing-library/react' import { + Activity, StrictMode, Suspense, createElement, + useEffect, useLayoutEffect, useState, } from 'react' @@ -3184,4 +3186,244 @@ describe('useChat', () => { ) }) }) + + describe('inside a hidden ', () => { + // React keeps a hidden Activity's state and memos but runs its effect + // cleanups, then runs the effects again when it shows. A view hidden for + // longer than a tick must come back with a working client. + function createRunServer() { + const chunks: Array = [] + const waiters = new Set<() => void>() + let done = false + const push = (...next: Array) => { + chunks.push(...next) + for (const wake of waiters) wake() + waiters.clear() + } + // The run's durable log: from the start, then live until it ends. + async function* tail(signal?: AbortSignal) { + let read = 0 + for (;;) { + if (signal?.aborted) return + if (read < chunks.length) { + yield chunks[read++]! + continue + } + if (done) return + await new Promise((resolve) => { + waiters.add(resolve) + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + } + } + const hydrate = vi.fn(async (_threadId: string) => ({ + messages: [], + activeRun: chunks.length > 0 && !done ? { runId: 'r1' } : null, + interrupts: null, + })) + const joinRun = vi.fn((_runId: string, signal?: AbortSignal) => + tail(signal), + ) + const connection: ResumableConnectConnectionAdapter = { + connect: (_messages, _data, signal) => { + push( + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'a1', + role: 'assistant', + timestamp: 2, + }, + ) + return tail(signal) + }, + joinRun, + hydrate, + } + return { + connection, + hydrate, + joinRun, + say: (delta: string) => + push({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'a1', + delta, + timestamp: 3, + }), + finish: () => { + push( + { type: EventType.TEXT_MESSAGE_END, messageId: 'a1', timestamp: 4 }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 5, + }, + ) + done = true + }, + } + } + + function renderInActivity(connection: ResumableConnectConnectionAdapter) { + let mode: 'visible' | 'hidden' = 'visible' + const rendered = renderHook( + ({ threadId }: { threadId: string }) => + useChat({ connection, threadId, persistence: true }), + { + initialProps: { threadId: 't1' }, + wrapper: ({ children }) => + createElement(Activity, { mode, children }), + }, + ) + const show = (shown: boolean, threadId = 't1') => { + mode = shown ? 'visible' : 'hidden' + rendered.rerender({ threadId }) + } + return { ...rendered, show } + } + + const assistantText = (messages: ReadonlyArray) => { + const part = messages + .find((m) => m.role === 'assistant') + ?.parts.find((p) => p.type === 'text') + return part && 'content' in part ? part.content : undefined + } + // Longer than the tick the hook waits before it disposes a client whose + // effects did not run again. + const outlastDisposal = () => + act(() => new Promise((resolve) => setTimeout(resolve, 10))) + + it('rejoins the run a hidden view left when it shows again', async () => { + const server = createRunServer() + const { result, show } = renderInActivity(server.connection) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + + act(() => { + void result.current.sendMessage('hi') + }) + server.say('Hel') + await waitFor(() => + expect(assistantText(result.current.messages)).toBe('Hel'), + ) + + act(() => show(false)) + await outlastDisposal() + server.say('lo') + act(() => show(true)) + server.finish() + + await waitFor(() => + expect(assistantText(result.current.messages)).toBe('Hello'), + ) + expect(server.joinRun).toHaveBeenCalledWith('r1', expect.anything()) + expect(result.current.status).toBe('ready') + }) + + it('shows again with the new client, never the disposed one', async () => { + const server = createRunServer() + // What the view's own effects read, as they run on each commit. + const loadingInEffects: Array = [] + let mode: 'visible' | 'hidden' = 'visible' + const { result, rerender } = renderHook( + () => { + const chat = useChat({ + connection: server.connection, + threadId: 't1', + persistence: true, + }) + useEffect(() => { + loadingInEffects.push(chat.isHydrating) + }) + return chat + }, + { + wrapper: ({ children }) => + createElement(Activity, { mode, children }), + }, + ) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + + mode = 'hidden' + rerender() + await outlastDisposal() + const shownAt = loadingInEffects.length + mode = 'visible' + rerender() + + // The effects that run as the view shows read a client that loads the + // thread, not the idle snapshot of the client the teardown disposed. + expect(loadingInEffects[shownAt]).toBe(true) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + }) + + it('keeps the transcript on screen after an idle hide', async () => { + const server = createRunServer() + const { result, show } = renderInActivity(server.connection) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + act(() => { + void result.current.sendMessage('hi') + }) + server.say('Hello') + server.finish() + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => show(false)) + await outlastDisposal() + const loads = server.hydrate.mock.calls.length + act(() => show(true)) + + // The new client loads the thread, and the server sends no messages. + await waitFor(() => { + expect(server.hydrate.mock.calls.length).toBeGreaterThan(loads) + expect(result.current.isHydrating).toBe(false) + }) + expect(assistantText(result.current.messages)).toBe('Hello') + }) + + it('keeps the client of a new thread, and disposes only the old one', async () => { + const server = createRunServer() + const { result, show } = renderInActivity(server.connection) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + + act(() => show(true, 't2')) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + await outlastDisposal() + await outlastDisposal() + + // The old thread's teardown disposes its own client, not the new one: + // the new thread loads once and stays loaded. + const loadsOfT2 = server.hydrate.mock.calls.filter( + ([threadId]) => threadId === 't2', + ) + expect(loadsOfT2).toHaveLength(1) + expect(result.current.isHydrating).toBe(false) + }) + + it('starts another thread empty when it shows on a new threadId', async () => { + const server = createRunServer() + const { result, show } = renderInActivity(server.connection) + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + act(() => { + void result.current.sendMessage('hi') + }) + server.say('Hello') + server.finish() + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => show(false)) + await outlastDisposal() + act(() => show(true, 't2')) + + await waitFor(() => expect(result.current.isHydrating).toBe(false)) + expect(result.current.messages).toEqual([]) + }) + }) }) diff --git a/testing/e2e/README.md b/testing/e2e/README.md index e28cb81663..e784e911e6 100644 --- a/testing/e2e/README.md +++ b/testing/e2e/README.md @@ -98,6 +98,7 @@ Notes: | `tests/delivery-durability.spec.ts` | Transport layer: offset-tagged log, `Last-Event-ID` reconnect, second-tab join, SSE + NDJSON | | `tests/persistence-durability.spec.ts` | Client layer: a browser refresh restores the conversation and any pending interrupt | | `tests/join-run-client-tool.spec.ts` | Mid-run reload: `joinRun` replay that ends on a client tool drains the continuation (issue #1058) | +| `tests/use-chat-hidden-view.spec.ts` | Client layer: a chat that `` hides mid-reply loads the thread and joins the run when it shows again | | `tests/sandbox-durability.spec.ts` | Sandbox instances: a second run resumes the persisted sandbox | | `tests/durable-takeover.spec.ts` | Takeover with log alignment, detach-on-disconnect, out-of-band cancel in both bands, cancel-vs-disconnect divergence, and the superseded-driver epoch fence | diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 35cb311a76..7047eb15f1 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -45,6 +45,7 @@ import { Route as SubagentsUiTestRouteImport } from './routes/subagents-ui-test' import { Route as TextFirstToolRouteImport } from './routes/text-first-tool' import { Route as ToolFirstTextRouteImport } from './routes/tool-first-text' import { Route as ToolsTestRouteImport } from './routes/tools-test' +import { Route as UseChatHiddenViewRouteImport } from './routes/use-chat-hidden-view' import { Route as WebMcpPageToolsRouteImport } from './routes/web-mcp-page-tools' import { Route as WebMcpToolsRouteImport } from './routes/web-mcp-tools' import { Route as WebsocketAdapterRouteImport } from './routes/websocket-adapter' @@ -193,6 +194,7 @@ import { Route as ApiToolFirstTextWireRouteImport } from './routes/api.tool-firs import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription' import { Route as ApiTtsRouteImport } from './routes/api.tts' +import { Route as ApiUseChatHiddenViewRouteImport } from './routes/api.use-chat-hidden-view' import { Route as ApiVideoRouteImport } from './routes/api.video' import { Route as ApiVideoLiveRouteImport } from './routes/api.video-live' import { Route as ApiVoiceRouteImport } from './routes/api.voice' @@ -393,6 +395,11 @@ const ToolsTestRoute = ToolsTestRouteImport.update({ path: '/tools-test', getParentRoute: () => rootRouteImport, } as any) +const UseChatHiddenViewRoute = UseChatHiddenViewRouteImport.update({ + id: '/use-chat-hidden-view', + path: '/use-chat-hidden-view', + getParentRoute: () => rootRouteImport, +} as any) const WebMcpPageToolsRoute = WebMcpPageToolsRouteImport.update({ id: '/web-mcp-page-tools', path: '/web-mcp-page-tools', @@ -1177,6 +1184,11 @@ const ApiTtsRoute = ApiTtsRouteImport.update({ path: '/api/tts', getParentRoute: () => rootRouteImport, } as any) +const ApiUseChatHiddenViewRoute = ApiUseChatHiddenViewRouteImport.update({ + id: '/api/use-chat-hidden-view', + path: '/api/use-chat-hidden-view', + getParentRoute: () => rootRouteImport, +} as any) const ApiVideoRoute = ApiVideoRouteImport.update({ id: '/api/video', path: '/api/video', @@ -1297,6 +1309,7 @@ export interface FileRoutesByFullPath { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/use-chat-hidden-view': typeof UseChatHiddenViewRoute '/web-mcp-page-tools': typeof WebMcpPageToolsRoute '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -1444,6 +1457,7 @@ export interface FileRoutesByFullPath { '/api/tools-test': typeof ApiToolsTestRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren + '/api/use-chat-hidden-view': typeof ApiUseChatHiddenViewRoute '/api/video': typeof ApiVideoRouteWithChildren '/api/video-live': typeof ApiVideoLiveRoute '/api/voice': typeof ApiVoiceRoute @@ -1499,6 +1513,7 @@ export interface FileRoutesByTo { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/use-chat-hidden-view': typeof UseChatHiddenViewRoute '/web-mcp-page-tools': typeof WebMcpPageToolsRoute '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -1646,6 +1661,7 @@ export interface FileRoutesByTo { '/api/tools-test': typeof ApiToolsTestRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren + '/api/use-chat-hidden-view': typeof ApiUseChatHiddenViewRoute '/api/video': typeof ApiVideoRouteWithChildren '/api/video-live': typeof ApiVideoLiveRoute '/api/voice': typeof ApiVoiceRoute @@ -1702,6 +1718,7 @@ export interface FileRoutesById { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/use-chat-hidden-view': typeof UseChatHiddenViewRoute '/web-mcp-page-tools': typeof WebMcpPageToolsRoute '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -1849,6 +1866,7 @@ export interface FileRoutesById { '/api/tools-test': typeof ApiToolsTestRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren + '/api/use-chat-hidden-view': typeof ApiUseChatHiddenViewRoute '/api/video': typeof ApiVideoRouteWithChildren '/api/video-live': typeof ApiVideoLiveRoute '/api/voice': typeof ApiVoiceRoute @@ -1906,6 +1924,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/use-chat-hidden-view' | '/web-mcp-page-tools' | '/web-mcp-tools' | '/websocket-adapter' @@ -2053,6 +2072,7 @@ export interface FileRouteTypes { | '/api/tools-test' | '/api/transcription' | '/api/tts' + | '/api/use-chat-hidden-view' | '/api/video' | '/api/video-live' | '/api/voice' @@ -2108,6 +2128,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/use-chat-hidden-view' | '/web-mcp-page-tools' | '/web-mcp-tools' | '/websocket-adapter' @@ -2255,6 +2276,7 @@ export interface FileRouteTypes { | '/api/tools-test' | '/api/transcription' | '/api/tts' + | '/api/use-chat-hidden-view' | '/api/video' | '/api/video-live' | '/api/voice' @@ -2310,6 +2332,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/use-chat-hidden-view' | '/web-mcp-page-tools' | '/web-mcp-tools' | '/websocket-adapter' @@ -2457,6 +2480,7 @@ export interface FileRouteTypes { | '/api/tools-test' | '/api/transcription' | '/api/tts' + | '/api/use-chat-hidden-view' | '/api/video' | '/api/video-live' | '/api/voice' @@ -2513,6 +2537,7 @@ export interface RootRouteChildren { TextFirstToolRoute: typeof TextFirstToolRoute ToolFirstTextRoute: typeof ToolFirstTextRoute ToolsTestRoute: typeof ToolsTestRoute + UseChatHiddenViewRoute: typeof UseChatHiddenViewRoute WebMcpPageToolsRoute: typeof WebMcpPageToolsRoute WebMcpToolsRoute: typeof WebMcpToolsRoute WebsocketAdapterRoute: typeof WebsocketAdapterRoute @@ -2660,6 +2685,7 @@ export interface RootRouteChildren { ApiToolsTestRoute: typeof ApiToolsTestRoute ApiTranscriptionRoute: typeof ApiTranscriptionRouteWithChildren ApiTtsRoute: typeof ApiTtsRouteWithChildren + ApiUseChatHiddenViewRoute: typeof ApiUseChatHiddenViewRoute ApiVideoRoute: typeof ApiVideoRouteWithChildren ApiVideoLiveRoute: typeof ApiVideoLiveRoute ApiVoiceRoute: typeof ApiVoiceRoute @@ -2928,6 +2954,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ToolsTestRouteImport parentRoute: typeof rootRouteImport } + '/use-chat-hidden-view': { + id: '/use-chat-hidden-view' + path: '/use-chat-hidden-view' + fullPath: '/use-chat-hidden-view' + preLoaderRoute: typeof UseChatHiddenViewRouteImport + parentRoute: typeof rootRouteImport + } '/web-mcp-page-tools': { id: '/web-mcp-page-tools' path: '/web-mcp-page-tools' @@ -3964,6 +3997,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTtsRouteImport parentRoute: typeof rootRouteImport } + '/api/use-chat-hidden-view': { + id: '/api/use-chat-hidden-view' + path: '/api/use-chat-hidden-view' + fullPath: '/api/use-chat-hidden-view' + preLoaderRoute: typeof ApiUseChatHiddenViewRouteImport + parentRoute: typeof rootRouteImport + } '/api/video': { id: '/api/video' path: '/api/video' @@ -4174,6 +4214,7 @@ const rootRouteChildren: RootRouteChildren = { TextFirstToolRoute: TextFirstToolRoute, ToolFirstTextRoute: ToolFirstTextRoute, ToolsTestRoute: ToolsTestRoute, + UseChatHiddenViewRoute: UseChatHiddenViewRoute, WebMcpPageToolsRoute: WebMcpPageToolsRoute, WebMcpToolsRoute: WebMcpToolsRoute, WebsocketAdapterRoute: WebsocketAdapterRoute, @@ -4327,6 +4368,7 @@ const rootRouteChildren: RootRouteChildren = { ApiToolsTestRoute: ApiToolsTestRoute, ApiTranscriptionRoute: ApiTranscriptionRouteWithChildren, ApiTtsRoute: ApiTtsRouteWithChildren, + ApiUseChatHiddenViewRoute: ApiUseChatHiddenViewRoute, ApiVideoRoute: ApiVideoRouteWithChildren, ApiVideoLiveRoute: ApiVideoLiveRoute, ApiVoiceRoute: ApiVoiceRoute, diff --git a/testing/e2e/src/routes/api.use-chat-hidden-view.ts b/testing/e2e/src/routes/api.use-chat-hidden-view.ts new file mode 100644 index 0000000000..a478a61b5a --- /dev/null +++ b/testing/e2e/src/routes/api.use-chat-hidden-view.ts @@ -0,0 +1,106 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Provider-free harness route for a `useChat` view that `` hides + * while a reply streams. Exempt from the aimock policy: it never calls an LLM. + * + * - `POST` streams the first half of the reply. The run then waits for + * `POST ?action=finish&threadId=…` before it streams the second half. + * - `GET ?threadId=…` is the mount load of a `persistence: true` client. It + * names the run of the thread while that run is in progress. + * - `GET ?offset=-1&runId=…` replays the log of the run (`joinRun`). + */ + +const FIRST_HALF = 'HIDDEN_VIEW the first half,' +const SECOND_HALF = ' and the second half.' + +const runsInProgress = new Map void }>() + +function reply( + threadId: string, + runId: string, + finished: Promise, +): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_START', + messageId: `assistant-${runId}`, + role: 'assistant', + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: `assistant-${runId}`, + delta: FIRST_HALF, + timestamp: Date.now(), + } as StreamChunk + await finished + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: `assistant-${runId}`, + delta: SECOND_HALF, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_END', + messageId: `assistant-${runId}`, + timestamp: Date.now(), + } as StreamChunk + runsInProgress.delete(threadId) + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } as StreamChunk + })() +} + +export const Route = createFileRoute('/api/use-chat-hidden-view')({ + server: { + handlers: { + POST: async ({ request }) => { + const url = new URL(request.url) + if (url.searchParams.get('action') === 'finish') { + const threadId = url.searchParams.get('threadId') ?? '' + runsInProgress.get(threadId)?.finish() + return new Response(null, { status: 204 }) + } + const body: { threadId: string; runId: string } = await request.json() + let finish = () => {} + const finished = new Promise((resolve) => (finish = resolve)) + runsInProgress.set(body.threadId, { runId: body.runId, finish }) + return toServerSentEventsResponse( + reply(body.threadId, body.runId, finished), + { durability: { adapter: memoryStream(request) } }, + ) + }, + GET: ({ request }) => { + const durability = memoryStream(request) + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + const threadId = new URL(request.url).searchParams.get('threadId') + const run = threadId ? runsInProgress.get(threadId) : undefined + return Response.json({ + messages: [], + activeRun: run ? { runId: run.runId } : null, + interrupts: null, + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/use-chat-hidden-view.tsx b/testing/e2e/src/routes/use-chat-hidden-view.tsx new file mode 100644 index 0000000000..9ac840197b --- /dev/null +++ b/testing/e2e/src/routes/use-chat-hidden-view.tsx @@ -0,0 +1,81 @@ +import { Activity, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +/** + * Harness page for a `useChat` view that `` hides and shows. The + * toggle stays outside the Activity. `?thread=` gives each test its own thread + * on `/api/use-chat-hidden-view`. + */ +export const Route = createFileRoute('/use-chat-hidden-view')({ + component: UseChatHiddenViewPage, + validateSearch: (search: Record) => ({ + thread: + typeof search.thread === 'string' + ? search.thread + : 'use-chat-hidden-view', + }), +}) + +const connection = fetchServerSentEvents('/api/use-chat-hidden-view') + +function UseChatHiddenViewPage() { + const { thread } = Route.useSearch() + const [shown, setShown] = useState(true) + + return ( +
+ + + + +
+ ) +} + +function Chat({ threadId }: { threadId: string }) { + const [input, setInput] = useState('') + const { messages, sendMessage } = useChat({ + threadId, + connection, + persistence: true, + }) + + return ( +
+
+ {messages.map((message) => ( +
+ {message.parts.map((part, index) => + part.type === 'text' ? ( + {part.content} + ) : null, + )} +
+ ))} +
+ setInput(e.target.value)} + /> + +
+ ) +} diff --git a/testing/e2e/tests/use-chat-hidden-view.spec.ts b/testing/e2e/tests/use-chat-hidden-view.spec.ts new file mode 100644 index 0000000000..6306222b69 --- /dev/null +++ b/testing/e2e/tests/use-chat-hidden-view.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' +import { sendMessage } from './helpers' + +/** + * A `useChat` view that `` hides while a reply streams. When the view + * shows again, `useChat` loads the thread, joins the run, and shows the rest of + * the reply. + * + * Provider-free: `/api/use-chat-hidden-view` streams a fixed reply that waits + * for the test before its second half, so there is nothing to mock. + */ +test('a hidden chat shows the rest of the reply when it shows again', async ({ + page, +}, testInfo) => { + const thread = encodeURIComponent(`hidden-view-${testInfo.testId}`) + await page.goto(`/use-chat-hidden-view?thread=${thread}`) + + await sendMessage(page, 'tell me in two halves') + const reply = page.getByTestId('assistant-message') + await expect(reply).toHaveText('HIDDEN_VIEW the first half,') + + await page.getByTestId('toggle-view').click() + // ponytail: useChat disposes a hidden client one tick after the hide, 100 ms covers it. + await page.waitForTimeout(100) + // The shown chat joins the run (`?offset=-1`). Only then the run ends. + const joined = page.waitForRequest((request) => + request.url().includes('/api/use-chat-hidden-view?offset=-1'), + ) + await page.getByTestId('toggle-view').click() + await joined + await page.request.post( + `/api/use-chat-hidden-view?action=finish&threadId=${thread}`, + ) + + await expect(reply).toHaveText( + 'HIDDEN_VIEW the first half, and the second half.', + ) +})