diff --git a/apps/desktop/src/components/connection/ConnectionTreeSelect.vue b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue index f594b9d51b..b4a5cd1cc2 100644 --- a/apps/desktop/src/components/connection/ConnectionTreeSelect.vue +++ b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue @@ -5,9 +5,8 @@ import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import ConnectionIcon from "@/components/icons/ConnectionIcon.vue"; import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree"; -import { connectionIconType } from "@/lib/connection/connectionPresentation"; import { cn } from "@/lib/common/utils"; import type { ConnectionConfig, SidebarLayout } from "@/types/database"; @@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) { @click="selectConnection(row.id)" > - + {{ row.label }} diff --git a/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts index 2bb638e2b2..c41e763c58 100644 --- a/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts +++ b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts @@ -26,6 +26,13 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ }), })); +vi.mock("@/components/plugins/PluginIcon.vue", () => ({ + default: defineComponent({ + props: ["pluginId", "contributionId"], + setup: (props) => () => h("span", { "data-plugin-id": props.pluginId, "data-contribution-id": props.contributionId }), + }), +})); + const mountedApps: App[] = []; const connections: ConnectionConfig[] = [ @@ -45,12 +52,12 @@ const layout: SidebarLayout = { order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }], }; -async function mountPicker() { +async function mountPicker(extraConnections: ConnectionConfig[] = []) { const container = document.createElement("div"); document.body.append(container); const app = createApp(ConnectionTreeSelect, { modelValue: "", - connections, + connections: [...connections, ...extraConnections], layout, placeholder: "Select connection", searchPlaceholder: "Search connections", @@ -82,6 +89,13 @@ afterEach(() => { }); describe("ConnectionTreeSelect search collapse behavior", () => { + it("passes plugin identity and provider logo metadata to the shared icon", async () => { + await mountPicker([{ ...connections[0], id: "plugin", name: "Plugin", db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" }]); + const icon = document.body.querySelector('[data-picker-connection="plugin"] [data-plugin-id]'); + expect(icon?.getAttribute("data-plugin-id")).toBe("sample.plugin"); + expect(icon?.getAttribute("data-contribution-id")).toBe("sample.connection"); + }); + it("ignores group clicks during search without changing the post-search state", async () => { await mountPicker(); await setSearchText("Primary"); diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index b4eafa5f99..b4fca92534 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -66,8 +66,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useUserSkillStore } from "@/stores/userSkillStore"; import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection"; import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills"; -import { connectionIconType } from "@/lib/connection/connectionPresentation"; -import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport"; +import ConnectionIcon from "@/components/icons/ConnectionIcon.vue"; import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue"; import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue"; import { useQueryStore } from "@/stores/queryStore"; @@ -154,11 +154,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue"; -import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; +import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter"; import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender"; import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown"; -import { aiStream, aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api"; +import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api"; import type { AiMessage } from "@/lib/backend/api"; import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai"; import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database"; @@ -184,6 +184,7 @@ import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport"; import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport"; import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch"; import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue"; +import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue"; import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy"; import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation"; @@ -1780,9 +1781,11 @@ watch( ); watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true }); +const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type)); + const showAiSchemaSelector = computed(() => { const connection = boundConnection.value; - return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection); + return showAiDatabaseSelector.value && !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection); }); const aiSchemaDatabaseKey = computed(() => { @@ -1804,7 +1807,7 @@ async function loadAiSchemas() { } async function loadDatabases(connection = boundConnection.value): Promise { - if (!connection) return []; + if (!connection || !supportsAiAssistantContext(connection.db_type)) return []; if (connection.db_type !== "dameng") { await loadDatabaseOptions(connection.id); return databaseOptions.value[connection.id] || []; @@ -2103,6 +2106,21 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla } } +/** Sends the user's answer for a pending plugin tool approval back to the run that asked. */ +async function resolveToolApproval(step: AiAgentStepItem, approved: boolean) { + const approval = step.approval; + if (!approval || approval.status !== "pending") return; + approval.status = "submitting"; + try { + // The final status arrives with `tool_approval_resolved`; `false` means + // the run stopped waiting (timed out, cancelled, or app restarted). + if (!(await resolveAiToolApproval(approval.sessionId, approval.approvalId, approved))) approval.status = "expired"; + } catch (error) { + approval.status = "pending"; + toast(t("ai.toolApproval.answerFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } +} + function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined { if (event.type === "context_compacted") { return { @@ -2343,7 +2361,7 @@ function mentionTargetDatabase(): string { async function loadMentionCandidates(query: string) { const connection = boundConnection.value; const mentionDatabase = mentionTargetDatabase(); - if (pluginContext.value || !connection || !boundConnectionId.value || !mentionDatabase) return; + if (pluginContext.value || !connection || !supportsAiAssistantContext(connection.db_type) || !boundConnectionId.value || !mentionDatabase) return; const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query); if (mentionCache.value[key]) { @@ -3693,6 +3711,28 @@ async function send() { if (step) upsertAgentStep(msg.agentSteps, step); } } + // A plugin tool call waits for (or got) the user's approval: keep the + // state on the tool card so the answer buttons live next to the call. + if (event.type === "tool_approval_required" || event.type === "tool_approval_resolved") { + const msg = runMessages[assistantIdx]; + if (msg?.agentSteps) { + const key = toolCallStepKey(event.tool_call_id, agentEvents.length - 1, "tool_call_start"); + if (event.type === "tool_approval_required") { + updateAgentStepApproval(msg.agentSteps, key, () => ({ + approvalId: event.approval_id, + sessionId, + pluginName: event.plugin_name, + pluginTool: event.plugin_tool, + connectionName: event.connection_name, + args: event.args ?? {}, + expiresAtMs: Date.now() + event.timeout_secs * 1000, + status: "pending", + })); + } else { + updateAgentStepApproval(msg.agentSteps, key, (approval) => (approval && approval.approvalId === event.approval_id ? { ...approval, status: event.outcome } : approval)); + } + } + } if (runIsVisible()) scrollToBottom(); }; if (runPluginContext) { @@ -5530,16 +5570,17 @@ async function openExternalUrl(url: string) { {{ t(step.labelKey) }} - : {{ step.toolName }} + : {{ formatAgentToolName(step.toolName) }} {{ formatToolDurationMs(step.durationMs) }} +
{{ step.toolArgs.sql }}