fix(ui): preserve optimistically-added user messages during loadChatHistory#68014
fix(ui): preserve optimistically-added user messages during loadChatHistory#68014njxiaohan wants to merge 3 commits into
Conversation
…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.
There was a problem hiding this comment.
💡 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".
| const fingerprint = Array.isArray(content) ? JSON.stringify(content) : String(content); | ||
| if (!serverFingerprints.has(fingerprint)) { |
There was a problem hiding this comment.
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 👍 / 👎.
| state.chatMessages = [...filteredMessages, ...pendingToKeep]; | ||
| state.pendingChatMessagesById.clear(); |
There was a problem hiding this comment.
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 SummaryThis PR fixes an optimistic-message-loss bug where
Confidence Score: 4/5Approach 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 ui/src/ui/controllers/chat.ts — specifically the
|
| // 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]; |
There was a problem hiding this 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.
| // 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.| 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)); | ||
| } |
There was a problem hiding this 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:
| 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.| 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>; |
There was a problem hiding this 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.
| 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
There was a problem hiding this comment.
💡 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(); |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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.
|
Closing this as implemented after Codex automated review. Current main already preserves optimistic Control UI chat messages across stale 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:
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. |
Summary
When a user sends a message, it is optimistically added to
chatMessagesbefore the server confirms it. However,loadChatHistory()would later replace the entirechatMessageswith the server history, causing the optimistic message to be lost (or appear as compressed metadata if context compaction had occurred).Root Cause
The
sendChatMessagefunction adds the user message tochatMessagesoptimistically:But
loadChatHistoryunconditionally replaceschatMessageswith the server response: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
Track pending messages: Add a
_tempIdfield to optimistically-added messages and track them in apendingChatMessagesByIdMap inChatStateMerge in
loadChatHistory: After receiving server history, remove optimistic messages from currentchatMessages(those with_tempId), use server history as the base, then re-add pending messages that are not already in server historyError handling: Preserve pending messages even when
loadChatHistoryfails to ensure no user messages are lostFiles Changed
ui/src/ui/controllers/chat.tsTesting
Manual testing shows:
Related Issues