Skip to content

fix(ui): prevent chat messages from disappearing after send when tools are used#67699

Closed
plokomarketing-ai wants to merge 1 commit into
openclaw:mainfrom
plokomarketing-ai:fix/chat-messages-disappear-after-send
Closed

fix(ui): prevent chat messages from disappearing after send when tools are used#67699
plokomarketing-ai wants to merge 1 commit into
openclaw:mainfrom
plokomarketing-ai:fix/chat-messages-disappear-after-send

Conversation

@plokomarketing-ai

Copy link
Copy Markdown

Summary

  • Bug: in the dashboard chat UI, the user's sent message (and the assistant reply) disappear for several seconds after every tool-assisted exchange, then reappear.
  • Root cause: when the assistant uses tools, handleTerminalChatEvent calls loadChatHistory after the run completes (hadToolEvents && state === "final"). The server returns a history snapshot that is 1–2 messages behind the UI (disk write hasn't caught up), and without a guard state.chatMessages = filtered overwrites the in-memory state — wiping the optimistic user message and the assistant reply that were already displayed.
  • Fix 1 (ui/src/ui/controllers/chat.ts): guard loadChatHistory so it only replaces chatMessages when the server returned at least as many non-system messages as the client currently holds. Covers both the tool-reload scenario and post-compaction reloads.
  • Fix 2 (ui/src/ui/controllers/chat.ts): don't clear chatStream / reset tool stream inside loadChatHistory while a run is still active (chatRunId is set), so streaming text isn't wiped before the final event arrives.
  • Fix 3 (ui/src/ui/app-gateway.ts): defer resetToolStream until after loadChatHistory resolves when tool events were seen, so tool cards stay visible during the network round-trip.
  • Fix 4 (ui/src/ui/app-render.helpers.ts, ui/src/ui/app-render.ts): clear chatMessages on session switch so the guard's length === 0 fast path fires correctly on initial load after switching.

Test plan

  • pnpm test -- ui/src/ui/controllers/chat.test.ts — 31 tests pass (3 new tests added)
  • pnpm test -- ui/src/ui/app-gateway.sessions.node.test.ts — passes
  • pnpm check — clean
  • Manually verified via browser console logging: server returned 199 non-system messages vs 201 in client → willReplace: false — messages no longer disappear after send

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bf1971e67

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ui/src/ui/app-gateway.ts
Comment on lines +310 to +312
void loadChatHistory(host as unknown as OpenClawApp).then(() => {
resetToolStream(toolHost);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope deferred tool-stream reset to the originating run

The deferred resetToolStream runs unconditionally after loadChatHistory resolves, but flushChatQueueForEvent is triggered just before this block and can immediately start the next queued run. In that case, the .then(...) callback can fire while the new run is already streaming tool output and clear its chatToolMessages/chatStreamSegments, causing tool cards to disappear mid-run. This race is introduced by the new async reset timing and is reproducible when a queued follow-up message starts quickly after a tool-using final event.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where user messages and assistant replies disappear briefly after tool-assisted exchanges in the dashboard chat UI. The root cause was loadChatHistory overwriting optimistic in-memory messages with a stale server snapshot. The fix introduces a count-based guard that only replaces chatMessages when the server returned at least as many non-system messages as the client holds, defers resetToolStream until after history loads, and suppresses stream-clearing during active runs.

  • Regression risk: resetChatStateForSessionSwitch (the tab-nav handler in app-render.helpers.ts) does not clear chatMessages, while switchChatSession (the dropdown handler) does. Because refreshChat also calls loadChatHistory without clearing first, a user who switches sessions via the Chat tab nav from a longer session (e.g. 50-message subagent) to a shorter one (e.g. 10-message main) will find the refresh button silently blocked by the new guard — the stale messages from the wrong session remain on screen.

Confidence Score: 4/5

Safe to merge after resolving the tab-nav session switch regression; all other fixes are correct.

The four fixes correctly address the disappearing-message bug and are well-tested. One P1 regression was identified: resetChatStateForSessionSwitch (tab-nav path) does not clear chatMessages, so the new guard in loadChatHistory can silently block a subsequent manual refresh when the user switched from a longer session to a shorter one via the Chat tab. A one-line fix (chatMessages = []) resolves it.

ui/src/ui/app-render.helpers.ts — resetChatStateForSessionSwitch needs state.chatMessages = [] to match switchChatSession and prevent the guard from blocking post-tab-nav refreshes.

Comments Outside Diff (1)

  1. ui/src/ui/app-render.helpers.ts, line 44-57 (link)

    P1 chatMessages not cleared on tab-nav session switch — guard blocks refresh

    resetChatStateForSessionSwitch resets stream state but leaves chatMessages intact. After a tab-nav switch from a longer session (e.g. a 50-message subagent) to a shorter one (e.g. a 10-message main session), the guard in loadChatHistory compares old-session message count (currentNonSystem = 50) with new-session server history (filteredNonSystem = 10). Because 10 < 50, the guard blocks and the refresh button silently leaves stale messages from the wrong session on screen.

    switchChatSession (dropdown path) correctly sets chatMessages = [] before calling loadChatHistory so the length === 0 fast-path bypasses the guard. The same treatment is needed in this function, particularly since no loadChatHistory call is made here and any subsequent reload (manual refresh, reconnect) will hit the guard with stale counts.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: ui/src/ui/app-render.helpers.ts
    Line: 44-57
    
    Comment:
    **`chatMessages` not cleared on tab-nav session switch — guard blocks refresh**
    
    `resetChatStateForSessionSwitch` resets stream state but leaves `chatMessages` intact. After a tab-nav switch from a longer session (e.g. a 50-message subagent) to a shorter one (e.g. a 10-message main session), the guard in `loadChatHistory` compares old-session message count (`currentNonSystem = 50`) with new-session server history (`filteredNonSystem = 10`). Because `10 < 50`, the guard blocks and the refresh button silently leaves stale messages from the wrong session on screen.
    
    `switchChatSession` (dropdown path) correctly sets `chatMessages = []` before calling `loadChatHistory` so the `length === 0` fast-path bypasses the guard. The same treatment is needed in this function, particularly since no `loadChatHistory` call is made here and any subsequent reload (manual refresh, reconnect) will hit the guard with stale counts.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/app-render.helpers.ts
Line: 44-57

Comment:
**`chatMessages` not cleared on tab-nav session switch — guard blocks refresh**

`resetChatStateForSessionSwitch` resets stream state but leaves `chatMessages` intact. After a tab-nav switch from a longer session (e.g. a 50-message subagent) to a shorter one (e.g. a 10-message main session), the guard in `loadChatHistory` compares old-session message count (`currentNonSystem = 50`) with new-session server history (`filteredNonSystem = 10`). Because `10 < 50`, the guard blocks and the refresh button silently leaves stale messages from the wrong session on screen.

`switchChatSession` (dropdown path) correctly sets `chatMessages = []` before calling `loadChatHistory` so the `length === 0` fast-path bypasses the guard. The same treatment is needed in this function, particularly since no `loadChatHistory` call is made here and any subsequent reload (manual refresh, reconnect) will hit the guard with stale counts.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/ui/controllers/chat.ts
Line: 113-118

Comment:
**`maybeResetToolStream` inside `loadChatHistory` neutralises the defer in Fix 3**

`handleTerminalChatEvent` defers `resetToolStream` until after `loadChatHistory` resolves so that tool cards stay visible during the round-trip. However, by the time the deferred `loadChatHistory` is called, `handleChatEvent` has already set `chatRunId = null` (final-event path, line 344). So `!state.chatRunId` is true here and `maybeResetToolStream` fires *inside* `loadChatHistory`—before the `.then()` callback—making the second `resetToolStream` in `handleTerminalChatEvent` a no-op.

The guard still achieves the right UX (tool cards cleared only after history arrives), but the logic is misleading: the behaviour leans entirely on the `maybeResetToolStream` call here, not on the deferred `.then()`. Consider documenting this interaction, or skip `maybeResetToolStream` in `loadChatHistory` when called from the tool-event path (e.g. by accepting an option like `{ keepToolStream?: boolean }`) so Fix 3's intent is explicit rather than incidental.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(ui): prevent chat messages from disa..." | Re-trigger Greptile

Comment on lines +113 to +118
// would cause the streamed text to vanish and reappear on the final event.
if (!state.chatRunId) {
maybeResetToolStream(state);
state.chatStream = null;
state.chatStreamStartedAt = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 maybeResetToolStream inside loadChatHistory neutralises the defer in Fix 3

handleTerminalChatEvent defers resetToolStream until after loadChatHistory resolves so that tool cards stay visible during the round-trip. However, by the time the deferred loadChatHistory is called, handleChatEvent has already set chatRunId = null (final-event path, line 344). So !state.chatRunId is true here and maybeResetToolStream fires inside loadChatHistory—before the .then() callback—making the second resetToolStream in handleTerminalChatEvent a no-op.

The guard still achieves the right UX (tool cards cleared only after history arrives), but the logic is misleading: the behaviour leans entirely on the maybeResetToolStream call here, not on the deferred .then(). Consider documenting this interaction, or skip maybeResetToolStream in loadChatHistory when called from the tool-event path (e.g. by accepting an option like { keepToolStream?: boolean }) so Fix 3's intent is explicit rather than incidental.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/chat.ts
Line: 113-118

Comment:
**`maybeResetToolStream` inside `loadChatHistory` neutralises the defer in Fix 3**

`handleTerminalChatEvent` defers `resetToolStream` until after `loadChatHistory` resolves so that tool cards stay visible during the round-trip. However, by the time the deferred `loadChatHistory` is called, `handleChatEvent` has already set `chatRunId = null` (final-event path, line 344). So `!state.chatRunId` is true here and `maybeResetToolStream` fires *inside* `loadChatHistory`—before the `.then()` callback—making the second `resetToolStream` in `handleTerminalChatEvent` a no-op.

The guard still achieves the right UX (tool cards cleared only after history arrives), but the logic is misleading: the behaviour leans entirely on the `maybeResetToolStream` call here, not on the deferred `.then()`. Consider documenting this interaction, or skip `maybeResetToolStream` in `loadChatHistory` when called from the tool-event path (e.g. by accepting an option like `{ keepToolStream?: boolean }`) so Fix 3's intent is explicit rather than incidental.

How can I resolve this? If you propose a fix, please make it concise.

@njxiaohan

Copy link
Copy Markdown

This issue has a similar root cause to what I fixed in PR #68014. The problem is that loadChatHistory() unconditionally replaces chatMessages with the server response, causing optimistic messages to disappear. My fix tracks pending messages in a Map and merges them back after loading server history. Could be relevant for this issue as well!

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group liberal-lynx-ebvz

Title: Open PR candidate: Control UI terminal chat history reload races

Number Title
#67699* fix(ui): prevent chat messages from disappearing after send when tools are used
#70391 fix(ui): avoid redundant reload after final chat event

* This PR

@clawsweeper

clawsweeper Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Closing this as implemented after Codex automated review.

Current main already implements the #67699 behavior: stale chat.history snapshots preserve optimistic user/assistant tail messages, tool-assisted final events reload history before clearing tool streams with run scoping, and session switches clear old chat messages. The related #68014 and #70391 discussions were useful, but this PR is now superseded by shipped/current main behavior.

Best possible solution:

Close #67699 as implemented on main. Keep the current signature-based optimistic-tail preservation, run-scoped deferred tool reset, and session-switch clearing behavior; any remaining edge case should be filed as a narrower reproduction against current main rather than merging this stale duplicate PR.

What I checked:

  • Optimistic tail preservation on main: loadChatHistory captures previous chatMessages and replaces server history through preserveOptimisticTailMessages, which keeps local optimistic user/assistant tail messages when a returned history snapshot is stale. (ui/src/ui/controllers/chat.ts:267, d54d2d6b9b8a)
  • History reload applies the preservation helper: Current loadChatHistory filters visible history, then assigns state.chatMessages from preserveOptimisticTailMessages(visibleMessages, previousMessages), rather than blindly overwriting the UI state with the server snapshot. (ui/src/ui/controllers/chat.ts:373, d54d2d6b9b8a)
  • Tool final reload is deferred and run-scoped: handleTerminalChatEvent reloads chat history after final tool output and only resets tool stream/flushes the queue in the loadChatHistory finally callback, with a completedRunId guard so a newer run is not cleared. (ui/src/ui/app-gateway.ts:406, d54d2d6b9b8a)
  • Session switch regression addressed: resetChatStateForSessionSwitch clears state.chatMessages before the new session loads, addressing the review concern that stale messages from a longer prior session could block a shorter target session refresh. (ui/src/ui/app-render.helpers.ts:68, d54d2d6b9b8a)
  • Regression tests cover stale and empty snapshots: Chat controller tests cover stale history preserving optimistic user/assistant tail messages, empty history preserving local optimistic messages, and avoiding duplicate optimistic tails after history catches up. (ui/src/ui/controllers/chat.test.ts:789, d54d2d6b9b8a)
  • Release proof for central shipped behavior: The v2026.4.24 tag contains the core stale-snapshot optimistic-tail preservation path and deferred tool-final history reload path, so the central tool-assisted disappearing-message behavior was already shipped there. (ui/src/ui/controllers/chat.ts:267, cbcfdf62c729)

So I’m closing this as already implemented rather than keeping a duplicate issue open.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against d54d2d6b9b8a; fix evidence: release v2026.4.24, commit cbcfdf62c729.

@clawsweeper clawsweeper Bot closed this Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants