From c32244877474c8932c8e8bb554d8cd7526feb44d Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 14 Aug 2026 12:52:38 +0530 Subject: [PATCH] #14 edit: PARK the edit on leave and RESUME it on return (Send updates the original) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/public/home.html | 44 +++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index 8fd12a6..2f96232 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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=''+ic('edit',13)+' Editing message'+ic('x',15)+''; 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=''+ic('edit',13)+' Editing message'+ic('x',15)+''; 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='
Loading messages…
'; } // 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