fix(composer): suppress stale draft restore after send
This commit is contained in:
parent
72746342d3
commit
b1a200f166
4 changed files with 136 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: '' }),
|
||||
|
|
|
|||
53
tests/test_composer_draft_after_send.py
Normal file
53
tests/test_composer_draft_after_send.py
Normal 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue