From 75d84366c65b02379124b469b2d826e0e3ed523d Mon Sep 17 00:00:00 2001 From: C10H14N2O5 <100066858+C10H14N2O5@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:40:18 +0800 Subject: [PATCH 1/2] fix: persist WebChat chat preferences --- dashboard/src/components/chat/Chat.vue | 26 +++++- .../src/components/chat/StandaloneChat.vue | 26 +++++- .../tests/chatPreferencesPersistence.test.mjs | 89 +++++++++++++++++++ 3 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 dashboard/tests/chatPreferencesPersistence.test.mjs diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 588e3b01b29..86e7980a3f7 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -738,9 +738,17 @@ const threadSelection = reactive<{ selectedText: "", }); const settingsOpen = ref(false); -const enableStreaming = ref(true); -const enableReasoning = ref(true); -const sendShortcut = ref<"enter" | "shift_enter">("enter"); +const enableStreaming = ref( + localStorage.getItem("chat.enableStreaming") !== "false", +); +const enableReasoning = ref( + localStorage.getItem("chat.enableReasoning") !== "false", +); +const sendShortcut = ref<"enter" | "shift_enter">( + localStorage.getItem("chat.sendShortcut") === "shift_enter" + ? "shift_enter" + : "enter", +); const DRAFT_SAVE_DELAY_MS = 300; let activeDraftSessionId = currSessionId.value; let draftSaveTimer: number | null = null; @@ -822,6 +830,18 @@ const transportMode = ref( : "sse", ); +watch(enableStreaming, (enabled) => { + localStorage.setItem("chat.enableStreaming", String(enabled)); +}); + +watch(enableReasoning, (enabled) => { + localStorage.setItem("chat.enableReasoning", String(enabled)); +}); + +watch(sendShortcut, (shortcut) => { + localStorage.setItem("chat.sendShortcut", shortcut); +}); + watch(transportMode, (mode) => { localStorage.setItem("chat.transportMode", mode); }); diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index 161191dbef1..6b6f9ddbd3d 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -267,9 +267,17 @@ const currentSession = ref(null); const draft = ref(""); const initializing = ref(false); const settingsOpen = ref(false); -const sendShortcut = ref<"enter" | "shift_enter">("enter"); -const enableStreaming = ref(true); -const enableReasoning = ref(true); +const sendShortcut = ref<"enter" | "shift_enter">( + localStorage.getItem("chat.sendShortcut") === "shift_enter" + ? "shift_enter" + : "enter", +); +const enableStreaming = ref( + localStorage.getItem("chat.enableStreaming") !== "false", +); +const enableReasoning = ref( + localStorage.getItem("chat.enableReasoning") !== "false", +); const shouldStickToBottom = ref(true); const messagesContainer = ref(null); const inputRef = ref | null>(null); @@ -320,6 +328,18 @@ const transportMode = ref( : "sse", ); +watch(enableStreaming, (enabled) => { + localStorage.setItem("chat.enableStreaming", String(enabled)); +}); + +watch(enableReasoning, (enabled) => { + localStorage.setItem("chat.enableReasoning", String(enabled)); +}); + +watch(sendShortcut, (shortcut) => { + localStorage.setItem("chat.sendShortcut", shortcut); +}); + watch(transportMode, (mode) => { localStorage.setItem("chat.transportMode", mode); }); diff --git a/dashboard/tests/chatPreferencesPersistence.test.mjs b/dashboard/tests/chatPreferencesPersistence.test.mjs new file mode 100644 index 00000000000..ca702c0d44b --- /dev/null +++ b/dashboard/tests/chatPreferencesPersistence.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; +import { effectScope, nextTick, ref, watch } from "vue"; + +const components = ["Chat.vue", "StandaloneChat.vue"]; +const preferences = ["enableStreaming", "enableReasoning", "sendShortcut"]; + +function mountPreferences(component, storage, t) { + const source = readFileSync( + new URL(`../src/components/chat/${component}`, import.meta.url), + "utf8", + ) + .split('")[0]; + const ast = ts.createSourceFile( + component, + source, + ts.ScriptTarget.Latest, + true, + ); + const statements = ast.statements.filter((node) => { + if (ts.isVariableStatement(node)) { + return node.declarationList.declarations.some((declaration) => + preferences.includes(declaration.name.getText(ast)), + ); + } + return ( + ts.isExpressionStatement(node) && + ts.isCallExpression(node.expression) && + node.expression.expression.getText(ast) === "watch" && + preferences.includes(node.expression.arguments[0]?.getText(ast)) + ); + }); + const context = vm.createContext({ + ref, + watch, + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + }, + }); + const scope = effectScope(); + t.after(() => scope.stop()); + return scope.run(() => + vm.runInContext( + ts.transpile( + statements.map((node) => node.getText(ast)).join("\n") + + "\n({ enableStreaming, enableReasoning, sendShortcut });", + ), + context, + ), + ); +} + +for (const component of components) { + test(`${component} preserves the default chat preferences`, (t) => { + const state = mountPreferences(component, new Map(), t); + assert.equal(state.enableStreaming.value, true); + assert.equal(state.enableReasoning.value, true); + assert.equal(state.sendShortcut.value, "enter"); + }); + + test(`${component} saves and restores chat preferences across entry points`, async (t) => { + const storage = new Map([["chat.transportMode", "websocket"]]); + const state = mountPreferences(component, storage, t); + for (const [streaming, reasoning, shortcut] of [ + [false, false, "shift_enter"], + [true, true, "enter"], + ]) { + state.enableStreaming.value = streaming; + state.enableReasoning.value = reasoning; + state.sendShortcut.value = shortcut; + await nextTick(); + assert.equal(storage.get("chat.enableStreaming"), String(streaming)); + assert.equal(storage.get("chat.enableReasoning"), String(reasoning)); + assert.equal(storage.get("chat.sendShortcut"), shortcut); + assert.equal(storage.get("chat.transportMode"), "websocket"); + for (const entryPoint of components) { + const restored = mountPreferences(entryPoint, storage, t); + assert.equal(restored.enableStreaming.value, streaming); + assert.equal(restored.enableReasoning.value, reasoning); + assert.equal(restored.sendShortcut.value, shortcut); + } + } + }); +} From 0faeedc09af03d4a1630f0ad4eb7ccd3a7322304 Mon Sep 17 00:00:00 2001 From: C10H14N2O5 <100066858+C10H14N2O5@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:08:04 +0800 Subject: [PATCH 2/2] fix: refresh chat preferences when returning to WebChat --- dashboard/src/components/chat/Chat.vue | 16 +++++++++ .../tests/chatPreferencesPersistence.test.mjs | 35 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 86e7980a3f7..381aa91f521 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -842,6 +842,22 @@ watch(sendShortcut, (shortcut) => { localStorage.setItem("chat.sendShortcut", shortcut); }); +// Chat stays mounted while other pages can update its saved preferences. +watch( + () => props.active, + (active) => { + if (!active) return; + enableStreaming.value = + localStorage.getItem("chat.enableStreaming") !== "false"; + enableReasoning.value = + localStorage.getItem("chat.enableReasoning") !== "false"; + sendShortcut.value = + localStorage.getItem("chat.sendShortcut") === "shift_enter" + ? "shift_enter" + : "enter"; + }, +); + watch(transportMode, (mode) => { localStorage.setItem("chat.transportMode", mode); }); diff --git a/dashboard/tests/chatPreferencesPersistence.test.mjs b/dashboard/tests/chatPreferencesPersistence.test.mjs index ca702c0d44b..4686ea41d86 100644 --- a/dashboard/tests/chatPreferencesPersistence.test.mjs +++ b/dashboard/tests/chatPreferencesPersistence.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import vm from "node:vm"; import ts from "typescript"; -import { effectScope, nextTick, ref, watch } from "vue"; +import { effectScope, nextTick, reactive, ref, watch } from "vue"; const components = ["Chat.vue", "StandaloneChat.vue"]; const preferences = ["enableStreaming", "enableReasoning", "sendShortcut"]; @@ -31,12 +31,17 @@ function mountPreferences(component, storage, t) { ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) && node.expression.expression.getText(ast) === "watch" && - preferences.includes(node.expression.arguments[0]?.getText(ast)) + (preferences.includes(node.expression.arguments[0]?.getText(ast)) || + (node.expression.arguments[0]?.getText(ast) === "() => props.active" && + node.expression.arguments[1] + ?.getText(ast) + .includes("enableStreaming.value"))) ); }); const context = vm.createContext({ ref, watch, + props: reactive({ active: true }), localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), @@ -48,13 +53,37 @@ function mountPreferences(component, storage, t) { vm.runInContext( ts.transpile( statements.map((node) => node.getText(ast)).join("\n") + - "\n({ enableStreaming, enableReasoning, sendShortcut });", + "\n({ enableStreaming, enableReasoning, sendShortcut, props });", ), context, ), ); } +test("retained Chat.vue refreshes preferences when it becomes active again", async (t) => { + const storage = new Map([["chat.transportMode", "websocket"]]); + const mainChat = mountPreferences("Chat.vue", storage, t); + for (const [streaming, reasoning, shortcut] of [ + [false, false, "shift_enter"], + [true, true, "enter"], + ]) { + mainChat.props.active = false; + await nextTick(); + const testChat = mountPreferences("StandaloneChat.vue", storage, t); + testChat.enableStreaming.value = streaming; + testChat.enableReasoning.value = reasoning; + testChat.sendShortcut.value = shortcut; + await nextTick(); + + mainChat.props.active = true; + await nextTick(); + assert.equal(mainChat.enableStreaming.value, streaming); + assert.equal(mainChat.enableReasoning.value, reasoning); + assert.equal(mainChat.sendShortcut.value, shortcut); + assert.equal(storage.get("chat.transportMode"), "websocket"); + } +}); + for (const component of components) { test(`${component} preserves the default chat preferences`, (t) => { const state = mountPreferences(component, new Map(), t);