fix(web-client): persist chat transcript across page reloads#1857
fix(web-client): persist chat transcript across page reloads#1857john-the-dev wants to merge 2 commits into
Conversation
The conversation transcript (#transcript) was in-DOM only — append-only with no persistence and no restore-on-load — so reloading the web UI wiped the entire chat. The Tasks list survived via taskMap, which is why a reload left task entries visible but no chat bubbles. Snapshot transcript entries to localStorage (capped at 50, oversized data-URL entries skipped) and restore them on DOMContentLoaded, sanitizing stored html with DOMPurify and re-attaching live copy buttons. A MutationObserver captures every append path (text replies, voice transcripts, images) without editing each call site. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prevents the "Ask Sutando anything." seed line from duplicating when the persisted transcript (which itself captures the seed as its first entry) is restored on top of the freshly-rendered default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@cla-assistant check |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
✅ MutationObserver approach is the right way to capture all append paths without touching each call site. 400ms debounce avoids a snapshot per keyframe. TRANSCRIPT_MAX_ENTRY_LEN guard prevents data-URL images from blowing localStorage quota. DOMPurify used on restore. Approve.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Correct approach: MutationObserver on #transcript debounced at 400ms avoids spamming localStorage on every DOM append, and catches all write paths (text, voice transcript, image) without editing each call site. The quota-exceeded retry (halving the entry count) is practical. The _transcriptRestoring guard prevents the restore pass from triggering a snapshot write, avoiding a needless re-serialization on load. TRANSCRIPT_MAX_ENTRIES=50 and MAX_ENTRY_LEN=20000 are reasonable bounds. ✓
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Transcript persistence is well-implemented. MutationObserver captures all append paths without touch-every-call-site. DOMPurify on restore is correct defense-in-depth. Quota-exceeded retry (slice to half), 20KB entry size limit, and _transcriptRestoring guard against snapshot feedback-loop are all good defensive choices. LGTM.
Summary
The web-UI conversation transcript (
#transcript) was in-DOM only — append-only with no persistence and no restore-on-load — so reloading the page wiped the entire chat. The Tasks list survived (it persists viataskMap→loadPersistedTaskMap()), which is exactly why a reload left task entries visible but no chat bubbles, making it look like "history is gone."This is a long-standing gap, not a new regression.
What changed (
src/web-client.ts)localStorage(sutando-transcript-v1), capped at the 50 most recent; oversized entries (e.g. data-URL images) are skipped to stay under the storage quota, with a halve-and-retry fallback onQuotaExceededError.DOMContentLoaded— recreate each bubble, sanitize stored html with DOMPurify, and re-attach a live copy button (the button'sonclickcan't survive an innerHTML round-trip, so it's stripped on snapshot and re-added on restore).#transcriptcaptures every append path (text replies, voice transcripts, images) without editing each call site; snapshots are debounced (400ms) and suppressed during restore.Interaction with #1856
#1856 (in-flight chat-send persistence) re-appends the user's pending message on load via
resumePendingChatSends(). When both land, an in-flight send during a reload would render the user bubble twice (once from transcript-restore, once from resume). Trivial follow-up when the second of the two merges: drop the user-bubble re-append fromresumePendingChatSends()since transcript-restore already covers it. No conflict today (separate branches off main).Test plan
npx tsc --noEmitpassesNote: UI change — typechecked and smoke-verified the fix is served, but the manual reload behavior needs a human pass (owner is testing live).
🤖 Generated with Claude Code