Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions dashboard/src/components/chat/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -822,6 +830,34 @@ const transportMode = ref<TransportMode>(
: "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);
});

// 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);
});
Expand Down
26 changes: 23 additions & 3 deletions dashboard/src/components/chat/StandaloneChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,17 @@ const currentSession = ref<Session | null>(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<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
Expand Down Expand Up @@ -320,6 +328,18 @@ const transportMode = ref<TransportMode>(
: "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);
});
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
Expand Down
118 changes: 118 additions & 0 deletions dashboard/tests/chatPreferencesPersistence.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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, reactive, 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('<script setup lang="ts">')[1]
.split("</script>")[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)) ||
(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),
},
});
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, 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);
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);
}
}
});
}
Loading