From b45446d25724585fee8379601c351e0bb1960373 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 12 May 2026 16:52:03 +0300 Subject: [PATCH 01/13] feat: notebooks with coding agents integration --- e2e/questdb | 2 +- e2e/tests/console/aiAssistant.spec.js | 6 +- .../console/aiAssistantPermissions.spec.js | 99 ++ .../console/mcpBridgePermissions.spec.js | 444 +++++++++ package.json | 8 +- public/assets/icon-notebook.svg | 1 + src/components/ContextMenu/index.tsx | 20 +- src/components/CopyButton/index.tsx | 5 +- src/components/DropdownMenu/index.tsx | 35 +- src/components/MultiSelect/index.tsx | 203 ++++ .../SetupAIAssistant/ConfigurationModal.tsx | 161 +-- .../SetupAIAssistant/CustomProviderModal.tsx | 5 +- .../SetupAIAssistant/ManageModelsModal.tsx | 7 +- .../SetupAIAssistant/ModelSettings.tsx | 126 +-- .../SetupAIAssistant/SettingsModal.tsx | 210 ++-- src/components/index.ts | 1 + src/consts/shared-definitions.json | 900 +++++++++++++++++ src/hooks/useQueryExecution.ts | 72 ++ src/index.tsx | 6 + .../AIConversationProvider/index.tsx | 117 +++ src/providers/AIConversationProvider/types.ts | 12 + .../userActionDigest.test.ts | 162 +++ .../userActionDigest.ts | 60 ++ src/providers/AIStatusProvider/index.tsx | 12 + src/providers/EditorProvider/index.tsx | 66 +- src/providers/LocalStorageProvider/types.ts | 5 + .../MCPBridgeProvider/PairingConsentModal.tsx | 350 +++++++ src/providers/MCPBridgeProvider/index.tsx | 488 +++++++++ .../MCPBridgeProvider/useBridgeToolRunner.ts | 74 ++ src/scenes/Console/index.tsx | 332 ++++--- src/scenes/Editor/AIChatWindow/ChatInput.tsx | 70 +- .../Editor/AIChatWindow/ChatMessages.tsx | 6 +- src/scenes/Editor/AIChatWindow/index.tsx | 79 ++ src/scenes/Editor/Monaco/QueryDropdown.tsx | 59 +- src/scenes/Editor/Monaco/importTabs.test.ts | 55 +- src/scenes/Editor/Monaco/importTabs.ts | 28 +- src/scenes/Editor/Monaco/tabs.tsx | 82 +- src/scenes/Editor/Monaco/utils.test.ts | 63 +- src/scenes/Editor/Monaco/utils.ts | 127 ++- .../Notebook/CellChart/ChartActions.tsx | 123 +++ .../Notebook/CellChart/ChartRenderer.tsx | 123 +++ .../CellChart/ChartSettingsDrawer.tsx | 324 ++++++ .../Notebook/CellChart/buildEchartsOption.ts | 406 ++++++++ .../Editor/Notebook/CellChart/chartTypes.ts | 34 + .../Editor/Notebook/CellChart/echartsSetup.ts | 40 + .../CellChart/inferChartConfig.test.ts | 211 ++++ .../Notebook/CellChart/inferChartConfig.ts | 229 +++++ .../Editor/Notebook/CellChart/questdbTheme.ts | 111 +++ .../DrawCanvas/drawCanvasUtils.test.ts | 126 +++ .../Notebook/DrawCanvas/drawCanvasUtils.ts | 43 + .../Editor/Notebook/DrawCanvas/index.tsx | 315 ++++++ .../Editor/Notebook/NotebookProvider.tsx | 499 ++++++++++ .../Editor/Notebook/NotebookToolbar.tsx | 144 +++ .../Notebook/NotebookWorkspaceBridge.tsx | 67 ++ .../Editor/Notebook/cells/AddCellButton.tsx | 135 +++ src/scenes/Editor/Notebook/cells/Cell.tsx | 706 +++++++++++++ .../Editor/Notebook/cells/CellToolbar.tsx | 180 ++++ .../Editor/Notebook/cells/CellWrapper.tsx | 59 ++ .../Editor/Notebook/cells/useCellResize.ts | 40 + .../cells/useCellSelectionDecoration.ts | 47 + .../Notebook/cells/useMonacoCellEditor.ts | 143 +++ src/scenes/Editor/Notebook/index.tsx | 337 +++++++ .../Editor/Notebook/notebookUtils.test.ts | 758 ++++++++++++++ src/scenes/Editor/Notebook/notebookUtils.ts | 445 +++++++++ .../Editor/Notebook/resize/EdgeHandle.tsx | 114 +++ .../Editor/Notebook/resize/ResizeHandle.tsx | 123 +++ src/scenes/Editor/Notebook/resize/index.ts | 2 + .../result-table/InlineResultTable.tsx | 60 ++ .../Notebook/result-table/ResultGrid.tsx | 350 +++++++ .../result-table/StatusNotification.tsx | 139 +++ .../Editor/Notebook/result-table/TabBar.tsx | 81 ++ .../Editor/Notebook/result-table/index.ts | 1 + .../result-table/inlineGridUtils.test.ts | 249 +++++ .../Notebook/result-table/inlineGridUtils.ts | 162 +++ .../Editor/Notebook/result-table/styles.ts | 325 ++++++ .../result-table/useGridKeyboardNav.ts | 174 ++++ .../Editor/Notebook/useCellExecution.ts | 244 +++++ src/scenes/Editor/Notebook/useCellsStore.ts | 177 ++++ .../Editor/Notebook/useNotebookPersistence.ts | 84 ++ src/scenes/Editor/index.tsx | 12 +- .../Footer/MCPBridgeStatus/PairPopover.tsx | 426 ++++++++ .../MCPBridgeStatus/PermissionsSection.tsx | 217 ++++ src/scenes/Footer/MCPBridgeStatus/index.tsx | 123 +++ src/scenes/Footer/MCPBridgeStatus/tone.ts | 25 + src/scenes/Footer/index.tsx | 2 + src/scenes/Layout/index.tsx | 47 +- .../Notification/ErrorNotification/index.tsx | 17 +- .../Notification/InfoNotification/index.tsx | 17 +- .../LoadingNotification/index.tsx | 17 +- .../Notification/NoticeNotification/index.tsx | 17 +- .../SuccessNotification/index.tsx | 18 +- .../Notifications/Notification/styles.tsx | 10 +- src/store/Query/types.ts | 2 + src/store/aiConversations.ts | 3 + src/store/buffers.ts | 31 +- src/store/db.ts | 4 + src/store/notebook.ts | 98 ++ src/styles/_editor.scss | 7 +- src/theme/index.ts | 1 + src/types/styled.d.ts | 1 + src/utils/ai/anthropicProvider.ts | 32 +- src/utils/ai/index.ts | 5 +- src/utils/ai/notebookSnapshot.test.ts | 371 +++++++ src/utils/ai/notebookSnapshot.ts | 296 ++++++ src/utils/ai/openaiChatCompletionsProvider.ts | 22 +- src/utils/ai/openaiProvider.ts | 22 +- src/utils/ai/prompts.ts | 68 +- src/utils/ai/settings.test.ts | 127 ++- src/utils/ai/settings.ts | 26 +- src/utils/ai/shared.notebookTools.test.ts | 927 ++++++++++++++++++ src/utils/ai/shared.ts | 151 --- src/utils/ai/tools.ts | 105 -- src/utils/ai/types.ts | 31 +- src/utils/aiAssistant.ts | 443 ++++++++- .../executeAIFlow.buildUserMessage.test.ts | 140 +++ src/utils/executeAIFlow.ts | 63 +- src/utils/mcp/MCPBridgeClient.test.ts | 389 ++++++++ src/utils/mcp/MCPBridgeClient.ts | 488 +++++++++ src/utils/mcp/consumePendingPair.test.ts | 114 +++ src/utils/mcp/consumePendingPair.ts | 53 + src/utils/mcp/dispatchMCPTool.test.ts | 289 ++++++ src/utils/mcp/dispatchMCPTool.ts | 205 ++++ src/utils/mcp/mcpBridgeStorage.test.ts | 95 ++ src/utils/mcp/mcpBridgeStorage.ts | 104 ++ src/utils/mcp/metaResolvers.test.ts | 148 +++ src/utils/mcp/metaResolvers.ts | 66 ++ src/utils/mcp/pairValidation.ts | 7 + src/utils/mcp/protocolVersion.ts | 5 + src/utils/mcp/types.ts | 124 +++ src/utils/notebookAIBridge.test.ts | 261 +++++ src/utils/notebookAIBridge.ts | 350 +++++++ src/utils/questdb/client.ts | 15 +- src/utils/questdb/types.ts | 8 +- src/utils/tools/dispatch.ts | 774 +++++++++++++++ src/utils/tools/permissions.test.ts | 439 +++++++++ src/utils/tools/permissions.ts | 235 +++++ src/utils/tools/runQuery.test.ts | 150 +++ src/utils/tools/runQuery.ts | 138 +++ src/utils/tools/tools.ts | 54 + yarn.lock | 149 ++- 140 files changed, 19934 insertions(+), 1138 deletions(-) create mode 100644 e2e/tests/console/aiAssistantPermissions.spec.js create mode 100644 e2e/tests/console/mcpBridgePermissions.spec.js create mode 100644 public/assets/icon-notebook.svg create mode 100644 src/components/MultiSelect/index.tsx create mode 100644 src/consts/shared-definitions.json create mode 100644 src/hooks/useQueryExecution.ts create mode 100644 src/providers/AIConversationProvider/userActionDigest.test.ts create mode 100644 src/providers/AIConversationProvider/userActionDigest.ts create mode 100644 src/providers/MCPBridgeProvider/PairingConsentModal.tsx create mode 100644 src/providers/MCPBridgeProvider/index.tsx create mode 100644 src/providers/MCPBridgeProvider/useBridgeToolRunner.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/ChartActions.tsx create mode 100644 src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx create mode 100644 src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx create mode 100644 src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/chartTypes.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/echartsSetup.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/inferChartConfig.test.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/inferChartConfig.ts create mode 100644 src/scenes/Editor/Notebook/CellChart/questdbTheme.ts create mode 100644 src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts create mode 100644 src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts create mode 100644 src/scenes/Editor/Notebook/DrawCanvas/index.tsx create mode 100644 src/scenes/Editor/Notebook/NotebookProvider.tsx create mode 100644 src/scenes/Editor/Notebook/NotebookToolbar.tsx create mode 100644 src/scenes/Editor/Notebook/NotebookWorkspaceBridge.tsx create mode 100644 src/scenes/Editor/Notebook/cells/AddCellButton.tsx create mode 100644 src/scenes/Editor/Notebook/cells/Cell.tsx create mode 100644 src/scenes/Editor/Notebook/cells/CellToolbar.tsx create mode 100644 src/scenes/Editor/Notebook/cells/CellWrapper.tsx create mode 100644 src/scenes/Editor/Notebook/cells/useCellResize.ts create mode 100644 src/scenes/Editor/Notebook/cells/useCellSelectionDecoration.ts create mode 100644 src/scenes/Editor/Notebook/cells/useMonacoCellEditor.ts create mode 100644 src/scenes/Editor/Notebook/index.tsx create mode 100644 src/scenes/Editor/Notebook/notebookUtils.test.ts create mode 100644 src/scenes/Editor/Notebook/notebookUtils.ts create mode 100644 src/scenes/Editor/Notebook/resize/EdgeHandle.tsx create mode 100644 src/scenes/Editor/Notebook/resize/ResizeHandle.tsx create mode 100644 src/scenes/Editor/Notebook/resize/index.ts create mode 100644 src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx create mode 100644 src/scenes/Editor/Notebook/result-table/ResultGrid.tsx create mode 100644 src/scenes/Editor/Notebook/result-table/StatusNotification.tsx create mode 100644 src/scenes/Editor/Notebook/result-table/TabBar.tsx create mode 100644 src/scenes/Editor/Notebook/result-table/index.ts create mode 100644 src/scenes/Editor/Notebook/result-table/inlineGridUtils.test.ts create mode 100644 src/scenes/Editor/Notebook/result-table/inlineGridUtils.ts create mode 100644 src/scenes/Editor/Notebook/result-table/styles.ts create mode 100644 src/scenes/Editor/Notebook/result-table/useGridKeyboardNav.ts create mode 100644 src/scenes/Editor/Notebook/useCellExecution.ts create mode 100644 src/scenes/Editor/Notebook/useCellsStore.ts create mode 100644 src/scenes/Editor/Notebook/useNotebookPersistence.ts create mode 100644 src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx create mode 100644 src/scenes/Footer/MCPBridgeStatus/PermissionsSection.tsx create mode 100644 src/scenes/Footer/MCPBridgeStatus/index.tsx create mode 100644 src/scenes/Footer/MCPBridgeStatus/tone.ts create mode 100644 src/store/notebook.ts create mode 100644 src/utils/ai/notebookSnapshot.test.ts create mode 100644 src/utils/ai/notebookSnapshot.ts create mode 100644 src/utils/ai/shared.notebookTools.test.ts delete mode 100644 src/utils/ai/tools.ts create mode 100644 src/utils/executeAIFlow.buildUserMessage.test.ts create mode 100644 src/utils/mcp/MCPBridgeClient.test.ts create mode 100644 src/utils/mcp/MCPBridgeClient.ts create mode 100644 src/utils/mcp/consumePendingPair.test.ts create mode 100644 src/utils/mcp/consumePendingPair.ts create mode 100644 src/utils/mcp/dispatchMCPTool.test.ts create mode 100644 src/utils/mcp/dispatchMCPTool.ts create mode 100644 src/utils/mcp/mcpBridgeStorage.test.ts create mode 100644 src/utils/mcp/mcpBridgeStorage.ts create mode 100644 src/utils/mcp/metaResolvers.test.ts create mode 100644 src/utils/mcp/metaResolvers.ts create mode 100644 src/utils/mcp/pairValidation.ts create mode 100644 src/utils/mcp/protocolVersion.ts create mode 100644 src/utils/mcp/types.ts create mode 100644 src/utils/notebookAIBridge.test.ts create mode 100644 src/utils/notebookAIBridge.ts create mode 100644 src/utils/tools/dispatch.ts create mode 100644 src/utils/tools/permissions.test.ts create mode 100644 src/utils/tools/permissions.ts create mode 100644 src/utils/tools/runQuery.test.ts create mode 100644 src/utils/tools/runQuery.ts create mode 100644 src/utils/tools/tools.ts diff --git a/e2e/questdb b/e2e/questdb index 65aa3e693..ebb17dc1b 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 65aa3e693e714e1b924bb0f1155199cd2a35db81 +Subproject commit ebb17dc1b836d28d85c833fa3db488cd89ab73dc diff --git a/e2e/tests/console/aiAssistant.spec.js b/e2e/tests/console/aiAssistant.spec.js index bf7991c67..c92835140 100644 --- a/e2e/tests/console/aiAssistant.spec.js +++ b/e2e/tests/console/aiAssistant.spec.js @@ -456,7 +456,7 @@ describe("ai assistant", () => { cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") // When - cy.getByDataHook("ai-settings-schema-access").click() + cy.getByDataHook("permission-schema").click() cy.getByDataHook("multi-step-modal-next-button").click() // Then - AI chat should be available @@ -481,7 +481,7 @@ describe("ai assistant", () => { // When - Open settings modal and enable schema access cy.getByDataHook("ai-assistant-settings-button").click() - cy.getByDataHook("ai-settings-schema-access").click() + cy.getByDataHook("permission-schema").click() cy.getByDataHook("ai-settings-save").click() cy.get(".toast-success-container").should("be.visible").click() @@ -3562,7 +3562,7 @@ describe("custom providers", () => { cy.get("[data-model='mistral']").should("exist") // Schema access toggle is not disabled - cy.getByDataHook("ai-settings-schema-access").should("not.be.disabled") + cy.getByDataHook("permission-schema").should("not.be.disabled") // Manage models button visible cy.getByDataHook("ai-settings-manage-models").should("be.visible") diff --git a/e2e/tests/console/aiAssistantPermissions.spec.js b/e2e/tests/console/aiAssistantPermissions.spec.js new file mode 100644 index 000000000..23a1955fd --- /dev/null +++ b/e2e/tests/console/aiAssistantPermissions.spec.js @@ -0,0 +1,99 @@ +/// + +const { + PROVIDERS, + getOpenAIConfiguredSettings, + createToolCallFlow, +} = require("../../utils/aiAssistant") + +describe("ai assistant permissions", () => { + beforeEach(() => { + // Fail loudly on any unmocked provider request — each test scripts its own intercept. + cy.intercept("POST", PROVIDERS.openai.endpoint, (req) => { + throw new Error( + `Unhandled OpenAI request detected! Request body: ${JSON.stringify( + req.body, + ).slice(0, 200)}...`, + ) + }).as("unhandledOpenAI") + }) + + describe("PermissionsSection in settings modals", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("renders three permission checkboxes with cascade visible in SettingsModal", () => { + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + + cy.getByDataHook("permissions").should("be.visible") + cy.getByDataHook("permission-schema").should("be.checked") + cy.getByDataHook("permission-read").should("not.be.checked") + cy.getByDataHook("permission-write").should("not.be.checked") + + // Cascade: check Write → both Read and Schema lock as checked. + cy.getByDataHook("permission-write").check() + cy.getByDataHook("permission-write").should("be.checked") + cy.getByDataHook("permission-read").should("be.checked") + cy.getByDataHook("permission-read").should("be.disabled") + cy.getByDataHook("permission-schema").should("be.checked") + cy.getByDataHook("permission-schema").should("be.disabled") + + // Reverse cascade: unchecking Schema also clears Read and Write. + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("permission-read").uncheck() + cy.getByDataHook("permission-schema").uncheck() + cy.getByDataHook("permission-schema").should("not.be.checked") + cy.getByDataHook("permission-read").should("not.be.checked") + cy.getByDataHook("permission-write").should("not.be.checked") + }) + }) + + describe("gate denies run_query when read=false", () => { + beforeEach(() => { + // grantSchemaAccess keeps run_query in the catalog so the tool call fires; the gate refuses execution. + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("returns PERMISSION_DENIED to the model when it calls run_query without read access", () => { + const expectedDenial = "PERMISSION_DENIED" + + const flow = createToolCallFlow({ + provider: "openai", + streaming: true, + question: "Please drop the btc_trades table.", + steps: [ + { + toolCall: { + name: "run_query", + args: { sql: "DROP TABLE btc_trades" }, + }, + }, + { + finalResponse: { + explanation: + "I cannot drop that table — write access is not granted.", + sql: null, + }, + expectToolResult: { includes: [expectedDenial] }, + }, + ], + }) + + flow.intercept() + + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type(flow.question) + cy.getByDataHook("chat-send-button").click() + + flow.waitForCompletion() + + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", "write access is not granted") + }) + }) +}) diff --git a/e2e/tests/console/mcpBridgePermissions.spec.js b/e2e/tests/console/mcpBridgePermissions.spec.js new file mode 100644 index 000000000..d42df6083 --- /dev/null +++ b/e2e/tests/console/mcpBridgePermissions.spec.js @@ -0,0 +1,444 @@ +/// + +// E2E coverage for the MCP bridge permission system. + +const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" +const baseUrl = `http://localhost:9999${contextPath}` + +const TEST_BRIDGE_URL = "ws://127.0.0.1:57123" +const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789" +// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped. +const PROTOCOL_VERSION = "1" + +// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`. +const installFakeWebSocket = (win) => { + class FakeWS { + constructor(url) { + this.url = url + this.readyState = 0 + this.sent = [] + this.listeners = {} + win.__mcpFakeWS = this + // Async "open" mirrors real WebSocket — lets listener registration finish. + win.setTimeout(() => { + this.readyState = 1 + this._dispatch("open", {}) + }, 0) + } + addEventListener(type, fn) { + if (!this.listeners[type]) this.listeners[type] = new Set() + this.listeners[type].add(fn) + } + removeEventListener(type, fn) { + this.listeners[type]?.delete(fn) + } + send(data) { + if (this.readyState !== 1) { + throw new Error("FakeWS.send while not open") + } + this.sent.push(data) + const parsed = JSON.parse(data) + if (parsed.type === "ping") { + this.receive({ + v: PROTOCOL_VERSION, + type: "pong", + nonce: parsed.nonce, + }) + } + } + close() { + this.readyState = 3 + this._dispatch("close", { code: 1000, reason: "test-close" }) + } + receive(payload) { + this._dispatch("message", { data: JSON.stringify(payload) }) + } + helloAck() { + this.receive({ + v: PROTOCOL_VERSION, + type: "hello_ack", + sessionId: "test-session", + heartbeatIntervalMs: 60000, + seenToolCount: 1, + }) + } + toolCall(name, args, requestId) { + const id = requestId || "req-" + Math.random().toString(36).slice(2) + this.receive({ + v: PROTOCOL_VERSION, + type: "tool_call", + requestId: id, + name, + arguments: args, + deadlineMs: 15000, + }) + return id + } + _dispatch(type, ev) { + this.listeners[type]?.forEach((fn) => fn(ev)) + } + framesOfType(type) { + return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type) + } + } + // Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN. + FakeWS.CONNECTING = 0 + FakeWS.OPEN = 1 + FakeWS.CLOSING = 2 + FakeWS.CLOSED = 3 + win.WebSocket = FakeWS +} + +// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML. +const installValidateIntercept = () => { + cy.intercept("GET", "**/api/v1/sql/validate*", (req) => { + const url = new URL(req.url) + const sql = (url.searchParams.get("query") || "").trim().toUpperCase() + if (sql.startsWith("SELECT") || sql.startsWith("SHOW")) { + req.reply({ + statusCode: 200, + body: { + query: sql, + columns: [{ name: "c1", type: "LONG" }], + timestamp: -1, + }, + }) + return + } + if ( + sql.startsWith("INSERT") || + sql.startsWith("UPDATE") || + sql.startsWith("DELETE") + ) { + req.reply({ statusCode: 200, body: { queryType: "INSERT" } }) + return + } + req.reply({ statusCode: 200, body: { queryType: "CREATE TABLE" } }) + }).as("validate") +} + +const deepLinkSuffix = () => + `?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` + + `&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}` + +// Visit with deep-link params before login so they survive into the SPA boot. +const loginAndVisitDeepLink = (seedLocalStorage = {}) => { + cy.visit(`${baseUrl}/${deepLinkSuffix()}`, { + onBeforeLoad: (win) => { + win.localStorage.clear() + win.sessionStorage.clear() + win.indexedDB.deleteDatabase("web-console") + for (const [k, v] of Object.entries(seedLocalStorage)) { + win.localStorage.setItem(k, v) + } + installFakeWebSocket(win) + }, + }) + cy.loginWithUserAndPassword() +} + +const waitForPaired = () => { + cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist") + cy.window({ timeout: 10000 }).should((win) => { + expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0) + }) + cy.window().then((win) => win.__mcpFakeWS.helloAck()) + cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should( + "contain", + "MCP connected", + ) +} + +const lastToolResult = (win, requestId) => { + const results = win.__mcpFakeWS.framesOfType("tool_result") + return results.find((r) => r.requestId === requestId) +} + +describe("MCP bridge permissions (e2e)", () => { + beforeEach(() => { + installValidateIntercept() + }) + + describe("consent modal", () => { + it("user unchecks Write before Connect; hello carries { grantSchemaAccess:T, read:T, write:F }", () => { + loginAndVisitDeepLink() + + cy.getByDataHook("permissions").should("be.visible") + cy.getByDataHook("permission-schema").should("be.checked") + cy.getByDataHook("permission-read").should("be.checked") + cy.getByDataHook("permission-write").should("be.checked") + cy.getByDataHook("permission-read").should("be.disabled") + cy.getByDataHook("permission-schema").should("be.disabled") + + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("permission-write").should("not.be.checked") + cy.getByDataHook("permission-read") + .should("be.checked") + .and("not.be.disabled") + cy.getByDataHook("permission-schema") + .should("be.checked") + .and("be.disabled") + + cy.getByDataHook("mcp-pair-consent-connect").click() + + cy.window().should((win) => { + const hellos = win.__mcpFakeWS.framesOfType("hello") + expect(hellos.length).to.be.greaterThan(0) + expect(hellos[0].permissions).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: false, + }) + expect(hellos[0].token).to.equal(TEST_BRIDGE_TOKEN) + }) + + cy.window().then((win) => win.__mcpFakeWS.helloAck()) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: false, + }) + }) + }) + + it("user unchecks Write, Read, then Schema; hello carries all-false (cascade respected)", () => { + loginAndVisitDeepLink() + + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("permission-read").uncheck() + cy.getByDataHook("permission-schema") + .should("be.checked") + .and("not.be.disabled") + cy.getByDataHook("permission-schema").uncheck() + cy.getByDataHook("permission-schema").should("not.be.checked") + cy.getByDataHook("permission-read").should("not.be.checked") + cy.getByDataHook("permission-write").should("not.be.checked") + + cy.getByDataHook("mcp-pair-consent-connect").click() + cy.window().should((win) => { + const hellos = win.__mcpFakeWS.framesOfType("hello") + expect(hellos.length).to.be.greaterThan(0) + expect(hellos[0].permissions).to.deep.equal({ + grantSchemaAccess: false, + read: false, + write: false, + }) + }) + }) + }) + + describe("permission gate over the wire", () => { + const setupPaired = (permissions) => { + loginAndVisitDeepLink({ + "mcp:permissions": JSON.stringify(permissions), + }) + cy.getByDataHook("permission-schema").should( + permissions.grantSchemaAccess ? "be.checked" : "not.be.checked", + ) + cy.getByDataHook("permission-read").should( + permissions.read ? "be.checked" : "not.be.checked", + ) + cy.getByDataHook("permission-write").should( + permissions.write ? "be.checked" : "not.be.checked", + ) + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + } + + it("read+write granted: DQL, DML, schema all run", () => { + setupPaired({ grantSchemaAccess: true, read: true, write: true }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT 1", + limit: 10, + }) + ids.dml = win.__mcpFakeWS.toolCall("run_query", { + sql: "INSERT INTO t VALUES (1)", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(3) + }) + + cy.window().then((w) => { + // Only PERMISSION_DENIED matters here; other errors are ignored. + for (const id of Object.values(ids)) { + const r = lastToolResult(w, id) + expect(r, `result for ${id}`).to.exist + if (r.isError) { + expect(r.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + } + }) + }) + + it("read-only: DQL granted, DDL/DML denied via /validate classification", () => { + setupPaired({ grantSchemaAccess: true, read: true, write: false }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT * FROM t", + limit: 10, + }) + ids.dml = win.__mcpFakeWS.toolCall("run_query", { + sql: "INSERT INTO t VALUES (1)", + limit: 10, + }) + ids.ddl = win.__mcpFakeWS.toolCall("run_query", { + sql: "CREATE TABLE t (a INT)", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(4) + }) + + cy.window().then((w) => { + const dml = lastToolResult(w, ids.dml) + expect(dml.isError).to.equal(true) + expect(dml.content[0].text).to.match(/PERMISSION_DENIED/) + expect(dml.content[0].text).to.match(/'write' permission/) + + const ddl = lastToolResult(w, ids.ddl) + expect(ddl.isError).to.equal(true) + expect(ddl.content[0].text).to.match(/PERMISSION_DENIED/) + + const schema = lastToolResult(w, ids.schema) + if (schema.isError) { + expect(schema.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + const dql = lastToolResult(w, ids.dql) + if (dql.isError) { + expect(dql.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + }) + }) + + it("no permissions: even DQL + schema reads are denied", () => { + setupPaired({ grantSchemaAccess: false, read: false, write: false }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT 1", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(2) + }) + + cy.window().then((w) => { + const dql = lastToolResult(w, ids.dql) + expect(dql.isError).to.equal(true) + expect(dql.content[0].text).to.match(/PERMISSION_DENIED/) + + const schema = lastToolResult(w, ids.schema) + expect(schema.isError).to.equal(true) + expect(schema.content[0].text).to.match(/PERMISSION_DENIED/) + expect(schema.content[0].text).to.match(/'grantSchemaAccess'/) + }) + }) + }) + + describe("popover: submit-only persistence", () => { + it("toggling Write off without clicking Apply does NOT change localStorage", () => { + loginAndVisitDeepLink() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("mcp-pair-popover").should("be.visible") + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: true }) + }) + + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("mcp-pair-cancel").click() + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: true }) + }) + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("permission-write").should("be.checked") + }) + + it("toggle Write off + Apply: localStorage updates, no reconnect", () => { + loginAndVisitDeepLink() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("mcp-pair-submit").should("contain", "Connect").click() + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: false }) + expect(win.__mcpFakeWS.framesOfType("hello")).to.have.length(1) + }) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + }) + }) + + describe("persistence across refresh", () => { + it("consented pair auto-restores; new hello carries the previously-committed permissions", () => { + loginAndVisitDeepLink() + cy.getByDataHook("permission-write").uncheck() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + // cy.reload doesn't accept onBeforeLoad — re-visit so the fake WS reinstalls. + cy.visit(baseUrl, { + onBeforeLoad: (win) => installFakeWebSocket(win), + }) + + cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist") + cy.window({ timeout: 10000 }).should((win) => { + expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan( + 0, + ) + }) + cy.window().then((win) => { + const hello = win.__mcpFakeWS.framesOfType("hello")[0] + expect(hello.permissions).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: false, + }) + win.__mcpFakeWS.helloAck() + }) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + }) + }) +}) diff --git a/package.json b/package.json index 5bf034a08..ba2662cab 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "@styled-icons/remix-editor": "^10.46.0", "@styled-icons/remix-fill": "10.46.0", "@styled-icons/remix-line": "10.46.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.23", "allotment": "^1.19.3", "animate.css": "3.7.2", "bowser": "^2.14.1", @@ -71,7 +73,8 @@ "dexie-react-hooks": "^4.2.0", "dotenv": "^10.0.0", "draggabilly": "^3.0.0", - "echarts": "^5.2.2", + "echarts": "^5.6.0", + "echarts-for-react": "^3.0.6", "eventemitter3": "^5.0.1", "fflate": "^0.8.2", "intersection-observer": "^0.12.2", @@ -90,6 +93,7 @@ "react": "17.0.2", "react-calendar": "^4.0.0", "react-dom": "17.0.2", + "react-grid-layout": "^2.2.3", "react-highlight-words": "^0.20.0", "react-hook-form": "^7.56.0", "react-is": "^18.1.0", @@ -123,13 +127,13 @@ "@styled-icons/styled-icon": "^10.7.0", "@types/babel__core": "^7", "@types/draggabilly": "^2.1.6", - "@types/echarts": "^4.9.22", "@types/jquery": "3.5.1", "@types/lodash.merge": "^4.6.9", "@types/node": "^18.0.0", "@types/ramda": "0.27.40", "@types/react": "17.0.2", "@types/react-dom": "17.0.2", + "@types/react-grid-layout": "^2.1.0", "@types/react-highlight-words": "^0.16.7", "@types/react-redux": "7.1.9", "@types/react-transition-group": "4.4.0", diff --git a/public/assets/icon-notebook.svg b/public/assets/icon-notebook.svg new file mode 100644 index 000000000..61f8e88bc --- /dev/null +++ b/public/assets/icon-notebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 8253d8f29..cf1c204b8 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -3,29 +3,28 @@ import styled from "styled-components" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" const StyledContent = styled(ContextMenuPrimitive.Content)` - background-color: #343846; /* vscode-menu-background */ + background-color: ${({ theme }) => theme.color.backgroundDarker}; border-radius: 0.5rem; padding: 0.4rem; - box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); /* vscode-widget-shadow */ + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); z-index: 9999; - min-width: 160px; + min-width: 16rem; ` const StyledItem = styled(ContextMenuPrimitive.Item)` - font-size: 1.3rem; - height: 3rem; + font-size: 1.4rem; font-family: "system-ui", sans-serif; cursor: pointer; - color: rgb(248, 248, 242); /* vscode-menu-foreground */ + color: ${({ theme }) => theme.color.foreground}; display: flex; + gap: 1rem; + min-height: 3rem; align-items: center; - padding: 1rem 1.2rem; + padding: 0.5rem 1rem; border-radius: 0.4rem; - border: 1px solid transparent; &[data-highlighted] { - background: #043c5c; - border: 1px solid #8be9fd; + background: ${({ theme }) => theme.color.tableSelection}; } &[data-disabled] { @@ -35,7 +34,6 @@ const StyledItem = styled(ContextMenuPrimitive.Item)` ` const IconWrapper = styled.span` - margin-right: 1.2rem; display: flex; align-items: center; justify-content: center; diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx index 1ad8ff249..26876c7bb 100644 --- a/src/components/CopyButton/index.tsx +++ b/src/components/CopyButton/index.tsx @@ -44,6 +44,7 @@ export const CopyButton = ({ skin="secondary" size={size} data-hook="copy-value" + data-copied={copied || undefined} title="Copy to clipboard" onClick={(e: React.MouseEvent) => { void copyToClipboard(text) @@ -57,9 +58,7 @@ export const CopyButton = ({ })} {...props} > - {copied && ( - - )} + {copied && } {iconOnly ? : "Copy"} ) diff --git a/src/components/DropdownMenu/index.tsx b/src/components/DropdownMenu/index.tsx index 63022fa63..29a11ce62 100644 --- a/src/components/DropdownMenu/index.tsx +++ b/src/components/DropdownMenu/index.tsx @@ -11,13 +11,12 @@ export const DropdownMenu = { Portal: styled(RadixDropdownMenu.Portal)``, Content: styled(RadixDropdownMenu.Content)` - display: grid; - gap: 0.2rem; - min-width: 22rem; - background: ${({ theme }) => theme.color.backgroundLighter}; - border-radius: ${({ theme }) => theme.borderRadius}; - box-shadow: 0 5px 5px 0 ${({ theme }) => theme.color.black40}; - padding: 0.5rem 0; + background-color: ${({ theme }) => theme.color.backgroundDarker}; + border-radius: 0.5rem; + padding: 0.4rem; + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); + z-index: 9999; + min-width: 16rem; `, Arrow: styled(RadixDropdownMenu.Arrow)` @@ -25,29 +24,31 @@ export const DropdownMenu = { `, Item: styled(RadixDropdownMenu.Item)` - border-radius: 3px; + font-size: 1.4rem; + cursor: pointer; + color: ${({ theme }) => theme.color.foreground}; display: flex; - gap: 1.5rem; + gap: 1rem; + min-height: 3rem; align-items: center; padding: 0.5rem 1rem; - margin: 0 0.5rem; + border-radius: 0.4rem; user-select: none; outline: none; - &[data-disabled] { - pointer-events: none; - opacity: 0.8; + &[data-highlighted] { + background: ${({ theme }) => theme.color.tableSelection}; } - &:focus { - background: ${({ theme }) => theme.color.comment}; - cursor: pointer; + &[data-disabled] { + opacity: 0.5; + pointer-events: none; } `, Divider: styled.div` height: 1px; background: ${({ theme }) => theme.color.selection}; - margin: 0.5rem 0; + margin: 0.3rem 0; `, } diff --git a/src/components/MultiSelect/index.tsx b/src/components/MultiSelect/index.tsx new file mode 100644 index 000000000..f77c386e9 --- /dev/null +++ b/src/components/MultiSelect/index.tsx @@ -0,0 +1,203 @@ +import React from "react" +import styled from "styled-components" +import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu" +import { ArrowDropDown } from "@styled-icons/remix-line" +import { Check } from "@phosphor-icons/react" + +export type MultiSelectOption = { + label: string + value: string +} + +type Props = { + options: MultiSelectOption[] + value: string[] + onChange: (next: string[]) => void + name?: string + placeholder?: string + disabled?: boolean + className?: string + // Above this count, trigger shows "X of Y" instead of comma-joined labels. + inlineThreshold?: number +} + +const Trigger = styled(RadixDropdownMenu.Trigger)` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + background: ${({ theme }) => theme.color.selection}; + border: 1px transparent solid; + padding: 0 0 0 0.75rem; + height: 3rem; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + cursor: pointer; + width: 100%; + font-family: inherit; + font-size: ${({ theme }) => theme.fontSize.md}; + + &:focus, + &[data-state="open"] { + border-color: ${({ theme }) => theme.color.pink}; + outline: none; + } + + &:disabled { + cursor: default; + color: ${({ theme }) => theme.color.gray1}; + border-color: ${({ theme }) => theme.color.gray1}; + } +` + +const TriggerLabel = styled.span` + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const Caret = styled(ArrowDropDown)` + flex-shrink: 0; + fill: ${({ theme }) => theme.color.white}; +` + +const Content = styled(RadixDropdownMenu.Content)` + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.selection}; + border-radius: 0.4rem; + padding: 0.4rem; + z-index: 9999; + min-width: var(--radix-dropdown-menu-trigger-width); + max-height: 30rem; + overflow-y: auto; + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); +` + +const Item = styled(RadixDropdownMenu.CheckboxItem)` + font-size: ${({ theme }) => theme.fontSize.md}; + color: ${({ theme }) => theme.color.foreground}; + display: flex; + align-items: center; + gap: 0.8rem; + min-height: 2.8rem; + padding: 0.4rem 0.8rem; + border-radius: 0.3rem; + user-select: none; + outline: none; + cursor: pointer; + + &[data-highlighted] { + background: ${({ theme }) => theme.color.tableSelection}; + } + + &[data-disabled] { + opacity: 0.5; + pointer-events: none; + } +` + +const CheckBox = styled.span<{ $checked: boolean }>` + width: 1.4rem; + height: 1.4rem; + border-radius: 0.2rem; + border: 1px solid + ${({ theme, $checked }) => + $checked ? theme.color.pinkPrimary : theme.color.gray1}; + background: ${({ theme, $checked }) => + $checked ? theme.color.pinkPrimary : "transparent"}; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${({ theme }) => theme.color.foreground}; +` + +const Empty = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.color.gray2}; + padding: 0.6rem 0.8rem; +` + +const summarize = ( + value: string[], + options: MultiSelectOption[], + placeholder: string, + inlineThreshold: number, +): string => { + if (value.length === 0) return placeholder + if (value.length === options.length && options.length > 0) + return `All (${options.length})` + if (value.length <= inlineThreshold) { + return value + .map((v) => options.find((o) => o.value === v)?.label ?? v) + .join(", ") + } + return `${value.length} of ${options.length}` +} + +export const MultiSelect: React.FC = ({ + options, + value, + onChange, + name, + placeholder = "None selected", + disabled, + className, + inlineThreshold = 2, +}) => { + const summary = summarize(value, options, placeholder, inlineThreshold) + const selected = new Set(value) + + const toggle = (v: string) => { + // Preserve option order so persisted selections stay stable across toggles. + if (selected.has(v)) { + onChange(value.filter((x) => x !== v)) + } else { + onChange( + options + .filter((o) => selected.has(o.value) || o.value === v) + .map((o) => o.value), + ) + } + } + + return ( + + + {summary} + + + + + {options.length === 0 ? ( + No options + ) : ( + options.map((opt) => { + const checked = selected.has(opt.value) + return ( + toggle(opt.value)} + onSelect={(e) => e.preventDefault()} + > + + {checked && } + + {opt.label} + + ) + }) + )} + + + + ) +} diff --git a/src/components/SetupAIAssistant/ConfigurationModal.tsx b/src/components/SetupAIAssistant/ConfigurationModal.tsx index 31c743fc3..d533f39a9 100644 --- a/src/components/SetupAIAssistant/ConfigurationModal.tsx +++ b/src/components/SetupAIAssistant/ConfigurationModal.tsx @@ -5,7 +5,6 @@ import { MultiStepModal, Step } from "../MultiStepModal" import { Box } from "../Box" import { Input } from "../Input" import { Switch } from "../Switch" -import { Checkbox } from "../Checkbox" import { Text } from "../Text" import { useLocalStorage } from "../../providers/LocalStorageProvider" import { testApiKey } from "../../utils/aiAssistant" @@ -20,6 +19,8 @@ import { type ProviderId, getProviderName, } from "../../utils/ai" +import { PermissionsSection } from "../../scenes/Footer/MCPBridgeStatus/PermissionsSection" +import type { Permissions } from "../../utils/tools/permissions" import { useModalNavigation } from "../MultiStepModal" import { OpenAIIcon } from "./OpenAIIcon" import { AnthropicIcon } from "./AnthropicIcon" @@ -217,11 +218,6 @@ const ModelList = styled(Box).attrs({ flexDirection: "column", gap: "1.2rem" })` width: 100%; ` -const StyledCheckbox = styled(Checkbox)` - font-size: 1.4rem; - display: inline; -` - const FormGroup = styled(Box).attrs({ flexDirection: "column", gap: "1.6rem", @@ -303,77 +299,6 @@ const ModelNameText = styled(Text)` color: ${({ theme }) => theme.color.foreground}; ` -const SchemaAccessSection = styled(Box).attrs({ - flexDirection: "column", - gap: "1.6rem", -})` - width: 100%; -` - -const SchemaAccessHeader = styled(Box).attrs({ - justifyContent: "space-between", - align: "center", - gap: "1rem", -})` - width: 100%; -` - -const SchemaAccessTitle = styled(Text)` - font-size: 1.6rem; - font-weight: 600; - color: ${({ theme }) => theme.color.gray2}; - flex: 1; -` - -const SchemaCheckboxContainer = styled(Box).attrs({ - gap: "1.5rem", - align: "flex-start", -})` - background: rgba(68, 71, 90, 0.56); - padding: 0.75rem; - border-radius: 0.4rem; - width: 100%; -` - -const SchemaCheckboxInner = styled(Box).attrs({ - gap: "1.5rem", - align: "center", -})` - flex: 1; - padding: 0.75rem; - border-radius: 0.5rem; -` - -const SchemaCheckboxWrapper = styled.div` - flex-shrink: 0; - display: flex; - align-items: center; -` - -const SchemaCheckboxContent = styled(Box).attrs({ - flexDirection: "column", - gap: "0.6rem", -})` - flex: 1; -` - -const SchemaCheckboxLabel = styled(Text)` - font-size: 1.4rem; - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - -const SchemaCheckboxDescription = styled(Text)` - font-size: 1.3rem; - font-weight: 400; - color: ${({ theme }) => theme.color.gray2}; -` - -const SchemaCheckboxDescriptionBold = styled.span` - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - const WarningText = styled(Text)` font-size: 1.3rem; font-weight: 400; @@ -427,10 +352,17 @@ type StepOneContentProps = { type StepTwoContentProps = { selectedProvider: ProviderId | null enabledModels: string[] - grantSchemaAccess: boolean + permissions: Permissions modelsByProvider: Record onModelToggle: (modelValue: string) => void - onSchemaAccessChange: (checked: boolean) => void + onPermissionsChange: (next: Permissions) => void +} + +// New scopes default to denied; schema access stays on for back-compat. +const DEFAULT_PERMISSIONS: Permissions = { + grantSchemaAccess: true, + read: false, + write: false, } const CloseButton = ({ onClick }: { onClick: () => void }) => { @@ -565,10 +497,10 @@ const StepOneContent = ({ const StepTwoContent = ({ selectedProvider, enabledModels, - grantSchemaAccess, + permissions, modelsByProvider, onModelToggle, - onSchemaAccessChange, + onPermissionsChange, }: StepTwoContentProps) => { const navigation = useModalNavigation() const handleClose: () => void = navigation.handleClose @@ -656,37 +588,11 @@ const StepTwoContent = ({ {currentProvider && ( - - - Schema Access - - - - - onSchemaAccessChange(e.target.checked)} - data-hook="ai-settings-schema-access" - /> - - - - Grant schema access to {getProviderName(currentProvider)} - - - When enabled, the AI assistant can access your database - schema information to provide more accurate suggestions and - explanations. Schema information helps the AI understand - your table structures, column names, and relationships.{" "} - - The AI model will not have access to your database store. - - - - - - + )} @@ -721,7 +627,8 @@ export const ConfigurationModal = ({ }, [open]) const [enabledModels, setEnabledModels] = useState([]) - const [grantSchemaAccess, setGrantSchemaAccess] = useState(true) + const [permissions, setPermissions] = + useState(DEFAULT_PERMISSIONS) const modelsByProvider = useMemo(() => { const result: Record = {} @@ -754,8 +661,8 @@ export const ConfigurationModal = ({ }) }, []) - const handleSchemaAccessChange = useCallback((checked: boolean) => { - setGrantSchemaAccess(checked) + const handlePermissionsChange = useCallback((next: Permissions) => { + setPermissions(next) }, []) const handleComplete = () => { @@ -763,7 +670,9 @@ export const ConfigurationModal = ({ void trackEvent(ConsoleEvent.AI_PROVIDER_CONFIGURE, { name: selectedProvider, - grantSchemaAccess, + grantSchemaAccess: permissions.grantSchemaAccess, + read: permissions.read, + write: permissions.write, }) const selectedModel = @@ -781,7 +690,9 @@ export const ConfigurationModal = ({ [selectedProvider]: { apiKey, enabledModels, - grantSchemaAccess, + grantSchemaAccess: permissions.grantSchemaAccess, + read: permissions.read, + write: permissions.write, }, }, } @@ -855,7 +766,7 @@ export const ConfigurationModal = ({ // When going back from step 2 to step 1, reset step 2 state but keep API key if (newStepIndex === 0 && direction === "previous") { setEnabledModels([]) - setGrantSchemaAccess(true) + setPermissions(DEFAULT_PERMISSIONS) } }, [], @@ -866,7 +777,7 @@ export const ConfigurationModal = ({ setApiKey("") setError(null) setEnabledModels([]) - setGrantSchemaAccess(true) + setPermissions(DEFAULT_PERMISSIONS) }, []) const handleCustomProviderSave = useCallback( @@ -888,6 +799,8 @@ export const ConfigurationModal = ({ apiKey: definition.apiKey ?? "", enabledModels: newEnabledModels, grantSchemaAccess: definition.grantSchemaAccess ?? false, + read: definition.read ?? false, + write: definition.write ?? false, }, }, } @@ -895,6 +808,8 @@ export const ConfigurationModal = ({ void trackEvent(ConsoleEvent.AI_PROVIDER_CONFIGURE, { name: "custom", grantSchemaAccess: definition.grantSchemaAccess ?? false, + read: definition.read ?? false, + write: definition.write ?? false, type: definition.type, contextWindow: definition.contextWindow, }) @@ -934,10 +849,10 @@ export const ConfigurationModal = ({ ), validate: validateStepTwo, @@ -951,10 +866,10 @@ export const ConfigurationModal = ({ handleProviderSelect, handleApiKeyChange, enabledModels, - grantSchemaAccess, + permissions, modelsByProvider, handleModelToggle, - handleSchemaAccessChange, + handlePermissionsChange, validateStepOne, validateStepTwo, ], diff --git a/src/components/SetupAIAssistant/CustomProviderModal.tsx b/src/components/SetupAIAssistant/CustomProviderModal.tsx index 6d4b686f3..ae655ca38 100644 --- a/src/components/SetupAIAssistant/CustomProviderModal.tsx +++ b/src/components/SetupAIAssistant/CustomProviderModal.tsx @@ -326,7 +326,9 @@ export const CustomProviderModal = ({ apiKey: apiKey || undefined, contextWindow: values.contextWindow, models: values.models, - grantSchemaAccess: values.grantSchemaAccess, + grantSchemaAccess: values.permissions.grantSchemaAccess, + read: values.permissions.read, + write: values.permissions.write, } onSave(providerId, definition) @@ -373,7 +375,6 @@ export const CustomProviderModal = ({ baseURL, }} renderSchemaAccess - providerName={name || "this provider"} /> ), diff --git a/src/components/SetupAIAssistant/ManageModelsModal.tsx b/src/components/SetupAIAssistant/ManageModelsModal.tsx index c4cf606c9..5b3917b35 100644 --- a/src/components/SetupAIAssistant/ManageModelsModal.tsx +++ b/src/components/SetupAIAssistant/ManageModelsModal.tsx @@ -130,9 +130,12 @@ export const ManageModelsModal = ({ initialValues={{ models: definition.models, contextWindow: definition.contextWindow, - grantSchemaAccess: definition.grantSchemaAccess, + permissions: { + grantSchemaAccess: definition.grantSchemaAccess, + read: definition.read, + write: definition.write, + }, }} - providerName={definition.name} onLoadingChange={setModelsLoading} /> )} diff --git a/src/components/SetupAIAssistant/ModelSettings.tsx b/src/components/SetupAIAssistant/ModelSettings.tsx index 2b05b9ae7..74493ab75 100644 --- a/src/components/SetupAIAssistant/ModelSettings.tsx +++ b/src/components/SetupAIAssistant/ModelSettings.tsx @@ -15,6 +15,8 @@ import { LoadingSpinner } from "../LoadingSpinner" import { WarningIcon, XIcon } from "@phosphor-icons/react" import { createProviderByType } from "../../utils/ai/registry" import type { ProviderType } from "../../utils/ai/settings" +import { PermissionsSection } from "../../scenes/Footer/MCPBridgeStatus/PermissionsSection" +import type { Permissions } from "../../utils/tools/permissions" export const InputSection = styled(Box).attrs({ flexDirection: "column", @@ -175,70 +177,6 @@ const SelectAllLink = styled.button` } ` -const SchemaAccessSection = styled(Box).attrs({ - flexDirection: "column", - gap: "1.6rem", - align: "flex-start", -})` - width: 100%; -` - -const SchemaAccessTitle = styled(Text)` - font-size: 1.6rem; - font-weight: 600; - color: ${({ theme }) => theme.color.gray2}; - flex: 1; -` - -const SchemaCheckboxContainer = styled(Box).attrs({ - gap: "1.5rem", - align: "flex-start", -})` - background: rgba(68, 71, 90, 0.56); - padding: 0.75rem; - border-radius: 0.4rem; - width: 100%; -` - -const SchemaCheckboxInner = styled(Box).attrs({ - gap: "1.5rem", - align: "center", -})` - flex: 1; - padding: 0.75rem; - border-radius: 0.5rem; -` - -const SchemaCheckboxWrapper = styled.div` - flex-shrink: 0; - display: flex; - align-items: center; -` - -const SchemaCheckboxContent = styled(Box).attrs({ - flexDirection: "column", - gap: "0.6rem", -})` - flex: 1; -` - -const SchemaCheckboxLabel = styled(Text)` - font-size: 1.4rem; - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - -const SchemaCheckboxDescription = styled(Text)` - font-size: 1.3rem; - font-weight: 400; - color: ${({ theme }) => theme.color.gray2}; -` - -const SchemaCheckboxDescriptionBold = styled.span` - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - const ContentSection = styled(Box).attrs({ flexDirection: "column", gap: "2rem", @@ -271,13 +209,14 @@ export type FetchConfig = { export type ModelSettingsInitialValues = { models?: string[] contextWindow?: number - grantSchemaAccess?: boolean + // Missing keys fall back to: schema=true, read=false, write=false. + permissions?: Partial } export type ModelSettingsData = { models: string[] contextWindow: number - grantSchemaAccess: boolean + permissions: Permissions } export type ModelSettingsRef = { @@ -289,7 +228,6 @@ export type ModelSettingsProps = { initialValues?: ModelSettingsInitialValues fetchConfig: FetchConfig renderSchemaAccess?: boolean - providerName?: string onLoadingChange?: (loading: boolean) => void } @@ -313,13 +251,7 @@ async function fetchProviderModels( export const ModelSettings = forwardRef( ( - { - initialValues, - fetchConfig, - renderSchemaAccess, - providerName, - onLoadingChange, - }, + { initialValues, fetchConfig, renderSchemaAccess, onLoadingChange }, ref, ) => { const theme = useTheme() @@ -333,9 +265,11 @@ export const ModelSettings = forwardRef( const [contextWindowInput, setContextWindowInput] = useState(() => String(initialValues?.contextWindow ?? 200_000), ) - const [grantSchemaAccess, setGrantSchemaAccess] = useState( - () => initialValues?.grantSchemaAccess ?? true, - ) + const [permissions, setPermissions] = useState(() => ({ + grantSchemaAccess: initialValues?.permissions?.grantSchemaAccess ?? true, + read: initialValues?.permissions?.read ?? false, + write: initialValues?.permissions?.write ?? false, + })) const [isLoading, setIsLoading] = useState(true) const fetchConfigRef = useRef(fetchConfig) @@ -450,7 +384,7 @@ export const ModelSettings = forwardRef( } const contextWindow = Number(contextWindowInput) || 0 - return { models, contextWindow, grantSchemaAccess } + return { models, contextWindow, permissions } }, validate: () => { const pending = manualModelInput.trim() @@ -473,7 +407,7 @@ export const ModelSettings = forwardRef( selectedModels, manualModels, contextWindowInput, - grantSchemaAccess, + permissions, ], ) @@ -638,35 +572,11 @@ export const ModelSettings = forwardRef( <> - - Schema Access - - - - setGrantSchemaAccess(e.target.checked)} - /> - - - - Grant schema access to {providerName || "this provider"} - - - When enabled, the AI assistant can access your database - schema information to provide more accurate suggestions - and explanations. Schema information helps the AI - understand your table structures, column names, and - relationships.{" "} - - The AI model will not have access to your data. - - - - - - + )} diff --git a/src/components/SetupAIAssistant/SettingsModal.tsx b/src/components/SetupAIAssistant/SettingsModal.tsx index db6df70d3..bd0b59a2b 100644 --- a/src/components/SetupAIAssistant/SettingsModal.tsx +++ b/src/components/SetupAIAssistant/SettingsModal.tsx @@ -5,7 +5,6 @@ import { Dialog } from "../Dialog" import { Box } from "../Box" import { Input } from "../Input" import { Switch } from "../Switch" -import { Checkbox } from "../Checkbox" import { Text } from "../Text" import { Button } from "../Button" import { useLocalStorage } from "../../providers/LocalStorageProvider" @@ -34,6 +33,8 @@ import type { AiAssistantSettings, CustomProviderDefinition, } from "../../providers/LocalStorageProvider/types" +import { PermissionsSection } from "../../scenes/Footer/MCPBridgeStatus/PermissionsSection" +import type { Permissions } from "../../utils/tools/permissions" import { ForwardRef } from "../ForwardRef" import { Badge, BadgeType } from "../../components/Badge" import { CheckboxCircle } from "@styled-icons/remix-fill" @@ -417,77 +418,6 @@ const ManageModelsButton = styled.button` } ` -const SchemaAccessSection = styled(Box).attrs({ - flexDirection: "column", - gap: "1.6rem", -})` - width: 100%; -` - -const SchemaAccessHeader = styled(Box).attrs({ - justifyContent: "space-between", - align: "center", - gap: "1rem", -})` - width: 100%; -` - -const SchemaAccessTitle = styled(Text)` - font-size: 1.6rem; - font-weight: 600; - color: ${({ theme }) => theme.color.foreground}; - flex: 1; -` - -const SchemaCheckboxContainer = styled(Box).attrs({ - gap: "1.5rem", - align: "flex-start", -})` - background: rgba(68, 71, 90, 0.56); - padding: 0.75rem; - border-radius: 0.4rem; - width: 100%; -` - -const SchemaCheckboxInner = styled(Box).attrs({ - gap: "1.5rem", - align: "center", -})` - flex: 1; - padding: 0.75rem; - border-radius: 0.5rem; -` - -const SchemaCheckboxWrapper = styled.div` - flex-shrink: 0; - display: flex; - align-items: center; -` - -const SchemaCheckboxContent = styled(Box).attrs({ - flexDirection: "column", - gap: "0.6rem", -})` - flex: 1; -` - -const SchemaCheckboxLabel = styled(Text)` - font-size: 1.4rem; - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - -const SchemaCheckboxDescription = styled(Text)` - font-size: 1.3rem; - font-weight: 400; - color: ${({ theme }) => theme.color.gray2}; -` - -const SchemaCheckboxDescriptionBold = styled.span` - font-weight: 500; - color: ${({ theme }) => theme.color.foreground}; -` - const RemoveProviderButton = styled(Button)` border: 0.1rem solid ${({ theme }) => theme.color.red}; background: ${({ theme }) => theme.color.backgroundDarker}; @@ -621,16 +551,25 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { [], ), ) - const [grantSchemaAccess, setGrantSchemaAccess] = useState< - Record + const [permissions, setPermissions] = useState< + Record >(() => - initializeProviderState((provider) => { - const providerSettings = aiAssistantSettings.providers?.[provider] - if (providerSettings) return providerSettings.grantSchemaAccess !== false - const custom = aiAssistantSettings.customProviders?.[provider] - if (custom) return custom.grantSchemaAccess !== false - return true - }, true), + initializeProviderState( + (provider) => { + const providerSettings = aiAssistantSettings.providers?.[provider] + const custom = aiAssistantSettings.customProviders?.[provider] + const source = providerSettings ?? custom + if (!source) { + return { grantSchemaAccess: true, read: false, write: false } + } + return { + grantSchemaAccess: source.grantSchemaAccess !== false, + read: source.read === true, + write: source.write === true, + } + }, + { grantSchemaAccess: true, read: false, write: false }, + ), ) const [validatedApiKeys, setValidatedApiKeys] = useState< Record @@ -766,12 +705,17 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { [], ) - const handleSchemaAccessChange = useCallback( - (provider: ProviderId, checked: boolean) => { - if (!checked) { - void trackEvent(ConsoleEvent.AI_SETTINGS_SCHEMA_ACCESS_REMOVE) - } - setGrantSchemaAccess((prev) => ({ ...prev, [provider]: checked })) + // Emit the legacy schema-access-removed event on grantSchemaAccess → false + // so existing dashboards keep working. + const handlePermissionsChange = useCallback( + (provider: ProviderId, next: Permissions) => { + setPermissions((prev) => { + const prior = prev[provider] + if (prior?.grantSchemaAccess && !next.grantSchemaAccess) { + void trackEvent(ConsoleEvent.AI_SETTINGS_SCHEMA_ACCESS_REMOVE) + } + return { ...prev, [provider]: next } + }) }, [], ) @@ -783,10 +727,13 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { for (const provider of allProviderIds) { const isCustom = !BUILTIN_PROVIDERS[provider] if (validatedApiKeys[provider] || isCustom) { + const perms = permissions[provider] updatedProviders[provider] = { apiKey: apiKeys[provider] ?? "", enabledModels: enabledModels[provider], - grantSchemaAccess: grantSchemaAccess[provider], + grantSchemaAccess: perms.grantSchemaAccess, + read: perms.read, + write: perms.write, } } else { delete updatedProviders[provider] @@ -800,17 +747,19 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { } } - // Sync API keys and schema access into custom provider definitions const updatedCustomProviders = Object.keys(localCustomProviders).length > 0 ? { ...localCustomProviders } : undefined if (updatedCustomProviders) { for (const provider of Object.keys(updatedCustomProviders)) { + const perms = permissions[provider] updatedCustomProviders[provider] = { ...updatedCustomProviders[provider], apiKey: apiKeys[provider] || undefined, - grantSchemaAccess: grantSchemaAccess[provider], + grantSchemaAccess: perms.grantSchemaAccess, + read: perms.read, + write: perms.write, } } } @@ -837,7 +786,7 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { localCustomProviders, apiKeys, enabledModels, - grantSchemaAccess, + permissions, validatedApiKeys, updateSettings, onOpenChange, @@ -862,7 +811,10 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { } setApiKeys((prev) => ({ ...prev, [providerId]: "" })) - setGrantSchemaAccess((prev) => ({ ...prev, [providerId]: false })) + setPermissions((prev) => ({ + ...prev, + [providerId]: { grantSchemaAccess: false, read: false, write: false }, + })) setValidatedApiKeys((prev) => ({ ...prev, [providerId]: false })) setValidationState((prev) => ({ ...prev, [providerId]: "idle" })) setValidationErrors((prev) => ({ ...prev, [providerId]: null })) @@ -899,9 +851,13 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { ...prev, [providerId]: definition.apiKey ?? "", })) - setGrantSchemaAccess((prev) => ({ + setPermissions((prev) => ({ ...prev, - [providerId]: definition.grantSchemaAccess ?? false, + [providerId]: { + grantSchemaAccess: definition.grantSchemaAccess ?? false, + read: definition.read ?? false, + write: definition.write ?? false, + }, })) setValidatedApiKeys((prev) => ({ ...prev, @@ -922,6 +878,8 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { apiKey: definition.apiKey ?? "", enabledModels: newEnabledModels, grantSchemaAccess: definition.grantSchemaAccess ?? false, + read: definition.read ?? false, + write: definition.write ?? false, }, } updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, { @@ -1341,53 +1299,23 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { - - - Schema Access - - - - - - handleSchemaAccessChange( - selectedProvider, - e.target.checked, - ) - } - disabled={ - !currentProviderValidated && - !( - isCustomProvider && - modelsForProvider.length > 0 - ) - } - data-hook="ai-settings-schema-access" - /> - - - - Grant schema access to{" "} - {getProviderName(selectedProvider, localSettings)} - - - When enabled, the AI assistant can access your - database schema information to provide more - accurate suggestions and explanations. Schema - information helps the AI understand your table - structures, column names, and relationships.{" "} - - The AI model will not have access to your data. - - - - - - + + handlePermissionsChange(selectedProvider, next) + } + disabled={ + !currentProviderValidated && + !(isCustomProvider && modelsForProvider.length > 0) + } + variant="rich" + /> prefix)." + } + }, + "required": [ + "buffer_id" + ] + } + }, + { + "name": "get_cell", + "category": "free", + "description": "Get full details of a cell (value up to 4 KB, UI flags, chart config, last-run status + trimmed error). Never includes query result data.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "get_notebook_state", + "category": "free", + "description": "Full structural snapshot of a notebook (layout, cells with previews, last-run statuses). No cell data values; no columns/rows/count.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + } + }, + "required": [ + "buffer_id" + ] + } + }, + { + "name": "add_cell", + "category": "free", + "mutatesNotebook": true, + "description": "Append a cell to the notebook. Returns only the new cell id and optional run status. You never see query data.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "sql": { + "type": "string", + "description": "SQL for the cell. May contain multiple statements separated by `;`. In draw mode, multi-statement cells overlay series on a single chart: the first query's timestamp column is the x-axis anchor, subsequent queries contribute additional numeric series merged on the time axis." + }, + "after_cell_id": { + "type": [ + "string", + "null" + ], + "description": "Insert after this cell id; pass null to append to the end." + }, + "run": { + "type": [ + "boolean", + "null" + ], + "description": "If true, run the cell immediately after inserting. Response includes success/error status — still no data. Pass null to skip. When the user has not granted the 'write' permission, running a DDL/DML cell is refused; the cell is still added." + } + }, + "required": [ + "buffer_id", + "sql", + "after_cell_id", + "run" + ] + } + }, + { + "name": "update_cell", + "category": "free", + "mutatesNotebook": true, + "description": "Replace a cell's value. Overwrites preemptively — cells are auto-saved. Use to fix a broken SQL cell.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id", + "value" + ] + } + }, + { + "name": "delete_cell", + "category": "free", + "mutatesNotebook": true, + "description": "Delete a cell from the notebook.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "move_cell_up", + "category": "free", + "mutatesNotebook": true, + "description": "Swap a cell with the one above it.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "move_cell_down", + "category": "free", + "mutatesNotebook": true, + "description": "Swap a cell with the one below it.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "duplicate_cell", + "category": "free", + "mutatesNotebook": true, + "description": "Duplicate a cell immediately after the original.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "run_cell", + "category": "sql", + "mutatesNotebook": true, + "description": "Execute a SQL cell. Returns ONLY { success: boolean, error?: string }. You do NOT see columns, rows, or counts — the user reads the result. Use this to trigger execution on the user's behalf, not to inspect data.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "set_layout_mode", + "category": "free", + "mutatesNotebook": true, + "description": "Switch a notebook between list and grid layouts.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "mode": { + "type": "string", + "enum": [ + "list", + "grid" + ] + } + }, + "required": [ + "buffer_id", + "mode" + ] + } + }, + { + "name": "set_cell_layout", + "category": "free", + "mutatesNotebook": true, + "description": "Position a single cell in grid mode. x/y/w/h are integers in the 12-column react-grid-layout grid (w ≤ 12).", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "w": { + "type": "number" + }, + "h": { + "type": "number" + } + }, + "required": [ + "buffer_id", + "cell_id", + "x", + "y", + "w", + "h" + ] + } + }, + { + "name": "set_cell_mode", + "category": "free", + "mutatesNotebook": true, + "description": "Switch a SQL cell between run (table output) and draw (chart output). Draw cells auto-execute — do not call run_cell afterwards. MULTI-SERIES TIP: a draw-mode cell can hold multiple SELECT statements separated by `;`. The first query's timestamp column is the chart's x-axis (the 'anchor'); every additional query contributes its numeric columns as extra series merged on the time axis. Use this to overlay metrics that come from different tables or different aggregations on a single chart.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "run", + "draw" + ] + } + }, + "required": [ + "buffer_id", + "cell_id", + "mode" + ] + } + }, + { + "name": "set_cell_chart_config", + "category": "free", + "mutatesNotebook": true, + "description": "Configure the chart for a draw-mode cell. `type` is one of line/area/bar/stackedBar/scatter/pie/candlestick. Omitted fields preserve the cell's current values (this is a patch, not a replace). For `candlestick`, supply `ohlc: { open, high, low, close }` with the four column names — or pass `y_columns` of exactly [open, high, low, close] in that order and it will be converted to ohlc automatically. MULTI-QUERY CHARTS: when the cell value contains multiple SELECT statements separated by `;`, the chart treats the FIRST query as the anchor (its timestamp column is the x-axis, its numeric columns are series) and subsequent queries contribute their numeric columns as additional series merged on the time axis. `x_column` should reference an anchor column; `y_columns` may include numeric columns from any query. Series names default to the column names; ensure your queries alias columns distinctly when overlaying similar metrics.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "line", + "area", + "bar", + "stackedBar", + "scatter", + "pie", + "candlestick" + ] + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Chart title. Null preserves the current name (patch semantics)." + }, + "x_column": { + "type": [ + "string", + "null" + ], + "description": "X-axis column; null preserves the current value." + }, + "y_columns": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Y-axis columns; null preserves the current value." + }, + "partition_by_column": { + "type": [ + "string", + "null" + ], + "description": "Categorical column to partition series by; null preserves current." + }, + "ohlc": { + "type": [ + "object", + "null" + ], + "description": "Column mapping for candlestick charts. All four fields required when used; null preserves current.", + "additionalProperties": false, + "properties": { + "open": { + "type": "string" + }, + "high": { + "type": "string" + }, + "low": { + "type": "string" + }, + "close": { + "type": "string" + } + }, + "required": [ + "open", + "high", + "low", + "close" + ] + } + }, + "required": [ + "buffer_id", + "cell_id", + "type", + "name", + "x_column", + "y_columns", + "partition_by_column", + "ohlc" + ] + } + }, + { + "name": "set_cell_autorefresh", + "category": "free", + "mutatesNotebook": true, + "description": "Enable or disable auto-refresh polling for a draw-mode cell's chart.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "buffer_id", + "cell_id", + "value" + ] + } + }, + { + "name": "set_cell_chart_maximized", + "category": "free", + "mutatesNotebook": true, + "description": "Toggle chart-fills-cell (maximized chart) for a draw cell.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "buffer_id", + "cell_id", + "value" + ] + } + }, + { + "name": "apply_notebook_state", + "category": "free", + "mutatesNotebook": true, + "description": "Bulk-apply the entire desired state of a notebook in one atomic call. Use INSTEAD OF chained add_cell + update_cell + set_cell_mode + set_cell_chart_config when composing a multi-cell layout from scratch or restructuring an existing notebook — far faster than per-cell calls. The cells array is the COMPLETE desired list: cells in the current notebook whose id is missing from your request are DELETED. For new cells, omit `id` and one will be generated. Charts in mode='draw' with auto_refresh=true render automatically — do not call run_cell afterwards. Cells with resolved mode='run' (explicit, or omitted: new defaults to 'run', existing preserves) auto-execute after the apply; the response includes a `runs: [{cellId, success, error?}]` array reporting per-cell outcomes (DDL/DML still requires the 'write' permission). Always call get_workspace_state first; the state-freshness gate applies.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "layout_mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "list", + "grid", + null + ], + "description": "Notebook layout mode after this apply. Null preserves current." + }, + "maximized_cell_id": { + "type": [ + "string", + "null" + ], + "description": "Spotlight one cell id, or null to clear. Pass null to clear." + }, + "cells": { + "type": "array", + "description": "Complete desired cell list, in order. Cell at index N gets position N. Missing existing-cell ids are deleted.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "description": "Existing cell id to update, or null/omitted for a new cell." + }, + "value": { + "type": "string", + "description": "SQL text of the cell. May contain multiple statements separated by `;`. In draw mode, this overlays series on one chart: the first query's timestamp is the x-axis anchor; subsequent queries contribute extra numeric series merged on time. Use this to combine metrics from different tables or different aggregations into a single chart." + }, + "mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "run", + "draw", + null + ], + "description": "Cell mode. Null defaults to 'run' for new cells, preserves current for existing." + }, + "auto_refresh": { + "type": [ + "boolean", + "null" + ], + "description": "Auto-refresh polling for draw cells. Null defaults to true when mode='draw'." + }, + "is_chart_maximized": { + "type": [ + "boolean", + "null" + ], + "description": "Chart-fills-cell flag. Null defaults to true when mode='draw'." + }, + "chart_config": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "description": "Chart configuration; required for new draw-mode cells. Null preserves existing.", + "properties": { + "type": { + "type": "string", + "enum": [ + "line", + "area", + "bar", + "stackedBar", + "scatter", + "pie", + "candlestick" + ] + }, + "x_column": { + "type": [ + "string", + "null" + ] + }, + "y_columns": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "partition_by_column": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ohlc": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "open": { + "type": "string" + }, + "high": { + "type": "string" + }, + "low": { + "type": "string" + }, + "close": { + "type": "string" + } + }, + "required": [ + "open", + "high", + "low", + "close" + ] + } + }, + "required": [ + "type", + "x_column", + "y_columns", + "partition_by_column", + "name", + "ohlc" + ] + }, + "grid": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "description": "Grid position when layout_mode='grid'. x/y/w/h in 12-column units (w ≤ 12).", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "w": { + "type": "integer" + }, + "h": { + "type": "integer" + } + }, + "required": [ + "x", + "y", + "w", + "h" + ] + } + }, + "required": [ + "id", + "value", + "mode", + "auto_refresh", + "is_chart_maximized", + "chart_config", + "grid" + ] + } + } + }, + "required": [ + "buffer_id", + "layout_mode", + "maximized_cell_id", + "cells" + ] + } + }, + { + "name": "set_cell_maximized", + "category": "free", + "mutatesNotebook": true, + "description": "Spotlight one cell (or null to restore normal layout). Hides other cells in the notebook view.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": [ + "string", + "null" + ], + "description": "Cell id to spotlight, or null to clear." + } + }, + "required": [ + "buffer_id", + "cell_id" + ] + } + }, + { + "name": "run_query", + "category": "sql", + "description": "Execute an arbitrary SQL statement against the user's QuestDB instance and return the result rows so you can inspect data, validate work, or compose follow-up queries. UNLIKE `run_cell`, this tool DOES return data values. Default limit is 100 rows; pass `limit` (max 1000) to request more. The response is truncated to fit a token budget — individual string cells are capped at ~500 chars and the whole payload at ~50 KB. The response includes `truncated`, `total_count`, and `returned_count` so you know exactly what was clipped. DDL/DML (CREATE / INSERT / UPDATE / DROP / etc.) is allowed and executes against the live database — be deliberate. Auth + connection are handled by the user's already-authenticated browser session.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "sql": { + "type": "string", + "description": "The SQL statement to execute." + }, + "limit": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 1000, + "description": "Maximum rows to return (default 100, max 1000). Pass null for default." + } + }, + "required": [ + "sql", + "limit" + ] + } + }, + { + "name": "get_workspace_state", + "category": "free", + "surfaces": [ + "mcp" + ], + "description": "If notebook tools fail with BRIDGE_NOT_PAIRED, call connect_web_console to begin pairing (the response includes a one-click URL to show the user; authentication runs in the browser, the bridge never sees credentials). Once paired, call get_workspace_state at the start of each notebook turn; the digest of edits since your last fetch is in get_recent_user_actions.\n\nReturn the current workspace + notebook context as text. Use this at the start of every notebook turn so you know which notebook is active, what cells exist, layout mode, chart configs, and last-run statuses. Pass include_user_events=true to also receive the digest of edits the user made since your last fetch.", + "inputSchema": { + "type": "object", + "properties": { + "include_user_events": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + { + "name": "get_recent_user_actions", + "category": "free", + "surfaces": [ + "mcp" + ], + "description": "If notebook tools fail with BRIDGE_NOT_PAIRED, call connect_web_console to begin pairing (the response includes a one-click URL to show the user; authentication runs in the browser, the bridge never sees credentials). Once paired, call get_workspace_state at the start of each notebook turn; the digest of edits since your last fetch is in get_recent_user_actions.\n\nReturn the digest of user edits to the notebook since your last fetch (or session start). Use this to detect that the user changed something the agent might want to react to. Coalesced — multiple typing events on the same cell collapse to a single 'edited' entry.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + } +] diff --git a/src/hooks/useQueryExecution.ts b/src/hooks/useQueryExecution.ts new file mode 100644 index 000000000..0f9d727f6 --- /dev/null +++ b/src/hooks/useQueryExecution.ts @@ -0,0 +1,72 @@ +import { useCallback, useContext } from "react" +import { QuestContext } from "../providers/QuestProvider" +import * as QuestDB from "../utils/questdb" +import type { ColumnDefinition, Timings } from "../utils/questdb/types" + +export type QueryExecResult = { + type: "dql" | "ddl" | "dml" | "error" + query: string + columns: ColumnDefinition[] + dataset: (boolean | string | number | null)[][] + count: number + timings?: Timings + error?: string +} + +export const useQueryExecution = () => { + const { quest } = useContext(QuestContext) + + const executeSingle = useCallback( + async (sql: string, signal?: AbortSignal): Promise => { + try { + const result = await quest.queryRaw(sql, { limit: "0,1000", signal }) + + if (result.type === QuestDB.Type.DQL) { + return { + type: "dql", + query: sql, + columns: result.columns, + dataset: result.dataset, + count: result.count, + timings: result.timings, + } + } + + if (result.type === QuestDB.Type.ERROR) { + return { + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: result.error, + } + } + + return { + type: result.type === QuestDB.Type.DDL ? "ddl" : "dml", + query: sql, + columns: [], + dataset: [], + count: 0, + } + } catch (e: unknown) { + const err = e as Record | null + return { + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: + (err?.error as string) ?? + (err?.message as string) ?? + "Unknown error", + } + } + }, + [quest], + ) + + return { executeSingle } +} diff --git a/src/index.tsx b/src/index.tsx index 1b23eb251..11907f38e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,6 +27,12 @@ import "./js/console" import "./utils/monacoInit" import "./js/console/cryptoPolyfill" +import { consumePendingPairFromUrl } from "./utils/mcp/consumePendingPair" + +// Stash MCP-pair URL params to sessionStorage before any provider mounts; +// AuthProvider can trigger an OIDC redirect that drops them otherwise. +consumePendingPairFromUrl() + import React from "react" import ReactDOM from "react-dom" import { Provider } from "react-redux" diff --git a/src/providers/AIConversationProvider/index.tsx b/src/providers/AIConversationProvider/index.tsx index f4564acff..ccc37cc71 100644 --- a/src/providers/AIConversationProvider/index.tsx +++ b/src/providers/AIConversationProvider/index.tsx @@ -20,7 +20,13 @@ import type { ChatWindowState, ConversationId, AIConversation, + UserActionDigest, } from "./types" +import { applyUserActionToDigest, createEmptyDigest } from "./userActionDigest" +import { + on as onUserAction, + type UserActionEvent, +} from "../../utils/notebookAIBridge" import type { QueryKey } from "../../scenes/Editor/Monaco/utils" import { normalizeQueryText, @@ -69,7 +75,23 @@ type AIConversationContextType = { bufferId?: number queryKey?: QueryKey tableId?: number + notebookBufferId?: number }) => Promise + findNotebookChat: ( + notebookBufferId: number, + ) => ConversationMetaWithStatus | undefined + findOrCreateNotebookChat: ( + notebookBufferId: number, + ) => Promise + openNotebookChat: (notebookBufferId: number) => Promise + bindConversationToNotebook: ( + conversationId: ConversationId, + notebookBufferId: number, + ) => Promise + readUserActionDigest: ( + conversationId: ConversationId, + ) => UserActionDigest | undefined + clearUserActionEvents: (conversationId: ConversationId) => void handleGlyphClick: (options: { bufferId: number queryKey: QueryKey @@ -163,6 +185,29 @@ export const AIConversationProvider: React.FC<{ [conversationMetasArray], ) + const userActionDigestsRef = useRef>( + new Map(), + ) + const conversationMetasRef = useRef(conversationMetas) + useEffect(() => { + conversationMetasRef.current = conversationMetas + }, [conversationMetas]) + + useEffect(() => { + const off = onUserAction("user-action", (evt: UserActionEvent) => { + for (const meta of conversationMetasRef.current.values()) { + if (meta.notebookBufferId !== evt.bufferId) continue + let digest = userActionDigestsRef.current.get(meta.id) + if (!digest) { + digest = createEmptyDigest() + userActionDigestsRef.current.set(meta.id, digest) + } + applyUserActionToDigest(digest, evt) + } + }) + return off + }, []) + const [activeConversationMessages, setActiveConversationMessages] = useState< ConversationMessage[] >([]) @@ -296,6 +341,7 @@ export const AIConversationProvider: React.FC<{ bufferId?: number queryKey?: QueryKey tableId?: number + notebookBufferId?: number }): Promise => { const id = crypto.randomUUID() const { queryText } = getQueryInfoFromKey(options.queryKey) @@ -304,6 +350,7 @@ export const AIConversationProvider: React.FC<{ queryKey: options.queryKey, bufferId: options.bufferId, tableId: options.tableId, + notebookBufferId: options.notebookBufferId, currentSQL: queryText, conversationName: "AI Assistant", updatedAt: Date.now(), @@ -316,6 +363,61 @@ export const AIConversationProvider: React.FC<{ [], ) + const findNotebookChat = useCallback( + (notebookBufferId: number): ConversationMetaWithStatus | undefined => { + let best: ConversationMetaWithStatus | undefined + for (const meta of conversationMetas.values()) { + if (meta.notebookBufferId !== notebookBufferId) continue + if (!best || meta.updatedAt > best.updatedAt) best = meta + } + return best + }, + [conversationMetas], + ) + + const findOrCreateNotebookChat = useCallback( + async (notebookBufferId: number): Promise => { + const existing = findNotebookChat(notebookBufferId) + if (existing) { + const messages = await aiConversationStore.getMessages(existing.id) + return { ...existing, messages } + } + return createConversation({ notebookBufferId }) + }, + [findNotebookChat, createConversation], + ) + + const bindConversationToNotebook = useCallback( + async ( + conversationId: ConversationId, + notebookBufferId: number, + ): Promise => { + // Fall back to Dexie when ref hasn't picked up a freshly-created meta yet — otherwise bind silently no-ops. + const meta = + conversationMetasRef.current.get(conversationId) ?? + (await aiConversationStore.getMeta(conversationId)) + if (!meta) return + if (meta.notebookBufferId !== undefined) return + await aiConversationStore.updateMeta(conversationId, { + notebookBufferId, + }) + }, + [], + ) + + const readUserActionDigest = useCallback( + (conversationId: ConversationId): UserActionDigest | undefined => + userActionDigestsRef.current.get(conversationId), + [], + ) + + const clearUserActionEvents = useCallback( + (conversationId: ConversationId): void => { + userActionDigestsRef.current.delete(conversationId) + }, + [], + ) + const acceptSuggestionChanges = useCallback( async ( conversationId: ConversationId, @@ -692,6 +794,15 @@ export const AIConversationProvider: React.FC<{ await openChatWindow(blankConversation.id, { loadMessages: false }) }, [createConversation, openChatWindow]) + const openNotebookChat = useCallback( + async (notebookBufferId: number): Promise => { + const conv = await findOrCreateNotebookChat(notebookBufferId) + const isExisting = conversationMetasRef.current.has(conv.id) + await openChatWindow(conv.id, { loadMessages: isExisting }) + }, + [findOrCreateNotebookChat, openChatWindow], + ) + const openHistoryView = useCallback(() => { void trackEvent(ConsoleEvent.AI_HISTORY_OPEN) setChatWindowState((prev) => ({ @@ -1003,6 +1114,12 @@ export const AIConversationProvider: React.FC<{ openChatWindow, openOrCreateBlankChatWindow, openBlankChatWindow, + openNotebookChat, + findNotebookChat, + findOrCreateNotebookChat, + bindConversationToNotebook, + readUserActionDigest, + clearUserActionEvents, closeChatWindow, openHistoryView, closeHistoryView, diff --git a/src/providers/AIConversationProvider/types.ts b/src/providers/AIConversationProvider/types.ts index 2e4276a1b..a8dcb35d7 100644 --- a/src/providers/AIConversationProvider/types.ts +++ b/src/providers/AIConversationProvider/types.ts @@ -65,6 +65,18 @@ export type AIConversation = { bufferId?: number queryKey?: QueryKey currentSQL?: string + // 1:1 notebook binding — persists via Dexie, never auto-cleared. + notebookBufferId?: number +} + +// Coalesced user-action summary since the last assistant turn — see applyUserActionToDigest for fold rules. +export type UserActionDigest = { + added: Set + deleted: Set + edited: Set + ran: Map + layoutModeTo?: "list" | "grid" + notebookStatusChange?: "archived" | "deleted" } export type ChatWindowState = { diff --git a/src/providers/AIConversationProvider/userActionDigest.test.ts b/src/providers/AIConversationProvider/userActionDigest.test.ts new file mode 100644 index 000000000..1a2b7490d --- /dev/null +++ b/src/providers/AIConversationProvider/userActionDigest.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest" +import { + applyUserActionToDigest, + createEmptyDigest, + isEmptyDigest, +} from "./userActionDigest" +import type { UserActionEvent } from "../../utils/notebookAIBridge" + +const apply = (events: UserActionEvent[]) => { + const d = createEmptyDigest() + for (const e of events) applyUserActionToDigest(d, e) + return d +} + +describe("createEmptyDigest / isEmptyDigest", () => { + it("starts empty", () => { + expect(isEmptyDigest(createEmptyDigest())).toBe(true) + }) + + it("is not empty after a single added cell", () => { + const d = apply([{ kind: "user_added_cell", bufferId: 1, cellId: "a" }]) + expect(isEmptyDigest(d)).toBe(false) + }) + + it("stays empty for events that only affect the snapshot", () => { + const d = apply([ + { kind: "user_moved_cell", bufferId: 1, cellId: "a" }, + { + kind: "user_duplicated_cell", + bufferId: 1, + cellId: "a", + newCellId: "b", + }, + { + kind: "user_changed_cell_mode", + bufferId: 1, + cellId: "a", + mode: "draw", + }, + { kind: "user_changed_grid_layout", bufferId: 1 }, + ]) + expect(isEmptyDigest(d)).toBe(true) + }) +}) + +describe("user_added_cell", () => { + it("adds the cell id to `added`", () => { + const d = apply([{ kind: "user_added_cell", bufferId: 1, cellId: "a" }]) + expect([...d.added]).toEqual(["a"]) + }) + + it("dedupes repeated adds of the same id", () => { + const d = apply([ + { kind: "user_added_cell", bufferId: 1, cellId: "a" }, + { kind: "user_added_cell", bufferId: 1, cellId: "a" }, + ]) + expect(d.added.size).toBe(1) + }) +}) + +describe("user_deleted_cell", () => { + it("add-then-delete cancels both", () => { + const d = apply([ + { kind: "user_added_cell", bufferId: 1, cellId: "a" }, + { kind: "user_deleted_cell", bufferId: 1, cellId: "a" }, + ]) + expect(d.added.size).toBe(0) + expect(d.deleted.size).toBe(0) + }) + + it("delete-on-existing moves to `deleted`", () => { + const d = apply([{ kind: "user_deleted_cell", bufferId: 1, cellId: "a" }]) + expect([...d.deleted]).toEqual(["a"]) + }) + + it("delete removes the cell from edited and ran", () => { + const d = apply([ + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + { + kind: "user_ran_cell", + bufferId: 1, + cellId: "a", + status: "success", + }, + { kind: "user_deleted_cell", bufferId: 1, cellId: "a" }, + ]) + expect(d.edited.has("a")).toBe(false) + expect(d.ran.has("a")).toBe(false) + expect(d.deleted.has("a")).toBe(true) + }) +}) + +describe("user_updated_cell", () => { + it("adds to `edited` for an existing cell", () => { + const d = apply([{ kind: "user_updated_cell", bufferId: 1, cellId: "a" }]) + expect([...d.edited]).toEqual(["a"]) + }) + + it("is a no-op when the cell is in `added` (edits fold into the add)", () => { + const d = apply([ + { kind: "user_added_cell", bufferId: 1, cellId: "a" }, + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + ]) + expect(d.added.has("a")).toBe(true) + expect(d.edited.has("a")).toBe(false) + }) + + it("dedupes repeated edits on the same id", () => { + const d = apply([ + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + { kind: "user_updated_cell", bufferId: 1, cellId: "a" }, + ]) + expect(d.edited.size).toBe(1) + }) +}) + +describe("user_ran_cell", () => { + it("latest status wins", () => { + const d = apply([ + { kind: "user_ran_cell", bufferId: 1, cellId: "a", status: "error" }, + { + kind: "user_ran_cell", + bufferId: 1, + cellId: "a", + status: "success", + }, + ]) + expect(d.ran.get("a")).toBe("success") + }) +}) + +describe("user_changed_layout_mode", () => { + it("stores the final mode", () => { + const d = apply([ + { kind: "user_changed_layout_mode", bufferId: 1, mode: "list" }, + { kind: "user_changed_layout_mode", bufferId: 1, mode: "grid" }, + ]) + expect(d.layoutModeTo).toBe("grid") + }) +}) + +describe("notebook lifecycle events", () => { + it("user_archived_notebook flips the flag to archived", () => { + const d = apply([{ kind: "user_archived_notebook", bufferId: 1 }]) + expect(d.notebookStatusChange).toBe("archived") + }) + + it("user_deleted_notebook flips the flag to deleted", () => { + const d = apply([{ kind: "user_deleted_notebook", bufferId: 1 }]) + expect(d.notebookStatusChange).toBe("deleted") + }) + + it("delete wins over archive if both fire", () => { + const d = apply([ + { kind: "user_archived_notebook", bufferId: 1 }, + { kind: "user_deleted_notebook", bufferId: 1 }, + ]) + expect(d.notebookStatusChange).toBe("deleted") + }) +}) diff --git a/src/providers/AIConversationProvider/userActionDigest.ts b/src/providers/AIConversationProvider/userActionDigest.ts new file mode 100644 index 000000000..8f2cda97d --- /dev/null +++ b/src/providers/AIConversationProvider/userActionDigest.ts @@ -0,0 +1,60 @@ +import type { UserActionEvent } from "../../utils/notebookAIBridge" +import type { UserActionDigest } from "./types" + +export const createEmptyDigest = (): UserActionDigest => ({ + added: new Set(), + deleted: new Set(), + edited: new Set(), + ran: new Map(), +}) + +// Coalesces user-action events into the digest. Notes: +// - delete after add in same digest cancels both (add wins → erased). +// - edit on an added cell folds into the creation (no-op). +// - move/duplicate/cell-mode/grid-layout aren't tracked here; they ride on the snapshot. +export const applyUserActionToDigest = ( + digest: UserActionDigest, + evt: UserActionEvent, +): UserActionDigest => { + switch (evt.kind) { + case "user_added_cell": + digest.added.add(evt.cellId) + return digest + case "user_deleted_cell": + if (digest.added.has(evt.cellId)) { + digest.added.delete(evt.cellId) + } else { + digest.deleted.add(evt.cellId) + } + digest.edited.delete(evt.cellId) + digest.ran.delete(evt.cellId) + return digest + case "user_updated_cell": + if (!digest.added.has(evt.cellId)) { + digest.edited.add(evt.cellId) + } + return digest + case "user_ran_cell": + digest.ran.set(evt.cellId, evt.status) + return digest + case "user_changed_layout_mode": + digest.layoutModeTo = evt.mode + return digest + case "user_archived_notebook": + digest.notebookStatusChange = "archived" + return digest + case "user_deleted_notebook": + digest.notebookStatusChange = "deleted" + return digest + default: + return digest + } +} + +export const isEmptyDigest = (d: UserActionDigest): boolean => + d.added.size === 0 && + d.deleted.size === 0 && + d.edited.size === 0 && + d.ran.size === 0 && + d.layoutModeTo === undefined && + d.notebookStatusChange === undefined diff --git a/src/providers/AIStatusProvider/index.tsx b/src/providers/AIStatusProvider/index.tsx index fdb479632..82c902036 100644 --- a/src/providers/AIStatusProvider/index.tsx +++ b/src/providers/AIStatusProvider/index.tsx @@ -47,9 +47,19 @@ export enum AIOperationStatus { RetrievingDocumentation = "Reviewing docs", InvestigatingDocs = "Investigating docs", ValidatingQuery = "Validating query", + RunningQuery = "Running query", GeneratingResponse = "Generating response", Aborted = "Operation has been cancelled", Compacting = "Compacting conversation", + BuildingNotebook = "Building notebook", + AddingCell = "Adding cell", + UpdatingCell = "Updating cell", + DeletingCell = "Deleting cell", + RunningCell = "Running cell", + // Layout = positional/structural; Chart = visualization settings. + ConfiguringLayout = "Configuring layout", + ConfiguringChart = "Configuring chart", + InspectingNotebook = "Inspecting notebook", } export type StatusArgs = { @@ -58,6 +68,8 @@ export type StatusArgs = { section?: string tableOpType?: "schema" | "details" items?: Array<{ name: string; section?: string }> + label?: string + cellId?: string } export type StatusEntry = { diff --git a/src/providers/EditorProvider/index.tsx b/src/providers/EditorProvider/index.tsx index 48bc71263..e5a446b55 100644 --- a/src/providers/EditorProvider/index.tsx +++ b/src/providers/EditorProvider/index.tsx @@ -36,6 +36,7 @@ import { toast } from "../../components/Toast" import { db } from "../../store/db" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" +import { emitUserAction } from "../../utils/notebookAIBridge" import { useLiveQuery } from "dexie-react-hooks" import { trackEvent } from "../../modules/ConsoleEventTracker" @@ -106,7 +107,6 @@ export type EditorContext = { isNavigatingFromSearchRef: MutableRefObject showPreviewBuffer: (content: PreviewBufferContent) => Promise closePreviewBuffer: () => Promise - // Apply AI SQL change to editor applyAISQLChange: (options: ApplyAISQLChangeOptions) => ApplyAISQLChangeResult executionRefs: MutableRefObject cleanupExecutionRefs: (bufferId: number) => void @@ -204,7 +204,11 @@ export const EditorProvider: React.FC = ({ children }) => { isNavigatingFromSearchRef.current = true } - if (editorRef.current && monacoRef.current) { + if ( + editorRef.current && + monacoRef.current && + !buffer.notebookViewState + ) { const currentModel = editorRef.current.getModel() if (currentModel) { currentModel.dispose() @@ -305,28 +309,32 @@ export const EditorProvider: React.FC = ({ children }) => { return undefined } + const bufferType = newBuffer?.notebookViewState + ? BufferType.NOTEBOOK + : newBuffer?.metricsViewState + ? BufferType.METRICS + : BufferType.SQL + void trackEvent(ConsoleEvent.TAB_ADD, { - type: newBuffer?.metricsViewState ? BufferType.METRICS : BufferType.SQL, + type: bufferType, count: (buffers?.length ?? 0) + 1, }) - const fallback = makeFallbackBuffer( - newBuffer?.metricsViewState ? BufferType.METRICS : BufferType.SQL, - ) + const fallback = makeFallbackBuffer(bufferType) const currentDefaultTabNumbers = ( await db.buffers - .filter((buffer) => - buffer.label.startsWith(fallback.label) && - newBuffer?.metricsViewState - ? buffer.metricsViewState !== undefined - : true, - ) + .filter((buffer) => { + if (!buffer.label.startsWith(fallback.label)) return false + if (newBuffer?.notebookViewState) + return buffer.notebookViewState !== undefined + if (newBuffer?.metricsViewState) + return buffer.metricsViewState !== undefined + return true + }) .toArray() ) - .map((buffer) => - buffer.label.slice(fallback.label.length + /* whitespace */ 1), - ) + .map((buffer) => buffer.label.slice(fallback.label.length + 1)) .filter(Boolean) .map((n) => parseInt(n, 10)) .sort() @@ -353,8 +361,11 @@ export const EditorProvider: React.FC = ({ children }) => { await setActiveBuffer(buffer, { focus: true }) - // Select all text if requested (model is already created by setActiveBuffer) - if (shouldSelectAll && editorRef.current) { + if ( + shouldSelectAll && + editorRef.current && + !newBuffer?.notebookViewState + ) { const model = editorRef.current.getModel() if (model) { editorRef.current.setSelection(model.getFullModelRange()) @@ -388,12 +399,10 @@ export const EditorProvider: React.FC = ({ children }) => { const label = content.type === "diff" ? "AI Suggestion" : "Preview" if (existingPreviewBuffer && existingPreviewBuffer.id) { - // Update existing preview buffer await bufferStore.update(existingPreviewBuffer.id, { previewContent, label, }) - // Switch to it const updatedBuffer = { ...existingPreviewBuffer, previewContent, @@ -401,7 +410,6 @@ export const EditorProvider: React.FC = ({ children }) => { } await setActiveBuffer(updatedBuffer) } else { - // Create new preview buffer const position = buffers ? buffers.filter((b) => !b.archived && !b.isTemporary).length : 0 @@ -412,13 +420,11 @@ export const EditorProvider: React.FC = ({ children }) => { position, previewContent, }) - // addBuffer already switches to it } }, [buffers, setActiveBuffer, addBuffer], ) - // this effect should run only once, after mount and after `buffers` and `activeBufferId` are ready from the db useEffect(() => { if (!ranOnce.current && buffers && activeBufferId) { const buffer = @@ -446,14 +452,11 @@ export const EditorProvider: React.FC = ({ children }) => { if (payload && "isTemporary" in payload) { if (payload.isTemporary) { - // archived -> temporary setTemporaryBufferId(id) } else if (id === temporaryBufferId) { if (payload?.archived === false) { - // temporary -> permanent newPosition = getNextPosition() } else { - // temporary -> archived newPosition = -1 } setTemporaryBufferId(null) @@ -499,7 +502,6 @@ export const EditorProvider: React.FC = ({ children }) => { } const setActiveBufferOnRemoved = async (id: number) => { - // set new active buffer only when removing currently active buffer const activeBufferId = (await bufferStore.getActiveId())?.value if (typeof activeBufferId !== "undefined" && activeBufferId === id) { const nextActive = await db.buffers @@ -518,6 +520,8 @@ export const EditorProvider: React.FC = ({ children }) => { } const archiveBuffer: EditorContext["archiveBuffer"] = async (id) => { + // Snapshot before write — live query refresh makes post-update read unreliable. + const wasNotebook = !!buffers.find((b) => b.id === id)?.notebookViewState await updateBuffer(id, { archived: true, archivedAt: new Date().getTime(), @@ -528,12 +532,16 @@ export const EditorProvider: React.FC = ({ children }) => { type: "archive", bufferId: id, }) + if (wasNotebook) { + emitUserAction({ kind: "user_archived_notebook", bufferId: id }) + } } const deleteBuffer: EditorContext["deleteBuffer"] = async ( id, setActiveBuffer = true, ) => { + const wasNotebook = !!buffers.find((b) => b.id === id)?.notebookViewState await bufferStore.delete(id) cleanupExecutionRefs(id) if (setActiveBuffer) { @@ -543,6 +551,9 @@ export const EditorProvider: React.FC = ({ children }) => { type: "delete", bufferId: id, }) + if (wasNotebook) { + emitUserAction({ kind: "user_deleted_notebook", bufferId: id }) + } } const setTemporaryBuffer: EditorContext["setTemporaryBuffer"] = async ( @@ -625,12 +636,11 @@ export const EditorProvider: React.FC = ({ children }) => { shouldReplace = true } } catch { - // Invalid queryKey or query not found, fall back to appending + // Invalid queryKey or query not found — fall back to appending. } } if (!shouldReplace || !replaceRange) { - // Append to end of editor const lineNumber = model.getLineCount() const column = model.getLineMaxColumn(lineNumber) finalQueryStartOffset = model.getOffsetAt({ lineNumber, column }) diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index 48e55986d..3e4bb8c44 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -2,6 +2,9 @@ export type ProviderSettings = { apiKey: string enabledModels: string[] grantSchemaAccess: boolean + // Optional for back-compat; missing fields default to denied. + read?: boolean + write?: boolean } export type CustomProviderDefinition = { @@ -12,6 +15,8 @@ export type CustomProviderDefinition = { contextWindow: number models: string[] grantSchemaAccess?: boolean + read?: boolean + write?: boolean } export type AiAssistantSettings = { diff --git a/src/providers/MCPBridgeProvider/PairingConsentModal.tsx b/src/providers/MCPBridgeProvider/PairingConsentModal.tsx new file mode 100644 index 000000000..9785acab1 --- /dev/null +++ b/src/providers/MCPBridgeProvider/PairingConsentModal.tsx @@ -0,0 +1,350 @@ +import React, { useEffect, useState } from "react" +import styled from "styled-components" +import { + PlugsConnectedIcon, + PlugsIcon, + ShieldCheckIcon, + WarningIcon, +} from "@phosphor-icons/react" +import { AlertDialog } from "../../components/AlertDialog" +import { Overlay } from "../../components/Overlay" +import { Button, LoadingSpinner } from "../../components" +import { MAX_RECONNECT_ATTEMPTS } from "../../utils/mcp/MCPBridgeClient" +import type { Permissions } from "../../utils/tools/permissions" +import { PermissionsSection } from "../../scenes/Footer/MCPBridgeStatus/PermissionsSection" + +const CONNECT_HOOK = "mcp-pair-consent-connect" + +const StyledContent = styled(AlertDialog.Content)` + background-color: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.selection}; + padding: 0; + display: flex; + flex-direction: column; +` + +const Hero = styled.div` + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2.4rem 2.4rem 1.6rem; + gap: 1.2rem; +` + +const IconBadge = styled.div<{ $tone?: "neutral" | "success" }>` + width: 4.8rem; + height: 4.8rem; + border-radius: 0.8rem; + border: 1px solid + ${({ theme, $tone }) => + $tone === "success" ? theme.color.green : theme.color.pinkPrimary}; + color: ${({ theme, $tone }) => + $tone === "success" ? theme.color.green : theme.color.pinkPrimary}; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +` + +const Title = styled(AlertDialog.Title)` + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const Lede = styled.p` + margin: 0; + color: ${({ theme }) => theme.color.gray2}; + line-height: 1.5; + font-size: 1.4rem; +` + +const Body = styled.div` + padding: 0 2.4rem 1.6rem; + display: flex; + flex-direction: column; + gap: 1.6rem; +` + +const Fields = styled.dl` + display: grid; + grid-template-columns: 1fr; + gap: 1.2rem; + margin: 0; + padding: 1.6rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.selection}; + border-radius: 0.6rem; + + @media (min-width: 32em) { + grid-template-columns: max-content 1fr; + column-gap: 2rem; + row-gap: 1rem; + align-items: baseline; + } + + dt { + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + margin: 0; + } + + dd { + margin: 0; + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: ${({ theme }) => theme.color.foreground}; + word-break: break-all; + line-height: 1.5; + } +` + +const TrustNote = styled.div` + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: ${({ theme }) => theme.color.gray2}; + + svg { + flex-shrink: 0; + color: ${({ theme }) => theme.color.green}; + } + + strong { + color: ${({ theme }) => theme.color.foreground}; + font-weight: 600; + } +` + +const StatusRow = styled.div<{ $tone: "info" | "danger" }>` + display: flex; + align-items: flex-start; + margin-right: auto; + gap: 0.8rem; + width: 100%; + background: ${({ theme, $tone }) => + $tone === "danger" ? `${theme.color.red}1f` : theme.color.backgroundDarker}; + color: ${({ theme, $tone }) => + $tone === "danger" ? theme.color.foreground : theme.color.gray2}; + + padding: 0.8rem 2.4rem; + + svg { + flex-shrink: 0; + color: ${({ theme, $tone }) => + $tone === "danger" ? theme.color.red : theme.color.pinkPrimary}; + margin-top: 0.2rem; + } + + strong { + color: ${({ theme }) => theme.color.foreground}; + font-weight: 600; + } +` + +const StatusText = styled.div` + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + line-height: 1.4; +` + +const StatusDetail = styled.span` + font-size: 1.2rem; + color: ${({ theme }) => theme.color.gray2}; + word-break: break-word; +` + +const Actions = styled.div` + display: flex; + gap: 0.8rem; + justify-content: flex-end; + padding: 2rem 2.4rem 2.4rem; + + @media (max-width: 28em) { + flex-direction: column-reverse; + + > * { + width: 100%; + } + } +` + +type Props = { + pending: { url: string; token: string } | null + isConnecting: boolean + succeeded: boolean + retryAttempt: number + error: string | null + permissions: Permissions + onConnect: (committedPermissions: Permissions) => void + onCancel: () => void +} + +export const PairingConsentModal: React.FC = ({ + pending, + isConnecting, + succeeded, + retryAttempt, + error, + permissions, + onConnect, + onCancel, +}) => { + const [pendingPermissions, setPendingPermissions] = useState( + () => permissions, + ) + useEffect(() => { + if (pending) setPendingPermissions(permissions) + }, [pending]) + const handleOpenAutoFocus = (e: Event) => { + e.preventDefault() + const root = e.currentTarget as HTMLElement | null + const target = root?.querySelector( + `[data-hook="${CONNECT_HOOK}"]`, + ) + target?.focus() + } + + const handleConnectClick = () => { + if (isConnecting) return + onConnect(pendingPermissions) + } + + return ( + + + + { + if (isConnecting) e.preventDefault() + }} + > + + + {succeeded ? ( + + ) : ( + + )} + + + {succeeded ? "MCP Bridge connected" : "Connect to MCP Bridge?"} + + + {succeeded + ? "You can track the connection status from the bottom bar." + : "An external coding agent is asking to pair with this Web Console. Once connected, it can read and edit notebooks, run SQL, and inspect schemas on your behalf."} + + + + {!succeeded && ( + + +
WebSocket URL:
+
{pending?.url ?? ""}
+
Token:
+
+ {pending?.token ?? ""} +
+
+ + + + + + + Loopback only: The bridge runs on your + machine and never leaves it. Every action runs through your + already-authenticated console session. + + + + )} + + {!succeeded && isConnecting && ( + + + + Establishing WebSocket connection... + {/* -1 on both sides so the counter shows retries-allowed, + not total attempts (the first attempt isn't a retry). */} + {retryAttempt > 1 && ( + + (Retry {retryAttempt - 1} of {MAX_RECONNECT_ATTEMPTS - 1}) + + )} + + + )} + + {!succeeded && !isConnecting && (error || retryAttempt > 0) && ( + + + + Could not connect to MCP bridge + + {error ?? + `Bridge stopped responding after ${MAX_RECONNECT_ATTEMPTS} attempts. Click Try again, or ask your coding agent for a fresh deep link.`} + + + + )} + + + + + + {!succeeded && ( + + + + )} + +
+
+
+ ) +} diff --git a/src/providers/MCPBridgeProvider/index.tsx b/src/providers/MCPBridgeProvider/index.tsx new file mode 100644 index 000000000..6e9be2cf5 --- /dev/null +++ b/src/providers/MCPBridgeProvider/index.tsx @@ -0,0 +1,488 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { MCPBridgeClient } from "../../utils/mcp/MCPBridgeClient" +import type { MCPBridgeClientStatus } from "../../utils/mcp/MCPBridgeClient" +import { dispatchMCPTool } from "../../utils/mcp/dispatchMCPTool" +import type { + FreshnessGate, + StateFreshness, +} from "../../utils/mcp/dispatchMCPTool" +import { mcpTools } from "../../utils/tools/tools" +import { EXPECTED_BRIDGE_VERSION } from "../../utils/mcp/protocolVersion" +import type { ToolCallMessage } from "../../utils/mcp/types" +import { + clearLegacyLocalStorage, + clearPendingPair, + markPendingPairConsented, + readPendingPair, + readPermissions, + writePendingPair, + writePermissions, +} from "../../utils/mcp/mcpBridgeStorage" +import { + DEFAULT_GRANTED, + type Permissions, +} from "../../utils/tools/permissions" +import { consumePendingPairFromUrl } from "../../utils/mcp/consumePendingPair" +import { QuestContext } from "../QuestProvider" +import { on as onUserAction } from "../../utils/notebookAIBridge" +import type { ToolDefinition } from "../../utils/ai/types" +import { + applyUserActionToDigest, + createEmptyDigest, +} from "../AIConversationProvider/userActionDigest" +import type { UserActionDigest } from "../AIConversationProvider/types" +import { useBridgeToolRunner } from "./useBridgeToolRunner" +import { PairingConsentModal } from "./PairingConsentModal" + +export type MCPBridgeContextValue = { + status: MCPBridgeClientStatus + latencyMs: number | null + lastError: string | null + retryAttempt: number + url: string | null + token: string | null + connect: (url: string, token: string) => void + disconnect: () => void + permissions: Permissions + setPermissions: (next: Permissions) => void +} + +const defaultContext: MCPBridgeContextValue = { + status: "disconnected", + latencyMs: null, + lastError: null, + retryAttempt: 0, + url: null, + token: null, + connect: () => undefined, + disconnect: () => undefined, + permissions: DEFAULT_GRANTED, + setPermissions: () => undefined, +} + +const MCPBridgeContext = createContext(defaultContext) + +export const useMCPBridge = () => useContext(MCPBridgeContext) + +// Sent in `hello` so the bridge can detect drift against its bundled +// tool list (Codex caches the initial tools/list and never refetches). +const toWireSchema = (t: ToolDefinition) => ({ + name: t.name, + description: t.description ?? "", + inputSchema: t.inputSchema as Record, +}) +const buildToolListForHello = () => mcpTools.map(toWireSchema) + +const consoleOrigin = + typeof window !== "undefined" ? window.location.origin : "unknown" + +const CONSENT_SUCCESS_AUTOCLOSE_MS = 3_000 + +export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { quest } = useContext(QuestContext) + const [url, setUrl] = useState(null) + const [token, setToken] = useState(null) + const [status, setStatus] = useState("disconnected") + const [latencyMs, setLatencyMs] = useState(null) + const [lastError, setLastError] = useState(null) + const [permissions, setPermissionsState] = useState(() => + readPermissions(), + ) + const permissionsRef = useRef(permissions) + permissionsRef.current = permissions + const permissionsDirtyRef = useRef(false) + const [retryAttempt, setRetryAttempt] = useState(0) + const [pendingConsent, setPendingConsent] = useState<{ + url: string + token: string + } | null>(null) + const [consentInFlight, setConsentInFlight] = useState(false) + const [consentSucceeded, setConsentSucceeded] = useState(false) + // Bumped on every Connect click so "Try again" re-runs the construction + // effect even when {url,token} are unchanged; otherwise a wedged client + // (consecutiveFailedAttempts pinned at the cap) is never rebuilt. + const [connectAttempt, setConnectAttempt] = useState(0) + + const freshnessRef = useRef("unfetched") + const freshnessGate: FreshnessGate = useMemo( + () => ({ + get: () => freshnessRef.current, + set: (next) => { + freshnessRef.current = next + }, + }), + [], + ) + + const digestRef = useRef(createEmptyDigest()) + const getDigest = useCallback(() => digestRef.current, []) + // Snapshot-and-reset so the same user actions aren't re-emitted across + // successive tool results. + const consumeDigest = useCallback(() => { + const snapshot = digestRef.current + digestRef.current = createEmptyDigest() + return snapshot + }, []) + + const { modelToolsClient, metaToolContext } = useBridgeToolRunner( + getDigest, + consumeDigest, + ) + + const validateSql = useCallback( + (sql: string) => quest.validateQuery(sql), + [quest], + ) + + // Refs let the dispatcher read live values outside React's render flow. + const permissionsRefs = useMemo( + () => ({ + get: () => permissionsRef.current, + consumeDirty: (): boolean => { + const wasDirty = permissionsDirtyRef.current + permissionsDirtyRef.current = false + return wasDirty + }, + }), + [], + ) + + const dispatchCtxRef = useRef({ + modelToolsClient, + freshness: freshnessGate, + metaToolContext, + permissions: permissionsRefs, + validateSql, + }) + dispatchCtxRef.current = { + modelToolsClient, + freshness: freshnessGate, + metaToolContext, + permissions: permissionsRefs, + validateSql, + } + + const inflightAbortersRef = useRef(new Map()) + + // Distinguishes a revocation-driven abort from other reasons so the + // toolCall handler sends an explicit error back to the bridge instead + // of silently letting the agent time out. + const PERMISSIONS_REVOKED_REASON = "permissions_revoked" + + const clientRef = useRef(null) + + useEffect(() => { + const off = onUserAction("user-action", (evt) => { + digestRef.current = applyUserActionToDigest(digestRef.current, evt) + freshnessRef.current = "stale" + }) + return off + }, []) + + useEffect(() => { + if (!url || !token) return + const client = new MCPBridgeClient({ + url, + token, + tools: buildToolListForHello(), + consoleOrigin, + permissions: permissionsRef.current, + }) + clientRef.current = client + + const offStatus = client.on("status", (s) => setStatus(s)) + const offLatency = client.on("latency", (ms) => setLatencyMs(ms)) + const offError = client.on("error", (err) => setLastError(err.message)) + const offRetry = client.on("retryAttempt", (n) => setRetryAttempt(n)) + const offHelloAck = client.on("helloAck", () => { + // Promote pending → consented so a same-tab refresh silently restores. + markPendingPairConsented() + setLastError(null) + }) + + const offToolCall = client.on("toolCall", (call: ToolCallMessage) => { + const aborter = new AbortController() + inflightAbortersRef.current.set(call.requestId, aborter) + const deadlineTimer = + typeof call.deadlineMs === "number" && call.deadlineMs > 0 + ? window.setTimeout( + () => aborter.abort("deadline_exceeded"), + call.deadlineMs, + ) + : null + const sendRevokedErrorIfNeeded = (): boolean => { + if (!aborter.signal.aborted) return false + if (aborter.signal.reason === PERMISSIONS_REVOKED_REASON) { + client.sendToolResult({ + v: EXPECTED_BRIDGE_VERSION, + type: "tool_result", + requestId: call.requestId, + content: [ + { + type: "text", + text: "PERMISSION_REVOKED: the user revoked permissions mid-call. Re-check current permissions before retrying.", + }, + ], + isError: true, + }) + } + return true + } + void (async () => { + try { + const result = await dispatchMCPTool(call, { + ...dispatchCtxRef.current, + signal: aborter.signal, + }) + if (sendRevokedErrorIfNeeded()) return + client.sendToolResult({ + v: EXPECTED_BRIDGE_VERSION, + type: "tool_result", + requestId: call.requestId, + content: result.content, + isError: result.isError ?? false, + }) + } catch (err) { + if (sendRevokedErrorIfNeeded()) return + const message = + err instanceof Error ? err.message : "tool dispatch failed" + client.sendToolResult({ + v: EXPECTED_BRIDGE_VERSION, + type: "tool_result", + requestId: call.requestId, + content: [{ type: "text", text: `dispatch_error: ${message}` }], + isError: true, + }) + } finally { + if (deadlineTimer !== null) window.clearTimeout(deadlineTimer) + inflightAbortersRef.current.delete(call.requestId) + } + })() + }) + + const offCancel = client.on("cancel", (msg) => { + const aborter = inflightAbortersRef.current.get(msg.requestId) + if (aborter) aborter.abort() + }) + + client.connect() + + return () => { + offStatus() + offLatency() + offError() + offRetry() + offHelloAck() + offToolCall() + offCancel() + for (const a of Array.from(inflightAbortersRef.current.values())) { + a.abort() + } + inflightAbortersRef.current.clear() + client.disconnect() + if (clientRef.current === client) clientRef.current = null + } + }, [url, token, connectAttempt]) + + const connect = useCallback((nextUrl: string, nextToken: string) => { + setRetryAttempt(0) + setLastError(null) + setConnectAttempt((n) => n + 1) + setUrl(nextUrl) + setToken(nextToken) + writePendingPair({ + url: nextUrl, + token: nextToken, + receivedAt: Date.now(), + }) + }, []) + + const setPermissions = useCallback((nextRaw: Permissions) => { + // Defensive cascade — UI's togglePermission already enforces this, + // but a direct API caller could write an impossible triple. + let next: Permissions = nextRaw + if (next.write) next = { grantSchemaAccess: true, read: true, write: true } + else if (next.read) + next = { grantSchemaAccess: true, read: true, write: false } + const prev = permissionsRef.current + const isDowngrade = + (prev.grantSchemaAccess && !next.grantSchemaAccess) || + (prev.read && !next.read) || + (prev.write && !next.write) + writePermissions(next) + permissionsDirtyRef.current = true + setPermissionsState(next) + clientRef.current?.setPermissions(next) + // Calls already past the dispatcher gate keep running; abort them so a + // revoked write doesn't complete and feed a stale success to the agent. + if (isDowngrade) { + for (const aborter of Array.from(inflightAbortersRef.current.values())) { + aborter.abort(PERMISSIONS_REVOKED_REASON) + } + } + }, []) + + const disconnect = useCallback(() => { + if (clientRef.current) { + clientRef.current.disconnect("user_disconnect") + } + clearPendingPair() + setUrl(null) + setToken(null) + setStatus("disconnected") + setLatencyMs(null) + setLastError(null) + setRetryAttempt(0) + digestRef.current = createEmptyDigest() + freshnessRef.current = "unfetched" + }, []) + + useEffect(() => { + clearLegacyLocalStorage() + + if (typeof window !== "undefined") { + consumePendingPairFromUrl() + } + const pair = readPendingPair() + if (!pair) return + if (pair.consented) { + setConnectAttempt((n) => n + 1) + setUrl(pair.url) + setToken(pair.token) + } else { + setPendingConsent({ url: pair.url, token: pair.token }) + } + }, []) + + // Distinguishes the initial "disconnected" (before Connect commits) + // from the terminal "gave up" disconnected. + const attemptSawActiveRef = useRef(false) + + useEffect(() => { + if (!pendingConsent || !consentInFlight) return + if (status === "connected") { + attemptSawActiveRef.current = false + setConsentInFlight(false) + setConsentSucceeded(true) + markPendingPairConsented() + } else if (status === "connecting" || status === "reconnecting") { + attemptSawActiveRef.current = true + } else if (status === "disconnected" && attemptSawActiveRef.current) { + attemptSawActiveRef.current = false + setConsentInFlight(false) + } + }, [status, pendingConsent, consentInFlight]) + + // After give-up against a dead bridge, drop a consented pair so a refresh + // doesn't restart an infinite-retry loop. Pre-consent pairs stay — the + // modal's "Try again" needs them. + useEffect(() => { + if (status !== "disconnected") return + if (!url || !token) return + if (!lastError) return + const stored = readPendingPair() + if (stored?.consented) clearPendingPair() + }, [status, url, token, lastError]) + + // Don't flip consentSucceeded inside the timer: React 17 doesn't batch + // setStates in setTimeout, and Radix keeps the modal mounted during its + // 0.25s exit animation, so a flip would briefly swap success → default. + useEffect(() => { + if (!consentSucceeded) return + const t = setTimeout(() => { + setPendingConsent(null) + }, CONSENT_SUCCESS_AUTOCLOSE_MS) + return () => clearTimeout(t) + }, [consentSucceeded]) + + const acceptPendingConsent = useCallback( + (committedPermissions: Permissions) => { + if (!pendingConsent || consentInFlight) return + // Order matters: the about-to-be-constructed client reads + // permissionsRef.current in its initial hello. + setPermissions(committedPermissions) + attemptSawActiveRef.current = false + setConsentInFlight(true) + setConsentSucceeded(false) + setLastError(null) + setRetryAttempt(0) + setConnectAttempt((n) => n + 1) + setUrl(pendingConsent.url) + setToken(pendingConsent.token) + }, + [pendingConsent, consentInFlight, setPermissions], + ) + + const declinePendingConsent = useCallback(() => { + // In the success state, Dismiss should NOT disconnect — the user + // just confirmed connection. + if (consentSucceeded) { + setPendingConsent(null) + setConsentSucceeded(false) + return + } + setPendingConsent(null) + setConsentInFlight(false) + if (clientRef.current) { + clientRef.current.disconnect("user_disconnect") + clientRef.current = null + } + setUrl(null) + setToken(null) + setRetryAttempt(0) + clearPendingPair() + }, [consentSucceeded]) + + const value = useMemo( + () => ({ + status, + latencyMs, + lastError, + retryAttempt, + url, + token, + connect, + disconnect, + permissions, + setPermissions, + }), + [ + status, + latencyMs, + lastError, + retryAttempt, + url, + token, + connect, + disconnect, + permissions, + setPermissions, + ], + ) + + return ( + + {children} + + + ) +} diff --git a/src/providers/MCPBridgeProvider/useBridgeToolRunner.ts b/src/providers/MCPBridgeProvider/useBridgeToolRunner.ts new file mode 100644 index 000000000..5cdaf40f1 --- /dev/null +++ b/src/providers/MCPBridgeProvider/useBridgeToolRunner.ts @@ -0,0 +1,74 @@ +import { useContext, useMemo } from "react" +import { useSelector } from "react-redux" +import { selectors } from "../../store" +import { QuestContext } from "../QuestProvider" +import { createModelToolsClient } from "../../utils/aiAssistant" +import { useEditor } from "../EditorProvider" +import type { MetaToolContext } from "../../utils/mcp/metaResolvers" +import type { WorkspaceInfo } from "../../utils/executeAIFlow" +import type { UserActionDigest } from "../AIConversationProvider/types" + +export const useBridgeToolRunner = ( + getDigest: () => UserActionDigest | null, + consumeDigest: () => UserActionDigest | null, +) => { + const { quest } = useContext(QuestContext) + const tables = useSelector(selectors.query.getTables) + const { activeBuffer, buffers } = useEditor() + + const modelToolsClient = useMemo( + () => createModelToolsClient(quest, tables), + [quest, tables], + ) + + const workspace: WorkspaceInfo | null = useMemo(() => { + const notebooks = buffers + .filter((b) => !!b.notebookViewState && typeof b.id === "number") + .map((b) => ({ + buffer_id: b.id as number, + label: b.label, + archived: !!b.archived, + })) + if (notebooks.length === 0 && typeof activeBuffer.id !== "number") { + return null + } + return { + notebooks, + ...(typeof activeBuffer.id === "number" + ? { + active: { + buffer_id: activeBuffer.id, + label: activeBuffer.label, + kind: activeBuffer.notebookViewState + ? ("notebook" as const) + : activeBuffer.metricsViewState + ? ("metrics" as const) + : activeBuffer.editorViewState + ? ("sql" as const) + : ("other" as const), + }, + } + : {}), + } + }, [buffers, activeBuffer]) + + const activeNotebookBufferId = useMemo( + () => + activeBuffer.notebookViewState && typeof activeBuffer.id === "number" + ? activeBuffer.id + : null, + [activeBuffer], + ) + + const metaToolContext: MetaToolContext = useMemo( + () => ({ + getActiveBufferId: () => activeNotebookBufferId, + getWorkspace: () => workspace, + getDigest, + consumeDigest, + }), + [activeNotebookBufferId, workspace, getDigest, consumeDigest], + ) + + return { modelToolsClient, metaToolContext } +} diff --git a/src/scenes/Console/index.tsx b/src/scenes/Console/index.tsx index 851d001f6..8c593aad2 100644 --- a/src/scenes/Console/index.tsx +++ b/src/scenes/Console/index.tsx @@ -52,6 +52,13 @@ const Top = styled.div` overflow: hidden; ` +const ContentArea = styled.div` + display: flex; + flex: 1; + height: 100%; + min-width: 0; +` + const Bottom = styled.div` display: flex; height: 100%; @@ -61,11 +68,15 @@ const Bottom = styled.div` const Tab = styled.div` display: flex; - width: calc(100% - 4.5rem); + width: 100%; height: 100%; overflow: auto; ` +const SidebarSpacer = styled.div` + flex: 1; +` + const SidePanelRight = styled.div` background: ${color("chatBackground")}; height: 100%; @@ -151,176 +162,179 @@ const Console = () => { > - { - updateSettings(StoreKey.RESULTS_SPLITTER_BASIS, sizes[0]) - }} - > - - - - {!sm && ( - - { - if (isDataSourcesPanelOpen) { - void trackEvent(ConsoleEvent.SCHEMA_OPEN) - updateLeftPanelState({ - type: null, - width: leftPanelState.width, - }) - } else { - void trackEvent(ConsoleEvent.SCHEMA_CLOSE) - updateLeftPanelState({ - type: LeftPanelType.DATASOURCES, - width: leftPanelState.width, - }) - } - }} - selected={isDataSourcesPanelOpen} - > - - - - )} - + + {!sm && ( + + { + if (isDataSourcesPanelOpen) { + void trackEvent(ConsoleEvent.SCHEMA_OPEN) + updateLeftPanelState({ + type: null, + width: leftPanelState.width, + }) + } else { + void trackEvent(ConsoleEvent.SCHEMA_CLOSE) + updateLeftPanelState({ + type: LeftPanelType.DATASOURCES, + width: leftPanelState.width, + }) + } + }} + selected={isDataSourcesPanelOpen} > + + + + )} + + { + if (!isSearchPanelOpen) { + void trackEvent(ConsoleEvent.SEARCH_OPEN) + } + setSearchPanelOpen(!isSearchPanelOpen) + }} + selected={isSearchPanelOpen} + > + + + + + {result && + viewModes.map(({ icon, mode, tooltipText }) => ( + { - if (!isSearchPanelOpen) { - void trackEvent(ConsoleEvent.SEARCH_OPEN) - } - setSearchPanelOpen(!isSearchPanelOpen) + const isActive = + activeBottomPanel === "result" && + resultViewMode === mode + void trackEvent(ConsoleEvent.PANEL_BOTTOM_SWITCH, { + panel: isActive ? "zeroState" : mode, + }) + dispatch( + actions.console.setActiveBottomPanel( + isActive ? "zeroState" : "result", + ), + ) + setResultViewMode(mode) }} - selected={isSearchPanelOpen} + selected={ + activeBottomPanel === "result" && + resultViewMode === mode + } > - + {icon} - - { - if (sizes[0] !== 0) { - updateLeftPanelState({ - type: leftPanelState.type, - width: sizes[0], + ))} + + { + const isActive = activeBottomPanel === "import" + void trackEvent(ConsoleEvent.PANEL_BOTTOM_SWITCH, { + panel: isActive ? "zeroState" : "import", }) - } - }} - snap + dispatch( + actions.console.setActiveBottomPanel( + isActive ? "zeroState" : "import", + ), + ) + }, + })} + selected={activeBottomPanel === "import"} + data-hook="import-panel-button" > - - - - - - - - - - - - - - - {result && - viewModes.map(({ icon, mode, tooltipText }) => ( - - { - void trackEvent( - ConsoleEvent.PANEL_BOTTOM_SWITCH, - { - panel: mode, - }, - ) - dispatch( - actions.console.setActiveBottomPanel("result"), - ) - setResultViewMode(mode) - }} - selected={ - activeBottomPanel === "result" && - resultViewMode === mode - } - > - {icon} - - - ))} - + + + + { + updateSettings(StoreKey.RESULTS_SPLITTER_BASIS, sizes[0]) + }} + > + + + { + if (sizes[0] !== 0) { + updateLeftPanelState({ + type: leftPanelState.type, + width: sizes[0], + }) + } + }} + snap > - { - void trackEvent(ConsoleEvent.PANEL_BOTTOM_SWITCH, { - panel: "import", - }) - dispatch( - actions.console.setActiveBottomPanel("import"), - ) - }, - })} - selected={activeBottomPanel === "import"} - data-hook="import-panel-button" + - - - - - - {result && } - - - - - - - - - - + + + + + + + + + + + + + + {result && } + + + + + + + + + + + diff --git a/src/scenes/Editor/AIChatWindow/ChatInput.tsx b/src/scenes/Editor/AIChatWindow/ChatInput.tsx index 8c8383b89..3c23938da 100644 --- a/src/scenes/Editor/AIChatWindow/ChatInput.tsx +++ b/src/scenes/Editor/AIChatWindow/ChatInput.tsx @@ -11,7 +11,7 @@ import styled from "styled-components" import { Box } from "../../../components" import { Text } from "../../../components/Text" import { color } from "../../../utils" -import { ArrowUpIcon, CodeBlockIcon } from "@phosphor-icons/react" +import { ArrowUpIcon, CodeBlockIcon, NotebookIcon } from "@phosphor-icons/react" import { Stop as StopFill } from "@styled-icons/remix-fill" import { useAIStatus, @@ -98,34 +98,36 @@ const ContextBadgeContainer = styled.div` background: ${color("backgroundDarker")}; ` -const ContextBadge = styled.div` +const ContextBadge = styled.div<{ $warn?: boolean }>` display: flex; padding: 0.3rem 0.6rem; align-items: center; gap: 0.4rem; line-height: 1.4; border-radius: 0.6rem; - border: 1px solid ${color("selection")}; + border: 1px solid + ${({ $warn, theme }) => ($warn ? theme.color.red : theme.color.selection)}; background: ${color("chatBackground")}; - color: ${color("gray2")}; + color: ${({ theme }) => theme.color.gray2}; font-size: 1.3rem; user-select: none; - cursor: pointer; + cursor: ${({ $warn }) => ($warn ? "default" : "pointer")}; &:hover { - border: 1px solid ${color("offWhite")}; - color: ${color("offWhite")}; + border: 1px solid + ${({ $warn, theme }) => ($warn ? theme.color.red : theme.color.offWhite)}; + color: ${({ theme }) => theme.color.offWhite}; } ` -const ContextBadgeIcon = styled.div` +const ContextBadgeIcon = styled.div<{ $warn?: boolean }>` display: flex; align-items: center; color: ${color("gray2")}; flex-shrink: 0; svg { - color: ${color("gray2")}; + color: ${({ $warn, theme }) => ($warn ? theme.color.red : color("gray2"))}; } ` @@ -199,12 +201,19 @@ const StopButton = styled.button` } ` +export type NotebookContext = { + label: string + warn?: "archived" | "deleted" | null + onClick: () => void | Promise +} + type ChatInputProps = { onSend: (message: string) => void disabled?: boolean placeholder?: string contextSQL?: string contextTableId?: number + contextNotebook?: NotebookContext onContextClick: () => void } @@ -227,6 +236,7 @@ export const ChatInput = forwardRef( placeholder = "Ask a question or request a refinement...", contextSQL, contextTableId, + contextNotebook, onContextClick, }, ref, @@ -240,13 +250,23 @@ export const ChatInput = forwardRef( return tables.find((t) => t.id === contextTableId) ?? null }, [contextTableId, tables]) - // Determine what to show in context badge - const contextText = tableData?.table_name - ? truncateText(tableData.table_name) - : contextSQL - ? truncateText(contextSQL) - : null + // Notebook context wins over table/SQL when the chat is bound; bound chats never carry SQL context. + const contextText = contextNotebook + ? truncateText(contextNotebook.label) + : tableData?.table_name + ? truncateText(tableData.table_name) + : contextSQL + ? truncateText(contextSQL) + : null const hasContext = Boolean(contextText) + const notebookWarn = contextNotebook?.warn + const notebookBadgeTitle = contextNotebook + ? notebookWarn === "archived" + ? `Notebook archived` + : notebookWarn === "deleted" + ? `Notebook deleted` + : `Attached: ${contextNotebook.label} — click to switch` + : undefined useImperativeHandle(ref, () => ({ focus: () => { @@ -315,11 +335,19 @@ export const ChatInput = forwardRef( void contextNotebook.onClick() + : onContextClick + } + title={notebookBadgeTitle} data-hook="chat-context-badge" > - - {tableData ? ( + + {contextNotebook ? ( + + ) : tableData ? ( ( )} - {contextText} + {notebookWarn === "archived" + ? `Archived: ${contextText}` + : notebookWarn === "deleted" + ? `Deleted: ${contextText}` + : contextText} )} diff --git a/src/scenes/Editor/AIChatWindow/ChatMessages.tsx b/src/scenes/Editor/AIChatWindow/ChatMessages.tsx index d35e697d9..12456f3c6 100644 --- a/src/scenes/Editor/AIChatWindow/ChatMessages.tsx +++ b/src/scenes/Editor/AIChatWindow/ChatMessages.tsx @@ -946,7 +946,7 @@ export const ChatMessages: React.FC = ({ ) } - // Default: plain text message + // Prefer `displayUserMessage` so wire-protocol prefixes don't show in the chat bubble. return ( = ({ if (el) messageRefs.current.set(message.id, el) }} > - {message.content} + + {message.displayUserMessage || message.content} + ) } else { diff --git a/src/scenes/Editor/AIChatWindow/index.tsx b/src/scenes/Editor/AIChatWindow/index.tsx index 3b701d078..9c76e23ec 100644 --- a/src/scenes/Editor/AIChatWindow/index.tsx +++ b/src/scenes/Editor/AIChatWindow/index.tsx @@ -55,6 +55,12 @@ import { RunningType } from "../../../store/Query/types" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { CircleNotchSpinner } from "../Monaco/icons" +import { getWorkspace } from "../../../utils/notebookAIBridge" +import { buildSnapshot } from "../../../utils/ai/notebookSnapshot" +import type { + NotebookFlowContext, + WorkspaceInfo, +} from "../../../utils/executeAIFlow" const HeaderLeft = styled.div` display: flex; @@ -186,6 +192,9 @@ const AIChatWindow: React.FC = () => { closePreviewBuffer, executionRefs, highlightQuery, + buffers, + activeBuffer, + setActiveBuffer, } = useEditor() const { conversationMetas, @@ -208,6 +217,9 @@ const AIChatWindow: React.FC = () => { acceptSuggestion, rejectSuggestion, persistMessages, + bindConversationToNotebook, + clearUserActionEvents, + readUserActionDigest, } = useAIConversation() const { status: aiStatus, @@ -361,6 +373,47 @@ const AIChatWindow: React.FC = () => { return "Ask AI about your tables, or generate a query..." } + // always emits when tabs exist so unbound chats can resolve user-said labels to buffer_ids. + const buildNotebookFlowContext = useCallback( + (conversationId: string): NotebookFlowContext | undefined => { + const meta = getConversationMeta(conversationId) + const bufferId = meta?.notebookBufferId + const snapshot = bufferId ? buildSnapshot(getWorkspace(), bufferId) : null + const digest = readUserActionDigest(conversationId) + const notebookBuffers = buffers.filter( + (b) => !!b.notebookViewState && typeof b.id === "number", + ) + const workspace: WorkspaceInfo | undefined = + notebookBuffers.length > 0 || typeof activeBuffer.id === "number" + ? { + notebooks: notebookBuffers.map((b) => ({ + buffer_id: b.id as number, + label: b.label, + archived: !!b.archived, + ...(b.id === bufferId ? { bound_to_this_chat: true } : {}), + })), + active: + typeof activeBuffer.id === "number" + ? { + buffer_id: activeBuffer.id, + label: activeBuffer.label, + kind: activeBuffer.notebookViewState + ? "notebook" + : activeBuffer.metricsViewState + ? "metrics" + : activeBuffer.editorViewState + ? "sql" + : "other", + } + : undefined, + } + : undefined + if (!snapshot && !digest && !workspace) return undefined + return { snapshot, digest, workspace } + }, + [getConversationMeta, readUserActionDigest, buffers, activeBuffer], + ) + const handleSendMessage = ( userMessage: string, hasUnactionedDiffParam: boolean = false, @@ -394,6 +447,7 @@ const AIChatWindow: React.FC = () => { tables, hasSchemaAccess, abortSignal: abortController?.signal, + notebookContext: buildNotebookFlowContext(conversationId), }), { addMessage, @@ -403,6 +457,8 @@ const AIChatWindow: React.FC = () => { persistMessages, updateConversationName, replaceConversationMessages, + bindNotebookToConversation: bindConversationToNotebook, + clearUserActionEvents, }, ) } @@ -582,6 +638,7 @@ const AIChatWindow: React.FC = () => { tables, hasSchemaAccess, abortSignal: abortController?.signal, + notebookContext: buildNotebookFlowContext(conversationId), } let isRemoved = false @@ -603,6 +660,8 @@ const AIChatWindow: React.FC = () => { persistMessages, updateConversationName, replaceConversationMessages, + bindNotebookToConversation: bindConversationToNotebook, + clearUserActionEvents, } switch (userMessage.displayType) { @@ -743,6 +802,25 @@ const AIChatWindow: React.FC = () => { return null } + const notebookContext = (() => { + const boundId = conversation?.notebookBufferId + if (typeof boundId !== "number") return undefined + const buf = buffers.find((b) => b.id === boundId) + const isMissing = !buf + const isArchived = !!buf?.archived + const label = buf?.label ?? `#${boundId}` + const warn: "archived" | "deleted" | null = isMissing + ? "deleted" + : isArchived + ? "archived" + : null + const onClick = async () => { + if (warn) return + if (buf) await setActiveBuffer(buf) + } + return { label, warn, onClick } + })() + return ( { placeholder={getPlaceholder()} contextSQL={queryInfo.queryText} contextTableId={conversation?.tableId} + contextNotebook={notebookContext} onContextClick={handleContextClick} /> diff --git a/src/scenes/Editor/Monaco/QueryDropdown.tsx b/src/scenes/Editor/Monaco/QueryDropdown.tsx index a0ad43ea9..4e10939a3 100644 --- a/src/scenes/Editor/Monaco/QueryDropdown.tsx +++ b/src/scenes/Editor/Monaco/QueryDropdown.tsx @@ -6,45 +6,10 @@ import { PlayFilled } from "../../../components/icons/play-filled" import { AISparkle } from "../../../components/AISparkle" import type { Request } from "./utils" -const StyledDropdownContent = styled(DropdownMenu.Content)` - background-color: #343846; - border-radius: 0.5rem; - padding: 0.4rem; - box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); - z-index: 9999; - min-width: 160px; - gap: 0; -` - -const StyledDropdownItem = styled(DropdownMenu.Item)` - font-size: 1.3rem; - height: 3rem; - font-family: "system-ui", sans-serif; - cursor: pointer; - color: rgb(248, 248, 242); - display: flex; - align-items: center; - padding: 1rem 1.2rem; - border-radius: 0.4rem; - margin: 0; - gap: 0; - border: 1px solid transparent; - - &[data-highlighted] { - background: #043c5c; - border: 1px solid #8be9fd; - } - - &[data-disabled] { - opacity: 0.5; - } -` - const IconWrapper = styled.span` display: flex; align-items: center; justify-content: center; - margin-right: 1.2rem; ` const StyledPlayFilled = styled(PlayFilled)` @@ -114,11 +79,11 @@ export const QueryDropdown: React.FC = ({ /> - + {isAIDropdownRef.current ? // AI dropdown - show "Ask AI about query X" options queriesRef.current.map((query, index) => ( - onAskAIRef.current(query)} @@ -128,14 +93,14 @@ export const QueryDropdown: React.FC = ({ Ask AI about {extractQueryTextToRun(query)} - + )) : queriesRef.current.length > 1 ? // Multiple queries - show options for each queriesRef.current .map((query, index) => { const items = [ - onRunQuery(query)} @@ -145,12 +110,12 @@ export const QueryDropdown: React.FC = ({ Run {extractQueryTextToRun(query)} - , + , ] if (isContextMenuRef.current) { items.push( - = ({ Get query plan for {extractQueryTextToRun(query)} - , + , ) } @@ -169,7 +134,7 @@ export const QueryDropdown: React.FC = ({ }) .flat() : [ - onRunQuery(queriesRef.current[0])} data-hook="dropdown-item-run-query" @@ -178,8 +143,8 @@ export const QueryDropdown: React.FC = ({ Run {extractQueryTextToRun(queriesRef.current[0])} - , - , + onExplainQuery(queriesRef.current[0])} @@ -190,9 +155,9 @@ export const QueryDropdown: React.FC = ({ Get query plan for{" "} {extractQueryTextToRun(queriesRef.current[0])} - , + , ]} - + ) diff --git a/src/scenes/Editor/Monaco/importTabs.test.ts b/src/scenes/Editor/Monaco/importTabs.test.ts index 4903a4652..c5a43bf96 100644 --- a/src/scenes/Editor/Monaco/importTabs.test.ts +++ b/src/scenes/Editor/Monaco/importTabs.test.ts @@ -105,12 +105,14 @@ describe("validateBufferSchema", () => { ).toBe("Item [0]: position must be a number") }) - it("should reject tabs without editorViewState or metricsViewState", () => { + it("should reject tabs without editorViewState, metricsViewState, or notebookViewState", () => { expect( validateBufferSchema([ { label: "Tab 1", value: "SELECT 1", position: 0 }, ]), - ).toBe("Item [0]: must have editorViewState or metricsViewState") + ).toBe( + "Item [0]: must have editorViewState, metricsViewState, or notebookViewState", + ) }) it("should accept tab with editorViewState", () => { @@ -138,6 +140,20 @@ describe("validateBufferSchema", () => { ]), ).toBe(true) }) + + it("should accept tab with notebookViewState", () => { + // Schema layer only checks presence; shape is validated downstream. + expect( + validateBufferSchema([ + { + label: "Notebook", + value: "", + position: 0, + notebookViewState: {}, + }, + ]), + ).toBe(true) + }) }) describe("line count limit", () => { @@ -943,5 +959,40 @@ describe("deduplication", () => { } expect(createBufferContentKey(buffer)).toBe("Tab|SELECT * FROM t") }) + + it("creates key from cell values for notebook tabs", () => { + const buffer = { + label: "Notebook", + value: "", + notebookViewState: { + cells: [ + { id: "a", type: "sql", position: 0, value: "SELECT 1" }, + { id: "b", type: "markdown", position: 1, value: "# title" }, + ], + }, + } as unknown as Parameters[0] + expect(createBufferContentKey(buffer)).toBe( + `Notebook|${JSON.stringify(["SELECT 1", "# title"])}`, + ) + }) + + it("notebook branch is guarded against missing cells array", () => { + const buffer = { + label: "Notebook", + value: "", + notebookViewState: {}, + } as unknown as Parameters[0] + expect(createBufferContentKey(buffer)).toBe("Notebook|[]") + }) + + it("notebook branch takes precedence over metricsViewState", () => { + const buffer = { + label: "Hybrid", + value: "", + metricsViewState: { viewMode: MetricViewMode.LIST }, + notebookViewState: { cells: [] }, + } as unknown as Parameters[0] + expect(createBufferContentKey(buffer)).toBe("Hybrid|[]") + }) }) }) diff --git a/src/scenes/Editor/Monaco/importTabs.ts b/src/scenes/Editor/Monaco/importTabs.ts index 78f5bbf16..1512e2f5e 100644 --- a/src/scenes/Editor/Monaco/importTabs.ts +++ b/src/scenes/Editor/Monaco/importTabs.ts @@ -98,8 +98,9 @@ export const validateBufferItem = (item: unknown): ValidationResult => { const hasEditorViewState = obj.editorViewState !== undefined const hasMetricsViewState = obj.metricsViewState !== undefined - if (!hasEditorViewState && !hasMetricsViewState) - return "must have editorViewState or metricsViewState" + const hasNotebookViewState = obj.notebookViewState !== undefined + if (!hasEditorViewState && !hasMetricsViewState && !hasNotebookViewState) + return "must have editorViewState, metricsViewState, or notebookViewState" if (hasMetricsViewState) { const result = validateMetricsViewState(obj.metricsViewState) @@ -160,6 +161,7 @@ export const sanitizeBuffer = ( item: Record, ): Omit => { const hasMetricsViewState = item.metricsViewState !== undefined + const hasNotebookViewState = item.notebookViewState !== undefined const sanitized: Omit = { label: item.label as string, @@ -167,7 +169,10 @@ export const sanitizeBuffer = ( position: item.position as number, } - if (hasMetricsViewState) { + if (hasNotebookViewState) { + sanitized.notebookViewState = + item.notebookViewState as Buffer["notebookViewState"] + } else if (hasMetricsViewState) { sanitized.metricsViewState = sanitizeMetricsViewState( item.metricsViewState as Record, ) @@ -198,10 +203,17 @@ export const validateBufferSchema = (data: unknown): ValidationResult => { } export const createBufferContentKey = ( - buffer: Pick, + buffer: Pick< + Buffer, + "label" | "value" | "metricsViewState" | "notebookViewState" + >, ): string => { - const content = buffer.metricsViewState - ? JSON.stringify(buffer.metricsViewState) - : buffer.value - return `${buffer.label}|${content}` + if (buffer.notebookViewState) { + const cellValues = buffer.notebookViewState.cells?.map((c) => c.value) ?? [] + return `${buffer.label}|${JSON.stringify(cellValues)}` + } + if (buffer.metricsViewState) { + return `${buffer.label}|${JSON.stringify(buffer.metricsViewState)}` + } + return `${buffer.label}|${buffer.value}` } diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index e3f2465dc..7c66495c4 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -2,12 +2,19 @@ import React, { useLayoutEffect, useState, useMemo } from "react" import styled, { css } from "styled-components" import { Tabs as ReactChromeTabs } from "../../../components/ReactChromeTabs" import { useEditor, MAX_TABS } from "../../../providers" -import { File, History, LineChart, Trash } from "@styled-icons/boxicons-regular" +import { + File, + History, + LineChart, + Trash, + Notepad, +} from "@styled-icons/boxicons-regular" import { DotsThreeVerticalIcon, DownloadSimpleIcon, UploadSimpleIcon, } from "@phosphor-icons/react" +import { createDefaultNotebookViewState } from "../../../store/notebook" import { toast } from "../../../components/Toast" import { db } from "../../../store/db" import { @@ -63,8 +70,7 @@ const HistoryButton = styled(Button)` const DropdownMenuContent = styled(DropdownMenu.Content)` margin-top: 0.5rem; - z-index: 100; - background: ${({ theme }) => theme.color.backgroundDarker}; + width: 20rem; ` const ArchivedBuffersList = styled.div` @@ -72,7 +78,21 @@ const ArchivedBuffersList = styled.div` overflow-y: auto; ` +// Invisible trigger we reposition to anchor Radix's menu at the chrome-tabs "+". +const NewTabAnchor = styled(DropdownMenu.Trigger)` + position: fixed; + width: 0; + height: 0; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +` + const mapTabIconToType = (buffer: Buffer) => { + if (buffer.notebookViewState) { + return "assets/icon-notebook.svg" + } if (buffer.metricsViewState) { return "assets/icon-chart.svg" } @@ -98,6 +118,11 @@ export const Tabs = () => { const userLocale = useMemo(fetchUserLocale, []) const [historyOpen, setHistoryOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const [newTabMenuOpen, setNewTabMenuOpen] = useState(false) + const [newTabMenuPos, setNewTabMenuPos] = useState<{ + x: number + y: number + } | null>(null) const [importSummaryOpen, setImportSummaryOpen] = useState(false) const [importedCount, setImportedCount] = useState(0) const [skippedTabs, setSkippedTabs] = useState([]) @@ -376,7 +401,9 @@ export const Tabs = () => { if ( buffer?.value !== "" || (buffer.metricsViewState?.metrics && - buffer.metricsViewState.metrics.length > 0) + buffer.metricsViewState.metrics.length > 0) || + (buffer.notebookViewState?.cells && + buffer.notebookViewState.cells.some((c) => c.value.trim() !== "")) ) { await archiveBuffer(parseInt(id)) } else { @@ -450,7 +477,16 @@ export const Tabs = () => { onTabReorder={reorder} onTabActive={active} onTabRename={rename} - onNewTab={addBuffer} + onNewTab={() => { + const btn = document.querySelector( + ".chrome-tabs .new-tab-button-wrapper", + ) + if (btn) { + const rect = btn.getBoundingClientRect() + setNewTabMenuPos({ x: rect.left, y: rect.top }) + } + setNewTabMenuOpen(true) + }} tabs={buffers .filter( (buffer) => @@ -468,6 +504,9 @@ export const Tabs = () => { }) .map((buffer) => { const classNames = [] + if (buffer.notebookViewState) { + classNames.push("notebook-tab") + } if (buffer.metricsViewState) { classNames.push("metrics-tab") } @@ -513,7 +552,7 @@ export const Tabs = () => { {archivedBuffers.length === 0 ? ( -
+
History is empty
) : ( @@ -638,6 +677,37 @@ export const Tabs = () => { importedCount={importedCount} skippedTabs={skippedTabs} /> + + + + + { + void addBuffer() + }} + > + + Editor tab + + { + void addBuffer({ + notebookViewState: createDefaultNotebookViewState(), + }) + }} + > + + Notebook tab + + + + ) } diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts index 02883a90f..38f128262 100644 --- a/src/scenes/Editor/Monaco/utils.test.ts +++ b/src/scenes/Editor/Monaco/utils.test.ts @@ -1,5 +1,66 @@ import { describe, it, expect } from "vitest" -import { isCursorInComment, isCursorInQuotedIdentifier } from "./utils" +import { + getQueriesFromText, + isCursorInComment, + isCursorInQuotedIdentifier, +} from "./utils" + +describe("getQueriesFromText", () => { + it("splits two simple statements", () => { + expect(getQueriesFromText("SELECT 1; SELECT 2;")).toEqual([ + "SELECT 1", + "SELECT 2", + ]) + }) + + it("returns empty for empty input", () => { + expect(getQueriesFromText("")).toEqual([]) + expect(getQueriesFromText(" \n ")).toEqual([]) + }) + + it("ignores semicolons inside strings", () => { + expect(getQueriesFromText("SELECT ';'; SELECT 'a;b'")).toEqual([ + "SELECT ';'", + "SELECT 'a;b'", + ]) + }) + + it("ignores semicolons in line comments", () => { + expect( + getQueriesFromText("SELECT 1 -- comment with ;\n; SELECT 2"), + ).toEqual(["SELECT 1 -- comment with ;", "SELECT 2"]) + }) + + it("ignores semicolons in block comments", () => { + expect(getQueriesFromText("SELECT 1 /* a; b; */; SELECT 2")).toEqual([ + "SELECT 1 /* a; b; */", + "SELECT 2", + ]) + }) + + it("handles trailing statement with no semicolon", () => { + expect(getQueriesFromText("SELECT 1;\nSELECT 2")).toEqual([ + "SELECT 1", + "SELECT 2", + ]) + }) + + it("preserves WITH..SELECT and DECLARE prefixes (VWAP example)", () => { + const sql = `declare + @symbol := 'BTC-USDT' +WITH sampled AS (SELECT 1 FROM trades) +SELECT * FROM sampled; +declare + @symbol := 'ETH-USDT' +WITH sampled AS (SELECT 2 FROM trades) +SELECT * FROM sampled;` + const out = getQueriesFromText(sql) + expect(out).toHaveLength(2) + expect(out[0]).toContain("BTC-USDT") + expect(out[0]).toContain("WITH sampled") + expect(out[1]).toContain("ETH-USDT") + }) +}) describe("isCursorInComment", () => { it("returns false when cursor is in normal SQL", () => { diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index fbbb4eb1d..2e5828dc3 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -67,6 +67,77 @@ export const stripSQLComments = (text: string): string => return match }) +export const getQueriesFromText = (text: string): string[] => { + if (!text) return [] + const queries: string[] = [] + let buf = "" + let inSingle = false + let inDouble = false + let inLineComment = false + let inBlockComment = false + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + const next = text[i + 1] + + if (inLineComment) { + buf += ch + if (ch === "\n") inLineComment = false + continue + } + if (inBlockComment) { + buf += ch + if (ch === "*" && next === "/") { + buf += next + i++ + inBlockComment = false + } + continue + } + if (inSingle) { + buf += ch + if (ch === "'") inSingle = false + continue + } + if (inDouble) { + buf += ch + if (ch === '"') inDouble = false + continue + } + + if (ch === "-" && next === "-") { + buf += ch + inLineComment = true + continue + } + if (ch === "/" && next === "*") { + buf += ch + inBlockComment = true + continue + } + if (ch === "'") { + inSingle = true + buf += ch + continue + } + if (ch === '"') { + inDouble = true + buf += ch + continue + } + if (ch === ";") { + const trimmed = buf.trim() + if (trimmed) queries.push(trimmed) + buf = "" + continue + } + buf += ch + } + const tail = buf.trim() + if (tail) queries.push(tail) + return queries +} + export const getSelectedText = ( editor: IStandaloneCodeEditor, ): string | undefined => { @@ -169,7 +240,6 @@ export const getQueriesFromPosition = ( column: editorPosition.column, } - // Calculate starting position - default to beginning if not provided const start = startPosition ? { row: startPosition.lineNumber - 1, @@ -177,14 +247,13 @@ export const getQueriesFromPosition = ( } : { row: 0, column: 1 } - // Convert start position to character index let startCharIndex = 0 if (startPosition) { const lines = text.split("\n") const maxRow = Math.min(start.row, lines.length - 1) for (let i = 0; i < maxRow; i++) { if (lines[i] !== undefined) { - startCharIndex += lines[i].length + 1 // +1 for newline character + startCharIndex += lines[i].length + 1 } } if (lines[maxRow] !== undefined) { @@ -412,8 +481,6 @@ export const getQueriesFromPosition = ( } } - // lastStackItem is the last query that is completed before the current cursor position. - // nextSql is the next query that is not completed before the current cursor position, or started after the current cursor position. if (!nextSql) { const sqlText = startPos === -1 @@ -708,8 +775,6 @@ export const getQueryRequestFromLastExecutedQuery = ( } } -// Creates a Request from an AI suggestion, using the original query's start offset -// so that the queryKey matches the original query position in the editor export const getQueryRequestFromAISuggestion = ( editor: IStandaloneCodeEditor, aiSuggestion: { query: string; startOffset: number }, @@ -717,17 +782,14 @@ export const getQueryRequestFromAISuggestion = ( const model = editor.getModel() if (!model) return undefined - // Convert the startOffset back to row/column position const position = model.getPositionAt(aiSuggestion.startOffset) - // Calculate end position from query length const lines = aiSuggestion.query.split("\n") const endRow = lines.length const endColumn = lines[lines.length - 1].length + 1 return { query: aiSuggestion.query, - // row is 0-indexed for Request, but position.lineNumber is 1-indexed row: position.lineNumber - 1, column: position.column, endRow: position.lineNumber - 1 + endRow - 1, @@ -827,11 +889,6 @@ const insertText = ({ ]) } -/** `getTextFixes` is used to create correct prefix and suffix for the text that is inserted in the editor. - * When inserting text, we want it to be neatly aligned with surrounding empty lines. - * For example, appending text at the last line should add one new line, whereas in other cases it should add two. - * This function defines these rules. - */ const getTextFixes = ({ appendAt, model, @@ -880,7 +937,6 @@ const getTextFixes = ({ }, { - // default case when: () => true, then: () => ({ prefix: 2, suffix: 0, selectStartOffset: 1 }), }, @@ -1095,14 +1151,7 @@ export const normalizeQueryText = (query: string) => { } export const findMatches = (model: editor.ITextModel, needle: string) => - model.findMatches( - needle /* searchString */, - true /* searchOnlyEditableRange */, - false /* isRegex */, - true /* matchCase */, - null /* wordSeparators */, - true /* captureMatches */, - ) ?? null + model.findMatches(needle, true, false, true, null, true) ?? null export const getLastPosition = ( editor: IStandaloneCodeEditor, @@ -1355,7 +1404,6 @@ export const validateQueryJIT = ( const queryText = normalizeQueryText(queryAtCursor.query) const version = model.getVersionId() - // Skip if already validated this exact query+version const cached = validationRefs[bufferKey] if (cached && cached.queryText === queryText && cached.version === version) { return @@ -1369,7 +1417,6 @@ export const validateQueryJIT = ( return } - // Skip if execution result already exists for this query const queryKey = createQueryKeyFromRequest(editor, queryAtCursor) const bufferExecutions = getBufferExecutions() if (bufferExecutions[queryKey]) { @@ -1380,7 +1427,6 @@ export const validateQueryJIT = ( return } - // Abort any previous in-flight validation for this buffer validationControllers[bufferKey]?.abort() const controller = new AbortController() validationControllers[bufferKey] = controller @@ -1401,7 +1447,6 @@ export const validateQueryJIT = ( return } - // Query was executed while validation was in flight — skip if (getBufferExecutions()[queryKey]) return if ("error" in result) { @@ -1437,15 +1482,12 @@ export const validateQueryJIT = ( } }) .catch(() => { - // Abort or network error — silently ignore if (validationControllers[bufferKey] === controller) { delete validationControllers[bufferKey] } }) } -// Creates a QueryKey for schema explanation conversations -// Uses DDL hash so same schema = same queryKey = cached conversation export const createSchemaQueryKey = ( tableName: string, ddl: string, @@ -1454,37 +1496,29 @@ export const createSchemaQueryKey = ( return `schema:${tableName}:${ddlHash}@0-0` as QueryKey } -/** - * Check if cursor is inside a line comment (--) or block comment. - * Skips over string literals and quoted identifiers so quotes - * inside comments don't cause false positives. - */ export function isCursorInComment(text: string, cursorOffset: number): boolean { let i = 0 const end = Math.min(cursorOffset, text.length) while (i < end) { const ch = text[i] const next = text[i + 1] - // Line comment: -- until end of line if (ch === "-" && next === "-") { i += 2 while (i < end && text[i] !== "\n") i++ if (i >= cursorOffset) return true continue } - // Block comment: /* until */ if (ch === "/" && next === "*") { i += 2 while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++ if (i >= cursorOffset) return true - i += 2 // skip */ + i += 2 continue } - // Skip over string literals and quoted identifiers so quotes inside comments don't confuse us if (ch === "'" || ch === '"') { i++ while (i < text.length && text[i] !== ch) i++ - i++ // skip closing quote + i++ continue } i++ @@ -1492,12 +1526,7 @@ export function isCursorInComment(text: string, cursorOffset: number): boolean { return false } -/** - * Check if cursor is inside a double-quoted identifier (e.g. "my-table"). - * Tracks quote state from `startOffset` to `cursorOffset`, handling - * escaped quotes (""), single-quoted strings, and comments. - * Returns the offset of the opening " if inside, or -1 if not. - */ +// Returns the offset of the opening `"` if cursor is inside a double-quoted identifier, else -1. export function isCursorInQuotedIdentifier( text: string, startOffset: number, @@ -1518,14 +1547,12 @@ export function isCursorInQuotedIdentifier( if (ch === '"' && next === '"') i++ else if (ch === '"') inDouble = false } else if (ch === "-" && next === "-") { - // Skip line comment i += 2 while (i < cursorOffset && text[i] !== "\n") i++ } else if (ch === "/" && next === "*") { - // Skip block comment i += 2 while (i < cursorOffset && !(text[i] === "*" && text[i + 1] === "/")) i++ - i++ // skip past */ + i++ } else if (ch === '"') { inDouble = true openQuoteOffset = i diff --git a/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx b/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx new file mode 100644 index 000000000..dbff3710f --- /dev/null +++ b/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx @@ -0,0 +1,123 @@ +import React from "react" +import styled from "styled-components" +import { + ArrowClockwiseIcon, + ArrowsInLineVerticalIcon, + ArrowsOutLineVerticalIcon, + GearIcon, +} from "@phosphor-icons/react" +import { Reset } from "@styled-icons/boxicons-regular" +import { Button } from "../../../../components" +import { Switch } from "../../../../components/Switch" +import { CellToolbar } from "../cells/CellToolbar" + +// Chart maximize uses ArrowsOutLineVertical/ArrowsInLineVertical (maximize INTO the cell) +// to stay visually distinct from the cell's CornersOut/CornersIn (maximize INTO the buffer). +const Bar = styled.div` + position: absolute; + top: 0.6rem; + right: 0.8rem; + z-index: 2; + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.3rem 0.6rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.selection}; + border-radius: 0.6rem; +` + +const ToggleGroup = styled.div` + display: flex; + align-items: center; + gap: 0.6rem; +` + +const ToggleLabel = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + user-select: none; +` + +const ResetIcon = styled(Reset)` + width: 1.8rem; + height: 1.8rem; +` + +type Props = { + autoRefresh: boolean + onAutoRefreshChange: (value: boolean) => void + onManualRefresh: () => void + isMaximized: boolean + onMaximizedChange: (maximized: boolean) => void + onOpenSettings: () => void + canResetZoom?: boolean + onResetZoom?: () => void + cellId?: string +} + +export const ChartActions: React.FC = ({ + autoRefresh, + onAutoRefreshChange, + onManualRefresh, + isMaximized, + onMaximizedChange, + onOpenSettings, + canResetZoom, + onResetZoom, + cellId, +}) => ( + + + Auto-refresh + + + {canResetZoom && onResetZoom && ( + + )} + {!autoRefresh && ( + + )} + + + {isMaximized && cellId !== undefined && ( + + )} + +) diff --git a/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx new file mode 100644 index 000000000..c67973deb --- /dev/null +++ b/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useImperativeHandle, useMemo, useRef } from "react" +import ReactECharts from "echarts-for-react/lib/core" +import type { EChartsOption } from "echarts" +import { echarts, QUESTDB_THEME } from "./echartsSetup" + +export type ChartRendererHandle = { + resetZoom: () => void +} + +type Props = { + option: EChartsOption + height?: number | string + onZoomChange?: (start: number, end: number) => void + isFocused?: boolean +} + +// Structural fingerprint — we remount on changes here so stale series from +// the prior option don't linger; routine data refreshes keep the key stable +// so dataZoom state survives the merge. +const structuralKey = (option: EChartsOption): string => { + const rawSeries = option.series + const series = Array.isArray(rawSeries) + ? rawSeries + : rawSeries + ? [rawSeries] + : [] + const seriesTypes = series.map((s) => (s as { type?: string }).type ?? "") + const xAxis = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis + const yAxis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis + const hasZoom = Array.isArray(option.dataZoom) && option.dataZoom.length > 0 + return [ + seriesTypes.join("|"), + (xAxis as { type?: string } | undefined)?.type ?? "", + (yAxis as { type?: string } | undefined)?.type ?? "", + hasZoom ? "z" : "nz", + ].join("::") +} + +type DataZoomEvent = { + start?: number + end?: number + batch?: Array<{ start?: number; end?: number }> +} + +export const ChartRenderer = React.forwardRef( + function ChartRenderer( + { option, height = "100%", onZoomChange, isFocused = true }, + ref, + ) { + const reactEchartsRef = useRef(null) + const wrapperRef = useRef(null) + + // Capture-phase wheel listener must intercept BEFORE ECharts' inner + // listeners so the page scrolls instead of ECharts preventDefaulting. + useEffect(() => { + if (isFocused) return + const node = wrapperRef.current + if (!node) return + const stop = (e: WheelEvent) => { + e.stopPropagation() + } + node.addEventListener("wheel", stop, { capture: true }) + return () => node.removeEventListener("wheel", stop, { capture: true }) + }, [isFocused]) + + useImperativeHandle( + ref, + () => ({ + resetZoom: () => { + const instance = reactEchartsRef.current?.getEchartsInstance() + instance?.dispatchAction({ + type: "dataZoom", + start: 0, + end: 100, + }) + }, + }), + [], + ) + + const key = useMemo(() => structuralKey(option), [option]) + + const events = useMemo(() => { + if (!onZoomChange) return undefined + return { + datazoom: (evt: unknown) => { + const e = evt as DataZoomEvent + const first = e.batch?.[0] ?? e + if ( + typeof first.start === "number" && + typeof first.end === "number" + ) { + onZoomChange(first.start, first.end) + } + }, + } + }, [onZoomChange]) + + return ( +
+ +
+ ) + }, +) diff --git a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx new file mode 100644 index 000000000..9cbf0aba7 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx @@ -0,0 +1,324 @@ +import React, { useEffect, useRef, useState } from "react" +import styled, { keyframes } from "styled-components" +import { XIcon } from "@phosphor-icons/react" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { Button, Input, MultiSelect, Text } from "../../../../components" +import { Select } from "../../../../components/Select" +import type { ChartConfig, ChartType } from "./chartTypes" +import { availableChartTypes, groupColumns } from "./inferChartConfig" + +const TYPE_LABELS: Record = { + line: "Line", + area: "Area", + bar: "Bar", + stackedBar: "Stacked bar", + scatter: "Scatter", + pie: "Pie", + candlestick: "Candlestick", +} + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +` + +const slideIn = keyframes` + from { transform: translateX(100%); } + to { transform: translateX(0); } +` + +const Backdrop = styled.div` + position: absolute; + inset: 0; + z-index: 3; + background: rgba(0, 0, 0, 0.35); + animation: ${fadeIn} 0.2s ease both; +` + +const Panel = styled.div` + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(36rem, 90%); + z-index: 4; + background: ${({ theme }) => theme.color.backgroundDarker}; + border-left: 1px solid ${({ theme }) => theme.color.selection}; + display: flex; + flex-direction: column; + animation: ${slideIn} 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; +` + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.2rem; + border-bottom: 1px solid ${({ theme }) => theme.color.selection}; +` + +const Title = styled.h3` + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const Body = styled.form` + flex: 1; + overflow-y: auto; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +` + +const Field = styled.label` + display: flex; + flex-direction: column; + gap: 0.4rem; +` + +const FieldLabel = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; +` + +const Footer = styled.div` + padding: 1rem 1.2rem; + border-top: 1px solid ${({ theme }) => theme.color.selection}; + display: flex; + justify-content: flex-end; + gap: 0.8rem; +` + +type Props = { + open: boolean + onClose: () => void + columns: ColumnDefinition[] + config: ChartConfig + onSave: (next: ChartConfig) => void +} + +export const ChartSettingsDrawer: React.FC = ({ + open, + onClose, + columns, + config, + onSave, +}) => { + const [draft, setDraft] = useState(config) + // Snapshot at open so commit diffs against it and only writes user-changed fields; otherwise external updates (auto-refresh columns, AI tool calls) would be wiped on Save. + const openSnapshotRef = useRef(config) + const latestConfigRef = useRef(config) + latestConfigRef.current = config + useEffect(() => { + if (open) { + setDraft(config) + openSnapshotRef.current = config + } + }, [open]) + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return + if ( + document.querySelector("[data-radix-popper-content-wrapper]") !== null + ) { + return + } + onClose() + e.stopImmediatePropagation() + } + window.addEventListener("keydown", onKey, { capture: true }) + return () => window.removeEventListener("keydown", onKey, { capture: true }) + }, [open, onClose]) + + if (!open) return null + + const groups = groupColumns(columns) + const hasOhlc = !!draft.ohlc + const types = availableChartTypes(groups, hasOhlc) + + const xCandidates = + draft.type === "candlestick" || draft.type === "line" + ? [...groups.temporal, ...groups.categorical] + : draft.type === "scatter" + ? groups.numeric + : [...groups.categorical, ...groups.temporal, ...groups.numeric] + + const yCandidates = groups.numeric + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + commit() + } + + const commit = () => { + const snapshot = openSnapshotRef.current + const latest = latestConfigRef.current + const userChanges: Partial = {} + for (const key of Object.keys(draft) as Array) { + if (draft[key] !== snapshot[key]) { + ;(userChanges as Record)[key] = draft[key] + } + } + onSave({ ...latest, ...userChanges }) + onClose() + } + + return ( + <> + + +
+ Chart settings + +
+ + + + Name + + setDraft((d) => ({ ...d, name: e.target.value })) + } + /> + + + + Type + + setDraft((d) => ({ ...d, xColumn: e.target.value || null })) + } + options={xCandidates.map((c) => ({ + label: c.name, + value: c.name, + }))} + /> + + )} + + {draft.type === "pie" && ( + + Category + + setDraft((d) => ({ ...d, yColumns: [e.target.value] })) + } + options={yCandidates.map((c) => ({ + label: c.name, + value: c.name, + }))} + /> + ) : ( + + setDraft((d) => ({ ...d, yColumns: next })) + } + options={yCandidates.map((c) => ({ + label: c.name, + value: c.name, + }))} + placeholder="None selected" + /> + )} + + )} + + {(draft.type === "line" || + draft.type === "area" || + draft.type === "bar" || + draft.type === "stackedBar") && + groups.categorical.length > 0 && ( + + Partition by + + + ))} + + ) + } + + return ( + + Permissions + {rows.map((row) => ( + + + + {row.label} + {row.hint} + + + ))} + + ) +} diff --git a/src/scenes/Footer/MCPBridgeStatus/index.tsx b/src/scenes/Footer/MCPBridgeStatus/index.tsx new file mode 100644 index 000000000..5603e236a --- /dev/null +++ b/src/scenes/Footer/MCPBridgeStatus/index.tsx @@ -0,0 +1,123 @@ +import React, { forwardRef, useCallback, useState } from "react" +import styled, { css, keyframes } from "styled-components" +import { PlugsConnectedIcon, PlugsIcon } from "@phosphor-icons/react" +import { PopperToggle } from "../../../components" +import { useMCPBridge } from "../../../providers/MCPBridgeProvider" +import { MCPBridgePairPopover } from "./PairPopover" +import { Tone, accentColor, deriveTone } from "./tone" + +const pulse = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +` + +const Wrapper = styled.button<{ $tone: Tone }>` + display: inline-flex; + align-items: center; + gap: 0.6rem; + height: 3rem; + padding: 0 1rem; + border: 1px solid transparent; + background: ${({ theme }) => theme.color.backgroundDarker}; + color: ${({ theme }) => theme.color.foreground}; + font: inherit; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + background: ${({ theme }) => theme.color.selection}; + } + + &:focus-visible { + outline: 1px solid + ${({ theme, $tone }) => + $tone === "idle" ? theme.color.cyan : theme.color[accentColor($tone)]}; + outline-offset: 2px; + } + + svg { + color: ${({ theme, $tone }) => theme.color[accentColor($tone)]}; + flex-shrink: 0; + ${({ $tone }) => + $tone === "connecting" && + css` + animation: ${pulse} 1.2s ease-in-out infinite; + `} + } +` + +type PillProps = { + tone: Tone + label: string + title: string +} + +const Pill = forwardRef< + HTMLButtonElement, + PillProps & React.HTMLAttributes +>(({ tone, label, title, ...rest }, ref) => { + const Icon = tone === "connected" ? PlugsConnectedIcon : PlugsIcon + return ( + + + {label} + + ) +}) +Pill.displayName = "MCPBridgeStatusPill" + +const POPPER_MODIFIERS = [ + { name: "offset", options: { offset: [0, 8] as [number, number] } }, +] + +export const MCPBridgeStatus: React.FC = () => { + const { status, url, token } = useMCPBridge() + const [popoverOpen, setPopoverOpen] = useState(false) + const paired = !!(url && token) + const tone = deriveTone(paired, status) + + let label: string + let title: string + + if (tone === "idle") { + label = "MCP not connected" + title = + "Click to enter MCP credentials manually, or ask Claude / Codex to pair this console." + } else if (tone === "connected") { + label = "MCP connected" + title = "Paired with the MCP bridge. Click to manage." + } else if (tone === "connecting") { + label = "MCP connecting…" + title = "Connecting to the bridge. Click to manage." + } else { + label = "MCP disconnected" + title = + "Click to re-enter MCP credentials, or ask Claude for a fresh pairing link." + } + + const closePopover = useCallback(() => setPopoverOpen(false), []) + + return ( + } + > + + + ) +} + +export default MCPBridgeStatus diff --git a/src/scenes/Footer/MCPBridgeStatus/tone.ts b/src/scenes/Footer/MCPBridgeStatus/tone.ts new file mode 100644 index 000000000..0ac56772c --- /dev/null +++ b/src/scenes/Footer/MCPBridgeStatus/tone.ts @@ -0,0 +1,25 @@ +// Shared so the pill and popover never drift — both must render the +// same tone for a given `MCPBridgeProvider` state. + +import type { MCPBridgeClientStatus } from "../../../utils/mcp/MCPBridgeClient" + +export type Tone = "connected" | "connecting" | "error" | "idle" + +export const accentColor = (tone: Tone) => + tone === "connected" + ? "green" + : tone === "connecting" + ? "pinkPrimary" + : tone === "error" + ? "red" + : "gray2" + +export const deriveTone = ( + paired: boolean, + status: MCPBridgeClientStatus, +): Tone => { + if (!paired) return "idle" + if (status === "connected") return "connected" + if (status === "connecting" || status === "reconnecting") return "connecting" + return "error" +} diff --git a/src/scenes/Footer/index.tsx b/src/scenes/Footer/index.tsx index af3742319..0dc562de5 100644 --- a/src/scenes/Footer/index.tsx +++ b/src/scenes/Footer/index.tsx @@ -32,6 +32,7 @@ import { Link, Text, TransitionDuration } from "../../components" import CtaBanner from "./CtaBanner" import BuildVersion from "./BuildVersion" import ConnectionStatus from "./ConnectionStatus" +import MCPBridgeStatus from "./MCPBridgeStatus" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" import { useSettings } from "../../providers" @@ -118,6 +119,7 @@ const Footer = () => { + {showBuildVersion && } { - - - -
- - - -
- - - - - - - - - -
- - - -