From 08c4ef8d88aa7fbf9388cf21a5ddd5f38a0a6637 Mon Sep 17 00:00:00 2001 From: Minimax Date: Sat, 9 May 2026 13:44:15 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20persistent=20composer=20draft=20?= =?UTF-8?q?=E2=80=94=20server-side,=20cross-client,=20survives=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session.composer_draft field: {text, files} stored in session JSON - POST+GET /api/session/draft endpoint for save/load - loadSession: save draft before switch, restore from S.session.composer_draft - textarea input: debounced 400ms auto-save to server - send(): clear draft after message is sent - lockComposerForClarify(): save draft before card locks composer - _restoreComposerDraft: clears textarea when target has no draft, guards against stale responses racing new session loads, exact text comparison - Session.compact(): includes composer_draft in response - Fix: use handler.command instead of parsed.method (ParseResult has no .method) Co-authored-by: Minimax --- api/models.py | 5 ++- api/routes.py | 37 ++++++++++++++++++++ static/boot.js | 5 +++ static/messages.js | 2 ++ static/sessions.js | 84 +++++++++++++++++++++++++++++++++++++++++++--- static/ui.js | 7 ++++ 6 files changed, 135 insertions(+), 5 deletions(-) diff --git a/api/models.py b/api/models.py index ed300617..1aac37a5 100644 --- a/api/models.py +++ b/api/models.py @@ -335,6 +335,7 @@ class Session: llm_title_generated: bool=False, parent_session_id: str=None, enabled_toolsets=None, + composer_draft=None, **kwargs): self.session_id = session_id or uuid.uuid4().hex[:12] self.title = title @@ -373,6 +374,7 @@ class Session: self.session_source = kwargs.get('session_source') self.source_label = kwargs.get('source_label') self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override + self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {} self._metadata_message_count = None @property @@ -413,7 +415,7 @@ class Session: 'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'parent_session_id', 'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', - 'enabled_toolsets', + 'enabled_toolsets', 'composer_draft', ] meta = {k: getattr(self, k, None) for k in METADATA_FIELDS} meta['messages'] = self.messages @@ -590,6 +592,7 @@ class Session: 'session_source': self.session_source, 'source_label': self.source_label, 'enabled_toolsets': self.enabled_toolsets, + 'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {}, 'is_streaming': _is_streaming_session( self.active_stream_id, active_stream_ids ) if include_runtime else False, diff --git a/api/routes.py b/api/routes.py index 26bcd782..61d12027 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3999,6 +3999,43 @@ def handle_post(handler, parsed) -> bool: s.save() return j(handler, {"ok": True, "enabled_toolsets": s.enabled_toolsets}) + if parsed.path == "/api/session/draft": + # GET ?session_id=X → return current draft + # POST body → save draft { session_id, text?, files? } + # HTTP method is in handler.command (e.g. "POST", "GET"), parsed has no .method + if handler.command == "GET": + query = parse_qs(parsed.query) + sid = query.get("session_id", [""])[0] if parsed.query else "" + if not sid: + return bad(handler, "session_id is required", 400) + try: + s = get_session(sid) + except KeyError: + return bad(handler, "Session not found", 404) + draft = getattr(s, "composer_draft", {}) or {} + return j(handler, {"draft": draft}) + # POST + try: + require(body, "session_id") + except ValueError as e: + return bad(handler, str(e)) + sid = body["session_id"] + text = body.get("text") + files = body.get("files") + try: + s = get_session(sid) + except KeyError: + return bad(handler, "Session not found", 404) + with _get_session_agent_lock(sid): + draft = getattr(s, "composer_draft", {}) or {} + if text is not None: + draft["text"] = text + if files is not None: + draft["files"] = files + s.composer_draft = draft + s.save() + return j(handler, {"ok": True, "draft": s.composer_draft}) + if parsed.path == "/api/session/update": try: require(body, "session_id") diff --git a/static/boot.js b/static/boot.js index 2902718c..66f6dd0b 100644 --- a/static/boot.js +++ b/static/boot.js @@ -872,6 +872,11 @@ $('modelSelect').onchange=async()=>{ $('msg').addEventListener('input',()=>{ autoResize(); updateSendBtn(); + // Persist composer draft to server (debounced in _saveComposerDraft). + const sid = S && S.session && S.session.session_id; + if (sid && typeof _saveComposerDraft === 'function') { + _saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []); + } const text=$('msg').value; if(text.startsWith('/')&&text.indexOf('\n')===-1){ if(typeof getSlashAutocompleteMatches==='function'){ diff --git a/static/messages.js b/static/messages.js index d7122b9f..75758f7c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -189,6 +189,8 @@ async function send(){ if(!msgText){setComposerStatus('Nothing to send');return;} $('msg').value='';autoResize(); + // Clear persisted composer draft since message was sent. + if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid); const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)'); const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000}; S.toolCalls=[]; // clear tool calls from previous turn diff --git a/static/sessions.js b/static/sessions.js index 8a88217a..631e214d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -17,6 +17,74 @@ const ICONS={ // before the first request completes (#1060). let _loadingSessionId = null; +// ── Composer draft persistence ──────────────────────────────────────────────── + +// Debounced save — prevents hammering the server on every keystroke. +let _draftSaveTimer = null; +const _DRAFT_SAVE_DELAY_MS = 400; + +function _saveComposerDraft(sid, text, files) { + if (!sid) return; + clearTimeout(_draftSaveTimer); + _draftSaveTimer = setTimeout(() => { + api('/api/session/draft', { + method: 'POST', + body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }), + }).catch(() => {}); + }, _DRAFT_SAVE_DELAY_MS); +} + +// Fire-and-forget immediate save (used before session switches). +function _saveComposerDraftNow(sid, text, files) { + if (!sid) return; + clearTimeout(_draftSaveTimer); + api('/api/session/draft', { + method: 'POST', + body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }), + }).catch(() => {}); +} + +// Restore composer draft from server onto #msg textarea. +// Only restores if there's actual text (skip empty/None drafts). +// Guards against double-restore when rapidly switching sessions. +function _restoreComposerDraft(draft, targetSid) { + const ta = $('msg'); + if (!ta) return; + // targetSid is the session that was requested — if it no longer matches + // _loadingSessionId, a newer session switch has already begun, so skip. + if (targetSid && _loadingSessionId !== null && _loadingSessionId !== targetSid) return; + const text = (draft && typeof draft.text === 'string') ? draft.text : ''; + const files = (draft && Array.isArray(draft.files)) ? draft.files : []; + // If there's no text and no files, clear the textarea (a previous session's + // draft may still be sitting there from a cross-session switch). + if (!text && !files.length) { + if (ta.value) { + ta.value = ''; + if (typeof autoResize === 'function') autoResize(); + if (typeof updateSendBtn === 'function') updateSendBtn(); + } + return; + } + // Only update if different to avoid cursor jumps on unrelated session switches. + const current = ta.value || ''; + if (current !== text) { + ta.value = text; + if (typeof autoResize === 'function') autoResize(); + if (typeof updateSendBtn === 'function') updateSendBtn(); + } + // Files restoration is skipped for now (requires S.pendingFiles plumbing). +} + +// Clear the saved draft for a session (called when message is sent). +function _clearComposerDraft(sid) { + if (!sid) return; + clearTimeout(_draftSaveTimer); + api('/api/session/draft', { + method: 'POST', + body: JSON.stringify({ session_id: sid, text: '' }), + }).catch(() => {}); +} + const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming'; @@ -345,11 +413,10 @@ async function loadSession(sid){ // Show loading indicator immediately for responsiveness. // Cleared by renderMessages() once full session data arrives. // Persist the current composer draft before switching away so it can be - // restored when the user switches back (#1060). + // restored when the user switches back (#1060). Save to server now so the + // draft survives page refresh and syncs across clients. if (currentSid && currentSid !== sid) { - if (!S.composerDrafts) S.composerDrafts = {}; - const draft = { text: ($('msg') || {}).value || '', files: S.pendingFiles ? [...S.pendingFiles] : [] }; - if (draft.text || draft.files.length) S.composerDrafts[currentSid] = draft; + _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []); } if (currentSid !== sid) { S.messages = []; @@ -563,6 +630,15 @@ async function loadSession(sid){ }); } if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession(); + + // Restore server-persisted composer draft (synced across clients + survives refresh). + // Pass sid so _restoreComposerDraft can skip if this session is mid-load (guards + // against stale writes from slow responses racing to restore the previous draft). + const _draft = S.session && S.session.composer_draft; + if (_draft && (typeof _restoreComposerDraft === 'function')) { + _restoreComposerDraft(_draft, sid); + } + _resolveSessionModelForDisplaySoon(sid); // Clear the in-flight session marker now that this load has completed (#1060). if (_loadingSessionId === sid) _loadingSessionId = null; diff --git a/static/ui.js b/static/ui.js index 3827d926..833b2fef 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2727,6 +2727,13 @@ let _composerLockState=null; function lockComposerForClarify(placeholderText){ const input=$('msg'); if(!input) return; + // Save the current composer text as a server-side draft before locking, + // so the user's draft is preserved if they switch sessions while a clarify + // card is active (and survives page refresh / syncs across clients). + const sid = S && S.session && S.session.session_id; + if (sid && typeof _saveComposerDraftNow === 'function') { + _saveComposerDraftNow(sid, input.value || '', S.pendingFiles ? [...S.pendingFiles] : []); + } if(!_composerLockState){ _composerLockState={ disabled: input.disabled,