diff --git a/tests/test_chat_input_drafts.py b/tests/test_chat_input_drafts.py new file mode 100644 index 000000000..398cdf920 --- /dev/null +++ b/tests/test_chat_input_drafts.py @@ -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) diff --git a/webui/components/chat/AGENTS.md b/webui/components/chat/AGENTS.md index 9a13e39cf..9b184c0d3 100644 --- a/webui/components/chat/AGENTS.md +++ b/webui/components/chat/AGENTS.md @@ -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. diff --git a/webui/components/chat/input/input-store.js b/webui/components/chat/input/input-store.js index 2ffe47875..b54e2acaa 100644 --- a/webui/components/chat/input/input-store.js +++ b/webui/components/chat/input/input-store.js @@ -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; } diff --git a/webui/index.js b/webui/index.js index 6dfc56e47..ce2b87be7 100644 --- a/webui/index.js +++ b/webui/index.js @@ -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);