#14 edit: PARK the edit on leave and RESUME it on return (Send updates the original)

Previous follow-up kept the in-progress edit text only as a plain draft, so sending
it after returning posted a NEW message instead of updating the original. Now leaving
a chat mid-edit parks {msgId, text, pre-edit draft} in _pendingEdits; reopening the
chat resumes edit MODE (editTarget + "Editing message" bar + the in-progress text)
once the thread is loaded (cache render, then network render as fallback). So Send
runs saveEdit → UPDATES the original message. If the message can't be found in the
loaded page (or was deleted) it falls back to a plain draft so the text isn't lost.

Verified with puppeteer against the live app:
 - edit "hi bob" -> switch to Cara -> back -> edit mode resumes (composer "hi bob
   EDITED", bar showing) -> Send -> thread count stays 1, message.edited_at set:
   PASS (updated original, no new message).
 - plain draft (no edit) switch-and-return still restores: PASS (no regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:52:38 +05:30
parent 354e65acdf
commit c322448774
+36 -8
View File
@@ -2393,14 +2393,35 @@ function clearReply(){ replyTarget=null; const bar=document.getElementById('repl
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar; // Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
// the next send saves the edit instead of posting a new message. // the next send saves the edit instead of posting a new message.
let editTarget=null, _editSavedDraft=''; // #14: the real (unsent) draft we set aside while editing, to restore after let editTarget=null, _editSavedDraft=''; // #14: the real (unsent) draft we set aside while editing, to restore after
const _pendingEdits={}; // #14: per-chat PARKED edits — leave a chat mid-edit → resume it (message + text + edit MODE) on return, so Send still UPDATES the original, not a new message
function _showEditBar(){ const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; } }
function startEdit(m){ function startEdit(m){
if(!m) return; clearReply(); if(!m) return; clearReply();
const inp=document.getElementById('msgInput'); const inp=document.getElementById('msgInput');
if(!editTarget) _editSavedDraft = inp ? inp.value : ''; // #14: remember the draft-in-progress BEFORE we load the edit text over it if(!editTarget) _editSavedDraft = inp ? inp.value : ''; // #14: remember the draft-in-progress BEFORE we load the edit text over it
editTarget=m; editTarget=m;
const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; } _showEditBar();
if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); } if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
} }
// #14: resume a parked edit when the chat is reopened (called from openConvo once the thread is loaded). This
// restores edit MODE — the "Editing message" bar + editTarget — so completing it Sends an UPDATE, not a new
// message. `final` (after the network render) is the last attempt: if the message can't be found (older than
// the loaded page, or deleted), fall back to keeping the typed text as a plain draft so it isn't lost.
function _resumePendingEdit(kind, id, final){
try{
const key=kind+':'+id; const pe=_pendingEdits[key]; if(!pe) return;
const m=THREAD.find(x=>x.id===pe.id);
if(m && !m.deleted){
delete _pendingEdits[key];
editTarget=m; _editSavedDraft=pe.savedDraft||'';
_showEditBar();
const inp=document.getElementById('msgInput'); if(inp){ inp.value=pe.text; autoGrow(inp); }
} else if(final){
delete _pendingEdits[key];
if(!m){ setDraft(kind, id, pe.text); const inp=document.getElementById('msgInput'); if(inp){ inp.value=pe.text; autoGrow(inp); } } // message not loaded → keep the text as a draft rather than lose it (deleted → drop silently)
}
}catch(_){}
}
// #14: leaving edit mode RESTORES the real draft (what you were typing before you hit edit) instead of // #14: leaving edit mode RESTORES the real draft (what you were typing before you hit edit) instead of
// blanking it — editing a message no longer eats a half-written reply. // blanking it — editing a message no longer eats a half-written reply.
function _restoreDraftAfterEdit(){ const inp=document.getElementById('msgInput'); if(inp){ inp.value=_editSavedDraft||''; autoGrow(inp); } try{ if(selected) setDraft(selected.kind, selected.id, _editSavedDraft||''); }catch(_){} _editSavedDraft=''; } function _restoreDraftAfterEdit(){ const inp=document.getElementById('msgInput'); if(inp){ inp.value=_editSavedDraft||''; autoGrow(inp); } try{ if(selected) setDraft(selected.kind, selected.id, _editSavedDraft||''); }catch(_){} _editSavedDraft=''; }
@@ -3420,13 +3441,18 @@ let _openUnread=0; // set by selectChat (the unread count before it's reset) so
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; } function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } } function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } }
function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} } function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} }
// #14: GUARANTEED save of the currently-open chat's draft — called the instant we leave it (switching chats // #14: called the instant we leave the open chat (switching chats OR back to the list). If we're NOT editing,
// OR going back to the list). The per-keystroke 'input' save can be missed on mobile (predictive text / a // snapshot the composer as that chat's DRAFT — a guaranteed save even if the per-keystroke 'input' event was
// fast tap-away fires no final input event), which lost the whole draft on switch-and-return. Snapshotting // missed on mobile (predictive text / fast tap-away), which used to lose the whole draft on switch-and-return.
// the composer value at leave-time makes it reliable regardless. This ALSO covers leaving mid-EDIT: the edit // If we ARE editing, PARK the edit (message id + in-progress text + the pre-edit draft) so reopening the chat
// is abandoned (openConvo clears editTarget), and whatever's in the composer is kept as that chat's draft so // resumes edit MODE and Send updates the ORIGINAL message instead of posting a new one.
// your typing isn't lost — it just returns as a normal draft (sending it posts a new message, not an edit). function persistCurrentDraft(){
function persistCurrentDraft(){ try{ if(selected){ const inp=document.getElementById('msgInput'); if(inp) setDraft(selected.kind, selected.id, inp.value); } }catch(_){} } try{
if(!selected) return; const inp=document.getElementById('msgInput'); if(!inp) return;
if(editTarget) _pendingEdits[selected.kind+':'+selected.id]={ id:editTarget.id, text:inp.value, savedDraft:_editSavedDraft||'' };
else setDraft(selected.kind, selected.id, inp.value);
}catch(_){}
}
// Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous // Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous
// render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a // render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a
// double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML // double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML
@@ -3595,6 +3621,7 @@ async function openConvo(kind,id){
let _renderedOpen=null; // the exact array we painted from cache, to diff against the network result below let _renderedOpen=null; // the exact array we painted from cache, to diff against the network result below
if(THREAD_CACHE.has(ckey)){ const _c=THREAD_CACHE.get(ckey); THREAD = _c.length>PAGE ? _c.slice(-PAGE) : _c.slice(); _hasMoreOlder = _c.length>PAGE; _renderedOpen=THREAD; renderThread(); } // open at the LATEST page only: a short DOM pins to the newest reliably even as images load. Re-opening a thread that was scrolled up no longer re-renders 100+ msgs and strands you mid-history — older pages back in on scroll-up. if(THREAD_CACHE.has(ckey)){ const _c=THREAD_CACHE.get(ckey); THREAD = _c.length>PAGE ? _c.slice(-PAGE) : _c.slice(); _hasMoreOlder = _c.length>PAGE; _renderedOpen=THREAD; renderThread(); } // open at the LATEST page only: a short DOM pins to the newest reliably even as images load. Re-opening a thread that was scrolled up no longer re-renders 100+ msgs and strands you mid-history — older pages back in on scroll-up.
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
_resumePendingEdit(kind, id, false); // #14: resume a parked edit if this chat's message is already in the cache
// Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now, // Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now,
// so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only. // so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only.
if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); } if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); }
@@ -3621,6 +3648,7 @@ async function openConvo(kind,id){
const _same = _renderedOpen && _renderedOpen.length===msgs.length && _renderedOpen.every((m,i)=>m.id===msgs[i].id && (m.edited_at||0)===(msgs[i].edited_at||0) && !!m.deleted===!!msgs[i].deleted); const _same = _renderedOpen && _renderedOpen.length===msgs.length && _renderedOpen.every((m,i)=>m.id===msgs[i].id && (m.edited_at||0)===(msgs[i].edited_at||0) && !!m.deleted===!!msgs[i].deleted);
THREAD=msgs; THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice()); THREAD_CACHE.set(ckey, THREAD.slice());
_resumePendingEdit(kind, id, true); // #14: final attempt to resume a parked edit now the full thread is loaded (falls back to a draft if the message isn't in this page)
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history _hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
// Apply the network result NOW (during the fade), not at the landing frame. The fade is compositor-driven, // Apply the network result NOW (during the fade), not at the landing frame. The fade is compositor-driven,
// so this main-thread work runs smoothly under it and is masked by the low opacity — by the time the pane is // so this main-thread work runs smoothly under it and is masked by the low opacity — by the time the pane is