diff --git a/CHANGELOG.md b/CHANGELOG.md index ead00c6eae..2744e0f5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819. - Claude CLI/sessions: classify `No conversation found with session ID` as `session_expired` so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn. - Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf. +- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc. ## 2026.4.14 diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 8cd7377b8b..7045ddb8e1 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -622,6 +622,38 @@ describe("connectGateway", () => { }, ); + it.each(["aborted", "error"] as const)( + "replays deferred session.message reloads after %s clears the active run", + (terminalState) => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-3"; + loadChatHistoryMock.mockClear(); + + client.emitEvent({ + event: "session.message", + payload: { + sessionKey: "main", + }, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + + client.emitEvent({ + event: "chat", + payload: { + runId: "main-run-3", + sessionKey: "main", + state: terminalState, + errorMessage: terminalState === "error" ? "chat failed" : undefined, + }, + }); + + expect(host.chatRunId).toBeNull(); + expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); + expect(loadChatHistoryMock).toHaveBeenCalledWith(host); + }, + ); + it("clears tracked BTW terminal runs after reconnect hello", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 2442197421..1bc9178fd1 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -142,6 +142,22 @@ describe("handleGatewayEvent session.message", () => { expect(loadChatHistoryMock).toHaveBeenCalledWith(host); }); + it("skips history reload while a chat run is active", () => { + loadChatHistoryMock.mockReset(); + const host = createHost(); + host.sessionKey = "agent:qa:main"; + host.chatRunId = "run-123"; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "agent:qa:main" }, + seq: 1, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + it("ignores transcript updates for other sessions", () => { loadChatHistoryMock.mockReset(); const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index ea1a30a0c8..e9f1239031 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -97,6 +97,10 @@ type GatewayHost = { updateAvailable: UpdateAvailable | null; }; +type GatewayHostWithDeferredSessionMessageReload = GatewayHost & { + pendingSessionMessageReloadSessionKey?: string | null; +}; + type SessionDefaultsSnapshot = { defaultAgentId?: string; mainKey?: string; @@ -409,8 +413,26 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u } const state = handleChatEvent(host as unknown as ChatState, payload); const historyReloaded = handleTerminalChatEvent(host, payload, state); + const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; + const deferredSessionKey = deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim(); + const payloadSessionKey = payload?.sessionKey?.trim(); + const shouldReplayDeferredSessionMessageReload = Boolean( + deferredSessionKey && + payloadSessionKey && + deferredSessionKey === payloadSessionKey && + isTerminalChatState(state) && + payloadSessionKey === host.sessionKey && + !host.chatRunId, + ); + if (deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey) { + deferredReloadHost.pendingSessionMessageReloadSessionKey = null; + } if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) { void loadChatHistory(host as unknown as ChatState); + return; + } + if (shouldReplayDeferredSessionMessageReload && !historyReloaded) { + void loadChatHistory(host as unknown as ChatState); } } @@ -418,10 +440,21 @@ function handleSessionMessageGatewayEvent( host: GatewayHost, payload: { sessionKey?: string } | undefined, ) { + const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; const sessionKey = payload?.sessionKey?.trim(); if (!sessionKey || sessionKey !== host.sessionKey) { return; } + // Skip history reload while a chat run is active. The chat event handler + // manages streaming state and appends the final assistant message. Reloading + // history mid-run races with the optimistic user-message update and resets + // chatStream, which delays the user message card from appearing until the + // first LLM delta arrives. + if (host.chatRunId) { + deferredReloadHost.pendingSessionMessageReloadSessionKey = sessionKey; + return; + } + deferredReloadHost.pendingSessionMessageReloadSessionKey = null; void loadChatHistory(host as unknown as ChatState); }