diff --git a/cypress.config.js b/cypress.config.js index c5a3de661..ce7f1f3fa 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -6,15 +6,15 @@ const baseUrl = `http://localhost:9999${contextPath}`; module.exports = defineConfig({ defaultCommandTimeout: 10000, e2e: { - defaultCommandTimeout: 30000, + defaultCommandTimeout: 10000, screenshotOnRunFailure: true, screenshotsFolder: "e2e/screenshots", videosFolder: "e2e/videos", video: false, baseUrl: baseUrl, chromeWebSecurity: false, //if it is true, cypress does not allow redirects - viewportWidth: 1280, - viewportHeight: 720, + viewportWidth: 1920, + viewportHeight: 1080, specPattern: "e2e/tests/**/*.spec.js", supportFile: "e2e/commands.js", setupNodeEvents(on) { diff --git a/e2e/commands.js b/e2e/commands.js index 55f11869e..8953f8e9c 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -89,8 +89,7 @@ beforeEach(() => { }); Cypress.Commands.add("clearSimulatedWarnings", () => { - cy.typeQuery("select simulate_warnings('', '');"); - cy.clickRunIconInLine(1); + cy.execQuery("select simulate_warnings('', '');"); }); Cypress.Commands.add("getByDataHook", (name) => @@ -205,26 +204,39 @@ Cypress.Commands.add("getCursorQueryDecoration", () => cy.get(".cursorQueryDecoration") ); -Cypress.Commands.add("getCursorQueryGlyph", () => cy.get(".cursorQueryGlyph")); +Cypress.Commands.add("getCursorQueryGlyph", () => cy.get(".glyph-widget-container")); Cypress.Commands.add("getRunIconInLine", (lineNumber) => { cy.getCursorQueryGlyph().should("be.visible"); - const selector = `.cursorQueryGlyph-line-${lineNumber}`; - return cy.get(selector).first(); + const selector = `.glyph-widget-${lineNumber}`; + return cy.get(selector).find(".glyph-run-icon").first(); +}); + +Cypress.Commands.add("getAIIconInLine", (lineNumber, expectedClass) => { + const selector = `.glyph-widget-${lineNumber}`; + cy.get(selector).should("be.visible"); + if (expectedClass) { + return cy.get(`${selector} .glyph-ai-icon.${expectedClass}`); + } + return cy.get(selector).find(".glyph-ai-icon"); }); Cypress.Commands.add("getCancelIconInLine", (lineNumber) => { - cy.get(".cancelQueryGlyph").should("be.visible"); - const selector = `.cancelQueryGlyph-line-${lineNumber}`; - return cy.get(selector).first(); + const selector = `.glyph-widget-${lineNumber}`; + cy.get(selector).should("be.visible"); + return cy.get(selector).find(".glyph-run-icon.cancel"); }); +Cypress.Commands.add("getSuccessIcons", () => cy.get(".glyph-run-icon.success")); + +Cypress.Commands.add("getErrorIcons", () => cy.get(".glyph-run-icon.error")); + Cypress.Commands.add("openRunDropdownInLine", (lineNumber) => { - cy.getRunIconInLine(lineNumber).rightclick(); + cy.getRunIconInLine(lineNumber).rightclick({ force: true }); }); Cypress.Commands.add("clickRunIconInLine", (lineNumber) => { - cy.getRunIconInLine(lineNumber).click(); + cy.getRunIconInLine(lineNumber).click({ force: true }); }); Cypress.Commands.add("clickDropdownRunQuery", () => { @@ -562,3 +574,7 @@ Cypress.Commands.add("createTabWithContent", (content, title) => { cy.get(".chrome-tab[active] .chrome-tab-rename").should("not.be.visible"); } }); + +Cypress.Commands.add("getActiveTabName", () => { + return cy.get(".chrome-tab[active]").get(".chrome-tab-title").invoke("text"); +}) diff --git a/e2e/questdb b/e2e/questdb index b58d6961a..031ff0f7c 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit b58d6961a58c3623fa44221a6413864a58c23cfd +Subproject commit 031ff0f7ce2de9510daa824744e33b8a4d2bc208 diff --git a/e2e/tests/console/aiAssistant.spec.js b/e2e/tests/console/aiAssistant.spec.js new file mode 100644 index 000000000..36f3e5c7d --- /dev/null +++ b/e2e/tests/console/aiAssistant.spec.js @@ -0,0 +1,2160 @@ +/// + +const { createToolCallFlow, createMultiTurnFlow } = require("../../utils") + +/** + * Intercepts AI requests with a custom response body. + * Use this when you need to control the exact response content. + * + * @param {"anthropic" | "openai"} provider - The AI provider to intercept + * @param {Object} responseBody - The response body to return + * @param {string} [alias] - Optional custom alias for the intercept + */ +function interceptAIRequestWithResponse(provider, responseBody, alias) { + const aliasName = alias || `${provider}CustomResponse` + + if (provider === "openai") { + cy.intercept("POST", "https://api.openai.com/v1/responses", { + statusCode: 200, + delay: 200, + body: responseBody, + }).as(aliasName) + } else if (provider === "anthropic") { + cy.intercept("POST", "https://api.anthropic.com/v1/messages", { + statusCode: 200, + delay: 200, + body: responseBody, + }).as(aliasName) + } +} + +/** + * Creates a valid OpenAI response for explain schema requests. + * @param {Object} schemaData - The schema explanation data + * @returns {Object} OpenAI response body + */ +function createOpenAIExplainSchemaResponse(schemaData) { + return { + id: "resp_mock_schema", + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: JSON.stringify(schemaData), + }, + ], + }, + ], + output_parsed: schemaData, + usage: { + input_tokens: 150, + output_tokens: 200, + }, + } +} + +/** + * Creates an OpenAI response where output_parsed is null, triggering parse error. + * This happens when OpenAI cannot parse the response into the expected schema format. + * @returns {Object} OpenAI response body with null output_parsed + */ +function createOpenAIParseFailureResponse() { + return { + id: "resp_mock_parse_fail", + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "", + }, + ], + }, + ], + output_parsed: null, + usage: { + input_tokens: 150, + output_tokens: 50, + }, + } +} + +function interceptAIChatRequest(provider, alias, delay = 200) { + const aliasName = alias || `${provider}ChatRequest` + + if (provider === "openai") { + cy.intercept("POST", "https://api.openai.com/v1/responses", { + statusCode: 200, + delay, + body: { + id: "resp_mock_chat", + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Test response" }], + }, + ], + }, + }).as(aliasName) + } else if (provider === "anthropic") { + cy.intercept("POST", "https://api.anthropic.com/v1/messages", { + statusCode: 200, + delay, + body: { + id: "msg_mock_chat", + type: "message", + role: "assistant", + content: [{ type: "text", text: "Test response" }], + model: "claude-sonnet-4-20250514", + stop_reason: "end_turn", + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + }).as(aliasName) + } +} + +/** + * Intercepts AI provider token validation requests. + * + * @param {"anthropic" | "openai"} provider - The AI provider to intercept + * @param {boolean} success - If true, returns 200 success response; if false, returns 401 error response + */ +function interceptTokenValidation(provider, success) { + if (provider === "openai") { + if (success) { + cy.intercept("POST", "https://api.openai.com/v1/responses", { + statusCode: 200, + delay: 200, + body: { + id: "resp_mock_test", + object: "response", + created_at: Date.now(), + status: "completed", + output: [], + }, + }).as("openaiValidation") + } else { + cy.intercept("POST", "https://api.openai.com/v1/responses", { + statusCode: 401, + delay: 200, + body: { + error: { + message: + "Incorrect API key provided: ***. You can find your API key at https://platform.openai.com/account/api-keys.", + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }, + }, + }).as("openaiValidation") + } + } else if (provider === "anthropic") { + if (success) { + cy.intercept("POST", "https://api.anthropic.com/v1/messages", { + statusCode: 200, + delay: 200, + body: { + id: "msg_mock_test", + type: "message", + role: "assistant", + content: [], + model: "claude-sonnet-4-20250514", + stop_reason: "end_turn", + usage: { + input_tokens: 10, + output_tokens: 5, + }, + }, + }).as("anthropicValidation") + } else { + cy.intercept("POST", "https://api.anthropic.com/v1/messages", { + statusCode: 401, + delay: 200, + body: { + type: "error", + error: { + type: "authentication_error", + message: "invalid x-api-key", + }, + request_id: "req_mock_test", + }, + }).as("anthropicValidation") + } + } +} + +/** + * Creates localStorage value for AI assistant settings with OpenAI configured. + * Use this with cy.loadConsoleWithAuth's localStorageItems parameter. + * + * @returns {Object} Object with localStorage key-value pair + */ +function getOpenAIConfiguredSettings() { + return { + "ai.assistant.settings": JSON.stringify({ + selectedModel: "gpt-5-mini", + providers: { + openai: { + apiKey: "test-openai-key", + enabledModels: ["gpt-5-mini", "gpt-5"], + grantSchemaAccess: true, + }, + }, + }), + } +} + +describe("ai assistant", () => { + describe("onboarding and settings", () => { + beforeEach(() => { + cy.loadConsoleWithAuth() + }) + + it("should display ai assistant promo", () => { + // When + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + + // Then + cy.getByDataHook("ai-promo-modal").should("be.visible") + + // When + cy.getByDataHook("ai-promo-close").should("be.visible").click() + + // Then + cy.getByDataHook("ai-promo-modal").should("not.exist") + + // When + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + + // Then + cy.getByDataHook("ai-settings-modal-step-one").should("be.visible") + }) + + it("should handle invalid api key", () => { + // When + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + + // Then + cy.getByDataHook("ai-settings-modal-step-one").should("be.visible") + cy.getByDataHook("ai-settings-api-key") + .should("be.visible") + .should("have.attr", "placeholder", "Enter API key") + .should("be.disabled") + + // When + cy.getByDataHook("ai-settings-provider-anthropic").click() + + // Then + cy.getByDataHook("ai-settings-api-key") + .should("be.visible") + .should("have.attr", "placeholder", "Enter Anthropic API key") + .should("not.be.disabled") + + // When + cy.getByDataHook("ai-settings-provider-openai").click() + + // Then + cy.getByDataHook("ai-settings-api-key") + .should("be.visible") + .should("have.attr", "placeholder", "Enter OpenAI API key") + .should("not.be.disabled") + ;["anthropic", "openai"].forEach((provider) => { + // Given + interceptTokenValidation(provider, false) + + // When + cy.getByDataHook(`ai-settings-provider-${provider}`).click() + + // Then + cy.getByDataHook("ai-settings-api-key") + .should("be.visible") + .should( + "have.attr", + "placeholder", + `Enter ${provider === "anthropic" ? "Anthropic" : "OpenAI"} API key`, + ) + .should("not.be.disabled") + .should("be.empty") + + // When + cy.getByDataHook("ai-settings-api-key").type("invalid-api-key") + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then + cy.getByDataHook("multi-step-modal-next-button") + .should("be.disabled") + .should("contain", "Validating...") + + // When + cy.wait(`@${provider}Validation`) + + // Then + cy.getByDataHook("ai-settings-api-key-error").should("be.visible") + }) + }) + + it("should handle valid api key", () => { + // Given + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + ;["anthropic", "openai"].forEach((provider) => { + // Given + interceptTokenValidation(provider, true) + + // When + cy.getByDataHook(`ai-settings-provider-${provider}`).click() + + // When + cy.getByDataHook("ai-settings-api-key").type("valid-api-key") + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then + cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") + cy.getByDataHook("multi-step-modal-cancel-button").click() + }) + }) + + it("should show ai buttons after setup is completed", () => { + // Given + interceptTokenValidation("openai", true) + + // When + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + cy.getByDataHook("ai-settings-provider-openai").click() + cy.getByDataHook("ai-settings-api-key").type("valid-api-key") + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then + cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") + + // When + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then + cy.getByDataHook("ai-assistant-settings-button").should( + "contain", + "Settings", + ) + cy.getByDataHook("ai-chat-button").should("be.visible") + cy.getByDataHook("ai-settings-model-dropdown").should("be.visible") + + // When + cy.getByDataHook("ai-settings-model-dropdown").click() + + // Then + cy.getByDataHook("ai-settings-model-item").should("be.visible") + ;[0, 1].forEach((index) => { + let label = "" + cy.getByDataHook(`ai-settings-model-item`) + .eq(index) + .getByDataHook(`ai-settings-model-item-label`) + .invoke("text") + .then((text) => { + label = text + }) + cy.getByDataHook(`ai-settings-model-item`).eq(index).click() + cy.getByDataHook("ai-settings-model-dropdown").should("contain", label) + }) + + // When + cy.typeQuery("SELECT 1;") + + // Then + cy.getAIIconInLine(1).should("be.visible") + + // When + cy.getByDataHook("ai-assistant-settings-button").click() + + // Then + cy.getByDataHook("ai-settings-validated-badge") + .should("be.visible") + .should("contain", "Validated") + cy.getByDataHook("ai-settings-provider-openai") + .getByDataHook("ai-settings-provider-status") + .should("be.visible") + .should("contain", "Enabled") + + cy.getByDataHook("ai-settings-provider-anthropic") + .getByDataHook("ai-settings-provider-status") + .should("be.visible") + .should("contain", "Inactive") + + // When + cy.getByDataHook("ai-settings-test-api") + .should("be.visible") + .should("contain", "Remove API Key") + .click() + + // Then + cy.getByDataHook("ai-settings-validated-badge").should("not.exist") + cy.getByDataHook("ai-settings-provider-openai") + .getByDataHook("ai-settings-provider-status") + .should("be.visible") + .should("contain", "Inactive") + + // When + cy.getByDataHook("ai-settings-save").click() + + // Then + cy.getByDataHook("ai-settings-model-dropdown").should("not.exist") + cy.getByDataHook("ai-chat-button").should("not.exist") + cy.getByDataHook("ai-assistant-settings-button").should( + "contain", + "Configure", + ) + }) + + it("should not provide schema tools when schema access is disabled", () => { + const schemaTools = ["get_tables", "get_table_schema"] + + // Given + interceptTokenValidation("openai", true) + + // When + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + cy.getByDataHook("ai-settings-provider-openai").click() + cy.getByDataHook("ai-settings-api-key").type("valid-api-key") + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then + cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") + + // When + cy.getByDataHook("ai-settings-schema-access").click() + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then - AI chat should be available + cy.get(".toast-success-container").should("be.visible").click() + cy.getByDataHook("ai-chat-button").should("be.visible") + + // When - Open chat and send a message + interceptAIChatRequest("openai", "chatWithoutSchema") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Hello, test message") + cy.getByDataHook("chat-send-button").click() + + // Then - Verify request does NOT contain schema tools + cy.wait("@chatWithoutSchema").then((interception) => { + const tools = interception.request.body.tools || [] + const toolNames = tools.map((t) => t.name || t.function?.name) + schemaTools.forEach((schemaTool) => { + expect(toolNames).to.not.include(schemaTool) + }) + }) + + // When - Open settings modal and enable schema access + cy.getByDataHook("ai-assistant-settings-button").click() + cy.getByDataHook("ai-settings-schema-access").click() + cy.getByDataHook("ai-settings-save").click() + cy.get(".toast-success-container").should("be.visible").click() + + // When - Send another message + interceptAIChatRequest("openai", "chatWithSchema") + cy.getByDataHook("chat-input-textarea").type("Another test message") + cy.getByDataHook("chat-send-button").click() + + // Then - Verify request DOES contain schema tools + cy.wait("@chatWithSchema").then((interception) => { + const tools = interception.request.body.tools || [] + const toolNames = tools.map((t) => t.name || t.function?.name) + schemaTools.forEach((schemaTool) => { + expect(toolNames).to.include(schemaTool) + }) + }) + }) + + it("should work with multiple providers", () => { + const openaiEnabledModels = [] + const anthropicEnabledModels = [] + + // Given - Set up OpenAI provider first + interceptTokenValidation("openai", true) + + // When - Complete setup with OpenAI + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + cy.getByDataHook("ai-promo-continue").should("be.visible").click() + cy.getByDataHook("ai-settings-provider-openai").click() + cy.getByDataHook("ai-settings-api-key").type("valid-openai-key") + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then - Should be on step two + cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") + + // When - Store enabled model labels for OpenAI + cy.get('[data-model-enabled="true"]').each(($modelRow) => { + openaiEnabledModels.push($modelRow.attr("data-model")) + }) + + cy.getByDataHook("multi-step-modal-next-button").click() + + // Then - Verify model dropdown shows exactly the enabled OpenAI models + cy.get(".toast-success-container").should("be.visible").click() + cy.getByDataHook("ai-settings-model-dropdown").click() + cy.then(() => { + cy.getByDataHook("ai-settings-model-item").should( + "have.length", + openaiEnabledModels.length, + ) + openaiEnabledModels.forEach((modelLabel) => { + cy.getByDataHook("ai-settings-model-item").contains(modelLabel) + }) + }) + cy.getByDataHook("ai-settings-model-dropdown").click() // close dropdown + + // When - Open settings and configure Anthropic provider + interceptTokenValidation("anthropic", true) + cy.getByDataHook("ai-assistant-settings-button").click() + + // Then - OpenAI should show Enabled, Anthropic should show Inactive + cy.getByDataHook("ai-settings-provider-openai") + .getByDataHook("ai-settings-provider-status") + .should("contain", "Enabled") + cy.getByDataHook("ai-settings-provider-anthropic") + .getByDataHook("ai-settings-provider-status") + .should("contain", "Inactive") + + // When - Configure Anthropic + cy.getByDataHook("ai-settings-provider-anthropic").click() + cy.getByDataHook("ai-settings-api-key").type("valid-anthropic-key") + cy.getByDataHook("ai-settings-test-api").click() + + // Then - Should show validating and then validated + cy.wait("@anthropicValidation") + + // Then - Anthropic should no longer show Inactive + cy.getByDataHook("ai-settings-provider-anthropic") + .getByDataHook("ai-settings-provider-status") + .should("not.contain", "Inactive") + + // When - Store enabled model labels for Anthropic + cy.get('[data-enabled="true"]').each(($modelRow) => { + anthropicEnabledModels.push($modelRow.attr("data-model")) + }) + + // When - Save settings + cy.getByDataHook("ai-settings-save").click() + cy.get(".toast-success-container").should("be.visible").click() + + // Then - Model dropdown should contain models from both providers + cy.getByDataHook("ai-settings-model-dropdown").click() + cy.then(() => { + const allEnabledModels = [ + ...openaiEnabledModels, + ...anthropicEnabledModels, + ] + cy.getByDataHook("ai-settings-model-item").should( + "have.length", + allEnabledModels.length, + ) + allEnabledModels.forEach((modelLabel) => { + cy.getByDataHook("ai-settings-model-item").contains(modelLabel) + }) + }) + + // When - Select first OpenAI model and open chat + cy.then(() => { + cy.getByDataHook("ai-settings-model-item") + .contains(openaiEnabledModels[0]) + .click() + }) + interceptAIChatRequest("openai", "openaiChat") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Test message for OpenAI") + cy.getByDataHook("chat-send-button").click() + + // Then - Should intercept OpenAI request + cy.wait("@openaiChat") + + // When - Select first Anthropic model from dropdown + cy.getByDataHook("ai-settings-model-dropdown").click() + cy.then(() => { + cy.getByDataHook("ai-settings-model-item") + .contains(anthropicEnabledModels[0]) + .click() + }) + + // When - Send another message + interceptAIChatRequest("anthropic", "anthropicChat") + cy.getByDataHook("chat-input-textarea").type("Test message for Anthropic") + cy.getByDataHook("chat-send-button").click() + + // Then - Should intercept Anthropic request + cy.wait("@anthropicChat") + }) + }) + + describe("ai chat window ergonomics", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("should open chat window with blank state on first open", () => { + // When - Click the chat button + cy.getByDataHook("ai-chat-button").should("be.visible").click() + + // Then - Chat window should be visible with blank state + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-blank-state").should("be.visible") + cy.getByDataHook("chat-window-title").should("contain", "AI Assistant") + }) + + it("should show current empty chat in history", () => { + // When - Open chat window + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + + // When - Click history + cy.getByDataHook("chat-window-history").click() + + // Then - Should see the current empty chat in history + cy.getByDataHook("chat-history-list").should("be.visible") + cy.getByDataHook("chat-history-item").should("have.length", 1) + }) + + it("should have new chat disabled when current chat is empty", () => { + // When - Open chat window + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + + // Then - New chat button should be disabled (current chat is empty) + cy.getByDataHook("chat-window-new").should("be.disabled") + }) + + it("should persist messages and restore on reopen", () => { + // Given - Set up intercept for chat request + interceptAIChatRequest("openai") + + // When - Open chat and send a message + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Hello AI") + cy.getByDataHook("chat-send-button").click() + + // Then - Message should appear + cy.wait("@openaiChatRequest") + cy.getByDataHook("chat-message-user").should("be.visible") + cy.getByDataHook("chat-message-assistant").should("be.visible") + + // When - Close chat window using close button + cy.getByDataHook("chat-window-close").click() + + // Then - Chat window should be closed + cy.getByDataHook("ai-chat-window").should("not.exist") + + // When - Reopen chat window + cy.getByDataHook("ai-chat-button").click() + + // Then - Previous messages should be restored + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-message-user").should("be.visible") + cy.getByDataHook("chat-message-assistant").should("be.visible") + }) + + it("should show chats in history after creating multiple chats", () => { + // Given - Create first chat with a message + interceptAIChatRequest("openai", "firstChat") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("First chat message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@firstChat") + + // When - Create a new chat + cy.getByDataHook("chat-window-new").should("not.be.disabled").click() + + // Then - Should see blank state for new chat + cy.getByDataHook("chat-blank-state").should("be.visible") + + // When - Send message in second chat + interceptAIChatRequest("openai", "secondChat") + cy.getByDataHook("chat-input-textarea").type("Second chat message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@secondChat") + + // When - Open history + cy.getByDataHook("chat-window-history").should("not.be.disabled").click() + + // Then - Should see both chats in history + cy.getByDataHook("chat-history-list").should("be.visible") + cy.getByDataHook("chat-history-item").should("have.length", 2) + }) + + it("should rename chats from history", () => { + // Given - Create a chat with a message + interceptAIChatRequest("openai") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("Test message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@openaiChatRequest") + + // When - Open history and click edit on the chat + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-item").first().trigger("mouseover") + cy.getByDataHook("chat-history-edit").first().click() + + // Then - Rename input should be visible + cy.getByDataHook("chat-history-rename").should("be.visible") + + // When - Type new name and press Enter + cy.getByDataHook("chat-history-rename") + .clear() + .type("Renamed Chat{enter}") + + // Then - Chat should be renamed + cy.getByDataHook("chat-history-title") + .first() + .should("contain", "Renamed Chat") + + // When - Navigate to that chat + cy.getByDataHook("chat-history-item").first().click() + + // Then - Chat window title should show the new name + cy.getByDataHook("chat-window-title").should("contain", "Renamed Chat") + }) + + it("should delete chats from history", () => { + // Given - Create two chats + interceptAIChatRequest("openai", "firstChat") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("First message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@firstChat") + + cy.getByDataHook("chat-window-new").click() + interceptAIChatRequest("openai", "secondChat") + cy.getByDataHook("chat-input-textarea").type("Second message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@secondChat") + + // When - Open history + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-item").should("have.length", 2) + + // When - Delete the first chat + cy.getByDataHook("chat-history-item").first().trigger("mouseover") + cy.getByDataHook("chat-history-delete").first().click() + + // Then - Confirm deletion dialog + cy.contains("button", "Delete").should("be.visible").click() + + // Then - Only one chat should remain + cy.getByDataHook("chat-history-item").should("have.length", 1) + }) + + it("should delete empty chat when closing chat window", () => { + // When - Open chat window (creates empty chat) + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-blank-state").should("be.visible") + + // When - Close chat window without sending any message + cy.getByDataHook("chat-window-close").click() + + // Then - Chat window should be closed + cy.getByDataHook("ai-chat-window").should("not.exist") + + // When - Reopen chat window + cy.getByDataHook("ai-chat-button").click() + + // Then - Should see blank state again (empty chat was deleted) + cy.getByDataHook("chat-blank-state").should("be.visible") + }) + + it("should delete empty chat when creating new chat", () => { + // Given - Create a chat with a message first + interceptAIChatRequest("openai") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("First message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@openaiChatRequest") + + // When - Create new chat (which is empty) + cy.getByDataHook("chat-window-new").click() + cy.getByDataHook("chat-blank-state").should("be.visible") + + // When - Create another new chat without sending message in previous + cy.getByDataHook("chat-window-new").should("be.disabled") + + // Then - New chat button should be disabled when current chat is empty + // (This prevents creating multiple empty chats) + }) + + it("should search chats by name", () => { + // Given - Create three chats with different names + const chatNames = [ + "Database Queries", + "Performance Tips", + "Schema Design", + ] + + // Create first chat + interceptAIChatRequest("openai", "chat1") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("First message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat1") + + // Create second chat + cy.getByDataHook("chat-window-new").click() + interceptAIChatRequest("openai", "chat2") + cy.getByDataHook("chat-input-textarea").type("Second message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat2") + + // Create third chat + cy.getByDataHook("chat-window-new").click() + interceptAIChatRequest("openai", "chat3") + cy.getByDataHook("chat-input-textarea").type("Third message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat3") + + // When - Open history + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-item").should("have.length", 3) + + // When - Rename each chat + chatNames.forEach((name, index) => { + cy.getByDataHook("chat-history-item").eq(index).trigger("mouseover") + cy.getByDataHook("chat-history-edit").eq(index).click() + cy.getByDataHook("chat-history-rename").clear().type(`${name}{enter}`) + cy.getByDataHook("chat-history-title").eq(index).should("contain", name) + }) + + // Then - Search for "Database" should show only one result + cy.getByDataHook("chat-history-search").type("Database") + cy.getByDataHook("chat-history-item").should("have.length", 1) + cy.getByDataHook("chat-history-title").should( + "contain", + "Database Queries", + ) + + // When - Search for "Performance" + cy.getByDataHook("chat-history-search").clear().type("Performance") + cy.getByDataHook("chat-history-item").should("have.length", 1) + cy.getByDataHook("chat-history-title").should( + "contain", + "Performance Tips", + ) + + // When - Search for "Schema" + cy.getByDataHook("chat-history-search").clear().type("Schema") + cy.getByDataHook("chat-history-item").should("have.length", 1) + cy.getByDataHook("chat-history-title").should("contain", "Schema Design") + + // When - Search for partial match "e" (should match all three) + cy.getByDataHook("chat-history-search").clear().type("e") + cy.getByDataHook("chat-history-item").should("have.length", 3) + + // When - Search for non-existent term + cy.getByDataHook("chat-history-search").clear().type("xyz123") + cy.getByDataHook("chat-history-item").should("have.length", 0) + cy.contains("No chats match your search").should("be.visible") + + // When - Clear search with Escape key + cy.getByDataHook("chat-history-search").type("{esc}") + cy.getByDataHook("chat-history-search").should("have.value", "") + cy.getByDataHook("chat-history-item").should("have.length", 3) + }) + + it("should switch between chats from history", () => { + // Given - Create two chats with different messages + interceptAIChatRequest("openai", "chat1") + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").type("First chat unique message") + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat1") + + cy.getByDataHook("chat-window-new").click() + interceptAIChatRequest("openai", "chat2") + cy.getByDataHook("chat-input-textarea").type( + "Second chat different content", + ) + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat2") + + // When - Open history + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-item").should("have.length", 2) + + // When - Click on the first chat (older one) + cy.getByDataHook("chat-history-item").eq(1).click() + + // Then - Should see the first chat's message + cy.getByDataHook("chat-message-user").should( + "contain", + "First chat unique message", + ) + + // When - Go back to history and select second chat + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-item").eq(0).click() + + // Then - Should see the second chat's message + cy.getByDataHook("chat-message-user").should( + "contain", + "Second chat different content", + ) + }) + }) + + describe("ai status indicator", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("should show status indicator only when chat window is closed during AI operation", () => { + // Given - Set up intercept with latency + interceptAIChatRequest("openai", "slowRequest") + + // When - Open chat window and send a message + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Test question for status") + cy.getByDataHook("chat-send-button").click() + + // Then - Status indicator should NOT be visible while chat is open + cy.getByDataHook("ai-status-indicator").should("not.exist") + + // When - Close chat window + cy.getByDataHook("chat-window-close").click() + + // Then - Status indicator should be visible + cy.getByDataHook("ai-status-indicator").should("be.visible") + cy.getByDataHook("ai-status-text").should("contain", "Working...") + + // When - Open chat panel again + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + + // Then - Status indicator should NOT be visible + cy.getByDataHook("ai-status-indicator").should("not.exist") + + // Cleanup - Wait for request to complete + cy.wait("@slowRequest") + }) + + it("should open chat window with previous message when clicking View chat button", () => { + // Given - Set up intercept with latency + interceptAIChatRequest("openai", "slowRequest") + + // When - Open chat window and send a message + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("My specific test question") + cy.getByDataHook("chat-send-button").click() + + // When - Close chat window + cy.getByDataHook("chat-window-close").click() + + // Then - Status indicator should be visible with View chat button + cy.getByDataHook("ai-status-indicator").should("be.visible") + cy.getByDataHook("ai-status-view-chat").should("be.visible") + + // When - Click View chat button + cy.getByDataHook("ai-status-view-chat").click() + + // Then - Chat window should open and contain the previous message + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-message-user").should( + "contain", + "My specific test question", + ) + + // And - Status indicator should be gone + cy.getByDataHook("ai-status-indicator").should("not.exist") + + // Cleanup - Wait for request to complete + cy.wait("@slowRequest") + }) + + it("should show aborted status and display cancellation message in chat when aborting", () => { + // Given - Set up intercept with latency + interceptAIChatRequest("openai", "slowRequest", 2000) + + // When - Open chat window and send a message + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Question to abort") + cy.getByDataHook("chat-send-button").click() + + // When - Close chat window + cy.getByDataHook("chat-window-close").click() + + // Then - Status indicator should be visible with Abort button + cy.getByDataHook("ai-status-indicator").should("be.visible") + cy.getByDataHook("ai-status-stop").should("be.visible") + + // When - Click Abort button + cy.getByDataHook("ai-status-stop").click() + + // Then - Status indicator should show Cancelled status + cy.getByDataHook("ai-status-text").should("contain", "Cancelled") + cy.getByDataHook("assistant-mode-operation-has-been-cancelled").should( + "be.visible", + ) + + // Then - After a few seconds, status indicator should disappear + cy.wait(2000) + cy.getByDataHook("ai-status-indicator").should("not.exist") + + // When - Open chat window + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("ai-chat-window").should("be.visible") + + // Then - Chat should show cancellation error message + cy.getByDataHook("chat-message-error") + .should("be.visible") + .should("contain", "Operation has been cancelled") + }) + }) + + describe("query - chat integration", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("should show initial query box with quick actions when glyph icon is clicked", () => { + // Given - Type a query + cy.typeQuery("SELECT a;") + cy.clickRunIconInLine(1) + cy.getByDataHook("error-notification").should("be.visible") + + // Then - AI icon should be in noChat state (hollow) + cy.getAIIconInLine(1, "noChat").should("be.visible") + + // When - Click on AI icon to open chat window + cy.getAIIconInLine(1).click() + + // Then - Chat window should open and finish loading + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading to complete + cy.getByDataHook("chat-context-badge") + .should("be.visible") + .should("contain", "SELECT a") + cy.getByDataHook("chat-initial-query-box").should("be.visible") + cy.getByDataHook("button-explain-query").should("be.visible") + cy.getByDataHook("button-fix-query").should("be.visible") + }) + + it("should transition AI glyph icon from noChat to highlight to active", () => { + // Given - Type a query + cy.typeQuery("SELECT 1;") + + // Then - AI icon should be in noChat state (hollow) + cy.getAIIconInLine(1, "noChat").should("be.visible") + + // When - Click on AI icon to open chat window + cy.getAIIconInLine(1).click() + + // Then - Chat window should open and finish loading + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading to complete + cy.getByDataHook("chat-context-badge").should("be.visible") + + // When - Send a message + interceptAIChatRequest("openai") + // Use force:true because context badge overlays the textarea + cy.getByDataHook("chat-input-textarea").type("Explain this query", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + + // Then - AI icon should transition to highlight state + cy.getAIIconInLine(1, "highlight").should("be.visible") + + // Then - After ~1 second, AI icon should transition to active state + cy.wait("@openaiChatRequest") + cy.getAIIconInLine(1, "active").should("be.visible") + }) + + it("should show active state for queries with existing chats", () => { + // Given - Create two queries + cy.typeQuery("SELECT 1;\n\nSELECT 2;") + + // Then - Both should be in noChat state initially + cy.getAIIconInLine(1, "noChat").should("be.visible") + cy.getAIIconInLine(3, "noChat").should("be.visible") + + // When - Create chat for first query + cy.getAIIconInLine(1).click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading + interceptAIChatRequest("openai", "chat1") + cy.getByDataHook("chat-input-textarea").type("Explain first query", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat1") + + // Then - First query should have active state, second should still be noChat + cy.getAIIconInLine(1, "active").should("be.visible") + cy.getAIIconInLine(3, "noChat").should("be.visible") + + // When - Create chat for second query + cy.getAIIconInLine(3).click() + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading + interceptAIChatRequest("openai", "chat2") + cy.getByDataHook("chat-input-textarea").type("Explain second query", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + cy.wait("@chat2") + cy.wait(2000) // Wait for highlight -> active transition + + // Then - Both queries should have active state + cy.getAIIconInLine(1, "active").should("be.visible") + cy.getAIIconInLine(3, "active").should("be.visible") + }) + + it("should toggle chat window when clicking AI icon for current query", () => { + // Given - Type a query and create a chat + cy.typeQuery("SELECT 1;") + cy.getAIIconInLine(1).click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading + cy.getByDataHook("chat-initial-query-box").should("be.visible") + interceptAIChatRequest("openai") + cy.getByDataHook("chat-input-textarea").type("Test message", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + cy.wait("@openaiChatRequest") + + // Then - Chat window should be open + cy.getByDataHook("ai-chat-window").should("be.visible") + + // When - Click AI icon again (should close chat) + cy.getAIIconInLine(1, "active").click() + + // Then - Chat window should be closed + cy.getByDataHook("ai-chat-window").should("not.exist") + + // When - Click AI icon again (should open chat) + cy.getAIIconInLine(1, "active").click() + + // Then - Chat window should be open with previous messages + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-message-user").should("be.visible") // Wait for messages to load + cy.getByDataHook("chat-message-user").should("contain", "Test message") + }) + + it("should move glyph icons when query position changes", () => { + // Given - Type a query starting at line 1 + cy.typeQuery("SELECT 1;") + + // Then - AI icon should be on line 1 + cy.getAIIconInLine(1, "noChat").should("be.visible") + + // When - Add empty lines before the query (press Home, then Enter twice) + cy.get(".monaco-editor textarea").type("{home}{enter}{enter}") + + // Then - AI icon should move to line 3 + cy.getAIIconInLine(3, "noChat").should("be.visible") + + // And - Line 1 should not have an AI icon + cy.get(".glyph-widget-1 .glyph-ai-icon").should("not.exist") + }) + + it("should navigate to query tab when clicking context badge from different tab", () => { + // Given - Create a query with chat in Tab 1 + cy.typeQuery("SELECT 'tab1_query';") + cy.getAIIconInLine(1).click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading + interceptAIChatRequest("openai", "tab1Chat") + cy.getByDataHook("chat-input-textarea").type("Message for tab 1", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + cy.wait("@tab1Chat") + + // When - Open history and rename this chat + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-list").should("be.visible") // Wait for history to load + cy.getByDataHook("chat-history-item").first().trigger("mouseover") + cy.getByDataHook("chat-history-edit").first().click() + cy.getByDataHook("chat-history-rename").clear().type("Tab 1 Chat{enter}") + + // When - Close chat and create a new tab + cy.getByDataHook("chat-window-close").click() + cy.get(".new-tab-button").click() + + // Then - New tab should be created (2 tabs total now) + cy.getEditorTabs().should("have.length", 2) + + // When - Create a query with chat in Tab 2 + cy.typeQuery("SELECT 'tab2_query';") + cy.getAIIconInLine(1).click() + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for loading + interceptAIChatRequest("openai", "tab2Chat") + cy.getByDataHook("chat-input-textarea").type("Message for tab 2", { + force: true, + }) + cy.getByDataHook("chat-send-button").click() + cy.wait("@tab2Chat") + + // When - Open history and rename this chat + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-list").should("be.visible") // Wait for history to load + cy.getByDataHook("chat-history-item").first().trigger("mouseover") + cy.getByDataHook("chat-history-edit").first().click() + cy.getByDataHook("chat-history-rename").clear().type("Tab 2 Chat{enter}") + + // When - Navigate to Tab 1 Chat via history + cy.getByDataHook("chat-history-item").contains("Tab 1 Chat").click() + + // Then - Should automatically switch to Tab 1 and show context badge + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for chat to load + cy.getByDataHook("chat-context-badge").should("contain", "tab1_query") + + // When - Click context badge + cy.getByDataHook("chat-context-badge").click() + + // Then - Query should be highlighted in the editor + cy.get(".aiQueryHighlight").should("exist") + cy.getActiveTabName().should("contain", "SQL") + + // When - Navigate to Tab 2 Chat via history + cy.getByDataHook("chat-window-history").click() + cy.getByDataHook("chat-history-list").should("be.visible") // Wait for history to load + cy.getByDataHook("chat-history-item").contains("Tab 2 Chat").click() + + // Then - Should automatically switch to Tab 2 and show context badge + cy.getByDataHook("chat-input-textarea").should("be.visible") // Wait for chat to load + cy.getByDataHook("chat-context-badge").should("contain", "tab2_query") + + // When - Click context badge + cy.getByDataHook("chat-context-badge").click() + + // Then - Query should be highlighted in the editor + cy.get(".aiQueryHighlight").should("exist") + cy.getActiveTabName().should("contain", "SQL 1") + }) + }) + + describe("explain schema", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + cy.typeQuery( + "CREATE TABLE IF NOT EXISTS test_trades (symbol SYMBOL, price DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL;", + ) + cy.clickRunQuery() + }) + + afterEach(() => { + cy.typeQuery("DROP TABLE IF EXISTS test_trades;") + cy.clickRunQuery() + }) + + it("should show processing status and display valid schema explanation", () => { + // Given - Set up intercept with valid schema response + const validSchemaResponse = createOpenAIExplainSchemaResponse({ + explanation: + "The test_trades table stores trading data with symbol identification, price values, and timestamps.", + columns: [ + { + name: "symbol", + description: "Stock ticker symbol identifier", + data_type: "SYMBOL", + }, + { + name: "price", + description: "Trade execution price", + data_type: "DOUBLE", + }, + { + name: "ts", + description: "Timestamp of the trade", + data_type: "TIMESTAMP", + }, + ], + storage_details: [ + "WAL enabled for durability", + "Partitioned by DAY", + "Designated timestamp: ts", + ], + }) + interceptAIRequestWithResponse( + "openai", + validSchemaResponse, + "explainSchema", + ) + + cy.refreshSchema() + // When - Right-click on table and select explain schema + cy.getByDataHook("schema-table-title") + .contains("test_trades") + .rightclick() + cy.getByDataHook("table-context-menu-explain-schema").click() + + // Then - Chat window should open with processing status + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("assistant-modes-container").should("be.visible") + cy.getByDataHook("assistant-mode-processing-request").should("be.visible") + + // When - Wait for response + cy.wait("@explainSchema") + + // Then - Should display the schema explanation content + cy.getByDataHook("chat-message-assistant").should("be.visible") + + // Verify explanation summary + cy.getByDataHook("chat-message-assistant").should( + "contain", + "The test_trades table stores trading data", + ) + + // Verify Columns section header and table content + cy.getByDataHook("chat-message-assistant").should("contain", "Columns") + cy.getByDataHook("chat-message-assistant").should("contain", "symbol") + cy.getByDataHook("chat-message-assistant").should("contain", "SYMBOL") + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Stock ticker symbol identifier", + ) + cy.getByDataHook("chat-message-assistant").should("contain", "price") + cy.getByDataHook("chat-message-assistant").should("contain", "DOUBLE") + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Trade execution price", + ) + cy.getByDataHook("chat-message-assistant").should("contain", "ts") + cy.getByDataHook("chat-message-assistant").should("contain", "TIMESTAMP") + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Timestamp of the trade", + ) + + // Verify Storage Details section + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Storage Details", + ) + cy.getByDataHook("chat-message-assistant").should( + "contain", + "WAL enabled for durability", + ) + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Partitioned by DAY", + ) + cy.getByDataHook("chat-message-assistant").should( + "contain", + "Designated timestamp: ts", + ) + }) + + it("should show error when schema explanation fails to parse", () => { + // Given - Set up intercept with parse failure response (output_parsed: null) + const parseFailureResponse = createOpenAIParseFailureResponse() + interceptAIRequestWithResponse( + "openai", + parseFailureResponse, + "explainSchemaFail", + ) + + cy.refreshSchema() + // When - Right-click on table and select explain schema + cy.getByDataHook("schema-table-title") + .contains("test_trades") + .rightclick() + cy.getByDataHook("table-context-menu-explain-schema").click() + + // Then - Chat window should open with processing status + cy.getByDataHook("ai-chat-window").should("be.visible") + cy.getByDataHook("assistant-modes-container").should("be.visible") + cy.getByDataHook("assistant-mode-processing-request").should("be.visible") + + // When - Wait for response + cy.wait("@explainSchemaFail") + + // Then - Should display error message + cy.getByDataHook("chat-message-error") + .should("be.visible") + .should("contain", "An unexpected error occurred. Please try again.") + }) + }) + + describe("tool calls", () => { + const testTables = ["btc_trades", "ecommerce_stats"] + + before(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + testTables.forEach((table) => { + cy.createTable(table) + }) + cy.refreshSchema() + }) + + after(() => { + cy.loadConsoleWithAuth() + testTables.forEach((table) => { + cy.dropTableIfExists(table) + }) + }) + + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("should provide correct table list when model calls get_tables tool", () => { + const assistantResponse = + "I found the following tables in your database: btc_trades and ecommerce_stats." + const flow = createToolCallFlow({ + question: "What tables are in the database?", + steps: [ + { toolCall: { name: "get_tables", args: {} } }, + { + finalResponse: { + explanation: assistantResponse, + sql: null, + }, + expectToolResult: { includes: ["btc_trades", "ecommerce_stats"] }, + }, + ], + }) + + 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", "btc_trades") + .should("contain", "ecommerce_stats") + + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-reviewing-tables").should("exist") + cy.getByDataHook("chat-message-assistant").should( + "contain", + assistantResponse, + ) + }) + + it("should provide correct schema when model calls get_table_schema tool", () => { + const flow = createToolCallFlow({ + question: "What is the schema of btc_trades table?", + steps: [ + { + toolCall: { + name: "get_table_schema", + args: { table_name: "btc_trades" }, + }, + }, + { + finalResponse: { + explanation: + "The btc_trades table has columns: symbol (SYMBOL), side (SYMBOL), price (DOUBLE), amount (DOUBLE), and timestamp (TIMESTAMP).", + sql: null, + }, + expectToolResult: { + includes: [ + "CREATE TABLE", + "btc_trades", + "symbol SYMBOL", + "price DOUBLE", + "timestamp TIMESTAMP", + ], + }, + }, + ], + }) + + 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", "symbol") + .should("contain", "price") + .should("contain", "timestamp") + }) + + it("should handle sequential tool calls (get_tables then get_table_schema)", () => { + const assistantResponse = + "The ecommerce_stats table tracks sales data by country and category, including visits, unique visitors, sales amount, and number of products." + const flow = createToolCallFlow({ + question: "Describe the ecommerce_stats table", + steps: [ + { toolCall: { name: "get_tables", args: {} } }, + { + toolCall: { + name: "get_table_schema", + args: { table_name: "ecommerce_stats" }, + }, + expectToolResult: { includes: ["btc_trades", "ecommerce_stats"] }, + }, + { + finalResponse: { + explanation: assistantResponse, + sql: null, + }, + expectToolResult: { + includes: [ + "CREATE TABLE", + "ecommerce_stats", + "country SYMBOL", + "sales DOUBLE", + ], + }, + }, + ], + }) + + 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", "ecommerce_stats") + .should("contain", "sales") + + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-reviewing-tables").should("exist") + cy.getByDataHook("assistant-mode-investigating-table-schema").should( + "be.visible", + ) + + cy.getByDataHook("chat-message-assistant").should( + "contain", + assistantResponse, + ) + }) + + it("should retrieve QuestDB table of contents when model calls get_questdb_toc tool", () => { + // Mock the QuestDB docs TOC endpoint + cy.intercept("GET", "**/questdb.com/docs/web-console/toc-list.json", { + statusCode: 200, + body: { + functions: ["sum", "avg", "count", "first", "last"], + operators: ["AND", "OR", "NOT", "IN", "BETWEEN"], + sql: ["SELECT", "INSERT", "UPDATE", "CREATE TABLE"], + concepts: ["Partitions", "WAL", "Designated Timestamp"], + schema: ["Tables", "Columns", "Indexes"], + }, + }).as("tocRequest") + + const assistantResponse = + "QuestDB supports various aggregate functions including sum, avg, count, first, and last for data aggregation operations." + const flow = createToolCallFlow({ + question: "What aggregate functions does QuestDB support?", + steps: [ + { toolCall: { name: "get_questdb_toc", args: {} } }, + { + finalResponse: { + explanation: assistantResponse, + sql: null, + }, + expectToolResult: { + includes: ["sum", "avg", "count", "Functions"], + }, + }, + ], + }) + + 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() + + // Verify inline status indicators + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-reviewing-docs").should("exist") + + // Verify final response text + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", assistantResponse) + + // Hover on assistant header to reveal token display + cy.getByDataHook("assistant-header").realHover() + + // Verify token usage is displayed (1 tool call + 1 final = 300 input / 150 output) + cy.get(".token-display") + .should("be.visible") + .should("contain", "300") + .should("contain", "150") + .should("contain", "input") + .should("contain", "output") + }) + + it("should retrieve specific documentation when model calls get_questdb_documentation tool", () => { + // Mock the QuestDB docs metadata endpoint + cy.intercept( + "GET", + "**/questdb.com/docs/web-console/functions-docs.json", + { + statusCode: 200, + body: [ + { + path: "reference/function/aggregation.md", + title: "Aggregation Functions", + headers: ["sum", "avg", "count"], + url: "https://questdb.com/docs/reference/function/aggregation.md", + }, + ], + }, + ).as("functionsMetadata") + + // Mock the actual documentation content + cy.intercept( + "GET", + "**/questdb.com/docs/reference/function/aggregation.md", + { + statusCode: 200, + body: `# Aggregation Functions + +## sum +Returns the sum of all values in a column. + +Syntax: \`sum(column)\` + +## avg +Returns the average of all values in a column. + +Syntax: \`avg(column)\` +`, + }, + ).as("aggregationDocs") + + const assistantResponse = + "The sum function returns the sum of all values in a column. Syntax: sum(column)" + + const flow = createToolCallFlow({ + question: "How do I use the sum function in QuestDB?", + steps: [ + { + toolCall: { + name: "get_questdb_documentation", + args: { category: "functions", items: ["sum"] }, + }, + }, + { + finalResponse: { + explanation: assistantResponse, + sql: "SELECT sum(price) FROM btc_trades;", + }, + expectToolResult: { + includes: ["sum", "Returns the sum"], + }, + }, + ], + }) + + 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() + + // Verify inline status indicators + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-investigating-docs").should("exist") + + // Verify final response text + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", assistantResponse) + + // Hover on assistant header to reveal token display + cy.getByDataHook("assistant-header").realHover() + + // Verify token usage is displayed (1 tool call + 1 final = 300 input / 150 output) + cy.get(".token-display") + .should("be.visible") + .should("contain", "300") + .should("contain", "150") + .should("contain", "input") + .should("contain", "output") + }) + + it("should validate SQL query syntax using validate_query tool against real QuestDB", () => { + const assistantResponse = + "The query is syntactically valid. It will select all columns from the btc_trades table." + const flow = createToolCallFlow({ + question: "Is this query valid: SELECT * FROM btc_trades", + steps: [ + { + toolCall: { + name: "validate_query", + args: { query: "SELECT * FROM btc_trades" }, + }, + }, + { + finalResponse: { + explanation: assistantResponse, + sql: "SELECT * FROM btc_trades;", + }, + expectToolResult: { + includes: ['"valid": true'], + }, + }, + ], + }) + + 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() + + // Verify inline status indicators + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-validating-generated-query").should( + "exist", + ) + + // Verify final response text + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", assistantResponse) + + // Hover on assistant header to reveal token display + cy.getByDataHook("assistant-header").realHover() + + // Verify token usage is displayed (1 tool call + 1 final = 300 input / 150 output) + cy.get(".token-display") + .should("be.visible") + .should("contain", "300") + .should("contain", "150") + .should("contain", "input") + .should("contain", "output") + }) + + it("should detect invalid SQL syntax using validate_query tool", () => { + const assistantResponse = + "The query has syntax errors. 'SELEC' should be 'SELECT' and 'FORM' should be 'FROM'." + const flow = createToolCallFlow({ + question: "Is this query valid: SELEC * FORM btc_trades", + steps: [ + { + toolCall: { + name: "validate_query", + args: { query: "SELEC * FORM btc_trades" }, + }, + }, + { + finalResponse: { + explanation: assistantResponse, + sql: "SELECT * FROM btc_trades;", + }, + expectToolResult: { + includes: ['"valid": false'], + }, + }, + ], + }) + + 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() + + // Verify inline status indicators + cy.getByDataHook("assistant-mode-processing-request").should("exist") + cy.getByDataHook("assistant-mode-validating-generated-query").should( + "exist", + ) + + // Verify final response text + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", assistantResponse) + + // Hover on assistant header to reveal token display + cy.getByDataHook("assistant-header").realHover() + + // Verify token usage is displayed (1 tool call + 1 final = 300 input / 150 output) + cy.get(".token-display") + .should("be.visible") + .should("contain", "300") + .should("contain", "150") + .should("contain", "input") + .should("contain", "output") + }) + }) + + describe("accept and reject suggestions", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("should accept suggestion and update editor", () => { + // Setup: Use flow to generate SQL suggestion + const flow = createToolCallFlow({ + question: "Show all data", + steps: [ + { + finalResponse: { + explanation: "Here's a query to show data.", + sql: "SELECT * FROM btc_trades LIMIT 10;", + }, + }, + ], + }) + + 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("message-action-accept").should("be.visible") + cy.getByDataHook("message-action-accept").click() + + cy.getByDataHook("diff-status-accepted").should("contain", "Accepted") + cy.getByDataHook("chat-context-badge").should( + "contain", + "SELECT * FROM btc_trades", + ) + + cy.getByDataHook("chat-context-badge").click() + cy.get(".aiQueryHighlight").should("exist") + }) + + it("should reject suggestion and show Rejected status", () => { + const flow = createToolCallFlow({ + question: "Count rows", + steps: [ + { + finalResponse: { + explanation: "Here's a count query.", + sql: "SELECT count() FROM btc_trades;", + }, + }, + ], + }) + + 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("message-action-reject").should("be.visible") + cy.getByDataHook("message-action-reject").click() + + cy.getByDataHook("diff-status-rejected").should("contain", "Rejected") + cy.getByDataHook("message-action-accept").should("not.exist") + cy.getByDataHook("message-action-reject").should("not.exist") + cy.getByDataHook("chat-context-badge").should("not.exist") + }) + + it("should apply previous suggestion to editor using Apply button", () => { + const flow = createToolCallFlow({ + question: "Get latest price", + steps: [ + { + finalResponse: { + explanation: "Here's a query for latest price.", + sql: "SELECT price FROM btc_trades LIMIT 1;", + }, + }, + ], + }) + + 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("message-action-reject").click() + + cy.getByDataHook("message-action-apply").should("be.visible") + cy.getByDataHook("message-action-apply").click() + + cy.getByDataHook("chat-context-badge").should( + "contain", + "SELECT price FROM btc_trades", + ) + + cy.getByDataHook("chat-context-badge").click() + cy.get(".aiQueryHighlight").should("exist") + }) + + it("should show Followed up status when user sends follow-up without accepting/rejecting", () => { + const flow = createMultiTurnFlow({ + turns: [ + { + explanation: "Query for symbols.", + sql: "SELECT symbol FROM btc_trades;", + }, + { + explanation: "Query for unique symbols.", + sql: "SELECT DISTINCT symbol FROM btc_trades;", + }, + ], + }) + + flow.intercept() + + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("Show symbols") + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(0) + + cy.getByDataHook("message-action-accept").should("be.visible") + + cy.getByDataHook("chat-input-textarea").type("Make it unique") + cy.getByDataHook("chat-send-button").click() + + cy.getByDataHook("inline-diff-container").should("contain", "Followed up") + cy.getByDataHook("inline-diff-container") + .getByDataHook("message-action-reject") + .should("not.exist") + cy.getByDataHook("inline-diff-container") + .getByDataHook("message-action-accept") + .should("not.exist") + + flow.waitForTurn(1) + + // Second suggestion is now the last one - expanded with Accept/Reject buttons + cy.getByDataHook("chat-message-assistant") + .eq(1) + .getByDataHook("inline-diff-container") + .getByDataHook("message-action-reject") + .should("be.visible") + cy.getByDataHook("chat-message-assistant") + .eq(1) + .contains("Query for unique symbols.") + .getByDataHook("message-action-accept") + .should("be.visible") + }) + + it("should toggle diff view expansion for older suggestions", () => { + const flow = createMultiTurnFlow({ + turns: [ + { explanation: "First simple query.", sql: "SELECT 1;" }, + { explanation: "Second simple query.", sql: "SELECT 2;" }, + ], + }) + + flow.intercept() + + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-input-textarea").type("First query") + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(0) + + // Send second suggestion to make first one collapse + cy.getByDataHook("chat-input-textarea").type("Second query") + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(1) + + cy.getByDataHook("message-action-accept").should("have.length", 1) + + cy.getByDataHook("diff-open-in-editor-button") + .first() + .click({ force: true }) + cy.getByDataHook("diff-editor-container").should("be.visible") + cy.getByDataHook("diff-reject-button").should("not.exist") + cy.getByDataHook("diff-accept-button").should("not.exist") + + cy.getByDataHook("diff-open-in-editor-button") + .eq(1) + .click({ force: true }) + cy.getByDataHook("diff-editor-container").should("be.visible") + cy.getByDataHook("diff-reject-button").should("be.visible") + cy.getByDataHook("diff-accept-button").should("be.visible") + }) + + it("should correctly maintain the history for multi-turn actions", () => { + const flow = createMultiTurnFlow({ + turns: [ + { explanation: "This is 1", sql: "SELECT 1;" }, + { explanation: "This is 2", sql: "SELECT 2;" }, + { explanation: "This is 3", sql: "SELECT 3;" }, + { explanation: "This is 4", sql: "SELECT 4;" }, + { explanation: "This is 5", sql: "SELECT 5;" }, + { explanation: "This is 6", sql: "SELECT 6;" }, + ], + }) + + flow.intercept() + + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea").should("be.visible") + cy.getByDataHook("chat-context-badge").should("not.exist") + + // Turn 0: User sends "select 1" + cy.getByDataHook("chat-input-textarea").type("select 1") + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(0).then(() => { + const body = flow.getRequestBody(0) + expect(body.input).to.have.length(1) + expect(body.input[0].content).to.include("select 1") + }) + + cy.getByDataHook("message-action-accept").should("be.visible") + cy.getByDataHook("chat-context-badge").should("not.exist") + + // Turn 1: User sends "select 2" without accepting/rejecting turn 0 + cy.getByDataHook("chat-input-textarea").type("select 2") + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(1).then(() => { + const body = flow.getRequestBody(1) + expect(body.input).to.have.length(3) + expect(body.input[2].content).to.include("select 2") + }) + + // Accept turn 1's suggestion (SELECT 2) + cy.getByDataHook("message-action-accept").click() + cy.getByDataHook("chat-context-badge").should("contain", "SELECT 2") + cy.getByDataHook("chat-context-badge").click() + cy.get(".aiQueryHighlight").should("exist") + + // Turn 2: User sends "select 3" - should see "User accepted" message + cy.getByDataHook("chat-input-textarea").type("select 3", { force: true }) + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(2).then(() => { + const body = flow.getRequestBody(2) + expect(body.input).to.have.length(6) + expect(body.input[4].content).to.include("User accepted") + expect(body.input[4].content).to.include("SELECT 2") + expect(body.input[5].content).to.include("select 3") + }) + + cy.wait(1000) + + cy.getByDataHook("chat-message-assistant") + .contains("This is 3") + .getByDataHook("message-action-accept") + .should("be.visible") + cy.getByDataHook("chat-messages-container").scrollTo("top") + cy.getByDataHook("chat-message-assistant") + .contains("This is 1") + .should("be.visible") + cy.getByDataHook("message-action-apply").first().click({ force: true }) + cy.getByDataHook("chat-context-badge").should("contain", "SELECT 1") + + // Turn 3: User sends "select 4" - should see "User replaced" message + cy.getByDataHook("chat-input-textarea").type("select 4", { force: true }) + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(3).then(() => { + const body = flow.getRequestBody(3) + expect(body.input).to.have.length(9) + expect(body.input[7].content).to.include("User replaced") + expect(body.input[7].content).to.include("SELECT 1") + expect(body.input[8].content).to.include("select 4") + }) + + // Reject turn 3's suggestion (SELECT 4) + cy.getByDataHook("message-action-reject").click() + cy.getByDataHook("diff-status-rejected").should("contain", "Rejected") + + // Turn 4: User sends "select 5" - should see "User rejected" message + cy.getByDataHook("chat-input-textarea").type("select 5", { force: true }) + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(4).then(() => { + const body = flow.getRequestBody(4) + expect(body.input).to.have.length(12) + expect(body.input[10].content).to.include("User rejected") + expect(body.input[11].content).to.include("select 5") + }) + + // Accept turn 4's suggestion (SELECT 5) + cy.getByDataHook("message-action-accept").click() + cy.getByDataHook("chat-context-badge").should("contain", "SELECT 5") + + // Turn 5: Final turn - should see "User accepted" for SELECT 5 + cy.getByDataHook("chat-input-textarea").type("select 6", { force: true }) + cy.getByDataHook("chat-send-button").click() + + flow.waitForTurn(5).then(() => { + const body = flow.getRequestBody(5) + expect(body.input).to.have.length(15) + expect(body.input[13].content).to.include("User accepted") + expect(body.input[14].content).to.include("select 6") + }) + }) + }) +}) diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index fc95e35af..803bc6a1c 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -28,13 +28,15 @@ describe("run query", () => { cy.typeQuery(" select count(*) from longseq;select 1;") // go to the end of second query - cy.clickLine(4).type(`${ctrlOrCmd}{enter}`) + cy.clickLine(4) + cy.focused().type(`${ctrlOrCmd}{enter}`) cy.getGridCol(0).should("contain", "1") cy.getGridRow(0).should("contain", "1") // go inside the second query cy.clickLine(4) cy.realPress("ArrowLeft") + cy.wait(50) cy.realPress("ArrowLeft") cy.focused().type(`${ctrlOrCmd}{enter}`) cy.getColumnName(0).should("contain", "1") @@ -44,6 +46,7 @@ describe("run query", () => { cy.clickLine(4) for (let i = 0; i < 10; i++) { cy.realPress("ArrowLeft") + cy.wait(50) } cy.focused().type(`${ctrlOrCmd}{enter}`) cy.getColumnName(0).should("contain", "count()") @@ -53,6 +56,7 @@ describe("run query", () => { cy.clickLine(4) for (let i = 0; i < 11; i++) { cy.realPress("ArrowLeft") + cy.wait(50) } cy.focused().type(`${ctrlOrCmd}{enter}`) cy.getColumnName(0).should("contain", "count()") @@ -261,8 +265,8 @@ describe("run all queries in tab", () => { cy.scrollToLine(1) // Then - cy.get(".success-glyph").should("have.length", 3) - cy.get(".error-glyph").should("have.length", 1) + cy.getSuccessIcons().should("have.length", 3) + cy.getErrorIcons().should("have.length", 1) // When cy.clickLine(9) @@ -296,9 +300,9 @@ describe("run all queries in tab", () => { "match", /Stopped after running\s+1 successful\s+and\s+1 failed\s+queries/, ) - cy.get(".success-glyph").should("have.length", 1) - cy.get(".error-glyph").should("have.length", 1) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 1) + cy.getErrorIcons().should("have.length", 1) + cy.getCursorQueryGlyph().should("have.length", 3) }) it("should run all queries if stop after failure is unchecked", () => { @@ -315,9 +319,9 @@ describe("run all queries in tab", () => { "match", /Running completed in \d+ms with\s+2 successful\s+and\s+1 failed\s+queries/, ) - cy.get(".success-glyph").should("have.length", 2) - cy.get(".error-glyph").should("have.length", 1) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 2) + cy.getErrorIcons().should("have.length", 1) + cy.getCursorQueryGlyph().should("have.length", 3) }) it("should scroll to the running query and show the loading notification", () => { @@ -551,6 +555,7 @@ describe("&query URL param", () => { const query = "select x\nfrom long_sequence(1);\n\n-- a\n-- b\n-- c" cy.typeQueryDirectly(query) cy.clickRunIconInLine(1) + cy.wait(1000) cy.visit(`${baseUrl}?query=${encodeURIComponent(query)}&executeQuery=true`) cy.getEditorContent().should("be.visible") cy.getEditorContent().should("have.value", query) @@ -1199,9 +1204,9 @@ describe("multiple run buttons with dynamic query log", () => { "match", /Running completed in \d+ms with\s+2 successful\s+and\s+1 failed\s+queries/, ) - cy.get(".success-glyph").should("have.length", 2) - cy.get(".error-glyph").should("have.length", 1) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 2) + cy.getErrorIcons().should("have.length", 1) + cy.getCursorQueryGlyph().should("have.length", 3) // When cy.get(".new-tab-button").click() @@ -1213,16 +1218,16 @@ describe("multiple run buttons with dynamic query log", () => { // When cy.typeQuery("select 1;\nselect a;\nselect 3;") // Then - cy.get(".success-glyph").should("have.length", 0) - cy.get(".error-glyph").should("have.length", 0) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 0) + cy.getErrorIcons().should("have.length", 0) + cy.getCursorQueryGlyph().should("have.length", 3) // When cy.clickRunIconInLine(3) // Then - cy.get(".success-glyph").should("have.length", 1) - cy.get(".error-glyph").should("have.length", 0) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 1) + cy.getErrorIcons().should("have.length", 0) + cy.getCursorQueryGlyph().should("have.length", 3) // When cy.getEditorTabByTitle("SQL").within(() => { @@ -1230,9 +1235,9 @@ describe("multiple run buttons with dynamic query log", () => { }) // Then cy.getEditorTabByTitle("SQL").should("have.attr", "active") - cy.get(".success-glyph").should("have.length", 2) - cy.get(".error-glyph").should("have.length", 1) - cy.get(".cursorQueryGlyph").should("have.length", 3) + cy.getSuccessIcons().should("have.length", 2) + cy.getErrorIcons().should("have.length", 1) + cy.getCursorQueryGlyph().should("have.length", 3) }) }) diff --git a/e2e/tests/console/result_charts.spec.js b/e2e/tests/console/result_charts.spec.js index 6ebc135f9..59a1e5b09 100644 --- a/e2e/tests/console/result_charts.spec.js +++ b/e2e/tests/console/result_charts.spec.js @@ -9,7 +9,6 @@ describe("questdb charts", () => { cy.typeQueryDirectly( "SELECT rnd_timestamp(to_timestamp('2024-07-19:00:00:00.000000', 'yyyy-MM-dd:HH:mm:ss.SSSUUU'), to_timestamp('2024-07-20:00:00:00.000000', 'yyyy-MM-dd:HH:mm:ss.SSSUUU'), 0), x FROM long_sequence(10);", ) - cy.get(".cursorQueryGlyph-line-1").should("be.visible") cy.clickRunIconInLine(1) cy.getByDataHook("chart-panel-button").should("be.visible").click() cy.get(".quick-vis-canvas").click() diff --git a/e2e/utils.js b/e2e/utils.js deleted file mode 100644 index 58e1f145f..000000000 --- a/e2e/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -exports.ctrlOrCmd = Cypress.platform === "darwin" ? "{cmd}" : "{ctrl}"; - -exports.escapeRegExp = (string) => { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -}; diff --git a/e2e/utils/index.js b/e2e/utils/index.js new file mode 100644 index 000000000..abfd89b5c --- /dev/null +++ b/e2e/utils/index.js @@ -0,0 +1,377 @@ +const ctrlOrCmd = Cypress.platform === "darwin" ? "{cmd}" : "{ctrl}" + +const escapeRegExp = (string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Creates an OpenAI tool call response + * @param {string} toolName - Name of the tool being called + * @param {Object} toolArguments - Arguments for the tool call + * @returns {Object} OpenAI response object with function_call + */ +function createOpenAIToolCallResponse(toolName, toolArguments = {}) { + const callId = `call_${Math.random().toString(36).substring(7)}` + return { + id: `resp_mock_tool_${toolName}`, + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "function_call", + id: `fc_${Math.random().toString(36).substring(7)}`, + name: toolName, + arguments: JSON.stringify(toolArguments), + call_id: callId, + }, + ], + output_text: "", + usage: { input_tokens: 100, output_tokens: 50 }, + } +} + +/** + * Creates an OpenAI final response with explanation and optional SQL + * @param {string} explanation - The explanation text + * @param {string|null} sql - Optional SQL query + * @returns {Object} OpenAI response object with message + */ +function createOpenAIFinalResponse(explanation, sql = null) { + const responseContent = { explanation, sql } + return { + id: "resp_mock_final", + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: JSON.stringify(responseContent) }, + ], + }, + ], + output_text: JSON.stringify(responseContent), + usage: { input_tokens: 200, output_tokens: 100 }, + } +} + +/** + * Creates a chat title response + * @param {string} title - The chat title + * @returns {Object} OpenAI response object with title + */ +function createChatTitleResponse(title = "Test Chat") { + return { + id: "resp_mock_title", + object: "response", + created_at: Date.now(), + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: JSON.stringify({ title }) }], + }, + ], + output_text: JSON.stringify({ title }), + usage: { input_tokens: 50, output_tokens: 20 }, + } +} + +/** + * Creates a multi-turn tool call flow with automatic intercept handling + * + * @param {Object} config - Flow configuration + * @param {string} config.question - The user's question to match + * @param {Array} config.steps - Array of step definitions + * @param {Object} [config.steps[].toolCall] - Tool call definition { name, args } + * @param {Object} [config.steps[].expectToolResult] - Expected result { includes: string[] } + * @param {Object} [config.steps[].finalResponse] - Final response { explanation, sql } + * @returns {Object} Flow controller with intercept() and waitForCompletion() methods + * + * @example + * const flow = createToolCallFlow({ + * question: "Describe the ecommerce_stats table", + * steps: [ + * { toolCall: { name: "get_tables", args: {} } }, + * { + * toolCall: { name: "get_table_schema", args: { table_name: "ecommerce_stats" } }, + * expectToolResult: { includes: ["btc_trades", "ecommerce_stats"] } + * }, + * { + * finalResponse: { explanation: "Table description...", sql: null }, + * expectToolResult: { includes: ["CREATE TABLE", "ecommerce_stats"] } + * } + * ] + * }) + * + * // Usage: + * flow.intercept() + * cy.getByDataHook("chat-send-button").click() + * flow.waitForCompletion() + */ +function createToolCallFlow(config) { + const { question, steps } = config + let requestCount = 0 + const totalRequests = steps.length + + return { + question, + + /** + * Sets up cy.intercept for both chat title and tool call flow + */ + intercept() { + // Handle chat title generation + cy.intercept("POST", "https://api.openai.com/v1/responses", (req) => { + if ( + req.body.input[0].content.includes("Generate a concise chat title") + ) { + req.reply({ statusCode: 200, body: createChatTitleResponse() }) + } + }) + + // Handle tool call flow + cy.intercept("POST", "https://api.openai.com/v1/responses", (req) => { + if (req.body.input[0].content !== question) { + return + } + requestCount++ + + const step = steps[requestCount - 1] + if (!step) return + + // Verify previous tool result if expectToolResult is defined + if (step.expectToolResult) { + const functionOutputs = req.body.input.filter( + (item) => item.type === "function_call_output", + ) + const latestOutput = functionOutputs[functionOutputs.length - 1] + expect(latestOutput).to.exist + + for (const expected of step.expectToolResult.includes || []) { + expect(latestOutput.output).to.include(expected) + } + } + + // Send response + if (step.toolCall) { + req.reply({ + statusCode: 200, + delay: 100, + body: createOpenAIToolCallResponse( + step.toolCall.name, + step.toolCall.args || {}, + ), + }) + } else if (step.finalResponse) { + req.reply({ + statusCode: 200, + delay: 100, + body: createOpenAIFinalResponse( + step.finalResponse.explanation, + step.finalResponse.sql, + ), + }) + } + }).as("openaiToolCall") + }, + + /** + * Waits for all tool call requests to complete + */ + waitForCompletion() { + for (let i = 0; i < totalRequests; i++) { + cy.wait("@openaiToolCall") + } + }, + } +} + +/** + * Creates a multi-turn conversation flow for testing multiple questions/responses + * in the same chat session. Unlike createToolCallFlow which matches by question content, + * this uses request counting to handle conversation history changes. + * + * Supports system message assertions to verify what context is sent to the AI. + * + * @param {Object} config - Flow configuration + * @param {Array} config.turns - Array of turn definitions + * @param {string} config.turns[].explanation - The AI's explanation text + * @param {string|null} config.turns[].sql - Optional SQL query suggestion + * @param {Object} [config.turns[].expectSystemMessage] - Expected content in system message + * @param {string[]} [config.turns[].expectSystemMessage.includes] - Strings that must appear in system message + * @param {string[]} [config.turns[].expectSystemMessage.excludes] - Strings that must NOT appear in system message + * @returns {Object} Flow controller with intercept(), waitForTurn(), and getRequestBody() methods + * + * @example + * const flow = createMultiTurnFlow({ + * turns: [ + * { explanation: "First query.", sql: "SELECT 1;" }, + * { + * explanation: "Second query.", + * sql: "SELECT 2;", + * expectSystemMessage: { + * includes: ["User accepted the suggested SQL"], + * excludes: ["User rejected"] + * } + * }, + * ] + * }) + * + * flow.intercept() + * // ... send first message ... + * flow.waitForTurn(0) + * // ... accept suggestion, send second message ... + * flow.waitForTurn(1) // Will assert system message content + */ +function createMultiTurnFlow(config) { + const { turns } = config + let requestCount = 0 + const requestBodies = [] + + return { + /** + * Sets up cy.intercept for chat title and all conversation turns + */ + intercept() { + // Intercept for chat title generation - separate alias to not interfere with turn counting + cy.intercept("POST", "https://api.openai.com/v1/responses", (req) => { + if ( + req.body.input[0].content.includes("Generate a concise chat title") + ) { + req.reply({ statusCode: 200, body: createChatTitleResponse() }) + } + }).as("chatTitleRequest") + + // Intercept for conversation turns only - uses routeMatcher to exclude title requests + cy.intercept( + { + method: "POST", + url: "https://api.openai.com/v1/responses", + }, + (req) => { + // Skip title requests - they're handled by the other intercept + if ( + req.body.input[0].content.includes("Generate a concise chat title") + ) { + return + } + + // Handle conversation turns + const turn = turns[requestCount] + if (turn) { + // Store the request body for later assertions + requestBodies[requestCount] = req.body + + // Verify system message expectations if defined + if (turn.expectSystemMessage) { + const allInputContent = req.body.input + .map((item) => item.content || "") + .join("\n") + + // Check includes + if (turn.expectSystemMessage.includes) { + for (const expected of turn.expectSystemMessage.includes) { + expect(allInputContent).to.include( + expected, + `Turn ${requestCount}: Expected system message to include "${expected}"`, + ) + } + } + + // Check excludes + if (turn.expectSystemMessage.excludes) { + for (const excluded of turn.expectSystemMessage.excludes) { + expect(allInputContent).to.not.include( + excluded, + `Turn ${requestCount}: Expected system message NOT to include "${excluded}"`, + ) + } + } + } + + requestCount++ + req.reply({ + statusCode: 200, + delay: 100, + body: createOpenAIFinalResponse(turn.explanation, turn.sql), + }) + } + }, + ).as("multiTurnRequest") + }, + + /** + * Waits for a specific turn to complete + * @param {number} turnIndex - The turn index (0-based) + * @returns {Cypress.Chainable} Chainable that resolves when the turn is complete + * + * @example + * flow.waitForTurn(0).then(() => { + * const body = flow.getRequestBody(0) + * expect(body.input).to.have.length(1) + * }) + */ + waitForTurn(turnIndex) { + // Wait until requestBodies has data for this turn index + // This avoids issues with alias matching title requests + // We use cy.wrap with should() which retries until the condition is met + return cy + .wrap(null) + .should(() => { + expect( + requestBodies[turnIndex], + `Turn ${turnIndex} should be captured`, + ).to.not.be.undefined + }) + .then(() => requestBodies[turnIndex]) + }, + + /** + * Waits for all turns to complete + * @returns {Cypress.Chainable} Chainable that yields all request bodies + */ + waitForAllTurns() { + // Wait for the last turn which means all turns are complete + return cy + .wrap(null) + .should(() => { + expect( + requestBodies[turns.length - 1], + `All ${turns.length} turns should be captured`, + ).to.not.be.undefined + }) + .then(() => requestBodies) + }, + + /** + * Gets the captured request body for a specific turn. + * Must be called inside cy.then() after waitForTurn() + * @param {number} turnIndex - The turn index (0-based) + * @returns {Object} The request body sent for that turn + */ + getRequestBody(turnIndex) { + return requestBodies[turnIndex] + }, + + /** + * Gets all captured request bodies + * Must be called inside cy.then() after waitForTurn() + * @returns {Array} Array of request bodies + */ + getAllRequestBodies() { + return requestBodies + }, + } +} + +module.exports = { + ctrlOrCmd, + escapeRegExp, + createToolCallFlow, + createMultiTurnFlow, +} diff --git a/package.json b/package.json index 8ad693087..a536d1e14 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,13 @@ "prepare": "husky" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@date-fns/tz": "^1.2.0", "@docsearch/css": "^3.5.2", "@docsearch/react": "^3.5.2", - "@hookform/resolvers": "2.8.5", + "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.6.0", + "@phosphor-icons/react": "^2.1.10", "@popperjs/core": "2.4.2", "@questdb/sql-grammar": "1.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -68,21 +70,24 @@ "draggabilly": "^3.0.0", "echarts": "^5.2.2", "eventemitter3": "^5.0.1", + "fflate": "^0.8.2", "intersection-observer": "^0.12.2", "joi": "17.5.0", "jquery": "3.5.1", "js-base64": "^3.7.7", "js-sha256": "^0.11.0", + "js-tiktoken": "^1.0.21", "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "monaco-editor": "^0.44.0", + "openai": "^5.21.0", "posthog-js": "1.298.1", "ramda": "0.27.1", "react": "17.0.2", "react-calendar": "^4.0.0", "react-dom": "17.0.2", "react-highlight-words": "^0.20.0", - "react-hook-form": "7.22.3", + "react-hook-form": "^7.56.0", "react-is": "^18.1.0", "react-markdown": "^8.0.7", "react-popper": "2.2.3", @@ -92,6 +97,7 @@ "react-virtuoso": "^2.2.6", "redux": "4.0.5", "redux-observable": "1.2.0", + "remark-gfm": "^3.0.1", "resize-observer-polyfill": "1.5.1", "rxjs": "6.5.5", "slim-select": "1.26.0", @@ -99,7 +105,9 @@ "styled-components": "5.3.5", "throttle-debounce": "2.2.1", "uplot": "^1.6.31", - "uplot-react": "^1.2.2" + "uplot-react": "^1.2.2", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.2.5", @@ -164,7 +172,7 @@ "files": [ { "path": "dist/assets/vendor-*.js", - "maxSize": "500KB", + "maxSize": "1MB", "compression": "none" }, { diff --git a/public/assets/ai-sparkle-hollow.svg b/public/assets/ai-sparkle-hollow.svg new file mode 100644 index 000000000..20f830afb --- /dev/null +++ b/public/assets/ai-sparkle-hollow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/ai-sparkle.svg b/public/assets/ai-sparkle.svg new file mode 100644 index 000000000..d0d65779c --- /dev/null +++ b/public/assets/ai-sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icon-compare.svg b/public/assets/icon-compare.svg new file mode 100644 index 000000000..4ef091075 --- /dev/null +++ b/public/assets/icon-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icon-explain-queries.svg b/public/assets/icon-explain-queries.svg new file mode 100644 index 000000000..8f436e5ff --- /dev/null +++ b/public/assets/icon-explain-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-explain-schema.svg b/public/assets/icon-explain-schema.svg new file mode 100644 index 000000000..27f6722fd --- /dev/null +++ b/public/assets/icon-explain-schema.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-fix-queries.svg b/public/assets/icon-fix-queries.svg new file mode 100644 index 000000000..da3078508 --- /dev/null +++ b/public/assets/icon-fix-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-generate-queries.svg b/public/assets/icon-generate-queries.svg new file mode 100644 index 000000000..2926e6fe1 --- /dev/null +++ b/public/assets/icon-generate-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/models-group-icon.svg b/public/assets/models-group-icon.svg new file mode 100644 index 000000000..280f608a2 --- /dev/null +++ b/public/assets/models-group-icon.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/AISparkle/index.tsx b/src/components/AISparkle/index.tsx new file mode 100644 index 000000000..1f2d7a15b --- /dev/null +++ b/src/components/AISparkle/index.tsx @@ -0,0 +1,68 @@ +import React from "react" +import styled from "styled-components" + +export type AISparkleVariant = "filled" | "hollow" + +export type AISparkleProps = { + size?: number + variant?: AISparkleVariant + className?: string + inverted?: boolean +} + +const Wrapper = styled.span<{ $size: number; $inverted: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: ${({ $size }) => $size}px; + height: ${({ $size }) => $size}px; + + svg { + width: 100%; + height: 100%; + ${({ $inverted }) => $inverted && "filter: brightness(0) invert(1);"} + } +` + +const FilledSparkle = () => ( + + + + + + + + + +) + +const HollowSparkle = () => ( + + + +) + +export const AISparkle = ({ + size = 20, + variant = "filled", + className, + inverted = false, +}: AISparkleProps) => ( + + {variant === "filled" ? : } + +) diff --git a/src/components/AIStatusIndicator/AssistantModes.tsx b/src/components/AIStatusIndicator/AssistantModes.tsx new file mode 100644 index 000000000..bf1ee8d4c --- /dev/null +++ b/src/components/AIStatusIndicator/AssistantModes.tsx @@ -0,0 +1,499 @@ +import React, { useState, useMemo, useRef, useEffect } from "react" +import styled, { css } from "styled-components" +import { CheckboxCircle, CloseCircle } from "@styled-icons/remix-fill" +import { FileText, Table } from "@styled-icons/remix-line" +import { ChevronDown, ChevronRight } from "@styled-icons/boxicons-solid" +import { CircleNotchSpinner } from "../../scenes/Editor/Monaco/icons" +import { + AIOperationStatus, + type StatusArgs, + type OperationHistory, +} from "../../providers/AIStatusProvider" +import { color } from "../../utils" +import { BrainIcon } from "../SetupAIAssistant/BrainIcon" + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; + align-items: flex-start; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +` + +const ModeHeader = styled.div<{ $expanded: boolean; $abort: boolean }>` + border: 1px solid ${color("selection")}; + border-radius: 0.4rem; + display: flex; + ${({ $expanded }) => + $expanded + ? css` + flex-direction: column; + align-items: flex-start; + ` + : css` + align-items: center; + justify-content: space-between; + padding: 1rem 1.2rem; + `} + ${({ $abort }) => + $abort && + css` + border-color: ${color("red")}; + `} + width: 100%; +` + +const ModeHeaderTop = styled.div<{ $expanded: boolean }>` + display: flex; + gap: 1rem; + align-items: center; + ${({ $expanded }) => + $expanded && + css` + border-bottom: 1px solid ${color("selection")}; + padding: 1rem 1.2rem; + width: 100%; + `} + ${({ $expanded }) => + !$expanded && + css` + flex: 1 0 0; + min-height: 0; + min-width: 0; + `} +` + +const ModeChevron = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${color("foreground")}; + cursor: pointer; + + &:hover { + opacity: 0.8; + } +` + +const ModeTitle = styled.div` + font-weight: 500; + font-size: 1.4rem; + color: ${color("foreground")}; + text-align: center; + margin-right: auto; +` + +const ReasoningThread = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; + padding: 1rem 1.2rem; + width: 100%; +` + +const ReasoningItem = styled.div` + display: flex; + gap: 1rem; + align-items: center; + padding: 0.2rem 0.6rem; + padding-left: 0; + width: 100%; +` + +const ReasoningIcon = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${color("foreground")}; +` + +const ReasoningText = styled.div` + display: flex; + flex: 1 0 0; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + min-height: 0; + min-width: 0; +` + +const ReasoningTextPart = styled.span` + font-weight: 400; + font-size: 1.3rem; + color: ${color("gray2")}; +` + +const CodeBadge = styled.div` + background: #2d303e; + border: 1px solid #44475a; + border-radius: 0.6rem; + padding: 0.2rem 0.6rem; + display: flex; + gap: 1rem; + align-items: center; + position: relative; +` + +const CodeBadgeText = styled.span` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: #9089fc; +` + +const CheckIcon = styled(CheckboxCircle)` + width: 1.6rem; + height: 1.6rem; + color: ${color("pink")}; + flex-shrink: 0; +` + +const CloseCircleIcon = styled(CloseCircle)` + color: ${color("red")}; + flex-shrink: 0; +` + +export type OperationSection = { + id: string + type: AIOperationStatus + active: boolean + operations: Array<{ type: AIOperationStatus; args?: StatusArgs }> + abort: boolean +} + +const formatDetailedStatusMessage = ( + status: AIOperationStatus, + args?: StatusArgs, +): string => { + if (status === AIOperationStatus.Processing && args && "type" in args) { + switch (args.type) { + case "fix": + return "Processing fix request" + case "explain": + return "Processing explain request" + default: + return status + } + } + return status +} + +const getIsExpandableSection = (section: OperationSection) => { + return ![ + AIOperationStatus.RetrievingTables, + AIOperationStatus.RetrievingDocumentation, + AIOperationStatus.Aborted, + AIOperationStatus.ValidatingQuery, + AIOperationStatus.Processing, + AIOperationStatus.Compacting, + ].includes(section.type) +} + +export const buildOperationSections = ( + operationHistory: OperationHistory, + currentStatus?: AIOperationStatus | null, + isLive?: boolean, +): OperationSection[] => { + const sections: OperationSection[] = [] + let currentSection: OperationSection | null = null + + for (const op of operationHistory) { + const sectionType = op.type + if (!currentSection || currentSection.type !== sectionType) { + currentSection = { + id: `section-${sections.length}-${sectionType}`, + type: sectionType, + active: false, + abort: sectionType === AIOperationStatus.Aborted, + operations: [op], + } + sections.push(currentSection) + } else { + currentSection.operations.push(op) + } + } + + const lastSection = sections[sections.length - 1] + if (isLive && lastSection && lastSection.type === currentStatus) { + lastSection.active = true + } + if (lastSection && lastSection.type === AIOperationStatus.Aborted) { + lastSection.active = false + } + + return sections +} + +type AssistantModesProps = { + operationHistory: OperationHistory + status?: AIOperationStatus | null + isLive?: boolean + onScrollNeeded?: () => void +} + +export const AssistantModes: React.FC = ({ + operationHistory, + status, + isLive = false, + onScrollNeeded, +}) => { + const [collapsedSections, setCollapsedSections] = useState< + Record + >({}) + const prevSectionCountRef = useRef(0) + const containerRef = useRef(null) + + const operationSections = useMemo( + () => buildOperationSections(operationHistory, status, isLive), + [operationHistory, status, isLive], + ) + + const prevIsLiveRef = useRef(isLive) + + useEffect(() => { + // When operation completes (isLive goes from true to false), collapse all sections + if (prevIsLiveRef.current && !isLive) { + const allCollapsed: Record = {} + operationSections.forEach((section) => { + allCollapsed[section.id] = true + }) + setCollapsedSections(allCollapsed) + } + // During live operation, when a new section is added, collapse previous sections + else if (isLive && operationSections.length > prevSectionCountRef.current) { + const newCollapsed: Record = {} + operationSections.forEach((section, index) => { + if (index < operationSections.length - 1) { + newCollapsed[section.id] = true + } + }) + setCollapsedSections(newCollapsed) + onScrollNeeded?.() + } + prevSectionCountRef.current = operationSections.length + prevIsLiveRef.current = isLive + }, [operationSections, isLive, onScrollNeeded]) + + const handleToggleSection = (sectionId: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })) + } + + if (operationHistory.length === 0) { + return null + } + + return ( + + {operationSections.map((section, index) => { + const isExpandable = getIsExpandableSection(section) + const isLastSection = index === operationSections.length - 1 + const defaultExpanded = isLive ? isLastSection : true + const isExpanded = + collapsedSections[section.id] === undefined + ? defaultExpanded && isExpandable + : !collapsedSections[section.id] && isExpandable + + return ( + + + + {section.active ? ( + + ) : section.abort ? ( + + ) : ( + + )} + + + {section.type} + {isExpandable && ( + handleToggleSection(section.id)}> + {isExpanded ? ( + + ) : ( + + )} + + )} + + {isExpanded && ( + + {section.operations.map((op, idx) => { + const opKey = `${section.id}-${idx}-${JSON.stringify(op.args)}` + + if (op.type === AIOperationStatus.Processing) { + const stepMessage = formatDetailedStatusMessage( + op.type, + op.args, + ) + return ( + + + + + + {stepMessage} + + + ) + } + + if (op.type === AIOperationStatus.InvestigatingTableSchema) { + const tableName = + op.args && "name" in op.args ? op.args.name : "table" + return ( + + + + + + Reading + + {tableName} + + schema + + + ) + } + + if (op.type === AIOperationStatus.InvestigatingDocs) { + const items = + op.args && + "items" in op.args && + Array.isArray(op.args.items) + ? op.args.items + : null + + if (items && items.length > 0) { + return ( + + {items.map((item, itemIdx) => { + const itemKey = `${opKey}-item-${itemIdx}` + return ( + + + + + + {item.section ? ( + <> + + Investigating + + + + {item.section} + + + in + + + {item.name} + + + + documentation + + + ) : ( + <> + + Investigating + + + + {item.name} + + + + documentation + + + )} + + + ) + })} + + ) + } + + const name = + op.args && "name" in op.args ? op.args.name : null + const docSection = + op.args && "section" in op.args ? op.args.section : null + return ( + + + + + + {name && docSection ? ( + <> + + Investigating + + + {docSection} + + in + + {name} + + + documentation + + + ) : name ? ( + <> + + Investigating + + + {name} + + + documentation + + + ) : ( + + Investigating documentation + + )} + + + ) + } + + return null + })} + + )} + + ) + })} + + ) +} diff --git a/src/components/AIStatusIndicator/index.tsx b/src/components/AIStatusIndicator/index.tsx new file mode 100644 index 000000000..dd37c6205 --- /dev/null +++ b/src/components/AIStatusIndicator/index.tsx @@ -0,0 +1,476 @@ +import React, { useState, useMemo, useRef, useEffect } from "react" +import styled, { css } from "styled-components" +import { + CheckboxCircle, + CloseCircle, + Stop as StopFill, +} from "@styled-icons/remix-fill" +import { SidebarSimpleIcon, XIcon } from "@phosphor-icons/react" +import { + useAIStatus, + AIOperationStatus, + isBlockingAIStatus, +} from "../../providers/AIStatusProvider" +import { color } from "../../utils" +import { slideAnimation } from "../Animation" +import { AISparkle } from "../AISparkle" +import { pinkLinearGradientHorizontal } from "../../theme" +import { MODEL_OPTIONS } from "../../utils/aiAssistantSettings" +import { useAIConversation } from "../../providers/AIConversationProvider" +import { Button } from "../../components/Button" +import { BrainIcon } from "../SetupAIAssistant/BrainIcon" +import { AssistantModes, buildOperationSections } from "./AssistantModes" +import { CircleNotchSpinner } from "../../scenes/Editor/Monaco/icons" +import { useSelector } from "react-redux" +import { selectors } from "../../store" + +const CaretGradient = (props: React.SVGProps) => ( + + + + + + + + + +) + +const Container = styled.div` + position: absolute; + bottom: 2rem; + right: 2rem; + width: 38.3rem; + background: ${color("backgroundDarker")}; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.8rem; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1rem; + z-index: 1000; + box-shadow: 0 0.4rem 1.2rem rgba(0, 0, 0, 0.3); + max-height: 50vh; +` + +const ChatStreaming = styled.div` + background: ${color("backgroundLighter")}; + border-radius: 0.4rem; + padding: 2rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 1rem; + align-items: center; + max-height: 13.8rem; + position: relative; + overflow: hidden; + flex-shrink: 0; +` + +const CloseButton = styled(Button).attrs({ skin: "transparent" })` + width: 2.4rem; + height: 2.4rem; + padding: 0; + flex-shrink: 0; + + &:hover { + background: transparent !important; + svg { + color: ${color("foreground")}; + } + } +` + +const ChatStreamingOverlay = styled.div` + background: linear-gradient( + 180deg, + ${color("backgroundLighter")} 0%, + rgba(40, 42, 54, 0) 60% + ); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1001; +` + +const ThoughtStreams = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + width: 32rem; +` + +const ThoughtStream = styled.div<{ + $active: boolean + $abort: boolean + $level: number +}>` + background: ${color("backgroundDarker")}; + border: 1px solid transparent; + background: + linear-gradient(${color("backgroundDarker")}, ${color("backgroundDarker")}) + padding-box, + ${pinkLinearGradientHorizontal} border-box; + ${({ $abort }) => + $abort && + css` + background: ${color("red")}; + `} + border-radius: 1rem; + display: flex; + gap: 0.8rem; + align-items: center; + padding: 0; + height: 4.3rem; + width: 32rem; + position: relative; + margin-bottom: ${({ $level }) => ($level ? `-1.2rem` : 0)}; + transition: transform 200ms; + z-index: ${({ $active }) => ($active ? 10 : 1)}; + ${({ $level }) => + $level && + css` + transform: scale(${1 - Math.abs($level) * 0.05}); + transform-origin: bottom center; + `} +` + +const ThoughtStreamContent = styled.div` + display: flex; + align-items: center; + background: ${color("backgroundDarker")}; + gap: 0.8rem; + width: 100%; + height: 100%; + border-radius: 1rem; + padding: 0.95rem 1.2rem; +` + +const CheckIcon = styled(CheckboxCircle)` + width: 2.4rem; + height: 2.4rem; + color: ${color("pink")}; + flex-shrink: 0; +` + +const CloseCircleIcon = styled(CloseCircle)` + color: ${color("red")}; + flex-shrink: 0; +` + +const ThoughtText = styled.div<{ $active: boolean }>` + font-weight: 500; + font-size: 1.6rem; + color: ${color("gray2")}; + ${({ $active }) => $active && slideAnimation} +` + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + flex-shrink: 0; +` + +const HeaderLeft = styled.div` + display: flex; + flex: 1 0 0; + gap: 1rem; + align-items: center; + justify-content: flex-start; + min-height: 0; + min-width: 0; +` + +const WorkingText = styled.div` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + color: ${color("foreground")}; + text-transform: uppercase; +` + +const AIStopButton = styled(Button)` + width: 2.2rem; + height: 2.2rem; + flex-shrink: 0; + border-radius: 100%; + background: #da152832; + border: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + + &:hover { + background: ${({ theme }) => theme.color.red} !important; + svg { + color: ${({ theme }) => theme.color.foreground}; + } + } +` + +const ViewChatButton = styled(Button).attrs({ skin: "transparent" })` + gap: 1rem; +` + +const ChevronButton = styled(Button).attrs({ skin: "transparent" })` + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; + margin-right: 1rem; + color: ${color("foreground")}; + + &:hover { + background: transparent !important; + svg { + filter: brightness(1.2); + } + } +` + +const ExtendedThinkingLabel = styled.div` + display: flex; + gap: 0.8rem; + align-items: center; + justify-content: center; + width: 100%; + flex-shrink: 0; +` + +const BrainIconWrapper = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +` + +const ExtendedThinkingText = styled.p` + flex: 1 0 0; + font-weight: 400; + font-size: 1.1rem; + color: ${color("gray2")}; + min-height: 0; + min-width: 0; + margin: 0; +` + +const AssistantModesContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; + align-items: flex-start; + padding-top: 0.8rem; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + flex: 1 1 auto; + max-height: 100%; + box-shadow: inset 0 0.1rem 0.4rem rgba(0, 0, 0, 0.3); +` + +export const AIStatusIndicator: React.FC = () => { + const { + status, + currentOperation, + currentModel, + abortOperation, + clearOperation, + } = useAIStatus() + const { chatWindowState, openChatWindow } = useAIConversation() + const [expanded, setExpanded] = useState(true) + const [isClosed, setIsClosed] = useState(false) + const isCompleted = status === null && currentOperation.length > 0 + const isAborted = status === AIOperationStatus.Aborted + const assistantModesRef = useRef(null) + const activeSidebar = useSelector(selectors.console.getActiveSidebar) + const statusRef = useRef(null) + const hasExtendedThinking = useMemo(() => { + return MODEL_OPTIONS.find((model) => model.value === currentModel)?.isSlow + }, [currentModel]) + + const operationSections = useMemo( + () => buildOperationSections(currentOperation, status, true), + [currentOperation, status], + ) + + const handleToggleExpand = () => { + setExpanded(!expanded) + if (!expanded) { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + } + + const handleClose = () => { + if (isCompleted) { + clearOperation() + } + setIsClosed(true) + } + + const handleScrollNeeded = () => { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + + useEffect(() => { + if (expanded) { + handleScrollNeeded() + } + }, [operationSections, expanded]) + + useEffect(() => { + if (statusRef.current === null && status !== null) { + setIsClosed(false) + } + if (status === null && activeSidebar === "aiChat") { + clearOperation() + } + statusRef.current = status + }, [status, activeSidebar, clearOperation]) + + if ( + !currentOperation || + currentOperation.length === 0 || + isClosed || + activeSidebar === "aiChat" + ) { + return null + } + + return ( + + + {operationSections.length > 1 && } + + {operationSections.map((section, index) => ( + + + {section.active ? ( + + ) : section.abort ? ( + + ) : ( + + )} + + {section.type} + + + + ))} + + +
+ + + + {isAborted ? "Cancelled" : isCompleted ? "Completed" : "Working..."} + + {isBlockingAIStatus(status) && ( + + + + )} + {chatWindowState.activeConversationId && ( + + openChatWindow(chatWindowState.activeConversationId!) + } + data-hook="ai-status-view-chat" + > + View chat + + + )} + + + {expanded ? ( + + ) : ( + + )} + + {!isAborted && ( + + + + )} +
+ + {hasExtendedThinking && ( + + + + + + Extended thinking model enabled. Responses may be slow. + + + )} + + {expanded && ( + + + + )} +
+ ) +} diff --git a/src/components/AlertDialog/index.tsx b/src/components/AlertDialog/index.tsx index 280241b32..add3c35fa 100644 --- a/src/components/AlertDialog/index.tsx +++ b/src/components/AlertDialog/index.tsx @@ -60,10 +60,8 @@ export const AlertDialog = { `, Title: styled(RadixAlertDialog.Title)` margin: 0; - padding: 2rem; font-size: 1.6rem; color: ${({ theme }) => theme.color.foreground}; - border-bottom: 1px ${({ theme }) => theme.color.backgroundLighter} solid; `, Description: styled.div` margin-top: 2rem; diff --git a/src/components/Animation/index.ts b/src/components/Animation/index.ts index 8100ceb99..6cf1e2dc3 100644 --- a/src/components/Animation/index.ts +++ b/src/components/Animation/index.ts @@ -23,6 +23,7 @@ ******************************************************************************/ import { css, keyframes } from "styled-components" +import { color } from "../../utils/styled" const spin = keyframes` from { @@ -37,3 +38,29 @@ const spin = keyframes` export const spinAnimation = css` animation: ${spin} 1.5s cubic-bezier(0.62, 0.28, 0.23, 0.99) infinite; ` + +export const slideAnimation = css` + @keyframes slide { + 0% { + background-position: 200% center; + } + 100% { + background-position: -200% center; + } + } + + background: linear-gradient( + 90deg, + ${color("gray2")} 0%, + ${color("gray2")} 40%, + ${color("white")} 50%, + ${color("gray2")} 60%, + ${color("gray2")} 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-fill-color: transparent; + animation: slide 3s linear infinite; +` diff --git a/src/components/Badge/index.tsx b/src/components/Badge/index.tsx index 6717e84e8..74e8c9338 100644 --- a/src/components/Badge/index.tsx +++ b/src/components/Badge/index.tsx @@ -14,6 +14,7 @@ type Props = { pulsate?: boolean children?: React.ReactNode className?: string + "data-hook"?: string } const pulsate = keyframes` @@ -117,8 +118,14 @@ export const Badge: React.FunctionComponent = ({ pulsate, children, className, + "data-hook": dataHook, }) => ( - + {icon && 0}>{icon}} {children} diff --git a/src/components/Box.tsx b/src/components/Box.tsx index ca9356d47..37a017712 100644 --- a/src/components/Box.tsx +++ b/src/components/Box.tsx @@ -7,6 +7,7 @@ type Props = { margin?: React.CSSProperties["margin"] align?: React.CSSProperties["alignItems"] justifyContent?: React.CSSProperties["justifyContent"] + alignSelf?: React.CSSProperties["alignSelf"] } export const Box = styled.div.attrs((props) => ({ @@ -15,6 +16,7 @@ export const Box = styled.div.attrs((props) => ({ margin: props.margin || "0", align: props.align || "center", justifyContent: props.justifyContent || "flex-start", + alignSelf: props.alignSelf || "", }))` display: flex; flex-direction: ${({ flexDirection }) => flexDirection}; @@ -22,4 +24,5 @@ export const Box = styled.div.attrs((props) => ({ margin: ${({ margin }) => margin}; align-items: ${({ align }) => align}; justify-content: ${({ justifyContent }) => justifyContent}; + align-self: ${({ alignSelf }) => alignSelf}; ` diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index db990a457..c2e44a499 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,22 +1,47 @@ import React, { MouseEvent, ReactNode } from "react" import styled, { css } from "styled-components" +import type { DefaultTheme } from "styled-components" import type { FontSize } from "../../types" import type { Skin } from "./skin" import { makeSkin } from "./skin" +import { + pinkLinearGradientHorizontal, + pinkLinearGradientVertical, +} from "../../theme" export const sizes = ["sm", "md", "lg"] as const export type Size = (typeof sizes)[number] type Type = "button" | "submit" -export type ButtonProps = { +const getPinkGradient = (props: ButtonProps & { theme: DefaultTheme }) => + props.gradientStyle === "vertical" + ? pinkLinearGradientVertical + : pinkLinearGradientHorizontal + +const getHoverPinkGradient = (props: ButtonProps & { theme: DefaultTheme }) => { + const base = getPinkGradient(props) + return base.includes("180deg") + ? base.replace("180deg", "0deg") + : base.replace("90deg", "270deg") +} + +const getBorderWidth = (props: ButtonProps) => + "gradientWeight" in props && props.gradientWeight === "thick" ? "2px" : "1px" + +const getFillColor = (props: ButtonProps & { theme: DefaultTheme }) => + "gradientWeight" in props && props.gradientWeight === "thick" + ? props.theme.color.selectionDarker + : props.theme.color.backgroundDarker + +type BaseButtonProps = { as?: React.ElementType - skin?: Skin children?: ReactNode className?: string disabled?: boolean fontSize?: FontSize onClick?: (event: MouseEvent) => void size?: Size + fullWidth?: boolean type?: Type title?: string rounded?: boolean @@ -24,6 +49,21 @@ export type ButtonProps = { dataHook?: string } +type GradientOnlyProps = { + skin: "gradient" + gradientWeight?: "thin" | "thick" + gradientStyle?: "horizontal" | "vertical" +} + +type NonGradientProps = { + skin?: Exclude + gradientWeight?: never + gradientStyle?: never +} + +export type ButtonProps = BaseButtonProps & + (GradientOnlyProps | NonGradientProps) + const Prefix = styled.div<{ disabled?: boolean }>` display: inline-flex; align-items: center; @@ -53,7 +93,7 @@ export const Button: React.FunctionComponent = React.forwardRef( }, ) -const StyledButton = styled.div` +const StyledButton = styled.button` display: inline-flex; height: ${getSize}; padding: 0 1rem; @@ -86,7 +126,36 @@ const StyledButton = styled.div` cursor: default; `} + ${(props) => + props.fullWidth && + css` + width: 100%; + `} + ${(props) => makeSkin(props.skin ?? "primary")} + + ${(props) => + props.skin === "gradient" && + css` + border: ${getBorderWidth} solid transparent; + background: + linear-gradient(${getFillColor}, ${getFillColor}) padding-box, + ${getPinkGradient} border-box; + color: ${props.theme.color.white}; + + &:hover:not([disabled]) { + background: + linear-gradient(${getFillColor}, ${getFillColor}) padding-box, + ${getHoverPinkGradient} border-box; + filter: brightness(120%); + } + + &:disabled { + border: ${getBorderWidth(props)} solid ${props.theme.color.gray1}; + background: ${props.theme.color.selection}; + color: ${props.theme.color.gray1}; + } + `} ` function getSize({ size }: { size?: Size }) { diff --git a/src/components/Button/skin.ts b/src/components/Button/skin.ts index 47acee7ef..600e6ecda 100644 --- a/src/components/Button/skin.ts +++ b/src/components/Button/skin.ts @@ -15,6 +15,7 @@ export const skins = [ "error", "warning", "transparent", + "gradient", ] as const export type Skin = (typeof skins)[number] @@ -30,13 +31,13 @@ const themes: { } = { primary: { normal: { - background: "pink", - border: "pink", + background: "pinkDarker", + border: "pinkDarker", color: "foreground", }, hover: { - background: "pinkDarker", - border: "pinkDarker", + background: "pink", + border: "pink", color: "foreground", }, disabled: { @@ -130,6 +131,23 @@ const themes: { color: "gray1", }, }, + gradient: { + normal: { + background: "backgroundDarker", + border: "transparent", + color: "white", + }, + hover: { + background: "backgroundDarker", + border: "transparent", + color: "white", + }, + disabled: { + background: "selection", + border: "gray1", + color: "gray1", + }, + }, } export const makeSkin = (skin: Skin) => { diff --git a/src/components/Calendar/index.tsx b/src/components/Calendar/index.tsx index e659ea085..1cc533e14 100644 --- a/src/components/Calendar/index.tsx +++ b/src/components/Calendar/index.tsx @@ -1,13 +1,12 @@ import React from "react" import ReactCalendar from "react-calendar" import type { CalendarProps } from "react-calendar" -import { LooseValue } from "react-calendar/dist/cjs/shared/types" type Props = { className?: string min: Date max: Date - value: LooseValue | undefined + value: CalendarProps["value"] selectRange: boolean onChange: (value: CalendarProps["value"]) => void } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 2517dd371..90c140c83 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -29,7 +29,7 @@ export const Dialog = { Trigger: RadixDialog.Trigger, Portal: RadixDialog.Portal, Content: styled(RadixDialog.Content)<{ maxwidth?: string }>` - background-color: ${({ theme }) => theme.color.background}; + background-color: ${({ theme }) => theme.color.backgroundDarker}; border-radius: ${({ theme }) => theme.borderRadius}; box-shadow: 0 7px 30px -10px ${({ theme }) => theme.color.black}; position: fixed; @@ -65,7 +65,7 @@ export const Dialog = { color: ${({ theme }) => theme.color.foreground}; border-bottom: 1px ${({ theme }) => theme.color.backgroundLighter} solid; `, - Description: styled.div` + Description: styled(RadixDialog.Description)` margin-top: 2rem; padding: 0 2rem; color: ${({ theme }) => theme.color.foreground}; diff --git a/src/components/Drawer/index.tsx b/src/components/Drawer/index.tsx index 85386ff44..e6e34fcdf 100644 --- a/src/components/Drawer/index.tsx +++ b/src/components/Drawer/index.tsx @@ -1,13 +1,14 @@ import React from "react" import * as RadixDialog from "@radix-ui/react-dialog" import styled, { css } from "styled-components" -import { Close } from "@styled-icons/remix-line" import { GroupHeader } from "./group-header" import { GroupItem } from "./group-item" import { Actions } from "./actions" import { ForwardRef, Overlay } from "../../components" +import { Button } from "../Button" import { ContentWrapper } from "./content-wrapper" import { Panel } from "../../components/Panel" +import { XIcon } from "@phosphor-icons/react" type DrawerProps = { mode?: "modal" | "side" @@ -50,13 +51,13 @@ const DrawerContent = styled(RadixDialog.Content).attrs({ forceMount: true })<{ width?: string mode: DrawerProps["mode"] }>` - background-color: ${({ theme }) => theme.color.backgroundLighter}; + background-color: ${({ theme }) => theme.color.chatBackground}; border-left: 0.2rem ${({ theme }) => theme.color.background} solid; position: ${({ mode }) => (mode === "modal" ? "fixed" : "inherit")}; top: 0; right: 0; - width: ${({ width }) => width ?? "50rem"}; max-width: 100%; + width: ${({ mode, width }) => width ?? (mode === "side" ? "100%" : "52rem")}; height: 100%; overflow: auto; z-index: 101; @@ -81,14 +82,15 @@ const DrawerContent = styled(RadixDialog.Content).attrs({ forceMount: true })<{ `}; ` -const StyledClose = styled(RadixDialog.Close).attrs({ +export const StyledClose = styled(Button).attrs({ "aria-label": "Close", - asChild: true, + skin: "transparent", })` - appearance: initial; margin-left: auto; + margin-right: 0.5rem; cursor: pointer; color: ${({ theme }) => theme.color.foreground}; + padding: 0.6rem; ` export const Drawer = ({ @@ -144,7 +146,7 @@ export const Drawer = ({ {...(withCloseButton && { afterTitle: ( - + ), })} diff --git a/src/components/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx new file mode 100644 index 000000000..e3d033bd8 --- /dev/null +++ b/src/components/ExplainQueryButton/index.tsx @@ -0,0 +1,191 @@ +import React, { useContext } from "react" +import styled from "styled-components" +import { Button, Box, Key } from "../../components" +import { color, platform } from "../../utils" +import { useSelector } from "react-redux" +import { + continueConversation, + createModelToolsClient, + isAiAssistantError, + generateChatTitle, + type ActiveProviderSettings, +} from "../../utils/aiAssistant" +import { + providerForModel, + MODEL_OPTIONS, +} from "../../utils/aiAssistantSettings" +import { AISparkle } from "../AISparkle" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { + useAIStatus, + type OperationHistory, + type AIOperationStatus, + type StatusArgs, +} from "../../providers/AIStatusProvider" +import { useAIConversation } from "../../providers/AIConversationProvider" +import type { ConversationId } from "../../providers/AIConversationProvider/types" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" + +const KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })` + color: ${({ theme }) => theme.color.pinkPrimary}; +` + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" + +const shortcutTitle = + platform.isMacintosh || platform.isIOS ? "Cmd+E" : "Ctrl+E" + +const ExplainButton = styled(Button)` + gap: 1rem; +` + +type ExplainQueryButtonProps = { + conversationId: ConversationId + queryText: string +} + +export const ExplainQueryButton = ({ + conversationId, + queryText, +}: ExplainQueryButtonProps) => { + const { quest } = useContext(QuestContext) + const tables = useSelector(selectors.query.getTables) + const { + setStatus, + abortController, + hasSchemaAccess, + currentModel: currentModelValue, + apiKey: apiKeyValue, + } = useAIStatus() + const { addMessage, updateMessage, updateConversationName, persistMessages } = + useAIConversation() + + const handleExplainQuery = () => { + const currentModel = currentModelValue! + const apiKey = apiKeyValue! + void (async () => { + const fullApiMessage = `Explain this SQL query with 2-4 sentences:\n\n\`\`\`sql\n${queryText}\n\`\`\`` + + addMessage({ + role: "user", + content: fullApiMessage, + timestamp: Date.now(), + displayType: "explain_request", + sql: queryText, + }) + + const assistantMessageId = crypto.randomUUID() + addMessage({ + id: assistantMessageId, + role: "assistant", + content: "", + timestamp: Date.now(), + operationHistory: [], + }) + + eventBus.publish(EventType.AI_QUERY_HIGHLIGHT, conversationId) + + const provider = providerForModel(currentModel) + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, + } + + const testModel = MODEL_OPTIONS.find( + (m) => m.isTestModel && m.provider === provider, + ) + if (testModel) { + void generateChatTitle({ + firstUserMessage: fullApiMessage, + settings: { model: testModel.value, provider, apiKey }, + }).then((title) => { + if (title) { + void updateConversationName(conversationId, title) + } + }) + } + + const handleStatusUpdate = (history: OperationHistory) => { + updateMessage(conversationId, assistantMessageId, { + operationHistory: [...history], + }) + } + + const response = await continueConversation({ + userMessage: fullApiMessage, + conversationHistory: [], + currentSQL: queryText, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus: (status: AIOperationStatus | null, args?: StatusArgs) => + setStatus( + status, + { ...(args ?? {}), conversationId }, + handleStatusUpdate, + ), + abortSignal: abortController?.signal, + operation: "explain", + }) + + if (isAiAssistantError(response)) { + const error = response + updateMessage(conversationId, assistantMessageId, { + error: + error.type !== "aborted" + ? error.message + : "Operation has been cancelled", + }) + await persistMessages(conversationId) + return + } + + const result = response + if (!result.explanation) { + updateMessage(conversationId, assistantMessageId, { + error: "No explanation received from AI Assistant", + }) + await persistMessages(conversationId) + return + } + + updateMessage(conversationId, assistantMessageId, { + content: result.explanation, + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + + await persistMessages(conversationId) + })() + } + + return ( + + + Explain query + + + + + + ) +} diff --git a/src/components/FeedbackDialog/index.tsx b/src/components/FeedbackDialog/index.tsx index 348c81bd9..2ac5c9fec 100644 --- a/src/components/FeedbackDialog/index.tsx +++ b/src/components/FeedbackDialog/index.tsx @@ -232,7 +232,9 @@ export const FeedbackDialog = ({ {title ?? "Get In Touch"} + + {title ?? "Get In Touch"} + } subtitle={ diff --git a/src/components/FixQueryButton/index.tsx b/src/components/FixQueryButton/index.tsx new file mode 100644 index 000000000..12ab5cbbb --- /dev/null +++ b/src/components/FixQueryButton/index.tsx @@ -0,0 +1,189 @@ +import React, { useContext } from "react" +import type { MutableRefObject } from "react" +import styled from "styled-components" +import { Button } from ".." +import { AISparkle } from "../AISparkle" +import { useSelector } from "react-redux" +import { useEditor } from "../../providers/EditorProvider" +import type { GeneratedSQL } from "../../utils/aiAssistant" +import { + isAiAssistantError, + createModelToolsClient, + continueConversation, + generateChatTitle, + type ActiveProviderSettings, +} from "../../utils/aiAssistant" +import { + providerForModel, + MODEL_OPTIONS, +} from "../../utils/aiAssistantSettings" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { + useAIStatus, + type OperationHistory, +} from "../../providers/AIStatusProvider" +import { useAIConversation } from "../../providers/AIConversationProvider" +import { extractErrorByQueryKey } from "../../scenes/Editor/utils" +import type { ExecutionRefs } from "../../scenes/Editor/index" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" + +const FixButton = styled(Button)` + gap: 1rem; +` + +export const FixQueryButton = () => { + const { quest } = useContext(QuestContext) + const { editorRef, executionRefs } = useEditor() + const tables = useSelector(selectors.query.getTables) + const { setStatus, abortController, hasSchemaAccess, currentModel, apiKey } = + useAIStatus() + const { + chatWindowState, + getConversationMeta, + addMessage, + updateMessage, + updateConversationName, + persistMessages, + } = useAIConversation() + + const handleFixQuery = async () => { + const conversationId = chatWindowState.activeConversationId! + const conversation = getConversationMeta(conversationId)! + + const errorInfo = extractErrorByQueryKey( + conversation.queryKey!, + conversation.bufferId!, + executionRefs as MutableRefObject | undefined, + editorRef, + )! + + const { errorMessage, queryText, word } = errorInfo + + const fullApiMessage = `Fix this SQL query that has an error:\n\n\`\`\`sql\n${queryText}\n\`\`\`\n\nError: ${errorMessage}${word ? `\n\nError near: "${word}"` : ""}` + + addMessage({ + role: "user", + content: fullApiMessage, + timestamp: Date.now(), + displayType: "fix_request", + sql: queryText, + }) + + const assistantMessageId = crypto.randomUUID() + addMessage({ + id: assistantMessageId, + role: "assistant", + content: "", + timestamp: Date.now(), + operationHistory: [], + }) + + eventBus.publish(EventType.AI_QUERY_HIGHLIGHT, conversation.id) + + const provider = providerForModel(currentModel!) + const settings: ActiveProviderSettings = { + model: currentModel!, + provider, + apiKey: apiKey!, + } + + const testModel = MODEL_OPTIONS.find( + (m) => m.isTestModel && m.provider === provider, + ) + if (testModel) { + void generateChatTitle({ + firstUserMessage: fullApiMessage, + settings: { model: testModel.value, provider, apiKey: apiKey! }, + }).then((title) => { + if (title) { + void updateConversationName(conversation.id, title) + } + }) + } + + const handleStatusUpdate = (history: OperationHistory) => { + updateMessage(conversation.id, assistantMessageId, { + operationHistory: [...history], + }) + } + + const response = await continueConversation({ + userMessage: fullApiMessage, + conversationHistory: [], + currentSQL: queryText, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus: (status, args) => + setStatus( + status, + { ...(args ?? {}), conversationId: conversation.id }, + handleStatusUpdate, + ), + abortSignal: abortController?.signal, + operation: "fix", + }) + + if (isAiAssistantError(response)) { + const error = response + updateMessage(conversation.id, assistantMessageId, { + error: + error.type !== "aborted" + ? error.message + : "Operation has been cancelled", + }) + await persistMessages(conversation.id) + return + } + + const result = response as GeneratedSQL + + if (!result.sql && result.explanation) { + updateMessage(conversation.id, assistantMessageId, { + content: result.explanation, + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + await persistMessages(conversation.id) + return + } + + if (!result.sql) { + updateMessage(conversation.id, assistantMessageId, { + error: "No fixed query or explanation received from AI Assistant", + }) + await persistMessages(conversation.id) + return + } + + const assistantContent = result.explanation + ? `SQL Query:\n\`\`\`sql\n${result.sql}\n\`\`\`\n\nExplanation:\n${result.explanation}` + : `SQL Query:\n\`\`\`sql\n${result.sql}\n\`\`\`` + + updateMessage(conversation.id, assistantMessageId, { + content: assistantContent, + sql: result.sql, + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + + await persistMessages(conversation.id) + } + + return ( + + + Fix query + + ) +} diff --git a/src/components/Form/index.tsx b/src/components/Form/index.tsx index c67d75b96..67aa9d264 100644 --- a/src/components/Form/index.tsx +++ b/src/components/Form/index.tsx @@ -76,7 +76,10 @@ export const Form = < useEffect(() => { if (onDirtyChange) { - onDirtyChange(methods.formState.isDirty, methods.formState.dirtyFields) + onDirtyChange( + methods.formState.isDirty, + methods.formState.dirtyFields as FieldNamesMarkedBoolean, + ) } }, [methods.formState]) diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 4b6681cbc..9f17b5e32 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -13,7 +13,7 @@ const errorStyle = css` ` export const Input = styled.input.attrs((props) => ({ - "data-lpignore": !!props.autoComplete, + "data-lpignore": props.autoComplete === "off", }))` background: ${({ theme }) => theme.color.selection}; border: 1px transparent solid; diff --git a/src/components/Key/index.tsx b/src/components/Key/index.tsx new file mode 100644 index 000000000..5909d1a90 --- /dev/null +++ b/src/components/Key/index.tsx @@ -0,0 +1,113 @@ +import React from "react" +import styled from "styled-components" +import { Box } from "../Box" +import { color } from "../../utils" +import { CornerDownLeft } from "@styled-icons/evaicons-solid" +import type { ThemeShape } from "../../types" + +type ColorFunction = (props?: { theme: ThemeShape }) => string | undefined + +const StyledKey = styled(Box).attrs({ + alignItems: "center", + justifyContent: "center", +})<{ $color?: string | ColorFunction; $hoverColor?: string | ColorFunction }>` + padding: 0 0.4rem; + background: ${color("backgroundDarker")}; + border: 0.5px solid ${color("midnight")}; + border-radius: 0.2rem; + font-size: 1.2rem; + height: 1.8rem; + min-width: 2rem; + color: ${({ $color, theme }) => { + if (typeof $color === "function") { + // Handle color() function signature which expects { theme } + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + position: relative; + display: flex; + box-shadow: + 0px 12px 16px -4px rgba(0, 0, 0, 0.2), + 0px 4px 6px -2px rgba(0, 0, 0, 0.2), + 0px 2px 2px -1px rgba(0, 0, 0, 0.2), + 0 0 4px 0 rgba(96, 96, 96, 0.2) inset; + transition: color 0.2s ease; + + &:hover { + color: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + } + + svg { + color: ${({ $color, theme }) => { + if (typeof $color === "function") { + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + fill: ${({ $color, theme }) => { + if (typeof $color === "function") { + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + } + + &:hover svg { + color: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + fill: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + } + + &:not(:last-child) { + margin-right: 0.25rem; + } +` + +type Props = { + keyString: string + color?: string | ColorFunction + hoverColor?: string | ColorFunction +} + +export const Key = ({ keyString, color: keyColor, hoverColor }: Props) => { + const isEnter = keyString.toLowerCase() === "enter" + + return ( + + {isEnter ? : keyString} + + ) +} diff --git a/src/components/LiteEditor/index.tsx b/src/components/LiteEditor/index.tsx new file mode 100644 index 000000000..1fcd5511d --- /dev/null +++ b/src/components/LiteEditor/index.tsx @@ -0,0 +1,278 @@ +import React, { useRef, useState } from "react" +import { Editor, DiffEditor } from "@monaco-editor/react" +import { QuestDBLanguageName } from "../../scenes/Editor/Monaco/utils" +import styled, { useTheme } from "styled-components" +import { Button } from "../Button" +import { FileCopy } from "@styled-icons/remix-line" +import { CheckboxCircle } from "@styled-icons/remix-fill" +import { SquareSplitHorizontalIcon } from "@phosphor-icons/react" +import { copyToClipboard } from "../../utils/copyToClipboard" + +const EditorWrapper = styled.div<{ $noBorder?: boolean }>` + position: relative; + padding: ${({ $noBorder }) => ($noBorder ? 0 : "0 1.2rem")}; + border-radius: 8px; + border: ${({ $noBorder, theme }) => + $noBorder ? "none" : `1px solid ${theme.color.selection}`}; + background: ${({ theme }) => theme.color.backgroundDarker}; + + .monaco-editor-background { + background: ${({ theme }) => theme.color.backgroundDarker}; + } + + .monaco-editor { + background: ${({ theme }) => theme.color.backgroundDarker}; + } + + .editor.original { + display: none !important; + } + + .editor-scrollable { + width: 100% !important; + } + + .view-lines { + width: 100% !important; + pointer-events: none; + } + + .current-line { + background: transparent !important; + border: 0 !important; + } + + .margin { + display: none !important; + } + + .monaco-scrollable-element { + left: 0 !important; + } + + .scrollbar { + display: none !important; + } + + .open-in-editor-btn { + opacity: 0; + transition: opacity 0.15s ease-in-out; + } + + &:hover .open-in-editor-btn { + opacity: 1; + } +` + +const OpenInEditorButton = styled(Button).attrs({ skin: "transparent" })` + gap: 1rem; + font-size: 1.2rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 0; + color: ${({ theme }) => theme.color.offWhite}; +` + +const SuccessIcon = styled(CheckboxCircle)` + position: absolute; + transform: translate(75%, -75%); + color: ${({ theme }) => theme.color.green}; +` + +const ButtonsContainer = styled.div` + position: absolute; + top: 0.8rem; + right: 1.2rem; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1.2rem; + z-index: 10; +` + +const CopyButtonBase = styled(Button)` + color: #e5e7eb; + padding: 0 0.6rem; + background: ${({ theme }) => theme.color.backgroundDarker}; +` + +const CopyButtonFloating = styled(CopyButtonBase)` + position: absolute; + top: 0.2rem; + right: 0.8rem; + z-index: 10; +` + +type BaseLiteEditorProps = { + height?: string | number + language?: string + theme?: string + fontSize?: number + padding?: { top?: number; bottom?: number } + lineHeight?: number + noBorder?: boolean +} + +type RegularEditorProps = BaseLiteEditorProps & { + diffEditor?: false + value: string + original?: never + modified?: never +} + +type DiffEditorProps = BaseLiteEditorProps & { + diffEditor: true + original: string + modified: string + value?: never + onExpandDiff?: () => void +} + +type LiteEditorProps = RegularEditorProps | DiffEditorProps + +export const LiteEditor: React.FC = React.memo( + ({ + height = "100%", + language = QuestDBLanguageName, + theme = "dracula", + fontSize = 12, + padding = { top: 8, bottom: 8 }, + lineHeight = 20, + noBorder, + ...props + }) => { + const appTheme = useTheme() + const scrolledRef = useRef(false) + const [copied, setCopied] = useState(false) + const handleCopy = (value: string) => { + void copyToClipboard(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + if (props.diffEditor) { + return ( + + + {props.onExpandDiff && ( + + Open in editor + + + )} + handleCopy(props.modified)} + title="Copy to clipboard" + > + {copied && } + + + + { + editor.onDidUpdateDiff(() => { + if (scrolledRef.current) return + const lineChange = editor.getLineChanges()?.[0] + if (lineChange) { + scrolledRef.current = true + editor + .getModifiedEditor() + .revealLineInCenter(lineChange.modifiedStartLineNumber) + } + }) + }} + options={{ + readOnly: true, + lineNumbers: "off", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + scrollbar: { + vertical: "hidden", + horizontal: "hidden", + }, + automaticLayout: true, + folding: false, + wordWrap: "on", + glyphMargin: false, + renderSideBySide: false, + enableSplitViewResizing: false, + renderIndicators: false, + renderOverviewRuler: false, + hideCursorInOverviewRuler: true, + originalEditable: false, + overviewRulerBorder: false, + fontSize, + lineHeight, + }} + /> + + ) + } + + return ( + + handleCopy(props.value ?? "")} + title="Copy to clipboard" + > + {copied && } + + + + + ) + }, + (prevProps, nextProps) => { + return ( + prevProps.value === nextProps.value && + prevProps.diffEditor === nextProps.diffEditor && + prevProps.original === nextProps.original && + prevProps.modified === nextProps.modified + ) + }, +) diff --git a/src/components/MultiStepModal/index.tsx b/src/components/MultiStepModal/index.tsx new file mode 100644 index 000000000..df0c8377e --- /dev/null +++ b/src/components/MultiStepModal/index.tsx @@ -0,0 +1,373 @@ +import React, { ReactNode, useState, createContext, useContext } from "react" +import * as RadixDialog from "@radix-ui/react-dialog" +import styled, { css } from "styled-components" +import { ArrowLeft } from "@styled-icons/remix-line" +import { Overlay } from "../Overlay" +import { Box } from "../Box" +import { Button } from "../Button" +import { Text } from "../Text" +import { LoadingSpinner } from "../LoadingSpinner" +import { ForwardRef } from "../ForwardRef" + +type NavigationContextType = { + handleNext: () => void | Promise + handlePrevious: () => void + handleClose: () => void + currentStep: number + isFirstStep: boolean + isLastStep: boolean +} + +const NavigationContext = createContext(null) + +export const useModalNavigation = (): NavigationContextType => { + const context = useContext(NavigationContext) + if (!context) { + return { + handleNext: () => {}, + handlePrevious: () => {}, + handleClose: () => {}, + currentStep: 0, + isFirstStep: true, + isLastStep: false, + } + } + return context +} + +const dialogShow = css` + @keyframes dialogShow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` + +const dialogHide = css` + @keyframes dialogHide { + from { + opacity: 1; + } + to { + opacity: 0; + } + } +` + +const StyledContent = styled(RadixDialog.Content)<{ maxwidth?: string }>` + background-color: ${({ theme }) => theme.color.backgroundDarker}; + border-radius: ${({ theme }) => theme.borderRadius}; + box-shadow: 0 0.7rem 3rem -1rem ${({ theme }) => theme.color.black}; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: ${({ maxwidth }) => maxwidth ?? "50rem"}; + max-height: 85vh; + padding: 0; + border: 0.1rem solid ${({ theme }) => theme.color.selection}; + z-index: 101; + display: flex; + flex-direction: column; + + ${dialogShow} + ${dialogHide} + + &[data-state="open"] { + animation: dialogShow 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: dialogHide 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + + &:focus { + outline: none; + } +` + +const StepIndicatorContainer = styled(Box).attrs({ + gap: "1rem", + align: "center", +})` + backdrop-filter: blur(0.6rem); + background: rgba(255, 255, 255, 0.06); + padding: 0.4rem; + border-radius: 10rem; + box-shadow: 0 0.1rem 0.2rem rgba(0, 0, 0, 0.08); + width: fit-content; +` + +const StepBadge = styled.div` + backdrop-filter: blur(0.6rem); + padding: 0.2rem 0.8rem; + border-radius: 10rem; + background: rgba(255, 255, 255, 0.16); +` + +const StepBadgeText = styled(Text)` + font-size: 1.2rem; + font-weight: 500; + text-transform: uppercase; + color: ${({ theme }) => theme.color.cyan}; + padding: 0.2rem 0.8rem; +` + +const StepBadgeLabel = styled(RadixDialog.Title)` + font-size: 1.2rem; + font-weight: 400; + line-height: 1.5; + color: ${({ theme }) => theme.color.white}; + margin: 0; +` + +const Content = styled.div` + flex: 1; + overflow-y: auto; +` + +const FooterSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + padding: 2.4rem; + width: 100%; + border-top: 0.1rem solid ${({ theme }) => theme.color.selection}; +` + +const FooterButtons = styled(Box).attrs({ + justifyContent: "flex-end", + align: "center", + gap: "1.6rem", +})` + width: 100%; +` + +const ValidationError = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; + text-align: right; + width: 100%; +` + +const CancelButton = styled(Button)` + flex: 1; + padding: 1.1rem 1.2rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 1.4rem; + font-weight: 500; + width: 100%; + height: 4rem; +` + +const NextButton = styled(Button)` + padding: 1.1rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + flex: 1; + height: 4rem; + width: 100%; +` + +export type Step = { + id: string + title: string + stepName: string + content: ReactNode | (() => ReactNode) + validate?: () => string | boolean | Promise +} + +type MultiStepModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + steps: Step[] + maxWidth?: string + onComplete?: () => void | Promise + onCancel?: () => void + canProceed?: (stepIndex: number) => boolean | Promise + completeButtonText?: string + onStepChange?: (stepIndex: number, direction: "next" | "previous") => void + showValidationError?: boolean +} + +export const MultiStepModal = ({ + open, + onOpenChange, + steps, + maxWidth, + onComplete, + onCancel, + canProceed, + completeButtonText = "Complete", + onStepChange, + showValidationError = true, +}: MultiStepModalProps) => { + const [currentStep, setCurrentStep] = useState(0) + const [validationError, setValidationError] = useState(null) + const [isValidating, setIsValidating] = useState(false) + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen && onCancel) { + onCancel() + } + onOpenChange?.(isOpen) + if (!isOpen) { + setCurrentStep(0) + setValidationError(null) + setIsValidating(false) + } + } + + const handleNext = async () => { + const currentStepData = steps[currentStep] + const canProceedResult = canProceed ? await canProceed(currentStep) : true + if (!canProceedResult) { + return + } + + if (currentStepData?.validate) { + setValidationError(null) + setIsValidating(true) + try { + const validationResult = await currentStepData.validate() + + if (typeof validationResult === "string") { + setValidationError(validationResult) + return + } else if (validationResult === false) { + setValidationError("Validation failed") + return + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Validation failed" + setValidationError(errorMessage) + return + } finally { + setIsValidating(false) + } + } + + if (currentStep < steps.length - 1) { + setValidationError(null) + const newStep = currentStep + 1 + onStepChange?.(newStep, "next") + setCurrentStep(newStep) + } else { + await onComplete?.() + handleOpenChange(false) + } + } + + const handlePrevious = () => { + if (currentStep > 0) { + setValidationError(null) + setIsValidating(false) + const newStep = currentStep - 1 + onStepChange?.(newStep, "previous") + setCurrentStep(newStep) + } + } + + const handleClose = () => { + handleOpenChange(false) + } + + const isLastStep = currentStep === steps.length - 1 + const isFirstStep = currentStep === 0 + + const navigationContextValue: NavigationContextType = { + handleNext, + handlePrevious, + handleClose, + currentStep, + isFirstStep, + isLastStep, + } + + return ( + + + + + + + + {steps.length > 1 && ( + + + + Step {currentStep + 1} of {steps.length} + + + + {steps[currentStep]?.stepName || + steps[currentStep]?.title} + + + + + )} + + {typeof steps[currentStep]?.content === "function" + ? steps[currentStep]?.content() + : steps[currentStep]?.content} + + + {showValidationError && validationError && ( + {validationError} + )} + + + {!isFirstStep && } + {isFirstStep ? "Cancel" : "Back"} + + + {isValidating ? ( + + + Validating... + + ) : isLastStep ? ( + completeButtonText + ) : ( + "Next" + )} + + + + + + + + ) +} diff --git a/src/components/Overlay/index.tsx b/src/components/Overlay/index.tsx index 8f3c459b8..b18cfc8d4 100644 --- a/src/components/Overlay/index.tsx +++ b/src/components/Overlay/index.tsx @@ -26,7 +26,7 @@ const overlayHide = css` ` const StyledOverlay = styled.div` - background-color: ${({ theme }) => theme.color.black70}; + background-color: ${({ theme }) => theme.color.overlayBackground}; position: fixed; inset: 0; z-index: 100; diff --git a/src/components/Panel/header.tsx b/src/components/Panel/header.tsx index e07502a9d..f10e09edf 100644 --- a/src/components/Panel/header.tsx +++ b/src/components/Panel/header.tsx @@ -50,8 +50,7 @@ const Title = styled(Text)` display: flex; align-items: center; padding-left: 1rem; - font-size: 1.8rem; - font-weight: 600; + font-size: 1.6rem; ` export const Header = ({ diff --git a/src/components/ReactChromeTabs/chrome-tabs.ts b/src/components/ReactChromeTabs/chrome-tabs.ts index d489c5ace..91997862d 100644 --- a/src/components/ReactChromeTabs/chrome-tabs.ts +++ b/src/components/ReactChromeTabs/chrome-tabs.ts @@ -369,7 +369,6 @@ class ChromeTabs { tabEl .querySelector(".chrome-tab-close")! .addEventListener("click", closeTabEvent) - tabEl.addEventListener("auxclick", closeTabEvent) } setTabRenameConfirmEventListener(tabEl: HTMLElement) { @@ -507,7 +506,6 @@ class ChromeTabs { setupDraggabilly() { const tabEls = this.tabEls - const tabPositions = this.tabPositions if (this.isDragging && this.draggabillyDragging) { this.isDragging = false @@ -533,14 +531,14 @@ class ChromeTabs { return } - tabEls.forEach((tabEl, originalIndex) => { - const originalTabPositionX = tabPositions[originalIndex] + tabEls.forEach((tabEl) => { const draggabilly = new Draggabilly(tabEl, { axis: "x", handle: ".chrome-tab-drag-handle", containment: this.tabContentEl, }) + let dragStartTabPositionX: number = 0 let lastClickX: number let lastClickY: number let lastTimeStamp: number = 0 @@ -577,6 +575,8 @@ class ChromeTabs { this.draggabillyDragging = draggabilly tabEl.classList.add("chrome-tab-is-dragging") this.el.classList.add("chrome-tabs-is-sorting") + const currentTabIndex = this.tabEls.indexOf(tabEl) + dragStartTabPositionX = this.tabPositions[currentTabIndex] ?? 0 this.emit("dragStart", {}) }) @@ -612,10 +612,10 @@ class ChromeTabs { const tabEls = this.tabEls const currentIndex = tabEls.indexOf(tabEl) - const currentTabPositionX = originalTabPositionX + moveVector.x + const currentTabPositionX = dragStartTabPositionX + moveVector.x const destinationIndexTarget = closest( currentTabPositionX, - tabPositions, + this.tabPositions, ) const destinationIndex = Math.max( 0, diff --git a/src/components/SetupAIAssistant/AIAssistantPromo.tsx b/src/components/SetupAIAssistant/AIAssistantPromo.tsx new file mode 100644 index 000000000..66a9373eb --- /dev/null +++ b/src/components/SetupAIAssistant/AIAssistantPromo.tsx @@ -0,0 +1,440 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import ReactDOM from "react-dom" +import { usePopper } from "react-popper" +import { CSSTransition } from "react-transition-group" +import styled from "styled-components" +import { Close } from "@styled-icons/remix-line" +import { Button } from "../Button" +import { Text } from "../Text" +import { Box } from "../Box" +import { AISparkle } from "../AISparkle" +import { TransitionDuration } from "../Transition" + +const TooltipContainer = styled.div<{ $positionReady: boolean }>` + position: relative; + z-index: 1000; + + visibility: ${({ $positionReady }) => + $positionReady ? "visible" : "hidden"}; +` + +const Arrow = styled.div<{ $styles?: React.CSSProperties }>` + position: absolute; + width: 1.6rem; + height: 0.6rem; + top: -0.6rem; + left: 50%; + transform: translateX(-50%) rotate(180deg); + pointer-events: none; + + &::before { + content: ""; + position: absolute; + width: 1.6rem; + height: 0.6rem; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.2) 100% + ); + clip-path: polygon(50% 100%, 0% 0%, 100% 0%); + transform: rotate(180deg); + } + + &::after { + content: ""; + position: absolute; + width: 0; + height: 0; + top: 0.1rem; + left: 50%; + transform: translateX(-50%); + border-left: 0.7rem solid transparent; + border-right: 0.7rem solid transparent; + border-bottom: 0.5rem solid ${({ theme }) => theme.color.backgroundDarker}; + } +` + +const Content = styled.div` + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 0.1rem solid transparent; + border-radius: 0.4rem; + width: 38.3rem; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1rem; +` + +const Header = styled(Box).attrs({ + align: "center", + justifyContent: "space-between", +})` + width: 100%; +` + +const AssistantTitle = styled(Box).attrs({ + align: "center", + gap: "1rem", +})` + flex: 1; +` + +const TitleText = styled(Text)` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + text-transform: uppercase; + color: ${({ theme }) => theme.color.foreground}; + line-height: 2.25rem; +` + +const CloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0.4rem; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray2}; + width: 2.8rem; + height: 2.8rem; + flex-shrink: 0; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Description = styled(Text)` + font-size: 1.3rem; + line-height: 1.857rem; + color: ${({ theme }) => theme.color.foreground}; + padding-right: 0.8rem; +` + +const AssistantModes = styled(Box).attrs({ + flexDirection: "column", + gap: "1rem", +})` + padding-top: 1.4rem; +` + +const AssistantMode = styled(Box).attrs({ + gap: "1rem", + align: "flex-start", +})` + width: 100%; +` + +const IconContainer = styled(Box).attrs({ + align: "center", + justifyContent: "center", +})` + background: ${({ theme }) => theme.color.selectionDarker}; + border-radius: 0.4rem; + padding: 0.8rem; + width: 4.8rem; + height: 4rem; + flex-shrink: 0; +` + +const ModeIcon = styled.img` + width: 2.4rem; + height: 2.4rem; + color: ${({ theme }) => theme.color.pink}; +` + +const ModeContent = styled(Box).attrs({ + flexDirection: "column", + gap: "0.5rem", + align: "flex-start", +})` + flex: 1; +` + +const ModeTitleRow = styled(Box).attrs({ + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const ModeTitle = styled(Text)` + font-weight: 600; + font-size: 1.4rem; + line-height: 1.8rem; + text-align: left; + color: ${({ theme }) => theme.color.white}; +` + +const ModeDescription = styled(Text)` + font-size: 1.3rem; + line-height: 1.857rem; + color: ${({ theme }) => theme.color.foreground}; +` + +const Footer = styled(Box).attrs({ + align: "center", + justifyContent: "space-between", +})` + padding-top: 1.4rem; + width: 100%; +` + +const SetupButton = styled(Button).attrs({ + skin: "primary", +})` + background: ${({ theme }) => theme.color.pinkDarker}; + margin-left: auto; +` + +type Props = { + triggerRef: React.RefObject + onSetupClick: () => void + showPromo: boolean + setShowPromo: (show: boolean) => void +} + +export const AIAssistantPromo = ({ + triggerRef, + onSetupClick, + showPromo, + setShowPromo, +}: Props) => { + const [container] = useState(document.createElement("div")) + const transitionTimeoutId = useRef() + const [arrowElement, setArrowElement] = useState(null) + const [popperElement, setPopperElement] = useState(null) + const [positionReady, setPositionReady] = useState(false) + const promoKeyRef = useRef(0) + + const { attributes, styles, forceUpdate } = usePopper( + triggerRef.current || undefined, + popperElement || undefined, + { + modifiers: [ + { + name: "arrow", + options: { element: arrowElement || undefined }, + }, + { + name: "offset", + options: { offset: [0, 10] }, + }, + { + name: "eventListeners", + enabled: showPromo, + }, + ], + placement: "bottom-end", + }, + ) + + const handleClose = useCallback(() => { + setShowPromo(false) + }, []) + + const handleSetupClick = useCallback(() => { + setShowPromo(false) + onSetupClick() + }, [onSetupClick]) + + useEffect(() => { + document.body.appendChild(container) + + return () => { + clearTimeout(transitionTimeoutId.current) + if (document.body.contains(container)) { + document.body.removeChild(container) + } + } + }, [container]) + + useEffect(() => { + if (showPromo) { + promoKeyRef.current += 1 + setPositionReady(false) + } else { + setPositionReady(false) + setPopperElement(null) + setArrowElement(null) + } + }, [showPromo]) + + useEffect(() => { + if (popperElement && styles.popper && showPromo && !positionReady) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setPositionReady(true) + }) + }) + } + }, [popperElement, styles.popper, positionReady, showPromo]) + + useEffect(() => { + if (showPromo && forceUpdate && triggerRef.current && popperElement) { + requestAnimationFrame(() => { + forceUpdate() + }) + } + }, [showPromo, forceUpdate, triggerRef, popperElement]) + + useEffect(() => { + if (!showPromo) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + const isClickInsidePopper = + popperElement && popperElement.contains(target) + const isClickOnTrigger = + triggerRef.current && triggerRef.current.contains(target) + + if (!isClickInsidePopper && !isClickOnTrigger) { + setShowPromo(false) + } + } + + document.addEventListener("mousedown", handleClickOutside, true) + + return () => { + document.removeEventListener("mousedown", handleClickOutside, true) + } + }, [showPromo, popperElement, triggerRef]) + + if (!triggerRef.current && !showPromo) { + return null + } + + if (!showPromo) { + return null + } + + return ( + <> + {ReactDOM.createPortal( + + + + +
+ + + Meet QuestDB Assistant + + + + +
+ + + Our AI Assistant is a specialized programming and support agent + that makes you more effective and helps you solve problems as + you interface with your QuestDB database. It can help you in the + following ways: + + + + + + + + + + Generate Queries + + + Create SQL queries from natural language, with + schema-aware context. + + + + + + + + + + Explain Queries + + + Get an inline explanation of your query. + + + + + + + + + + Fix Queries + + Documentation-referenced suggestions to fix query errors. + + + + + + + + + + Explain Schema + + Detailed overview and structure of tables. + + + + + +
+ + Setup Assistant + +
+
+
+
, + container, + )} + + ) +} diff --git a/src/components/SetupAIAssistant/AnthropicIcon.tsx b/src/components/SetupAIAssistant/AnthropicIcon.tsx new file mode 100644 index 000000000..d24b9c229 --- /dev/null +++ b/src/components/SetupAIAssistant/AnthropicIcon.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export const AnthropicIcon = (props: React.SVGProps) => { + return ( + + + + ) +} diff --git a/src/components/SetupAIAssistant/BrainIcon.tsx b/src/components/SetupAIAssistant/BrainIcon.tsx new file mode 100644 index 000000000..eb2db9354 --- /dev/null +++ b/src/components/SetupAIAssistant/BrainIcon.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export const BrainIcon = (props: React.SVGProps) => { + return ( + + + + ) +} diff --git a/src/components/SetupAIAssistant/ConfigurationModal.tsx b/src/components/SetupAIAssistant/ConfigurationModal.tsx new file mode 100644 index 000000000..d48f22ab5 --- /dev/null +++ b/src/components/SetupAIAssistant/ConfigurationModal.tsx @@ -0,0 +1,921 @@ +import React, { useState, useMemo, useCallback } from "react" +import styled, { css } from "styled-components" +import { Dialog } from "../Dialog" +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" +import { StoreKey } from "../../utils/localStorage/types" +import { toast } from "../Toast" +import { + MODEL_OPTIONS, + type ModelOption, + type Provider, +} from "../../utils/aiAssistantSettings" +import { useModalNavigation } from "../MultiStepModal" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { theme } from "../../theme" + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; +` + +const HeaderSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + padding: 2.4rem; + padding-top: 0; + width: 100%; +` + +const HeaderTitleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "flex-start", + gap: "1rem", +})` + width: 100%; +` + +const HeaderText = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "flex-start", +})` + flex: 1; +` + +const ModalTitle = styled(Dialog.Title)` + font-size: 2.4rem; + font-weight: 600; + margin: 0; + padding: 0; + color: ${({ theme }) => theme.color.foreground}; + border: 0; +` + +const ModalSubtitle = styled(Dialog.Description)` + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + padding: 0; +` + +const StyledCloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Separator = styled.div` + height: 0.1rem; + width: 100%; + background: ${({ theme }) => theme.color.selection}; +` + +const ContentSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + padding: 2.4rem; + width: 100%; +` + +const SectionTitle = styled(Text)` + font-size: 1.8rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const SectionDescription = styled(Text)` + font-size: 1.3rem; + font-weight: 300; + color: ${({ theme }) => theme.color.gray2}; +` + +const ProviderSelectionContainer = styled(Box).attrs({ + gap: "4rem", + align: "center", +})` + width: 100%; +` + +const ProviderCardsContainer = styled(Box).attrs({ + gap: "2rem", +})` + height: 8.5rem; +` + +const ProviderCard = styled.button<{ $selected: boolean }>` + background: #262833; + border: 0.1rem solid ${({ theme }) => theme.color.selection}; + border-radius: 0.8rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 1.2rem 2rem; + width: 10rem; + height: 8.5rem; + transition: all 0.2s; + + ${({ $selected, theme }) => + $selected && + ` + border-color: ${theme.color.foreground}; + box-shadow: 0 0 0 0.1rem ${theme.color.foreground}; + background: ${theme.color.midnight}; + `} + + &:hover { + border-color: ${({ theme }) => theme.color.foreground}; + } + + &:focus-visible { + outline: 0.2rem solid ${({ theme }) => theme.color.foreground}; + outline-offset: 0.2rem; + } +` + +const ProviderName = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: rgba(249, 250, 251, 0.8); + text-align: center; +` + +const ComingSoonContainer = styled(Box).attrs({ + flexDirection: "column", + gap: "0.6rem", + align: "flex-start", +})` + width: 13.2rem; +` + +const ComingSoonIcons = styled(Box).attrs({ + align: "center", +})` + width: 100%; + padding-left: 0; + padding-right: 1.2rem; +` + +const ComingSoonIcon = styled.img` + width: 100%; + height: auto; + object-fit: contain; +` + +const ComingSoonText = styled(Text)` + font-size: 1.3rem; + font-weight: 300; + color: ${({ theme }) => theme.color.gray2}; +` + +const InputSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + width: 100%; +` + +const InputLabel = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.gray2}; +` + +const StyledInput = styled(Input)<{ $hasError?: boolean; disabled?: boolean }>` + width: 100%; + background: #262833; + border: 0.1rem solid + ${({ theme, $hasError }) => ($hasError ? theme.color.red : "#6b7280")}; + border-radius: 0.8rem; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "text")}; + font-size: 1.4rem; + min-height: 3rem; + text-security: disc; + -webkit-text-security: disc; + -moz-text-security: disc; + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-family: inherit; + } + + ${({ disabled }) => + disabled && + css` + opacity: 0.6; + cursor: not-allowed; + `} +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; +` + +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", +})` + width: 100%; + align-items: flex-start; +` + +const ProviderBadge = styled(Box).attrs({ + gap: "0.6rem", + align: "center", +})` + background: #2d303e; + padding: 0.6rem 0.8rem; + border-radius: 0.4rem; + box-shadow: inset 0 0.1rem 0.4rem rgba(0, 0, 0, 0.1); +` + +const ProviderBadgeText = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: ${({ theme }) => theme.color.foreground}; + font-family: "Open Sans", sans-serif; +` + +const EnableModelsSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + width: 100%; +` + +const EnableModelsHeader = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const EnableModelsTitle = styled(Text)` + font-size: 1.8rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const ModelToggleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "2.4rem", +})` + width: 100%; +` + +const ModelInfoColumn = styled(Box).attrs({ + flexDirection: "column", + gap: "0.8rem", +})` + flex: 1; + align-items: flex-start; +` + +const ModelInfoRow = styled(Box).attrs({ + gap: "0.8rem", + align: "center", +})` + width: 100%; +` + +const ModelDescriptionText = styled(Text)` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + flex: 1; +` + +const ModelNameText = styled(Text)` + font-size: 1.4rem; + font-weight: 400; + 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; + color: ${({ theme }) => theme.color.gray2}; + padding: 2.4rem; + text-align: left; +` + +type ConfigurationModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +const getProviderName = (provider: Provider | null) => { + if (!provider) return "" + return provider === "openai" ? "OpenAI" : "Anthropic" +} + +type StepOneContentProps = { + selectedProvider: Provider | null + apiKey: string + error: string | null + providerName: string + onProviderSelect: (provider: Provider) => void + onApiKeyChange: (value: string) => void +} + +type StepTwoContentProps = { + selectedProvider: Provider | null + enabledModels: string[] + grantSchemaAccess: boolean + modelsByProvider: { anthropic: ModelOption[]; openai: ModelOption[] } + onModelToggle: (modelValue: string) => void + onSchemaAccessChange: (checked: boolean) => void +} + +const CloseButton = ({ onClick }: { onClick: () => void }) => { + return ( + + + + + + ) +} + +const StepOneContent = ({ + selectedProvider, + apiKey, + error, + providerName, + onProviderSelect, + onApiKeyChange, +}: StepOneContentProps) => { + const navigation = useModalNavigation() + const handleClose: () => void = navigation.handleClose + + return ( + + + + + Add a model provider + + Select an AI model provider and enter your API key. You'll be + able to configure and switch between multiple providers later. + + + + + + + + + + Select Provider + + We currently only support two model providers, with support for + more coming soon. + + + + + onProviderSelect("openai")} + type="button" + data-hook="ai-settings-provider-openai" + > + + OpenAI + + onProviderSelect("anthropic")} + type="button" + data-hook="ai-settings-provider-anthropic" + > + + Anthropic + + + + + + + Coming soon... + + + + + + + + API Key + onApiKeyChange(e.target.value)} + placeholder={`Enter${providerName ? ` ${providerName}` : ""} API key`} + $hasError={!!error} + disabled={!selectedProvider} + data-hook="ai-settings-api-key" + /> + {error && ( + {error} + )} + + Stored locally in your browser and never sent to QuestDB servers. + This API key is used to authenticate your requests to the model + provider. + + + + + ) +} + +const StepTwoContent = ({ + selectedProvider, + enabledModels, + grantSchemaAccess, + modelsByProvider, + onModelToggle, + onSchemaAccessChange, +}: StepTwoContentProps) => { + const navigation = useModalNavigation() + const handleClose: () => void = navigation.handleClose + const currentProvider = selectedProvider + + const getModelsForProvider = (provider: Provider) => { + return provider === "openai" + ? modelsByProvider.openai + : modelsByProvider.anthropic + } + + return ( + + + + + Setup your model preferences + + Enable and disable each of the models QuestDB currently supports + from this provider, and a level of data access. You'll be + able to update these settings any time. + + + + + + + + {currentProvider ? ( + + + + Enable Models + + {currentProvider === "openai" ? ( + + ) : ( + + )} + + {getProviderName(currentProvider)} + + + + + {getModelsForProvider(currentProvider).map((model) => { + const isEnabled = enabledModels.includes(model.value) + return ( + + + {model.label} + {model.isSlow && ( + + + + Due to advanced reasoning & thinking + capabilities, responses using this model can be + slow. + + + )} + + onModelToggle(model.value)} + data-checked={isEnabled} + /> + + ) + })} + + + + ) : ( + + Please configure at least one provider in step 1 before enabling + models. + + )} + + + + {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. + + + + + + + )} + + + The AI assistant may occasionally produce incorrect information. Please + verify important details and review all generated queries before + execution. + + + ) +} + +export const ConfigurationModal = ({ + open, + onOpenChange, +}: ConfigurationModalProps) => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const [selectedProvider, setSelectedProvider] = useState( + null, + ) + const providerName = useMemo( + () => getProviderName(selectedProvider), + [selectedProvider], + ) + const [apiKey, setApiKey] = useState("") + const [error, setError] = useState(null) + + const [enabledModels, setEnabledModels] = useState([]) + const [grantSchemaAccess, setGrantSchemaAccess] = useState(true) + + const modelsByProvider = useMemo(() => { + const anthropic: ModelOption[] = [] + const openai: ModelOption[] = [] + MODEL_OPTIONS.forEach((model) => { + if (model.provider === "anthropic") { + anthropic.push(model) + } else { + openai.push(model) + } + }) + return { anthropic, openai } + }, []) + + const handleProviderSelect = useCallback((provider: Provider) => { + setSelectedProvider(provider) + setError(null) + setApiKey("") + }, []) + + const handleApiKeyChange = useCallback((value: string) => { + setApiKey(value) + setError(null) + }, []) + + const handleModelToggle = useCallback((modelValue: string) => { + setEnabledModels((prev) => { + const isEnabled = prev.includes(modelValue) + return isEnabled + ? prev.filter((m) => m !== modelValue) + : [...prev, modelValue] + }) + }, []) + + const handleSchemaAccessChange = useCallback((checked: boolean) => { + setGrantSchemaAccess(checked) + }, []) + + const handleComplete = () => { + if (!selectedProvider || enabledModels.length === 0) return + + const selectedModel = + enabledModels.find( + (m) => MODEL_OPTIONS.find((mo) => mo.value === m)?.default, + ) ?? enabledModels[0] + + const newSettings = { + ...aiAssistantSettings, + selectedModel, + providers: { + ...aiAssistantSettings.providers, + [selectedProvider]: { + apiKey, + enabledModels, + grantSchemaAccess, + }, + }, + } + + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, newSettings) + toast.success("AI Assistant activated successfully") + onOpenChange?.(false) + } + + const canProceed = (stepIndex: number): boolean => { + if (stepIndex === 0) { + if (!selectedProvider) return false + return !!apiKey + } + return true + } + + const validateStepOne = useCallback(async (): Promise => { + if (!selectedProvider) { + return "Please select a provider" + } + + if (!apiKey) { + return "Please enter an API key" + } + + const testModel = + MODEL_OPTIONS.find( + (m) => m.isTestModel && m.provider === selectedProvider, + )?.value ?? modelsByProvider[selectedProvider][0].value + + try { + const result = await testApiKey(apiKey, testModel) + if (!result.valid) { + const errorMsg = result.error || "Invalid API key" + setError(errorMsg) + return errorMsg + } + const defaultModels = MODEL_OPTIONS.filter( + (m) => m.defaultEnabled && m.provider === selectedProvider, + ).map((m) => m.value) + if (defaultModels.length > 0) { + setEnabledModels(defaultModels) + } + setError(null) + return true + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to validate API key" + setError(errorMessage) + return errorMessage + } + }, [selectedProvider, apiKey, modelsByProvider]) + + const validateStepTwo = useCallback((): string | boolean => { + if (!selectedProvider) return "Please select a provider" + if (enabledModels.length === 0) { + return "Please enable at least one model" + } + return true + }, [enabledModels, selectedProvider]) + + const handleStepChange = useCallback( + (newStepIndex: number, direction: "next" | "previous") => { + // 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) + } + }, + [], + ) + + const handleModalClose = useCallback(() => { + setSelectedProvider(null) + setApiKey("") + setError(null) + setEnabledModels([]) + setGrantSchemaAccess(true) + }, []) + + const steps: Step[] = useMemo( + () => [ + { + id: "provider", + title: "Add a model provider", + stepName: "Add model provider", + content: ( + + ), + validate: validateStepOne, + }, + { + id: "models", + title: "Configure Models", + stepName: "Configure provider settings", + content: ( + + ), + validate: validateStepTwo, + }, + ], + [ + selectedProvider, + apiKey, + error, + providerName, + handleProviderSelect, + handleApiKeyChange, + enabledModels, + grantSchemaAccess, + modelsByProvider, + handleModelToggle, + handleSchemaAccessChange, + validateStepOne, + validateStepTwo, + ], + ) + + return ( + { + if (!isOpen) { + handleModalClose() + } + onOpenChange?.(isOpen) + }} + onStepChange={handleStepChange} + steps={steps} + maxWidth="64rem" + onComplete={handleComplete} + canProceed={canProceed} + completeButtonText="Activate Assistant" + showValidationError={false} + /> + ) +} diff --git a/src/components/SetupAIAssistant/ModelDropdown.tsx b/src/components/SetupAIAssistant/ModelDropdown.tsx new file mode 100644 index 000000000..d15c52fb7 --- /dev/null +++ b/src/components/SetupAIAssistant/ModelDropdown.tsx @@ -0,0 +1,275 @@ +import React, { useMemo, useState } from "react" +import styled, { css } from "styled-components" +import { Check } from "@styled-icons/remix-line" +import { Error as ErrorIcon } from "@styled-icons/boxicons-regular" +import { PopperToggle } from "../PopperToggle" +import { Box } from "../Box" +import { Text } from "../Text" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { MODEL_OPTIONS } from "../../utils/aiAssistantSettings" +import { useAIStatus } from "../../providers/AIStatusProvider" +import { StoreKey } from "../../utils/localStorage/types" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { PopperHover } from "../PopperHover" +import { Tooltip } from "../Tooltip" + +const ExpandUpDown = () => ( + + + +) + +const DropdownTrigger = styled.button<{ disabled?: boolean }>` + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.75rem 1rem; + background: ${({ theme }) => theme.color.background}; + border-radius: 0.4rem; + border: none; + color: ${({ theme }) => theme.color.gray2}; + font-size: 1.2rem; + white-space: nowrap; + height: 3rem; + min-width: 17rem; + justify-content: space-between; + cursor: pointer; + + ${({ disabled }) => + disabled && + css` + cursor: not-allowed; + gap: 0.5rem; + min-width: unset; + `} + + &:focus-visible { + outline: none; + } + &:focus { + outline: none; + } + + &:hover { + background: ${({ theme }) => theme.color.comment}; + color: ${({ theme }) => theme.color.foreground}; + } + + ${({ disabled }) => + disabled && + css` + &:hover { + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.gray2}; + } + `} + + > * { + color: inherit; + } +` + +const DropdownIcon = styled(ExpandUpDown)` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + color: inherit; +` + +const DropdownContent = styled.div` + display: flex; + flex-direction: column; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.6rem; + padding: 1.2rem; + box-shadow: 0 0.5rem 0.5rem 0 ${({ theme }) => theme.color.black40}; + min-width: 22.8rem; + gap: 0.4rem; + z-index: 9999; +` + +const Title = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + margin-bottom: 0.4rem; +` + +const ModelItem = styled.div<{ $selected?: boolean }>` + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.8rem; + border-radius: 0.4rem; + cursor: pointer; + background: ${({ theme }) => theme.color.backgroundDarker}; + color: ${({ theme }) => theme.color.gray2}; + font-size: 1.2rem; + line-height: 1.5; + position: relative; + margin: 0; + box-shadow: inset 0px 1px 4px 0px rgba(0, 0, 0, 0.1); + border: none; + width: 100%; + + ${({ $selected }) => + $selected && + css` + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + `} + + &:hover { + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ModelIconTitle = styled(Box)` + flex: 1; + gap: 0.6rem; + align-items: center; +` + +const ModelLabel = styled(Text)` + font-size: 1.2rem; + color: inherit; +` + +const CheckIcon = styled(Check)` + width: 1.8rem; + height: 1.8rem; + color: ${({ theme }) => theme.color.green}; + flex-shrink: 0; +` + +export const ModelDropdown = () => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const { + isConfigured, + models: enabledModelValues, + currentModel, + } = useAIStatus() + const [dropdownActive, setDropdownActive] = useState(false) + + const enabledModels = useMemo(() => { + return MODEL_OPTIONS.filter((model) => + enabledModelValues.includes(model.value), + ) + }, [enabledModelValues]) + + const handleModelSelect = (modelValue: string) => { + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, { + ...aiAssistantSettings, + selectedModel: modelValue, + }) + setDropdownActive(false) + } + + if (!isConfigured) { + return null + } + + // currentModel is guaranteed to be from MODEL_OPTIONS (set in modals) + const displayModel = currentModel + ? (enabledModels.find((m) => m.value === currentModel) ?? enabledModels[0]) + : (enabledModels[0] ?? null) + + if (!displayModel) { + return ( + + } + placement="bottom" + modifiers={[ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + ]} + > + + + You can enable models in the AI Assistant settings + + + + + No models enabled + + + ) + } + + return ( + + {displayModel.provider === "openai" ? ( + + ) : ( + + )} + + {displayModel.label} + + + + } + > + + Select Model + {enabledModels.map((model) => { + const isSelected = currentModel === model.value + + return ( + handleModelSelect(model.value)} + $selected={isSelected} + > + + {model.provider === "openai" ? ( + + ) : ( + + )} + + {model.label} + + {model.isSlow && } + + {isSelected && } + + ) + })} + + + ) +} diff --git a/src/components/SetupAIAssistant/OpenAIIcon.tsx b/src/components/SetupAIAssistant/OpenAIIcon.tsx new file mode 100644 index 000000000..87fba651e --- /dev/null +++ b/src/components/SetupAIAssistant/OpenAIIcon.tsx @@ -0,0 +1,25 @@ +import React from "react" + +export const OpenAIIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/src/components/SetupAIAssistant/SettingsModal.tsx b/src/components/SetupAIAssistant/SettingsModal.tsx new file mode 100644 index 000000000..be389a38f --- /dev/null +++ b/src/components/SetupAIAssistant/SettingsModal.tsx @@ -0,0 +1,1077 @@ +import React, { useState, useCallback, useMemo, useRef } from "react" +import styled from "styled-components" +import * as RadixDialog from "@radix-ui/react-dialog" +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" +import { testApiKey } from "../../utils/aiAssistant" +import { StoreKey } from "../../utils/localStorage/types" +import { toast } from "../Toast" +import { Edit } from "@styled-icons/remix-line" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { LoadingSpinner } from "../LoadingSpinner" +import { Overlay } from "../Overlay" +import { + getAllProviders, + MODEL_OPTIONS, + type ModelOption, + type Provider, + getNextModel, +} from "../../utils/aiAssistantSettings" +import type { AiAssistantSettings } from "../../providers/LocalStorageProvider/types" +import { ForwardRef } from "../ForwardRef" +import { Badge, BadgeType } from "../../components/Badge" +import { CheckboxCircle } from "@styled-icons/remix-fill" + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; +` + +const StyledContent = styled(Dialog.Content).attrs({ + maxwidth: "72rem", +})` + display: flex; + flex-direction: column; + max-height: 85vh; + overflow: hidden; +` + +const HeaderSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + padding: 2.4rem; + width: 100%; + flex-shrink: 0; +` + +const HeaderTitleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "flex-start", + gap: "1rem", +})` + width: 100%; +` + +const HeaderText = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "flex-start", +})` + flex: 1; +` + +const ModalTitle = styled(Dialog.Title)` + font-size: 2.4rem; + font-weight: 600; + margin: 0; + padding: 0; + color: ${({ theme }) => theme.color.foreground}; + border: 0; +` + +const ModalSubtitle = styled(Dialog.Description)` + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + padding: 0; +` + +const CloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Separator = styled.div` + height: 0.1rem; + width: 100%; + background: ${({ theme }) => theme.color.selection}; +` + +const MainContentArea = styled(Box)` + display: flex; + flex-direction: row; + width: 100%; + align-items: stretch; + min-height: 0; + flex: 1; + gap: 0; + overflow: hidden; +` + +const Sidebar = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + padding: 0; + padding-top: 2.4rem; + width: 15.1rem; + flex-shrink: 0; + overflow-y: auto; +` + +const ProviderTab = styled.button<{ $active: boolean }>` + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.2rem 2.4rem; + background: ${({ $active, theme }) => + $active ? theme.color.midnight : "transparent"}; + border: none; + border-bottom: ${({ $active, theme }) => + $active ? "0.2rem solid " + theme.color.pinkPrimary : "none"}; + cursor: pointer; + align-items: flex-start; + width: 100%; + + &:hover { + background: ${({ $active, theme }) => + $active ? theme.color.midnight : theme.color.selection}; + } +` + +const ProviderTabTitle = styled(Box).attrs({ + gap: "0.6rem", + align: "center", +})` + width: 100%; +` + +const ProviderTabName = styled(Text)<{ $active: boolean }>` + font-size: 1.6rem; + font-weight: ${({ $active }) => ($active ? 600 : 400)}; + color: ${({ theme, $active }) => + $active ? theme.color.foreground : theme.color.gray2}; +` + +const StatusBadge = styled(Box).attrs({ + gap: "0.4rem", + align: "center", +})<{ $enabled: boolean }>` + background: ${({ $enabled }) => ($enabled ? "transparent" : "#2d303e")}; + padding: 0.3rem; + border-radius: 0.2rem; +` + +const StatusDot = styled.div<{ $enabled: boolean }>` + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: ${({ $enabled, theme }) => + $enabled ? theme.color.green : theme.color.gray2}; +` + +const StatusText = styled(Text)<{ $enabled: boolean }>` + font-size: 1rem; + font-weight: 400; + color: ${({ $enabled, theme }) => ($enabled ? theme.color.green : "#bbbbbb")}; +` + +const VerticalSeparator = styled.div` + width: 0.1rem; + background: ${({ theme }) => theme.color.selection}; + flex-shrink: 0; + align-self: stretch; +` + +const ContentPanel = styled(Box).attrs({ + flexDirection: "column", + gap: "2.8rem", +})` + flex: 1; + padding: 2.4rem; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +` + +const ContentSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "stretch", +})` + width: 100%; +` + +const SectionTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const SectionDescription = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; +` + +const InputWrapper = styled(Box)` + position: relative; + width: 100%; +` + +const StyledInput = styled(Input)<{ + $hasError?: boolean + $showEditButton?: boolean +}>` + width: 100%; + background: ${({ theme }) => theme.color.background}; + border: 0.1rem solid + ${({ theme, $hasError }) => ($hasError ? theme.color.red : "#6b7280")}; + border-radius: 0.8rem; + padding: 1.2rem; + padding-right: ${({ $showEditButton }) => + $showEditButton ? "4rem" : "1.2rem"}; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.4rem; + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-family: inherit; + } +` + +const EditButton = styled.button` + position: absolute; + right: 1.2rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + width: 2rem; + height: 2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ValidatedBadge = styled(Badge).attrs({ + type: BadgeType.SUCCESS, +})` + font-size: 1rem; + margin-right: auto; + padding: 0.3rem 0.6rem; + height: 2rem; + border: 0; +` + +const APIKeyLink = styled.a` + color: ${({ theme }) => theme.color.gray2}; + + &:hover { + text-decoration: underline; + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; +` + +const ValidateRemoveButton = styled.button` + height: 3rem; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + border-radius: 0.4rem; + padding: 0.6rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.color.pinkDarker}; + color: ${({ theme }) => theme.color.foreground}; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +` + +const ModelsPlaceholder = styled(Box).attrs({ + flexDirection: "column", + gap: "1rem", +})` + background: rgba(68, 71, 90, 0.56); + padding: 0.75rem; + border-radius: 0.4rem; + width: 100%; +` + +const ModelsPlaceholderText = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; +` + +const ModelList = styled(Box).attrs({ flexDirection: "column", gap: "1.6rem" })` + width: 100%; +` + +const ModelToggleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "2.4rem", +})` + width: 100%; +` + +const ModelInfoColumn = styled(Box).attrs({ + flexDirection: "column", + gap: "0.8rem", +})` + flex: 1; + align-items: flex-start; +` + +const ModelInfoRow = styled(Box).attrs({ + gap: "0.8rem", + align: "center", +})` + width: 100%; +` + +const ModelDescriptionText = styled(Text)` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + flex: 1; +` + +const ModelNameText = styled(Text)` + font-size: 1.4rem; + font-weight: 400; + color: ${({ theme }) => theme.color.foreground}; +` + +const EnableModelsTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + 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.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 FooterSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + padding: 2.4rem 2.4rem 0.4rem 2.4rem; + width: 100%; + flex-shrink: 0; +` + +const FooterButtons = styled(Box).attrs({ + justifyContent: "flex-end", + align: "center", + gap: "1.6rem", +})` + width: 100%; +` + +const CancelButton = styled(Button)` + flex: 1; + padding: 1.1rem 1.2rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 1.4rem; + font-weight: 500; + width: 100%; + height: 4rem; +` + +const SaveButton = styled(Button)` + padding: 1.1rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + flex: 1; + height: 4rem; + width: 100%; +` + +type SettingsModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +const getProviderName = (provider: Provider) => { + return provider === "openai" ? "OpenAI" : "Anthropic" +} + +const getModelsForProvider = (provider: Provider): ModelOption[] => { + return MODEL_OPTIONS.filter((m) => m.provider === provider) +} + +const getProvidersWithApiKeys = (settings: AiAssistantSettings): Provider[] => { + const providers: Provider[] = [] + const allProviders = getAllProviders() + for (const provider of allProviders) { + if (settings.providers?.[provider]?.apiKey) { + providers.push(provider) + } + } + return providers +} + +export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const initializeProviderState = useCallback( + ( + getValue: (provider: Provider) => T, + defaultValue: T, + ): Record => { + const allProviders = getAllProviders() + const state = {} as Record + for (const provider of allProviders) { + state[provider] = getValue(provider) ?? defaultValue + } + return state + }, + [], + ) + + const [selectedProvider, setSelectedProvider] = useState(() => { + const providersWithKeys = getProvidersWithApiKeys(aiAssistantSettings) + return providersWithKeys[0] || getAllProviders()[0] + }) + const [apiKeys, setApiKeys] = useState>(() => + initializeProviderState( + (provider) => aiAssistantSettings.providers?.[provider]?.apiKey || "", + "", + ), + ) + const [enabledModels, setEnabledModels] = useState< + Record + >(() => + initializeProviderState( + (provider) => + aiAssistantSettings.providers?.[provider]?.enabledModels || [], + [], + ), + ) + const [grantSchemaAccess, setGrantSchemaAccess] = useState< + Record + >(() => + initializeProviderState( + (provider) => + aiAssistantSettings.providers?.[provider]?.grantSchemaAccess !== false, + true, + ), + ) + const [validatedApiKeys, setValidatedApiKeys] = useState< + Record + >(() => + initializeProviderState( + (provider) => !!aiAssistantSettings.providers?.[provider]?.apiKey, + false, + ), + ) + const [validationState, setValidationState] = useState< + Record + >(() => initializeProviderState(() => "idle" as const, "idle" as const)) + const [validationErrors, setValidationErrors] = useState< + Record + >(() => initializeProviderState(() => null, null)) + const [isInputFocused, setIsInputFocused] = useState< + Record + >(() => initializeProviderState(() => false, false)) + const inputRef = useRef(null) + + const handleProviderSelect = useCallback((provider: Provider) => { + setSelectedProvider(provider) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + }, []) + + const handleApiKeyChange = useCallback( + (provider: Provider, value: string) => { + setApiKeys((prev) => ({ ...prev, [provider]: value })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + // If API key changes, mark as not validated + if (validatedApiKeys[provider]) { + setValidatedApiKeys((prev) => ({ ...prev, [provider]: false })) + } + }, + [validatedApiKeys], + ) + + const handleValidateApiKey = useCallback( + async (provider: Provider) => { + const apiKey = apiKeys[provider] + if (!apiKey) { + setValidationErrors((prev) => ({ + ...prev, + [provider]: "Please enter an API key", + })) + return + } + + setValidationState((prev) => ({ ...prev, [provider]: "validating" })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + + const providerModels = getModelsForProvider(provider) + if (providerModels.length === 0) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + setValidationErrors((prev) => ({ + ...prev, + [provider]: "No models available for this provider", + })) + return + } + + const testModel = ( + providerModels.find((m) => m.isTestModel) ?? providerModels[0] + ).value + try { + const result = await testApiKey(apiKey, testModel) + if (!result.valid) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + setValidationErrors((prev) => ({ + ...prev, + [provider]: result.error || "Invalid API key", + })) + } else { + const defaultModels = MODEL_OPTIONS.filter( + (m) => m.defaultEnabled && m.provider === provider, + ).map((m) => m.value) + if (defaultModels.length > 0) { + setEnabledModels((prev) => ({ ...prev, [provider]: defaultModels })) + } + setValidationState((prev) => ({ ...prev, [provider]: "validated" })) + setValidatedApiKeys((prev) => ({ ...prev, [provider]: true })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + } + } catch (err) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + const errorMessage = + err instanceof Error ? err.message : "Failed to validate API key" + setValidationErrors((prev) => ({ ...prev, [provider]: errorMessage })) + } + }, + [apiKeys], + ) + + const handleRemoveApiKey = useCallback((provider: Provider) => { + // Remove API key from local state only + // Settings will be persisted when Save Settings is clicked + setApiKeys((prev) => ({ ...prev, [provider]: "" })) + setValidatedApiKeys((prev) => ({ ...prev, [provider]: false })) + setValidationState((prev) => ({ ...prev, [provider]: "idle" })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + setIsInputFocused((prev) => ({ ...prev, [provider]: false })) + }, []) + + const handleModelToggle = useCallback( + (provider: Provider, modelValue: string) => { + setEnabledModels((prev) => { + const current = prev[provider] + const isEnabled = current.includes(modelValue) + return { + ...prev, + [provider]: isEnabled + ? current.filter((m) => m !== modelValue) + : [...current, modelValue], + } + }) + }, + [], + ) + + const handleSchemaAccessChange = useCallback( + (provider: Provider, checked: boolean) => { + setGrantSchemaAccess((prev) => ({ ...prev, [provider]: checked })) + }, + [], + ) + + const handleSave = useCallback(() => { + const updatedProviders = { ...aiAssistantSettings.providers } + const allProviders = getAllProviders() + + for (const provider of allProviders) { + if (validatedApiKeys[provider]) { + // Only save providers with validated API keys + updatedProviders[provider] = { + apiKey: apiKeys[provider], + enabledModels: enabledModels[provider], + grantSchemaAccess: grantSchemaAccess[provider], + } + } else { + // Remove provider entry if no validated API key + delete updatedProviders[provider] + } + } + + const updatedSettings: AiAssistantSettings = { + ...aiAssistantSettings, + providers: updatedProviders, + } + + const nextModel = getNextModel(updatedSettings.selectedModel, enabledModels) + updatedSettings.selectedModel = nextModel || undefined + + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, updatedSettings) + toast.success("Settings saved successfully") + onOpenChange?.(false) + }, [ + aiAssistantSettings, + apiKeys, + enabledModels, + grantSchemaAccess, + validatedApiKeys, + updateSettings, + onOpenChange, + ]) + + const handleClose = useCallback(() => { + onOpenChange?.(false) + }, [onOpenChange]) + + const currentProviderValidated = validatedApiKeys[selectedProvider] + const currentProviderApiKey = apiKeys[selectedProvider] + const currentProviderValidationState = validationState[selectedProvider] + const currentProviderError = validationErrors[selectedProvider] + const currentProviderIsFocused = isInputFocused[selectedProvider] + const maskInput = !!(currentProviderApiKey && !currentProviderIsFocused) + + const modelsForProvider = useMemo( + () => getModelsForProvider(selectedProvider), + [selectedProvider], + ) + + const enabledModelsForProvider = useMemo( + () => enabledModels[selectedProvider], + [enabledModels, selectedProvider], + ) + + const allProviders = useMemo(() => getAllProviders(), []) + + const renderProviderIcon = (provider: Provider, isActive: boolean) => { + const color = isActive ? "#f8f8f2" : "#9ca3af" + if (provider === "openai") { + return + } + return + } + + return ( + + + + + + + + + + + Assistant Settings + + Modify settings for your AI assistant, set up new providers, + and review access. + + + + + + + + + + + + + {allProviders.map((provider) => { + const isActive = selectedProvider === provider + return ( + handleProviderSelect(provider)} + data-hook={`ai-settings-provider-${provider}`} + > + + {renderProviderIcon(provider, isActive)} + + {getProviderName(provider)} + + + + + + {validatedApiKeys[provider] ? "Enabled" : "Inactive"} + + + + ) + })} + + + + + + + API Key + {validatedApiKeys[selectedProvider] && ( + } + data-hook="ai-settings-validated-badge" + > + Validated + + )} + + Get your API key from{" "} + + {getProviderName(selectedProvider)} + + . + + + + { + handleApiKeyChange(selectedProvider, e.target.value) + }} + placeholder={`Enter ${getProviderName(selectedProvider)} API key`} + $hasError={!!currentProviderError} + $showEditButton={maskInput} + readOnly={maskInput} + onFocus={() => { + setIsInputFocused((prev) => ({ + ...prev, + [selectedProvider]: true, + })) + }} + onBlur={() => { + setIsInputFocused((prev) => ({ + ...prev, + [selectedProvider]: false, + })) + if (inputRef.current) { + inputRef.current.blur() + } + }} + onMouseDown={(e) => { + if (maskInput) { + e.preventDefault() + } + }} + tabIndex={maskInput ? -1 : 0} + style={{ + cursor: maskInput ? "default" : "text", + }} + data-hook="ai-settings-api-key" + /> + {maskInput && ( + { + inputRef.current?.focus() + }} + title="Edit API key" + > + + + )} + + {currentProviderError && ( + {currentProviderError} + )} + {!currentProviderError && ( + + Stored locally in your browser and never sent to QuestDB + servers. This API key is used to authenticate your + requests to the model provider. + + )} + + currentProviderValidated + ? handleRemoveApiKey(selectedProvider) + : handleValidateApiKey(selectedProvider) + } + disabled={ + currentProviderValidationState === "validating" || + (!currentProviderValidated && !currentProviderApiKey) + } + data-hook="ai-settings-test-api" + > + {currentProviderValidationState === "validating" ? ( + + + Validating... + + ) : currentProviderValidated ? ( + "Remove API Key" + ) : ( + "Validate API Key" + )} + + + + + + Enable Models + {currentProviderValidated ? ( + + {modelsForProvider.map((model) => { + const isEnabled = enabledModelsForProvider.includes( + model.value, + ) + return ( + + + {model.label} + {model.isSlow && ( + + + + Due to advanced reasoning & thinking + capabilities, responses using this model + can be slow. + + + )} + + + handleModelToggle( + selectedProvider, + model.value, + ) + } + /> + + ) + })} + + ) : ( + + + When you've entered and validated your API key, + you'll be able to select and enable available + models. + + + )} + + + + + + Schema Access + + + + + + handleSchemaAccessChange( + selectedProvider, + e.target.checked, + ) + } + disabled={!currentProviderValidated} + data-hook="ai-settings-schema-access" + /> + + + + Grant schema access to{" "} + {getProviderName(selectedProvider)} + + + 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. + + + + + + + + + + + + + + Cancel + + + Save Settings + + + + + + + + ) +} diff --git a/src/components/SetupAIAssistant/index.tsx b/src/components/SetupAIAssistant/index.tsx new file mode 100644 index 000000000..b942df5b2 --- /dev/null +++ b/src/components/SetupAIAssistant/index.tsx @@ -0,0 +1,76 @@ +import React, { useState, useRef } from "react" +import styled from "styled-components" +import { Button } from "../Button" +import { Box } from "../Box" +import { AISparkle } from "../AISparkle" +import { AIAssistantPromo } from "./AIAssistantPromo" +import { ConfigurationModal } from "./ConfigurationModal" +import { SettingsModal } from "./SettingsModal" +import { ModelDropdown } from "./ModelDropdown" +import { useAIStatus } from "../../providers/AIStatusProvider" + +const SettingsButton = styled(Button)` + padding: 0.6rem; + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.cyan}; + } +` + +export const SetupAIAssistant = () => { + const [configModalOpen, setConfigModalOpen] = useState(false) + const [settingsModalOpen, setSettingsModalOpen] = useState(false) + const [showPromo, setShowPromo] = useState(false) + const configureButtonRef = useRef(null) + const { isConfigured } = useAIStatus() + + const handleSettingsClick = () => { + if (isConfigured) { + setSettingsModalOpen(true) + } else { + if (showPromo) { + setShowPromo(false) + setConfigModalOpen(true) + } else { + setShowPromo(true) + } + } + } + + return ( + <> + + +
}> + } + data-hook="ai-assistant-settings-button" + title="AI Assistant Settings" + > + {isConfigured ? "Settings" : "Configure"} + +
+
+ { + setShowPromo(false) + setConfigModalOpen(true) + }} + /> + + {settingsModalOpen && ( + + )} + + ) +} diff --git a/src/components/Switch/index.tsx b/src/components/Switch/index.tsx index 51db66b28..79daac156 100644 --- a/src/components/Switch/index.tsx +++ b/src/components/Switch/index.tsx @@ -14,22 +14,24 @@ const Root = styled(SwitchPrimitive.Root)` display: inline-flex; align-items: center; justify-content: flex-start; - padding: 0 3px; - width: 38px; - height: 21px; - border-radius: 10px; - border: 1px solid #c4c4c9; + padding: 1px; + width: 36px; + height: 18px; + border-radius: 20px; + border: 0; background: transparent; appearance: none; position: relative; transition: 0.2s ease-out; + cursor: pointer; + background: ${({ theme }) => theme.color.selection}; &:focus { border-color: #878eb6; } &[data-state="checked"] { - background: #44475a; + background: ${({ theme }) => theme.color.greenDarker}; } &[data-disabled], @@ -40,16 +42,16 @@ const Root = styled(SwitchPrimitive.Root)` const StyledThumb = styled(SwitchPrimitive.Thumb)` display: block; - width: 14px; - height: 14px; - background-color: #d8d8d8; - border-radius: 50%; - transition: linear transform 100ms; + width: 16px; + height: 16px; + background-color: ${({ theme }) => theme.color.foreground}; + border-radius: 100%; + transition: transform 100ms linear; transform: translateX(0); will-change: transform; &[data-state="checked"] { - transform: translateX(17px); + transform: translateX(18px); } &[data-disabled] { diff --git a/src/components/TableSchemaDialog/actions.tsx b/src/components/TableSchemaDialog/actions.tsx index f682553a3..5bb8d039a 100644 --- a/src/components/TableSchemaDialog/actions.tsx +++ b/src/components/TableSchemaDialog/actions.tsx @@ -8,6 +8,8 @@ import { Tooltip } from "../Tooltip" import { useFieldArray, useFormContext } from "react-hook-form" import type { Action, SchemaColumn } from "./types" import { InsertRowBottom, InsertRowTop } from "@styled-icons/remix-editor" +import { XIcon } from "@phosphor-icons/react" +import { StyledClose } from "../Drawer" export const Actions = ({ action, @@ -15,12 +17,14 @@ export const Actions = ({ lastFocusedIndex, onAdded, isEditLocked, + onDismiss, }: { action: Action ctaText: string lastFocusedIndex?: number onAdded: (index?: number) => void isEditLocked?: boolean + onDismiss?: () => void }) => { const newEntry = { name: "", @@ -128,6 +132,9 @@ export const Actions = ({ } variant="success"> {ctaText} + + + ) } diff --git a/src/components/TableSchemaDialog/dialog.tsx b/src/components/TableSchemaDialog/dialog.tsx index 1c1b57863..e56bf20c1 100644 --- a/src/components/TableSchemaDialog/dialog.tsx +++ b/src/components/TableSchemaDialog/dialog.tsx @@ -112,6 +112,12 @@ export const Dialog = ({ }) } + const handleDismiss = () => { + resetToDefaults() + onOpenChange(undefined) + dispatch(actions.console.setActiveSidebar(undefined)) + } + const validationSchema = Joi.object({ name: Joi.string() .required() @@ -203,10 +209,7 @@ export const Dialog = ({ ) } - onDismiss={() => { - resetToDefaults() - onOpenChange(undefined) - }} + onDismiss={handleDismiss} onOpenChange={(isOpen) => { if (isOpen && action === "add") { dispatch( @@ -225,6 +228,7 @@ export const Dialog = ({ onSubmit={(values) => { onSchemaChange(values) onOpenChange(undefined) + dispatch(actions.console.setActiveSidebar(undefined)) }} onChange={(values) => setCurrentValues(values as SchemaFormValues)} validationSchema={validationSchema} @@ -238,6 +242,7 @@ export const Dialog = ({ isEditLocked={isEditLocked} lastFocusedIndex={lastFocusedIndex} onAdded={setLastFocusedIndex} + onDismiss={handleDismiss} /> } /> diff --git a/src/components/Text/index.tsx b/src/components/Text/index.tsx index f3c750d67..e83d00a60 100644 --- a/src/components/Text/index.tsx +++ b/src/components/Text/index.tsx @@ -47,6 +47,8 @@ export type TextProps = Readonly<{ transform?: Transform type?: Type weight?: number + margin?: string + padding?: string }> const defaultProps: Readonly<{ @@ -71,6 +73,8 @@ export const textStyles = css` font-weight: ${({ weight }) => weight}; text-transform: ${({ transform }) => transform}; ${({ align }) => (align ? `text-align: ${align}` : "")}; + ${({ margin }) => margin && `margin: ${margin}`}; + ${({ padding }) => padding && `padding: ${padding}`}; ${({ ellipsis }) => ellipsis && ellipsisStyles}; ` diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index eb07a3778..40356db1e 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -8,7 +8,6 @@ import { ToastContent, } from "react-toastify" import { useNotificationCenter as RTNotificationCenter } from "react-toastify/addons/use-notification-center" -import { NotificationCenterItem as RNotificationCenterItem } from "react-toastify/addons/use-notification-center/useNotificationCenter" import { BadgeType } from "../../scenes/Import/ImportCSVFiles/types" import { CloseCircle, @@ -30,8 +29,6 @@ export type ToastOptions = RTToastOptions export const useNotificationCenter = RTNotificationCenter -export type NotificationCenterItem = RNotificationCenterItem - export const ToastIcon = ({ type, size = 18, diff --git a/src/components/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index dc2eba828..0557a5322 100644 --- a/src/components/TopBar/toolbar.tsx +++ b/src/components/TopBar/toolbar.tsx @@ -7,8 +7,8 @@ import { User as UserIcon, LogoutCircle, Edit } from "@styled-icons/remix-line" import { InfoCircle, Error as ErrorIcon } from "@styled-icons/boxicons-regular" import { Tools, ShieldCheck } from "@styled-icons/bootstrap" import { Flask } from "@styled-icons/boxicons-solid" +import { toast } from "../Toast" import { Box, Button } from "../../components" -import { toast } from "../" import { Text } from "../Text" import { selectors } from "../../store" import { useSelector } from "react-redux" @@ -49,7 +49,7 @@ const CustomTooltipWrapper = styled.div<{ display: flex; flex-direction: column; padding: 1.5rem 0; - background: ${({ theme }) => theme.color.background}; + background: ${({ theme }) => theme.color.backgroundDarker}; font-size: 1.4rem; border-radius: 0.8rem; border: 1px solid ${({ $badgeColors }) => $badgeColors.primary}; diff --git a/src/components/index.ts b/src/components/index.ts index 2c63886e4..cafba26c9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -35,15 +35,19 @@ export * from "./Dialog" export * from "./Drawer" export * from "./DropdownMenu" export * from "./Emoji" +export * from "./ExplainQueryButton" export * from "./FeedbackDialog" +export * from "./FixQueryButton" export * from "./Form" export * from "./ForwardRef" export * from "./Heading" export * from "./IconWithTooltip" export * from "./Input" +export * from "./Key" export * from "./Link" export * from "./Loader" export * from "./LoadingSpinner" +export * from "./MultiStepModal" export * from "./Overlay" export * from "./PaneContent" export * from "./PaneMenu" @@ -52,6 +56,7 @@ export * from "./PopperHover" export * from "./PopperToggle" export * from "./Popover" export * from "./Select" +export * from "./SetupAIAssistant" export * from "./Switch" export * from "./Table" export * from "./Text" diff --git a/src/index.tsx b/src/index.tsx index c50b6d376..a9d28aea3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,6 +24,7 @@ import "core-js/features/promise" import "./js/console" +import "./utils/monacoInit" import "./js/console/cryptoPolyfill" import React from "react" @@ -60,9 +61,7 @@ const epicMiddleware = createEpicMiddleware< const store = createStore(rootReducer, compose(applyMiddleware(epicMiddleware))) -if (import.meta.env.MODE !== "development") { - epicMiddleware.run(rootEpic) -} +epicMiddleware.run(rootEpic) const FadeReg = createGlobalFadeTransition("fade-reg", TransitionDuration.REG) diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index 31325a18f..0407161a3 100644 --- a/src/modules/EventBus/types.ts +++ b/src/modules/EventBus/types.ts @@ -21,4 +21,6 @@ export enum EventType { TAB_BLUR = "tab.blur", METRICS_REFRESH_DATA = "metrics.refresh.data", BUFFERS_UPDATED = "buffers.updated", + EXPLAIN_QUERY_EXEC = "ai.explain.query.exec", + AI_QUERY_HIGHLIGHT = "ai.query.highlight", } diff --git a/src/providers/AIConversationProvider/index.tsx b/src/providers/AIConversationProvider/index.tsx new file mode 100644 index 000000000..cdb999f8c --- /dev/null +++ b/src/providers/AIConversationProvider/index.tsx @@ -0,0 +1,991 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from "react" +import { useLiveQuery } from "dexie-react-hooks" +import { + db, + type ConversationMeta, + type ConversationMetaWithStatus, +} from "../../store/db" +import { aiConversationStore } from "../../store/aiConversations" +import { actions, selectors } from "../../store" +import type { + ConversationMessage, + ChatWindowState, + ConversationId, + AIConversation, +} from "./types" +import type { QueryKey } from "../../scenes/Editor/Monaco/utils" +import { + normalizeQueryText, + createQueryKey, + getQueryInfoFromKey, + shiftQueryKey, +} from "../../scenes/Editor/Monaco/utils" +import { useEditor } from "../EditorProvider" +import { normalizeSql } from "../../utils/aiAssistant" +import { useDispatch, useSelector } from "react-redux" + +export type AcceptSuggestionParams = { + conversationId: ConversationId + messageId: string + skipDefaultMessage?: boolean +} + +type AIConversationContextType = { + conversationMetas: Map + activeConversationMessages: ConversationMessage[] + chatWindowState: ChatWindowState + isLoadingMessages: boolean + + getConversationMeta: ( + id: ConversationId, + ) => ConversationMetaWithStatus | undefined + findConversationByQuery: ( + bufferId: number, + queryKey: QueryKey, + ) => ConversationMetaWithStatus | undefined + findConversationByTableId: ( + tableId: number, + ) => ConversationMetaWithStatus | undefined + findQueryByConversationId: ( + conversationId: ConversationId, + ) => { queryKey: QueryKey; bufferId: number } | null + hasConversationForQuery: (bufferId: number, queryKey: QueryKey) => boolean + + createConversation: (options: { + bufferId?: number + queryKey?: QueryKey + tableId?: number + }) => Promise + handleGlyphClick: (options: { + bufferId: number + queryKey: QueryKey + }) => Promise + shiftQueryKeysForBuffer: ( + bufferId: string | number, + changeOffset: number, + delta: number, + ) => boolean + + openChatWindow: (conversationId: ConversationId) => Promise + openOrCreateBlankChatWindow: () => Promise + openBlankChatWindow: () => Promise + closeChatWindow: () => void + openHistoryView: () => void + closeHistoryView: () => void + deleteConversation: (conversationId: ConversationId) => Promise + + addMessage: ( + message: Omit & { id?: string }, + ) => void + updateMessage: ( + conversationId: ConversationId, + messageId: string, + updates: Partial, + ) => void + replaceConversationMessages: ( + conversationId: ConversationId, + newMessages: Array, + ) => void + updateConversationName: ( + conversationId: ConversationId, + name: string, + ) => Promise + + acceptSuggestion: (params: AcceptSuggestionParams) => Promise + rejectSuggestion: ( + conversationId: ConversationId, + messageId: string, + ) => Promise + persistMessages: (conversationId: ConversationId) => Promise +} + +const AIConversationContext = createContext< + AIConversationContextType | undefined +>(undefined) + +export const useAIConversation = () => { + const context = useContext(AIConversationContext) + if (!context) { + throw new Error( + "useAIConversation must be used within AIConversationProvider", + ) + } + return context +} + +export const AIConversationProvider: React.FC<{ + children: React.ReactNode +}> = ({ children }) => { + const dispatch = useDispatch() + const activeSidebar = useSelector(selectors.console.getActiveSidebar) + const conversationMetasArray = useLiveQuery( + async () => { + const metas: ConversationMeta[] = await db.ai_conversations.toArray() + const metasWithStatus: ConversationMetaWithStatus[] = [] + for (const meta of metas) { + const hasMessages = await db.ai_conversation_messages + .where("conversationId") + .equals(meta.id) + .count() + metasWithStatus.push({ + ...meta, + hasMessages: hasMessages > 0, + }) + } + return metasWithStatus + }, + [], + null, + ) + + const conversationMetas = useMemo( + () => new Map(conversationMetasArray?.map((m) => [m.id, m]) ?? []), + [conversationMetasArray], + ) + + const [activeConversationMessages, setActiveConversationMessages] = useState< + ConversationMessage[] + >([]) + + const activeConversationMessagesRef = useRef([]) + useEffect(() => { + activeConversationMessagesRef.current = activeConversationMessages + }, [activeConversationMessages]) + + const [isLoadingMessages, setIsLoadingMessages] = useState(false) + + const [chatWindowState, setChatWindowState] = useState({ + activeConversationId: null, + isHistoryOpen: false, + previousConversationId: null, + }) + + const activeConversationId = chatWindowState.activeConversationId + + const activeConversationIdRef = useRef(activeConversationId) + useEffect(() => { + activeConversationIdRef.current = activeConversationId + }, [activeConversationId]) + + const isOpeningChatWindowRef = useRef(false) + + const persistMessages = useCallback( + async (conversationId: ConversationId, updateTimestamp: boolean = true) => { + if (conversationId !== activeConversationIdRef.current) { + return + } + await aiConversationStore.saveMessages( + conversationId, + activeConversationMessagesRef.current, + ) + if (updateTimestamp) { + await aiConversationStore.updateMeta(conversationId, { + updatedAt: Date.now(), + }) + } + }, + [], + ) + + const getConversationMeta = useCallback( + (id: ConversationId): ConversationMetaWithStatus | undefined => { + return conversationMetas.get(id) + }, + [conversationMetas], + ) + + const findConversationByQuery = useCallback( + ( + bufferId: string | number, + queryKey: QueryKey, + ): ConversationMetaWithStatus | undefined => { + for (const meta of conversationMetas.values()) { + if (meta.bufferId === bufferId && meta.queryKey === queryKey) { + return meta + } + } + return undefined + }, + [conversationMetas], + ) + + const findConversationByTableId = useCallback( + (tableId: number): ConversationMetaWithStatus | undefined => { + for (const meta of conversationMetas.values()) { + if (meta.tableId === tableId) { + return meta + } + } + return undefined + }, + [conversationMetas], + ) + + const findQueryByConversationId = useCallback( + ( + conversationId: ConversationId, + ): { queryKey: QueryKey; bufferId: number } | null => { + const meta = conversationMetas.get(conversationId) + if (!meta) return null + if (!meta.queryKey || !meta.bufferId) return null + return { queryKey: meta.queryKey, bufferId: meta.bufferId } + }, + [conversationMetas], + ) + + const hasConversationForQuery = useCallback( + (bufferId: string | number, queryKey: QueryKey): boolean => { + const meta = findConversationByQuery(bufferId, queryKey) + if (!meta) return false + return ( + meta.hasMessages || + (chatWindowState.activeConversationId === meta.id && + activeConversationMessagesRef.current.length > 0) + ) + }, + [findConversationByQuery, activeConversationId], + ) + + const createConversation = useCallback( + async (options: { + bufferId?: number + queryKey?: QueryKey + tableId?: number + }): Promise => { + const id = crypto.randomUUID() + const { queryText } = getQueryInfoFromKey(options.queryKey) + const meta: ConversationMeta = { + id, + queryKey: options.queryKey, + bufferId: options.bufferId, + tableId: options.tableId, + currentSQL: queryText, + conversationName: "AI Assistant", + updatedAt: Date.now(), + } + + await aiConversationStore.saveMeta(meta) + + return { ...meta, messages: [] } + }, + [], + ) + + const updateConversationAssociations = useCallback( + async ( + conversationId: ConversationId, + updates: { + bufferId?: number + queryKey?: QueryKey + tableId?: number + }, + ): Promise => { + await aiConversationStore.updateMeta(conversationId, { + ...updates, + updatedAt: Date.now(), + }) + }, + [], + ) + + const shiftQueryKeysForBuffer = useCallback( + ( + bufferId: string | number, + changeOffset: number, + delta: number, + ): boolean => { + let shiftedQueryKeys = false + for (const [id, meta] of conversationMetas) { + if (meta.bufferId === bufferId && meta.queryKey) { + const { startOffset } = getQueryInfoFromKey(meta.queryKey) + if (startOffset >= changeOffset) { + const newQueryKey = shiftQueryKey( + meta.queryKey, + changeOffset, + delta, + ) + void aiConversationStore.updateMeta(id, { + queryKey: newQueryKey, + updatedAt: Date.now(), + }) + shiftedQueryKeys = true + } + } + } + return shiftedQueryKeys + }, + [conversationMetas], + ) + + const addMessage = useCallback( + (message: Omit & { id?: string }) => { + const messageWithId: ConversationMessage = { + ...message, + id: message.id || crypto.randomUUID(), + } + setActiveConversationMessages((prev) => [...prev, messageWithId]) + }, + [], + ) + + const updateMessage = useCallback( + ( + conversationId: ConversationId, + messageId: string, + updates: Partial, + ) => { + const meta = conversationMetas.get(conversationId) + + setActiveConversationMessages((prev) => { + let hasSQLChange = false + const updatedMessages = prev.map((msg) => { + if (msg.id !== messageId) return msg + + let finalUpdates = updates + const newSql = updates.sql + if ( + newSql !== undefined && + msg.previousSQL === undefined && + updates.previousSQL === undefined && + meta + ) { + const { queryText: acceptedSQL } = getQueryInfoFromKey( + meta.queryKey, + ) + const normalizedNewSQL = normalizeQueryText(newSql || "") + const normalizedAcceptedSQL = normalizeQueryText(acceptedSQL) + const sqlActuallyChanged = + normalizedNewSQL !== normalizedAcceptedSQL + + if (sqlActuallyChanged) { + hasSQLChange = true + } + + finalUpdates = { + ...updates, + previousSQL: sqlActuallyChanged ? acceptedSQL : undefined, + } + } + + return { ...msg, ...finalUpdates } + }) + + if (hasSQLChange && updates.sql) { + void aiConversationStore.updateMeta(conversationId, { + currentSQL: updates.sql, + updatedAt: Date.now(), + }) + } + + return updatedMessages + }) + }, + [conversationMetas], + ) + + const replaceConversationMessages = useCallback( + ( + _conversationId: ConversationId, + newMessages: Array, + ) => { + setActiveConversationMessages((prev) => { + const conversationMessages = [...prev] + let lastReplaceIndex = -1 + for (const message of newMessages) { + const index = conversationMessages.findIndex( + (m) => m.id === message.id, + ) + if (index !== -1) { + lastReplaceIndex = Math.max(lastReplaceIndex, index) + conversationMessages[index] = message + } + } + conversationMessages.splice( + lastReplaceIndex + 1, + 0, + newMessages[newMessages.length - 1], + ) + return conversationMessages + }) + }, + [], + ) + + const updateConversationSQL = useCallback( + async (conversationId: ConversationId, sql: string) => { + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const { startOffset } = getQueryInfoFromKey(meta.queryKey) + const newQueryKey = createQueryKey(sql, startOffset) + + await aiConversationStore.updateMeta(conversationId, { + currentSQL: sql, + queryKey: newQueryKey, + updatedAt: Date.now(), + }) + }, + [conversationMetas], + ) + + const updateConversationName = useCallback( + async (conversationId: ConversationId, name: string) => { + await aiConversationStore.updateMeta(conversationId, { + conversationName: name, + }) + }, + [], + ) + + const acceptConversationChanges = useCallback( + async (conversationId: ConversationId, messageId: string) => { + if (activeConversationId !== conversationId) return + + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const targetMessage = activeConversationMessages.find( + (m) => m.id === messageId, + ) + if (!targetMessage || !targetMessage.sql) return + + const { startOffset } = getQueryInfoFromKey(meta.queryKey) + const newQueryKey = createQueryKey(targetMessage.sql, startOffset) + + await aiConversationStore.updateMeta(conversationId, { + queryKey: newQueryKey, + updatedAt: Date.now(), + }) + + setActiveConversationMessages((prev) => + prev.map((msg) => { + if (msg.id === messageId) { + return { ...msg, isAccepted: true } + } + return msg + }), + ) + }, + [activeConversationId, conversationMetas, activeConversationMessages], + ) + + const rejectLatestChange = useCallback( + async (conversationId: ConversationId, messageId: string) => { + if (activeConversationId !== conversationId) return + + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const latestMessage = activeConversationMessages.find( + (m) => m.id === messageId, + ) + if (!latestMessage || !latestMessage.sql) return + + const { queryText: acceptedSQL } = getQueryInfoFromKey(meta.queryKey) + const revertedSQL = + typeof latestMessage.previousSQL === "string" + ? latestMessage.previousSQL + : acceptedSQL + + await aiConversationStore.updateMeta(conversationId, { + currentSQL: revertedSQL, + updatedAt: Date.now(), + }) + + const rejectionMessage: ConversationMessage = { + id: crypto.randomUUID(), + role: "user", + content: `User rejected your latest change. Please use the previous version as the base for future modifications.`, + timestamp: Date.now(), + hideFromUI: true, + } + + setActiveConversationMessages((prev) => { + const updatedMessages = prev.map((msg) => { + if (msg.id === messageId) { + return { ...msg, isRejected: true } + } + return msg + }) + return [...updatedMessages, rejectionMessage] + }) + }, + [activeConversationId, conversationMetas, activeConversationMessages], + ) + + const openChatWindow = useCallback( + async (conversationId: ConversationId, loadMessages: boolean = true) => { + if (isOpeningChatWindowRef.current) return + isOpeningChatWindowRef.current = true + + try { + const prevId = chatWindowState.activeConversationId + + if (prevId && prevId !== conversationId) { + const prevMeta = conversationMetas.get(prevId) + if (prevMeta && activeConversationMessages.length === 0) { + await aiConversationStore.deleteConversation(prevId) + } else { + await persistMessages(prevId, false) + } + } else if ( + prevId === conversationId && + !chatWindowState.isHistoryOpen && + activeSidebar === "aiChat" + ) { + return + } + + if (!activeConversationId) { + setActiveConversationMessages([]) + } + + if (loadMessages && conversationId !== prevId) { + setIsLoadingMessages(true) + const msgs = await aiConversationStore.getMessages(conversationId) + setActiveConversationMessages(msgs) + setIsLoadingMessages(false) + } else if (!loadMessages) { + setActiveConversationMessages([]) + } + + setChatWindowState((prev) => ({ + ...prev, + isHistoryOpen: false, + previousConversationId: null, + activeConversationId: conversationId, + })) + dispatch(actions.console.setActiveSidebar("aiChat")) + } finally { + isOpeningChatWindowRef.current = false + } + }, + [ + activeSidebar, + chatWindowState, + conversationMetas, + activeConversationMessages, + persistMessages, + ], + ) + + const closeChatWindow = useCallback(() => { + dispatch(actions.console.setActiveSidebar(undefined)) + if (chatWindowState.activeConversationId) { + if (activeConversationMessages.length === 0) { + void aiConversationStore.deleteConversation( + chatWindowState.activeConversationId, + ) + } + } + }, [chatWindowState.activeConversationId, activeConversationMessages]) + + const handleGlyphClick = useCallback( + async (options: { + bufferId: number + queryKey: QueryKey + }): Promise => { + const existing = findConversationByQuery( + options.bufferId, + options.queryKey, + ) + if (existing) { + if (activeConversationId === existing.id) { + if (activeSidebar === "aiChat") { + closeChatWindow() + return + } + } + await openChatWindow(existing.id) + return + } + const newConversation = await createConversation({ + bufferId: options.bufferId, + queryKey: options.queryKey, + }) + await openChatWindow(newConversation.id) + }, + [ + closeChatWindow, + findConversationByQuery, + createConversation, + activeConversationId, + activeSidebar, + ], + ) + + const openOrCreateBlankChatWindow = useCallback(async () => { + if (chatWindowState.activeConversationId) { + const existingMeta = conversationMetas.get( + chatWindowState.activeConversationId, + ) + if (existingMeta) { + await openChatWindow(chatWindowState.activeConversationId) + return + } + } + + if (conversationMetas.size > 0) { + const latestMeta = Array.from(conversationMetas.values()).reduce( + (latest, meta) => (meta.updatedAt > latest.updatedAt ? meta : latest), + ) + await openChatWindow(latestMeta.id) + return + } + + const blankConversation = await createConversation({}) + await openChatWindow(blankConversation.id) + }, [ + chatWindowState.activeConversationId, + conversationMetas, + openChatWindow, + createConversation, + ]) + + const openBlankChatWindow = useCallback(async () => { + const blankConversation = await createConversation({}) + await openChatWindow(blankConversation.id, false) + }, [createConversation, openChatWindow]) + + const openHistoryView = useCallback(() => { + setChatWindowState((prev) => ({ + ...prev, + isHistoryOpen: true, + previousConversationId: prev.activeConversationId, + })) + }, []) + + const closeHistoryView = useCallback(() => { + setChatWindowState((prev) => ({ + ...prev, + isHistoryOpen: false, + activeConversationId: + prev.previousConversationId ?? prev.activeConversationId, + })) + }, []) + + const deleteConversation = useCallback( + async (conversationId: ConversationId) => { + await aiConversationStore.deleteConversation(conversationId) + + let fallbackId: ConversationId | null = null + let latestUpdatedAt = 0 + for (const [id, meta] of conversationMetas) { + if (id !== conversationId && meta.updatedAt > latestUpdatedAt) { + latestUpdatedAt = meta.updatedAt + fallbackId = id + } + } + + if (activeConversationId === conversationId) { + if (fallbackId) { + const msgs = await aiConversationStore.getMessages(fallbackId) + setActiveConversationMessages(msgs) + } else { + setActiveConversationMessages([]) + } + } + + setChatWindowState((prev) => { + const updates: Partial = {} + if (prev.activeConversationId === conversationId) { + updates.activeConversationId = fallbackId + } + if (prev.previousConversationId === conversationId) { + updates.previousConversationId = fallbackId + } + return Object.keys(updates).length > 0 ? { ...prev, ...updates } : prev + }) + }, + [activeConversationId, conversationMetas], + ) + + const { + editorRef, + buffers, + activeBuffer, + setActiveBuffer, + addBuffer, + closeDiffBufferForConversation, + applyAISQLChange, + } = useEditor() + + const applyChangesToActiveTab = useCallback( + async ( + conversationId: ConversationId, + normalizedSQL: string, + messageId: string, + ): Promise => { + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const result = applyAISQLChange({ + newSQL: normalizedSQL, + queryKey: meta.queryKey ?? undefined, + }) + + if (!result.success) return + + await updateConversationAssociations(conversationId, { + queryKey: result.finalQueryKey ?? meta.queryKey, + }) + + await updateConversationSQL(conversationId, normalizedSQL) + await acceptConversationChanges(conversationId, messageId) + + if (meta.tableId != null) { + await aiConversationStore.updateMeta(conversationId, { + tableId: undefined, + updatedAt: Date.now(), + }) + } + }, + [ + conversationMetas, + updateConversationAssociations, + updateConversationSQL, + acceptConversationChanges, + ], + ) + + const applyChangesToNewTab = useCallback( + async ( + conversationId: ConversationId, + normalizedSQL: string, + messageId: string, + ): Promise => { + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const sqlWithSemicolon = normalizeSql(normalizedSQL) + const newBuffer = await addBuffer({ + value: sqlWithSemicolon, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + if (!editorRef.current) return + + const model = editorRef.current.getModel() + if (!model) return + + const queryStartOffset = 0 + const normalizedQuery = normalizeQueryText(normalizedSQL) + const queryEndOffset = normalizedQuery.length + + const startPosition = model.getPositionAt(queryStartOffset) + const endPosition = model.getPositionAt(queryEndOffset) + + const highlightRange = { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + } + + const decorationId = model.deltaDecorations( + [], + [ + { + range: highlightRange, + options: { + isWholeLine: false, + className: "aiQueryHighlight", + }, + }, + ], + ) + + editorRef.current.revealPositionNearTop(startPosition) + + setTimeout(() => { + model.deltaDecorations(decorationId, []) + }, 2000) + + const newQueryKey = createQueryKey(normalizedQuery, queryStartOffset) + await updateConversationAssociations(conversationId, { + bufferId: newBuffer.id, + queryKey: newQueryKey, + }) + + await updateConversationSQL(conversationId, normalizedSQL) + await acceptConversationChanges(conversationId, messageId) + + if (meta.tableId != null) { + await aiConversationStore.updateMeta(conversationId, { + tableId: undefined, + updatedAt: Date.now(), + }) + } + }, + [ + conversationMetas, + addBuffer, + updateConversationAssociations, + updateConversationSQL, + acceptConversationChanges, + ], + ) + + const acceptSuggestion = useCallback( + async (params: AcceptSuggestionParams): Promise => { + const { conversationId, messageId, skipDefaultMessage } = params + + if (activeConversationId !== conversationId) return + + const meta = conversationMetas.get(conversationId) + if (!meta) return + + const message = activeConversationMessages.find((m) => m.id === messageId) + if (!message || !message.sql) return + + const normalizedSQL = normalizeSql(message.sql, false) + + await closeDiffBufferForConversation(conversationId) + + const conversationBufferId = meta.bufferId + const buffer = buffers.find((b) => b.id === conversationBufferId) + + const bufferStatus = + conversationBufferId == null + ? ("none" as const) + : !buffer + ? ("deleted" as const) + : buffer.archived + ? ("archived" as const) + : buffer.id === activeBuffer.id + ? ("active" as const) + : ("inactive" as const) + + try { + if (bufferStatus === "active") { + await applyChangesToActiveTab( + conversationId, + normalizedSQL, + messageId, + ) + } else if ( + bufferStatus === "deleted" || + bufferStatus === "archived" || + bufferStatus === "none" + ) { + await applyChangesToNewTab(conversationId, normalizedSQL, messageId) + } else if (bufferStatus === "inactive" && buffer) { + await setActiveBuffer(buffer) + await new Promise((resolve) => setTimeout(resolve, 100)) + await applyChangesToActiveTab( + conversationId, + normalizedSQL, + messageId, + ) + } + + if (!skipDefaultMessage) { + addMessage({ + id: crypto.randomUUID(), + role: "user" as const, + content: `User accepted your SQL change. Now the query is:\n\n\`\`\`sql\n${normalizedSQL.replaceAll(/\s+/g, " ").trim()}\n\`\`\``, + timestamp: Date.now(), + hideFromUI: true, + }) + } + + await persistMessages(conversationId) + } catch (error) { + console.error("Error applying changes:", error) + } + }, + [ + activeConversationId, + conversationMetas, + activeConversationMessages, + buffers, + activeBuffer.id, + setActiveBuffer, + closeDiffBufferForConversation, + applyChangesToActiveTab, + applyChangesToNewTab, + addMessage, + persistMessages, + ], + ) + + const rejectSuggestion = useCallback( + async ( + conversationId: ConversationId, + messageId: string, + ): Promise => { + if (activeConversationId !== conversationId) return + + const meta = conversationMetas.get(conversationId) + if (!meta) return + + await rejectLatestChange(conversationId, messageId) + await closeDiffBufferForConversation(conversationId) + + if (activeBuffer.isDiffBuffer) { + const originalBuffer = buffers.find( + (b) => b.id === meta.bufferId && !b.archived, + ) + if (originalBuffer) { + await setActiveBuffer(originalBuffer) + } + } + + await persistMessages(conversationId) + }, + [ + activeConversationId, + conversationMetas, + rejectLatestChange, + closeDiffBufferForConversation, + activeBuffer, + buffers, + setActiveBuffer, + persistMessages, + ], + ) + + return ( + + {children} + + ) +} diff --git a/src/providers/AIConversationProvider/types.ts b/src/providers/AIConversationProvider/types.ts new file mode 100644 index 000000000..b84f5e424 --- /dev/null +++ b/src/providers/AIConversationProvider/types.ts @@ -0,0 +1,64 @@ +import type { PartitionBy } from "../../utils/questdb" +import type { QueryKey } from "../../scenes/Editor/Monaco/utils" +import type { OperationHistory } from "../AIStatusProvider" + +export type { QueryKey } + +export type ConversationId = string + +export type TokenUsage = { + inputTokens: number + outputTokens: number +} + +export type SchemaDisplayData = { + tableName: string + isMatView: boolean + partitionBy?: PartitionBy + walEnabled?: boolean + designatedTimestamp?: string +} + +export type UserMessageDisplayType = + | "fix_request" + | "explain_request" + | "ask_request" + | "schema_explain_request" + +export type ConversationMessage = { + id: string + role: "user" | "assistant" + content: string + timestamp: number + error?: string + sql?: string + explanation?: string + tokenUsage?: TokenUsage // Token usage for current turn in total, including tool calls that we omit from the history after response + previousSQL?: string // SQL before this change (for diff display) + isRejected?: boolean + isAccepted?: boolean + hideFromUI?: boolean // User messages for accept/reject and compaction result are hidden + isCompacted?: boolean // When converted to true, we include it in the history for UI, but do not send to the model anymore + operationHistory?: OperationHistory + // Predefined actions (Fix and Explain) + displayType?: UserMessageDisplayType + displayUserMessage?: string + displaySchemaData?: SchemaDisplayData +} + +export type AIConversation = { + id: ConversationId + conversationName: string + messages: ConversationMessage[] + updatedAt: number + tableId?: number + bufferId?: number + queryKey?: QueryKey + currentSQL?: string +} + +export type ChatWindowState = { + activeConversationId: ConversationId | null + isHistoryOpen?: boolean + previousConversationId?: ConversationId | null // For navigating back after toggling off the history view +} diff --git a/src/providers/AIConversationProvider/utils.ts b/src/providers/AIConversationProvider/utils.ts new file mode 100644 index 000000000..d89eaab88 --- /dev/null +++ b/src/providers/AIConversationProvider/utils.ts @@ -0,0 +1,49 @@ +import type { ConversationMessage } from "./types" + +/** + * Trims trailing semicolon from SQL for display purposes. + * Also ensures the result ends with a newline for Monaco diff editor compatibility. + */ +export const trimSemicolonForDisplay = ( + sql: string | undefined | null, +): string => { + if (!sql || typeof sql !== "string") return "\n" + let trimmed = sql.trim() + if (trimmed.endsWith(";")) { + trimmed = trimmed.slice(0, -1).trim() + } + return trimmed + "\n" +} + +/** + * Finds the last visible assistant message with an unactioned SQL diff. + * Returns the message if found, null otherwise. + * + */ +export const getLastUnactionedDiff = ( + messages: ConversationMessage[], +): ConversationMessage | null => { + // Find last visible message + const visibleMessages = messages.filter((m) => !m.hideFromUI) + if (visibleMessages.length === 0) return null + + const lastVisible = visibleMessages[visibleMessages.length - 1] + + // Check if it's an assistant message with SQL that hasn't been actioned + const hasUnactionedDiff = + lastVisible.role === "assistant" && + lastVisible.sql !== undefined && + lastVisible.previousSQL !== undefined && + !lastVisible.isAccepted && + !lastVisible.isRejected + + return hasUnactionedDiff ? lastVisible : null +} + +/** + * Checks if there's an unactioned diff in the conversation messages. + * Simple boolean helper wrapping getLastUnactionedDiff. + */ +export const hasUnactionedDiff = (messages: ConversationMessage[]): boolean => { + return getLastUnactionedDiff(messages) !== null +} diff --git a/src/providers/AIStatusProvider/index.tsx b/src/providers/AIStatusProvider/index.tsx new file mode 100644 index 000000000..6314ff4ac --- /dev/null +++ b/src/providers/AIStatusProvider/index.tsx @@ -0,0 +1,256 @@ +import React, { + createContext, + useCallback, + useContext, + useState, + useRef, + useEffect, + useMemo, +} from "react" +import { useLocalStorage } from "../LocalStorageProvider" +import { + isAiAssistantConfigured, + getSelectedModel, + hasSchemaAccess, + providerForModel, + canUseAiAssistant, +} from "../../utils/aiAssistantSettings" + +export const useAIStatus = () => { + const context = useContext(AIStatusContext) + if (!context) { + throw new Error("useAIStatus must be used within AIStatusProvider") + } + return context +} + +export const isBlockingAIStatus = (status: AIOperationStatus | null) => { + return ( + status !== undefined && + status !== null && + status !== AIOperationStatus.Aborted + ) +} + +const AIStatusContext = createContext( + undefined, +) + +export enum AIOperationStatus { + Processing = "Processing request", + RetrievingTables = "Reviewing tables", + InvestigatingTableSchema = "Investigating table schema", + RetrievingDocumentation = "Reviewing docs", + InvestigatingDocs = "Investigating docs", + ValidatingQuery = "Validating generated query", + Aborted = "Operation has been cancelled", + Compacting = "Compacting conversation", +} + +export type StatusArgs = { + conversationId?: string + name?: string + section?: string + items?: Array<{ name: string; section?: string }> +} + +export type StatusEntry = { + type: AIOperationStatus + args?: StatusArgs +} + +export type OperationHistory = StatusEntry[] + +type BaseAIStatusContextType = { + status: AIOperationStatus | null + setStatus: ( + status: AIOperationStatus | null, + args?: StatusArgs, + onUpdate?: (history: OperationHistory) => void, + ) => void + abortController: AbortController | null + abortOperation: () => void + hasSchemaAccess: boolean + models: string[] + currentOperation: OperationHistory + clearOperation: () => void +} + +export type AIStatusContextType = + | (BaseAIStatusContextType & { + isConfigured: true + canUse: boolean + currentModel: string + apiKey: string + }) + | (BaseAIStatusContextType & { + isConfigured: false + canUse: false + currentModel: string | null + apiKey: string | null + }) + +interface AIStatusProviderProps { + children: React.ReactNode +} + +export const AIStatusProvider: React.FC = ({ + children, +}) => { + const { aiAssistantSettings } = useLocalStorage() + const [status, setStatusState] = useState(null) + const [currentOperation, setCurrentOperation] = useState([]) + const [abortController, setAbortController] = useState( + new AbortController(), + ) + const abortControllerRef = useRef(null) + const statusRef = useRef(null) + const currentOperationRef = useRef([]) + const timeoutRef = useRef | null>(null) + const isConfigured = useMemo( + () => isAiAssistantConfigured(aiAssistantSettings), + [aiAssistantSettings], + ) + + const canUse = useMemo( + () => canUseAiAssistant(aiAssistantSettings), + [aiAssistantSettings], + ) + + const currentModel = useMemo( + () => getSelectedModel(aiAssistantSettings), + [aiAssistantSettings], + ) + + const hasSchemaAccessValue = useMemo( + () => hasSchemaAccess(aiAssistantSettings), + [aiAssistantSettings], + ) + + const apiKey = useMemo(() => { + if (!currentModel) return null + const provider = providerForModel(currentModel) + return aiAssistantSettings.providers?.[provider]?.apiKey || null + }, [currentModel, aiAssistantSettings]) + + const models = useMemo(() => { + const allModels: string[] = [] + const anthropicModels = + aiAssistantSettings.providers?.anthropic?.enabledModels || [] + const openaiModels = + aiAssistantSettings.providers?.openai?.enabledModels || [] + allModels.push(...anthropicModels, ...openaiModels) + return allModels + }, [aiAssistantSettings]) + + const setStatus = useCallback( + ( + newStatus: AIOperationStatus | null, + args?: StatusArgs, + onUpdate?: (history: OperationHistory) => void, + ) => { + if (newStatus !== null) { + const statusPayload = { + type: newStatus, + args: args || undefined, + } + if ( + statusRef.current === null || + statusRef.current === AIOperationStatus.Aborted + ) { + currentOperationRef.current = [statusPayload] + } else { + currentOperationRef.current.push(statusPayload) + } + if (onUpdate) { + onUpdate([...currentOperationRef.current]) + } + } + setCurrentOperation([...currentOperationRef.current]) + statusRef.current = newStatus + setStatusState(newStatus) + }, + [], + ) + + const clearOperation = useCallback(() => { + currentOperationRef.current = [] + setCurrentOperation([]) + }, []) + + const abortOperation = useCallback(() => { + if ( + abortControllerRef.current && + statusRef.current !== null && + statusRef.current !== AIOperationStatus.Aborted + ) { + abortControllerRef.current?.abort() + setAbortController(new AbortController()) + setStatus(AIOperationStatus.Aborted) + } + }, [setStatus]) + + useEffect(() => { + if (status === AIOperationStatus.Aborted && timeoutRef.current === null) { + timeoutRef.current = setTimeout(() => { + currentOperationRef.current = [] + setCurrentOperation([]) + setStatus(null) + }, 2000) + } else if ( + status !== AIOperationStatus.Aborted && + timeoutRef.current !== null + ) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + }, [status]) + + useEffect(() => { + abortControllerRef.current = abortController + }, [abortController]) + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + const contextValue: AIStatusContextType = isConfigured + ? { + status, + setStatus, + abortController, + abortOperation, + clearOperation, + isConfigured: true, + canUse, + hasSchemaAccess: hasSchemaAccessValue, + currentModel: currentModel!, + apiKey: apiKey!, + models, + currentOperation, + } + : { + status, + setStatus, + abortController, + abortOperation, + clearOperation, + isConfigured: false, + canUse: false, + hasSchemaAccess: hasSchemaAccessValue, + currentModel, + apiKey, + models, + currentOperation, + } + + return ( + + {children} + + ) +} diff --git a/src/providers/EditorProvider/index.tsx b/src/providers/EditorProvider/index.tsx index 82eda1ca2..55612856c 100644 --- a/src/providers/EditorProvider/index.tsx +++ b/src/providers/EditorProvider/index.tsx @@ -1,5 +1,5 @@ import type { Monaco } from "@monaco-editor/react" -import type { editor } from "monaco-editor" +import type { editor, IRange } from "monaco-editor" import React, { createContext, MutableRefObject, @@ -16,8 +16,15 @@ import { clearModelMarkers, insertTextAtCursor, QuestDBLanguageName, + QueryKey, + normalizeQueryText, + parseQueryKey, + createQueryKey, } from "../../scenes/Editor/Monaco/utils" +import type { ConversationId } from "../AIConversationProvider/types" +import { normalizeSql } from "../../utils/aiAssistant" import type { Buffer } from "../../store/buffers" +import type { ExecutionRefs } from "../../scenes/Editor/index" import { bufferStore, BufferType, @@ -33,6 +40,23 @@ import { useLiveQuery } from "dexie-react-hooks" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor +export type DiffBufferContent = { + original: string + modified: string + conversationId?: ConversationId +} + +export type ApplyAISQLChangeOptions = { + newSQL: string + queryKey?: QueryKey +} + +export type ApplyAISQLChangeResult = { + success: boolean + finalQueryKey?: QueryKey + queryStartOffset?: number +} + export type EditorContext = { editorRef: MutableRefObject monacoRef: MutableRefObject @@ -50,9 +74,8 @@ export type EditorContext = { buffer?: Partial, options?: { shouldSelectAll?: boolean }, ) => Promise - deleteBuffer: (id: number) => Promise + deleteBuffer: (id: number, setActiveBuffer?: boolean) => Promise archiveBuffer: (id: number) => Promise - deleteAllBuffers: () => Promise updateBuffer: ( id: number, buffer?: Partial, @@ -62,11 +85,19 @@ export type EditorContext = { positions: { id: number; position: number }[], ) => Promise editorReadyTrigger: (editor: IStandaloneCodeEditor) => void - inFocus: boolean setTemporaryBuffer: (buffer: Buffer) => Promise temporaryBufferId: number | null queryParamProcessedRef: MutableRefObject isNavigatingFromSearchRef: MutableRefObject + // Global diff buffer management + showDiffBuffer: (content: DiffBufferContent) => Promise + closeDiffBufferForConversation: ( + conversationId: ConversationId, + ) => Promise + // Apply AI SQL change to editor + applyAISQLChange: (options: ApplyAISQLChangeOptions) => ApplyAISQLChangeResult + executionRefs: MutableRefObject + cleanupExecutionRefs: (bufferId: number) => void } const defaultValues = { @@ -82,15 +113,18 @@ const defaultValues = { addBuffer: () => Promise.resolve(fallbackBuffer), deleteBuffer: () => Promise.resolve(), archiveBuffer: () => Promise.resolve(), - deleteAllBuffers: () => Promise.resolve(), updateBuffer: () => Promise.resolve(), updateBuffersPositions: () => Promise.resolve(), editorReadyTrigger: () => undefined, - inFocus: false, setTemporaryBuffer: () => Promise.resolve(), temporaryBufferId: null, queryParamProcessedRef: { current: false }, isNavigatingFromSearchRef: { current: false }, + showDiffBuffer: () => Promise.resolve(), + closeDiffBufferForConversation: () => Promise.resolve(), + applyAISQLChange: () => ({ success: false }), + executionRefs: { current: {} }, + cleanupExecutionRefs: () => undefined, } const EditorContext = createContext(defaultValues) @@ -98,6 +132,7 @@ const EditorContext = createContext(defaultValues) export const EditorProvider: React.FC = ({ children }) => { const editorRef = useRef(null) const monacoRef = useRef(null) + const executionRefs = useRef({}) const [temporaryBufferId, setTemporaryBufferId] = useState( null, ) @@ -117,7 +152,6 @@ export const EditorProvider: React.FC = ({ children }) => { )?.value const [activeBuffer, setActiveBufferState] = useState(fallbackBuffer) - const [inFocus, setInFocus] = useState(false) const searchUpdateTimeoutRef = useRef(null) const queryParamProcessedRef = useRef(false) const isNavigatingFromSearchRef = useRef(false) @@ -130,6 +164,10 @@ export const EditorProvider: React.FC = ({ children }) => { return Math.max(...activeBuffers.map((b) => b.position), -1) + 1 }, [buffers]) + const cleanupExecutionRefs = useCallback((bufferId: number) => { + delete executionRefs.current[bufferId.toString()] + }, []) + // this effect should run only once, after mount and after `buffers` and `activeBufferId` are ready from the db useEffect(() => { if (!ranOnce.current && buffers && activeBufferId) { @@ -250,11 +288,6 @@ export const EditorProvider: React.FC = ({ children }) => { return { id, ...buffer } } - const deleteAllBuffers = async () => { - await bufferStore.deleteAll() - eventBus.publish(EventType.BUFFERS_UPDATED, { type: "deleteAll" }) - } - const updateBuffer: EditorContext["updateBuffer"] = async ( id, payload, @@ -350,9 +383,15 @@ export const EditorProvider: React.FC = ({ children }) => { }) } - const deleteBuffer: EditorContext["deleteBuffer"] = async (id) => { + const deleteBuffer: EditorContext["deleteBuffer"] = async ( + id, + setActiveBuffer = true, + ) => { await bufferStore.delete(id) - await setActiveBufferOnRemoved(id) + cleanupExecutionRefs(id) + if (setActiveBuffer) { + await setActiveBufferOnRemoved(id) + } eventBus.publish(EventType.BUFFERS_UPDATED, { type: "delete", bufferId: id, @@ -384,6 +423,203 @@ export const EditorProvider: React.FC = ({ children }) => { }) } + const showDiffBuffer: EditorContext["showDiffBuffer"] = async (content) => { + const existingDiffBuffer = buffers.find( + (b) => b.isDiffBuffer && !b.archived, + ) + + if (existingDiffBuffer && existingDiffBuffer.id) { + // Update existing diff buffer + await bufferStore.update(existingDiffBuffer.id, { + diffContent: { + original: content.original, + modified: content.modified, + conversationId: content.conversationId, + queryStartOffset: 0, + }, + }) + // Switch to it + const updatedBuffer = { + ...existingDiffBuffer, + diffContent: { + original: content.original, + modified: content.modified, + conversationId: content.conversationId, + queryStartOffset: 0, + }, + } + await setActiveBuffer(updatedBuffer) + } else { + // Create new diff buffer + const position = buffers.filter( + (b) => !b.archived && !b.isTemporary, + ).length + await addBuffer({ + label: "AI Suggestion", + value: "", + isDiffBuffer: true, + position, + diffContent: { + original: content.original, + modified: content.modified, + conversationId: content.conversationId, + queryStartOffset: 0, + }, + }) + // addBuffer already switches to it + } + } + + const closeDiffBufferForConversation: EditorContext["closeDiffBufferForConversation"] = + async (conversationId) => { + const diffBuffer = buffers.find( + (b) => + b.isDiffBuffer && + !b.archived && + b.diffContent?.conversationId === conversationId, + ) + if (diffBuffer && diffBuffer.id) { + await deleteBuffer(diffBuffer.id, true) + } + } + + const applyAISQLChange: EditorContext["applyAISQLChange"] = (options) => { + const { newSQL, queryKey } = options + + if (!editorRef.current) { + return { success: false } + } + + const model = editorRef.current.getModel() + if (!model) { + return { success: false } + } + + let finalQueryStartOffset: number = 0 + let replaceRange: IRange | null = null + let shouldReplace = false + + if (queryKey) { + try { + const { queryText, startOffset, endOffset } = parseQueryKey(queryKey) + const currentEditorText = model.getValue() + const queryInEditor = currentEditorText.slice(startOffset, endOffset) + const normalizedQueryInEditor = normalizeQueryText(queryInEditor) + const normalizedOriginalQuery = normalizeQueryText(queryText) + + if (normalizedQueryInEditor === normalizedOriginalQuery) { + const startPosition = model.getPositionAt(startOffset) + + let extendedEndOffset = endOffset + const textAfterQuery = currentEditorText.slice( + endOffset, + endOffset + 10, + ) + const semicolonMatch = textAfterQuery.match(/^(\s*;)/) + if (semicolonMatch) { + extendedEndOffset = endOffset + semicolonMatch[0].length + } + + const endPosition = model.getPositionAt(extendedEndOffset) + replaceRange = { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + } + finalQueryStartOffset = startOffset + shouldReplace = true + } + } catch { + // 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 }) + replaceRange = { + startLineNumber: lineNumber, + startColumn: column, + endLineNumber: lineNumber, + endColumn: column, + } + } + + // Apply the edit with proper semicolon handling + // normalizeSql ensures: removes trailing semicolon, formats, then adds single semicolon + const sqlWithSemicolon = normalizeSql(newSQL) + const isAppend = + replaceRange.startColumn === replaceRange.endColumn && + replaceRange.startLineNumber === replaceRange.endLineNumber + editorRef.current.executeEdits("accept-ai-change", [ + { + range: replaceRange, + text: isAppend ? "\n" + sqlWithSemicolon + "\n" : sqlWithSemicolon, + forceMoveMarkers: true, + }, + ]) + + // Recalculate positions after edit + const finalModel = editorRef.current.getModel() + if (!finalModel) { + return { success: false } + } + + const actualQueryStartOffset = isAppend + ? finalQueryStartOffset + 1 + : finalQueryStartOffset + const normalizedQuery = normalizeQueryText(newSQL) + const actualQueryEndOffset = actualQueryStartOffset + normalizedQuery.length + + const finalStartPosition = finalModel.getPositionAt(actualQueryStartOffset) + const finalEndPosition = finalModel.getPositionAt(actualQueryEndOffset) + + // Apply highlighting decoration + const highlightRange = { + startLineNumber: finalStartPosition.lineNumber, + startColumn: finalStartPosition.column, + endLineNumber: finalEndPosition.lineNumber, + endColumn: finalEndPosition.column, + } + + const decorationId = finalModel.deltaDecorations( + [], + [ + { + range: highlightRange, + options: { + isWholeLine: false, + className: "aiQueryHighlight", + }, + }, + ], + ) + + // Set cursor to beginning of the query and focus the editor + editorRef.current.setPosition(finalStartPosition) + editorRef.current.revealPositionNearTop(finalStartPosition) + editorRef.current.focus() + + setTimeout(() => { + finalModel.deltaDecorations(decorationId, []) + }, 1000) + + // Return the final query key for caller to update conversation state + const finalQueryKey = createQueryKey( + normalizedQuery, + actualQueryStartOffset, + ) + + return { + success: true, + finalQueryKey, + queryStartOffset: actualQueryStartOffset, + } + } + return ( { appendQuery(editorRef.current, text, options) } }, - inFocus, tabsDisabled, setTabsDisabled, buffers, @@ -408,21 +643,22 @@ export const EditorProvider: React.FC = ({ children }) => { addBuffer, deleteBuffer, archiveBuffer, - deleteAllBuffers, updateBuffer, updateBuffersPositions, setTemporaryBuffer, temporaryBufferId, queryParamProcessedRef, isNavigatingFromSearchRef, + showDiffBuffer, + closeDiffBufferForConversation, + applyAISQLChange, + executionRefs, + cleanupExecutionRefs, editorReadyTrigger: (editor) => { if (!activeBuffer.isTemporary && !isNavigatingFromSearchRef.current) { editor.focus() - setInFocus(true) } - editor.onDidFocusEditorWidget(() => setInFocus(true)) - editor.onDidBlurEditorWidget(() => setInFocus(false)) if (activeBuffer.editorViewState) { editor.restoreViewState(activeBuffer.editorViewState) } diff --git a/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 17040b2cf..ec3ecf258 100644 --- a/src/providers/LocalStorageProvider/index.tsx +++ b/src/providers/LocalStorageProvider/index.tsx @@ -27,12 +27,17 @@ import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" import { parseInteger } from "./utils" import { + AiAssistantSettings, LocalConfig, SettingsType, LeftPanelState, LeftPanelType, } from "./types" +export const DEFAULT_AI_ASSISTANT_SETTINGS: AiAssistantSettings = { + providers: {}, +} + const defaultConfig: LocalConfig = { editorCol: 10, editorLine: 10, @@ -40,10 +45,12 @@ const defaultConfig: LocalConfig = { resultsSplitterBasis: 350, exampleQueriesVisited: false, autoRefreshTables: true, + aiAssistantSettings: DEFAULT_AI_ASSISTANT_SETTINGS, leftPanelState: { type: LeftPanelType.DATASOURCES, width: 350, }, + aiChatPanelWidth: 500, } type ContextProps = { @@ -56,6 +63,9 @@ type ContextProps = { autoRefreshTables: boolean leftPanelState: LeftPanelState updateLeftPanelState: (state: LeftPanelState) => void + aiAssistantSettings: AiAssistantSettings + aiChatPanelWidth: number + updateAiChatPanelWidth: (width: number) => void } const defaultValues: ContextProps = { @@ -68,6 +78,9 @@ const defaultValues: ContextProps = { autoRefreshTables: true, leftPanelState: defaultConfig.leftPanelState, updateLeftPanelState: (_state: LeftPanelState) => undefined, + aiAssistantSettings: defaultConfig.aiAssistantSettings, + aiChatPanelWidth: defaultConfig.aiChatPanelWidth, + updateAiChatPanelWidth: (_width: number) => undefined, } export const LocalStorageContext = createContext(defaultValues) @@ -121,8 +134,44 @@ export const LocalStorageProvider = ({ const [leftPanelState, setLeftPanelState] = useState(getLeftPanelState()) + const getAiAssistantSettings = (): AiAssistantSettings => { + const stored = getValue(StoreKey.AI_ASSISTANT_SETTINGS) + if (stored) { + try { + const parsed = JSON.parse(stored) as AiAssistantSettings + return { + selectedModel: parsed.selectedModel, + providers: parsed.providers || {}, + } + } catch (e) { + return defaultConfig.aiAssistantSettings + } + } + return defaultConfig.aiAssistantSettings + } + + const [aiAssistantSettings, setAiAssistantSettings] = + useState(getAiAssistantSettings()) + + const [aiChatPanelWidth, setAiChatPanelWidth] = useState( + parseInteger( + getValue(StoreKey.AI_CHAT_PANEL_WIDTH), + defaultConfig.aiChatPanelWidth, + ), + ) + + const updateAiChatPanelWidth = useCallback((width: number) => { + setValue(StoreKey.AI_CHAT_PANEL_WIDTH, width.toString()) + setAiChatPanelWidth(width) + }, []) + const updateSettings = (key: StoreKey, value: SettingsType) => { - setValue(key, value.toString()) + if (key === StoreKey.AI_ASSISTANT_SETTINGS) { + setValue(key, JSON.stringify(value)) + } else { + const typedValue = value as string | boolean | number + setValue(key, typedValue as string) + } refreshSettings(key) } @@ -156,6 +205,9 @@ export const LocalStorageProvider = ({ case StoreKey.AUTO_REFRESH_TABLES: setAutoRefreshTables(value === "true") break + case StoreKey.AI_ASSISTANT_SETTINGS: + setAiAssistantSettings(getAiAssistantSettings()) + break } } @@ -171,6 +223,9 @@ export const LocalStorageProvider = ({ autoRefreshTables, leftPanelState, updateLeftPanelState, + aiAssistantSettings, + aiChatPanelWidth, + updateAiChatPanelWidth, }} > {children} diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index dd1d4521b..7a7335d09 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -1,28 +1,18 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2022 QuestDB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - ******************************************************************************/ +export type ProviderSettings = { + apiKey: string + enabledModels: string[] + grantSchemaAccess: boolean +} + +export type AiAssistantSettings = { + selectedModel?: string + providers: { + anthropic?: ProviderSettings + openai?: ProviderSettings + } +} -export type SettingsType = string | boolean | number +export type SettingsType = string | boolean | number | AiAssistantSettings export enum LeftPanelType { DATASOURCES = "datasources", @@ -42,4 +32,6 @@ export type LocalConfig = { exampleQueriesVisited: boolean autoRefreshTables: boolean leftPanelState: LeftPanelState + aiAssistantSettings: AiAssistantSettings + aiChatPanelWidth: number } diff --git a/src/providers/SettingsProvider/index.tsx b/src/providers/SettingsProvider/index.tsx index dfa953493..d3d25dd23 100644 --- a/src/providers/SettingsProvider/index.tsx +++ b/src/providers/SettingsProvider/index.tsx @@ -6,6 +6,7 @@ import React, { useReducer, useState, } from "react" +import isEqual from "lodash.isequal" import styled from "styled-components" import { ConsoleConfig, Settings, Warning } from "./types" import { CenteredLayout, Box, Text, Button } from "../../components" @@ -277,8 +278,12 @@ export const SettingsProvider = ({ version: result["preferences.version"], ...result?.preferences, } - setSettings(newSettings) - setPreferences(newPreferences) + if (!isEqual(newSettings, settings)) { + setSettings(newSettings) + } + if (!isEqual(newPreferences, preferences)) { + setPreferences(newPreferences) + } return { settings: newSettings, preferences: newPreferences, diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 66413cd83..05fb9d0e4 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -28,3 +28,4 @@ export * from "./SettingsProvider" export * from "./PosthogProviderWrapper" export * from "./SearchProvider" export * from "./LocalStorageProvider" +export * from "./AIConversationProvider" diff --git a/src/scenes/Console/index.tsx b/src/scenes/Console/index.tsx index 4d412cab9..3b53c986b 100644 --- a/src/scenes/Console/index.tsx +++ b/src/scenes/Console/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react" +import React, { lazy, Suspense, useEffect, useState } from "react" import { useDispatch } from "react-redux" import styled from "styled-components" import { PopperHover } from "../../components" @@ -25,14 +25,37 @@ import { Import as ImportIcon } from "../../components/icons/import" import { useSettings, useSearch } from "../../providers" import { SearchPanel } from "../Search" import { LeftPanelType } from "../../providers/LocalStorageProvider/types" +import { color } from "../../utils/styled" +import { AIStatusIndicator } from "../../components/AIStatusIndicator" +import { CircleNotchSpinner } from "../../scenes/Editor/Monaco/icons" + +const AIChatWindow = lazy(() => import("../Editor/AIChatWindow")) +import { AIChatErrorBoundary } from "../Editor/AIChatWindow/AIChatErrorBoundary" + +const LoaderContainer = styled.div` + display: flex; + align-items: center; + background: ${color("chatBackground")}; + justify-content: center; + height: 100%; + width: 100%; +` const Root = styled.div` display: flex; - flex-direction: column; + flex-direction: row; flex: 1; max-height: 100%; ` +const MainContent = styled.div` + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-width: 0; +` + const Top = styled.div` display: flex; height: 100%; @@ -55,6 +78,16 @@ const Tab = styled.div` overflow: auto; ` +const Drawer = styled.div<{ $aiChat: boolean }>` + background: ${color("chatBackground")}; + height: 100%; + ${({ $aiChat }) => + $aiChat && + ` + display: none; + `} +` + const viewModes: { icon: React.ReactNode mode: ResultViewMode @@ -80,8 +113,11 @@ const Console = () => { updateSettings, leftPanelState, updateLeftPanelState, + aiChatPanelWidth, + updateAiChatPanelWidth, } = useLocalStorage() const result = useSelector(selectors.query.getResult) + const activeSidebar = useSelector(selectors.console.getActiveSidebar) const activeBottomPanel = useSelector(selectors.console.getActiveBottomPanel) const { consoleConfig } = useSettings() const { isSearchPanelOpen, setSearchPanelOpen, searchPanelRef } = useSearch() @@ -123,154 +159,203 @@ const Console = () => { return ( { - updateSettings(StoreKey.RESULTS_SPLITTER_BASIS, sizes[0]) + // sizes[1] is the AI chat panel width when it's open + if (activeSidebar !== undefined && sizes[1] !== undefined) { + updateAiChatPanelWidth(sizes[1]) + } }} > - - - - {!sm && ( - { - if (isDataSourcesPanelOpen) { - updateLeftPanelState({ - type: null, - width: leftPanelState.width, - }) - } else { - updateLeftPanelState({ - type: LeftPanelType.DATASOURCES, - width: leftPanelState.width, - }) - } - }} - selected={isDataSourcesPanelOpen} - > - - - } - > - - {isDataSourcesPanelOpen ? "Hide" : "Show"} data sources - - - )} - setSearchPanelOpen(!isSearchPanelOpen)} - selected={isSearchPanelOpen} - > - - - } - > - - {isSearchPanelOpen ? "Hide search in tabs" : "Search in tabs"} - - - + + { - if (sizes[0] !== 0) { - updateLeftPanelState({ - type: leftPanelState.type, - width: sizes[0], - }) - } + updateSettings(StoreKey.RESULTS_SPLITTER_BASIS, sizes[0]) }} - snap > - - + + + {!sm && ( + { + if (isDataSourcesPanelOpen) { + updateLeftPanelState({ + type: null, + width: leftPanelState.width, + }) + } else { + updateLeftPanelState({ + type: LeftPanelType.DATASOURCES, + width: leftPanelState.width, + }) + } + }} + selected={isDataSourcesPanelOpen} + > + + + } + > + + {isDataSourcesPanelOpen ? "Hide" : "Show"} data + sources + + + )} + setSearchPanelOpen(!isSearchPanelOpen)} + selected={isSearchPanelOpen} + > + + + } + > + + {isSearchPanelOpen + ? "Hide search in tabs" + : "Search in tabs"} + + + + { + if (sizes[0] !== 0) { + updateLeftPanelState({ + type: leftPanelState.type, + width: sizes[0], + }) + } + }} + snap + > + + + + + + + + + - - + + + + + {result && + viewModes.map(({ icon, mode, tooltipText }) => ( + { + dispatch( + actions.console.setActiveBottomPanel( + "result", + ), + ) + setResultViewMode(mode) + }} + selected={ + activeBottomPanel === "result" && + resultViewMode === mode + } + > + {icon} + + } + > + {tooltipText} + + ))} + { + dispatch( + actions.console.setActiveBottomPanel("import"), + ) + }, + })} + selected={activeBottomPanel === "import"} + data-hook="import-panel-button" + > + + + } + > + + {consoleConfig.readOnly + ? "To use this feature, turn off read-only mode in the configuration file" + : "Import files from CSV"} + + + + + {result && } + + + + + + + + - + + - - - - - {result && - viewModes.map(({ icon, mode, tooltipText }) => ( - { - dispatch( - actions.console.setActiveBottomPanel("result"), - ) - setResultViewMode(mode) - }} - selected={ - activeBottomPanel === "result" && - resultViewMode === mode - } - > - {icon} - - } - > - {tooltipText} - - ))} - { - dispatch(actions.console.setActiveBottomPanel("import")) - }, - })} - selected={activeBottomPanel === "import"} - data-hook="import-panel-button" - > - - + + + {activeSidebar === "aiChat" && ( + + + + } > - - {consoleConfig.readOnly - ? "To use this feature, turn off read-only mode in the configuration file" - : "Import files from CSV"} - - - - - {result && } - - - - - - - - + + + + )} diff --git a/src/scenes/Editor/AIChatWindow/AIChatErrorBoundary.tsx b/src/scenes/Editor/AIChatWindow/AIChatErrorBoundary.tsx new file mode 100644 index 000000000..5e94cfa9e --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/AIChatErrorBoundary.tsx @@ -0,0 +1,86 @@ +import React, { Component, type ReactNode } from "react" +import styled from "styled-components" +import { WarningCircleIcon, ArrowClockwiseIcon } from "@phosphor-icons/react" +import { Text, Button } from "../../../components" +import { color } from "../../../utils" + +type Props = Readonly<{ + children: ReactNode + onReset?: () => void +}> + +type State = { + hasError: boolean + error: Error | null +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + width: 100%; + height: 100%; + padding: 2rem; + background: ${color("chatBackground")}; + border-left: 0.2rem ${color("backgroundDarker")} solid; +` + +const IconWrapper = styled.div` + color: ${color("red")}; + margin-bottom: 1rem; +` + +const ErrorTextLine = styled(Text)` + margin-top: 0.5rem; +` + +const RetryButton = styled(Button)` + margin-top: 1.5rem; + gap: 0.5rem; +` + +export class AIChatErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("AIChatWindow error:", error, errorInfo) + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }) + this.props.onReset?.() + } + + render() { + if (this.state.hasError) { + return ( + + + + + Something went wrong + {this.state.error && ( + + {this.state.error.message} + + )} + + + Try again + + + ) + } + + return this.props.children + } +} diff --git a/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx new file mode 100644 index 000000000..40afd5f05 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx @@ -0,0 +1,249 @@ +import React, { useState, useRef, useEffect } from "react" +import styled from "styled-components" +import { + ChatTextIcon, + PencilSimpleLineIcon, + TrashSimpleIcon, +} from "@phosphor-icons/react" +import { color } from "../../../utils" +import type { ConversationMeta } from "../../../store/db" + +const Container = styled.div<{ $disabled?: boolean }>` + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.8rem; + border-radius: 4px; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + background: ${color("transparent")}; + opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)}; + + &:hover { + background: ${({ $disabled }) => + $disabled ? "transparent" : color("selection")}; + + .chat-title { + color: ${({ $disabled }) => + $disabled ? color("offWhite") : color("foreground")}; + } + } +` + +const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${color("gray2")}; +` + +const Content = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + padding-left: 0.4rem; +` + +const Title = styled.div.attrs({ className: "chat-title" })` + padding: 0.2rem 0.4rem; + border: 1px solid transparent; + color: ${color("offWhite")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transform: translateX(-0.4rem); +` + +const TitleInput = styled.input` + color: ${color("foreground")}; + background: transparent; + border: 1px solid ${color("pinkDarker")}; + border-radius: 6px; + outline: none; + padding: 0.2rem 0.4rem; + font-family: inherit; + transform: translateX(-0.4rem); + + &:focus { + outline: none; + } + + &::selection { + background: ${color("pinkPrimary")}; + } + &::-moz-selection { + background: ${color("pinkPrimary")}; + } + &::-webkit-selection { + background: ${color("pinkPrimary")}; + } +` + +const Subtitle = styled.div` + font-size: 1.2rem; + line-height: 1.5; + color: ${color("gray2")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const ActionsContainer = styled.div` + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +` + +const ActionButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0.4rem; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: ${color("foreground")}; + opacity: 0; + + ${Container}:hover & { + opacity: 1; + } + + &:hover { + background: ${color("backgroundDarker")}; + } +` + +const CurrentIndicator = styled.span` + font-size: 1.2rem; + line-height: 1.5rem; + color: #9ca3af; + white-space: nowrap; +` + +type ChatHistoryItemProps = { + conversation: ConversationMeta + subtitle?: string + isCurrent: boolean + hasOngoingProcess?: boolean + disabled?: boolean + onSelect: (id: string) => void + onRename: (id: string, newName: string) => void + onDelete: (id: string) => void +} + +export const ChatHistoryItem: React.FC = ({ + conversation, + subtitle, + isCurrent, + hasOngoingProcess, + disabled, + onSelect, + onRename, + onDelete, +}) => { + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(conversation.conversationName) + const inputRef = useRef(null) + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation() + setEditValue(conversation.conversationName) + setIsEditing(true) + } + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation() + onDelete(conversation.id) + } + + const handleSave = () => { + const trimmedValue = editValue.trim() + if (trimmedValue && trimmedValue !== conversation.conversationName) { + onRename(conversation.id, trimmedValue) + } + setIsEditing(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave() + } else if (e.key === "Escape") { + setEditValue(conversation.conversationName) + setIsEditing(false) + } + } + + const handleBlur = () => { + handleSave() + } + + const handleContainerClick = () => { + if (!isEditing && !disabled) { + onSelect(conversation.id) + } + } + + return ( + + + + + + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + onClick={(e) => e.stopPropagation()} + data-hook="chat-history-rename" + /> + ) : ( + + {conversation.conversationName} + + )} + {subtitle && {subtitle}} + + + {!isEditing && ( + <> + + + + {!hasOngoingProcess && ( + + + + )} + + )} + {isCurrent && Current} + + + ) +} diff --git a/src/scenes/Editor/AIChatWindow/ChatHistoryView.tsx b/src/scenes/Editor/AIChatWindow/ChatHistoryView.tsx new file mode 100644 index 000000000..4e042620b --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatHistoryView.tsx @@ -0,0 +1,346 @@ +import React, { useState, useMemo, useRef, useEffect } from "react" +import styled from "styled-components" +import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react" +import { useSelector } from "react-redux" +import { color } from "../../../utils" +import { useAIConversation } from "../../../providers/AIConversationProvider" +import { + useAIStatus, + isBlockingAIStatus, +} from "../../../providers/AIStatusProvider" +import { useEditor } from "../../../providers" +import { selectors } from "../../../store" +import { + Button, + AlertDialog, + Overlay, + ForwardRef, + Input, +} from "../../../components" +import { ChatHistoryItem } from "./ChatHistoryItem" +import { DateSeparator } from "./DateSeparator" +import { useGroupedConversations, filterConversations } from "./historyUtils" +import type { ConversationId } from "../../../providers/AIConversationProvider/types" + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: 2rem 1rem 4rem 1rem; + background: ${color("chatBackground")}; + overflow: hidden; +` + +const SearchContainer = styled.div` + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; +` + +const SearchIcon = styled.div` + position: absolute; + left: 1.2rem; + display: flex; + align-items: center; + color: ${color("gray2")}; + pointer-events: none; + z-index: 1; +` + +const ClearButton = styled.button` + position: absolute; + right: 0.8rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: ${color("gray2")}; + + &:hover { + color: ${color("foreground")}; + } +` + +const SearchInput = styled(Input)` + width: 100%; + background: transparent; + color: ${color("foreground")}; + padding: 0.8rem 3.6rem 0.8rem 3.6rem; + border: 1px solid ${color("gray2")}4d; + height: 3rem; + border-radius: 0.6rem; + + &:focus { + background: transparent; + border-color: ${color("pinkDarker")}; + } +` + +const ListContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 2rem 0.4rem; + overflow-y: auto; + flex: 1; + min-height: 0; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + color: ${color("gray2")}; + font-size: 1.3rem; + text-align: center; + padding: 2rem; +` + +const AlertDialogContent = styled(AlertDialog.Content)` + background: ${color("chatBackground")}; +` + +const DialogHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 2rem; + border-bottom: 1px solid ${color("selection")}; +` + +const DialogTitle = styled.h3` + margin: 0; + font-weight: 500; + color: ${color("foreground")}; +` + +const DialogDescription = styled.p` + margin: 2rem; + font-size: 1.4rem; + line-height: 1.5; +` + +const DialogButtons = styled.div` + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 0 2rem 0 2rem; +` + +const CancelButton = styled(Button).attrs({ skin: "secondary" })`` + +const DeleteButton = styled(Button)` + background: ${color("red")}; + border-color: ${color("red")}; + + &:hover:not(:disabled) { + background: ${color("red")}; + filter: brightness(1.1); + } +` + +type ChatHistoryViewProps = { + currentConversationId: ConversationId | null +} + +export const ChatHistoryView: React.FC = ({ + currentConversationId, +}) => { + const [searchQuery, setSearchQuery] = useState("") + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [conversationToDelete, setConversationToDelete] = + useState(null) + const currentItemRef = useRef(null) + + const { + conversationMetas, + chatWindowState, + openChatWindow, + updateConversationName, + deleteConversation, + } = useAIConversation() + + const { status } = useAIStatus() + const { buffers } = useEditor() + const tables = useSelector(selectors.query.getTables) + + const conversationList = useMemo( + () => Array.from(conversationMetas.values()), + [conversationMetas], + ) + + const filteredConversations = useMemo( + () => filterConversations(conversationList, searchQuery), + [conversationList, searchQuery], + ) + + const groupedConversations = useGroupedConversations(filteredConversations) + + const getSubtitle = (id: number, type: "buffer" | "table") => { + if (!id) { + return undefined + } + if (type === "buffer") { + const buffer = buffers.find((b) => b.id === id) + if (buffer) { + return buffer.label + } + } else if (type === "table") { + const table = tables.find((t) => t.id === id) + if (table) { + return table.table_name + } + } + return undefined + } + + const handleSelect = (id: ConversationId) => { + void openChatWindow(id) + } + + const handleRename = (id: ConversationId, newName: string) => { + void updateConversationName(id, newName) + } + + const handleDeleteClick = (id: ConversationId) => { + setConversationToDelete(id) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (conversationToDelete) { + void deleteConversation(conversationToDelete) + } + setDeleteDialogOpen(false) + setConversationToDelete(null) + } + + const handleCancelDelete = () => { + setDeleteDialogOpen(false) + setConversationToDelete(null) + } + + useEffect(() => { + if (currentItemRef.current) { + currentItemRef.current.scrollIntoView({ + block: "center", + }) + } + }, []) + + if (conversationList.length === 0) { + return ( + + + No conversations yet + + + ) + } + + return ( + + + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape" && searchQuery) { + setSearchQuery("") + } + }} + data-hook="chat-history-search" + /> + {searchQuery && ( + setSearchQuery("")} title="Clear search"> + + + )} + + + + {groupedConversations.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.conversations.map((conv) => { + const isCurrent = conv.id === currentConversationId + return ( +
+ +
+ ) + })} +
+ ))} + {filteredConversations.length === 0 && searchQuery && ( + No chats match your search + )} +
+ + + + + + + + + Delete conversation + + + Are you sure you want to delete this conversation? This action + cannot be undone. + + + + Cancel + + + + Delete + + + + + + +
+ ) +} diff --git a/src/scenes/Editor/AIChatWindow/ChatInput.tsx b/src/scenes/Editor/AIChatWindow/ChatInput.tsx new file mode 100644 index 000000000..eaa072559 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatInput.tsx @@ -0,0 +1,395 @@ +import React, { + useState, + useRef, + useEffect, + forwardRef, + useImperativeHandle, + useMemo, +} from "react" +import { useSelector } from "react-redux" +import styled, { css } from "styled-components" +import { Box } from "../../../components" +import { Text } from "../../../components/Text" +import { color } from "../../../utils" +import { ArrowUpIcon, CodeBlockIcon } from "@phosphor-icons/react" +import { Stop as StopFill, CloseCircle } from "@styled-icons/remix-fill" +import { + useAIStatus, + isBlockingAIStatus, + AIOperationStatus, +} from "../../../providers/AIStatusProvider" +import { slideAnimation } from "../../../components/Animation" +import { pinkLinearGradientHorizontal } from "../../../theme" +import { TableIcon } from "../../Schema/table-icon" +import { selectors } from "../../../store" +import { CircleNotchSpinner } from "../../Editor/Monaco/icons" + +const InputContainer = styled(Box)` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + padding: 1rem 1.2rem; + flex-shrink: 0; + width: 100%; + margin-top: auto; + border-top: 1px solid ${color("selection")}; +` + +const InputWrapper = styled(Box)` + display: flex; + position: relative; + width: 100%; + overflow: hidden; +` + +const StyledTextArea = styled.textarea<{ $hasContext: boolean }>` + flex: 1; + min-height: 8rem; + max-height: 30rem; + line-height: 1.3; + padding: ${({ $hasContext }) => + $hasContext + ? "4.4rem 4.5rem 1.2rem 1.2rem" + : "1.2rem 4.5rem 1.2rem 1.2rem"}; + background: ${color("backgroundDarker")}; + border: 1px solid ${color("selection")}; + border-radius: 0.6rem; + color: ${color("foreground")}; + font-size: 1.4rem; + font-family: ${({ theme }) => theme.font}; + resize: none; + outline: none; + + &:focus { + border-color: ${color("pinkDarker")}; + } + + &::placeholder { + color: ${color("gray2")}; + } + + &:disabled { + opacity: 0.5; + } +` + +const ActionButton = styled.button` + position: absolute; + right: 0.8rem; + bottom: 0.8rem; + padding: 0.6rem; + border: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.4rem; + cursor: pointer; +` + +const ContextBadgeContainer = styled.div` + position: absolute; + padding: 0.8rem; + top: 2px; + border-radius: 0.6rem; + left: 1px; + width: calc(100% - 0.2rem); + display: inline-flex; + background: ${color("backgroundDarker")}; +` + +const ContextBadge = styled.div<{ $type: "sql" | "table" }>` + 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")}; + background: ${color("chatBackground")}; + color: ${color("gray2")}; + font-size: 1.3rem; + user-select: none; + + ${({ $type }) => + $type === "sql" && + css` + cursor: pointer; + + &:hover { + border: 1px solid ${color("offWhite")}; + color: ${color("offWhite")}; + } + `} +` + +const ContextBadgeIcon = styled.div` + display: flex; + align-items: center; + color: ${color("gray2")}; + flex-shrink: 0; + + svg { + color: ${color("gray2")}; + } +` + +const SendButton = styled(ActionButton)` + background: ${color("pinkDarker")}; + color: ${color("foreground")}; + + &:hover:not(:disabled) { + background: ${color("pink")}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +` + +const ThoughtStream = styled.div<{ $aborted?: boolean }>` + display: flex; + position: relative; + width: 100%; + background: ${color("backgroundDarker")}; + border: 1px solid transparent; + background: + linear-gradient(${color("backgroundDarker")}, ${color("backgroundDarker")}) + padding-box, + ${pinkLinearGradientHorizontal} border-box; + border-radius: 0.6rem; + height: 4rem; + + ${({ $aborted }) => + $aborted && + css` + background: ${color("backgroundDarker")}; + border: 1px solid ${color("red")}; + `} +` + +const ThoughtStreamContent = styled.div<{ $aborted?: boolean }>` + display: flex; + align-items: center; + background: ${color("backgroundDarker")}; + gap: 0.8rem; + width: 100%; + height: 100%; + border-radius: 0.6rem; + padding: 0 1.2rem; + padding-right: ${({ $aborted }) => ($aborted ? "1.2rem" : "4.5rem")}; +` + +const CloseCircleIcon = styled(CloseCircle)` + width: 2rem; + height: 2rem; + color: ${color("red")}; + flex-shrink: 0; +` + +const ThoughtText = styled.div<{ $aborted?: boolean }>` + font-weight: 500; + font-size: 1.4rem; + color: ${color("gray2")}; + ${({ $aborted }) => !$aborted && slideAnimation} +` + +const StopButton = styled.button` + position: absolute; + right: 0.8rem; + top: 50%; + transform: translateY(-50%); + width: 2.6rem; + height: 2.6rem; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + background: #da152832; + color: #da1e28; + + &:hover { + background: ${color("red")}; + color: ${color("foreground")}; + } +` + +type ChatInputProps = { + onSend: (message: string) => void + disabled?: boolean + placeholder?: string + contextSQL?: string + contextTableId?: number + onContextClick: () => void +} + +const truncateText = (text: string, maxLength: number = 30): string => { + if (!text) return "" + const trimmed = text.trim().replace(/\s+/g, " ") + if (trimmed.length <= maxLength) return trimmed + return trimmed.slice(0, maxLength) + "..." +} + +export type ChatInputHandle = { + focus: () => void +} + +export const ChatInput = forwardRef( + ( + { + onSend, + disabled = false, + placeholder = "Ask a question or request a refinement...", + contextSQL, + contextTableId, + onContextClick, + }, + ref, + ) => { + const [input, setInput] = useState("") + const textareaRef = useRef(null) + const tables = useSelector(selectors.query.getTables) + + const tableData = useMemo(() => { + if (contextTableId == null) return null + 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 + const hasContext = Boolean(contextText) + + useImperativeHandle(ref, () => ({ + focus: () => { + textareaRef.current?.focus() + }, + })) + const { status: aiStatus, abortOperation } = useAIStatus() + + const isAIInProgress = isBlockingAIStatus(aiStatus) + const isAborted = aiStatus === AIOperationStatus.Aborted + + useEffect(() => { + // Auto-resize textarea + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px` + } + }, [input]) + + const handleSend = () => { + const trimmed = input.trim() + if (trimmed && !disabled && !isAIInProgress) { + onSend(trimmed) + setInput("") + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + } + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + // Enter without Shift -> send message + e.preventDefault() + handleSend() + } + // Shift+Enter -> allow default behavior (new line) + } + + const handleContextClickInternal = () => { + if (!tableData) { + onContextClick() + } + } + + const handleStop = () => { + abortOperation() + } + + const showThoughtStream = isAIInProgress || isAborted + + return ( + + {showThoughtStream ? ( + + + {isAborted ? ( + + ) : ( + + )} + {aiStatus} + + {!isAborted && ( + + + + )} + + ) : ( + + {hasContext && ( + + + + {tableData ? ( + + ) : ( + + )} + + {contextText} + + + )} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + rows={1} + $hasContext={hasContext} + data-hook="chat-input-textarea" + /> + + + + + )} + + Chats are connected to a single query to improve responses. + + + ) + }, +) diff --git a/src/scenes/Editor/AIChatWindow/ChatMessages.tsx b/src/scenes/Editor/AIChatWindow/ChatMessages.tsx new file mode 100644 index 000000000..c4963298c --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatMessages.tsx @@ -0,0 +1,1247 @@ +import React, { useEffect, useRef, useMemo, useState, useCallback } from "react" +import styled, { css, keyframes, useTheme } from "styled-components" +import { LiteEditor } from "../../../components/LiteEditor" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { Box, Text, Button } from "../../../components" +import { AISparkle } from "../../../components/AISparkle" +import { AssistantModes } from "../../../components/AIStatusIndicator/AssistantModes" +import { color } from "../../../utils" +import type { + ConversationMessage, + UserMessageDisplayType, +} from "../../../providers/AIConversationProvider/types" +import { trimSemicolonForDisplay } from "../../../providers/AIConversationProvider/utils" +import { normalizeQueryText, createQueryKey } from "../Monaco/utils" +import { + PlayIcon, + ErrorIcon, + SuccessIcon, + LoadingIconSvg, + ExpandUpDownIcon, +} from "../Monaco/icons" +import { + GaugeIcon, + CodeIcon, + KeyReturnIcon, + ChatDotsIcon, +} from "@phosphor-icons/react" +import { CloseCircle } from "@styled-icons/remix-fill" +import { CheckmarkOutline, CloseOutline } from "@styled-icons/evaicons-outline" +import { TableIcon } from "../../Schema/table-icon" +import type { QueryNotifications } from "../../../store/Query/types" +import { NotificationType, RunningType } from "../../../store/Query/types" +import type { QueryKey } from "../Monaco/utils" +import { useAIStatus } from "../../../providers/AIStatusProvider" + +type QueryRunStatus = "neutral" | "loading" | "success" | "error" + +const spinAnimation = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +` + +const LoadingIconWrapper = styled.span` + display: flex; + align-items: center; + justify-content: center; + animation: ${spinAnimation} 3s linear infinite; +` + +const LoadingIcon = () => ( + + + +) + +const MessagesContainer = styled(Box)<{ $scrolled: boolean }>` + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem 1rem; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; + width: 100%; + ${({ $scrolled }) => + !$scrolled && + css` + opacity: 0; + `} +` + +const MessageBubble = styled(Box).attrs({ align: "flex-start" })` + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.8rem; + border-radius: 0.8rem; + width: 100%; + align-self: flex-end; + background: ${color("loginBackground")}; + border: 1px solid rgba(25, 26, 33, 0.32); + flex-shrink: 0; + overflow: visible; +` + +const UserRequestBox = styled(Box)` + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.8rem; + width: 100%; + align-self: flex-end; + background: ${color("loginBackground")}; + border: 1px solid rgba(25, 26, 33, 0.32); + border-radius: 0.6rem; + flex-shrink: 0; + overflow: visible; +` + +const UserRequestHeader = styled(Box).attrs({ + alignItems: "center", + gap: "0.8rem", +})` + width: 100%; + padding: 0.4rem; + flex: 1 0 auto; +` + +const UserRequestContent = styled(Box)` + display: flex; + flex-direction: column; + border-radius: 0.6rem; + overflow: hidden; + flex-shrink: 0; + width: 100%; + align-items: flex-start; +` + +const InlineSQLEditor = styled.div` + width: 100%; +` + +// Operation Badge components for fix/explain/generate/schema requests +const OperationBadge = styled(Box).attrs({ + gap: "1rem", + alignItems: "center", +})` + width: 100%; + padding: 0 0.4rem; +` + +const BadgeIconContainer = styled(Box).attrs({ + align: "center", + justifyContent: "center", +})` + background: #290a13; + border: 1px solid rgba(122, 31, 58, 0.64); + border-radius: 0.4rem; + padding: 0.8rem; + width: 4.8rem; + height: 4rem; + flex-shrink: 0; +` + +const BadgeIcon = styled.img` + width: 1.8rem; + height: 1.8rem; +` + +const BadgeTitle = styled(Text)` + font-weight: 500; + font-size: 1.6rem; + line-height: 1.6rem; + color: ${color("foreground")}; +` + +const BadgeDescriptionContainer = styled(Box)` + padding: 0.8rem; + width: 100%; +` + +const BadgeDescriptionText = styled(Text)` + font-size: 1.4rem; + line-height: 2.1rem; + color: ${color("foreground")}; +` + +const SchemaNameDisplay = styled(Box)` + margin-left: 0.4rem; + padding: 0.8rem 1.2rem; + align-items: center; + gap: 1rem; + border-radius: 8px; + border: 1px solid ${color("selection")}; + background: ${color("backgroundDarker")}; +` + +const SchemaName = styled(Text)` + font-size: 1.4rem; + color: ${color("foreground")}; +` + +const MessageContent = styled(Text)` + font-size: 1.4rem; + line-height: 1.8rem; + color: ${color("foreground")}; + white-space: pre-wrap; + word-wrap: break-word; + overflow: visible; +` + +const ExplanationBox = styled(Box)<{ $hasOperationHistory?: boolean }>` + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + align-self: flex-start; + text-align: left; + background: transparent; + padding: 0.4rem; + border-radius: 0.6rem; + flex-shrink: 0; + overflow: visible; + + ${({ $hasOperationHistory }) => + $hasOperationHistory && + css` + padding-top: 0; + `} + + .assistant-label, + .token-display { + transition: opacity 0.2s; + opacity: 0; + } + + &:hover { + .assistant-label, + .token-display { + opacity: 1; + } + } +` + +const AssistantHeader = styled(Box).attrs({ + alignItems: "center", + gap: "1rem", +})` + width: 100%; + padding: 0.4rem; + flex: 1 0 auto; +` + +const AssistantLabel = styled(Text).attrs({ className: "assistant-label" })` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.4rem; + text-transform: uppercase; + color: ${color("foreground")}; + line-height: 1; +` + +const TokenDisplay = styled(Box).attrs({ className: "token-display" })` + align-items: center; + gap: 0.9rem; + margin: 0 0 0 auto; +` + +const ExplanationContent = styled(Box)` + display: flex; + flex-direction: column; + border-radius: 0.6rem; + padding: 0.8rem; + overflow: visible; + flex-shrink: 0; + width: 100%; +` + +const Divider = styled.div` + width: 100%; + height: 1px; + background: linear-gradient(90deg, #9c274b 0%, rgba(54, 14, 26, 0) 100%); + margin-bottom: 1.5rem; +` + +const OperationHistoryContainer = styled.div<{ $trimBottom: boolean }>` + margin-bottom: 1rem; + padding-bottom: 1rem; + width: 100%; + ${({ $trimBottom }) => + $trimBottom && + css` + margin-bottom: 0; + padding-bottom: 0.3rem; + `} +` + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + flex-shrink: 0; + gap: 1rem; + padding: 1rem 1.2rem; + border-radius: 0.6rem; + border: 1px solid ${color("red")}; + color: ${color("foreground")}; + font-size: 1.4rem; + line-height: 2rem; + width: 100%; +` + +const MarkdownContent = styled.div` + margin: 0; + width: 100%; + font-family: ${({ theme }) => theme.font}; + font-size: 1.4rem; + line-height: 2.1rem; + color: ${color("foreground")}; + overflow: visible; + word-break: break-word; + + p { + margin: 0 0 1rem 0; + &:last-child { + margin-bottom: 0; + } + } + + code { + background: ${color("background")}; + border: 1px solid ${color("selection")}; + border-radius: 0.4rem; + padding: 0.1rem 0.4rem; + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: ${color("purple")}; + white-space: pre-wrap; + } + + strong { + font-weight: 600; + color: ${color("foreground")}; + } + + em { + font-style: italic; + } + + ul, + ol { + margin: 0.5rem 0; + padding-left: 2rem; + } + + li { + margin-bottom: 0.3rem; + } + + a { + color: ${({ theme }) => theme.color.cyan}; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + h1, + h2, + h3, + h4 { + margin: 1rem 0 0.5rem 0; + font-weight: 600; + } + + h1 { + font-size: 1.8rem; + } + h2 { + font-size: 1.6rem; + } + h3 { + font-size: 1.5rem; + } + h4 { + font-size: 1.4rem; + } + + blockquote { + border-left: 3px solid ${color("selection")}; + margin: 1rem 0; + padding-left: 1rem; + color: ${color("gray2")}; + } + + .table-wrapper { + overflow-x: auto; + margin: 1rem 0; + } + + table { + border-collapse: collapse; + min-width: max-content; + border-radius: 0.8rem; + } + + th, + td { + padding: 0.6rem 0.8rem; + border: 1px solid ${color("selection")}; + text-align: left; + white-space: nowrap; + } + + th { + background: ${color("backgroundDarker")}; + font-weight: 600; + } + + td:last-child { + white-space: normal; + min-width: 200px; + } +` + +const DiffContainer = styled(Box)` + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 1rem; + padding: 8px 12px; + border: 1px solid ${color("selection")}; + border-radius: 8px; + background: ${color("backgroundDarker")}; + width: 100%; +` + +const DiffHeader = styled(Box)<{ $isExpanded?: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid ${color("selectionDarker")}; + width: 100%; + ${({ $isExpanded }) => + !$isExpanded && + css` + border-bottom: 0; + padding-bottom: 0; + `} +` + +const DiffHeaderLeft = styled(Box)` + display: flex; + align-items: center; + gap: 1rem; + padding: 4px 0; +` + +const DiffHeaderLabel = styled.span` + font-size: 1.4rem; + color: ${color("offWhite")}; +` + +const DiffHeaderRight = styled(Box)` + display: flex; + align-items: center; + gap: 1.8rem; + margin-left: auto; +` + +const DiffHeaderStatus = styled(Box)<{ + $isAccepted?: boolean + $isRejected?: boolean + $isRejectedWithFollowUp?: boolean +}>` + display: flex; + align-items: center; + gap: 0.8rem; + color: ${({ $isAccepted, $isRejected, $isRejectedWithFollowUp }) => { + if ($isRejected) return color("red") + if ($isRejectedWithFollowUp) return color("cyan") + if ($isAccepted) return color("greenDarker") + return color("gray2") + }}; + font-size: 1.3rem; +` + +const StatusIcon = styled.span<{ + $isAccepted?: boolean + $isRejected?: boolean + $isRejectedWithFollowUp?: boolean +}>` + display: flex; + align-items: center; + color: ${({ $isAccepted, $isRejected, $isRejectedWithFollowUp }) => { + if ($isRejected) return color("red") + if ($isRejectedWithFollowUp) return color("cyan") + if ($isAccepted) return color("greenDarker") + return color("gray2") + }}; +` + +const IconButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + height: 22px; + width: 22px; + color: ${color("gray2")}; + + &:hover { + svg { + filter: brightness(1.3); + } + } +` + +const ExpandButton = styled(IconButton)` + width: 16px; + height: 16px; +` + +const DiffEditorWrapper = styled.div` + position: relative; + height: 300px; + width: 100%; +` + +const ButtonBar = styled(Box)` + padding: 0.5rem; + gap: 1rem; + justify-content: center; + flex-shrink: 0; + width: fit-content; + margin: 0 auto; + background: ${color("backgroundDarker")}; + border: 1px solid ${color("selection")}; + border-radius: 0.4rem; +` + +const CodeBlockWrapper = styled.div` + margin: 1rem 0; + width: 100%; +` + +const AcceptButton = styled(Button)` + background: ${({ theme }) => theme.color.pinkDarker}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + width: 10rem; + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.color.pinkDarker}; + border-color: ${({ theme }) => theme.color.pinkDarker}; + filter: brightness(1.2); + } +` + +const RejectButton = styled(Button)` + background: ${color("background")}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + width: 10rem; + + &:hover:not(:disabled) { + background: ${color("selection")}; + border-color: ${({ theme }) => theme.color.pinkDarker}; + } +` + +type ChatMessagesProps = { + messages: ConversationMessage[] + onAcceptChange?: (messageId: string) => void + onRejectChange?: (messageId: string) => void + onRunQuery?: (sql: string) => void + onExpandDiff?: (original: string, modified: string) => void + // Apply SQL to editor and mark that specific message as accepted + onApplyToEditor?: (messageId: string, sql: string) => void + // Query execution status + running?: RunningType + aiSuggestionRequest?: { query: string; startOffset: number } | null + // Query notifications for this conversation's buffer - keyed by QueryKey + queryNotifications?: Record + // The start offset used when running queries from this conversation + queryStartOffset?: number + // Whether an AI operation is in progress + isOperationInProgress?: boolean + // Current SQL in editor (acceptedSQL) - used to hide Apply button when suggestion matches editor + editorSQL?: string +} + +const getOperationBadgeInfo = ( + displayType: UserMessageDisplayType, +): { icon: string; title: string; description?: string } | null => { + switch (displayType) { + case "fix_request": + return { + icon: "/assets/icon-fix-queries.svg", + title: "Fix Query", + description: + "Help me debug and fix the error with the attached SQL query", + } + case "explain_request": + return { + icon: "/assets/icon-explain-queries.svg", + title: "Explain Query", + description: "Explain this query in detail", + } + case "schema_explain_request": { + return { + icon: "/assets/icon-explain-schema.svg", + title: "Explain Schema", + description: + "Provide an overview, detailed column descriptions and storage details.", + } + } + default: + return null + } +} + +// Helper to get the appropriate icon based on query run status +const getQueryStatusIcon = (status: QueryRunStatus) => { + switch (status) { + case "loading": + return + case "success": + return + case "error": + return + default: + return + } +} + +export const ChatMessages: React.FC = ({ + messages, + onAcceptChange, + onRejectChange, + onRunQuery, + onExpandDiff, + onApplyToEditor, + running, + aiSuggestionRequest, + queryNotifications, + queryStartOffset = 0, + isOperationInProgress, + editorSQL, +}) => { + const theme = useTheme() + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + const { status } = useAIStatus() + const [scrolled, setScrolled] = useState(false) + const userScrolledRef = useRef(false) + + const handleScrollNeeded = useCallback(() => { + if (scrolled && userScrolledRef.current) return + const behavior = scrolled ? "smooth" : "instant" + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior }) + setScrolled(true) + }) + }, [scrolled]) + + useEffect(() => { + const container = messagesContainerRef.current + if (!container) return + + const handleWheel = () => { + userScrolledRef.current = true + } + + container.addEventListener("wheel", handleWheel) + return () => container.removeEventListener("wheel", handleWheel) + }, []) + + useEffect(() => { + userScrolledRef.current = false + }, [messages.length]) + + const latestDiffIndex = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role === "assistant" && msg.sql) { + return i + } + } + return -1 + }, [messages]) + + const prevLatestDiffIndexRef = useRef( + latestDiffIndex >= 0 ? latestDiffIndex : -1, + ) + + const [expandedDiffs, setExpandedDiffs] = useState>( + latestDiffIndex >= 0 ? new Set([latestDiffIndex]) : new Set(), + ) + + useEffect(() => { + // Only auto-expand if this is a genuinely new diff (index changed) + if ( + latestDiffIndex >= 0 && + latestDiffIndex !== prevLatestDiffIndexRef.current + ) { + setExpandedDiffs((prev) => new Set([...prev, latestDiffIndex])) + prevLatestDiffIndexRef.current = latestDiffIndex + } + }, [latestDiffIndex]) + + const formatTokenCount = (count: number): string => { + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K` + } + return count.toString() + } + + const visibleMessagesCount = useMemo( + () => + messages.reduce( + (acc, msg) => + acc + + (msg.hideFromUI + ? 0 + : (msg.operationHistory?.length ?? 0) + + (msg.error || msg.content ? 1 : 0)), + 0, + ), + [messages], + ) + + useEffect(() => { + handleScrollNeeded() + }, [visibleMessagesCount]) + + const visibleMessages: Array<{ + message: ConversationMessage + originalIndex: number + }> = [] + messages.forEach((msg, originalIdx) => { + if (!msg.hideFromUI) { + visibleMessages.push({ message: msg, originalIndex: originalIdx }) + } + }) + + const lastVisibleMessageIndex = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].originalIndex + : -1 + + const lastAssistantMessageIndex = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant" && !messages[i].hideFromUI) { + return i + } + } + return -1 + }, [messages]) + + const hasVisibleUserMessageAfter = (index: number): boolean => { + for (let i = index + 1; i < messages.length; i++) { + if (messages[i].role === "user" && !messages[i].hideFromUI) { + return true + } + } + return false + } + + return ( + + {visibleMessages.map(({ message, originalIndex }) => { + const key = `${message.id}` + if (message.role === "user") { + // Check if this is a special request type with inline SQL display + const displayType = message.displayType + const sql = message.sql + + // Render badge/title/description types + if ( + displayType && + (displayType === "fix_request" || + displayType === "explain_request" || + displayType === "schema_explain_request") + ) { + const badgeInfo = getOperationBadgeInfo(displayType) + + // Determine content to render below badge/description + let content: React.ReactNode = null + + if ( + displayType === "schema_explain_request" && + message.displaySchemaData + ) { + const schemaData = message.displaySchemaData + content = ( + + + + {schemaData.tableName} + + + ) + } else if (sql) { + // fix_request and explain_request show SQL editor + const lineCount = sql.split("\n").length + const editorHeight = Math.min(lineCount * 20 + 16, 200) + content = ( + + + + + + ) + } + + return ( + + + + + + {badgeInfo?.title} + + {badgeInfo?.description && ( + + + {badgeInfo.description} + + + )} + {content} + + ) + } + + // Special handling for ask_request: show user's question above SQL + if (displayType === "ask_request" && sql) { + const userQuestion = message.displayUserMessage || message.content + const lineCount = sql.split("\n").length + const editorHeight = Math.min(lineCount * 20 + 16, 200) + + return ( + + + {userQuestion} + + + + + + + + ) + } + + // Default: plain text message + return ( + + {message.content} + + ) + } else { + // Assistant message - show as ExplanationBox + const explanation = message.explanation || message.content + const tokenUsage = message.tokenUsage as + | { inputTokens: number; outputTokens: number } + | undefined + let tokenDisplay: React.ReactNode | null = null + if ( + tokenUsage && + typeof tokenUsage.inputTokens === "number" && + typeof tokenUsage.outputTokens === "number" + ) { + tokenDisplay = ( + <> + + {formatTokenCount(tokenUsage.inputTokens)} + {" "} + input /{" "} + + {formatTokenCount(tokenUsage.outputTokens)} + {" "} + output tokens + + ) + } + + const hasSQLChange = !!message.sql + const isExpanded = expandedDiffs.has(originalIndex) + + // Read status from message, compute isRejectedWithFollowUp from message positions + const isAccepted = message.isAccepted === true + const isRejected = message.isRejected === true + // A message is "followed up" if it has SQL, isn't accepted/rejected, and has a visible user message after it + const isRejectedWithFollowUp = + hasSQLChange && + !isAccepted && + !isRejected && + hasVisibleUserMessageAfter(originalIndex) + + const isLastVisibleMessage = originalIndex === lastVisibleMessageIndex + const showButtons = + hasSQLChange && + !isAccepted && + !isRejected && + !isRejectedWithFollowUp && + isLastVisibleMessage + + // Compute query run status for this message's SQL + let queryRunStatus: QueryRunStatus = "neutral" + if (message.sql) { + const normalizedMessageSQL = normalizeQueryText(message.sql) + // Check if this query is currently running + if ( + running === RunningType.AI_SUGGESTION && + aiSuggestionRequest && + normalizeQueryText(aiSuggestionRequest.query) === + normalizedMessageSQL + ) { + queryRunStatus = "loading" + } + // Check if we have a notification for this specific query in queryNotifications + // The query key is created from the normalized SQL and the conversation's queryStartOffset + else if (queryNotifications) { + const queryKey = createQueryKey( + normalizedMessageSQL, + queryStartOffset, + ) + const notification = queryNotifications[queryKey]?.latest + if (notification) { + if (notification.type === NotificationType.ERROR) { + queryRunStatus = "error" + } else if ( + notification.type === NotificationType.SUCCESS || + notification.type === NotificationType.INFO + ) { + queryRunStatus = "success" + } + } + } + } + + const previousSQLForDiff = trimSemicolonForDisplay( + message.previousSQL, + ) + const currentSQLForDiff = trimSemicolonForDisplay(message.sql) + + const operationHistory = message.operationHistory + const hasError = !!message.error + + const isLiveOperation = + originalIndex === lastAssistantMessageIndex && + isOperationInProgress === true + + const hasOperationHistory = + !!operationHistory && operationHistory.length > 0 + + return ( + + {hasOperationHistory && ( + <> + + + + + + )} + {hasError && ( + + + {message.error} + + )} + + {message.content && ( + <> + + + Assistant + {tokenDisplay && ( + + + + {tokenDisplay} + + + )} + + + + ) => ( + + {children} + + ), + table: ({ + children, + ...props + }: React.ComponentProps<"table">) => ( +
+
{children}
+ + ), + // Render pre as fragment since code blocks are handled by code component + pre: ({ children }: React.ComponentProps<"pre">) => ( + <>{children} + ), + code: ({ + children, + className, + }: React.ComponentProps<"code">) => { + // Check if this is a code block (has language class) or inline code + const isCodeBlock = + typeof className === "string" && + className.includes("language-") + if (isCodeBlock) { + // Extract text content from children (can be string or array) + const codeContent = ( + Array.isArray(children) + ? children.join("") + : typeof children === "string" + ? children + : "" + ).replace(/\n$/, "") + const lineCount = codeContent.split("\n").length + // LiteEditor has 8px padding top and bottom (16px total) + const editorHeight = Math.min( + lineCount * 20 + 16, + 316, + ) + return ( + + + + ) + } + // Inline code - render as default + return {children} + }, + }} + > + {explanation} + + + {hasSQLChange && ( + + + + + Suggested change + + {(isAccepted || + isRejected || + isRejectedWithFollowUp) && ( + + + {isRejected ? ( + + ) : isRejectedWithFollowUp ? ( + + ) : ( + + )} + + {isRejected + ? "Rejected" + : isRejectedWithFollowUp + ? "Followed up" + : "Accepted"} + + )} + + { + e.stopPropagation() + if (message.sql && onRunQuery) { + onRunQuery(message.sql) + } + }} + title="Run this query" + data-hook="message-action-run-sql" + > + {getQueryStatusIcon(queryRunStatus)} + + {/* Show Apply to Editor button only when: + - accept/reject buttons are NOT shown + - NOT the latest suggestion that is already accepted (would have no effect) + - suggestion SQL differs from what's in editor (otherwise no effect) + */} + {!showButtons && + onApplyToEditor && + !( + originalIndex === latestDiffIndex && isAccepted + ) && + normalizeQueryText(message.sql || "") !== + normalizeQueryText(editorSQL || "") && ( + { + e.stopPropagation() + if ( + message.id && + message.sql && + !isOperationInProgress + ) { + onApplyToEditor(message.id, message.sql) + } + }} + title="Apply to editor" + disabled={isOperationInProgress} + style={{ + opacity: isOperationInProgress ? 0.5 : 1, + cursor: isOperationInProgress + ? "not-allowed" + : "pointer", + }} + data-hook="message-action-apply" + > + + + )} + { + setExpandedDiffs((prev) => { + const next = new Set(prev) + if (next.has(originalIndex)) { + next.delete(originalIndex) + } else { + next.add(originalIndex) + } + return next + }) + }} + > + + + + + {isExpanded && ( + <> + + + onExpandDiff( + message.previousSQL || "", + message.sql || "", + ) + : undefined + } + /> + + {showButtons && ( + + {onRejectChange && message.id && ( + onRejectChange(message.id)} + data-hook="message-action-reject" + > + Reject + + )} + {onAcceptChange && message.id && ( + onAcceptChange(message.id)} + data-hook="message-action-accept" + > + Accept + + )} + + )} + + )} + + )} + + + )} + + ) + } + })} +
+ + ) +} diff --git a/src/scenes/Editor/AIChatWindow/DateSeparator.tsx b/src/scenes/Editor/AIChatWindow/DateSeparator.tsx new file mode 100644 index 000000000..2ca948c57 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/DateSeparator.tsx @@ -0,0 +1,48 @@ +import React from "react" +import styled from "styled-components" +import { ClockCountdownIcon } from "@phosphor-icons/react" +import { color } from "../../../utils" + +const Container = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: 0.4rem 0; + width: 100%; +` + +const Line = styled.div` + flex: 1; + height: 1px; + background: ${color("selection")}; +` + +const LabelContainer = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + color: ${color("gray2")}; +` + +const Label = styled.span` + font-size: 1.3rem; + letter-spacing: 0.016rem; + white-space: nowrap; +` + +type DateSeparatorProps = { + label: string +} + +export const DateSeparator: React.FC = ({ label }) => { + return ( + + + + + + + + + ) +} diff --git a/src/scenes/Editor/AIChatWindow/historyUtils.ts b/src/scenes/Editor/AIChatWindow/historyUtils.ts new file mode 100644 index 000000000..bbdf6a26d --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/historyUtils.ts @@ -0,0 +1,81 @@ +import { useState, useEffect, useMemo } from "react" +import { formatDistance } from "date-fns" +import { fetchUserLocale, getLocaleFromLanguage } from "../../../utils" +import type { ConversationMeta } from "../../../store/db" + +export type DateGroup = { + label: string + conversations: ConversationMeta[] +} + +const UPDATE_INTERVAL_MS = 60_000 + +export function getRelativeDateLabel(timestamp: number): string { + const userLocale = fetchUserLocale() + const locale = getLocaleFromLanguage(userLocale) + + return formatDistance(timestamp, new Date().getTime(), { locale }) + " ago" +} + +function groupConversationsByDate( + conversations: ConversationMeta[], +): DateGroup[] { + const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt) + + const groups = new Map() + + for (const conv of sorted) { + const label = getRelativeDateLabel(conv.updatedAt) + const existing = groups.get(label) || [] + groups.set(label, [...existing, conv]) + } + + const result: DateGroup[] = [] + const seenLabels = new Set() + + for (const conv of sorted) { + const label = getRelativeDateLabel(conv.updatedAt) + if (!seenLabels.has(label)) { + seenLabels.add(label) + result.push({ + label, + conversations: groups.get(label) || [], + }) + } + } + + return result +} + +export function useGroupedConversations( + conversations: ConversationMeta[], +): DateGroup[] { + const [tick, setTick] = useState(0) + + useEffect(() => { + const interval = setInterval(() => { + setTick((t) => t + 1) + }, UPDATE_INTERVAL_MS) + + return () => clearInterval(interval) + }, []) + + return useMemo( + () => groupConversationsByDate(conversations), + [conversations, tick], + ) +} + +export function filterConversations( + conversations: ConversationMeta[], + searchQuery: string, +): ConversationMeta[] { + if (!searchQuery.trim()) { + return conversations + } + + const query = searchQuery.toLowerCase().trim() + return conversations.filter((conv) => + conv.conversationName.toLowerCase().includes(query), + ) +} diff --git a/src/scenes/Editor/AIChatWindow/index.tsx b/src/scenes/Editor/AIChatWindow/index.tsx new file mode 100644 index 000000000..34300184b --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/index.tsx @@ -0,0 +1,873 @@ +import React, { + useMemo, + useRef, + useContext, + useCallback, + useEffect, +} from "react" +import type { MutableRefObject } from "react" +import styled, { css } from "styled-components" +import { Button, Box } from "../../../components" +import { AISparkle } from "../../../components/AISparkle" +import { ExplainQueryButton } from "../../../components/ExplainQueryButton" +import { FixQueryButton } from "../../../components/FixQueryButton" +import { + PlusIcon, + XIcon, + ClockCounterClockwiseIcon, +} from "@phosphor-icons/react" +import { useEditor } from "../../../providers" +import { useAIConversation } from "../../../providers/AIConversationProvider" +import { extractErrorByQueryKey } from "../utils" +import { getQueryInfoFromKey } from "../Monaco/utils" +import type { ExecutionRefs } from "../index" +import { + trimSemicolonForDisplay, + hasUnactionedDiff as checkHasUnactionedDiff, +} from "../../../providers/AIConversationProvider/utils" +import { + isBlockingAIStatus, + useAIStatus, + type OperationHistory, +} from "../../../providers/AIStatusProvider" +import { toast } from "../../../components/Toast" +import { color } from "../../../utils" +import { LiteEditor } from "../../../components/LiteEditor" +import { ChatMessages } from "./ChatMessages" +import { ChatInput, type ChatInputHandle } from "./ChatInput" +import { ChatHistoryView } from "./ChatHistoryView" +import { + continueConversation, + isAiAssistantError, + normalizeSql, + generateChatTitle, + type ActiveProviderSettings, +} from "../../../utils/aiAssistant" +import { + providerForModel, + MODEL_OPTIONS, +} from "../../../utils/aiAssistantSettings" +import { createModelToolsClient } from "../../../utils/aiAssistant" +import { QuestContext } from "../../../providers" +import { useDispatch, useSelector } from "react-redux" +import { actions, selectors } from "../../../store" +import { RunningType } from "../../../store/Query/types" +import { eventBus } from "../../../modules/EventBus" +import { EventType } from "../../../modules/EventBus/types" +import { CircleNotchSpinner } from "../Monaco/icons" + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + background: ${color("chatBackground")}; + border-left: 0.2rem ${color("backgroundDarker")} solid; +` + +const Header = styled.div` + height: 46px; + padding: 0 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + background: ${color("backgroundLighter")}; + flex-shrink: 0; +` + +const HeaderLeft = styled.div` + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + min-width: 0; + overflow: hidden; +` + +const HeaderTitle = styled.span` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1.6rem; +` + +const HeaderButton = styled(Button).attrs( + ({ $active }: { $active: boolean }) => ({ + skin: "transparent", + $active, + }), +)` + color: ${color("foreground")}; + padding: 0.6rem; + + ${({ $active }) => + $active && + css` + background: ${color("selection")}; + `} +` + +const HeaderRight = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +` + +const ChatWindowContent = styled.div` + display: flex; + height: calc(100% - 46px); + width: 100%; + overflow: hidden; +` + +const InitialQueryContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; + width: 100%; +` + +const InitialQueryBox = styled.div` + display: flex; + flex-direction: column; + align-self: flex-end; + flex-shrink: 0; + overflow: hidden; + width: 100%; +` + +const InitialQueryEditor = styled.div` + width: 100%; + overflow: hidden; +` + +const ButtonContainer = styled.div` + display: flex; + justify-content: flex-start; + align-items: center; + gap: 1rem; + width: 100%; + margin-top: 0.5rem; +` + +const BlankChatContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1.2rem; + padding: 1.8rem; + flex: 1 1 auto; + min-height: 0; + max-width: 40rem; + text-align: center; + margin: 0 auto; +` + +const BlankChatHeading = styled.h2` + font-size: 2rem; + font-weight: 600; + text-align: left; + color: ${color("foreground")}; + margin: 0; +` + +const BlankChatSubheading = styled.p` + font-size: 1.4rem; + font-weight: 400; + color: ${color("gray2")}; + text-align: left; + margin: 0; + line-height: 1.5; +` + +const ChatPanel = styled(Box)` + display: flex; + flex-direction: column; + align-items: stretch; + height: 100%; + width: 100%; + gap: 0; +` + +const AIChatWindow: React.FC = () => { + const dispatch = useDispatch() + const activeSidebar = useSelector(selectors.console.getActiveSidebar) + const { quest } = useContext(QuestContext) + const { + editorRef, + buffers, + activeBuffer, + setActiveBuffer, + showDiffBuffer, + closeDiffBufferForConversation, + executionRefs, + } = useEditor() + const { + conversationMetas, + activeConversationMessages, + chatWindowState, + isLoadingMessages, + closeChatWindow, + openBlankChatWindow, + openHistoryView, + closeHistoryView, + getConversationMeta, + addMessage, + updateMessage, + replaceConversationMessages, + updateConversationName, + acceptSuggestion, + rejectSuggestion, + persistMessages, + } = useAIConversation() + const { + status: aiStatus, + setStatus, + abortController, + canUse, + hasSchemaAccess, + currentModel, + apiKey, + } = useAIStatus() + const tables = useSelector(selectors.query.getTables) + const running = useSelector(selectors.query.getRunning) + const aiSuggestionRequest = useSelector( + selectors.query.getAISuggestionRequest, + ) + + const conversationMeta = chatWindowState.activeConversationId + ? getConversationMeta(chatWindowState.activeConversationId) + : null + + const conversation = useMemo(() => { + if (!conversationMeta) return null + return { ...conversationMeta, messages: activeConversationMessages } + }, [conversationMeta, activeConversationMessages]) + + // Get query notifications for the conversation's buffer + // Use the conversation's bufferId (original buffer, not diff buffer) for looking up notifications + const conversationBufferId = conversation?.bufferId + const queryNotifications = useSelector( + selectors.query.getQueryNotificationsForBuffer(conversationBufferId ?? -1), + ) + + // Ref for ChatInput to programmatically focus + const chatInputRef = useRef(null) + + const currentSQL = useMemo(() => { + return trimSemicolonForDisplay(conversation?.currentSQL) + }, [conversation]) + + const queryInfo = useMemo(() => { + return getQueryInfoFromKey(conversation?.queryKey) + }, [conversation?.queryKey]) + + const messages = activeConversationMessages + + const hasUnactionedDiff = useMemo(() => { + return checkHasUnactionedDiff(messages) + }, [messages]) + + // Determine the buffer/tab status for this conversation + const bufferStatus = useMemo(() => { + if (!conversation) return { type: "none" as const } + + const conversationBufferId = conversation.bufferId + const buffer = buffers.find((b) => b.id === conversationBufferId) + + if (!buffer) { + // Buffer doesn't exist (deleted) + return { type: "deleted" as const } + } + + if (buffer.archived) { + // Buffer is archived + return { type: "archived" as const, buffer } + } + + if (buffer.id === activeBuffer.id) { + // Buffer is the current active tab + return { type: "active" as const, buffer } + } + + // Buffer exists but is not active + return { type: "inactive" as const, buffer } + }, [conversation, buffers, activeBuffer]) + + const shouldShowMessages = useMemo(() => { + return messages.length > 0 && !isLoadingMessages + }, [messages, isLoadingMessages]) + + const shouldShowExplainButton = useMemo(() => { + return ( + messages.length === 0 && + currentSQL && + currentSQL.trim() !== "\n" && + canUse && + !isBlockingAIStatus(aiStatus) + ) + }, [messages.length, currentSQL, canUse, aiStatus]) + + const hasErrorForCurrentQuery = useMemo(() => { + if ( + !shouldShowExplainButton || + !conversation || + !conversation.queryKey || + !conversation.bufferId || + !editorRef.current + ) { + return false + } + + const errorInfo = extractErrorByQueryKey( + conversation.queryKey, + conversation.bufferId, + executionRefs as MutableRefObject | undefined, + editorRef, + ) + return errorInfo !== null + }, [shouldShowExplainButton, conversation, editorRef, executionRefs]) + + const shouldShowFixButton = + shouldShowExplainButton && + hasErrorForCurrentQuery && + canUse && + !isBlockingAIStatus(aiStatus) + + const isHistoryOpen = chatWindowState.isHistoryOpen ?? false + const hasConversations = conversationMetas.size > 0 + + const addButtonDisabled = useMemo(() => { + if (isBlockingAIStatus(aiStatus)) return true + if (!conversation) return false + return ( + conversation.messages.length === 0 && + !conversation.queryKey && + !conversation.tableId + ) + }, [conversation, aiStatus]) + + const headerTitle = useMemo(() => { + if (isHistoryOpen) { + return "Chat history" + } + + if (!conversation) return "" + + if (conversation.conversationName) { + return conversation.conversationName + } + + // Otherwise, show a generic title based on the flow type + // Check the first message's displayType to determine the flow + const firstMessage = conversation.messages[0] + if (firstMessage?.displayType) { + switch (firstMessage.displayType) { + case "fix_request": + return "Fix query" + case "explain_request": + return "Explain query" + case "ask_request": + return "Ask AI" + default: + return "AI Assistant" + } + } + + return "AI Assistant" + }, [conversation, isHistoryOpen]) + + const handleHistoryToggle = useCallback(() => { + if (isHistoryOpen) { + void closeHistoryView() + } else { + openHistoryView() + } + }, [isHistoryOpen, closeHistoryView, openHistoryView]) + + const getPlaceholder = () => { + if (messages.length > 0) { + return "Ask a follow up question or request refinement..." + } + if (conversation?.tableId != null || currentSQL?.trim()) { + return "Ask a question or request an edit..." + } + return "Ask AI about your tables, or generate a query..." + } + + const handleSendMessage = ( + userMessage: string, + hasUnactionedDiffParam: boolean = false, + ) => { + if (!canUse || !chatWindowState.activeConversationId || !conversation) { + return + } + + const conversationId = chatWindowState.activeConversationId + + if (hasUnactionedDiffParam) { + void closeDiffBufferForConversation(conversationId) + } + + const hasAssistantMessages = conversation.messages.some( + (msg) => msg.role === "assistant", + ) + + let userMessageContent = userMessage + let displayType: "ask_request" | undefined = undefined + let sql: string | undefined = undefined + let displayUserMessage: string | undefined = undefined + + if (!hasAssistantMessages && currentSQL && currentSQL.trim()) { + // First message with SQL context (like "Ask AI" flow) + // Store the enriched message so it's preserved in conversation history for API + userMessageContent = `Current SQL query:\n\`\`\`sql\n${currentSQL}\n\`\`\`\n\nUser request: ${userMessage}` + // Set display type for proper UI rendering (shows user message + SQL editor) + displayType = "ask_request" + sql = currentSQL.trim() + displayUserMessage = userMessage // Store the original user message for display + } + + if (!hasAssistantMessages) { + eventBus.publish(EventType.AI_QUERY_HIGHLIGHT, conversationId) + } + + const userMessageEntry = { + role: "user" as const, + content: userMessageContent, + timestamp: Date.now(), + ...(displayType && { displayType }), + ...(sql && { sql }), + ...(displayUserMessage && { displayUserMessage }), + } + + addMessage(userMessageEntry) + + const assistantMessageId = crypto.randomUUID() + addMessage({ + id: assistantMessageId, + role: "assistant", + content: "", + timestamp: Date.now(), + operationHistory: [], + }) + + const provider = providerForModel(currentModel) + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, + } + + // Generate chat title in parallel using test model (only for first message) + if (!hasAssistantMessages) { + const testModel = MODEL_OPTIONS.find( + (m) => m.isTestModel && m.provider === provider, + ) + if (testModel) { + void generateChatTitle({ + firstUserMessage: userMessageContent, + settings: { model: testModel.value, provider, apiKey }, + }).then((title) => { + if (title) { + void updateConversationName(conversationId, title) + } + }) + } + } + + const handleStatusUpdate = (history: OperationHistory) => { + updateMessage(conversationId, assistantMessageId, { + operationHistory: [...history], + }) + } + + const processResponse = async () => { + const response = await continueConversation({ + userMessage: userMessageContent, + conversationHistory: conversation.messages.filter( + (m) => !m.isCompacted, + ), + currentSQL, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus: (status, args) => + setStatus( + status, + { ...(args ?? {}), conversationId }, + handleStatusUpdate, + ), + abortSignal: abortController?.signal, + }) + + if (isAiAssistantError(response)) { + const error = response + updateMessage(conversationId, assistantMessageId, { + error: + error.type !== "aborted" + ? error.message + : "Operation has been cancelled", + }) + return + } + + const result = response + let assistantContent = result.explanation || "Response received" + const hasSQLInResult = + "sql" in result && result.sql && result.sql.trim() !== "" + if (hasSQLInResult) { + assistantContent = `SQL Query:\n\`\`\`sql\n${result.sql}\n\`\`\`\n\nExplanation:\n${result.explanation || ""}` + } + + if (result.compactedConversationHistory) { + replaceConversationMessages( + conversationId, + result.compactedConversationHistory, + ) + } + + updateMessage(conversationId, assistantMessageId, { + content: assistantContent, + ...(hasSQLInResult && { sql: result.sql as string }), + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + } + + void processResponse().then(async () => { + await persistMessages(conversationId) + }) + } + + const handleExpandDiff = useCallback( + (original: string, modified: string) => { + if (!chatWindowState.activeConversationId) return + void showDiffBuffer({ + original, + modified, + conversationId: chatWindowState.activeConversationId, + }) + }, + [showDiffBuffer, chatWindowState.activeConversationId], + ) + + const handleAcceptChange = useCallback( + async (messageId: string) => { + if (!chatWindowState.activeConversationId) return + + await acceptSuggestion({ + conversationId: chatWindowState.activeConversationId, + messageId, + }) + + dispatch(actions.query.setAISuggestionRequest(null)) + }, + [chatWindowState.activeConversationId, acceptSuggestion], + ) + + const handleRejectChange = useCallback( + async (messageId: string) => { + if (!chatWindowState.activeConversationId) return + + await rejectSuggestion(chatWindowState.activeConversationId, messageId) + + setTimeout(() => { + chatInputRef.current?.focus() + }, 100) + }, + [chatWindowState.activeConversationId, rejectSuggestion], + ) + + const handleRunQuery = useCallback( + (sql: string) => { + const normalizedSQL = sql.trim().endsWith(";") + ? sql.trim().slice(0, -1) + : sql.trim() + + dispatch( + actions.query.setAISuggestionRequest({ + query: normalizedSQL, + startOffset: queryInfo.startOffset, + }), + ) + dispatch(actions.query.toggleRunning(RunningType.AI_SUGGESTION)) + }, + [queryInfo.startOffset], + ) + + const navigateToBuffer = useCallback(async (): Promise => { + if ( + bufferStatus.type === "deleted" || + bufferStatus.type === "archived" || + bufferStatus.type === "none" + ) { + return false + } + + try { + // Switch to the buffer if it's inactive + if (bufferStatus.type === "inactive" && bufferStatus.buffer) { + await setActiveBuffer(bufferStatus.buffer) + // Wait for the buffer to be set + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + return true + } catch (error) { + console.error("Error navigating to buffer:", error) + return false + } + }, [bufferStatus, setActiveBuffer]) + + // Handle context badge click - navigate to query and highlight it + const handleContextClick = useCallback(async () => { + if (!conversation || !editorRef.current || !conversation.queryKey) { + return + } + + // Navigate to the buffer first + const success = await navigateToBuffer() + if (!success) return + + try { + const model = editorRef.current.getModel() + if (!model) return + + const startPosition = model.getPositionAt(queryInfo.startOffset) + const endPosition = model.getPositionAt(queryInfo.endOffset) + + // Reveal the position in the center of the viewport + editorRef.current.revealPositionNearTop(startPosition) + editorRef.current.setPosition(startPosition) + + // Apply highlighting decoration + const decorationIds = model.deltaDecorations( + [], + [ + { + range: { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + }, + options: { + isWholeLine: false, + className: "aiQueryHighlight", + }, + }, + ], + ) + + editorRef.current.focus() + + setTimeout(() => { + model.deltaDecorations(decorationIds, []) + }, 1000) + } catch (error) { + console.error("Error highlighting query:", error) + } + }, [conversation, editorRef, navigateToBuffer, queryInfo]) + + const handleApplyToEditor = useCallback( + async (messageId: string, sql: string) => { + if (!chatWindowState.activeConversationId) return + + const normalizedSQL = normalizeSql(sql, false) + + try { + await acceptSuggestion({ + conversationId: chatWindowState.activeConversationId, + messageId, + skipDefaultMessage: true, + }) + + addMessage({ + role: "user" as const, + content: `User replaced query with one of your previous suggestions. Now the query is:\n\n\`\`\`sql\n${normalizedSQL.replaceAll(/\s+/g, " ").trim()}\n\`\`\``, + timestamp: Date.now(), + hideFromUI: true, + }) + + await persistMessages(chatWindowState.activeConversationId) + } catch (error) { + console.error("Error applying SQL to editor:", error) + toast.error("Failed to apply changes to editor") + } + }, + [ + chatWindowState.activeConversationId, + acceptSuggestion, + addMessage, + persistMessages, + ], + ) + + const explainButtonRef = useRef(null) + + const handleExplainQuery = useCallback(() => { + const button = explainButtonRef.current?.querySelector( + 'button[data-hook="button-explain-query"]', + ) as HTMLButtonElement + button?.click() + }, []) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!shouldShowExplainButton) return + if (!((e.metaKey || e.ctrlKey) && (e.key === "e" || e.key === "E"))) { + return + } + e.preventDefault() + handleExplainQuery() + }, + [shouldShowExplainButton], + ) + + useEffect(() => { + if (shouldShowExplainButton) { + eventBus.subscribe(EventType.EXPLAIN_QUERY_EXEC, handleExplainQuery) + + document.addEventListener("keydown", handleKeyDown) + return () => { + document.removeEventListener("keydown", handleKeyDown) + eventBus.unsubscribe(EventType.EXPLAIN_QUERY_EXEC, handleExplainQuery) + } + } + }, [shouldShowExplainButton, handleKeyDown, handleExplainQuery]) + + if (activeSidebar !== "aiChat" || (!conversation && !isHistoryOpen)) { + return null + } + + return ( + +
+ + + {headerTitle} + + + + + + + + + + + + +
+ + {isLoadingMessages ? ( + + + + ) : isHistoryOpen ? ( + + ) : ( + + {shouldShowMessages ? ( + + ) : currentSQL && currentSQL.trim() ? ( + + + + + + + {(shouldShowExplainButton || shouldShowFixButton) && ( + + {shouldShowExplainButton && ( + + )} + {shouldShowFixButton && } + + )} + + ) : ( + + + Leverage AI directly in your database + + + Our AI Assistant is a specialized programming and support + agent that makes you more effective and helps you solve + problems as you interface with your QuestDB database. Start a + conversation. + + + )} + + handleSendMessage(message, hasUnactionedDiff) + } + disabled={!canUse || isBlockingAIStatus(aiStatus)} + placeholder={getPlaceholder()} + contextSQL={queryInfo.queryText} + contextTableId={conversation?.tableId} + onContextClick={handleContextClick} + /> + + )} + +
+ ) +} + +export default AIChatWindow diff --git a/src/scenes/Editor/ButtonBar/index.tsx b/src/scenes/Editor/ButtonBar/index.tsx index 8bfdfa959..a0d3fb181 100644 --- a/src/scenes/Editor/ButtonBar/index.tsx +++ b/src/scenes/Editor/ButtonBar/index.tsx @@ -1,35 +1,46 @@ -import React, { useCallback, useState, useEffect } from "react" -import styled from "styled-components" +import React, { useCallback, useState, useEffect, useRef } from "react" +import styled, { css } from "styled-components" import { useDispatch, useSelector } from "react-redux" import { Stop } from "@styled-icons/remix-line" -import { CornerDownLeft } from "@styled-icons/evaicons-solid" +import { Key } from "../../../components" import { ChevronDown } from "@styled-icons/boxicons-solid" import { Box, Button, PopperToggle } from "../../../components" import { actions, selectors } from "../../../store" import { platform, color } from "../../../utils" import { RunningType } from "../../../store/Query/types" +type ButtonBarProps = { + onTriggerRunScript: (runAll?: boolean) => void + isTemporary: boolean | undefined +} + const ButtonBarWrapper = styled.div<{ $searchWidgetType: "find" | "replace" | null }>` - position: absolute; - top: ${({ $searchWidgetType }) => - $searchWidgetType === "replace" + ${({ $searchWidgetType }) => css` + position: absolute; + top: ${$searchWidgetType === "replace" ? "8.2rem" : $searchWidgetType === "find" ? "5.3rem" : "1rem"}; - right: 2.4rem; - z-index: 1; - transition: top 0.1s linear; + right: 2.4rem; + z-index: 1; + transition: top 0.1s linear; + display: flex; + gap: 1rem; + align-items: center; + `} ` const ButtonGroup = styled.div` display: flex; gap: 0; + margin-left: auto; ` const SuccessButton = styled(Button)` + margin-left: auto; background-color: ${color("greenDarker")}; border-color: ${color("greenDarker")}; color: ${color("foreground")}; @@ -37,7 +48,7 @@ const SuccessButton = styled(Button)` &:hover:not(:disabled) { background-color: ${color("green")}; border-color: ${color("green")}; - color: ${color("gray1")}; + color: ${color("selectionDarker")}; } &:disabled { @@ -61,6 +72,7 @@ const SuccessButton = styled(Button)` ` const StopButton = styled(Button)` + margin-left: auto; background-color: ${color("red")}; border-color: ${color("red")}; color: ${color("foreground")}; @@ -124,23 +136,6 @@ const DropdownMenu = styled.div` } ` -const Key = styled(Box).attrs({ alignItems: "center" })` - padding: 0 0.4rem; - background: ${color("gray1")}; - border-radius: 0.2rem; - font-size: 1.2rem; - height: 1.8rem; - color: ${color("green")}; - - &:not(:last-child) { - margin-right: 0.25rem; - } - - svg { - color: ${color("green")} !important; - } -` - const RunShortcut = styled(Box).attrs({ alignItems: "center", gap: "0" })` margin-left: 1rem; ` @@ -149,25 +144,21 @@ const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" const shortcutTitles = platform.isMacintosh || platform.isIOS ? { - [RunningType.QUERY]: "Cmd+Enter", - [RunningType.SCRIPT]: "Cmd+Shift+Enter", + [RunningType.QUERY]: "Run query (Cmd+Enter)", + [RunningType.SCRIPT]: "Run all queries (Cmd+Shift+Enter)", } : { - [RunningType.QUERY]: "Ctrl+Enter", - [RunningType.SCRIPT]: "Ctrl+Shift+Enter", + [RunningType.QUERY]: "Run query (Ctrl+Enter)", + [RunningType.SCRIPT]: "Run all queries (Ctrl+Shift+Enter)", } -const ButtonBar = ({ - onTriggerRunScript, - isTemporary, -}: { - onTriggerRunScript: (runAll?: boolean) => void - isTemporary: boolean | undefined -}) => { +const ButtonBar = ({ onTriggerRunScript, isTemporary }: ButtonBarProps) => { const dispatch = useDispatch() const running = useSelector(selectors.query.getRunning) const queriesToRun = useSelector(selectors.query.getQueriesToRun) const [dropdownActive, setDropdownActive] = useState(false) + const observerRef = useRef(null) + const [searchWidgetType, setSearchWidgetType] = useState< "find" | "replace" | null >(null) @@ -236,9 +227,13 @@ const ButtonBar = ({ attributeFilter: ["class"], attributeOldValue: false, }) + observerRef.current = observer return () => { - observer.disconnect() + if (observerRef.current) { + observerRef.current.disconnect() + observerRef.current = null + } } }, []) @@ -265,11 +260,21 @@ const ButtonBar = ({ > Run all queries - {ctrlCmd} - ⇧ - - - + + + ) @@ -317,10 +322,16 @@ const ButtonBar = ({ > {getQueryButtonText()} - {ctrlCmd} - - - + + ` position: relative; flex: 0 0 auto; + margin-right: 0.5rem; @keyframes pulse { 0% { @@ -187,6 +189,7 @@ const Menu = () => { /> )} + diff --git a/src/scenes/Editor/Monaco/QueryDropdown.tsx b/src/scenes/Editor/Monaco/QueryDropdown.tsx index aeaef527b..a0ad43ea9 100644 --- a/src/scenes/Editor/Monaco/QueryDropdown.tsx +++ b/src/scenes/Editor/Monaco/QueryDropdown.tsx @@ -3,6 +3,7 @@ import styled from "styled-components" import { Information } from "@styled-icons/remix-line" import { DropdownMenu } from "../../../components/DropdownMenu" import { PlayFilled } from "../../../components/icons/play-filled" +import { AISparkle } from "../../../components/AISparkle" import type { Request } from "./utils" const StyledDropdownContent = styled(DropdownMenu.Content)` @@ -67,8 +68,10 @@ type QueryDropdownProps = { positionRef: React.MutableRefObject<{ x: number; y: number } | null> queriesRef: React.MutableRefObject isContextMenuRef: React.MutableRefObject + isAIDropdownRef: React.MutableRefObject onRunQuery: (query: Request) => void onExplainQuery: (query: Request) => void + onAskAIRef: React.MutableRefObject<(query?: Request) => void> } export const QueryDropdown: React.FC = ({ @@ -77,8 +80,10 @@ export const QueryDropdown: React.FC = ({ positionRef, queriesRef, isContextMenuRef, + isAIDropdownRef, onRunQuery, onExplainQuery, + onAskAIRef, }) => { const handleOpenChange = (isOpen: boolean) => { onOpenChange(isOpen) @@ -110,68 +115,83 @@ export const QueryDropdown: React.FC = ({ - {queriesRef.current.length > 1 - ? // Multiple queries - show options for each - queriesRef.current - .map((query, index) => { - const items = [ - onRunQuery(query)} - data-hook={`dropdown-item-run-query-${index}`} - > - - - - Run {extractQueryTextToRun(query)} - , - ] - - if (isContextMenuRef.current) { - items.push( + {isAIDropdownRef.current + ? // AI dropdown - show "Ask AI about query X" options + queriesRef.current.map((query, index) => ( + onAskAIRef.current(query)} + data-hook={`dropdown-item-ask-ai-${index}`} + > + + + + Ask AI about {extractQueryTextToRun(query)} + + )) + : queriesRef.current.length > 1 + ? // Multiple queries - show options for each + queriesRef.current + .map((query, index) => { + const items = [ onExplainQuery(query)} - data-hook={`dropdown-item-explain-query-${index}`} + key={`run-${query.query}-${index}`} + onClick={() => onRunQuery(query)} + data-hook={`dropdown-item-run-query-${index}`} > - + - Get query plan for {extractQueryTextToRun(query)} + Run {extractQueryTextToRun(query)} , - ) - } + ] - return items - }) - .flat() - : [ - onRunQuery(queriesRef.current[0])} - data-hook="dropdown-item-run-query" - > - - - - Run {extractQueryTextToRun(queriesRef.current[0])} - , - onExplainQuery(queriesRef.current[0])} - data-hook="dropdown-item-get-query-plan" - > - - - - Get query plan for{" "} - {extractQueryTextToRun(queriesRef.current[0])} - , - ]} + if (isContextMenuRef.current) { + items.push( + onExplainQuery(query)} + data-hook={`dropdown-item-explain-query-${index}`} + > + + + + Get query plan for {extractQueryTextToRun(query)} + , + ) + } + + return items + }) + .flat() + : [ + onRunQuery(queriesRef.current[0])} + data-hook="dropdown-item-run-query" + > + + + + Run {extractQueryTextToRun(queriesRef.current[0])} + , + onExplainQuery(queriesRef.current[0])} + data-hook="dropdown-item-get-query-plan" + > + + + + Get query plan for{" "} + {extractQueryTextToRun(queriesRef.current[0])} + , + ]} diff --git a/src/scenes/Editor/Monaco/editor-addons.ts b/src/scenes/Editor/Monaco/editor-addons.ts index d009023a2..e0bd385dc 100644 --- a/src/scenes/Editor/Monaco/editor-addons.ts +++ b/src/scenes/Editor/Monaco/editor-addons.ts @@ -33,6 +33,8 @@ import { import { QuestDBLanguageName } from "./utils" import { bufferStore } from "../../../store/buffers" import type { editor, IDisposable } from "monaco-editor" +import { eventBus } from "../../../modules/EventBus" +import { EventType } from "../../../modules/EventBus/types" enum Command { EXECUTE = "execute", @@ -41,6 +43,7 @@ enum Command { ADD_NEW_TAB = "add_new_tab", CLOSE_ACTIVE_TAB = "close_active_tab", SEARCH_DOCS = "search_docs", + EXPLAIN_QUERY = "explain_query", } export const registerEditorActions = ({ @@ -141,6 +144,17 @@ export const registerEditorActions = ({ }), ) + actions.push( + editor.addAction({ + id: Command.EXPLAIN_QUERY, + label: "Explain query", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE], + run: () => { + eventBus.publish(EventType.EXPLAIN_QUERY_EXEC) + }, + }), + ) + return () => { actions.forEach((action) => { action.dispose() diff --git a/src/scenes/Editor/Monaco/glyphUtils.ts b/src/scenes/Editor/Monaco/glyphUtils.ts new file mode 100644 index 000000000..45bec8003 --- /dev/null +++ b/src/scenes/Editor/Monaco/glyphUtils.ts @@ -0,0 +1,201 @@ +import type { editor } from "monaco-editor" +import { + createSvgElement, + createAIGutterIcon, + type GutterIconState, +} from "./icons" + +export type GlyphWidgetOptions = { + isCancel: boolean + hasError: boolean + isSuccessful: boolean + showAI?: boolean + hasConversation: boolean + isHighlighted: boolean + onRunClick: () => void + onRunContextMenu: () => void + onAIClick: () => void +} + +export const createGlyphWidgetId = ( + lineNumber: number, + options: GlyphWidgetOptions, +): string => { + const { + isCancel, + hasError, + isSuccessful, + showAI, + hasConversation, + isHighlighted, + } = options + const optionsId = [ + isCancel, + hasError, + isSuccessful, + showAI, + hasConversation, + isHighlighted, + ] + .map((val) => (val ? "1" : "0")) + .join("") + return `glyph-widget-${lineNumber}-${optionsId}` +} + +/** + * Creates a glyph margin widget for a specific line in the Monaco editor. + * The widget contains an optional AI sparkle icon and a run/cancel button, + * each with independent hover effects and click handlers. + */ +export const createGlyphWidget = ( + lineNumber: number, + options: GlyphWidgetOptions, +): editor.IGlyphMarginWidget => { + const domNode = document.createElement("div") + domNode.className = "glyph-widget-container" + domNode.classList.add(`glyph-widget-${lineNumber}`) + + if (options.showAI) { + let baseState: GutterIconState = options.hasConversation + ? "active" + : "noChat" + if (options.isHighlighted) { + baseState = "highlight" + } + + const aiIconWrapper = createAIGutterIcon(baseState, 16) + aiIconWrapper.classList.add("glyph-ai-icon") + + if (options.isHighlighted) { + setTimeout(() => { + aiIconWrapper.classList.remove("highlight") + aiIconWrapper.classList.add("active") + }, 1000) + } + + aiIconWrapper.addEventListener("click", (e) => { + e.stopPropagation() + options.onAIClick?.() + }) + + domNode.appendChild(aiIconWrapper) + } + + // Run/Cancel/Status icon + const runIconWrapper = document.createElement("span") + runIconWrapper.style.display = "inline-flex" + runIconWrapper.style.alignItems = "center" + runIconWrapper.style.justifyContent = "center" + runIconWrapper.style.width = "24px" + runIconWrapper.style.position = "absolute" + runIconWrapper.style.top = "0" + runIconWrapper.style.right = options.showAI ? "0" : "20px" + runIconWrapper.style.height = "100%" + runIconWrapper.style.cursor = "pointer" + + // Determine which icon to show + let iconType: "play" | "cancel" | "loading" | "error" | "success" = "play" + if (options.isCancel) { + iconType = "cancel" + } else if (options.hasError) { + iconType = "error" + } else if (options.isSuccessful) { + iconType = "success" + } + + // Add icon type as class for later identification + runIconWrapper.className = `glyph-run-icon ${iconType}` + + const runSvg = createSvgElement(iconType, 22) + runIconWrapper.appendChild(runSvg) + + runIconWrapper.addEventListener("mouseenter", () => { + runIconWrapper.style.filter = "brightness(1.3)" + }) + runIconWrapper.addEventListener("mouseleave", () => { + runIconWrapper.style.filter = "" + }) + runIconWrapper.addEventListener("click", (e) => { + e.stopPropagation() + options.onRunClick() + }) + runIconWrapper.addEventListener("contextmenu", (e) => { + e.preventDefault() + e.stopPropagation() + options.onRunContextMenu?.() + }) + + domNode.appendChild(runIconWrapper) + + return { + getId: () => createGlyphWidgetId(lineNumber, options), + getDomNode: () => domNode, + getPosition: () => ({ + lane: 1, // monaco.editor.GlyphMarginLane.Left + zIndex: 1, + range: { + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: 1, + }, + }), + } +} + +type IconType = "play" | "cancel" | "loading" | "error" | "success" + +/** + * Toggles a glyph widget's run icon between loading and its previous state. + * Reads the current icon type from CSS classes on the runIconWrapper element. + */ +export const toggleGlyphWidgetLoading = ( + lineNumber: number, + isLoading: boolean, +): void => { + const domNode = document.querySelector(`.glyph-widget-${lineNumber}`) + if (!(domNode instanceof HTMLElement)) return + + const runIconWrapper = domNode.querySelector(".glyph-run-icon") + if (!(runIconWrapper instanceof HTMLElement)) return + + // Get current icon type from classes + const iconTypes: IconType[] = [ + "play", + "cancel", + "loading", + "error", + "success", + ] + const currentIconType = + iconTypes.find((type) => runIconWrapper.classList.contains(type)) || "play" + + if (isLoading && currentIconType !== "loading") { + // Store current icon type and switch to loading + runIconWrapper.classList.remove(...iconTypes) + runIconWrapper.classList.add("loading") + runIconWrapper.dataset.previousIconType = currentIconType + + // Replace with loading icon + const loadingSvg = createSvgElement("loading", 22) + runIconWrapper.innerHTML = "" + runIconWrapper.appendChild(loadingSvg) + runIconWrapper.style.animation = "glyph-spin 3s linear infinite" + runIconWrapper.style.pointerEvents = "none" + } else if (!isLoading && currentIconType === "loading") { + // Restore previous icon type + const previousIconType = + (runIconWrapper.dataset.previousIconType as IconType) || "play" + delete runIconWrapper.dataset.previousIconType + + runIconWrapper.classList.remove("loading") + runIconWrapper.classList.add(previousIconType) + + // Recreate the original icon + const originalSvg = createSvgElement(previousIconType, 22) + runIconWrapper.innerHTML = "" + runIconWrapper.appendChild(originalSvg) + runIconWrapper.style.animation = "" + runIconWrapper.style.pointerEvents = "auto" + } +} diff --git a/src/scenes/Editor/Monaco/icons.tsx b/src/scenes/Editor/Monaco/icons.tsx new file mode 100644 index 000000000..4e4a52680 --- /dev/null +++ b/src/scenes/Editor/Monaco/icons.tsx @@ -0,0 +1,297 @@ +import { spinAnimation } from "../../../components/Animation" +import React from "react" +import styled from "styled-components" + +export type GutterIconState = "noChat" | "active" | "highlight" + +// Play icon - green play button +export const PlayIcon = () => ( + + + + +) + +// Cancel icon - red stop square +export const CancelIcon = () => ( + + + + +) + +// Loading icon - white spinner (requires animation wrapper) +export const LoadingIconSvg = () => ( + + + + +) + +// Error icon - play button with red error badge +export const ErrorIcon = () => ( + + + + + + + + + + + + + +) + +// Success icon - play button with green checkmark badge +export const SuccessIcon = () => ( + + + + + + + + + + + + +) + +// Expand up/down icon for collapsible sections +export const ExpandUpDownIcon = () => ( + + + +) + +const CircleNotch = ( + props: React.SVGProps & { size?: number }, +) => ( + )} + > + + + + + + + + +) + +const CircleNotchStyled = styled(CircleNotch)<{ size?: number }>` + ${spinAnimation}; + flex-shrink: 0; + transform-origin: center; +` + +export const CircleNotchSpinner = ( + props: Omit, "ref"> & { size?: number }, +) => + +/** + * Creates an SVG element for use in vanilla DOM (glyph widgets). + * This is needed because Monaco glyph widgets use DOM elements, not React components. + */ +export const createSvgElement = ( + type: + | "play" + | "cancel" + | "loading" + | "error" + | "success" + | "aiSparkleHollow" + | "aiSparkleFilled", + size = 22, +): SVGSVGElement => { + const svgNS = "http://www.w3.org/2000/svg" + const svg = document.createElementNS(svgNS, "svg") + + switch (type) { + case "play": { + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "#50fa7b") + svg.innerHTML = ` + + + ` + break + } + case "cancel": { + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "#ff5555") + svg.innerHTML = ` + + + ` + break + } + case "loading": { + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "white") + svg.innerHTML = ` + + + ` + break + } + case "error": { + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "none") + svg.innerHTML = ` + + + + + + + ` + break + } + case "success": { + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "none") + svg.innerHTML = ` + + + + + + ` + break + } + case "aiSparkleHollow": { + svg.setAttribute("viewBox", "0 0 15 15") + svg.classList.add("ai-sparkle-hollow") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "none") + svg.innerHTML = ` + + ` + break + } + case "aiSparkleFilled": { + const gradientId = `aiSparkleGradient-${Math.random()}` + svg.classList.add("ai-sparkle-filled") + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "none") + svg.innerHTML = ` + + + + + + + + + ` + break + } + } + + return svg +} + +export const createAIGutterIcon = ( + state: GutterIconState, + size = 16, +): HTMLElement => { + const wrapper = document.createElement("span") + wrapper.className = "glyph-ai-icon" + wrapper.classList.add(state) + + const hollowSvg = createSvgElement("aiSparkleHollow", size) + const filledSvg = createSvgElement("aiSparkleFilled", size) + wrapper.appendChild(hollowSvg) + wrapper.appendChild(filledSvg) + + return wrapper +} diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index ac2d188ad..65b1a6d1a 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -1,6 +1,5 @@ import Editor from "@monaco-editor/react" import type { Monaco } from "@monaco-editor/react" -import { loader } from "@monaco-editor/react" import { Stop } from "@styled-icons/remix-line" import { Error as ErrorIcon } from "@styled-icons/boxicons-regular" import type { editor, IDisposable } from "monaco-editor" @@ -14,7 +13,7 @@ import React, { import type { ReactNode } from "react" import { useDispatch, useSelector } from "react-redux" import styled from "styled-components" -import type { ExecutionInfo, ExecutionRefs } from "../../Editor" +import type { ExecutionInfo } from "../../Editor" import { Box, Button, @@ -29,6 +28,11 @@ import { formatTiming } from "../QueryResult" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { QuestContext, useEditor } from "../../../providers" +import { + useAIStatus, + isBlockingAIStatus, +} from "../../../providers/AIStatusProvider" +import { useAIConversation } from "../../../providers/AIConversationProvider" import { actions, selectors } from "../../../store" import { RunningType } from "../../../store/Query/types" import type { NotificationShape } from "../../../store/Query/types" @@ -39,8 +43,7 @@ import { color } from "../../../utils" import * as QuestDB from "../../../utils/questdb" import Loader from "../Loader" import QueryResult from "../QueryResult" -import dracula from "./dracula" -import { registerEditorActions, registerLanguageAddons } from "./editor-addons" +import { registerEditorActions } from "./editor-addons" import { registerLegacyEventBusEvents } from "./legacy-event-bus" import { QueryInNotification } from "./query-in-notification" import { createSchemaCompletionProvider } from "./questdb-sql" @@ -53,6 +56,7 @@ import { getQueryFromCursor, getQueryRequestFromEditor, getQueryRequestFromLastExecutedQuery, + getQueryRequestFromAISuggestion, QuestDBLanguageName, getAllQueries, getQueriesInRange, @@ -70,6 +74,13 @@ import { import { toast } from "../../../components/Toast" import ButtonBar from "../ButtonBar" import { QueryDropdown } from "./QueryDropdown" +import { + createGlyphWidget, + createGlyphWidgetId, + toggleGlyphWidgetLoading, + GlyphWidgetOptions, +} from "./glyphUtils" +import type { ConversationId } from "../../../providers/AIConversationProvider/types" type IndividualQueryResult = { success: boolean @@ -79,18 +90,26 @@ type IndividualQueryResult = { | null } -loader.config({ - paths: { - vs: "assets/vs", - }, -}) - export const LINE_NUMBER_HARD_LIMIT = 99999 -const Content = styled(PaneContent)` +const Content = styled(PaneContent)<{ $hidden?: boolean }>` position: relative; + display: flex; + flex-direction: column; overflow: hidden; background: #2c2e3d; + height: 100%; + width: 100%; + + ${({ $hidden }) => + $hidden && + ` + position: absolute; + width: 0; + height: 0; + overflow: hidden; + visibility: hidden; + `} .monaco-editor .squiggly-error { background: none; border-bottom: 0.3rem ${color("red")} solid; @@ -110,6 +129,72 @@ const Content = styled(PaneContent)` } } + .glyph-widget-container { + align-items: center; + width: 50px !important; + display: flex; + align-items: center; + gap: 5px; + margin-left: 1rem; + width: 53px; + height: 100%; + } + + .glyph-ai-icon { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + + .ai-sparkle-hollow, + .ai-sparkle-filled { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .ai-sparkle-hollow { + visibility: visible; + } + .ai-sparkle-filled { + visibility: hidden; + } + &:hover, + &.highlight { + .ai-sparkle-hollow { + visibility: hidden; + } + .ai-sparkle-filled { + visibility: visible; + } + } + &.active { + border: 1px solid transparent; + box-shadow: none; + background: + linear-gradient(#2c2e3d, #2c2e3d) padding-box, + linear-gradient(90deg, #d14671 0%, #892c6c 100%) border-box; + } + + &.highlight { + border: 1px solid #d14671; + background: linear-gradient( + 90deg, + rgba(209, 70, 113, 0.24) 0%, + rgba(137, 44, 108, 0.24) 100% + ); + box-shadow: none; + } + } + .selectionErrorHighlight { background-color: rgba(255, 85, 85, 0.15); border-radius: 2px; @@ -125,42 +210,13 @@ const Content = styled(PaneContent)` border-radius: 2px; } - .cursorQueryGlyph, - .cancelQueryGlyph { - margin-left: 2rem; - z-index: 1; - cursor: pointer; - - &:after { - display: block; - content: ""; - width: 22px; - height: 22px; - background-repeat: no-repeat; - background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGFyaWEtaGlkZGVuPSJ0cnVlIiBmb2N1c2FibGU9ImZhbHNlIiBmaWxsPSIjNTBmYTdiIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJTdHlsZWRJY29uQmFzZS1zYy1lYTl1bGotMCBrZkRiTmwiPjxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiPjwvcGF0aD48cGF0aCBkPSJNMTYuMzk0IDEyIDEwIDcuNzM3djguNTI2TDE2LjM5NCAxMnptMi45ODIuNDE2TDguNzc3IDE5LjQ4MkEuNS41IDAgMCAxIDggMTkuMDY2VjQuOTM0YS41LjUgMCAwIDEgLjc3Ny0uNDE2bDEwLjU5OSA3LjA2NmEuNS41IDAgMCAxIDAgLjgzMnoiPjwvcGF0aD48L3N2Zz4K"); - transform: scale(1.1); - } - &:hover:after { - filter: brightness(1.3); - } - } - - .cursorQueryGlyph.success-glyph:after { - background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8ZGVmcz4KICAgICAgICA8Y2xpcFBhdGggaWQ9ImNsaXAwIj48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiLz48L2NsaXBQYXRoPgogICAgPC9kZWZzPgogICAgPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwKSI+CiAgICAgICAgPHBhdGggZD0iTTggNC45MzR2MTQuMTMyYzAgLjQzMy40NjYuNzAyLjgxMi40ODRsMTAuNTYzLTcuMDY2YS41LjUgMCAwIDAgMC0uODMyTDguODEyIDQuNjE2QS41LjUgMCAwIDAgOCA0LjkzNFoiIGZpbGw9IiM1MGZhN2IiLz4KICAgICAgICA8Y2lyY2xlIGN4PSIxOCIgY3k9IjgiIHI9IjYiIGZpbGw9IiMwMGFhM2IiLz4KICAgICAgICA8cGF0aCBkPSJtMTUgOC41IDIgMiA0LTQiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGZpbGw9Im5vbmUiLz4KICAgIDwvZz4KPC9zdmc+"); - } - - .cursorQueryGlyph.error-glyph:after { - background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8ZGVmcz4KICAgICAgICA8Y2xpcFBhdGggaWQ9ImNsaXAwIj48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiLz48L2NsaXBQYXRoPgogICAgPC9kZWZzPgogICAgPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwKSI+CiAgICAgICAgPHBhdGggZD0iTTggNC45MzR2MTQuMTMyYzAgLjQzMy40NjYuNzAyLjgxMi40ODRsMTAuNTYzLTcuMDY2YS41LjUgMCAwIDAgMC0uODMyTDguODEyIDQuNjE2QS41LjUgMCAwIDAgOCA0LjkzNFoiIGZpbGw9IiM1MGZhN2IiLz4KICAgICAgICA8Y2lyY2xlIGN4PSIxOCIgY3k9IjgiIHI9IjYiIGZpbGw9IiNmZjU1NTUiLz4KICAgICAgICA8cmVjdCB4PSIxNyIgeT0iNCIgd2lkdGg9IjIiIGhlaWdodD0iNSIgZmlsbD0id2hpdGUiIHJ4PSIwLjUiLz4KICAgICAgICA8Y2lyY2xlIGN4PSIxOCIgY3k9IjExIiByPSIxIiBmaWxsPSJ3aGl0ZSIvPgogICAgPC9nPgo8L3N2Zz4="); - } - - .cursorQueryGlyph.loading-glyph:after { - height: 22px; - width: 22px; - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPgogIDxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiIC8+CiAgPHBhdGggZD0iTTEyIDJhMSAxIDAgMCAxIDEgMXYzYTEgMSAwIDAgMS0yIDBWM2ExIDEgMCAwIDEgMS0xem0wIDE1YTEgMSAwIDAgMSAxIDF2M2ExIDEgMCAwIDEtMiAwdi0zYTEgMSAwIDAgMSAxLTF6bTguNjYtMTBhMSAxIDAgMCAxLS4zNjYgMS4zNjZsLTIuNTk4IDEuNWExIDEgMCAxIDEtMS0xLjczMmwyLjU5OC0xLjVBMSAxIDAgMCAxIDIwLjY2IDd6TTcuNjcgMTQuNWExIDEgMCAwIDEtLjM2NiAxLjM2NmwtMi41OTggMS41YTEgMSAwIDEgMS0xLTEuNzMybDIuNTk4LTEuNWExIDEgMCAwIDEgMS4zNjYuMzY2ek0yMC42NiAxN2ExIDEgMCAwIDEtMS4zNjYuMzY2bC0yLjU5OC0xLjVhMSAxIDAgMCAxIDEtMS43MzJsMi41OTggMS41QTEgMSAwIDAgMSAyMC42NiAxN3pNNy42NyA5LjVhMSAxIDAgMCAxLTEuMzY2LjM2NmwtMi41OTgtMS41YTEgMSAwIDEgMSAxLTEuNzMybDIuNTk4IDEuNUExIDEgMCAwIDEgNy42NyA5LjV6IiAvPgo8L3N2Zz4="); - animation: loading-glyph-spin 3s linear infinite; + .aiQueryHighlight { + background-color: rgba(241, 250, 140, 0.5); + border-radius: 2px; } - @keyframes loading-glyph-spin { + /* Keyframe animation for glyph widget spinner */ + @keyframes glyph-spin { from { transform: rotate(0); } @@ -168,16 +224,6 @@ const Content = styled(PaneContent)` transform: rotate(360deg); } } - - .cancelQueryGlyph { - &:after { - background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGFyaWEtaGlkZGVuPSJ0cnVlIiBmb2N1c2FibGU9ImZhbHNlIiBmaWxsPSIjZmY1NTU1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJTdHlsZWRJY29uQmFzZS1zYy1lYTl1bGotMCBqQ2hkR0siPjxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiPjwvcGF0aD48cGF0aCBkPSJNNyA3djEwaDEwVjdIN3pNNiA1aDEyYTEgMSAwIDAgMSAxIDF2MTJhMSAxIDAgMCAxLTEgMUg2YTEgMSAwIDAgMS0xLTFWNmExIDEgMCAwIDEgMS0xeiI+PC9wYXRoPjwvc3ZnPgo="); - } - - &:hover:after { - filter: brightness(1.3); - } - } ` const CancelButton = styled(Button)` @@ -197,14 +243,19 @@ const StyledDialogButton = styled(Button)` } ` -const DEFAULT_LINE_CHARS = 5 +const EditorWrapper = styled.div` + flex: 1; + overflow: hidden; + position: relative; +` -const MonacoEditor = ({ - executionRefs, -}: { - executionRefs: React.MutableRefObject -}) => { +const getDefaultLineNumbersMinChars = (canUseAI: boolean) => { + return canUseAI ? 7 : 5 +} + +const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const editorContext = useEditor() + const { executionRefs, cleanupExecutionRefs } = editorContext const { buffers, setTabsDisabled, @@ -218,6 +269,13 @@ const MonacoEditor = ({ isNavigatingFromSearchRef, } = editorContext const { quest } = useContext(QuestContext) + const { canUse: canUseAI, status: aiStatus } = useAIStatus() + const { + handleGlyphClick, + hasConversationForQuery, + shiftQueryKeysForBuffer, + findQueryByConversationId, + } = useAIConversation() const [request, setRequest] = useState() const [editorReady, setEditorReady] = useState(false) const [lastExecutedQuery, setLastExecutedQuery] = useState("") @@ -229,6 +287,9 @@ const MonacoEditor = ({ const scriptConfirmationOpenRef = useRef(false) const dispatch = useDispatch() const running = useSelector(selectors.query.getRunning) + const aiSuggestionRequest = useSelector( + selectors.query.getAISuggestionRequest, + ) const tables = useSelector(selectors.query.getTables) const columns = useSelector(selectors.query.getColumns) const activeNotification = useSelector(selectors.query.getActiveNotification) @@ -259,10 +320,21 @@ const MonacoEditor = ({ const requestRef = useRef(request) const queryNotificationsRef = useRef(queryNotifications) const activeNotificationRef = useRef(activeNotification) + const aiSuggestionRequestRef = useRef<{ + query: string + startOffset: number + } | null>(aiSuggestionRequest) + const canUseAIRef = useRef(canUseAI) + const hasConversationForQueryRef = useRef(hasConversationForQuery) + const shiftQueryKeysForBufferRef = useRef(shiftQueryKeysForBuffer) + const findQueryByConversationIdRef = useRef(findQueryByConversationId) + const isBlockingAIStatusRef = useRef(isBlockingAIStatus(aiStatus) ?? false) const contentJustChangedRef = useRef(false) const cursorChangeTimeoutRef = useRef(null) - const decorationCollectionRef = - useRef(null) + const glyphWidgetsRef = useRef>( + new Map(), + ) + const highlightedLineNumberRef = useRef(null) const visibleLinesRef = useRef<{ startLine: number; endLine: number }>({ startLine: 1, endLine: 1, @@ -277,11 +349,25 @@ const MonacoEditor = ({ const dropdownPositionRef = useRef<{ x: number; y: number } | null>(null) const dropdownQueriesRef = useRef([]) const isContextMenuDropdownRef = useRef(false) + const isAIDropdownRef = useRef(false) const cleanupActionsRef = useRef<(() => void)[]>([]) + const handleBufferContentChange = (value: string | undefined) => { + const lineCount = editorRef.current?.getModel()?.getLineCount() + if (lineCount && lineCount > LINE_NUMBER_HARD_LIMIT) { + if (editorRef.current && currentBufferValueRef.current !== undefined) { + editorRef.current.setValue(currentBufferValueRef.current) + } + toast.error("Maximum line limit reached") + return + } + currentBufferValueRef.current = value + void updateBuffer(activeBuffer.id as number, { value }) + } + // Set the initial line number width in chars based on the number of lines in the active buffer const [lineNumbersMinChars, setLineNumbersMinChars] = useState( - DEFAULT_LINE_CHARS + + (canUseAI ? 7 : 5) + activeBuffer.value.split("\n").length.toString().length - 1, ) @@ -291,6 +377,18 @@ const MonacoEditor = ({ } const updateQueryNotification = (queryKey?: QueryKey) => { + const currentAISuggestion = aiSuggestionRequestRef.current + if (currentAISuggestion && activeNotificationRef.current) { + const aiQueryKey = createQueryKey( + normalizeQueryText(currentAISuggestion.query), + currentAISuggestion.startOffset, + ) + // If current notification is from AI suggestion, preserve it + if (activeNotificationRef.current.query === aiQueryKey) { + return + } + } + let newActiveNotification: NotificationShape | null = null if (queryKey) { @@ -393,54 +491,6 @@ const MonacoEditor = ({ } } - const beforeMount = (monaco: Monaco) => { - registerLanguageAddons(monaco) - - monaco.editor.defineTheme("dracula", dracula) - } - - const handleEditorClick = (e: React.MouseEvent) => { - const editor = editorRef.current - const model = editor?.getModel() - if (!editor || !model) return - - if ( - e.target instanceof Element && - e.target.classList.contains("cancelQueryGlyph") - ) { - toggleRunning(RunningType.NONE) - return - } - - if ( - e.target instanceof Element && - e.target.classList.contains("cursorQueryGlyph") - ) { - if (e.target.classList.contains("loading-glyph")) { - return - } - - editor.focus() - const target = editor.getTargetAtClientPoint(e.clientX, e.clientY) - - if (target && target.position) { - const position = { - lineNumber: target.position.lineNumber, - column: 1, - } - const dropdownQueries = getDropdownQueries(position.lineNumber) - if (dropdownQueries.length > 1) { - dropdownQueriesRef.current = dropdownQueries - openDropdownAtPosition(e.clientX, e.clientY, position, false) - return - } - if (dropdownQueries.length === 1) { - runQueryAction(dropdownQueries[0], RunningType.QUERY) - } - } - } - } - const handleRunQuery = (query: Request) => { setDropdownOpen(false) runQueryAction(query, RunningType.QUERY) @@ -498,6 +548,22 @@ const MonacoEditor = ({ toggleRunning(pending.type) } + const handleAskAI = async (query?: Request) => { + setDropdownOpen(false) + if (!query || !editorRef.current) return + + const queryKey = createQueryKeyFromRequest(editorRef.current, query) + + await handleGlyphClick({ + queryKey, + bufferId: activeBufferRef.current.id!, + }) + } + const handleAskAIRef = useRef(handleAskAI) + useEffect(() => { + handleAskAIRef.current = handleAskAI + }, [handleAskAI]) + const applyLineMarkings = ( monaco: Monaco, editor: editor.IStandaloneCodeEditor, @@ -514,7 +580,8 @@ const MonacoEditor = ({ const activeBufferId = activeBufferRef.current.id as number const lineMarkingDecorations: editor.IModelDeltaDecoration[] = [] - const bufferExecutions = executionRefs.current[activeBufferId] || {} + const bufferExecutions = + executionRefs.current[activeBufferId.toString()] || {} if (queryAtCursor) { const queryKey = createQueryKeyFromRequest(editor, queryAtCursor) @@ -621,10 +688,10 @@ const MonacoEditor = ({ const activeBufferId = activeBufferRef.current.id as number - const allDecorations: editor.IModelDeltaDecoration[] = [] const allQueryOffsets: { startOffset: number; endOffset: number }[] = [] + const newGlyphWidgetIds = new Map() + const newGlyphWidgetLineNumbers = new Set() - // Add decorations for queries in range if (queries.length > 0) { queries.forEach((query) => { const queryOffsets = { @@ -638,7 +705,8 @@ const MonacoEditor = ({ }), } allQueryOffsets.push(queryOffsets) - const bufferExecutions = executionRefs.current[activeBufferId] || {} + const bufferExecutions = + executionRefs.current[activeBufferId.toString()] || {} const queryKey = createQueryKeyFromRequest(editor, query) const queryExecutionBuffer = bufferExecutions[queryKey] const hasError = @@ -650,34 +718,109 @@ const MonacoEditor = ({ // Convert 0-based row to 1-based line number for Monaco const startLineNumber = query.row + 1 - // Add glyph for all queries with line number in class name - const glyphClassName = + const hasConversation = canUseAIRef.current + ? hasConversationForQueryRef.current(activeBufferId, queryKey) + : false + + const isRunningQuery = runningValueRef.current !== RunningType.NONE && requestRef.current?.row !== undefined && requestRef.current?.row + 1 === startLineNumber - ? `cancelQueryGlyph cancelQueryGlyph-line-${startLineNumber}` - : hasError - ? `cursorQueryGlyph error-glyph cursorQueryGlyph-line-${startLineNumber}` - : isSuccessful - ? `cursorQueryGlyph success-glyph cursorQueryGlyph-line-${startLineNumber}` - : `cursorQueryGlyph cursorQueryGlyph-line-${startLineNumber}` - - allDecorations.push({ - range: new monaco.Range(startLineNumber, 1, startLineNumber, 1), - options: { - isWholeLine: false, - glyphMarginClassName: glyphClassName, - }, - }) - }) - } - if (decorationCollectionRef.current) { - decorationCollectionRef.current.clear() + const handleRunClick = () => { + if (isRunningQuery) { + toggleRunning(RunningType.NONE) + } else { + const dropdownQueries = getDropdownQueries(startLineNumber) + if (dropdownQueries.length > 1) { + dropdownQueriesRef.current = dropdownQueries + isAIDropdownRef.current = false + openDropdownAtPosition( + 0, + 0, + { lineNumber: startLineNumber, column: 1 }, + false, + ) + } else if (dropdownQueries.length === 1) { + runQueryAction(dropdownQueries[0], RunningType.QUERY) + } + } + } + + const handleAIClick = () => { + if (isBlockingAIStatusRef.current) return + const dropdownQueries = getDropdownQueries(startLineNumber) + if (dropdownQueries.length > 1) { + dropdownQueriesRef.current = dropdownQueries + isAIDropdownRef.current = true + openDropdownAtPosition( + 0, + 0, + { lineNumber: startLineNumber, column: 1 }, + false, + ) + } else if (dropdownQueries.length === 1) { + void handleAskAIRef.current(dropdownQueries[0]) + } + } + + const handleRunContextMenu = () => { + if (isBlockingAIStatusRef.current) return + const dropdownQueries = getDropdownQueries(startLineNumber) + if (dropdownQueries.length > 0) { + dropdownQueriesRef.current = dropdownQueries + isAIDropdownRef.current = false + openDropdownAtPosition( + 0, + 0, + { lineNumber: startLineNumber, column: 1 }, + true, + ) + } + } + + const isHighlighted = + highlightedLineNumberRef.current === startLineNumber + + const widgetOptions: GlyphWidgetOptions = { + isHighlighted, + isCancel: isRunningQuery, + hasError, + isSuccessful, + showAI: canUseAIRef.current, + hasConversation, + onRunClick: handleRunClick, + onRunContextMenu: handleRunContextMenu, + onAIClick: handleAIClick, + } + + const widgetId = createGlyphWidgetId(startLineNumber, widgetOptions) + const isNewWidget = !glyphWidgetsRef.current.has(widgetId) + + if (isNewWidget && !newGlyphWidgetLineNumbers.has(startLineNumber)) { + const widget = createGlyphWidget(startLineNumber, widgetOptions) + editor.addGlyphMarginWidget(widget) + newGlyphWidgetIds.set(widgetId, widget) + newGlyphWidgetLineNumbers.add(startLineNumber) + } else if (!isNewWidget) { + newGlyphWidgetIds.set( + widgetId, + glyphWidgetsRef.current.get(widgetId)!, + ) + newGlyphWidgetLineNumbers.add(startLineNumber) + } + if (isHighlighted) { + highlightedLineNumberRef.current = null + } + }) } + glyphWidgetsRef.current.forEach((widget, widgetId) => { + if (!newGlyphWidgetIds.has(widgetId)) { + editor.removeGlyphMarginWidget(widget) + } + }) + glyphWidgetsRef.current = newGlyphWidgetIds - decorationCollectionRef.current = - editor.createDecorationsCollection(allDecorations) queryOffsetsRef.current = allQueryOffsets applyLineMarkings(monaco, editor, source) @@ -686,7 +829,6 @@ const MonacoEditor = ({ const onMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { monacoRef.current = monaco editorRef.current = editor - monaco.editor.setTheme("dracula") editor.updateOptions({ find: { addExtraSpaceOnTop: false, @@ -726,42 +868,9 @@ const MonacoEditor = ({ }), ) - editor.onContextMenu((e) => { - if ( - e.target.element && - e.target.element.classList.contains("cursorQueryGlyph") - ) { - const posX = e.event.posx, - posY = e.event.posy - if (editorRef.current) { - const target = editorRef.current.getTargetAtClientPoint(posX, posY) - - if (target && target.position) { - const linePosition = { - lineNumber: target.position.lineNumber, - column: 1, - } - - const dropdownQueries = getDropdownQueries(linePosition.lineNumber) - - if (dropdownQueries.length > 0) { - dropdownQueriesRef.current = dropdownQueries - openDropdownAtPosition(posX, posY, linePosition, true) - } - } - } - } - }) - editor.onDidChangeCursorPosition((e) => { // To ensure the fixed position of the "run query" glyph we adjust the width of the line count element. // This width is represented in char numbers. - const lineCount = editorRef.current?.getModel()?.getLineCount() - if (lineCount) { - setLineNumbersMinChars( - DEFAULT_LINE_CHARS + (lineCount.toString().length - 1), - ) - } if (contentJustChangedRef.current) { return @@ -790,10 +899,18 @@ const MonacoEditor = ({ const model = editor.getModel() if (!model) return + const lineCount = model.getLineCount() + if (lineCount) { + setLineNumbersMinChars( + getDefaultLineNumbersMinChars(canUseAIRef.current) + + (lineCount.toString().length - 1), + ) + } + contentJustChangedRef.current = true const activeBufferId = activeBufferRef.current.id as number - const bufferExecutions = executionRefs.current[activeBufferId] + const bufferExecutions = executionRefs.current[activeBufferId.toString()] const notificationUpdates: Array<() => void> = [] @@ -870,8 +987,21 @@ const MonacoEditor = ({ } const currentNotifications = queryNotificationsRef.current || {} + const currentAISuggestion = aiSuggestionRequestRef.current + const aiSuggestionQueryKey = currentAISuggestion + ? createQueryKey( + normalizeQueryText(currentAISuggestion.query), + currentAISuggestion.startOffset, + ) + : null + Object.keys(currentNotifications).forEach((key) => { const queryKey = key as QueryKey + + if (aiSuggestionQueryKey && queryKey === aiSuggestionQueryKey) { + return + } + const { queryText, startOffset, endOffset } = parseQueryKey(queryKey) const effectiveOffsetDelta = e.changes .filter((change) => change.rangeOffset < endOffset) @@ -907,11 +1037,31 @@ const MonacoEditor = ({ }) if (bufferExecutions && Object.keys(bufferExecutions).length === 0) { - delete executionRefs.current[activeBufferId] + cleanupExecutionRefs(activeBufferId) } - executionRefs.current[activeBufferId] = bufferExecutions + executionRefs.current[activeBufferId.toString()] = bufferExecutions - applyGlyphsAndLineMarkings(monaco, editor) + let shiftedQueryKeys = false + if (e.changes.length > 0) { + const earliestChangeOffset = Math.min( + ...e.changes.map((c) => c.rangeOffset), + ) + const totalDelta = e.changes.reduce( + (acc, c) => acc + c.text.length - c.rangeLength, + 0, + ) + if (totalDelta !== 0) { + shiftedQueryKeys = shiftQueryKeysForBufferRef.current( + activeBufferId, + earliestChangeOffset, + totalDelta, + ) + } + } + + if (!shiftedQueryKeys) { + applyGlyphsAndLineMarkings(monaco, editor) + } const queriesToRun = getQueriesToRun( editor, @@ -925,6 +1075,10 @@ const MonacoEditor = ({ }) editor.onDidChangeModel(() => { + glyphWidgetsRef.current.forEach((widget) => { + editor.removeGlyphMarginWidget(widget) + }) + glyphWidgetsRef.current.clear() setTimeout(() => { if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) @@ -1074,10 +1228,11 @@ const MonacoEditor = ({ }, ) - if (executionRefs.current[activeBufferId]) { - delete executionRefs.current[activeBufferId][queryKey] - if (Object.keys(executionRefs.current[activeBufferId]).length === 0) { - delete executionRefs.current[activeBufferId] + const bufferIdStr = activeBufferId.toString() + if (executionRefs.current[bufferIdStr]) { + delete executionRefs.current[bufferIdStr][queryKey] + if (Object.keys(executionRefs.current[bufferIdStr]).length === 0) { + cleanupExecutionRefs(activeBufferId) } } @@ -1118,12 +1273,13 @@ const MonacoEditor = ({ } if (query.selection) { - if (!executionRefs.current[activeBufferId]) { - executionRefs.current[activeBufferId] = {} + const bufferIdStr = activeBufferId.toString() + if (!executionRefs.current[bufferIdStr]) { + executionRefs.current[bufferIdStr] = {} } const queryStartOffset = getQueryStartOffset(editor, query) - executionRefs.current[activeBufferId][queryKey] = { + executionRefs.current[bufferIdStr][queryKey] = { success: true, selection: query.selection, queryText: query.query, @@ -1144,12 +1300,13 @@ const MonacoEditor = ({ } catch (_error: unknown) { const error = _error as ErrorResult - if (!executionRefs.current[activeBufferId]) { - executionRefs.current[activeBufferId] = {} + const bufferIdStr = activeBufferId.toString() + if (!executionRefs.current[bufferIdStr]) { + executionRefs.current[bufferIdStr] = {} } const startOffset = getQueryStartOffset(editor, query) - executionRefs.current[activeBufferId][queryKey] = { + executionRefs.current[bufferIdStr][queryKey] = { error, queryText: query.query, startOffset, @@ -1253,9 +1410,7 @@ const MonacoEditor = ({ const activeBufferId = activeBuffer.id as number if (runningAllQueries) { dispatch(actions.query.cleanupBufferNotifications(activeBufferId)) - if (executionRefs.current[activeBufferId]) { - delete executionRefs.current[activeBufferId] - } + cleanupExecutionRefs(activeBufferId) } isRunningScriptRef.current = true @@ -1268,31 +1423,18 @@ const MonacoEditor = ({ const startTime = Date.now() for (let i = 0; i < queries.length; i++) { const query = queries[i] + const lineNumber = query.row + 1 editor.revealPositionInCenterIfOutsideViewport({ - lineNumber: query.row + 1, + lineNumber, column: query.column, }) - const queryGlyph = editor - .getLineDecorations(query.row + 1) - ?.find((d) => - d.options.glyphMarginClassName?.includes("cursorQueryGlyph"), - ) - let delta = null - if (queryGlyph) { - delta = editor.createDecorationsCollection([ - { - range: new monaco.Range(query.row + 1, 1, query.row + 1, 1), - options: { - isWholeLine: false, - glyphMarginClassName: "cursorQueryGlyph loading-glyph", - }, - }, - ]) - } + + toggleGlyphWidgetLoading(lineNumber, true) + const result = await runIndividualQuery(query, i === queries.length - 1) - if (delta) { - delta.clear() - } + + toggleGlyphWidgetLoading(lineNumber, false) + individualQueryResults.push(result) if (result.success) { successfulQueries++ @@ -1410,17 +1552,36 @@ const MonacoEditor = ({ useEffect(() => { // Remove all execution information for the buffers that have been deleted - Object.keys(executionRefs.current).map((key) => { - if (!buffers.find((b) => b.id === parseInt(key))) { - delete executionRefs.current[key] + Object.keys(executionRefs.current).forEach((key) => { + const bufferId = parseInt(key) + if (!buffers.find((b) => b.id === bufferId)) { + cleanupExecutionRefs(bufferId) } }) - }, [buffers]) + }, [buffers, executionRefs, cleanupExecutionRefs]) + + useEffect(() => { + canUseAIRef.current = canUseAI + const lineCount = editorRef.current?.getModel()?.getLineCount() + if (lineCount) { + setLineNumbersMinChars( + getDefaultLineNumbersMinChars(canUseAIRef.current) + + (lineCount.toString().length - 1), + ) + } + if (monacoRef.current && editorRef.current) { + applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + } + }, [canUseAI]) useEffect(() => { activeNotificationRef.current = activeNotification }, [activeNotification]) + useEffect(() => { + aiSuggestionRequestRef.current = aiSuggestionRequest + }, [aiSuggestionRequest]) + useEffect(() => { abortConfirmationOpenRef.current = abortConfirmationOpen }, [abortConfirmationOpen]) @@ -1466,17 +1627,32 @@ const MonacoEditor = ({ const request = running === RunningType.REFRESH ? getQueryRequestFromLastExecutedQuery(lastExecutedQuery) - : getQueryRequestFromEditor(editorRef.current) + : running === RunningType.AI_SUGGESTION && + aiSuggestionRequestRef.current + ? getQueryRequestFromAISuggestion( + editorRef.current, + aiSuggestionRequestRef.current, + ) + : getQueryRequestFromEditor(editorRef.current) const isRunningExplain = running === RunningType.EXPLAIN + const isAISuggestion = + running === RunningType.AI_SUGGESTION && + aiSuggestionRequestRef.current !== null + + const targetBufferId = activeBufferRef.current.id as number if (request?.query) { editorRef.current?.updateOptions({ readOnly: true }) const parentQuery = request.query - const parentQueryKey = createQueryKeyFromRequest( - editorRef.current, - request, - ) + // For AI_SUGGESTION, use the startOffset directly from aiSuggestionRequestRef + // because the editor model doesn't contain the AI suggestion query + const parentQueryKey = isAISuggestion + ? createQueryKey( + request.query, + aiSuggestionRequestRef.current!.startOffset, + ) + : createQueryKeyFromRequest(editorRef.current, request) const originalQueryText = request.selection ? request.selection.queryText : request.query @@ -1511,7 +1687,7 @@ const MonacoEditor = ({ ), sideContent: , }, - activeBuffer.id as number, + targetBufferId, ), ) } @@ -1532,29 +1708,30 @@ const MonacoEditor = ({ setRequest(undefined) if (!editorRef.current) return - const activeBufferId = activeBuffer.id as number - - if (executionRefs.current[activeBufferId] && editorRef.current) { - delete executionRefs.current[activeBufferId][parentQueryKey] + const targetBufferIdStr = targetBufferId.toString() + if (executionRefs.current[targetBufferIdStr] && editorRef.current) { + delete executionRefs.current[targetBufferIdStr][parentQueryKey] if ( - Object.keys(executionRefs.current[activeBufferId]).length === 0 + Object.keys(executionRefs.current[targetBufferIdStr]).length === + 0 ) { - delete executionRefs.current[activeBufferId] + cleanupExecutionRefs(targetBufferId) } } if (request.selection) { const model = editorRef.current.getModel() if (model) { - if (!executionRefs.current[activeBufferId]) { - executionRefs.current[activeBufferId] = {} + const targetBufferIdStr = targetBufferId.toString() + if (!executionRefs.current[targetBufferIdStr]) { + executionRefs.current[targetBufferIdStr] = {} } - const queryStartOffset = getQueryStartOffset( - editorRef.current, - request, - ) - executionRefs.current[activeBufferId][parentQueryKey] = { + // For AI_SUGGESTION, use the startOffset from aiSuggestionRequestRef + const queryStartOffset = isAISuggestion + ? aiSuggestionRequestRef.current!.startOffset + : getQueryStartOffset(editorRef.current, request) + executionRefs.current[targetBufferIdStr][parentQueryKey] = { success: true, selection: request.selection, queryText: parentQuery, @@ -1579,7 +1756,7 @@ const MonacoEditor = ({ isExplain: isRunningExplain, content: , }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_SCHEMA) @@ -1602,7 +1779,7 @@ const MonacoEditor = ({ sideContent: , type: NotificationType.NOTICE, }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_SCHEMA) @@ -1624,7 +1801,7 @@ const MonacoEditor = ({ ), sideContent: , }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_DATASET, result) @@ -1661,20 +1838,18 @@ const MonacoEditor = ({ const errorToStore = { ...error, position: adjustedErrorPosition } - const parentQueryKey = createQueryKeyFromRequest( - editorRef.current, - request, - ) - const activeBufferId = activeBuffer.id as number - if (!executionRefs.current[activeBufferId]) { - executionRefs.current[activeBufferId] = {} + // Use the already-defined parentQueryKey (which correctly handles AI_SUGGESTION) + // instead of recalculating it here + const targetBufferIdStr = targetBufferId.toString() + if (!executionRefs.current[targetBufferIdStr]) { + executionRefs.current[targetBufferIdStr] = {} } - const startOffset = getQueryStartOffset( - editorRef.current, - request, - ) - executionRefs.current[activeBufferId][parentQueryKey] = { + // For AI_SUGGESTION, use the startOffset from aiSuggestionRequestRef + const startOffset = isAISuggestion + ? aiSuggestionRequestRef.current!.startOffset + : getQueryStartOffset(editorRef.current, request) + executionRefs.current[targetBufferIdStr][parentQueryKey] = { error: errorToStore, selection: request.selection, queryText: parentQuery, @@ -1707,7 +1882,7 @@ const MonacoEditor = ({ sideContent: , type: NotificationType.ERROR, }, - activeBuffer.id as number, + targetBufferId, ), ) } @@ -1771,6 +1946,31 @@ const MonacoEditor = ({ } }, [activeBuffer]) + useEffect(() => { + findQueryByConversationIdRef.current = findQueryByConversationId + shiftQueryKeysForBufferRef.current = shiftQueryKeysForBuffer + }, [findQueryByConversationId, shiftQueryKeysForBuffer]) + + useEffect(() => { + hasConversationForQueryRef.current = hasConversationForQuery + if (monacoRef.current && editorRef.current) { + applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + } + }, [hasConversationForQuery]) + + useEffect(() => { + const oldIsBlocking = isBlockingAIStatusRef.current + const newIsBlocking = isBlockingAIStatus(aiStatus) ?? false + isBlockingAIStatusRef.current = newIsBlocking + if ( + monacoRef.current && + editorRef.current && + newIsBlocking !== oldIsBlocking + ) { + applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + } + }, [aiStatus]) + useEffect(() => { window.addEventListener("focus", setCompletionProvider) return () => { @@ -1778,6 +1978,30 @@ const MonacoEditor = ({ } }, [setCompletionProvider]) + useEffect(() => { + const handler = (conversationId: unknown) => { + if (!editorRef.current) return + const model = editorRef.current.getModel() + if (!model) return + + const queryInfo = findQueryByConversationIdRef.current( + conversationId as ConversationId, + ) + if (!queryInfo) return + const { queryKey, bufferId } = queryInfo + if (activeBufferRef.current.id !== bufferId) return + const startOffset = parseQueryKey(queryKey).startOffset + const lineNumber = model.getPositionAt(startOffset).lineNumber + highlightedLineNumberRef.current = lineNumber + } + + eventBus.subscribe(EventType.AI_QUERY_HIGHLIGHT, handler) + + return () => { + eventBus.unsubscribe(EventType.AI_QUERY_HIGHLIGHT, handler) + } + }, []) + useEffect(() => { return () => { cleanupActionsRef.current.forEach((cleanup) => cleanup()) @@ -1793,9 +2017,11 @@ const MonacoEditor = ({ window.clearTimeout(notificationTimeoutRef.current) } - if (decorationCollectionRef.current) { - decorationCollectionRef.current.clear() - } + glyphWidgetsRef.current.forEach((widget) => { + editorRef.current?.removeGlyphMarginWidget(widget) + }) + glyphWidgetsRef.current.clear() + editorRef.current?.getModel()?.dispose() editorRef.current?.dispose() editorRef.current = null @@ -1805,51 +2031,40 @@ const MonacoEditor = ({ return ( <> - - - { - const lineCount = editorRef.current?.getModel()?.getLineCount() - if (lineCount && lineCount > LINE_NUMBER_HARD_LIMIT) { - if ( - editorRef.current && - currentBufferValueRef.current !== undefined - ) { - editorRef.current.setValue(currentBufferValueRef.current) - } - toast.error("Maximum line limit reached") - return - } - currentBufferValueRef.current = value - void updateBuffer(activeBuffer.id as number, { value }) - }} - options={{ - // initially null, but will be set during onMount with editor.setModel - model: null, - fixedOverflowWidgets: true, - fontSize: 14, - lineHeight: 24, - fontFamily: theme.fontMonospace, - glyphMargin: true, - renderLineHighlight: "gutter", - useShadowDOM: false, - minimap: { - enabled: false, - }, - selectOnLineNumbers: false, - scrollBeyondLastLine: false, - tabSize: 2, - lineNumbersMinChars, - }} - theme="vs-dark" - /> + + {!hidden && ( + + )} + + + @@ -1861,13 +2076,16 @@ const MonacoEditor = ({ dropdownPositionRef.current = null dropdownQueriesRef.current = [] isContextMenuDropdownRef.current = false + isAIDropdownRef.current = false } }} positionRef={dropdownPositionRef} queriesRef={dropdownQueriesRef} isContextMenuRef={isContextMenuDropdownRef} + isAIDropdownRef={isAIDropdownRef} onRunQuery={handleRunQuery} onExplainQuery={handleExplainQuery} + onAskAIRef={handleAskAIRef} /> { if (buffer.metricsViewState) { return "assets/icon-chart.svg" } + if (buffer.isDiffBuffer) { + return "assets/icon-compare.svg" + } return "assets/icon-file.svg" } @@ -120,6 +123,12 @@ export const Tabs = () => { return } + if (buffer.isDiffBuffer) { + await deleteBuffer(parseInt(id), true) + await repositionActiveBuffers(id) + return + } + if (buffer.isTemporary) { await updateBuffer(parseInt(id), { isTemporary: false }, true) return @@ -226,6 +235,9 @@ export const Tabs = () => { if (buffer.isTemporary) { classNames.push("temporary-tab") } + if (buffer.isDiffBuffer) { + classNames.push("diff-tab") + } const className = classNames.length > 0 ? classNames.join(" ") : undefined diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index 7c5b1b220..0a8c8c270 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -24,6 +24,7 @@ import type { editor, IPosition, IRange } from "monaco-editor" import type { Monaco } from "@monaco-editor/react" import type { ErrorResult } from "../../../utils" +import { hashString } from "../../../utils" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor @@ -682,6 +683,33 @@ 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 }, +): Request | undefined => { + 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, + endColumn: endRow === 1 ? position.column + endColumn - 1 : endColumn, + } +} + export const getErrorRange = ( editor: IStandaloneCodeEditor, request: Request, @@ -1099,6 +1127,23 @@ export const parseQueryKey = ( } } +export const getQueryInfoFromKey = ( + queryKey?: QueryKey, +): { queryText: string; startOffset: number; endOffset: number } => { + if (!queryKey) return { queryText: "", startOffset: 0, endOffset: 0 } + return parseQueryKey(queryKey) +} + +export const shiftQueryKey = ( + queryKey: QueryKey, + changeOffset: number, + delta: number, +): QueryKey => { + const { queryText, startOffset } = parseQueryKey(queryKey) + const newStartOffset = shiftOffset(startOffset, changeOffset, delta) + return createQueryKey(queryText, newStartOffset) +} + export const shiftOffset = ( offset: number, changeOffset: number, @@ -1199,3 +1244,13 @@ export const setErrorMarkerForQuery = ( monaco.editor.setModelMarkers(model, QuestDBLanguageName, markers) } + +// Creates a QueryKey for schema explanation conversations +// Uses DDL hash so same schema = same queryKey = cached conversation +export const createSchemaQueryKey = ( + tableName: string, + ddl: string, +): QueryKey => { + const ddlHash = hashString(ddl) + return `schema:${tableName}:${ddlHash}@0-0` as QueryKey +} diff --git a/src/scenes/Editor/index.tsx b/src/scenes/Editor/index.tsx index f089a17b7..38b705768 100644 --- a/src/scenes/Editor/index.tsx +++ b/src/scenes/Editor/index.tsx @@ -22,20 +22,33 @@ * ******************************************************************************/ -import React, { CSSProperties, forwardRef, Ref, useEffect, useRef } from "react" +import React, { + CSSProperties, + forwardRef, + Ref, + useEffect, + useMemo, + useCallback, +} from "react" import styled from "styled-components" +import { DiffEditor } from "@monaco-editor/react" -import { PaneWrapper } from "../../components" +import { PaneWrapper, Box, Button, Key } from "../../components" +import { useKeyPress } from "../../hooks" import Monaco from "./Monaco" import { Tabs } from "./Monaco/tabs" import { useEditor } from "../../providers/EditorProvider" +import { useAIConversation } from "../../providers/AIConversationProvider" import { Metrics } from "./Metrics" import Notifications from "../../scenes/Notifications" import type { QueryKey } from "../../store/Query/types" import type { ErrorResult } from "../../utils" +import { color, platform } from "../../utils" +import { getLastUnactionedDiff } from "../../providers/AIConversationProvider/utils" import { useDispatch } from "react-redux" import { actions } from "../../store" +import { QuestDBLanguageName, normalizeQueryText } from "./Monaco/utils" type Props = Readonly<{ style?: CSSProperties @@ -69,19 +82,111 @@ export type ExecutionRefs = Record< const EditorPaneWrapper = styled(PaneWrapper)` height: 100%; overflow: hidden; + display: flex; + flex-direction: row; + + & > div { + height: 100%; + width: 100%; + } +` + +const EditorLeftPane = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; ` +const EditorContent = styled.div` + flex: 1; + display: flex; + overflow: hidden; + min-height: 0; +` + +const EditorPane = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +` + +const DiffViewWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + background: #2c2e3d; +` + +const DiffEditorContainer = styled.div` + flex: 1; + overflow: hidden; +` + +const ButtonBar = styled(Box)` + padding: 0.8rem; + gap: 1rem; + justify-content: center; + flex-shrink: 0; + width: fit-content; + margin: 1rem auto; + background: ${color("backgroundDarker")}; + border: 1px solid ${color("selection")}; + border-radius: 0.4rem; +` + +const KeyContainer = styled(Box).attrs({ alignItems: "center", gap: "0.3rem" })` + margin-left: 1rem; +` + +const RejectButton = styled(Button)` + background: ${color("background")}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + flex: 1; + &:hover:not(:disabled) { + background: ${color("selection")}; + border-color: ${({ theme }) => theme.color.pinkDarker}; + } + width: 13.5rem; +` + +const AcceptButton = styled(Button)` + background: ${({ theme }) => theme.color.pinkDarker}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + flex: 1; + &:hover:not(:disabled) { + background: ${({ theme }) => theme.color.pink}; + border-color: ${({ theme }) => theme.color.pink}; + filter: brightness(1.1); + } + width: 13.5rem; +` + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" + const Editor = ({ innerRef, ...rest }: Props & { innerRef: Ref }) => { const dispatch = useDispatch() - const { activeBuffer, addBuffer } = useEditor() - const executionRefs = useRef({}) + const { activeBuffer, addBuffer, cleanupExecutionRefs } = useEditor() + const { + getConversationMeta, + activeConversationMessages, + acceptSuggestion, + rejectSuggestion, + } = useAIConversation() const handleClearNotifications = (bufferId: number) => { dispatch(actions.query.cleanupBufferNotifications(bufferId)) - delete executionRefs.current[bufferId] + cleanupExecutionRefs(bufferId) } useEffect(() => { @@ -92,14 +197,184 @@ const Editor = ({ } }, []) + const isMonacoHidden = + !!activeBuffer.isDiffBuffer || !!activeBuffer.metricsViewState + + const pendingDiffInfo = useMemo(() => { + if ( + !activeBuffer.isDiffBuffer || + !activeBuffer.diffContent?.conversationId + ) { + return null + } + + const conversationId = activeBuffer.diffContent.conversationId + const meta = getConversationMeta(conversationId) + + if (!meta) { + return null + } + + const lastUnactionedDiff = getLastUnactionedDiff(activeConversationMessages) + if (!lastUnactionedDiff) { + return null + } + + const normalizedDiffModified = normalizeQueryText( + activeBuffer.diffContent.modified || "", + ) + const normalizedCurrentSQL = normalizeQueryText(meta.currentSQL || "") + + if (normalizedDiffModified !== normalizedCurrentSQL) { + return null + } + + return { + conversationId, + messageId: lastUnactionedDiff.id, + } + }, [activeBuffer, getConversationMeta, activeConversationMessages]) + + // Handle accept button click from diff editor button bar + const handleAcceptFromDiffEditor = useCallback(async () => { + if (!pendingDiffInfo || !activeBuffer.diffContent) return + + const { conversationId } = pendingDiffInfo + + // Use unified acceptSuggestion from provider + await acceptSuggestion({ + conversationId, + messageId: pendingDiffInfo.messageId, + }) + }, [pendingDiffInfo, activeBuffer.diffContent, acceptSuggestion]) + + // Handle reject button click from diff editor button bar + const handleRejectFromDiffEditor = useCallback(async () => { + if (!pendingDiffInfo) return + + const { conversationId, messageId } = pendingDiffInfo + + // Use unified rejectSuggestion from provider + await rejectSuggestion(conversationId, messageId) + }, [pendingDiffInfo, rejectSuggestion]) + + // Keyboard shortcut: Escape to reject diff + const escPressed = useKeyPress("Escape") + useEffect(() => { + if (escPressed && pendingDiffInfo) { + void handleRejectFromDiffEditor() + } + }, [escPressed, pendingDiffInfo, handleRejectFromDiffEditor]) + + // Keyboard shortcut: Ctrl/Cmd+Enter to accept diff + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && pendingDiffInfo) { + e.preventDefault() + void handleAcceptFromDiffEditor() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [pendingDiffInfo, handleAcceptFromDiffEditor]) + return ( - - {activeBuffer.editorViewState && } - {activeBuffer.metricsViewState && } - {activeBuffer.editorViewState && ( - - )} + + + + + {activeBuffer.editorViewState && + + ) } diff --git a/src/scenes/Editor/utils.ts b/src/scenes/Editor/utils.ts new file mode 100644 index 000000000..ad1909c40 --- /dev/null +++ b/src/scenes/Editor/utils.ts @@ -0,0 +1,74 @@ +import type { MutableRefObject } from "react" +import type { editor } from "monaco-editor" +import type { ExecutionRefs } from "./index" +import { parseQueryKey, type QueryKey } from "./Monaco/utils" + +type IStandaloneCodeEditor = editor.IStandaloneCodeEditor + +export const extractErrorByQueryKey = ( + queryKey: QueryKey, + bufferId: string | number, + executionRefs: MutableRefObject | undefined, + editorRef: MutableRefObject, +): { + errorMessage: string + fixStart: number + fixEnd: number + queryText: string + word: string | null +} | null => { + if (!executionRefs?.current || !editorRef.current) { + return null + } + const model = editorRef.current.getModel() + if (!model) { + return null + } + + const bufferExecutions = executionRefs.current[bufferId.toString()] + if (!bufferExecutions) { + return null + } + + const execution = bufferExecutions[queryKey] + + if (!execution || !execution.error) { + return null + } + + const fixStart = execution.selection + ? execution.selection.startOffset + : execution.startOffset + + const fixEnd = execution.selection + ? execution.selection.endOffset + : execution.endOffset + + const startPosition = model.getPositionAt(fixStart) + const errorWordPosition = model.getPositionAt( + fixStart + execution.error.position, + ) + const errorWord = model.getWordAtPosition(errorWordPosition) + const endPosition = model.getPositionAt(fixEnd) + + const queryText = execution.selection + ? model.getValueInRange({ + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + }) + : (() => { + // Fallback: parse queryKey to get query text + const parsed = parseQueryKey(queryKey) + return parsed.queryText + })() + + return { + errorMessage: execution.error.error || "Query execution failed", + word: errorWord ? errorWord.word : null, + fixStart, + fixEnd, + queryText, + } +} diff --git a/src/scenes/Layout/AIChatButton.tsx b/src/scenes/Layout/AIChatButton.tsx new file mode 100644 index 000000000..9bc7abc66 --- /dev/null +++ b/src/scenes/Layout/AIChatButton.tsx @@ -0,0 +1,53 @@ +import React from "react" +import styled from "styled-components" +import { PrimaryToggleButton, IconWithTooltip, Box } from "../../components" +import { AISparkle } from "../../components/AISparkle" +import { useAIConversation } from "../../providers/AIConversationProvider" +import { useAIStatus } from "../../providers/AIStatusProvider" +import { useSelector } from "react-redux" +import { selectors } from "../../store" + +const ChatButton = styled(PrimaryToggleButton)` + padding: 0; +` + +const TooltipWrapper = styled(Box).attrs({ justifyContent: "center" })` + width: 100%; + height: 100%; +` + +export const AIChatButton = () => { + const { openOrCreateBlankChatWindow, closeChatWindow } = useAIConversation() + const { canUse } = useAIStatus() + const activeSidebar = useSelector(selectors.console.getActiveSidebar) + + if (!canUse) { + return null + } + + const handleClick = () => { + if (activeSidebar === "aiChat") { + closeChatWindow() + } else { + void openOrCreateBlankChatWindow() + } + } + + return ( + + + + + } + placement="left" + tooltip="AI Assistant" + /> + + ) +} diff --git a/src/scenes/Layout/index.tsx b/src/scenes/Layout/index.tsx index df44608f7..e7d98b55d 100644 --- a/src/scenes/Layout/index.tsx +++ b/src/scenes/Layout/index.tsx @@ -29,19 +29,23 @@ import Console from "../Console" import SideMenu from "../SideMenu" import { Sidebar } from "../../components/Sidebar" import { TopBar } from "../../components/TopBar" -import { useSelector } from "react-redux" -import { selectors } from "../../store" import News from "../../scenes/News" import { CreateTableDialog } from "../../components/CreateTableDialog" -import { EditorProvider, SearchProvider } from "../../providers" +import { + EditorProvider, + SearchProvider, + AIConversationProvider, +} from "../../providers" import { Help } from "./help" import { Warnings } from "./warning" import { ImageZoom } from "../News/image-zoom" +import { AIChatButton } from "./AIChatButton" import "allotment/dist/style.css" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" +import { AIStatusProvider } from "../../providers/AIStatusProvider" const Page = styled.div` display: flex; @@ -65,21 +69,14 @@ const Root = styled.div` overflow-y: auto; ` -const Main = styled.div<{ sideOpened: boolean }>` +const Main = styled.div` position: relative; flex: 1; display: flex; - width: ${({ sideOpened }) => - sideOpened ? "calc(100% - 50rem - 4.5rem)" : "calc(100% - 4.5rem)"}; -` - -const Drawer = styled.div` - background: ${({ theme }) => theme.color.backgroundDarker}; + width: calc(100% - 4.5rem); ` const Layout = () => { - const activeSidebar = useSelector(selectors.console.getActiveSidebar) - const focusListener = useCallback(() => { eventBus.publish(EventType.TAB_FOCUS) }, []) @@ -101,30 +98,31 @@ const Layout = () => { return ( - - - -
- - - - -
- - - - - - - - - - -
- - - -