fix(composer): suppress stale draft restore after send

This commit is contained in:
Anthony Ruiz 2026-07-03 19:02:36 +00:00 committed by Agent Zero
parent 72746342d3
commit b1a200f166
4 changed files with 136 additions and 3 deletions

View file

@ -1027,8 +1027,10 @@ function _composerTextWithPendingSelections(){
}
function _clearComposerAfterQueuedSelectionSend(){
const sid=arguments.length?arguments[0]:(S.session&&S.session.session_id);
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(composer)composer.value='';
if(sid&&typeof _clearComposerDraft==='function') _clearComposerDraft(sid);
_clearPendingSelections();
if(typeof autoResize==='function') autoResize();
}
@ -1306,6 +1308,7 @@ async function send(){
const _modelState=_chatPayloadModelState();
queueSessionMessage(_targetSid,{text:_text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
_clearComposerAfterQueuedSelectionSend();
if(_targetSid&&typeof _clearComposerDraft==='function') _clearComposerDraft(_targetSid);
S.pendingFiles=[];renderTray();
updateQueueBadge(_targetSid);
showToast(`Queued: "${_text.slice(0,40)}${_text.length>40?'…':''}"`,2000);
@ -1373,12 +1376,13 @@ async function send(){
// After a delivered steer, clear any staged files because the text-only
// steer payload has been handled. On failure, keep files staged.
if(_steerDelivered){S.pendingFiles=[];renderTray();}
if(_steerDelivered&&S.session&&typeof _clearComposerDraft==='function') _clearComposerDraft(S.session.session_id);
} else if(defaultMessageMode==='interrupt'){
// Queue the message, then cancel so drain re-sends it.
const _modelState=_chatPayloadModelState();
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
$('msg').value='';autoResize();
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
S.pendingFiles=[];renderTray();
if(S.activeStreamId&&typeof cancelStream==='function'){
showToast(t('busy_interrupt_confirm'),2000);
@ -1391,7 +1395,7 @@ async function send(){
// 'steer' mode when no stream is active or _trySteer is unavailable.
const _modelState=_chatPayloadModelState();
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
$('msg').value='';autoResize();
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
S.pendingFiles=[];renderTray();
updateQueueBadge(S.session.session_id);
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);

View file

@ -34,6 +34,36 @@ let _draftSaveTimer = null;
const _DRAFT_SAVE_DELAY_MS = 400;
const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session';
const _composerDraftKnownPayloadSessions = new Set();
const _composerDraftRestoreSuppressedUntilBySid = new Map();
const _COMPOSER_DRAFT_RESTORE_SUPPRESS_MS = 30000;
function _suppressComposerDraftRestoreAfterSubmit(sid) {
if (!sid) return;
_composerDraftRestoreSuppressedUntilBySid.set(
sid,
Date.now() + _COMPOSER_DRAFT_RESTORE_SUPPRESS_MS,
);
// Local state must reflect the submitted/cleared composer immediately. The
// POST that clears the server-side draft is async; same-session refreshes can
// otherwise race in with the old draft and repopulate the textarea.
_rememberComposerDraftPayloadState(sid, '', []);
}
function _clearComposerDraftRestoreSuppression(sid) {
if (!sid) return;
_composerDraftRestoreSuppressedUntilBySid.delete(sid);
}
function _isComposerDraftRestoreSuppressed(sid) {
if (!sid) return false;
const until = _composerDraftRestoreSuppressedUntilBySid.get(sid);
if (!until) return false;
if (Date.now() > until) {
_composerDraftRestoreSuppressedUntilBySid.delete(sid);
return false;
}
return true;
}
function _profileMatchesActiveProfile(profile, activeProfile){
const eventName = (typeof profile === 'string' && profile.trim()) ? profile.trim() : 'default';
@ -103,6 +133,7 @@ function _saveComposerDraft(sid, text, files) {
const normalizedText = String(text || '');
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
_clearComposerDraftRestoreSuppression(sid);
_composerDraftKnownPayloadSessions.add(sid);
}
_draftSaveTimer = setTimeout(() => {
@ -144,6 +175,9 @@ function _saveComposerDraftNow(sid, text, files) {
clearTimeout(_draftSaveTimer);
const normalizedText = String(text || '');
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
_clearComposerDraftRestoreSuppression(sid);
}
// Most chat switches leave an empty composer. Avoid putting the switch path
// behind a network POST unless there is new local draft content or an existing
// server draft that must be cleared.
@ -174,6 +208,11 @@ function _restoreComposerDraft(draft, targetSid, opts={}) {
const files = (draft && Array.isArray(draft.files)) ? draft.files : [];
const current = ta.value || '';
const preserveActiveInput = !!(opts && opts.preserveActiveInput);
const restoreSid = targetSid || (S.session && S.session.session_id);
const hasServerDraftPayload = _composerDraftHasPayload(text, files);
if (restoreSid && hasServerDraftPayload && _isComposerDraftRestoreSuppressed(restoreSid)) return;
if (restoreSid && !hasServerDraftPayload) _clearComposerDraftRestoreSuppression(restoreSid);
// Same-session force refreshes are driven by external state changes and may
// finish seconds after the user continued typing. In that case the local
@ -206,6 +245,7 @@ function _clearComposerDraft(sid) {
if (!sid) return;
clearTimeout(_draftSaveTimer);
_clearRememberedNewChatDraftSession(sid);
_suppressComposerDraftRestoreAfterSubmit(sid);
return api('/api/session/draft', {
method: 'POST',
body: JSON.stringify({ session_id: sid, text: '' }),

View file

@ -0,0 +1,53 @@
"""Regression coverage for stale composer_draft restoration after send."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = ROOT.joinpath("static", "sessions.js").read_text(encoding="utf-8")
MESSAGES_JS = ROOT.joinpath("static", "messages.js").read_text(encoding="utf-8")
def _block(source: str, start_marker: str, end_marker: str) -> str:
start = source.index(start_marker)
end = source.index(end_marker, start)
return source[start:end]
def test_clear_composer_draft_suppresses_same_session_stale_restore():
"""An async draft-clear POST must not allow old server draft text to repopulate #msg."""
assert "const _composerDraftRestoreSuppressedUntilBySid = new Map();" in SESSIONS_JS
assert "function _suppressComposerDraftRestoreAfterSubmit(sid)" in SESSIONS_JS
clear_body = _block(SESSIONS_JS, "function _clearComposerDraft(sid)", "const SESSION_VIEWED_COUNTS_KEY")
suppress_idx = clear_body.index("_suppressComposerDraftRestoreAfterSubmit(sid);")
post_idx = clear_body.index("api('/api/session/draft'")
assert suppress_idx < post_idx, "restore suppression must be local and immediate before async POST"
def test_non_empty_draft_save_clears_submit_restore_suppression():
save_body = _block(SESSIONS_JS, "function _saveComposerDraft(sid, text, files)", "function _composerDraftHasPayload")
assert "_clearComposerDraftRestoreSuppression(sid);" in save_body
now_body = _block(SESSIONS_JS, "function _saveComposerDraftNow(sid, text, files)", "// Restore composer draft")
assert "_clearComposerDraftRestoreSuppression(sid);" in now_body
def test_restore_skips_suppressed_non_empty_server_draft_only():
restore_body = _block(SESSIONS_JS, "function _restoreComposerDraft(draft, targetSid", "// Clear the saved draft")
assert "const restoreSid = targetSid || (S.session && S.session.session_id);" in restore_body
assert "const hasServerDraftPayload = _composerDraftHasPayload(text, files);" in restore_body
assert "hasServerDraftPayload && _isComposerDraftRestoreSuppressed(restoreSid)" in restore_body
assert "!hasServerDraftPayload) _clearComposerDraftRestoreSuppression(restoreSid);" in restore_body
def test_busy_send_paths_clear_persisted_composer_draft():
helper_body = _block(MESSAGES_JS, "function _clearComposerAfterQueuedSelectionSend", "function _flushSelectionBlocksToComposer")
assert "function _clearComposerAfterQueuedSelectionSend()" in helper_body
assert "const sid=arguments.length?arguments[0]:(S.session&&S.session.session_id);" in helper_body
assert "_clearComposerDraft(sid)" in helper_body
in_progress_body = _block(MESSAGES_JS, "if (_sendInProgress) {", " _sendInProgress = true;")
assert "_clearComposerAfterQueuedSelectionSend();" in in_progress_body
assert "_clearComposerDraft(_targetSid);" in in_progress_body
busy_body = _block(MESSAGES_JS, "if(S.busy||compressionRunning){", " if(S.session&&(S.session.read_only||S.session.is_read_only))")
assert "_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);" in busy_body
assert busy_body.count("_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);") >= 2
assert "_clearComposerDraft(S.session.session_id)" in busy_body, "delivered steer must clear persisted draft"

View file

@ -38,11 +38,18 @@ def _draft_restore_block() -> str:
return SESSIONS_JS[start:end]
def _run_case(*, initial_text: str, draft: dict | None, opts: dict | None, current_sid, force_reload: bool) -> dict:
def _draft_restore_suppression_block() -> str:
start = SESSIONS_JS.index("const _composerDraftRestoreSuppressedUntilBySid")
end = SESSIONS_JS.index("function _profileMatchesActiveProfile", start)
return SESSIONS_JS[start:end]
def _run_case(*, initial_text: str, draft: dict | None, opts: dict | None, current_sid, force_reload: bool, suppress_restore: bool = False) -> dict:
if NODE is None:
pytest.skip("node not on PATH")
restore_fn = _function_block(SESSIONS_JS, "function _restoreComposerDraft(draft, targetSid, opts={}) {")
draft_block = _draft_restore_block()
suppression_block = _draft_restore_suppression_block()
script = f"""
const state = {{
value: {json.dumps(initial_text)},
@ -68,6 +75,14 @@ const sid = 'boot-session';
const currentSid = {json.dumps(current_sid)};
const forceReload = {json.dumps(force_reload)};
const opts = {json.dumps(opts or {})};
function _composerDraftHasPayload(text, files) {{
return !!(String(text || '') || (Array.isArray(files) && files.filter(Boolean).length));
}}
function _rememberComposerDraftPayloadState(sid, text, files) {{
state.rememberedDraft = {{sid, text, files}};
}}
{suppression_block}
if ({json.dumps(suppress_restore)}) _suppressComposerDraftRestoreAfterSubmit(sid);
{restore_fn}
{draft_block}
process.stdout.write(JSON.stringify({{
@ -123,6 +138,27 @@ def test_boot_restore_still_populates_empty_composer_from_saved_draft():
assert data["updateSendBtnCount"] == 1
def test_same_session_submitted_clear_blocks_stale_server_draft_restore():
"""After send clears the composer, a stale same-session refresh must not refill it.
Mobile browsers can deliver refresh/input timing such that the textarea is
empty locally while /api/session still returns the previous non-empty
composer_draft. The submitted-clear suppression should keep that old draft
out of the input.
"""
data = _run_case(
initial_text="",
draft={"text": "old submitted suffix", "files": []},
opts={"preserveActiveInput": True},
current_sid="boot-session",
force_reload=True,
suppress_restore=True,
)
assert data["value"] == ""
assert data["autoResizeCount"] == 0
assert data["updateSendBtnCount"] == 0
def test_cross_session_restore_keeps_existing_draft_semantics():
"""Ordinary cross-session loads should still restore the target session draft."""
data = _run_case(