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);