diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 773e35a46..7227ff00c 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -103,7 +103,7 @@ const showSettingsDialog = ref(false); const showDriverStore = ref(false); const agentDriverUpdateCount = ref(0); const showHistory = ref(false); -const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false"); +const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") === "true"); const aiPanelReady = ref(false); const { sidebarWidth, aiPanelWidth, historyWidth, startSidebarResize, startAiPanelResize, startHistoryResize } = usePanelResize(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d2c95a792..142f55f88 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -62,6 +62,22 @@ export const useQueryStore = defineStore("query", () => { } } + function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) { + tab.result = undefined; + tab.results = undefined; + tab.activeResultIndex = undefined; + tab.resultSessionId = undefined; + tab.queryAnalysis = undefined; + tab.querySourceColumns = undefined; + tab.queryEditabilityReason = undefined; + tab.resultEvicted = options.evicted ? true : undefined; + } + + async function evictCachedResult(tab: QueryTab) { + await closeResultSession(tab); + clearResultPayload(tab, { evicted: true }); + } + const _persistSnapshot = computed(() => tabs.value.map((t) => ({ id: t.id, @@ -164,8 +180,7 @@ export const useQueryStore = defineStore("query", () => { if (tabs.value[idx].isExecuting) void cancelTabExecution(id); if (tabs.value[idx].isExplaining) void cancelTabExplain(id); void closeResultSession(tabs.value[idx]); - tabs.value[idx].result = undefined; - tabs.value[idx].results = undefined; + clearResultPayload(tabs.value[idx]); tabs.value.splice(idx, 1); if (activeTabId.value === id) { activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null; @@ -248,13 +263,11 @@ export const useQueryStore = defineStore("query", () => { tab.database = database; tab.schema = undefined; tab.objectBrowser = undefined; - tab.result = undefined; + void closeResultSession(tab); + clearResultPayload(tab); tab.lastExecutedSql = undefined; tab.resultBaseSql = undefined; tab.resultSortedSql = undefined; - tab.queryAnalysis = undefined; - tab.querySourceColumns = undefined; - tab.queryEditabilityReason = undefined; clearExplain(tab); tab.tableMeta = undefined; } @@ -272,13 +285,11 @@ export const useQueryStore = defineStore("query", () => { tab.connectionId = connectionId; tab.database = database; tab.schema = undefined; - tab.result = undefined; + void closeResultSession(tab); + clearResultPayload(tab); tab.lastExecutedSql = undefined; tab.resultBaseSql = undefined; tab.resultSortedSql = undefined; - tab.queryAnalysis = undefined; - tab.querySourceColumns = undefined; - tab.queryEditabilityReason = undefined; clearExplain(tab); tab.tableMeta = undefined; } @@ -325,6 +336,9 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.result = toErrorResult(e); + tab.results = undefined; + tab.activeResultIndex = undefined; + tab.resultSessionId = undefined; tab.isExecuting = false; tab.isCancelling = false; tab.executionId = undefined; @@ -608,7 +622,7 @@ export const useQueryStore = defineStore("query", () => { }); } } - trimResultCache(); + await trimResultCache(); } async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType) { @@ -707,17 +721,11 @@ export const useQueryStore = defineStore("query", () => { tab.queryEditabilityReason = undefined; } - function trimResultCache() { - const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && t.result); + async function trimResultCache() { + const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results)); if (inactive.length > MAX_CACHED_RESULTS) { const toEvict = inactive.slice(0, inactive.length - MAX_CACHED_RESULTS); - toEvict.forEach((t) => { - t.result = undefined; - t.resultEvicted = true; - t.queryAnalysis = undefined; - t.querySourceColumns = undefined; - t.queryEditabilityReason = undefined; - }); + await Promise.all(toEvict.map((t) => evictCachedResult(t))); } } diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 69e8d8c15..068376a5d 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -2,6 +2,25 @@ import { strict as assert } from "node:assert"; import test from "node:test"; import { createPinia, setActivePinia } from "pinia"; import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts"; +import type { QueryResult } from "../../apps/desktop/src/types/database.ts"; + +function installMemoryStorage() { + const values = new Map(); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return () => { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }; +} test("setErrorResult stops loading and shows the error result", () => { setActivePinia(createPinia()); @@ -18,3 +37,65 @@ test("setErrorResult stops loading and shows the error result", () => { assert.deepEqual(tab?.result?.columns, ["Error"]); assert.deepEqual(tab?.result?.rows, [["Error: metadata failed"]]); }); + +test("evicting cached tab results releases multi-result payloads and sessions", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + let executeCount = 0; + const closedSessions: string[] = []; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + executeCount++; + const results: QueryResult[] = [ + { + columns: ["id"], + rows: [[executeCount]], + affected_rows: 0, + execution_time_ms: 1, + session_id: `session-${executeCount}`, + }, + { + columns: ["detail"], + rows: [[`payload-${executeCount}`]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]; + return new Response(JSON.stringify(results), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/query/close-session") { + const body = JSON.parse(String(init?.body ?? "{}")); + closedSessions.push(body.sessionId); + return new Response(JSON.stringify(true), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + const tabIds: string[] = []; + for (let i = 0; i < 7; i++) { + const tabId = store.createTab("conn-1", "db", `Query ${i + 1}`); + tabIds.push(tabId); + await store.executeTabSql(tabId, `select ${i + 1}; select ${i + 1} as detail`); + } + + const evicted = store.tabs.find((tab) => tab.id === tabIds[0]); + assert.equal(executeCount, 7); + assert.equal(evicted?.result, undefined); + assert.equal(evicted?.results, undefined); + assert.equal(evicted?.activeResultIndex, undefined); + assert.equal(evicted?.resultSessionId, undefined); + assert.equal(evicted?.resultEvicted, true); + assert.deepEqual(closedSessions, ["session-1"]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); diff --git a/packages/app-tests/startupChunkBoundaries.test.ts b/packages/app-tests/startupChunkBoundaries.test.ts index cbdc8b826..db036be24 100644 --- a/packages/app-tests/startupChunkBoundaries.test.ts +++ b/packages/app-tests/startupChunkBoundaries.test.ts @@ -16,6 +16,10 @@ test("app defers cold-start side panels and modal pages behind async components" } }); +test("AI assistant panel stays closed on first launch to preserve startup memory", () => { + assert.match(appSource, /const showAiPanel = ref\(localStorage\.getItem\("dbx-ai-panel-open"\) === "true"\)/); +}); + test("app dialogs keep non-primary dialogs out of the startup chunk", () => { for (const component of ["ConnectionDialog", "EditorSettingsDialog", "DangerConfirmDialog"]) { assert.doesNotMatch(appDialogsSource, new RegExp(`import ${component} from`));