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.
This commit is contained in:
Alessandro 2026-08-19 12:28:42 +02:00
parent 81fcc24364
commit 7eb819731c
4 changed files with 130 additions and 3 deletions

View file

@ -0,0 +1,91 @@
import base64
from pathlib import Path
import re
import shutil
import subprocess
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
INPUT_STORE = PROJECT_ROOT / "webui/components/chat/input/input-store.js"
INDEX_JS = PROJECT_ROOT / "webui/index.js"
@pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required")
def test_chat_input_keeps_separate_session_drafts() -> None:
index_source = INDEX_JS.read_text(encoding="utf-8")
set_context = index_source[index_source.index("export const setContext"):]
assert set_context.index("inputStore.setDraftContext(id);") < set_context.index("context = id;")
source = INPUT_STORE.read_text(encoding="utf-8")
source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
source = source[: source.index('const store = createStore("chatInput", model);')]
module_source = r"""
const shortcuts = {
getCurrentContextId: () => globalThis.__context,
callJsonApi: async () => ({}),
frontendNotification: () => {},
NotificationType: {},
NotificationPriority: {},
};
const fileBrowserStore = {};
const messageQueueStore = { hasQueue: false };
const attachmentsStore = {
attachments: [],
clearAttachments() { this.attachments = []; },
};
const chatsStore = { selected: "", selectedContext: null };
""" + source + "\nexport { model, chatsStore };\n"
module_url = "data:text/javascript;base64," + base64.b64encode(
module_source.encode("utf-8")
).decode("ascii")
script = f"""
const makeStorage = () => ({{
values: new Map(),
getItem(key) {{ return this.values.get(key) ?? null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ this.values.delete(key); }},
}});
globalThis.sessionStorage = makeStorage();
globalThis.localStorage = makeStorage();
globalThis.document = {{ activeElement: null, getElementById: () => null, querySelectorAll: () => [] }};
globalThis.__context = null;
const {{ model, chatsStore }} = await import({module_url!r});
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
globalThis.__context = "chat-a";
model.setDraftContext("chat-a");
model.message = "alpha draft";
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === "alpha draft", "chat A was not saved");
globalThis.__context = "chat-b";
model.setDraftContext("chat-b");
assert(model.message === "", "a new chat inherited another chat's draft");
model.message = "beta draft";
globalThis.__context = "chat-a";
model.setDraftContext("chat-a");
assert(model.message === "alpha draft", "chat A was not restored");
model.message = "";
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === null, "cleared draft remained stored");
globalThis.__context = null;
model.setDraftContext("");
model.message = "welcome prompt";
chatsStore.newChat = async () => {{
globalThis.__context = "chat-new";
chatsStore.selected = "chat-new";
model.setDraftContext("chat-new");
return "chat-new";
}};
let sent = "";
globalThis.sendMessage = async () => {{ sent = model.message; }};
await model.sendMessage();
assert(sent === "welcome prompt", "creating a chat erased the Welcome prompt");
assert(sessionStorage.getItem("a0:chat-draft:chat-new") === "welcome prompt", "first prompt did not follow its new chat");
"""
subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)

View file

@ -19,6 +19,7 @@
- Use shared API, WebSocket, notification, and attachment helpers where available.
- Do not bypass CSRF or WebSocket state-sync expectations.
- 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.
- 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.
- 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.
- 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.
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.

View file

@ -9,6 +9,7 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
function escapeHTML(value) {
return String(value ?? "")
@ -56,6 +57,7 @@ const model = {
_historyIndex: null,
_draft: "",
_historyCtxid: null,
_draftCtxid: null,
/** Composer + menu (bottom actions moved into dropdown) */
chatMoreMenuOpen: false,
progressText: "",
@ -68,6 +70,7 @@ const model = {
set message(value) {
this._message = String(value ?? "");
this._renderEditorFromText(this._message);
this._saveDraft();
},
toggleChatMoreMenu() {
@ -148,15 +151,17 @@ const model = {
async sendMessage() {
this._syncMessageFromEditor();
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
const pendingMessage = this.message;
if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
const ctxid = await chatsStore.newChat();
if (!ctxid && !chatsStore.selected) return;
this.message = pendingMessage;
}
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
// Delegate to the global function
if (globalThis.sendMessage) {
await globalThis.sendMessage();
@ -174,6 +179,7 @@ const model = {
mountEditor(editor) {
this._editorEl = editor;
this.setDraftContext(shortcuts.getCurrentContextId());
this._renderEditorFromText(this._message);
this.adjustTextareaHeight({ target: editor });
},
@ -255,6 +261,7 @@ const model = {
if (!this._editorEl) return;
this._message = this._editorToMarkdown();
this._setEditorEmptyState();
this._saveDraft();
},
_isInCodeBlock(target) {
@ -579,6 +586,33 @@ const model = {
}
},
setDraftContext(ctxid) {
const nextCtxid = String(ctxid || "");
if (nextCtxid === this._draftCtxid) return;
if (this._draftCtxid !== null) this._syncMessageFromEditor();
this._draftCtxid = nextCtxid;
this._historyIndex = null;
this._draft = "";
let draft = "";
if (nextCtxid) {
try { draft = sessionStorage.getItem(DRAFT_STORAGE_PREFIX + nextCtxid) || ""; } catch (_e) { /* ignore */ }
}
this._message = draft;
this._renderEditorFromText(draft);
queueMicrotask(() => this.adjustTextareaHeight());
},
_saveDraft() {
if (!this._draftCtxid) return;
try {
const key = DRAFT_STORAGE_PREFIX + this._draftCtxid;
if (this._message) sessionStorage.setItem(key, this._message);
else sessionStorage.removeItem(key);
} catch (_e) { /* ignore unavailable storage */ }
},
_loadHistory() {
let ctxid = null;
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }

View file

@ -608,6 +608,7 @@ globalThis.newContext = newContext;
export const setContext = function (id) {
if (id == context) return;
inputStore.setDraftContext(id);
context = id;
if (id) beginChatLoading(id);
else beginChatLoading(null);