diff --git a/src/App.vue b/src/App.vue index bf8558af82..d849be0458 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,6 +15,7 @@ import UpdateDialog from "@/components/layout/UpdateDialog.vue"; import LoginPage from "@/components/auth/LoginPage.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; +import { useAgentRuntimeStore } from "@/stores/agentRuntimeStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; @@ -46,6 +47,7 @@ import type { HistoryEntry } from "@/lib/tauri"; const { t } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); +const agentRuntimeStore = useAgentRuntimeStore(); const settingsStore = useSettingsStore(); const savedSqlStore = useSavedSqlStore(); const { message: toastMessage, visible: toastVisible, toast } = useToast(); @@ -158,6 +160,7 @@ watch( () => queryStore.activeTabId, () => { selectedSql.value = ""; + agentRuntimeStore.setSelectedSql(""); activeOutputView.value = "result"; }, ); @@ -614,7 +617,12 @@ onUnmounted(() => { if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, v); } " - @editor-selection-change="(v: string) => (selectedSql = v)" + @editor-selection-change=" + (v: string) => { + selectedSql = v; + agentRuntimeStore.setSelectedSql(v); + } + " @editor-cursor-change="(p: number) => (cursorPos = p)" @format-error="toast(t('toolbar.formatSqlFailed'))" @reload=" diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 9e5a6e644f..3b115df733 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -62,9 +62,17 @@ import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database"; import * as api from "@/lib/api"; import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; import { isHiddenGridColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing"; -import { formatGridSqlLiteral } from "@/lib/dataGridSql"; -import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; +import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql"; +import { formatMarkdownTable } from "@/lib/markdownTable"; +import { buildXlsxWorkbook } from "@/lib/xlsxExport"; +import { + matchesRowStatusFilter, + rowStatusFilterAfterAddingRow, + type RowStatus, + type RowStatusFilter, +} from "@/lib/gridRowStatus"; +import { useAgentRuntimeStore } from "@/stores/agentRuntimeStore"; import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; @@ -74,6 +82,7 @@ import { useDataGridEditor } from "@/composables/useDataGridEditor"; const { t } = useI18n(); const { toast } = useToast(); +const agentRuntimeStore = useAgentRuntimeStore(); const props = defineProps<{ result: QueryResult; @@ -1090,6 +1099,22 @@ const activeCellDetail = computed(() => { const detailEditValue = ref(""); const isEditingDetail = ref(false); +watch( + selectedCells, + (data) => { + agentRuntimeStore.setSelection( + selectedCellCount.value > 0 + ? { + type: "grid-cells", + range: selectedRange.value, + data, + } + : { type: "none" }, + ); + }, + { deep: true }, +); + function startDetailEdit() { const detail = activeCellDetail.value; if (!detail || !detail.isEditable) return; @@ -1611,6 +1636,7 @@ watch( onUnmounted(() => { cleanupFrames(); + agentRuntimeStore.setSelection({ type: "none" }); onDdlResizeEnd(); finishCellSelection(); clearTimeout(_searchTimer); diff --git a/src/lib/agentRuntimeSnapshot.ts b/src/lib/agentRuntimeSnapshot.ts new file mode 100644 index 0000000000..5494863fd8 --- /dev/null +++ b/src/lib/agentRuntimeSnapshot.ts @@ -0,0 +1,62 @@ +import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database"; + +export const DEFAULT_RESULT_SAMPLE_LIMIT = 50; + +export interface RuntimeResultSample { + columns: string[]; + rows: QueryResult["rows"]; + truncated: boolean; + executionTimeMs: number; + sampleLimit: number; +} + +export interface AgentRuntimeSnapshot { + activeConnectionId?: string; + activeConnectionName?: string; + database?: string; + schema?: string; + activeTabId?: string; + activeTabTitle?: string; + sql?: string; + selectedSql?: string; + selection: unknown; + result?: RuntimeResultSample; +} + +export interface BuildAgentRuntimeSnapshotOptions { + tabs: QueryTab[]; + activeTabId: string | null; + getConnection: (connectionId: string) => Pick | undefined; + selectedSql?: string; + selection?: unknown; + resultSampleLimit?: number; +} + +export function buildAgentRuntimeSnapshot(options: BuildAgentRuntimeSnapshotOptions): AgentRuntimeSnapshot { + const tab = options.tabs.find((item) => item.id === options.activeTabId); + const conn = tab ? options.getConnection(tab.connectionId) : undefined; + const selectedSql = options.selectedSql?.trim(); + const limit = options.resultSampleLimit ?? DEFAULT_RESULT_SAMPLE_LIMIT; + const result = tab?.result + ? { + columns: tab.result.columns, + rows: tab.result.rows.slice(0, limit), + truncated: tab.result.rows.length > limit || !!tab.result.truncated, + executionTimeMs: tab.result.execution_time_ms, + sampleLimit: limit, + } + : undefined; + + return { + activeConnectionId: tab?.connectionId, + activeConnectionName: conn?.name, + database: tab?.database, + schema: tab?.schema, + activeTabId: tab?.id, + activeTabTitle: tab?.title, + sql: tab?.sql, + selectedSql: selectedSql || undefined, + selection: options.selection ?? { type: "none" }, + result, + }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 86fa229dd3..0098c528df 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -72,6 +72,10 @@ export const executeScript = forward("executeScript"); export const executeInTransaction = forward("executeInTransaction"); export const cancelQuery = forward("cancelQuery"); +// Agent Runtime +export const agentRuntimeUpdateSnapshot = forward("agentRuntimeUpdateSnapshot"); +export const agentRuntimeLoadHandoffs = forward("agentRuntimeLoadHandoffs"); + // AI export const aiComplete = forward("aiComplete"); export const aiStream = forward("aiStream"); diff --git a/src/lib/http.ts b/src/lib/http.ts index 6fdfb977ab..148a68f71f 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -39,6 +39,7 @@ import type { TableImportSummary, TableImportProgress, } from "./tauri"; +import type { AgentRuntimeSnapshot } from "@/lib/agentRuntimeSnapshot"; // --------------------------------------------------------------------------- // Helpers @@ -290,6 +291,12 @@ export async function cancelQuery(executionId: string): Promise { return post("/api/query/cancel", { executionId }); } +export async function agentRuntimeUpdateSnapshot(_snapshot: AgentRuntimeSnapshot): Promise {} + +export async function agentRuntimeLoadHandoffs(): Promise { + return []; +} + // --------------------------------------------------------------------------- // AI // --------------------------------------------------------------------------- diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 1c2906ffcb..2be0e47a67 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -19,6 +19,7 @@ import type { SavedSqlFolder, SavedSqlLibrary, } from "@/types/database"; +import type { AgentRuntimeSnapshot } from "@/lib/agentRuntimeSnapshot"; import type { AiConfig } from "@/stores/settingsStore"; export interface AiMessage { @@ -80,6 +81,14 @@ export async function loadAiConfig(): Promise { return invoke("load_ai_config"); } +export async function agentRuntimeUpdateSnapshot(snapshot: AgentRuntimeSnapshot): Promise { + return invoke("agent_runtime_update_snapshot", { snapshot }); +} + +export async function agentRuntimeLoadHandoffs(): Promise { + return invoke("agent_runtime_load_handoffs"); +} + // --- AI Conversations --- export interface AiChatMessage { diff --git a/src/stores/agentRuntimeStore.ts b/src/stores/agentRuntimeStore.ts new file mode 100644 index 0000000000..97d7f38be5 --- /dev/null +++ b/src/stores/agentRuntimeStore.ts @@ -0,0 +1,51 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import * as api from "@/lib/api"; +import { buildAgentRuntimeSnapshot, DEFAULT_RESULT_SAMPLE_LIMIT } from "@/lib/agentRuntimeSnapshot"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; + +export const useAgentRuntimeStore = defineStore("agentRuntime", () => { + const selection = ref({ type: "none" }); + const selectedSql = ref(""); + let timer: ReturnType | null = null; + + function setSelection(value: unknown) { + selection.value = value; + scheduleSync(); + } + + function setSelectedSql(value: string) { + selectedSql.value = value; + scheduleSync(); + } + + function scheduleSync() { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void syncNow(); + }, 100); + } + + async function syncNow() { + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + const snapshot = buildAgentRuntimeSnapshot({ + tabs: queryStore.tabs, + activeTabId: queryStore.activeTabId, + getConnection: (connectionId) => connectionStore.getConfig(connectionId), + selectedSql: selectedSql.value, + selection: selection.value, + resultSampleLimit: DEFAULT_RESULT_SAMPLE_LIMIT, + }); + + try { + await api.agentRuntimeUpdateSnapshot(snapshot); + } catch (err) { + console.debug("[DBX] Agent runtime snapshot sync skipped:", err); + } + } + + return { selection, selectedSql, setSelection, setSelectedSql, scheduleSync, syncNow }; +}); diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index d3094ddb9e..4532c5b425 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -10,6 +10,7 @@ import { analyzeEditableQuery, allPrimaryKeysPresent } from "@/lib/sqlAnalysis"; import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence"; import * as api from "@/lib/api"; import { useConnectionStore } from "@/stores/connectionStore"; +import { useAgentRuntimeStore } from "@/stores/agentRuntimeStore"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import type { SavedSqlFile } from "@/types/database"; @@ -72,11 +73,18 @@ export const useQueryStore = defineStore("query", () => { return tabs.value.find((t) => t.connectionId === connectionId && t.database === database && t.title === title); } + function scheduleAgentRuntimeSync() { + useAgentRuntimeStore().scheduleSync(); + } + + watch(activeTabId, () => scheduleAgentRuntimeSync()); + function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query") { if (title) { const existing = findTabByTitle(connectionId, database, title); if (existing) { activeTabId.value = existing.id; + scheduleAgentRuntimeSync(); return existing.id; } } @@ -95,6 +103,7 @@ export const useQueryStore = defineStore("query", () => { }; tabs.value.push(tab); activeTabId.value = id; + scheduleAgentRuntimeSync(); return id; } @@ -145,6 +154,7 @@ export const useQueryStore = defineStore("query", () => { if (activeTabId.value === id) { activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null; } + scheduleAgentRuntimeSync(); } function closeOtherTabs(id: string) { @@ -153,6 +163,7 @@ export const useQueryStore = defineStore("query", () => { const next = closeOtherTabsState(tabs.value, activeTabId.value, id); tabs.value = next.tabs; activeTabId.value = next.activeTabId; + scheduleAgentRuntimeSync(); } function closeAllTabs() { @@ -161,6 +172,7 @@ export const useQueryStore = defineStore("query", () => { const next = closeAllTabsState(tabs.value, activeTabId.value); tabs.value = next.tabs; activeTabId.value = next.activeTabId; + scheduleAgentRuntimeSync(); } function updateSql(id: string, sql: string) { @@ -169,6 +181,7 @@ export const useQueryStore = defineStore("query", () => { tab.sql = sql; tab.resultSortedSql = undefined; tab.resultBaseSql = undefined; + scheduleAgentRuntimeSync(); } } @@ -183,6 +196,7 @@ export const useQueryStore = defineStore("query", () => { const existing = tabs.value.find((tab) => tab.savedSqlId === file.id); if (existing) { activeTabId.value = existing.id; + scheduleAgentRuntimeSync(); return existing.id; } @@ -202,6 +216,7 @@ export const useQueryStore = defineStore("query", () => { }; tabs.value.push(tab); activeTabId.value = id; + scheduleAgentRuntimeSync(); return id; } @@ -224,6 +239,7 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortedSql = undefined; clearExplain(tab); tab.tableMeta = undefined; + scheduleAgentRuntimeSync(); } function updateSchema(id: string, schema: string | undefined) { @@ -231,6 +247,7 @@ export const useQueryStore = defineStore("query", () => { if (!tab || tab.schema === schema) return; tab.schema = schema; if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema }; + scheduleAgentRuntimeSync(); } function updateConnection(id: string, connectionId: string, database = "") { @@ -245,6 +262,7 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortedSql = undefined; clearExplain(tab); tab.tableMeta = undefined; + scheduleAgentRuntimeSync(); } function setTableMeta(id: string, meta: NonNullable) { @@ -410,6 +428,7 @@ export const useQueryStore = defineStore("query", () => { console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() }); await analyzeQueryMetadata(current, current.resultBaseSql); console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() }); + scheduleAgentRuntimeSync(); } else { console.warn("[DBX][executeTabSql:stale-result]", { traceId, @@ -428,6 +447,7 @@ export const useQueryStore = defineStore("query", () => { if (current.mode !== "data") current.tableMeta = undefined; current.resultBaseSql = options?.resultBaseSql ?? sql; current.resultSortedSql = options?.resultSortedSql; + scheduleAgentRuntimeSync(); } } finally { const current = tabs.value.find((t) => t.id === id); @@ -538,6 +558,7 @@ export const useQueryStore = defineStore("query", () => { if (!tab?.results || index < 0 || index >= tab.results.length) return; tab.activeResultIndex = index; tab.result = tab.results[index]; + scheduleAgentRuntimeSync(); } function trimResultCache() { diff --git a/tests/agentRuntimeSnapshot.test.ts b/tests/agentRuntimeSnapshot.test.ts new file mode 100644 index 0000000000..3b02ddf167 --- /dev/null +++ b/tests/agentRuntimeSnapshot.test.ts @@ -0,0 +1,80 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { buildAgentRuntimeSnapshot } from "../src/lib/agentRuntimeSnapshot.ts"; + +test("builds runtime snapshot from active tab, selection, and limited result rows", () => { + const snapshot = buildAgentRuntimeSnapshot({ + tabs: [ + { + id: "tab-1", + title: "Orders", + connectionId: "conn-1", + database: "sales", + schema: "public", + sql: "select * from orders", + result: { + columns: ["id"], + rows: [[1], [2], [3]], + affected_rows: 0, + execution_time_ms: 12, + }, + isExecuting: false, + mode: "query", + }, + ], + activeTabId: "tab-1", + getConnection: (id) => (id === "conn-1" ? { name: "Prod Sales" } : undefined), + selectedSql: "select *", + selection: { + type: "grid-cells", + data: { columns: ["id"], rows: [[1]] }, + }, + resultSampleLimit: 2, + }); + + assert.deepEqual(snapshot, { + activeConnectionId: "conn-1", + activeConnectionName: "Prod Sales", + database: "sales", + schema: "public", + activeTabId: "tab-1", + activeTabTitle: "Orders", + sql: "select * from orders", + selectedSql: "select *", + selection: { + type: "grid-cells", + data: { columns: ["id"], rows: [[1]] }, + }, + result: { + columns: ["id"], + rows: [[1], [2]], + truncated: true, + executionTimeMs: 12, + sampleLimit: 2, + }, + }); +}); + +test("omits empty selected SQL and marks empty UI selection as none", () => { + const snapshot = buildAgentRuntimeSnapshot({ + tabs: [ + { + id: "tab-2", + title: "Scratch", + connectionId: "conn-2", + database: "", + sql: "", + isExecuting: false, + mode: "query", + }, + ], + activeTabId: "tab-2", + getConnection: () => ({ name: "Local" }), + selectedSql: " ", + resultSampleLimit: 50, + }); + + assert.equal(snapshot.selectedSql, undefined); + assert.deepEqual(snapshot.selection, { type: "none" }); + assert.equal(snapshot.result, undefined); +});