#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:
+36
-8
@@ -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;
|
||||
// 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
|
||||
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){
|
||||
if(!m) return; clearReply();
|
||||
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
|
||||
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(); }
|
||||
}
|
||||
// #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
|
||||
// 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=''; }
|
||||
@@ -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 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(_){} }
|
||||
// #14: GUARANTEED save of the currently-open chat's draft — called the instant we leave it (switching chats
|
||||
// OR going back to the list). The per-keystroke 'input' save can be missed on mobile (predictive text / a
|
||||
// fast tap-away fires no final input event), which lost the whole draft on switch-and-return. Snapshotting
|
||||
// the composer value at leave-time makes it reliable regardless. This ALSO covers leaving mid-EDIT: the edit
|
||||
// is abandoned (openConvo clears editTarget), and whatever's in the composer is kept as that chat's draft so
|
||||
// your typing isn't lost — it just returns as a normal draft (sending it posts a new message, not an edit).
|
||||
function persistCurrentDraft(){ try{ if(selected){ const inp=document.getElementById('msgInput'); if(inp) setDraft(selected.kind, selected.id, inp.value); } }catch(_){} }
|
||||
// #14: called the instant we leave the open chat (switching chats OR back to the list). If we're NOT editing,
|
||||
// snapshot the composer as that chat's DRAFT — a guaranteed save even if the per-keystroke 'input' event was
|
||||
// missed on mobile (predictive text / fast tap-away), which used to lose the whole draft on switch-and-return.
|
||||
// If we ARE editing, PARK the edit (message id + in-progress text + the pre-edit draft) so reopening the chat
|
||||
// resumes edit MODE and Send updates the ORIGINAL message instead of posting a new one.
|
||||
function persistCurrentDraft(){
|
||||
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
|
||||
// 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
|
||||
@@ -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
|
||||
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
|
||||
_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,
|
||||
// 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(); }
|
||||
@@ -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);
|
||||
THREAD=msgs;
|
||||
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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user