fix(ui): skip chat history reload during active sends to prevent mess… (#66997)

Merged via squash.

Prepared head SHA: cec28cfa90c445c583bddcb371cc54c5d393ca64
Co-authored-by: scotthuang <1670837+scotthuang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
scotthuang
2026-04-15 16:56:24 +08:00
committed by GitHub
parent fb4395c1fe
commit 7734a40a56
4 changed files with 82 additions and 0 deletions
+1
View File
@@ -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
+32
View File
@@ -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();
@@ -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();
+33
View File
@@ -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);
}