From 5a67c5dbd69ee5185ed0ee2a7acab0598d5bb066 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Wed, 14 Oct 2026 20:46:20 +0800 Subject: [PATCH] fix(ai): report ai sql action results and errors in pane --- apps/desktop/src/App.vue | 191 +++++++++++++++-- .../src/components/editor/AiAssistant.vue | 153 ++++++++++++-- .../AiAssistant.sqlActionFeedback.spec.ts | 195 ++++++++++++++++++ .../__tests__/useSqlExecution.spec.ts | 44 ++++ .../src/composables/useSqlExecution.ts | 125 ++++++++--- .../i18n/__tests__/featureMessages.spec.ts | 5 + apps/desktop/src/i18n/locales/az.ts | 12 ++ apps/desktop/src/i18n/locales/en.ts | 12 ++ apps/desktop/src/i18n/locales/es.ts | 12 ++ apps/desktop/src/i18n/locales/id.ts | 12 ++ apps/desktop/src/i18n/locales/it.ts | 12 ++ apps/desktop/src/i18n/locales/ja.ts | 12 ++ apps/desktop/src/i18n/locales/ko.ts | 12 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 12 ++ apps/desktop/src/i18n/locales/ru.ts | 12 ++ apps/desktop/src/i18n/locales/th.ts | 12 ++ apps/desktop/src/i18n/locales/tr.ts | 12 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 12 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 12 ++ .../ai/__tests__/aiSqlActionFeedback.spec.ts | 101 +++++++++ .../desktop/src/lib/ai/aiSqlActionFeedback.ts | 117 +++++++++++ .../src/lib/sql/sqlExecutionOutcome.ts | 34 +++ docs/content/docs/ai-assistant.cn.mdx | 14 ++ docs/content/docs/ai-assistant.mdx | 14 ++ 24 files changed, 1097 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/AiAssistant.sqlActionFeedback.spec.ts create mode 100644 apps/desktop/src/lib/ai/__tests__/aiSqlActionFeedback.spec.ts create mode 100644 apps/desktop/src/lib/ai/aiSqlActionFeedback.ts create mode 100644 apps/desktop/src/lib/sql/sqlExecutionOutcome.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 59d69d190d..5c86861680 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -162,6 +162,9 @@ import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { isShortcutEditingKeydown, isShortcutEditingTarget } from "@/lib/editor/shortcutRecorder"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; +import { aiSqlActionFeedback, type AiSqlActionOutcome, type AiSqlActionReport } from "@/lib/ai/aiSqlActionFeedback"; +import type { SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome"; +import { tabDisplayTitle } from "@/lib/tabs/tabPresentation"; import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; import { assessProductionSql } from "@/lib/database/productionSafety"; import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities"; @@ -237,6 +240,12 @@ type AiAssistantHandle = { focusSearch: () => boolean; /** Opens a conversation by id (used by the background-run toast, §9). */ selectConversationById: (conversationId: string) => void; + /** + * Settlement of an AI-initiated SQL action (#11962). The panel shows the + * outcome inside the SQL block that started it, because the target editor tab + * may not be on screen (#9902 keeps AI actions from moving the workspace). + */ + reportAiSqlAction: (report: AiSqlActionReport) => void; }; type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null; @@ -1050,6 +1059,9 @@ const { requestDangerConfirmation: (request) => sqlExecutionDangerStore.requestConfirmation(request), onExecutionStarted: (editorViewportRequestId) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(editorViewportRequestId), onExecutionCancelled: (editorViewportRequestId) => contentAreaRef.value?.cancelQueryEditorExecutionViewport(editorViewportRequestId), + // A dialog-parked execution reports its real outcome here, not from tryExecute + // (#11962): that call returned as soon as the dialog opened. + onDeferredExecutionSettled: (outcome) => settleDeferredAiSqlAction(outcome), }); function captureActiveEditorExecutionSnapshot(tabId: string) { @@ -3631,34 +3643,187 @@ function aiTargetTabSql(tabId: string): string { return queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? ""; } -function onAiAppendSql(sql: string, target: AiConversationBinding) { - if (routeAiRedisCommand(sql, false, target)) return; +/** + * Whether an AI action's feedback has to come from the panel instead of the tab. + * + * Deliberately *conservative* against the `v-show` on the editor content + * (`isSpecialPageActive || (!isAiPanelMaximized && !isHistoryPanelMaximized) || + * isZenMode`): any state that hides the workspace counts as not rendered. Zen + * mode is the one divergence — it renders the workspace while `isAiPanelMaximized` + * can still be latched, and the AI panel itself is hidden in zen, so reporting the + * toast there is the right call rather than a spare one. This can only ever + * produce an extra notice; it never hides one. + */ +function workspaceRendered(): boolean { + return !isAiPanelMaximized.value && !isHistoryPanelMaximized.value && !isSpecialPageActive.value; +} + +/** + * Whether an AI action's target tab is actually on screen (#11962). + * + * "On screen" means the workspace renders *and* the tab is the active tab of + * some editor group. Every group renders (SqlEditorWorkspace.vue), so a + * non-focused split group's active tab counts — same idiom as + * `SqlEditorWorkspace.editorGroupForTab`. + */ +function aiTargetTabOnScreen(tabId: string): boolean { + return workspaceRendered() && queryStore.groups.some((group) => group.activeTabId === tabId); +} + +function aiTargetTabTitle(tabId: string): string { + const tab = queryStore.tabs.find((candidate) => candidate.id === tabId); + return tab ? tabDisplayTitle(tab, t) : tabId; +} + +/** Rows returned or affected by the target tab's current result, for the panel summary. */ +function aiTargetResultCounts(tabId: string): { rows?: number; affectedRows?: number } { + const result = queryStore.tabs.find((tab) => tab.id === tabId)?.result; + if (!result || result.execution_error === true) return {}; + return { + ...(result.rows.length ? { rows: result.rows.length } : {}), + ...(result.affected_rows ? { affectedRows: result.affected_rows } : {}), + }; +} + +/** + * Outcome reasons that mean the user was already shown something (a danger or + * production dialog, the SQL-parameter dialog, the read-only unlock prompt, the + * blocked-Redis toast, the "select a database" toast) and dismissed it. The + * panel then only stops its "running" state — no second report. + */ +const DISMISSED_OUTCOME_REASONS = new Set(["cancelled", "parameters", "blocked", "missing-database"]); + +/** + * AI actions waiting for a dialog-parked execution (#11962). + * + * Keyed by tab because the resumed execution identifies itself by tab only, and + * the parameter / danger dialogs are modal, so one pending entry per tab is + * enough; a second click on the same tab simply takes over the entry. + */ +const deferredAiSqlActions = new Map(); + +function rememberDeferredAiSqlAction(tabId: string, requestId: number | undefined): void { + if (requestId !== undefined) deferredAiSqlActions.set(tabId, requestId); +} + +/** + * Settlement of a dialog-parked execution. Executions started from the editor + * reach this too — they have no entry, and the editor owns its own feedback — so + * an unknown tab is deliberately ignored. + */ +function settleDeferredAiSqlAction(outcome: SqlExecutionOutcome): void { + const tabId = outcome.tabId; + const requestId = tabId ? deferredAiSqlActions.get(tabId) : undefined; + if (tabId) deferredAiSqlActions.delete(tabId); + if (requestId === undefined) return; + settleAiSqlAction(requestId, outcome); +} + +/** + * Reports an AI action's settlement to the panel and, when the target tab is not + * on screen, raises the toast plus `[查看]` action that gets the user there. + * + * Feedback lives here because the editor cannot deliver it: the target tab is + * created without activation (#9902) and may be hidden behind another tab, a + * maximized panel, or a settings page. Never activates the tab itself — only the + * `[查看]` click does. + */ +function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void { + const tabId = outcome.tabId; + if (outcome.status === "deferred") { + // Parked on a parameter / danger / Redis dialog. The block keeps its "running" + // state — the click still shows something — and nothing is reported yet: the + // execution has not run, so the resumed one settles it through + // `onDeferredExecutionSettled`. + rememberDeferredAiSqlAction(outcome.tabId, requestId); + return; + } + const targetOnScreen = !!tabId && aiTargetTabOnScreen(tabId); + const feedback = aiSqlActionFeedback(outcome, { targetOnScreen, tabTitle: tabId ? aiTargetTabTitle(tabId) : undefined }); + const feedbackText = feedback.level === "none" ? undefined : t(feedback.messageKey, feedback.params); + + if (requestId !== undefined && aiAssistantRef.value) { + const report: AiSqlActionReport = (() => { + if (outcome.status === "applied") return { requestId, status: "applied" }; + if (outcome.status === "not-started") { + // A dismissal (dialog answered) has nothing to say; anything else keeps the + // reason in the block, where it outlives the toast. + return DISMISSED_OUTCOME_REASONS.has(outcome.reason) ? { requestId, status: "dismissed" as const } : { requestId, status: "unavailable" as const, ...(feedbackText ? { message: feedbackText } : {}) }; + } + return { + requestId, + status: outcome.ok ? ("succeeded" as const) : ("failed" as const), + ...(outcome.ok ? aiTargetResultCounts(outcome.tabId) : { message: outcome.errorMessage }), + }; + })(); + aiAssistantRef.value.reportAiSqlAction(report); + } + + if (feedback.level === "none" || feedbackText === undefined) return; + const revealTabId = feedback.revealTabId; + // `[查看]` is the only path that moves the workspace (#9902): the AI action + // itself never activates, selects, or creates anything the user did not ask for. + const reveal = revealTabId ? { label: t("ai.sqlActionViewTab"), onClick: () => activateQueryTab(revealTabId) } : undefined; + toast(feedbackText, 5000, reveal); +} + +function onAiAppendSql(sql: string, target: AiConversationBinding, requestId?: number) { + if (routeAiRedisCommand(sql, false, target)) { + // Redis keeps its own feedback (`ai.redisConsoleUnreachable`); this only + // stops the block's "running" state, which no editor tab can settle. + settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" }); + return; + } const tabId = ensureQueryTabForConnection(target); - if (!tabId) return; + if (!tabId) { + settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" }); + return; + } const currentSql = aiTargetTabSql(tabId); const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql); if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql); + settleAiSqlAction(requestId, { status: "applied", tabId }); } -function runAiGeneratedSql(sql: string, tabId: string) { - if (!tabId) return; +function runAiGeneratedSql(sql: string, tabId: string, requestId?: number): Promise { + if (!tabId) { + settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" }); + return Promise.resolve(); + } selectedSql.value = ""; - nextTick(() => tryExecute(sql, { tabId })); + return nextTick(async () => { + try { + settleAiSqlAction(requestId, await tryExecute(sql, { tabId })); + } catch (error) { + // Execution threw before it could report. Settle as a failure anyway: a + // request that never comes back must not leave the block spinning. + settleAiSqlAction(requestId, { status: "started", ok: false, errorMessage: translateBackendError(t, error), tabId }); + } + }); } -function onAiExecuteSql(sql: string, target: AiConversationBinding) { - if (routeAiRedisCommand(sql, true, target)) return; +function onAiExecuteSql(sql: string, target: AiConversationBinding, requestId?: number) { + if (routeAiRedisCommand(sql, true, target)) { + settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" }); + return; + } const tabId = ensureQueryTabForConnection(target); - if (!tabId) return; + if (!tabId) { + settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" }); + return; + } const currentSql = aiTargetTabSql(tabId); const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql); if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql); - runAiGeneratedSql(sql, tabId); + void runAiGeneratedSql(sql, tabId, requestId); } -function onAiTempRunSql(sql: string, target: AiConversationBinding) { - if (routeAiRedisCommand(sql, true, target)) return; - runAiGeneratedSql(sql, ensureQueryTabForConnection(target)); +function onAiTempRunSql(sql: string, target: AiConversationBinding, requestId?: number) { + if (routeAiRedisCommand(sql, true, target)) { + settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" }); + return; + } + void runAiGeneratedSql(sql, ensureQueryTabForConnection(target), requestId); } function onAiRequestAutoExecuteSql(sql: string, target: AiConversationBinding) { diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index efdf9ddd39..a60cda31c3 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -203,6 +203,7 @@ import { databaseManifestEntry } from "@/lib/database/databaseDriverManifest"; import type { AgentEvent } from "@/lib/backend/tauri"; import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy"; +import type { AiSqlActionReport } from "@/lib/ai/aiSqlActionFeedback"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue"; import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; @@ -393,9 +394,9 @@ const props = defineProps<{ // active tab, so AI-generated SQL could be written into, and executed on, // another connection's editor. const emit = defineEmits<{ - appendSql: [sql: string, target: AiConversationBinding]; - executeSql: [sql: string, target: AiConversationBinding]; - tempRunSql: [sql: string, target: AiConversationBinding]; + appendSql: [sql: string, target: AiConversationBinding, requestId?: number]; + executeSql: [sql: string, target: AiConversationBinding, requestId?: number]; + tempRunSql: [sql: string, target: AiConversationBinding, requestId?: number]; requestAutoExecuteSql: [sql: string, target: AiConversationBinding]; insertRedisCommand: [command: string, target: AiConversationBinding]; executeRedisCommand: [command: string, target: AiConversationBinding]; @@ -1543,6 +1544,9 @@ function submitEdit(visibleIndex: number) { } draftPluginContext.value = pluginContext.value; messages.value = messages.value.slice(0, actualIndex); + // The truncated tail carried the positional SQL-block statuses of the messages + // that just disappeared; the regenerated message reuses those indices. + clearSqlActionBlockStatuses(); editingMessageIndex.value = null; editingContent.value = ""; selectedMentions.value = editingMentions.value.filter((mention): mention is AiTableMention & { kind: "table" } => mention.kind === "table").map(({ raw, schema, table }) => ({ raw, schema, table })); @@ -5088,6 +5092,7 @@ function resetPendingRequestState() { // unmount must drop it synchronously, or "识别中" stays on screen in the next // conversation until that bounded call settles. routingInFlight.value = false; + clearSqlActionBlockStatuses(); } // `alreadyCancelledSessionId`: the session id a caller (cancelStream()) has @@ -5120,28 +5125,126 @@ function abandonInFlightRequest(alreadyCancelledSessionId?: string) { } } -function applySql(code: string) { +function applySql(code: string, key: string) { if (isRedisConnection.value) { emit("insertRedisCommand", code, conversationBinding.value); return; } - emit("appendSql", code, conversationBinding.value); + // Start the block's status before the request leaves: the target tab may not + // be on screen, so this block is the only place the user can see it happen. + const requestId = beginSqlAction(key); + emit("appendSql", code, conversationBinding.value, requestId); } -function executeSql(code: string) { +function executeSql(code: string, key: string) { if (isRedisConnection.value) { emit("executeRedisCommand", code, conversationBinding.value); return; } - emit("executeSql", code, conversationBinding.value); + const requestId = beginSqlAction(key); + emit("executeSql", code, conversationBinding.value, requestId); } -function tempRunSql(code: string) { +function tempRunSql(code: string, key: string) { if (isRedisConnection.value) { emit("executeRedisCommand", code, conversationBinding.value); return; } - emit("tempRunSql", code, conversationBinding.value); + const requestId = beginSqlAction(key); + emit("tempRunSql", code, conversationBinding.value, requestId); +} + +/** + * Per-SQL-block action status (#11962). The panel is the only surface that can + * report an AI action's outcome when its editor tab is not on screen, so each + * block tracks the request it started and renders running/succeeded/failed + * inline. + */ +type AiSqlBlockStatus = + /** `requestId` is the click this block currently waits for (see beginSqlAction). */ + | { phase: "running"; requestId: number } + | { phase: "succeeded"; rows?: number; affectedRows?: number } + /** `apply` appended the SQL to the editor: there is no execution to summarise. */ + | { phase: "applied" } + | { phase: "failed"; message: string } + | { phase: "unavailable"; message: string }; + +const aiSqlBlockStatuses = ref>(new Map()); +const pendingSqlActionRequests = new Map(); +let nextSqlActionRequestId = 0; + +function beginSqlAction(key: string): number { + const requestId = ++nextSqlActionRequestId; + pendingSqlActionRequests.set(requestId, key); + const next = new Map(aiSqlBlockStatuses.value); + // A second click on the same block supersedes the first: the block tracks the + // request it is waiting for, so the earlier response cannot overwrite it. + next.set(key, { phase: "running", requestId }); + aiSqlBlockStatuses.value = next; + return requestId; +} + +/** + * Block keys are positional (`code:${messageIndex}:${segment}`), so a status only + * means anything while the transcript that produced it is still on screen. + * Every path that replaces or truncates the transcript must drop them, or a + * stale "failed" line renders on whatever block now sits at that index — in this + * chat after an edit-resend, or in another conversation entirely. The call sites + * are `selectConversation`, `clearMessages`, the edit-resend truncation, and + * `resetPendingRequestState`. + */ +function clearSqlActionBlockStatuses() { + if (aiSqlBlockStatuses.value.size) aiSqlBlockStatuses.value = new Map(); + pendingSqlActionRequests.clear(); +} + +/** Host callback: settles the block that started request `requestId`. */ +function reportAiSqlAction(report: AiSqlActionReport) { + const key = pendingSqlActionRequests.get(report.requestId); + if (key === undefined) return; + pendingSqlActionRequests.delete(report.requestId); + const pending = aiSqlBlockStatuses.value.get(key); + if (pending?.phase === "running" && pending.requestId !== report.requestId) { + // The block was clicked again while this request was in flight; that later + // click owns the block's status, so this stale answer is dropped. + return; + } + const next = new Map(aiSqlBlockStatuses.value); + if (report.status === "dismissed") { + // The user answered a dialog (danger/production/parameters/read-only) or the + // request was cancelled: there is no outcome to report, so drop the state + // instead of leaving the block spinning forever. + next.delete(key); + } else if (report.status === "succeeded") { + next.set(key, { phase: "succeeded", rows: report.rows, affectedRows: report.affectedRows }); + } else if (report.status === "applied") { + next.set(key, { phase: "applied" }); + } else if (report.status === "failed") { + next.set(key, { phase: "failed", message: report.message ?? "" }); + } else { + next.set(key, { phase: "unavailable", message: report.message ?? "" }); + } + aiSqlBlockStatuses.value = next; +} + +const sqlBlockKey = (messageIndex: number, segmentIndex: number) => `code:${messageIndex}:${segmentIndex}`; + +/** Status of one SQL block's action, or `undefined` when nothing ran from it. */ +function sqlBlockStatus(messageIndex: number, segmentIndex: number): AiSqlBlockStatus | undefined { + return aiSqlBlockStatuses.value.get(sqlBlockKey(messageIndex, segmentIndex)); +} + +function sqlBlockStatusText(messageIndex: number, segmentIndex: number): string { + const status = sqlBlockStatus(messageIndex, segmentIndex); + if (!status) return ""; + if (status.phase === "running") return t("ai.sqlActionRunning"); + if (status.phase === "applied") return t("ai.sqlActionApplied"); + if (status.phase === "succeeded") { + if (status.affectedRows) return t("ai.sqlActionSucceededAffected", { count: status.affectedRows }); + if (status.rows) return t("ai.sqlActionSucceededRows", { count: status.rows }); + return t("ai.sqlActionSucceeded"); + } + return status.message; } const copiedContentKey = ref(""); @@ -5314,6 +5417,8 @@ function clearMessages() { // the transcript, so returning to this conversation shows what changed. if (conversationId.value) conversationReadMessageCount.set(conversationId.value, visibleMessages.value.length); messages.value = []; + // Positional SQL-block statuses belong to the transcript just wiped. + clearSqlActionBlockStatuses(); draftPluginContext.value = undefined; clearContextReferences(); clearPendingWriteGrant(); @@ -5667,6 +5772,9 @@ function selectConversation(conv: AiConversation) { skillHintLatch.value = null; const activeRun = backgroundAiRunsEnabled ? desktopAiRun(conv.id) : undefined; messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv); + // Block keys are positional, so the arriving transcript must not inherit the + // departing conversation's statuses (#11962). + clearSqlActionBlockStatuses(); // Req 13 after a restart: the transcript keeps the fact that skills were // loaded, so the chips can light up before anything is read again. The bodies // come back when a request actually needs them (`rehydrateLoadedSkillBodies`). @@ -6442,7 +6550,7 @@ function focusSearch(): boolean { return true; } -defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch }); +defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch, reportAiSqlAction }); const messageRenderer = computed(() => { const appearance = aiCodeAppearance.value; @@ -6914,7 +7022,7 @@ async function openExternalUrl(url: string) { v-if="!pluginContext && !seg.pending && seg.isSql && !isRedisConnection" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.tempRunSql')" - @click="tempRunSql(seg.content)" + @click="tempRunSql(seg.content, sqlBlockKey(i, j))" > @@ -6922,7 +7030,7 @@ async function openExternalUrl(url: string) { v-if="!pluginContext && !seg.pending && (seg.isSql || isRedisConnection)" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.executeSql')" - @click="executeSql(seg.content)" + @click="executeSql(seg.content, sqlBlockKey(i, j))" > @@ -6930,7 +7038,7 @@ async function openExternalUrl(url: string) { v-if="!pluginContext && !seg.pending && (seg.isSql || isRedisConnection)" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.apply')" - @click="applySql(seg.content)" + @click="applySql(seg.content, sqlBlockKey(i, j))" > @@ -6948,6 +7056,25 @@ async function openExternalUrl(url: string) {
+ +
+ + + + + {{ sqlBlockStatusText(i, j) }} + +
diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.sqlActionFeedback.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.sqlActionFeedback.spec.ts new file mode 100644 index 0000000000..34bf01209c --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.sqlActionFeedback.spec.ts @@ -0,0 +1,195 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// Regression coverage for https://github.com/t8y2/dbx/issues/11962. +// +// The AI panel's SQL actions deliver to an editor tab resolved from the +// conversation's bound connection, created without activation (#9902). When the +// tab was not on screen the click looked like a no-op and the database error +// stayed in that background tab. App.vue is a large SFC the suite never mounts, +// so the wiring is pinned as source text (same approach as +// aiRedisConsoleRouting.spec.ts); the decision table itself is unit-tested in +// lib/ai/__tests__/aiSqlActionFeedback.spec.ts. +// +// These assertions read the .vue sources verbatim, so run `oxfmt` on the changed +// files BEFORE running this spec — a reflow of the pinned text fails it. +const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8"); + +function appBodyOf(fnSignature: string): string { + const start = appSource.indexOf(fnSignature); + expect(start, `expected to find "${fnSignature}" in App.vue`).toBeGreaterThanOrEqual(0); + const braceStart = appSource.indexOf("{", start); + let depth = 0; + for (let i = braceStart; i < appSource.length; i++) { + if (appSource[i] === "{") depth++; + else if (appSource[i] === "}") { + depth--; + if (depth === 0) return appSource.slice(braceStart, i + 1); + } + } + throw new Error(`unbalanced braces reading body of "${fnSignature}"`); +} + +describe("AI SQL action feedback (#11962)", () => { + it("treats a target tab as visible only when the workspace renders and some group shows it", () => { + const onScreen = appBodyOf("function aiTargetTabOnScreen(tabId: string): boolean"); + expect(onScreen).toContain("workspaceRendered()"); + // Same idiom as SqlEditorWorkspace.editorGroupForTab: every group renders, so + // a non-focused split group's active tab counts as visible. + expect(onScreen).toContain("queryStore.groups.some((group) => group.activeTabId === tabId)"); + + const rendered = appBodyOf("function workspaceRendered(): boolean"); + // Mirrors the v-show on [data-editor-content]: a maximized AI panel hides the + // workspace, so nothing in it can deliver feedback. + expect(rendered).toContain("!isAiPanelMaximized.value"); + expect(rendered).toContain("!isHistoryPanelMaximized.value"); + expect(rendered).toContain("!isSpecialPageActive.value"); + expect(rendered).not.toContain("isZenMode"); + }); + + it("routes the feedback decision through the pure mapper and only reveals on click", () => { + const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void"); + + expect(settle).toContain("aiSqlActionFeedback(outcome, { targetOnScreen"); + expect(settle).toContain('if (feedback.level === "none" || feedbackText === undefined) return;'); + // The toast's action button is the ONLY activation site on this path. + expect(settle).toContain("onClick: () => activateQueryTab(revealTabId)"); + expect(settle).not.toContain("activateQueryTab(tabId)"); + expect(settle).toContain("aiAssistantRef.value.reportAiSqlAction(report);"); + // The summary shown in the panel comes from the target tab's own result. + expect(settle).toContain("aiTargetResultCounts(outcome.tabId)"); + // A request that never started still says why inside the block, where the text + // outlives the toast. + expect(settle).toContain('{ requestId, status: "unavailable" as const, ...(feedbackText ? { message: feedbackText } : {}) }'); + }); + + it("reports dismissal instead of re-toasting a dialog the user already answered", () => { + const reasons = appSource.slice(appSource.indexOf("const DISMISSED_OUTCOME_REASONS"), appSource.indexOf("const DISMISSED_OUTCOME_REASONS") + 200); + for (const reason of ["cancelled", "parameters", "blocked", "missing-database"]) expect(reasons).toContain(`"${reason}"`); + }); + + it("leaves no silent early return on the three AI SQL action paths", () => { + for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql("]) { + const body = appBodyOf(handler); + // Every `!tabId` branch settles with a reportable reason... + expect(body).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });'); + expect(body).not.toMatch(/if \(!tabId\) return;/); + } + // ...including the temp-run path, which has no branch of its own and hands + // the empty tab id to the shared helper. + expect(appBodyOf("function runAiGeneratedSql(")).toContain("if (!tabId) {"); + expect(appBodyOf("function runAiGeneratedSql(")).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });'); + expect(appBodyOf("function runAiGeneratedSql(")).toContain("await tryExecute(sql, { tabId })"); + expect(appBodyOf("function onAiTempRunSql(")).toContain("void runAiGeneratedSql(sql, ensureQueryTabForConnection(target), requestId);"); + }); + + it("keeps #9902 intact: the AI action itself never moves the workspace", () => { + // Target resolution is unchanged (connection + database + exact schema). + const target = appBodyOf("function ensureQueryTabForConnection(target: AiConversationBinding): string"); + expect(target).toContain("{ activate: false }"); + // No AI action handler activates, selects or creates a tab behind the user's back. + for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql(", "function onAiTempRunSql(", "function runAiGeneratedSql("]) { + const body = appBodyOf(handler); + expect(body).not.toContain("activateTab"); + expect(body).not.toContain("switchTab"); + } + }); + + it("passes the request id through every AI action emit", () => { + expect(appSource).toContain('@append-sql="onAiAppendSql"'); + expect(appSource).toContain('@execute-sql="onAiExecuteSql"'); + expect(appSource).toContain('@temp-run-sql="onAiTempRunSql"'); + // Every AI action emit forwards the request id as a third argument. + const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + expect(assistant).toContain('emit("appendSql", code, conversationBinding.value, requestId);'); + expect(assistant).toContain('emit("executeSql", code, conversationBinding.value, requestId);'); + expect(assistant).toContain('emit("tempRunSql", code, conversationBinding.value, requestId);'); + // The auto-execute path is deliberately excluded: it already reports in-chat. + expect(appSource).toContain('@request-auto-execute-sql="onAiRequestAutoExecuteSql"'); + expect(appBodyOf("function onAiRequestAutoExecuteSql(")).not.toContain("settleAiSqlAction"); + }); + + it("declares the panel report method on the handle type and the component", () => { + expect(appSource).toContain("reportAiSqlAction: (report: AiSqlActionReport) => void;"); + const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + expect(assistant).toContain("function reportAiSqlAction(report: AiSqlActionReport)"); + expect(assistant).toContain("defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch, reportAiSqlAction });"); + }); + + it("drops positional block statuses whenever the transcript is replaced", () => { + const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + // Block keys are positional (`code::`), so a status left + // behind by a wiped transcript would render as the outcome of whatever block + // now sits at that index — another conversation's error, or the regenerated + // message of an edit-resend. + expect(assistant.match(/function clearSqlActionBlockStatuses\(/g)).toHaveLength(1); + for (const replaced of [ + "messages.value = messages.value.slice(0, actualIndex);", // edit-resend truncation + "messages.value = [];", // clearMessages (new chat) + "messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);", // selectConversation + ]) { + const at = assistant.indexOf(replaced); + expect(at, `expected to find "${replaced}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0); + expect(assistant.slice(at, at + 300)).toContain("clearSqlActionBlockStatuses();"); + } + }); + + it("announces the block outcome through a status live region", () => { + const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + // The outcome lands in the transcript, not in the tab the user is looking at, + // so it has to be announced. `role="status"` matches the other block-level + // notices in this file; the row stays mounted between running and terminal, so + // the change is a text mutation inside an existing live region. + const row = assistant.slice(assistant.indexOf("data-ai-sql-action-status")); + expect(row.slice(0, 200)).toContain('role="status"'); + }); + + it("lets the newest click on a block own its status", () => { + const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8"); + // Clicking the same block twice used to let the FIRST response settle the + // block (last write wins by arrival order, not by click order). + expect(assistant).toContain('next.set(key, { phase: "running", requestId });'); + expect(assistant).toContain('if (pending?.phase === "running" && pending.requestId !== report.requestId)'); + }); + + it("carries the AI request across a dialog-parked execution", () => { + // A parameter / danger / Redis dialog makes tryExecute return `deferred`, so the + // settled execution has to be correlated back to the click: App remembers the + // request per tab and settles from the resumed execution. + const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void"); + expect(settle).toContain('if (outcome.status === "deferred") {'); + expect(settle).toContain("rememberDeferredAiSqlAction(outcome.tabId, requestId);"); + + const deferredSettle = appBodyOf("function settleDeferredAiSqlAction(outcome: SqlExecutionOutcome): void"); + expect(deferredSettle).toContain("deferredAiSqlActions.get(tabId)"); + expect(deferredSettle).toContain("if (requestId === undefined) return;"); + expect(deferredSettle).toContain("settleAiSqlAction(requestId, outcome);"); + // Editor-initiated executions reach the same callback and must not be toasted. + expect(deferredSettle).toContain("deferredAiSqlActions.delete(tabId);"); + + // The composable reports exactly where the dialog's own handlers discarded the + // outcome before (#11962 review). + const execution = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8"); + expect(execution).toContain("reportDeferredExecution(await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }));"); + expect(execution).toContain("reportDeferredExecution(await continueExecute(context, sql, sourceOffset,"); + expect(execution).toContain('return deferred("parameters", context.tabId);'); + expect(execution).toContain('return deferred("danger", context.tabId);'); + expect(execution).toContain('return deferred("redis-confirm", context.tabId);'); + // Cancelling a dialog unbinds the parked request instead of leaving it waiting. + expect(execution).toContain('if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId));'); + }); + + it("names the target tab in the off-screen toast of every locale", () => { + // The toast is the only thing that tells the user *where* an off-screen run + // landed, so these keys must interpolate the tab title — and the block copy + // must not (it renders under the SQL, with no tab name to interpolate). + const locales = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "th", "tr", "zh-CN", "zh-TW"]; + for (const locale of locales) { + const messages = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8"); + expect(messages, locale).toMatch(/sqlActionSucceededIn: "[^"]*\{title\}[^"]*"/); + expect(messages, locale).toMatch(/sqlActionAppliedIn: "[^"]*\{title\}[^"]*"/); + expect(messages, locale).toMatch(/sqlActionFailed: "[^"]*\{title\}[^"]*\{message\}[^"]*"/); + expect(messages, locale).toMatch(/sqlActionSucceeded: "[^"{]*"/); + } + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 009089332d..1251943edc 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -928,6 +928,28 @@ SELECT @value AS Message;`; expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { tabId: "tab-1", openInNewResultTab: true }); }); + it("settles a failed execution after accepting a SQL parameter dialog", async () => { + const sql = "SELECT * FROM users WHERE id = :id"; + const activeTab = ref({ ...queryTab("app"), sql }); + const activeConnection = ref(connection("mysql")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + vi.spyOn(useQueryStore(), "executeCurrentSql").mockRejectedValue(new Error("connection lost")); + const onDeferredExecutionSettled = vi.fn(); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + onDeferredExecutionSettled, + }); + + await execution.tryExecute(); + await execution.onSqlParametersConfirm("SELECT * FROM users WHERE id = 7"); + + expect(onDeferredExecutionSettled).toHaveBeenCalledWith({ status: "started", ok: false, errorMessage: "connection lost", tabId: "tab-1" }); + }); + it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => { const sql = ` WITH rec_flow AS ( @@ -1592,6 +1614,28 @@ SELECT @value AS Message;`; expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true }); }); + it("settles a failed execution after accepting a dangerous-SQL dialog", async () => { + const sql = "DELETE FROM users WHERE id = 7"; + const activeTab = ref({ ...queryTab("app"), sql }); + const activeConnection = ref(connection("mysql")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + vi.spyOn(useQueryStore(), "executeCurrentSql").mockRejectedValue(new Error("connection lost")); + const onDeferredExecutionSettled = vi.fn(); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + onDeferredExecutionSettled, + }); + + await execution.tryExecute(); + await execution.onDangerConfirm(); + + expect(onDeferredExecutionSettled).toHaveBeenCalledWith({ status: "started", ok: false, errorMessage: "connection lost", tabId: "tab-1" }); + }); + it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => { const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; const activeTab = ref({ diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index a51b6b1c11..d378da4925 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -35,6 +35,7 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry"; import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult"; import { translateBackendError } from "@/i18n/backend-errors"; +import type { SqlExecutionDeferredReason, SqlExecutionNotStartedReason, SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome"; const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; @@ -170,6 +171,13 @@ export function useSqlExecution(deps: { requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise; onExecutionStarted?: (editorViewportRequestId: number) => void; onExecutionCancelled?: (editorViewportRequestId: number) => void; + /** + * Terminal outcome of an execution a dialog parked (#11962). Such a request + * leaves `tryExecute` as `{ status: "deferred" }` when the dialog opens, so this + * is the only place its real result arrives: accepted → the execution outcome, + * dismissed → `not-started`. Hosts that do not track the request ignore it. + */ + onDeferredExecutionSettled?: (outcome: SqlExecutionOutcome) => void; }) { const { t } = useI18n(); const queryStore = useQueryStore(); @@ -245,47 +253,78 @@ export function useSqlExecution(deps: { return { sql, sourceOffset: source.selectionFrom + leadingWhitespace, editorViewportRequestId }; } - async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) { + /** + * Every early return reports *why* it stopped (#11962). The editor path + * ignores this value, but the AI panel needs it: the target tab may not be on + * screen, so a silent return there leaves the user with no feedback at all. + */ + function notStarted(reason: SqlExecutionNotStartedReason, tabId?: string): SqlExecutionOutcome { + return { status: "not-started", reason, ...(tabId ? { tabId } : {}) }; + } + + function deferred(reason: SqlExecutionDeferredReason, tabId: string): SqlExecutionOutcome { + return { status: "deferred", reason, tabId }; + } + + /** + * Report for the execution a dialog parked (#11962). `tryExecute` returned + * `deferred` when it opened the dialog, so the caller that needs the real + * outcome — the AI panel — is told here instead: once the user answers, and with + * whatever the resumed execution produced. Editor-initiated executions reach this + * too; they simply have no listener registered for them. + */ + function reportDeferredExecution(outcome: SqlExecutionOutcome) { + deps.onDeferredExecutionSettled?.(outcome); + } + + /** A resumed confirmation must settle its AI request even when execution throws. */ + function reportDeferredExecutionFailure(error: unknown, tabId: string) { + reportDeferredExecution({ status: "started", ok: false, errorMessage: translateBackendError(t, error), tabId }); + } + + async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}): Promise { const context = captureExecutionContext(options.tabId); if (!context) { cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("no-tab", options.tabId); } const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context); const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId }; if (!sql.trim()) { cancelEditorViewportRequest(editorViewportRequestId); - return; + return notStarted("empty-sql", context.tabId); } const tab = resolveExecutionTab(context.tabId); if (!tab) { cancelEditorViewportRequest(editorViewportRequestId); - return; + return notStarted("no-tab", context.tabId); } if (requiresDatabaseSelection(tab, context.connection, sql)) { deps.onMissingDatabase?.(context.tabId); cancelEditorViewportRequest(editorViewportRequestId); - return; + return notStarted("missing-database", context.tabId); } if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) { - return; + return deferred("parameters", context.tabId); } - await continueExecute(context, sql, sourceOffset, executionOptions); + return continueExecute(context, sql, sourceOffset, executionOptions); } function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) { return tryExecute(sqlOverride, { ...options, openInNewResultTab: true }); } - async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) { + async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}): Promise { const tab = resolveExecutionTab(context.tabId); if (!tab) { cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("no-tab", context.tabId); } if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) { + // The read-only unlock dialog was dismissed: the user saw UI, so the panel + // adds nothing (see aiSqlActionFeedback). cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("cancelled", context.tabId); } // Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level) if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) { @@ -307,7 +346,7 @@ export function useSqlExecution(deps: { if (highestSafety === "blocked") { toast(t("redis.blockedCommand", { command: "Redis" }), 5000); cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("blocked", context.tabId); } if (highestSafety === "confirm") { dangerSql.value = sql; @@ -319,7 +358,9 @@ export function useSqlExecution(deps: { pendingDangerTabId.value = context.tabId; suppressDangerConfirm.value = false; showDangerDialog.value = true; - return; + // Parked on the Redis confirmation, not finished: `onDangerConfirm` reports + // the real outcome. + return deferred("redis-confirm", context.tabId); } } const productionAssessment = assessProductionSql(sql, context.connection, tab.database); @@ -333,11 +374,10 @@ export function useSqlExecution(deps: { source: t("production.sourceSqlEditor"), }); if (confirmed) { - await doExecute(sql, sourceOffset, options); - } else { - cancelEditorViewportRequest(options.editorViewportRequestId); + return doExecute(sql, sourceOffset, options); } - return; + cancelEditorViewportRequest(options.editorViewportRequestId); + return notStarted("cancelled", context.tabId); } if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) { dangerSql.value = sql; @@ -349,9 +389,10 @@ export function useSqlExecution(deps: { pendingDangerTabId.value = context.tabId; suppressDangerConfirm.value = false; showDangerDialog.value = true; - } else { - await doExecute(sql, sourceOffset, options); + // Parked on the danger confirmation; `onDangerConfirm` reports the outcome. + return deferred("danger", context.tabId); } + return doExecute(sql, sourceOffset, options); } function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise | void): boolean { @@ -406,31 +447,31 @@ export function useSqlExecution(deps: { queryStore.setActiveResultIndex(executionTabId, sqlServerDataResultIndex !== undefined && sqlServerDataResultIndex >= 0 ? sqlServerDataResultIndex : sqlServerMessageResultIndex); } - async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) { + async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}): Promise { if (sql === undefined) { const context = captureExecutionContext(options.tabId); if (!context) { cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("no-tab", options.tabId); } ({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context)); } const executionTabId = options.tabId ?? deps.activeTab.value?.id; if (!executionTabId || !sql || !sql.trim()) { cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("empty-sql", executionTabId); } const tab = resolveExecutionTab(executionTabId); if (!tab) { cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("no-tab", executionTabId); } const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value; const executionDatabaseType = executionConnection?.db_type; if (requiresDatabaseSelection(tab, executionConnection, sql)) { deps.onMissingDatabase?.(executionTabId); cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("missing-database", executionTabId); } const statementCount = splitSqlStatementRanges(sql, executionDatabaseType, sqlStatementParameterOptionsForCompatibility(executionDatabaseType, executionDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(tab.connectionId, tab.database) : undefined)).length; const redisConsoleSelected = executionDatabaseType === "redis" && tab.uiState?.redisResultViewMode === "console"; @@ -450,8 +491,11 @@ export function useSqlExecution(deps: { ...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}), }); if (producedResult === false) { + // `executeCurrentSql` reports `false` while another request is already + // preparing a result run for this tab (queryStore's pending-preparation + // guard), i.e. the tab is busy — never a silent no-op. cancelEditorViewportRequest(options.editorViewportRequestId); - return; + return notStarted("busy", executionTabId); } const executionTabStillActive = deps.activeTab.value?.id === executionTabId; const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined; @@ -502,6 +546,14 @@ export function useSqlExecution(deps: { await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema); } } + return { + status: "started", + ok: success, + tabId: tab.id, + // Same text the history entry records, so the panel shows the database's + // own words rather than a second, drifting translation of them. + ...(failure ? { errorMessage: failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? "") } : {}), + }; } async function executeTargetSql(input: TargetSqlExecutionInput): Promise { @@ -905,14 +957,20 @@ export function useSqlExecution(deps: { suppressDangerConfirm.value = false; const tab = resolveExecutionTab(tabId); if (!tab) { + reportDeferredExecution(notStarted("no-tab", tabId)); return; } const connection = connectionStore.getConfig(tab.connectionId); if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) { cancelEditorViewportRequest(editorViewportRequestId); + reportDeferredExecution(notStarted("cancelled", tab.id)); return; } - await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }); + try { + reportDeferredExecution(await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id })); + } catch (error) { + reportDeferredExecutionFailure(error, tab.id); + } } async function onSqlParametersConfirm(sql: string) { @@ -937,14 +995,24 @@ export function useSqlExecution(deps: { } const context = captureExecutionContext(tabId); if (!context) { + reportDeferredExecution(notStarted("no-tab", tabId)); return; } - await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId }); + try { + reportDeferredExecution(await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId })); + } catch (error) { + reportDeferredExecutionFailure(error, context.tabId); + } } watch(showSqlParameterDialog, (open) => { if (open) return; const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value; + // Still set means the dialog was dismissed rather than accepted: + // `onSqlParametersConfirm` clears it up front, before it resumes the execution + // and reports that outcome itself. Without this the caller parked on the dialog + // would wait forever (#11962). + const abandonedTabId = pendingSqlParameterTabId.value; sqlParameterSourceSql.value = ""; sqlParameterNames.value = []; sqlParameterDatabaseType.value = undefined; @@ -955,11 +1023,15 @@ export function useSqlExecution(deps: { pendingSqlParameterTabId.value = undefined; pendingSqlParameterContinuation = undefined; cancelEditorViewportRequest(editorViewportRequestId); + if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId)); }); watch(showDangerDialog, (open) => { if (open) return; const editorViewportRequestId = pendingDangerEditorViewportRequestId.value; + // Same dismissal contract as the parameter dialog: a tab id still pending here + // means the user closed the dialog instead of confirming it. + const abandonedTabId = pendingDangerTabId.value; pendingDangerSql.value = ""; pendingDangerSourceOffset.value = undefined; pendingDangerKind.value = "sql"; @@ -968,6 +1040,7 @@ export function useSqlExecution(deps: { pendingDangerTabId.value = undefined; suppressDangerConfirm.value = false; cancelEditorViewportRequest(editorViewportRequestId); + if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId)); }); return { diff --git a/apps/desktop/src/i18n/__tests__/featureMessages.spec.ts b/apps/desktop/src/i18n/__tests__/featureMessages.spec.ts index 9f5835ae53..7b277d3e15 100644 --- a/apps/desktop/src/i18n/__tests__/featureMessages.spec.ts +++ b/apps/desktop/src/i18n/__tests__/featureMessages.spec.ts @@ -49,6 +49,11 @@ const FEATURE_MESSAGES: FeatureMessages[] = [ { feature: "AI conversation export (#6467)", keys: under("ai", ["exportConversation", "conversationExportMarkdown", "conversationExportHtml", "conversationRoleUser", "conversationRoleAssistant", "conversationFailedMarker", "conversationExportEmpty"]) }, { feature: "AI conversation management", keys: under("ai", ["renameConversation", "conversationRenameFailed", "clearDatabaseSelection", "searchDatabases", "noDatabasesFound"]) }, { feature: "AI auto routing (#9118)", keys: ["ai.actions.auto", "ai.routing.recognizing", "ai.routing.chip", "ai.routing.switchTo", "ai.defaultAutoRouting", "ai.defaultAutoRoutingDescription"] }, + { + feature: "AI SQL action feedback (#11962)", + keys: under("ai", ["sqlActionRunning", "sqlActionSucceeded", "sqlActionSucceededIn", "sqlActionSucceededRows", "sqlActionSucceededAffected", "sqlActionFailed", "sqlActionApplied", "sqlActionAppliedIn", "sqlActionEmptySql", "sqlActionBusy", "sqlActionNoConnection", "sqlActionViewTab"]), + translated: true, + }, { feature: "plugin AI conversation", keys: ["ai.pluginHttpModelOnly", "ai.pluginWelcome", "ai.pluginFollowUp"] }, { feature: "quick open", keys: ["quickOpen.*", "settings.shortcutGlobalSearch"] }, { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 7375f5e8ba..30b4951f12 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -3171,6 +3171,18 @@ export default withEnglishFallback({ fixWithAi: "AI ilə düzəlt", autoSqlBlocked: "AI-nin yaratdığı SQL avtomatik icra üçün həddindən artıq riskli göründü. İşlətməzdən əvvəl onu əl ilə nəzərdən keçirin.", redisConsoleUnreachable: "AI əmri bu söhbətə bağlı Redis konsoluna çatmadı. Həmin bağlantının Redis konsolunu açıb yenidən cəhd edin.", + sqlActionRunning: "İcra olunur…", + sqlActionSucceeded: "İcra tamamlandı", + sqlActionSucceededIn: "«{title}» üzrə icra tamamlandı", + sqlActionSucceededRows: "{count} sətir qaytarıldı", + sqlActionSucceededAffected: "{count} sətir təsirləndi", + sqlActionFailed: "«{title}» üzərində icra alınmadı: {message}", + sqlActionApplied: "Redaktora tətbiq edildi", + sqlActionAppliedIn: "«{title}» redaktoruna tətbiq edildi", + sqlActionEmptySql: "İcra ediləcək SQL yoxdur.", + sqlActionBusy: "Bu redaktor tabı hələ əvvəlki sorğunu emal edir. Bir az sonra yenidən cəhd edin.", + sqlActionNoConnection: "Bu söhbətin istifadə edilə bilən bağlantısı yoxdur (silinmiş ola bilər). Bağlantı seçib SQL-i yenidən icra edin.", + sqlActionViewTab: "Bax", writeSqlConfirmationRequired: "Bu SQL məlumatları və ya sxemi dəyişir. İcradan əvvəl diqqətlə nəzərdən keçirin.", writeSqlConfirmationQuestion: "Bu SQL bir dəfə icra edilsin?", writeSqlConfirmYes: "İcranı təsdiqlə", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 323a01ff24..55d56bb24a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3933,6 +3933,18 @@ export default { fixWithAi: "Fix with AI", autoSqlBlocked: "The AI-generated SQL looked too risky to auto-execute. Review it manually before running.", redisConsoleUnreachable: "The AI command could not reach this conversation's Redis console. Open the bound connection's Redis console and try again.", + sqlActionRunning: "Executing…", + sqlActionSucceeded: "Execution finished", + sqlActionSucceededIn: "Finished in “{title}”", + sqlActionSucceededRows: "{count} rows returned", + sqlActionSucceededAffected: "{count} rows affected", + sqlActionFailed: "Execution failed in “{title}”: {message}", + sqlActionApplied: "Applied to editor", + sqlActionAppliedIn: "Applied to editor “{title}”", + sqlActionEmptySql: "There is no SQL to execute.", + sqlActionBusy: "This editor tab is still processing the previous request. Try again in a moment.", + sqlActionNoConnection: "This conversation has no available connection (it may have been deleted). Pick a connection, then run the SQL again.", + sqlActionViewTab: "View", writeSqlConfirmationRequired: "This SQL changes data or schema. Review it carefully before execution.", writeSqlConfirmationQuestion: "Execute this SQL once?", writeSqlConfirmYes: "Confirm execution", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9574e1c071..d0fc150bc2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3775,6 +3775,18 @@ export default withEnglishFallback({ fixWithAi: "Corregir con IA", autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.", redisConsoleUnreachable: "El comando de la IA no pudo llegar a la consola de Redis vinculada a esta conversación. Abre la consola de Redis de esa conexión e inténtalo de nuevo.", + sqlActionRunning: "Ejecutando…", + sqlActionSucceeded: "Ejecución completada", + sqlActionSucceededIn: "Ejecución completada en «{title}»", + sqlActionSucceededRows: "{count} filas devueltas", + sqlActionSucceededAffected: "{count} filas afectadas", + sqlActionFailed: "La ejecución falló en «{title}»: {message}", + sqlActionApplied: "Aplicado al editor", + sqlActionAppliedIn: "Aplicado al editor «{title}»", + sqlActionEmptySql: "No hay SQL para ejecutar.", + sqlActionBusy: "Esta pestaña del editor todavía está procesando la solicitud anterior. Inténtalo de nuevo en un momento.", + sqlActionNoConnection: "Esta conversación no tiene una conexión disponible (puede que se haya eliminado). Elige una conexión y vuelve a ejecutar el SQL.", + sqlActionViewTab: "Ver", writeSqlConfirmationRequired: "Este SQL modifica datos o el esquema. Revísalo cuidadosamente antes de ejecutarlo.", writeSqlConfirmationQuestion: "¿Ejecutar este SQL una vez?", writeSqlConfirmYes: "Confirmar ejecución", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 4569dee8db..4f5e7016d0 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -3496,6 +3496,18 @@ export default withEnglishFallback({ fixWithAi: "Perbaiki dengan AI", autoSqlBlocked: "SQL yang dihasilkan AI terlihat terlalu berisiko untuk dieksekusi otomatis. Tinjau secara manual sebelum menjalankannya.", redisConsoleUnreachable: "Perintah AI tidak dapat menjangkau konsol Redis percakapan ini. Buka konsol Redis koneksi yang terhubung dan coba lagi.", + sqlActionRunning: "Menjalankan…", + sqlActionSucceeded: "Eksekusi selesai", + sqlActionSucceededIn: "Selesai dijalankan di “{title}”", + sqlActionSucceededRows: "{count} baris dikembalikan", + sqlActionSucceededAffected: "{count} baris terpengaruh", + sqlActionFailed: "Eksekusi gagal di “{title}”: {message}", + sqlActionApplied: "Diterapkan ke editor", + sqlActionAppliedIn: "Diterapkan ke editor “{title}”", + sqlActionEmptySql: "Tidak ada SQL untuk dijalankan.", + sqlActionBusy: "Tab editor ini masih memproses permintaan sebelumnya. Coba lagi sebentar lagi.", + sqlActionNoConnection: "Percakapan ini tidak memiliki koneksi yang tersedia (mungkin sudah dihapus). Pilih koneksi, lalu jalankan SQL lagi.", + sqlActionViewTab: "Lihat", writeSqlConfirmationRequired: "SQL ini mengubah data atau skema. Tinjau dengan hati-hati sebelum eksekusi.", writeSqlConfirmationQuestion: "Eksekusi SQL ini sekali?", writeSqlConfirmYes: "Konfirmasi eksekusi", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3e783b08d2..938773a997 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3538,6 +3538,18 @@ export default withEnglishFallback({ fixWithAi: "Correggi con AI", autoSqlBlocked: "L'SQL generato dall'AI è sembrato troppo rischioso per l'esecuzione automatica. Rivedilo manualmente prima di eseguirlo.", redisConsoleUnreachable: "Il comando dell'AI non ha raggiunto la console Redis associata a questa conversazione. Apri la console Redis di quella connessione e riprova.", + sqlActionRunning: "Esecuzione in corso…", + sqlActionSucceeded: "Esecuzione completata", + sqlActionSucceededIn: "Esecuzione completata in «{title}»", + sqlActionSucceededRows: "{count} righe restituite", + sqlActionSucceededAffected: "{count} righe modificate", + sqlActionFailed: "Esecuzione non riuscita in «{title}»: {message}", + sqlActionApplied: "Applicato all'editor", + sqlActionAppliedIn: "Applicato all'editor «{title}»", + sqlActionEmptySql: "Non c'è SQL da eseguire.", + sqlActionBusy: "Questa scheda dell'editor sta ancora elaborando la richiesta precedente. Riprova tra poco.", + sqlActionNoConnection: "Questa conversazione non ha una connessione disponibile (potrebbe essere stata eliminata). Scegli una connessione e riesegui l'SQL.", + sqlActionViewTab: "Visualizza", writeSqlConfirmationRequired: "Questo SQL modifica dati o schema. Rivedilo attentamente prima dell'esecuzione.", writeSqlConfirmationQuestion: "Eseguire questo SQL una volta?", writeSqlConfirmYes: "Conferma esecuzione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6feaa0ff1f..a5b10dab28 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3654,6 +3654,18 @@ export default withEnglishFallback({ fixWithAi: "AIで修正", autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。", redisConsoleUnreachable: "AI コマンドをこの会話に紐づく Redis コンソールに送信できませんでした。その接続の Redis コンソールを開いて再試行してください。", + sqlActionRunning: "実行中…", + sqlActionSucceeded: "実行が完了しました", + sqlActionSucceededIn: "「{title}」で実行が完了しました", + sqlActionSucceededRows: "{count} 行を返しました", + sqlActionSucceededAffected: "{count} 行に影響しました", + sqlActionFailed: "「{title}」で実行に失敗しました: {message}", + sqlActionApplied: "エディターに適用しました", + sqlActionAppliedIn: "エディター「{title}」に適用しました", + sqlActionEmptySql: "実行する SQL がありません。", + sqlActionBusy: "このエディタータブは前のリクエストを処理中です。しばらくしてから再試行してください。", + sqlActionNoConnection: "この会話に利用可能な接続がありません(削除された可能性があります)。接続を選択して再実行してください。", + sqlActionViewTab: "表示", writeSqlConfirmationRequired: "この SQL はデータまたはスキーマを変更します。実行前に内容をよく確認してください。", writeSqlConfirmationQuestion: "この SQL を一度実行しますか?", writeSqlConfirmYes: "実行を確認", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1c2a800818..5a72a27b0c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3486,6 +3486,18 @@ export default withEnglishFallback({ fixWithAi: "AI로 수정", autoSqlBlocked: "AI가 생성한 SQL이 자동 실행하기에는 너무 위험해 보입니다. 실행 전에 수동으로 검토하세요.", redisConsoleUnreachable: "AI 명령을 이 대화에 바인딩된 Redis 콘솔로 전달하지 못했습니다. 해당 연결의 Redis 콘솔을 열고 다시 시도하세요.", + sqlActionRunning: "실행 중…", + sqlActionSucceeded: "실행 완료", + sqlActionSucceededIn: "“{title}”에서 실행이 완료되었습니다", + sqlActionSucceededRows: "{count}개 행 반환", + sqlActionSucceededAffected: "{count}개 행 영향", + sqlActionFailed: "“{title}”에서 실행 실패: {message}", + sqlActionApplied: "편집기에 적용했습니다", + sqlActionAppliedIn: "편집기 “{title}”에 적용했습니다", + sqlActionEmptySql: "실행할 SQL이 없습니다.", + sqlActionBusy: "이 편집기 탭이 아직 이전 요청을 처리 중입니다. 잠시 후 다시 시도하세요.", + sqlActionNoConnection: "이 대화에 사용할 수 있는 연결이 없습니다(삭제되었을 수 있음). 연결을 선택한 뒤 다시 실행하세요.", + sqlActionViewTab: "보기", writeSqlConfirmationRequired: "이 SQL은 데이터 또는 스키마를 변경합니다. 실행 전에 신중하게 검토하세요.", writeSqlConfirmationQuestion: "이 SQL을 한 번 실행할까요?", writeSqlConfirmYes: "실행 확인", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index cc09db5f63..438a71696e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3621,6 +3621,18 @@ export default withEnglishFallback({ fixWithAi: "Corrigir com AI", autoSqlBlocked: "O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.", redisConsoleUnreachable: "O comando da AI não chegou ao console Redis vinculado a esta conversa. Abra o console Redis dessa conexão e tente novamente.", + sqlActionRunning: "Executando…", + sqlActionSucceeded: "Execução concluída", + sqlActionSucceededIn: "Execução concluída em “{title}”", + sqlActionSucceededRows: "{count} linhas retornadas", + sqlActionSucceededAffected: "{count} linhas afetadas", + sqlActionFailed: "Falha na execução em “{title}”: {message}", + sqlActionApplied: "Aplicado ao editor", + sqlActionAppliedIn: "Aplicado ao editor “{title}”", + sqlActionEmptySql: "Não há SQL para executar.", + sqlActionBusy: "Esta aba do editor ainda está processando a solicitação anterior. Tente novamente em instantes.", + sqlActionNoConnection: "Esta conversa não tem uma conexão disponível (ela pode ter sido excluída). Escolha uma conexão e execute o SQL novamente.", + sqlActionViewTab: "Ver", writeSqlConfirmationRequired: "Este SQL altera dados ou o esquema. Revise-o cuidadosamente antes de executar.", writeSqlConfirmationQuestion: "Executar este SQL uma vez?", writeSqlConfirmYes: "Confirmar execução", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 71c1c248f3..c18e1c5901 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -3645,6 +3645,18 @@ export default withEnglishFallback({ fixWithAi: "Исправить с помощью ИИ", autoSqlBlocked: "SQL, сгенерированный ИИ, выглядит слишком рискованным для автоматического выполнения. Проверьте его вручную перед запуском.", redisConsoleUnreachable: "Команда ИИ не смогла подключиться к консоли Redis этого диалога. Откройте консоль Redis привязанного подключения и повторите попытку.", + sqlActionRunning: "Выполняется…", + sqlActionSucceeded: "Выполнение завершено", + sqlActionSucceededIn: "Выполнено в «{title}»", + sqlActionSucceededRows: "Возвращено строк: {count}", + sqlActionSucceededAffected: "Затронуто строк: {count}", + sqlActionFailed: "Ошибка выполнения в «{title}»: {message}", + sqlActionApplied: "Применено в редакторе", + sqlActionAppliedIn: "Применено в редакторе «{title}»", + sqlActionEmptySql: "Нет SQL для выполнения.", + sqlActionBusy: "Эта вкладка редактора всё ещё обрабатывает предыдущий запрос. Повторите попытку позже.", + sqlActionNoConnection: "В этом диалоге нет доступного подключения (возможно, оно удалено). Выберите подключение и выполните SQL снова.", + sqlActionViewTab: "Просмотр", writeSqlConfirmationRequired: "Этот SQL изменяет данные или схему. Внимательно проверьте его перед выполнением.", writeSqlConfirmationQuestion: "Выполнить этот SQL один раз?", writeSqlConfirmYes: "Подтвердить выполнение", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index 5fb7bb2773..a658b495bc 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -3855,6 +3855,18 @@ export default withEnglishFallback({ fixWithAi: "แก้ไขด้วย AI", autoSqlBlocked: "SQL ที่ AI สร้างดูเสี่ยงเกินกว่าจะเรียกใช้อัตโนมัติ ตรวจสอบด้วยตนเองก่อนเรียกใช้", redisConsoleUnreachable: "คำสั่ง AI เข้าถึง Redis Console ของการสนทนานี้ไม่ได้ เปิด Redis Console ของการเชื่อมต่อที่ผูกไว้แล้วลองอีกครั้ง", + sqlActionRunning: "กำลังเรียกใช้…", + sqlActionSucceeded: "เรียกใช้เสร็จแล้ว", + sqlActionSucceededIn: "เรียกใช้เสร็จสิ้นใน “{title}”", + sqlActionSucceededRows: "คืนค่า {count} แถว", + sqlActionSucceededAffected: "มีผลกับ {count} แถว", + sqlActionFailed: "เรียกใช้ใน “{title}” ไม่สำเร็จ: {message}", + sqlActionApplied: "นำไปใช้กับตัวแก้ไขแล้ว", + sqlActionAppliedIn: "นำไปใช้กับตัวแก้ไข “{title}” แล้ว", + sqlActionEmptySql: "ไม่มี SQL ให้เรียกใช้", + sqlActionBusy: "แท็บตัวแก้ไขนี้ยังประมวลผลคำขอก่อนหน้าอยู่ ลองอีกครั้งในอีกสักครู่", + sqlActionNoConnection: "การสนทนานี้ไม่มีการเชื่อมต่อที่ใช้งานได้ (อาจถูกลบไปแล้ว) เลือกการเชื่อมต่อแล้วเรียกใช้ SQL อีกครั้ง", + sqlActionViewTab: "ดู", writeSqlConfirmationRequired: "SQL นี้เปลี่ยนข้อมูลหรือ Schema ตรวจสอบอย่างระมัดระวังก่อนเรียกใช้", writeSqlConfirmationQuestion: "เรียกใช้ SQL นี้ครั้งเดียวหรือไม่?", writeSqlConfirmYes: "ยืนยันการเรียกใช้", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index d993aedcfd..5795250429 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -3145,6 +3145,18 @@ export default withEnglishFallback({ fixWithAi: "Yapay Zekâ ile Düzelt", autoSqlBlocked: "Yapay zekânın ürettiği SQL, otomatik çalıştırmak için fazla riskli göründü. Çalıştırmadan önce elle inceleyin.", redisConsoleUnreachable: "AI komutu bu konuşmaya bağlı Redis konsoluna ulaşamadı. O bağlantının Redis konsolunu açıp yeniden deneyin.", + sqlActionRunning: "Çalıştırılıyor…", + sqlActionSucceeded: "Çalıştırma tamamlandı", + sqlActionSucceededIn: "«{title}» içinde yürütme tamamlandı", + sqlActionSucceededRows: "{count} satır döndü", + sqlActionSucceededAffected: "{count} satır etkilendi", + sqlActionFailed: "«{title}» içinde çalıştırma başarısız: {message}", + sqlActionApplied: "Düzenleyiciye uygulandı", + sqlActionAppliedIn: "«{title}» düzenleyicisine uygulandı", + sqlActionEmptySql: "Çalıştırılacak SQL yok.", + sqlActionBusy: "Bu düzenleyici sekmesi hâlâ önceki isteği işliyor. Birazdan yeniden deneyin.", + sqlActionNoConnection: "Bu konuşmanın kullanılabilir bir bağlantısı yok (silinmiş olabilir). Bir bağlantı seçip SQL'i yeniden çalıştırın.", + sqlActionViewTab: "Görüntüle", writeSqlConfirmationRequired: "Bu SQL veriyi veya şemayı değiştiriyor. Çalıştırmadan önce dikkatle inceleyin.", writeSqlConfirmationQuestion: "Bu SQL bir kez çalıştırılsın mı?", writeSqlConfirmYes: "Çalıştırmayı onayla", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a10b9d7ed9..b90e1bed2b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3863,6 +3863,18 @@ export default withEnglishFallback({ fixWithAi: "用 AI 修复", autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。", redisConsoleUnreachable: "AI 命令未能送达本会话绑定的 Redis 控制台。请打开该连接的 Redis 控制台后重试。", + sqlActionRunning: "执行中…", + sqlActionSucceeded: "执行完成", + sqlActionSucceededIn: "已在「{title}」执行完成", + sqlActionSucceededRows: "返回 {count} 行", + sqlActionSucceededAffected: "影响 {count} 行", + sqlActionFailed: "在「{title}」执行失败:{message}", + sqlActionApplied: "已应用到编辑器", + sqlActionAppliedIn: "已应用到编辑器「{title}」", + sqlActionEmptySql: "没有可执行的 SQL。", + sqlActionBusy: "该编辑器页签仍在处理上一个请求,请稍后重试。", + sqlActionNoConnection: "本会话没有可用连接(可能已被删除)。请选择连接后重新执行。", + sqlActionViewTab: "查看", writeSqlConfirmationRequired: "此 SQL 会修改数据或结构。执行前请仔细核对。", writeSqlConfirmationQuestion: "确认执行此 SQL 一次吗?", writeSqlConfirmYes: "确认执行", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e642b078a6..e729fc8227 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3539,6 +3539,18 @@ export default withEnglishFallback({ fixWithAi: "用 AI 修復", autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。", redisConsoleUnreachable: "AI 指令未能送達本工作階段綁定的 Redis 控制台。請開啟該連線的 Redis 控制台後重試。", + sqlActionRunning: "執行中…", + sqlActionSucceeded: "執行完成", + sqlActionSucceededIn: "已在「{title}」執行完成", + sqlActionSucceededRows: "傳回 {count} 列", + sqlActionSucceededAffected: "影響 {count} 列", + sqlActionFailed: "在「{title}」執行失敗:{message}", + sqlActionApplied: "已套用到編輯器", + sqlActionAppliedIn: "已套用到編輯器「{title}」", + sqlActionEmptySql: "沒有可執行的 SQL。", + sqlActionBusy: "此編輯器分頁仍在處理上一個請求,請稍後重試。", + sqlActionNoConnection: "此工作階段沒有可用連線(可能已被刪除)。請選擇連線後重新執行。", + sqlActionViewTab: "檢視", writeSqlConfirmationRequired: "此 SQL 會修改資料或結構。執行前請仔細核對。", writeSqlConfirmationQuestion: "確認執行此 SQL 一次嗎?", writeSqlConfirmYes: "確認執行", diff --git a/apps/desktop/src/lib/ai/__tests__/aiSqlActionFeedback.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiSqlActionFeedback.spec.ts new file mode 100644 index 0000000000..34623fbfa3 --- /dev/null +++ b/apps/desktop/src/lib/ai/__tests__/aiSqlActionFeedback.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { aiSqlActionFeedback, type AiSqlActionOutcome } from "../aiSqlActionFeedback"; + +// Regression coverage for https://github.com/t8y2/dbx/issues/11962. +// +// An AI SQL action is delivered to an editor tab resolved from the +// conversation's bound connection, and that tab is deliberately created without +// activation (#9902). When it is not the active tab of any editor group, the +// click used to change nothing on screen and the database error landed only in +// that background tab. This decision table is what the panel says instead. +describe("aiSqlActionFeedback (#11962)", () => { + const started = (ok: boolean, errorMessage?: string): AiSqlActionOutcome => ({ status: "started", ok, tabId: "tab-1", ...(errorMessage ? { errorMessage } : {}) }); + + it("announces a success with the target tab's title and a reveal action when the tab is off screen", () => { + // The `*In` key, not the block's `sqlActionSucceeded`: the user is looking at + // the panel, so the toast has to name the tab the result landed in. + expect(aiSqlActionFeedback(started(true), { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ + level: "info", + messageKey: "ai.sqlActionSucceededIn", + params: { title: "MySQL@app" }, + revealTabId: "tab-1", + }); + }); + + it("reports the database error text when the tab is off screen", () => { + const feedback = aiSqlActionFeedback(started(false, "Unknown column 'u.foo' in 'field list'"), { targetOnScreen: false, tabTitle: "MySQL@app" }); + + expect(feedback.level).toBe("error"); + expect(feedback).toMatchObject({ messageKey: "ai.sqlActionFailed", params: { title: "MySQL@app", message: "Unknown column 'u.foo' in 'field list'" }, revealTabId: "tab-1" }); + }); + + it("stays silent whenever the target tab is on screen", () => { + // This is the #9902 regression guard: the tab itself shows the result and the + // error, and an AI action must never add a toast on top of it. + for (const outcome of [started(true), started(false, "boom"), { status: "applied" as const, tabId: "tab-1" }]) { + expect(aiSqlActionFeedback(outcome, { targetOnScreen: true, tabTitle: "MySQL@app" })).toEqual({ level: "none" }); + } + }); + + it("falls back to the tab id when the tab has no display title", () => { + expect(aiSqlActionFeedback(started(true), { targetOnScreen: false })).toMatchObject({ params: { title: "tab-1" } }); + }); + + it("reports apply without an execution summary", () => { + expect(aiSqlActionFeedback({ status: "applied", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ + level: "info", + messageKey: "ai.sqlActionAppliedIn", + params: { title: "MySQL@app" }, + revealTabId: "tab-1", + }); + }); + + it("never activates anything itself: the reveal id is data, not an action", () => { + // The mapper has no activation entry point at all — App.vue wires + // `revealTabId` to the toast's [查看] button, and only that click switches. + const feedback = aiSqlActionFeedback(started(true), { targetOnScreen: false, tabTitle: "t" }); + expect(Object.keys(feedback).sort()).toEqual(["level", "messageKey", "params", "revealTabId"]); + }); + + it("does not add a second notice when the user already saw a dialog or toast", () => { + // Danger / production / parameter / read-only dialogs, the blocked-Redis + // toast and the "select a database" toast are all produced elsewhere. + for (const reason of ["cancelled", "parameters", "blocked", "missing-database"] as const) { + expect(aiSqlActionFeedback({ status: "not-started", reason, tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ level: "none" }); + } + }); + + it("makes the silent early returns visible", () => { + expect(aiSqlActionFeedback({ status: "not-started", reason: "no-connection" }, { targetOnScreen: false })).toEqual({ level: "error", messageKey: "ai.sqlActionNoConnection", params: {} }); + expect(aiSqlActionFeedback({ status: "not-started", reason: "empty-sql", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ + level: "error", + messageKey: "ai.sqlActionEmptySql", + params: {}, + revealTabId: "tab-1", + }); + expect(aiSqlActionFeedback({ status: "not-started", reason: "busy", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ + level: "error", + messageKey: "ai.sqlActionBusy", + params: {}, + revealTabId: "tab-1", + }); + }); + + it("treats an unresolvable tab as a no-tab report even when a stale id is carried", () => { + expect(aiSqlActionFeedback({ status: "not-started", reason: "no-tab", tabId: "tab-gone" }, { targetOnScreen: false })).toMatchObject({ level: "error", messageKey: "ai.sqlActionNoConnection" }); + }); + + it("stays silent while a dialog parks the execution", () => { + // The dialog itself is the visible surface; the caller keeps waiting and the + // resumed execution reports through App's deferred path. + for (const reason of ["parameters", "danger", "redis-confirm"] as const) { + expect(aiSqlActionFeedback({ status: "deferred", reason, tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ level: "none" }); + } + }); + + it("omits the reveal id for a target that no longer exists", () => { + const feedback = aiSqlActionFeedback({ status: "not-started", reason: "empty-sql" }, { targetOnScreen: false }); + expect(feedback).toEqual({ level: "error", messageKey: "ai.sqlActionEmptySql", params: {} }); + expect("revealTabId" in feedback).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiSqlActionFeedback.ts b/apps/desktop/src/lib/ai/aiSqlActionFeedback.ts new file mode 100644 index 0000000000..c26eadd881 --- /dev/null +++ b/apps/desktop/src/lib/ai/aiSqlActionFeedback.ts @@ -0,0 +1,117 @@ +/** + * Feedback decision for an AI-initiated SQL action (#11962). + * + * The AI panel's execute / temp-run / apply buttons hand the whole interaction + * to an editor tab resolved from the conversation's bound connection, and that + * tab is deliberately created without activation (#9902: an AI action never + * moves the workspace). When the tab is not on screen the click used to produce + * *no* visible change at all — the database error was written only into that + * background tab's result. This module decides what the panel must say instead. + * + * Pure and dependency-free on purpose: the decision table is unit-tested + * without mounting `App.vue`. + */ + +import type { SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome"; + +/** One AI-initiated action's outcome: a real execution, or the panel's own append. */ +export type AiSqlActionOutcome = SqlExecutionOutcome | { status: "applied"; tabId: string }; + +export interface AiSqlActionFeedbackContext { + /** Whether the target tab is currently rendered as some editor group's active tab. */ + targetOnScreen: boolean; + /** Display title of the target tab, for the "executed in …" copy. */ + tabTitle?: string; +} + +export type AiSqlActionFeedback = { level: "none" } | { level: "info" | "error"; messageKey: string; params: Record; revealTabId?: string }; + +const NONE: AiSqlActionFeedback = { level: "none" }; + +/** + * Decides the panel-side feedback for one AI SQL action. + * + * - The user already saw UI (danger/production/parameter/unlock dialogs, the + * blocked-Redis toast, the "select a database" toast) → `none`. Repeating it + * as a second toast would be noise, and the panel's own block status still + * settles so nothing is left spinning. + * - Target tab on screen → `none`: the tab itself shows the result and error, + * and #9902 promises the workspace is never disturbed by an AI action. + * - Target tab off screen → the error path is always visible, and the success + * path is announced with a `[查看]` reveal action; never an automatic switch. + */ +export function aiSqlActionFeedback(outcome: AiSqlActionOutcome, context: AiSqlActionFeedbackContext): AiSqlActionFeedback { + if (outcome.status === "not-started") { + switch (outcome.reason) { + case "cancelled": + case "parameters": + case "blocked": + // A dialog/toast the user is already looking at, or a prompt they will + // still answer — the AI action has not been abandoned, so say nothing. + return NONE; + case "missing-database": + // `promptActiveDatabaseSelection` in App.vue already toasts + // `editor.selectDatabaseRequired` for this exact request. + return NONE; + case "empty-sql": + return reveal({ level: "error", messageKey: "ai.sqlActionEmptySql", params: {} }, outcome.tabId); + case "busy": + return reveal({ level: "error", messageKey: "ai.sqlActionBusy", params: {} }, outcome.tabId); + case "no-tab": + case "no-connection": + // No target tab at all (unbound conversation, deleted connection, or a + // tab that vanished): report the reason, and there is nothing to reveal. + return { level: "error", messageKey: "ai.sqlActionNoConnection", params: {} }; + } + } + + if (outcome.status === "deferred") { + // The dialog is on screen and the execution has not run yet, so there is + // nothing to announce: the caller keeps waiting and settles from the resumed + // execution (`onDeferredExecutionSettled`) instead. + return NONE; + } + + if (context.targetOnScreen) return NONE; + + const title = context.tabTitle?.trim() || outcome.tabId; + if (outcome.status === "applied") { + return { level: "info", messageKey: "ai.sqlActionAppliedIn", params: { title }, revealTabId: outcome.tabId }; + } + if (outcome.ok) { + // The *In keys name the tab the SQL ran in: the user is looking at the panel, + // not at the editor, so the toast has to say where the result landed. + return { level: "info", messageKey: "ai.sqlActionSucceededIn", params: { title }, revealTabId: outcome.tabId }; + } + return { + level: "error", + messageKey: "ai.sqlActionFailed", + params: { title, message: outcome.errorMessage?.trim() || "" }, + revealTabId: outcome.tabId, + }; +} + +function reveal(feedback: { level: "info" | "error"; messageKey: string; params: Record }, tabId: string | undefined): AiSqlActionFeedback { + return tabId ? { ...feedback, revealTabId: tabId } : feedback; +} + +/** + * Settlement report the host pushes back into the AI panel (#11962), so the SQL + * block that started the action shows its own outcome without the user having to + * find the target tab. Messages are already-translated text. + */ +export interface AiSqlActionReport { + requestId: number; + /** + * `dismissed` means the user answered a dialog (or the request was cancelled) + * and the panel must simply stop showing "running" — no error, no summary. + * `applied` is the append-to-editor action, which has no execution summary. + */ + status: "succeeded" | "applied" | "failed" | "unavailable" | "dismissed"; + /** Result rows returned, when the execution produced any. */ + rows?: number; + /** Rows the statement affected, when the backend reported any. */ + affectedRows?: number; + /** Database error text (`failed`) or why nothing ran (`unavailable`). */ + message?: string; +} diff --git a/apps/desktop/src/lib/sql/sqlExecutionOutcome.ts b/apps/desktop/src/lib/sql/sqlExecutionOutcome.ts new file mode 100644 index 0000000000..36effeb171 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlExecutionOutcome.ts @@ -0,0 +1,34 @@ +/** + * What one editor execution actually did (#11962). + * + * The editor path ignores this value — its feedback is the tab's own result pane + * — but a caller that starts an execution on a tab the user is *not* looking at + * (the AI panel does, see `lib/ai/aiSqlActionFeedback`) has nowhere else to learn + * that the request ran, ran late, or never started. Reporting every early return + * is the point: silence there is exactly the bug in #11962. + */ + +/** + * Why an execution request never reached the database. + * + * `cancelled` / `parameters` / `blocked` / `missing-database` are the reasons the + * user has already been shown a dialog or toast, so a caller must not report them + * a second time. + */ +export type SqlExecutionNotStartedReason = "no-tab" | "no-connection" | "empty-sql" | "missing-database" | "parameters" | "busy" | "cancelled" | "blocked"; + +/** Dialog an execution is parked on until the user answers it. */ +export type SqlExecutionDeferredReason = "parameters" | "danger" | "redis-confirm"; + +export type SqlExecutionOutcome = + /** The request reached the backend; `ok` is whether it succeeded. */ + | { status: "started"; ok: boolean; errorMessage?: string; tabId: string } + /** + * The request opened a dialog (parameter / danger / Redis confirmation) and only + * continues if the user accepts it, so this is NOT terminal: a caller that needs + * the final result must wait for the resumed execution + * (`onDeferredExecutionSettled`) instead of settling here. Treating it as a + * plain dismissal is what left a confirmed AI execution unreported. + */ + | { status: "deferred"; reason: SqlExecutionDeferredReason; tabId: string } + | { status: "not-started"; reason: SqlExecutionNotStartedReason; tabId?: string }; diff --git a/docs/content/docs/ai-assistant.cn.mdx b/docs/content/docs/ai-assistant.cn.mdx index e2445eea1a..f64812f755 100644 --- a/docs/content/docs/ai-assistant.cn.mdx +++ b/docs/content/docs/ai-assistant.cn.mdx @@ -169,6 +169,20 @@ Redis 连接改用只读命令工具,而不是 SQL。助手可以执行 DBX AI Agent 不会获得“以后都可以写”的授权,也不会绕过 MCP 策略、连接只读保护或数据库权限。更多边界见 [生产环境安全](/cn/docs/production-safety)。 +## SQL 代码块操作 + +回复里的每个 SQL 代码块都提供 **临时运行**、**立即执行** 和 **应用到编辑器**。三者都作用于该对话**自己绑定**的连接、数据库和 Schema 对应的查询页签,而不是当前恰好在前台的那个编辑器。 + +该页签不存在时会在后台创建,动作本身也**不会移动你的工作区**:AI 动作不会自行切换、选中或打开任何页签。 + +正因为结果不总落在你眼前,代码块会自己报告结果: + +* 块内显示 **执行中…**,结束后显示返回或影响的行数,失败则显示数据库返回的错误文本。 +* 目标页签不在屏幕上时,提示会写明是哪个页签并提供 **查看**;只有点击 **查看** 才会切过去。 +* 根本没跑起来的情况(没有可用连接、页签仍在处理上一个请求、没有可执行的 SQL)也会在块内说明原因。 + +危险 SQL、生产库、参数和只读解锁仍然使用各自的确认对话框,代码块和提示不会重复一遍。 + ## 推理强度和 Thinking 不同供应商使用不同机制:枚举 effort、reasoning level、布尔 thinking、整数预算或自定义文本。DBX 按当前模型能力展示可用选项,并记住每个配置/模型的选择。 diff --git a/docs/content/docs/ai-assistant.mdx b/docs/content/docs/ai-assistant.mdx index 3d2892149b..24db4c550f 100644 --- a/docs/content/docs/ai-assistant.mdx +++ b/docs/content/docs/ai-assistant.mdx @@ -171,6 +171,20 @@ Redis connections use a read-only command tool instead of SQL. The assistant can AI Agent never receives “write from now on” permission and cannot bypass MCP policy, connection read-only protection, or database privileges. See [Production Safety](/en/docs/production-safety) for the complete boundary. +## SQL Block Actions + +Each SQL block in a reply offers **Temp run**, **Execute**, and **Apply to editor**. All three target the query tab that belongs to the conversation's own connection, database, and schema — the connection the conversation is bound to, never whichever editor happens to be in front. + +That tab is created in the background when it does not exist yet, and the action never moves your workspace: an AI action does not switch, select, or open tabs on its own. + +Because the result therefore does not always land where you are looking, the block reports its own outcome: + +* The block shows **Executing…**, then the row count (returned or affected) or the database's error text. +* When the target tab is not on screen, a notification names that tab and offers **View**; only clicking **View** brings it to the front. +* Requests that never reached the database say why in the block instead: no available connection, an editor tab still busy with another request, or nothing to execute. + +Dangerous-SQL, production, parameter, and read-only confirmations keep their own dialogs; those are not repeated in the block or as a notification. + ## Reasoning Effort and Thinking Providers expose different mechanisms: enumerated effort, reasoning levels, boolean thinking, integer budgets, or free-text options. DBX shows controls from the active model capability and remembers the selection per configuration and model.