Stage 326: PR #1956 — feat: persistent composer draft — server-side, cross-client, survives refresh by @JKJameson

This commit is contained in:
nesquena-hermes
2026-05-09 18:17:51 +00:00
6 changed files with 135 additions and 5 deletions
+4 -1
View File
@@ -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,
+37
View File
@@ -4001,6 +4001,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")
+5
View File
@@ -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'){
+2
View File
@@ -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
+80 -4
View File
@@ -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;
+7
View File
@@ -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,