Skip to content

fix(ui): preserve optimistically-added user messages during loadChatHistory#68014

Closed
njxiaohan wants to merge 3 commits into
openclaw:mainfrom
njxiaohan:main
Closed

fix(ui): preserve optimistically-added user messages during loadChatHistory#68014
njxiaohan wants to merge 3 commits into
openclaw:mainfrom
njxiaohan:main

Conversation

@njxiaohan

Copy link
Copy Markdown

Summary

When a user sends a message, it is optimistically added to chatMessages before the server confirms it. However, loadChatHistory() would later replace the entire chatMessages with the server history, causing the optimistic message to be lost (or appear as compressed metadata if context compaction had occurred).

Root Cause

The sendChatMessage function adds the user message to chatMessages optimistically:

state.chatMessages = [...state.chatMessages, userMessage];

But loadChatHistory unconditionally replaces chatMessages with the server response:

state.chatMessages = messages.filter((message) => !shouldHideHistoryMessage(message));

If the context was compacted before the message was committed to history, the message appears as a metadata block instead of the original text.

Fix

  1. Track pending messages: Add a _tempId field to optimistically-added messages and track them in a pendingChatMessagesById Map in ChatState

  2. Merge in loadChatHistory: After receiving server history, remove optimistic messages from current chatMessages (those with _tempId), use server history as the base, then re-add pending messages that are not already in server history

  3. Error handling: Preserve pending messages even when loadChatHistory fails to ensure no user messages are lost

Files Changed

  • ui/src/ui/controllers/chat.ts

Testing

Manual testing shows:

  • User messages now appear immediately in the Control UI chat
  • No duplicate messages
  • No metadata blocks replacing original text
  • Works correctly when AI responds with tools used

Related Issues

…istory

When a user sends a message, it is optimistically added to chatMessages
before the server confirms it. However, loadChatHistory() would later
replace the entire chatMessages with the server history, causing the
optimistic message to be lost (or appear as compressed metadata if context
compaction had occurred).

This fix:
1. Adds a _tempId field to optimistically-added messages and tracks them
   in a pendingChatMessagesById Map
2. In loadChatHistory(), removes optimistic messages from the current
   chatMessages (those with _tempId), uses server history as the base,
   then re-adds pending messages that aren't already in server history
3. Handles errors gracefully by preserving pending messages even when
   loadChatHistory fails

Fixes the issue where user messages would not display immediately
in the Control UI chat, instead appearing as compressed metadata blocks
after the AI responded.

@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: 32e4567984

ℹ️ 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 on lines +203 to +204
const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
if (!serverFingerprints.has(fingerprint)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop deduping pending messages by content alone

This comparison treats a pending message as "already in history" whenever any server message has the same serialized content, regardless of role, age, or which turn it belongs to. In practice, if history already contains common text (for example a prior "ok") and loadChatHistory runs before the new send is committed, the new optimistic message is dropped from pendingToKeep; because the map is then cleared, that user message disappears on subsequent refreshes.

Useful? React with 👍 / 👎.

Comment on lines +209 to +210
state.chatMessages = [...filteredMessages, ...pendingToKeep];
state.pendingChatMessagesById.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Isolate pending optimistic messages per session

This merge appends all pending entries into the currently loaded session without checking which session they were created in. Since pending entries are tracked on shared state and the session-switch reset path (ui/src/ui/app-render.helpers.ts:71-88) does not clear this map, sending in session A and switching to session B before A is reconciled can surface A’s pending user text in B’s conversation.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes an optimistic-message-loss bug where loadChatHistory would silently overwrite chatMessages with server history before a pending user message was committed, particularly during context compaction. The approach — tracking pending messages in a Map by temp ID and merging them back after history loads — is sound.

  • P1: When requestChatSend throws in sendChatMessage, the temp entry is never removed from pendingChatMessagesById. A subsequent loadChatHistory (reconnect, refresh) will see the never-delivered message as still-pending, re-surface it in chatMessages via pendingToKeep, and simultaneously discard the "Error: …" assistant message that the catch block appended — leaving the user with a misleading "still pending" message and no error indicator.

Confidence Score: 4/5

Approach is correct but the send-failure path leaves a stale pending entry that causes misleading UI state on reconnect — needs a one-line fix before merging.

One clear P1 defect: failed sends don't remove their entry from pendingChatMessagesById, so a reconnect-triggered loadChatHistory re-surfaces the unsent message and drops the visible error. The remaining findings are P2. Score is 4 rather than 3 because the core fix is correct and the P1 is a small, targeted one-liner.

ui/src/ui/controllers/chat.ts — specifically the sendChatMessage catch block (send-failure cleanup) and the pendingToKeep loop in loadChatHistory (strip _tempId).

Comments Outside Diff (1)

  1. ui/src/ui/controllers/chat.ts, line 391-406 (link)

    P1 Pending message not cleared on send failure

    When requestChatSend throws, pendingChatMessagesById still holds the unsent message. If loadChatHistory runs afterward (e.g. on reconnect), it will see the entry as "not yet in server history," append it to chatMessages via pendingToKeep, and simultaneously overwrite chatMessages with filteredMessages, silently dropping the "Error: …" assistant message that the catch block added. The user ends up seeing their message as still-pending even though it was never delivered, with no error visible.

    Add a cleanup step in the catch block:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: ui/src/ui/controllers/chat.ts
    Line: 391-406
    
    Comment:
    **Pending message not cleared on send failure**
    
    When `requestChatSend` throws, `pendingChatMessagesById` still holds the unsent message. If `loadChatHistory` runs afterward (e.g. on reconnect), it will see the entry as "not yet in server history," append it to `chatMessages` via `pendingToKeep`, and simultaneously overwrite `chatMessages` with `filteredMessages`, silently dropping the `"Error: …"` assistant message that the catch block added. The user ends up seeing their message as still-pending even though it was never delivered, with no error visible.
    
    Add a cleanup step in the catch block:
    
    
    
    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/controllers/chat.ts
Line: 391-406

Comment:
**Pending message not cleared on send failure**

When `requestChatSend` throws, `pendingChatMessagesById` still holds the unsent message. If `loadChatHistory` runs afterward (e.g. on reconnect), it will see the entry as "not yet in server history," append it to `chatMessages` via `pendingToKeep`, and simultaneously overwrite `chatMessages` with `filteredMessages`, silently dropping the `"Error: …"` assistant message that the catch block added. The user ends up seeing their message as still-pending even though it was never delivered, with no error visible.

Add a cleanup step in the catch block:

```suggestion
  } catch (err) {
    const error = formatConnectError(err);
    state.chatRunId = null;
    state.chatStream = null;
    state.chatStreamStartedAt = null;
    state.lastError = error;
    // Remove from pending tracking — send failed, so the message will never
    // arrive in server history. Leaving it here would cause loadChatHistory
    // to re-surface the message as "still pending" without the error context.
    state.pendingChatMessagesById?.delete(tempId);
    state.chatMessages = [
      ...state.chatMessages,
      {
        role: "assistant",
        content: [{ type: "text", text: "Error: " + error }],
        timestamp: Date.now(),
      },
    ];
    return null;
  }
```

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: 198-209

Comment:
**`_tempId` not stripped in success path**

The error path correctly strips `_tempId` via object spread (`{ _tempId: _1, ...msg }`), but in the success path the pending message objects are pushed into `pendingToKeep` — and then into `chatMessages` — with `_tempId` still present. This is inconsistent and leaks the internal field into the display layer until the next `loadChatHistory` call overwrites the array.

```suggestion
      const pendingToKeep: unknown[] = [];
      for (const pendingMsg of state.pendingChatMessagesById.values()) {
        const { _tempId: _ignored, ...pending } = pendingMsg as Record<string, unknown>;
        const content = pending.content;
        const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
        if (!serverFingerprints.has(fingerprint)) {
          pendingToKeep.push(pending);
        }
      }
```

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: 191-196

Comment:
**Content-only fingerprint can misfire on repeated identical messages**

The fingerprint is built solely from `content` with no position or timestamp. If the user sends the same text twice (e.g. "retry" or a short acknowledgement), the second pending message will match the first one already confirmed in server history and be silently dropped from `pendingToKeep`, causing a brief visual disappearance of the optimistic message. Including at least `timestamp` in the fingerprint would avoid this:

```suggestion
      for (const m of filteredMessages) {
        const msg = m as Record<string, unknown>;
        const content = msg.content;
        const ts = typeof msg.timestamp === "number" ? String(msg.timestamp) : "";
        serverFingerprints.add(
          (Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts,
        );
      }
```

And the corresponding pending-side fingerprint:
```typescript
const ts = typeof pending.timestamp === "number" ? String(pending.timestamp) : "";
const fingerprint =
  (Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts;
```

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: 119

Comment:
**Type vs. runtime defensive checks are inconsistent**

`pendingChatMessagesById` is declared as the required (non-optional) type `Map<string, unknown>`, yet `sendChatMessage` guards with `if (!state.pendingChatMessagesById)` and lazily initialises it, and both `loadChatHistory` paths also guard with `if (state.pendingChatMessagesById && ...)`. This implies some `ChatState` initialisers don't set the field, so the declared type is incorrect. Either make the type optional (`pendingChatMessagesById?: Map<string, unknown>`) or ensure every `ChatState` factory initialises it to `new Map()` and drop the defensive checks.

```suggestion
  pendingChatMessagesById?: Map<string, unknown>;
```

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

Reviews (1): Last reviewed commit: "fix(ui): preserve optimistically-added u..." | Re-trigger Greptile

Comment on lines +198 to +209
// Find pending messages that need to be preserved (not yet in server history)
const pendingToKeep: unknown[] = [];
for (const pendingMsg of state.pendingChatMessagesById.values()) {
const pending = pendingMsg as Record<string, unknown>;
const content = pending.content;
const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
if (!serverFingerprints.has(fingerprint)) {
pendingToKeep.push(pending);
}
}

state.chatMessages = [...filteredMessages, ...pendingToKeep];

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 _tempId not stripped in success path

The error path correctly strips _tempId via object spread ({ _tempId: _1, ...msg }), but in the success path the pending message objects are pushed into pendingToKeep — and then into chatMessages — with _tempId still present. This is inconsistent and leaks the internal field into the display layer until the next loadChatHistory call overwrites the array.

Suggested change
// Find pending messages that need to be preserved (not yet in server history)
const pendingToKeep: unknown[] = [];
for (const pendingMsg of state.pendingChatMessagesById.values()) {
const pending = pendingMsg as Record<string, unknown>;
const content = pending.content;
const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
if (!serverFingerprints.has(fingerprint)) {
pendingToKeep.push(pending);
}
}
state.chatMessages = [...filteredMessages, ...pendingToKeep];
const pendingToKeep: unknown[] = [];
for (const pendingMsg of state.pendingChatMessagesById.values()) {
const { _tempId: _ignored, ...pending } = pendingMsg as Record<string, unknown>;
const content = pending.content;
const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
if (!serverFingerprints.has(fingerprint)) {
pendingToKeep.push(pending);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/chat.ts
Line: 198-209

Comment:
**`_tempId` not stripped in success path**

The error path correctly strips `_tempId` via object spread (`{ _tempId: _1, ...msg }`), but in the success path the pending message objects are pushed into `pendingToKeep` — and then into `chatMessages` — with `_tempId` still present. This is inconsistent and leaks the internal field into the display layer until the next `loadChatHistory` call overwrites the array.

```suggestion
      const pendingToKeep: unknown[] = [];
      for (const pendingMsg of state.pendingChatMessagesById.values()) {
        const { _tempId: _ignored, ...pending } = pendingMsg as Record<string, unknown>;
        const content = pending.content;
        const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content);
        if (!serverFingerprints.has(fingerprint)) {
          pendingToKeep.push(pending);
        }
      }
```

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

Comment on lines +191 to +196
const serverFingerprints = new Set<string>();
for (const m of filteredMessages) {
const msg = m as Record<string, unknown>;
const content = msg.content;
serverFingerprints.add(Array.isArray(content) ? JSON.stringify(content) : String(content));
}

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 Content-only fingerprint can misfire on repeated identical messages

The fingerprint is built solely from content with no position or timestamp. If the user sends the same text twice (e.g. "retry" or a short acknowledgement), the second pending message will match the first one already confirmed in server history and be silently dropped from pendingToKeep, causing a brief visual disappearance of the optimistic message. Including at least timestamp in the fingerprint would avoid this:

Suggested change
const serverFingerprints = new Set<string>();
for (const m of filteredMessages) {
const msg = m as Record<string, unknown>;
const content = msg.content;
serverFingerprints.add(Array.isArray(content) ? JSON.stringify(content) : String(content));
}
for (const m of filteredMessages) {
const msg = m as Record<string, unknown>;
const content = msg.content;
const ts = typeof msg.timestamp === "number" ? String(msg.timestamp) : "";
serverFingerprints.add(
(Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts,
);
}

And the corresponding pending-side fingerprint:

const ts = typeof pending.timestamp === "number" ? String(pending.timestamp) : "";
const fingerprint =
  (Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts;
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/chat.ts
Line: 191-196

Comment:
**Content-only fingerprint can misfire on repeated identical messages**

The fingerprint is built solely from `content` with no position or timestamp. If the user sends the same text twice (e.g. "retry" or a short acknowledgement), the second pending message will match the first one already confirmed in server history and be silently dropped from `pendingToKeep`, causing a brief visual disappearance of the optimistic message. Including at least `timestamp` in the fingerprint would avoid this:

```suggestion
      for (const m of filteredMessages) {
        const msg = m as Record<string, unknown>;
        const content = msg.content;
        const ts = typeof msg.timestamp === "number" ? String(msg.timestamp) : "";
        serverFingerprints.add(
          (Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts,
        );
      }
```

And the corresponding pending-side fingerprint:
```typescript
const ts = typeof pending.timestamp === "number" ? String(pending.timestamp) : "";
const fingerprint =
  (Array.isArray(content) ? JSON.stringify(content) : String(content)) + "|" + ts;
```

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

Comment thread ui/src/ui/controllers/chat.ts Outdated
lastError: string | null;
// Track optimistically-added user messages that haven't been confirmed by the server yet.
// Maps client-generated temp ID -> the pending message object.
pendingChatMessagesById: Map<string, unknown>;

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 Type vs. runtime defensive checks are inconsistent

pendingChatMessagesById is declared as the required (non-optional) type Map<string, unknown>, yet sendChatMessage guards with if (!state.pendingChatMessagesById) and lazily initialises it, and both loadChatHistory paths also guard with if (state.pendingChatMessagesById && ...). This implies some ChatState initialisers don't set the field, so the declared type is incorrect. Either make the type optional (pendingChatMessagesById?: Map<string, unknown>) or ensure every ChatState factory initialises it to new Map() and drop the defensive checks.

Suggested change
pendingChatMessagesById: Map<string, unknown>;
pendingChatMessagesById?: Map<string, unknown>;
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/controllers/chat.ts
Line: 119

Comment:
**Type vs. runtime defensive checks are inconsistent**

`pendingChatMessagesById` is declared as the required (non-optional) type `Map<string, unknown>`, yet `sendChatMessage` guards with `if (!state.pendingChatMessagesById)` and lazily initialises it, and both `loadChatHistory` paths also guard with `if (state.pendingChatMessagesById && ...)`. This implies some `ChatState` initialisers don't set the field, so the declared type is incorrect. Either make the type optional (`pendingChatMessagesById?: Map<string, unknown>`) or ensure every `ChatState` factory initialises it to `new Map()` and drop the defensive checks.

```suggestion
  pendingChatMessagesById?: Map<string, unknown>;
```

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

- Make pendingChatMessagesById optional in ChatState type
- Fix type casting in error handler for pending message merge
- Build and lint pass

@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: d456b74f2d

ℹ️ 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".

}

state.chatMessages = [...filteredMessages, ...pendingToKeep];
state.pendingChatMessagesById.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain pending entries until server confirmation

Clearing pendingChatMessagesById immediately after re-appending pendingToKeep reintroduces message loss on the next history refresh: if the backend still has not committed the user message, a second loadChatHistory call will treat there as no pending local messages and replace chatMessages with server history only. This can happen with consecutive refreshes (e.g., reconnect/startup retries) before persistence completes.

Useful? React with 👍 / 👎.

const { _tempId: _1, ...rest } = msg;
pendingMsgs.push(rest);
}
state.chatMessages = [...state.chatMessages, ...pendingMsgs];

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 Avoid duplicating pending messages on history errors

On non-auth loadChatHistory failures, pending optimistic messages are usually already present in state.chatMessages, but this branch appends the same pending entries again before clearing the map. A transient chat.history error during an in-flight send therefore duplicates the user's message in the conversation UI.

Useful? React with 👍 / 👎.

…istory

When a user sends a message, it is optimistically added to chatMessages
before the server confirms it. However, loadChatHistory() would later
replace the entire chatMessages with the server history, causing the
optimistic message to be lost (or appear as compressed metadata if context
compaction had occurred).

This fix:
1. Adds a _tempId field to optimistically-added messages and tracks them
   in a pendingChatMessagesById Map (optional field in ChatState)
2. In loadChatHistory(), removes optimistic messages from the current
   chatMessages (those with _tempId), uses server history as the base,
   then re-adds pending messages that aren't already in server history
3. Handles errors gracefully by preserving pending messages even when
   loadChatHistory fails
4. Uses underscore prefix for intentionally unused destructured variable

Fixes the issue where user messages would not display immediately
in the Control UI chat, instead appearing as compressed metadata blocks
after the AI responded.
@clawsweeper

clawsweeper Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Closing this as implemented after Codex automated review.

Current main already preserves optimistic Control UI chat messages across stale chat.history reloads with a smaller tail-preservation implementation, regression coverage, docs, and release evidence. The PR's pending-map approach is obsolete and still has unresolved review concerns around stale pending entries, content-only dedupe, and session leakage.

Best possible solution:

Close this PR and keep the current main implementation. It solves the observable Control UI history-reload race with a narrower tail-preservation helper, documentation, and regression tests, while avoiding the PR's unresolved pending-map failure modes. Any future edge case should be filed as a focused follow-up against the shipped helper.

What I checked:

  • Current implementation preserves optimistic tails: loadChatHistory captures the previous in-memory chat messages, filters visible server history, and assigns state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages). The helper only keeps local user/assistant tail messages without transcript metadata and appends them after the last shared history signature. (ui/src/ui/controllers/chat.ts:267, d54d2d6b9b8a)
  • Optimistic send behavior remains present: sendChatMessage still appends the user message to state.chatMessages before calling requestChatSend, which is the UI behavior this PR was intended to protect. (ui/src/ui/controllers/chat.ts:561, d54d2d6b9b8a)
  • Regression tests cover the reported race: Tests cover stale history preserving optimistic user/assistant tail messages, empty history preserving local optimistic messages, caught-up history avoiding duplicates, and stale history responses after session switches. (ui/src/ui/controllers/chat.test.ts:789, d54d2d6b9b8a)
  • Related session-message reload race is handled: The gateway handler skips session.message history reloads while a chat run is active, records a deferred reload, and replays it after terminal chat events. Tests cover skipping active-run reloads and replaying deferred reloads after abort/error terminal events. (ui/src/ui/app-gateway.ts:522, d54d2d6b9b8a)
  • Behavior is documented: The Control UI docs state that during an active send and final history refresh, local optimistic user/assistant messages remain visible if chat.history briefly returns an older snapshot, until canonical Gateway history catches up. Public docs: docs/web/control-ui.md. (docs/web/control-ui.md:163, d54d2d6b9b8a)
  • Release evidence: The v2026.4.24 tag contains preserveOptimisticTailMessages, the stale-snapshot regression tests, and the Control UI docs entry for optimistic-message preservation. (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.

Bug: User messages not displayed immediately in Control UI (optimistic update lost after history reload)

1 participant