diff --git a/static/messages.js b/static/messages.js index 8d4c378a..9446fc4b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/sessions.js b/static/sessions.js index 0de9a277..6542ae47 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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: '' }), diff --git a/tests/test_composer_draft_after_send.py b/tests/test_composer_draft_after_send.py new file mode 100644 index 00000000..ac741250 --- /dev/null +++ b/tests/test_composer_draft_after_send.py @@ -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" diff --git a/tests/test_issue5060_boot_composer_preserve.py b/tests/test_issue5060_boot_composer_preserve.py index 56702f21..eff8ea6a 100644 --- a/tests/test_issue5060_boot_composer_preserve.py +++ b/tests/test_issue5060_boot_composer_preserve.py @@ -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(