Preserve per-chat composer drafts
Store unsent composer text per chat in session storage and restore it when switching contexts or refreshing the page. Keep the Welcome composer prompt intact when its first send creates a chat, and cover the behavior with a focused Node regression.
Alessandro committed
Aug 19, 2026 at 12:28 UTC
7eb819731cdb73c1e493deb8d671aaa416e04d0a
4 files changed
+130
-3
tests/test_chat_input_drafts.py
new
+91
@@ -0,0 +1,91 @@
1
+import base64
2
+from pathlib import Path
3
+import re
4
+import shutil
5
+import subprocess
6
+
7
+import pytest
8
+
9
+
10
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
11
+INPUT_STORE = PROJECT_ROOT / "webui/components/chat/input/input-store.js"
12
+INDEX_JS = PROJECT_ROOT / "webui/index.js"
13
+
14
+
15
+@pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required")
16
+def test_chat_input_keeps_separate_session_drafts() -> None:
17
+ index_source = INDEX_JS.read_text(encoding="utf-8")
18
+ set_context = index_source[index_source.index("export const setContext"):]
19
+ assert set_context.index("inputStore.setDraftContext(id);") < set_context.index("context = id;")
20
+
21
+ source = INPUT_STORE.read_text(encoding="utf-8")
22
+ source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
23
+ source = source[: source.index('const store = createStore("chatInput", model);')]
24
+ module_source = r"""
25
+const shortcuts = {
26
+ getCurrentContextId: () => globalThis.__context,
27
+ callJsonApi: async () => ({}),
28
+ frontendNotification: () => {},
29
+ NotificationType: {},
30
+ NotificationPriority: {},
31
+};
32
+const fileBrowserStore = {};
33
+const messageQueueStore = { hasQueue: false };
34
+const attachmentsStore = {
35
+ attachments: [],
36
+ clearAttachments() { this.attachments = []; },
37
+};
38
+const chatsStore = { selected: "", selectedContext: null };
39
+""" + source + "\nexport { model, chatsStore };\n"
40
+ module_url = "data:text/javascript;base64," + base64.b64encode(
41
+ module_source.encode("utf-8")
42
+ ).decode("ascii")
43
+
44
+ script = f"""
45
+const makeStorage = () => ({{
46
+ values: new Map(),
47
+ getItem(key) {{ return this.values.get(key) ?? null; }},
48
+ setItem(key, value) {{ this.values.set(key, String(value)); }},
49
+ removeItem(key) {{ this.values.delete(key); }},
50
+}});
51
+globalThis.sessionStorage = makeStorage();
52
+globalThis.localStorage = makeStorage();
53
+globalThis.document = {{ activeElement: null, getElementById: () => null, querySelectorAll: () => [] }};
54
+globalThis.__context = null;
55
+
56
+const {{ model, chatsStore }} = await import({module_url!r});
57
+const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
58
+
59
+globalThis.__context = "chat-a";
60
+model.setDraftContext("chat-a");
61
+model.message = "alpha draft";
62
+assert(sessionStorage.getItem("a0:chat-draft:chat-a") === "alpha draft", "chat A was not saved");
63
+
64
+globalThis.__context = "chat-b";
65
+model.setDraftContext("chat-b");
66
+assert(model.message === "", "a new chat inherited another chat's draft");
67
+model.message = "beta draft";
68
+
69
+globalThis.__context = "chat-a";
70
+model.setDraftContext("chat-a");
71
+assert(model.message === "alpha draft", "chat A was not restored");
72
+model.message = "";
73
+assert(sessionStorage.getItem("a0:chat-draft:chat-a") === null, "cleared draft remained stored");
74
+
75
+globalThis.__context = null;
76
+model.setDraftContext("");
77
+model.message = "welcome prompt";
78
+chatsStore.newChat = async () => {{
79
+ globalThis.__context = "chat-new";
80
+ chatsStore.selected = "chat-new";
81
+ model.setDraftContext("chat-new");
82
+ return "chat-new";
83
+}};
84
+let sent = "";
85
+globalThis.sendMessage = async () => {{ sent = model.message; }};
86
+await model.sendMessage();
87
+assert(sent === "welcome prompt", "creating a chat erased the Welcome prompt");
88
+assert(sessionStorage.getItem("a0:chat-draft:chat-new") === "welcome prompt", "first prompt did not follow its new chat");
89
+"""
90
+
91
+ subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
webui/components/chat/AGENTS.md
+1
@@ -19,6 +19,7 @@
19
- Use shared API, WebSocket, notification, and attachment helpers where available.
20
- Do not bypass CSRF or WebSocket state-sync expectations.
21
- The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
22
+- Unsent composer text is kept as a separate browser-session draft for each selected chat and restored when switching contexts; a Welcome-screen prompt must follow the chat created for its first send.
23
- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
24
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
25
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
webui/components/chat/input/input-store.js
+37
-3
@@ -9,6 +9,7 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
10
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
11
const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
12
+const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
13
14
function escapeHTML(value) {
15
return String(value ?? "")
@@ -56,6 +57,7 @@ const model = {
57
_historyIndex: null,
58
_draft: "",
59
_historyCtxid: null,
60
+ _draftCtxid: null,
61
/** Composer + menu (bottom actions moved into dropdown) */
62
chatMoreMenuOpen: false,
63
progressText: "",
@@ -68,6 +70,7 @@ const model = {
70
set message(value) {
71
this._message = String(value ?? "");
72
this._renderEditorFromText(this._message);
73
+ this._saveDraft();
74
},
75
76
toggleChatMoreMenu() {
@@ -148,15 +151,17 @@ const model = {
151
152
async sendMessage() {
153
this._syncMessageFromEditor();
151
-
152
- // Capture sent prompt to per-chat history (bash-style)
153
- try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
154
+ const pendingMessage = this.message;
155
156
if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
157
const ctxid = await chatsStore.newChat();
158
if (!ctxid && !chatsStore.selected) return;
159
+ this.message = pendingMessage;
160
}
161
162
+ // Capture sent prompt to per-chat history (bash-style)
163
+ try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
164
+
165
// Delegate to the global function
166
if (globalThis.sendMessage) {
167
await globalThis.sendMessage();
@@ -174,6 +179,7 @@ const model = {
179
180
mountEditor(editor) {
181
this._editorEl = editor;
182
+ this.setDraftContext(shortcuts.getCurrentContextId());
183
this._renderEditorFromText(this._message);
184
this.adjustTextareaHeight({ target: editor });
185
},
@@ -255,6 +261,7 @@ const model = {
261
if (!this._editorEl) return;
262
this._message = this._editorToMarkdown();
263
this._setEditorEmptyState();
264
+ this._saveDraft();
265
},
266
267
_isInCodeBlock(target) {
@@ -579,6 +586,33 @@ const model = {
586
}
587
},
588
589
+ setDraftContext(ctxid) {
590
+ const nextCtxid = String(ctxid || "");
591
+ if (nextCtxid === this._draftCtxid) return;
592
+ if (this._draftCtxid !== null) this._syncMessageFromEditor();
593
+
594
+ this._draftCtxid = nextCtxid;
595
+ this._historyIndex = null;
596
+ this._draft = "";
597
+
598
+ let draft = "";
599
+ if (nextCtxid) {
600
+ try { draft = sessionStorage.getItem(DRAFT_STORAGE_PREFIX + nextCtxid) || ""; } catch (_e) { /* ignore */ }
601
+ }
602
+ this._message = draft;
603
+ this._renderEditorFromText(draft);
604
+ queueMicrotask(() => this.adjustTextareaHeight());
605
+ },
606
+
607
+ _saveDraft() {
608
+ if (!this._draftCtxid) return;
609
+ try {
610
+ const key = DRAFT_STORAGE_PREFIX + this._draftCtxid;
611
+ if (this._message) sessionStorage.setItem(key, this._message);
612
+ else sessionStorage.removeItem(key);
613
+ } catch (_e) { /* ignore unavailable storage */ }
614
+ },
615
+
616
_loadHistory() {
617
let ctxid = null;
618
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
webui/index.js
+1
@@ -608,6 +608,7 @@ globalThis.newContext = newContext;
608
609
export const setContext = function (id) {
610
if (id == context) return;
611
+ inputStore.setDraftContext(id);
612
context = id;
613
if (id) beginChatLoading(id);
614
else beginChatLoading(null);