Skip to content

fix(webchat): persist chat queue to localStorage with drain-on-reconnect#73154

Open
jjjojoj wants to merge 4 commits intoopenclaw:mainfrom
jjjojoj:fix/chat-queue-persistence-v2
Open

fix(webchat): persist chat queue to localStorage with drain-on-reconnect#73154
jjjojoj wants to merge 4 commits intoopenclaw:mainfrom
jjjojoj:fix/chat-queue-persistence-v2

Conversation

@jjjojoj
Copy link
Copy Markdown
Contributor

@jjjojoj jjjojoj commented Apr 28, 2026

Summary

Fixes #51549 — WebChat loses queued messages on browser refresh or session switch.

Problem

chatQueue was stored as a pure in-memory array. On browser refresh or session switch, the array was recreated empty and all queued messages were permanently lost.

Solution

Persists chatQueue to localStorage on every mutation, with automatic restore on reconnect or session switch.

Changes

  • ui/src/ui/chat-queue-persistence.ts (new): persistChatQueue + restoreChatQueue with 24h TTL
  • ui/src/ui/app-chat.ts: call persistQueue after every chatQueue mutation (enqueue, steer, drain, remove)
  • ui/src/ui/app-chat.ts: export flushChatQueue wrapper for lifecycle use
  • ui/src/ui/app-lifecycle.ts: restore queue from localStorage on reconnect + auto-drain
  • ui/src/ui/app-render.ts: restore queue on session switch

Notes

Fixes #51549

- New file chat-queue-persistence.ts: persistChatQueue + restoreChatQueue with 24h TTL
- app-chat.ts: call persistQueue after every chatQueue mutation
- app-chat.ts: export flushChatQueue wrapper for lifecycle use
- app-lifecycle.ts: restore queue from localStorage on reconnect + auto-drain
- app-render.ts: restore queue on session switch

Fixes openclaw#51549
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR persists chatQueue to localStorage on every mutation and restores it on reconnect or session switch, fixing message loss across browser refreshes. Three issues need attention before merging:

  • Build-breaking rename omission: async function flushChatQueue (line 349) was never renamed to flushChatQueueInternal, so the new exported wrapper references an undefined symbol and there are two conflicting function declarations — TypeScript will refuse to compile.
  • Dead drain trigger: The host.connected guard in handleConnected is always false at DOM-mount time (the gateway hasn't connected yet), so restored items are written to memory but never flushed to the server.
  • Quota silent failure: Attachment dataUrl base64 fields can exceed localStorage's 5 MB origin budget; the caught QuotaExceededError silently skips persistence, reproducing the original data-loss bug for users with attachments in their queue.

Confidence Score: 1/5

Not safe to merge — the PR will fail to compile due to a missing function rename.

A P0 build-breaking error (undefined flushChatQueueInternal + duplicate function declaration) prevents the code from compiling at all, plus two independent P1 logic defects that undermine the core feature.

ui/src/ui/app-chat.ts (rename omission), ui/src/ui/app-lifecycle.ts (drain dead code), ui/src/ui/chat-queue-persistence.ts (quota guard)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/app-chat.ts
Line: 396-398

Comment:
**`flushChatQueueInternal` is never defined — build will fail**

The exported wrapper calls `flushChatQueueInternal`, but that name does not exist anywhere in this file. The private async implementation is still declared as `async function flushChatQueue` on line 349, so TypeScript will emit two errors: "Duplicate function implementation" for the name collision and "Cannot find name 'flushChatQueueInternal'" for the undefined reference. Any call site that reaches `flushChatQueue` (including `sendChatMessageNow` line 224 and `flushChatQueueForEvent` on line 650) will fail.

The private function needs to be renamed to `flushChatQueueInternal` (including the recursive call on line 379).

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/app-lifecycle.ts
Line: 70-72

Comment:
**Drain guard `host.connected` is always false here — restored queue never sent**

`handleConnected` is the LitElement `connectedCallback`, called when the component mounts to the DOM. At this point the gateway WebSocket has not yet been established — `connectGateway` is only invoked later inside the `bootstrapReady.finally()` callback. `host.connected` is therefore `false` (or `undefined`) when this branch is evaluated, so `flushChatQueue` is never called and the restored items will sit in memory indefinitely without being drained.

The drain should be triggered wherever the gateway fires its "connected" event and sets `host.connected = true`, not here in the DOM lifecycle hook.

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/chat-queue-persistence.ts
Line: 14-27

Comment:
**Attachment `dataUrl` fields can exhaust localStorage quota silently**

`ChatQueueItem` carries an `attachments` array whose `dataUrl` fields are base64-encoded blobs. For a single image these can easily be several megabytes, which exceeds localStorage's ~5 MB budget for the entire origin. `storage.setItem` will throw a `QuotaExceededError`, caught silently here, so the queue is never actually persisted — reproducing the original data-loss bug for users with attachments in their queue.

Consider stripping attachment data before serialising (persist only metadata such as `id`, `mimeType`, `fileName`) or checking the serialized size before writing and falling back gracefully.

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

Reviews (1): Last reviewed commit: "fix(webchat): persist chat queue to loca..." | Re-trigger Greptile

Comment thread ui/src/ui/app-chat.ts
Comment on lines +396 to 398
export function flushChatQueue(host: ChatHost): void {
void flushChatQueueInternal(host);
}
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.

P0 flushChatQueueInternal is never defined — build will fail

The exported wrapper calls flushChatQueueInternal, but that name does not exist anywhere in this file. The private async implementation is still declared as async function flushChatQueue on line 349, so TypeScript will emit two errors: "Duplicate function implementation" for the name collision and "Cannot find name 'flushChatQueueInternal'" for the undefined reference. Any call site that reaches flushChatQueue (including sendChatMessageNow line 224 and flushChatQueueForEvent on line 650) will fail.

The private function needs to be renamed to flushChatQueueInternal (including the recursive call on line 379).

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-chat.ts
Line: 396-398

Comment:
**`flushChatQueueInternal` is never defined — build will fail**

The exported wrapper calls `flushChatQueueInternal`, but that name does not exist anywhere in this file. The private async implementation is still declared as `async function flushChatQueue` on line 349, so TypeScript will emit two errors: "Duplicate function implementation" for the name collision and "Cannot find name 'flushChatQueueInternal'" for the undefined reference. Any call site that reaches `flushChatQueue` (including `sendChatMessageNow` line 224 and `flushChatQueueForEvent` on line 650) will fail.

The private function needs to be renamed to `flushChatQueueInternal` (including the recursive call on line 379).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: renamed private flushChatQueueflushChatQueueInternal (including recursive call). Thanks for catching this.

Comment thread ui/src/ui/app-lifecycle.ts Outdated
Comment on lines +70 to +72
if (restored.length > 0 && host.connected) {
flushChatQueue(host as unknown as ChatHost);
}
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.

P1 Drain guard host.connected is always false here — restored queue never sent

handleConnected is the LitElement connectedCallback, called when the component mounts to the DOM. At this point the gateway WebSocket has not yet been established — connectGateway is only invoked later inside the bootstrapReady.finally() callback. host.connected is therefore false (or undefined) when this branch is evaluated, so flushChatQueue is never called and the restored items will sit in memory indefinitely without being drained.

The drain should be triggered wherever the gateway fires its "connected" event and sets host.connected = true, not here in the DOM lifecycle hook.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-lifecycle.ts
Line: 70-72

Comment:
**Drain guard `host.connected` is always false here — restored queue never sent**

`handleConnected` is the LitElement `connectedCallback`, called when the component mounts to the DOM. At this point the gateway WebSocket has not yet been established — `connectGateway` is only invoked later inside the `bootstrapReady.finally()` callback. `host.connected` is therefore `false` (or `undefined`) when this branch is evaluated, so `flushChatQueue` is never called and the restored items will sit in memory indefinitely without being drained.

The drain should be triggered wherever the gateway fires its "connected" event and sets `host.connected = true`, not here in the DOM lifecycle hook.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: removed the drain trigger from handleConnected (which fires before connectGateway). Moved it to onHello in app-gateway.ts, called unconditionally after host.connected = true. This ensures the queue drains on: (1) initial page load, (2) normal reconnect, and (3) seq-gap reconnect. Thanks for the precise analysis.

Comment on lines +14 to +27
}
try {
const key = `${CHAT_QUEUE_STORAGE_KEY_PREFIX}${sessionKey}`;
if (queue.length === 0) {
// Drain complete — remove the stale entry so localStorage doesn't grow indefinitely.
storage.removeItem(key);
return;
}
const value = JSON.stringify({ items: queue, ts: Date.now() });
storage.setItem(key, value);
} catch {
// Best-effort: localStorage may be unavailable (e.g. private browsing).
}
}
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.

P1 Attachment dataUrl fields can exhaust localStorage quota silently

ChatQueueItem carries an attachments array whose dataUrl fields are base64-encoded blobs. For a single image these can easily be several megabytes, which exceeds localStorage's ~5 MB budget for the entire origin. storage.setItem will throw a QuotaExceededError, caught silently here, so the queue is never actually persisted — reproducing the original data-loss bug for users with attachments in their queue.

Consider stripping attachment data before serialising (persist only metadata such as id, mimeType, fileName) or checking the serialized size before writing and falling back gracefully.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat-queue-persistence.ts
Line: 14-27

Comment:
**Attachment `dataUrl` fields can exhaust localStorage quota silently**

`ChatQueueItem` carries an `attachments` array whose `dataUrl` fields are base64-encoded blobs. For a single image these can easily be several megabytes, which exceeds localStorage's ~5 MB budget for the entire origin. `storage.setItem` will throw a `QuotaExceededError`, caught silently here, so the queue is never actually persisted — reproducing the original data-loss bug for users with attachments in their queue.

Consider stripping attachment data before serialising (persist only metadata such as `id`, `mimeType`, `fileName`) or checking the serialized size before writing and falling back gracefully.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: on QuotaExceededError, strip attachment dataUrl fields (keep id/mimeType/fileName metadata) and retry. If even the slimmed data exceeds quota, give up silently. Thanks for catching this edge case.

jjjojoj added 3 commits April 28, 2026 11:34
1. flushChatQueueInternal: rename private function from flushChatQueue
   (the exported wrapper referenced undefined flushChatQueueInternal)

2. Dead drain in handleConnected: host.connected is always false there
   (handleConnected fires before connectGateway is called).
   Move drain trigger to onHello in app-gateway.ts, after host.connected = true.
   Always call flushChatQueueForEvent so queue drains on initial load AND reconnect.

3. QuotaExceededError: catch specifically and retry with attachment
   dataUrl fields stripped (keep id/mimeType/fileName only). Fall back
   silently if even slimmed data exceeds quota.
@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 30, 2026

Codex review: found issues before merge.

Summary
The PR adds localStorage-backed WebChat queue persistence, restores queued messages on reconnect/session switch, persists queue mutations, and drains restored queues after gateway hello.

Reproducibility: yes. A static reproduction is clear: refresh during an active response with queued follow-ups, restore the queue before gateway hello, then the PR drains after chatRunId is cleared while the old run can still be active; attachment loss follows by queuing a file, refreshing, and replaying metadata without payload bytes.

Next step before merge
Manual review is needed because the remaining blockers involve active-run ordering, attachment replay semantics, and browser retention/privacy decisions rather than a narrow mechanical patch.

Security
Needs attention: Needs attention: the patch adds no dependency or code-execution path, but it retains queued prompt text and attachment metadata in localStorage for 24 hours.

Review findings

  • [P2] Wait for the active run before draining restored queues — ui/src/ui/app-gateway.ts:455-459
  • [P2] Preserve attachment payloads when restoring queued messages — ui/src/ui/chat-queue-persistence.ts:73
  • [P2] Restore persisted queues through the shared session helper — ui/src/ui/app-render.ts:1574-1579
Review details

Best possible solution:

Implement durable WebChat queue persistence in the shared chat/session lifecycle, with active-run ACK or completion coordination, explicit attachment restore or rejection behavior, reviewed retention semantics, focused tests, and a user-facing changelog entry.

Do we have a high-confidence way to reproduce the issue?

Yes. A static reproduction is clear: refresh during an active response with queued follow-ups, restore the queue before gateway hello, then the PR drains after chatRunId is cleared while the old run can still be active; attachment loss follows by queuing a file, refreshing, and replaying metadata without payload bytes.

Is this the best way to solve the issue?

No. localStorage may be part of the fix, but this implementation is not the best path until it uses the shared session helper, coordinates restored drains with active-run state, and defines attachment and privacy behavior.

Full review comments:

  • [P2] Wait for the active run before draining restored queues — ui/src/ui/app-gateway.ts:455-459
    On a refresh during an active response, the PR restores queued messages before gateway hello and then drains them immediately after onHello clears chatRunId. The previous run may still be active server-side, so queued follow-ups can be sent before the response they were queued behind finishes.
    Confidence: 0.84
  • [P2] Preserve attachment payloads when restoring queued messages — ui/src/ui/chat-queue-persistence.ts:73
    Restored queue items can list attachments, but the bytes live in the in-memory attachment payload store and are gone after refresh. buildApiAttachments then drops those attachments, so replayed messages silently lose files unless persistence stores a recoverable payload or rejects those restored entries with visible feedback.
    Confidence: 0.9
  • [P2] Restore persisted queues through the shared session helper — ui/src/ui/app-render.ts:1574-1579
    Current main routes session changes through switchChatSession and app-render.helpers.ts owns queue save/restore. Adding persistence in one app-render callback misses other session-switch paths and conflicts with the helper-owned lifecycle added on main.
    Confidence: 0.82
  • [P3] Add the required changelog entry — ui/src/ui/app-chat.ts:154
    This is a user-facing WebChat bug fix, but the PR does not update CHANGELOG.md. OpenClaw policy requires a single-line user-facing fix entry before merge.
    Confidence: 0.87

Overall correctness: patch is incorrect
Overall confidence: 0.88

Security concerns:

  • [low] Queued chat content is retained in localStorage — ui/src/ui/chat-queue-persistence.ts:22
    persistChatQueue writes queued user message text and attachment metadata under a predictable per-session browser key. That expands client-side retention for potentially sensitive prompts and file names, so maintainers should confirm the retention model or add explicit clear/opt-in behavior before merge.
    Confidence: 0.84

Acceptance criteria:

  • pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-gateway.node.test.ts ui/src/ui/controllers/chat.test.ts
  • pnpm tsgo:test:ui
  • pnpm exec oxfmt --check --threads=1 ui/src/ui/app-chat.ts ui/src/ui/app-gateway.ts ui/src/ui/app-lifecycle.ts ui/src/ui/app-render.helpers.ts ui/src/ui/app-render.ts ui/src/ui/chat-queue-persistence.ts CHANGELOG.md
  • pnpm check:changed in Testbox before push if code changes proceed

What I checked:

Likely related people:

  • tmimmanuel: Merged PR fix(ui): preserve queued chat messages across session switches #73679 added the helper-owned queued-message preservation path across session switches that this PR needs to integrate with. (role: introduced related current-main behavior; confidence: high; commits: 86f0557be5cb; files: ui/src/ui/app-render.helpers.ts, ui/src/ui/app-render.helpers.node.test.ts)
  • steipete: Local history and CODEOWNERS context show repeated Control UI/WebChat maintenance across app-chat, app-gateway, and app-render, including current shallow blame for central queue code. (role: recent maintainer and adjacent owner; confidence: medium; commits: bb294bcd20eb, 1fad8efa12, 382785c6ce; files: ui/src/ui/app-chat.ts, ui/src/ui/app-gateway.ts, ui/src/ui/app-render.helpers.ts)
  • Gustavo Madeira Santana: Local history shows Control UI seq-gap reconnect work in the gateway path that owns resumeChatQueueAfterReconnect and reconnect queue flushing. (role: adjacent reconnect owner; confidence: medium; commits: c191dc9928, 1600c1726e; files: ui/src/ui/app-gateway.ts)
  • scotthuang: Local history shows active-send history reload race work in the same WebChat run-state area that this PR affects. (role: adjacent active-send/session-history owner; confidence: medium; commits: 7734a40a56; files: ui/src/ui/app-gateway.ts, ui/src/ui/controllers/chat.ts)

Remaining risk / open question:

  • No runtime tests were run because this was a read-only review; the blockers are based on static code-path inspection and the provided latest PR diff.
  • Browser-local retention of queued prompt text and attachment metadata needs maintainer privacy review before this can merge.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 4655ee803d27.

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]: WebChat loses message queue, conversation history, and draft on browser refresh

1 participant