enhancement
major
minor
defect
critical
major
minor
major
#30126
tl-chat: Chat actions run as React commands within the window rather than via a separate ChatServlet; the chat's proxy workaround is no longer used in favor of engine polling
Context
The chat feature of the React interface ( tl-chat module: <chat-conversation>, <chat-rail>, <chat-dock>, Sections) consists of React controls but communicates with the server primarily via its own JSON API: ChatServlet at /chat/* with about 20 endpoints (send, respond, cancel, regenerate, truncate, feedback, sessions, preferences, approval-mode, improve-prompt, subagents, interactions, capabilities, updates, stream), called via fetch from tl-chat/ui/src/api.ts. The API dated back to when the chat was a standalone page.
As a result:
- Every request had to include its window and conversation (windowId, conversation); the server retrieved them via ChatSessionRegistry (backward lookup of the sub-session, Map CHAT_TRACKERS) and the cached window state ChatViewBinding. Cached state of this kind repeatedly returned outdated values (layout, language, login status #30109).
- Custom access control (ChatSessionFilter, 401 for anonymous users).
- The calls bypassed the window’s command queue (command-channel.ts) and were invisible to script recorders, replays, and headless agents (ScriptingSession).
In addition, tl-chat introduced its own workaround for proxies that block the window’s event stream (script/tl-window-stream.js, WindowPollServlet, PolledWindow, Gitea #940). The script replaced window.EventSource and wrapped window.fetch. Since #29878, the engine has been polling on its own (react-api/poll, PollServlet, sse-client.ts), but the script did not check for this: In every application using tl-chat, both mechanisms ran concurrently.
Change
- The chat’s proxy workaround is no longer needed. Behind a caching proxy, the engine handles the polling.
- The chat’s actions are React commands from the chat controls. State is managed via the control state; ongoing replies are handled via the window’s event stream; attachments are handled via the engine’s UploadHandler; and large read operations (transcripts) are handled via the window-specific route /react-api/data.
- This eliminates the need for ChatServlet, ChatSessionFilter, the reverse lookup in ChatSessionRegistry, and the cached window state in ChatViewBinding.
Solution
- Response from a React command (Engine, com.top_logic.layout.react): A @ReactCommandHandler or UploadHandler can include a response in its HandlerResult (ReactCommandReply.create().put(...).toResult()); ReactServlet writes these fields, along with "success," into the command’s response. On the client side, useTLCommand() and useTLUpload() return this response (CommandResponse). The Script Recorder ignores the response; the headless API ( /agent-api/act) returns it as the `reply` field. Described in the article view-layer/client-server-protocol (“Answering a command”).
- Additional engine enhancements: SSEUpdateQueue.registerListener allows patches sent to a listener ID that is not a control (the chat’s frame stream) to survive the end of an interaction. The state of a ReactControl may contain msgbuf objects. CommandOptions.ignoreStaleUi allows a command that merely cleans up after something that has disappeared to discard the command if the page is stale, rather than reloading the page.
- Commands for chat controls: All chat controls (<chat-conversation>, <chat-rail>, <chat-dock>, Sections) share the common base class ChatControl with the following commands: Subscribe/unsubscribe to stream, request execution status, open and delete conversation, send (creates the conversation upon first send), edit and resend (a command to ensure that a rejected send does not lose replies), Regenerate, Remove Attachment, Respond to Approval/Clarification Request, Abort Run, Rating, Approval Mode, Improve and Abort Prompt, Message to a Subagent, Abort Subagent, Save Layout. Rejections (busy, no permission, limit reached) are returned as the status and reason in the command’s response.
- State instead of queries: Capabilities (agents, models, MCP server) and the list of conversations are stored in the control’s state; if the list changes, the server rewrites it. The browser reads transcripts (including those from subagents) via the Control’s /react-api/data endpoint. Run state, open approvals/requests, and subagents are included in a `run_state` frame when the stream is registered and in the command response when a conversation is opened. The server evaluates the page’s context facts (`<context>`) itself upon sending.
- Attachments are uploaded via the Control’s UploadHandler and remain on the server in the conversation’s attachment list ( draftAttachments state) until they are sent; there is no longer any Base64 in the JSON. The server checks the type, size (10 MB or the application’s upload limit), and number (5) with its own message.
- Prompt improvement runs in the background; its result is returned as a `prompt_improved` frame so that the LLM call does not block the window’s command sequence.
- Login in the same window: The chat calculates the login status and name on every render. If the user of a window changes, they receive their own binding and their own tracker; ongoing replies, shares, and follow-up questions from the previous user in this window are aborted, and their presentations do not reach the new user’s side.
- Removed: ChatServlet (/chat/*) along with request/response DTOs, ChatSessionFilter, ChatSessionRegistry.windowId/`resolveSubSession`, the cached values in ChatViewBinding, api.ts calls via `fetch`, `expectFrames()`, and the proxy workaround (tl-window-stream.js, WindowPollServlet, PolledWindow). ReactViewComponent (tl-chat-classic) takes the window name from its sub-session.
Tested
- Test suites for com.top_logic.layout.react, com.top_logic.layout.view, tl-chat (Java and vitest), and tl-chat-classic; the existing servlet tests have been adapted to use commands (ChatCommandDriver).
- tl-chat/proxy-rig/: all 14 profiles (direct, caching, compressing, SSE-disabling, and blocking proxies; React page and classic tab) pass as of the state after the merge from master; behind a caching proxy, the engine takes over polling.
- Tested in the browser in tl-ai-demo (React page and classic tab).
Migration
Only necessary for applications that call the Chat API directly or use classes from tl-chat in their own code:
- The JSON API /chat/* (ChatServlet) no longer exists; instead, a dedicated client calls the commands of the chat controls (name constants in ChatControl), e.g., via the headless interface /agent-api.
- ChatSessionRegistry: windowId(session, subSession), resolveSubSession(session, windowId), and resolveTracker(session, windowId, conversation) have been removed. The tracker for a window is returned by tracker(subSession, conversation, account); the window name is available via the sub-session (subSession.getLayoutContext().getWindowId().getWindowName()).
- TaskProgressTracker: get/setUserName, get/setWelcomeName, and get/setWindowId are deprecated; the tracker’s account is returned by getAccount().
- ChatViewBinding: writeSharedState, subjectOf, windowId, contextPath, userName, welcomeName, language, and authenticated have been removed (the state is written by writeRenderState on every render). presentable and presentableNames now also expect the session’s account.
- useTLCommand() and useTLUpload() now return the command’s response (Promise<CommandResponse | undefined> instead of Promise<void>); existing callers that ignore the result remain unchanged.