diff --git a/e2e/questdb b/e2e/questdb index 2c48e19c9..2f7420988 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 2c48e19c9e963ed970e24b1e4c0a39f4cbbfd8cd +Subproject commit 2f7420988687dfd9c294ec102f0df418364a2802 diff --git a/package.json b/package.json index 7486cfdae..923cfcc24 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", "@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", @@ -76,6 +78,7 @@ "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", @@ -92,6 +95,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", 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/index.tsx b/src/components/AIStatusIndicator/index.tsx new file mode 100644 index 000000000..fdf365f16 --- /dev/null +++ b/src/components/AIStatusIndicator/index.tsx @@ -0,0 +1,901 @@ +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 { FileText, Table } from "@styled-icons/remix-line" +import { SidebarSimpleIcon, XIcon } from "@phosphor-icons/react" +import { ChevronDown, ChevronRight } from "@styled-icons/boxicons-solid" +import { + useAIStatus, + AIOperationStatus, + StatusArgs, + isBlockingAIStatus, +} from "../../providers/AIStatusProvider" +import { color } from "../../utils" +import { slideAnimation, spinAnimation } from "../Animation" +import { BrainIcon } from "../SetupAIAssistant/BrainIcon" +import { AISparkle } from "../AISparkle" +import { pinkLinearGradientHorizontal } from "../../theme" +import { MODEL_OPTIONS } from "../../utils/aiAssistantSettings" +import { useAIConversation } from "../../providers/AIConversationProvider" +import { Button } from "../../components/Button" + +const CircleNotch = (props: React.SVGProps) => ( + + + + + + + + + +) + +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 SpinnerIcon = styled(CircleNotch)` + width: 2.4rem; + height: 2.4rem; + ${spinAnimation}; + flex-shrink: 0; + transform-origin: center; +` + +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 AssistantModes = 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); +` + +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; +` + +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, + ].includes(section.type) +} + +export const AIStatusIndicator: React.FC = () => { + const { + status, + currentOperation, + currentModel, + abortOperation, + activeConversationId, + clearOperation, + } = useAIStatus() + const { chatWindowState, openChatWindow } = useAIConversation() + const [expanded, setExpanded] = useState(true) + const [collapsedSections, setCollapsedSections] = useState< + Record + >({}) + const [isClosed, setIsClosed] = useState(false) + const isCompleted = status === null && currentOperation.length > 0 + const isAborted = status === AIOperationStatus.Aborted + const assistantModesRef = useRef(null) + const isChatWindowOpen = chatWindowState.isOpen + const statusRef = useRef(null) + const hasExtendedThinking = useMemo(() => { + return MODEL_OPTIONS.find((model) => model.value === currentModel)?.isSlow + }, [currentModel]) + + const operationSections = useMemo(() => { + const sections: OperationSection[] = [] + let currentSection: OperationSection | null = null + + for (const op of currentOperation) { + 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 (lastSection && lastSection.type === status) { + lastSection.active = true + } + if (lastSection && lastSection.type === AIOperationStatus.Aborted) { + lastSection.active = false + } + + return sections + }, [currentOperation, status]) + + const handleToggleExpand = () => { + setExpanded(!expanded) + if (!expanded) { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + } + + const handleToggleSection = (sectionId: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })) + } + + const handleClose = () => { + if (isCompleted) { + clearOperation() + } + setIsClosed(true) + } + + useEffect(() => { + if (expanded) { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + }, [operationSections, expanded]) + + useEffect(() => { + if (statusRef.current === null && status !== null) { + setIsClosed(false) + } + if (status === null && chatWindowState.isOpen) { + clearOperation() + } + statusRef.current = status + }, [status, chatWindowState.isOpen, clearOperation]) + + if ( + !currentOperation || + currentOperation.length === 0 || + isClosed || + isChatWindowOpen + ) { + return null + } + + return ( + + + {operationSections.length > 1 && } + + {operationSections.map((section, index) => ( + + + {section.active ? ( + + ) : section.abort ? ( + + ) : ( + + )} + + {section.type} + + + + ))} + + +
+ + + + {isAborted ? "Cancelled" : isCompleted ? "Completed" : "Working..."} + + {isBlockingAIStatus(status) && ( + + + + )} + {activeConversationId && ( + openChatWindow(activeConversationId)} + > + View chat + + + )} + + + {expanded ? ( + + ) : ( + + )} + + {!isAborted && ( + + + + )} +
+ + {hasExtendedThinking && ( + + + + + + Extended thinking model enabled. Responses may be slow. + + + )} + + {expanded && ( + + {operationSections.map((section) => { + const isExpandable = getIsExpandableSection(section) + const isExpanded = + collapsedSections[section.id] !== true && 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/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/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/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/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx new file mode 100644 index 000000000..0a3edd280 --- /dev/null +++ b/src/components/ExplainQueryButton/index.tsx @@ -0,0 +1,161 @@ +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 { toast } from "../Toast" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { useAIStatus } from "../../providers/AIStatusProvider" +import { useAIConversation } from "../../providers/AIConversationProvider" +import type { ConversationId } from "../../providers/AIConversationProvider/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, addMessageAndUpdateSQL, updateConversationName } = + 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(conversationId, { + role: "user", + content: fullApiMessage, + timestamp: Date.now(), + displayType: "explain_request", + displaySQL: queryText, + }) + + 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) { + updateConversationName(conversationId, title) + } + }) + } + + const response = await continueConversation({ + userMessage: fullApiMessage, + conversationHistory: [], + currentSQL: queryText, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + operation: "explain", + conversationId, + }) + + if (isAiAssistantError(response)) { + const error = response + if (error.type !== "aborted") { + toast.error(error.message, { autoClose: 10000 }) + } + return + } + + const result = response + if (!result.explanation) { + toast.error("No explanation received from AI Assistant", { + autoClose: 10000, + }) + return + } + + const assistantContent = result.explanation + + addMessageAndUpdateSQL(conversationId, { + role: "assistant", + content: assistantContent, + timestamp: Date.now(), + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + })() + } + + 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..1f6299ec7 --- /dev/null +++ b/src/components/FixQueryButton/index.tsx @@ -0,0 +1,159 @@ +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 { toast } from "../Toast" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { useAIStatus } from "../../providers/AIStatusProvider" +import { useAIConversation } from "../../providers/AIConversationProvider" +import { extractErrorByQueryKey } from "../../scenes/Editor/utils" +import type { ExecutionRefs } from "../../scenes/Editor/index" + +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, + getConversation, + addMessage, + addMessageAndUpdateSQL, + updateConversationName, + } = useAIConversation() + + const handleFixQuery = async () => { + const conversationId = chatWindowState.activeConversationId! + const conversation = getConversation(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(conversation.id, { + role: "user", + content: fullApiMessage, + timestamp: Date.now(), + displayType: "fix_request", + displaySQL: queryText, + }) + + 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) { + updateConversationName(conversation.id, title) + } + }) + } + + const response = await continueConversation({ + userMessage: fullApiMessage, + conversationHistory: [], + currentSQL: queryText, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + operation: "fix", + conversationId: conversation.id, + }) + + if (isAiAssistantError(response)) { + const error = response + if (error.type !== "aborted") { + toast.error(error.message, { autoClose: 10000 }) + } + return + } + + const result = response as GeneratedSQL + + if (!result.sql && result.explanation) { + addMessageAndUpdateSQL(conversation.id, { + role: "assistant", + content: result.explanation, + timestamp: Date.now(), + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + return + } + + if (!result.sql) { + toast.error("No fixed query or explanation received from AI Assistant", { + autoClose: 10000, + }) + 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\`\`\`` + + addMessageAndUpdateSQL(conversation.id, { + role: "assistant", + content: assistantContent, + timestamp: Date.now(), + sql: result.sql, + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + } + + return ( + + + Fix query + + ) +} 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..8f1d216b0 --- /dev/null +++ b/src/components/LiteEditor/index.tsx @@ -0,0 +1,255 @@ +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; + } + + .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; +` + +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: 0.8rem; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1.2rem; + z-index: 10; +` + +const CopyButton = styled(Button)` + position: absolute; + top: 0.8rem; + right: 0.8rem; + color: #e5e7eb; + 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 = ({ + 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 + + + )} + + + { + 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 && } + + + + + ) +} diff --git a/src/components/MultiStepModal/index.tsx b/src/components/MultiStepModal/index.tsx new file mode 100644 index 000000000..dd8c59448 --- /dev/null +++ b/src/components/MultiStepModal/index.tsx @@ -0,0 +1,371 @@ +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/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..7badf8c1d --- /dev/null +++ b/src/components/SetupAIAssistant/AIAssistantPromo.tsx @@ -0,0 +1,433 @@ +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..2e4855c49 --- /dev/null +++ b/src/components/SetupAIAssistant/ConfigurationModal.tsx @@ -0,0 +1,910 @@ +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" + > + + OpenAI + + onProviderSelect("anthropic")} + type="button" + > + + Anthropic + + + + + + + Coming soon... + + + + + + + + API Key + onApiKeyChange(e.target.value)} + placeholder={`Enter${providerName ? ` ${providerName}` : ""} API key`} + $hasError={!!error} + disabled={!selectedProvider} + /> + {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)} + /> + + ) + })} + + + + ) : ( + + Please configure at least one provider in step 1 before enabling + models. + + )} + + + + {currentProvider && ( + + + Schema Access + + + + + onSchemaAccessChange(e.target.checked)} + /> + + + + 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..f8c2a20a7 --- /dev/null +++ b/src/components/SetupAIAssistant/ModelDropdown.tsx @@ -0,0 +1,272 @@ +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..39a96e450 --- /dev/null +++ b/src/components/SetupAIAssistant/SettingsModal.tsx @@ -0,0 +1,1055 @@ +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)} + > + + {renderProviderIcon(provider, isActive)} + + {getProviderName(provider)} + + + + + + {validatedApiKeys[provider] ? "Enabled" : "Inactive"} + + + + ) + })} + + + + + + + API Key + {validatedApiKeys[selectedProvider] && ( + }> + 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", + }} + /> + {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) + } + > + {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} + /> + + + + 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..21509a1eb --- /dev/null +++ b/src/components/SetupAIAssistant/index.tsx @@ -0,0 +1,77 @@ +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 { + // First click: show promo + 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/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/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index bde352848..cf61d493f 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 89e3c0337..c48b56d4d 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 React from "react" import ReactDOM from "react-dom" diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index 31325a18f..c48bac759 100644 --- a/src/modules/EventBus/types.ts +++ b/src/modules/EventBus/types.ts @@ -21,4 +21,5 @@ export enum EventType { TAB_BLUR = "tab.blur", METRICS_REFRESH_DATA = "metrics.refresh.data", BUFFERS_UPDATED = "buffers.updated", + EXPLAIN_QUERY_EXEC = "ai.explain.query.exec", } diff --git a/src/providers/AIConversationProvider/index.tsx b/src/providers/AIConversationProvider/index.tsx new file mode 100644 index 000000000..150521715 --- /dev/null +++ b/src/providers/AIConversationProvider/index.tsx @@ -0,0 +1,891 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, +} from "react" +import type { + AIConversation, + ConversationMessage, + ChatWindowState, + ConversationId, +} 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 { buildIndices, createQueryLookupKey } from "./indices" + +export type AcceptSuggestionParams = { + conversationId: ConversationId + sql: string + messageIndex?: number // Index of the specific message to mark as accepted + skipHiddenMessage?: boolean +} + +type AIConversationContextType = { + // Storage + conversations: Map + chatWindowState: ChatWindowState + + // Lookup functions + getConversation: (id: ConversationId) => AIConversation | undefined + findConversationByQuery: ( + bufferId: string | number, + queryKey: QueryKey, + ) => AIConversation | undefined + findConversationByTableId: (tableId: number) => AIConversation | undefined + hasConversationForQuery: ( + bufferId: string | number, + queryKey: QueryKey, + ) => boolean + + // Creation and mutation + createConversation: (options: { + bufferId?: string | number | null + queryKey?: QueryKey | null + tableId?: number + }) => AIConversation + getOrCreateConversationForQuery: (options: { + bufferId: string | number + queryKey: QueryKey + }) => AIConversation + updateConversationAssociations: ( + conversationId: ConversationId, + updates: { + bufferId?: string | number | null + queryKey?: QueryKey | null + }, + ) => void + shiftQueryKeysForBuffer: ( + bufferId: string | number, + changeOffset: number, + delta: number, + ) => void + + // Chat window + openChatWindow: (conversationId: ConversationId) => void + openChatWindowForQuery: ( + bufferId: string | number, + queryKey: QueryKey, + ) => void + openOrCreateBlankChatWindow: () => void + openBlankChatWindow: () => void + closeChatWindow: () => void + openHistoryView: () => void + closeHistoryView: () => void + deleteConversation: (conversationId: ConversationId) => void + + // Message operations (now use ConversationId) + addMessage: ( + conversationId: ConversationId, + message: ConversationMessage, + ) => void + updateConversationSQL: (conversationId: ConversationId, sql: string) => void + addMessageAndUpdateSQL: ( + conversationId: ConversationId, + message: ConversationMessage, + ) => void + updateConversationName: (conversationId: ConversationId, name: string) => void + acceptConversationChanges: ( + conversationId: ConversationId, + messageIndex?: number, + ) => void + rejectLatestChange: (conversationId: ConversationId) => void + + // Accept/reject suggestions + acceptSuggestion: (params: AcceptSuggestionParams) => Promise + rejectSuggestion: (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 [conversations, setConversations] = useState< + Map + >(new Map()) + + const [chatWindowState, setChatWindowState] = useState({ + isOpen: false, + activeConversationId: null, + isHistoryOpen: false, + previousConversationId: null, + }) + + const indices = useMemo(() => buildIndices(conversations), [conversations]) + + const generateConversationId = useCallback( + (): ConversationId => crypto.randomUUID(), + [], + ) + + const getConversation = useCallback( + (id: ConversationId): AIConversation | undefined => { + return conversations.get(id) + }, + [conversations], + ) + + const findConversationByQuery = useCallback( + ( + bufferId: string | number, + queryKey: QueryKey, + ): AIConversation | undefined => { + const lookupKey = createQueryLookupKey(bufferId, queryKey) + const id = indices.queryIndex.get(lookupKey) + return id ? conversations.get(id) : undefined + }, + [conversations, indices], + ) + + const findConversationByTableId = useCallback( + (tableId: number): AIConversation | undefined => { + const id = indices.tableIndex.get(tableId) + return id ? conversations.get(id) : undefined + }, + [conversations, indices], + ) + + const hasConversationForQuery = useCallback( + (bufferId: string | number, queryKey: QueryKey): boolean => { + const lookupKey = createQueryLookupKey(bufferId, queryKey) + const conversationId: ConversationId | undefined = + indices.queryIndex.get(lookupKey) + if (!conversationId) return false + const conversation = conversations.get(conversationId) + return conversation !== undefined && conversation.messages.length > 0 + }, + [indices, conversations], + ) + + const createConversation = useCallback( + (options: { + bufferId?: string | number | null + queryKey?: QueryKey | null + tableId?: number + }): AIConversation => { + const id = generateConversationId() + const { queryText } = getQueryInfoFromKey(options.queryKey ?? null) + const conversation: AIConversation = { + id, + queryKey: options.queryKey ?? null, + bufferId: options.bufferId ?? null, + tableId: options.tableId, + currentSQL: queryText, + conversationName: "AI Assistant", + messages: [], + updatedAt: Date.now(), + } + + setConversations((prev) => { + const next = new Map(prev) + next.set(id, conversation) + return next + }) + + return conversation + }, + [generateConversationId], + ) + + const getOrCreateConversationForQuery = useCallback( + (options: { + bufferId: string | number + queryKey: QueryKey + }): AIConversation => { + const existing = findConversationByQuery( + options.bufferId, + options.queryKey, + ) + if (existing) { + return existing + } + + return createConversation({ + bufferId: options.bufferId, + queryKey: options.queryKey, + }) + }, + [findConversationByQuery, createConversation], + ) + + const updateConversationAssociations = useCallback( + ( + conversationId: ConversationId, + updates: { + bufferId?: string | number | null + queryKey?: QueryKey | null + }, + ): void => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (!conv) return prev + + next.set(conversationId, { + ...conv, + ...updates, + updatedAt: Date.now(), + }) + return next + }) + }, + [], + ) + + const shiftQueryKeysForBuffer = useCallback( + (bufferId: string | number, changeOffset: number, delta: number): void => { + setConversations((prev) => { + const next = new Map(prev) + let hasChanges = false + + for (const [id, conv] of prev) { + if (conv.bufferId === bufferId && conv.queryKey) { + const { startOffset } = getQueryInfoFromKey(conv.queryKey) + // Only shift if the query starts at or after the change point + if (startOffset >= changeOffset) { + const newQueryKey = shiftQueryKey( + conv.queryKey, + changeOffset, + delta, + ) + next.set(id, { + ...conv, + queryKey: newQueryKey, + updatedAt: Date.now(), + }) + hasChanges = true + } + } + } + + return hasChanges ? next : prev + }) + }, + [], + ) + + const addMessage = useCallback( + (conversationId: ConversationId, message: ConversationMessage) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + next.set(conversationId, { + ...conv, + messages: [...conv.messages, message], + updatedAt: Date.now(), + }) + } + return next + }) + }, + [], + ) + + const updateConversationSQL = useCallback( + (conversationId: ConversationId, sql: string) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + // When SQL is applied to editor, update both currentSQL and queryKey + // queryKey is the source of truth for what's in the editor + const { startOffset } = getQueryInfoFromKey(conv.queryKey) + const newQueryKey = createQueryKey(sql, startOffset) + next.set(conversationId, { + ...conv, + currentSQL: sql, + queryKey: newQueryKey, + updatedAt: Date.now(), + }) + } + return next + }) + }, + [], + ) + + const addMessageAndUpdateSQL = useCallback( + (conversationId: ConversationId, message: ConversationMessage) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + // Track previous SQL only if this message contains SQL changes + // (message.sql will be undefined if no SQL change, due to conditional spreading) + const hasSQLChange = message.sql !== undefined + const sql = message.sql || "" + + const normalizedNewSQL = hasSQLChange ? normalizeQueryText(sql) : "" + const { queryText: acceptedSQL } = getQueryInfoFromKey(conv.queryKey) + const normalizedAcceptedSQL = normalizeQueryText(acceptedSQL) + const sqlActuallyChanged = + hasSQLChange && normalizedNewSQL !== normalizedAcceptedSQL + + // This ensures the diff shows "what's in editor" vs "what model suggests" + const previousSQL = sqlActuallyChanged ? acceptedSQL : undefined + + const messageWithHistory: ConversationMessage = { + ...message, + previousSQL, + } + + next.set(conversationId, { + ...conv, + messages: [...conv.messages, messageWithHistory], + // Only update currentSQL if there's an actual SQL change + currentSQL: hasSQLChange ? sql : conv.currentSQL, + updatedAt: Date.now(), + }) + } + return next + }) + }, + [], + ) + + const updateConversationName = useCallback( + (conversationId: ConversationId, name: string) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + next.set(conversationId, { + ...conv, + conversationName: name, + updatedAt: Date.now(), + }) + } + return next + }) + }, + [], + ) + + const acceptConversationChanges = useCallback( + (conversationId: ConversationId, messageIndex?: number) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (!conv) return next + + let targetIndex = messageIndex + if (targetIndex === undefined) { + for (let i = conv.messages.length - 1; i >= 0; i--) { + if ( + conv.messages[i].role === "assistant" && + conv.messages[i].sql !== undefined + ) { + targetIndex = i + break + } + } + } + + const targetMessage = + targetIndex !== undefined ? conv.messages[targetIndex] : undefined + const sqlToAccept = targetMessage?.sql || conv.currentSQL + + const { startOffset } = getQueryInfoFromKey(conv.queryKey) + const newQueryKey = createQueryKey(sqlToAccept, startOffset) + + const updatedMessages = conv.messages.map((msg, idx) => { + if (msg.role === "assistant" && msg.sql && idx === targetIndex) { + return { ...msg, isAccepted: true } + } + return msg + }) + + next.set(conversationId, { + ...conv, + queryKey: newQueryKey, + messages: updatedMessages, + updatedAt: Date.now(), + }) + return next + }) + }, + [], + ) + + const rejectLatestChange = useCallback((conversationId: ConversationId) => { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + // Find the latest assistant message with SQL change + let latestAssistantIndex = -1 + for (let i = conv.messages.length - 1; i >= 0; i--) { + if (conv.messages[i].role === "assistant" && conv.messages[i].sql) { + latestAssistantIndex = i + break + } + } + + if (latestAssistantIndex === -1) { + // No change to reject + return next + } + + const latestMessage = conv.messages[latestAssistantIndex] + if (!latestMessage) { + return next + } + + // Revert currentSQL to previous SQL (from message or queryKey as fallback) + const { queryText: acceptedSQL } = getQueryInfoFromKey(conv.queryKey) + const revertedSQL = + typeof latestMessage.previousSQL === "string" + ? latestMessage.previousSQL + : acceptedSQL + + const updatedMessages = conv.messages.map((msg, idx) => { + if (idx === latestAssistantIndex) { + return { ...msg, isRejected: true } + } + return msg + }) + + // Add user message about rejection so model is aware in future conversations + // Hide from UI but include in conversation history for API calls + const rejectionMessage: ConversationMessage = { + role: "user", + content: `User rejected your latest change. Please use the previous version as the base for future modifications.`, + timestamp: Date.now(), + hideFromUI: true, + } + + next.set(conversationId, { + ...conv, + currentSQL: revertedSQL, + messages: [...updatedMessages, rejectionMessage], + updatedAt: Date.now(), + }) + } + return next + }) + }, []) + + const openChatWindow = useCallback((conversationId: ConversationId) => { + let prevId: ConversationId | null = null + + setChatWindowState((prev) => { + prevId = prev.activeConversationId + return { + ...prev, + isOpen: true, + isHistoryOpen: false, + previousConversationId: null, + activeConversationId: conversationId, + } + }) + + if (prevId && prevId !== conversationId) { + setConversations((currentConversations) => { + const prevConversation = currentConversations.get(prevId!) + if (prevConversation && prevConversation.messages.length === 0) { + const next = new Map(currentConversations) + next.delete(prevId!) + return next + } + return currentConversations + }) + } + }, []) + + const openChatWindowForQuery = useCallback( + (bufferId: string | number, queryKey: QueryKey) => { + const conv = findConversationByQuery(bufferId, queryKey) + if (conv) { + openChatWindow(conv.id) + } + }, + [findConversationByQuery, openChatWindow], + ) + + const closeChatWindow = useCallback(() => { + setChatWindowState((prev) => ({ + ...prev, + isOpen: false, + // Keep activeConversationId so conversation persists after closing + })) + }, []) + + const openOrCreateBlankChatWindow = useCallback(() => { + if (chatWindowState.activeConversationId) { + const existingConv = conversations.get( + chatWindowState.activeConversationId, + ) + if (existingConv) { + openChatWindow(chatWindowState.activeConversationId) + return + } + } + + const allConversations = Array.from(conversations.values()) + if (allConversations.length > 0) { + const latestConversation = allConversations.reduce((latest, conv) => + conv.updatedAt > latest.updatedAt ? conv : latest, + ) + openChatWindow(latestConversation.id) + return + } + + const blankConversation = createConversation({}) + openChatWindow(blankConversation.id) + }, [ + chatWindowState.activeConversationId, + conversations, + openChatWindow, + createConversation, + ]) + + const openBlankChatWindow = useCallback(() => { + const blankConversation = createConversation({}) + openChatWindow(blankConversation.id) + }, [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((conversationId: ConversationId) => { + setConversations((prev) => { + const next = new Map(prev) + next.delete(conversationId) + return next + }) + + setChatWindowState((prev) => { + const updates: Partial = {} + if (prev.activeConversationId === conversationId) { + updates.activeConversationId = null + } + if (prev.previousConversationId === conversationId) { + updates.previousConversationId = null + } + return Object.keys(updates).length > 0 ? { ...prev, ...updates } : prev + }) + }, []) + + const { + editorRef, + buffers, + activeBuffer, + setActiveBuffer, + addBuffer, + closeDiffBufferForConversation, + applyAISQLChange, + } = useEditor() + + // Unified accept function - handles all scenarios + const acceptSuggestion = useCallback( + async (params: AcceptSuggestionParams): Promise => { + const { conversationId, sql, messageIndex, skipHiddenMessage } = params + const conversation = conversations.get(conversationId) + if (!conversation) return + + const normalizedSQL = normalizeSql(sql, false) + + // Close any open diff buffer for this conversation first + await closeDiffBufferForConversation(conversationId) + + // Determine buffer status + const conversationBufferId = conversation.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 { + // Handle based on buffer status + if (bufferStatus === "active") { + applyChangesToActiveTab( + conversationId, + normalizedSQL, + conversation, + messageIndex, + ) + } else if ( + bufferStatus === "deleted" || + bufferStatus === "archived" || + bufferStatus === "none" + ) { + await applyChangesToNewTab( + conversationId, + normalizedSQL, + messageIndex, + ) + } else if (bufferStatus === "inactive" && buffer) { + await setActiveBuffer(buffer) + await new Promise((resolve) => setTimeout(resolve, 100)) + applyChangesToActiveTab( + conversationId, + normalizedSQL, + conversation, + messageIndex, + ) + } + + // Add hidden message to inform model about acceptance (unless skipped) + if (!skipHiddenMessage) { + addMessage(conversationId, { + role: "user" as const, + content: `User accepted your latest SQL change. Now the query is:\n\n\`\`\`sql\n${normalizedSQL}\n\`\`\``, + timestamp: Date.now(), + hideFromUI: true, + }) + } + } catch (error) { + console.error("Error applying changes:", error) + } + }, + [ + conversations, + buffers, + activeBuffer, + setActiveBuffer, + closeDiffBufferForConversation, + addMessage, + ], + ) + + // Helper: Apply changes to active tab + const applyChangesToActiveTab = useCallback( + ( + conversationId: ConversationId, + normalizedSQL: string, + conversation: AIConversation, + messageIndex?: number, + ): void => { + const result = applyAISQLChange({ + newSQL: normalizedSQL, + queryKey: conversation.queryKey ?? undefined, + }) + + if (!result.success) { + return + } + + updateConversationAssociations(conversationId, { + queryKey: result.finalQueryKey ?? conversation.queryKey, + }) + + // Update SQL and mark as accepted + updateConversationSQL(conversationId, normalizedSQL) + acceptConversationChanges(conversationId, messageIndex) + + if (conversation.tableId != null) { + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv) { + next.set(conversationId, { + ...conv, + tableId: undefined, + }) + } + return next + }) + } + }, + [ + applyAISQLChange, + updateConversationAssociations, + updateConversationSQL, + acceptConversationChanges, + ], + ) + + // Helper: Apply changes to new tab (when original is deleted/archived or no buffer) + const applyChangesToNewTab = useCallback( + async ( + conversationId: ConversationId, + normalizedSQL: string, + messageIndex?: number, + ): Promise => { + 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) + + // Apply highlighting decoration + 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) + updateConversationAssociations(conversationId, { + bufferId: newBuffer.id, + queryKey: newQueryKey, + }) + + updateConversationSQL(conversationId, normalizedSQL) + acceptConversationChanges(conversationId, messageIndex) + + setConversations((prev) => { + const next = new Map(prev) + const conv = next.get(conversationId) + if (conv && conv.tableId != null) { + next.set(conversationId, { + ...conv, + tableId: undefined, + }) + } + return next + }) + }, + [ + addBuffer, + editorRef, + updateConversationAssociations, + updateConversationSQL, + acceptConversationChanges, + ], + ) + + // Unified reject function + const rejectSuggestion = useCallback( + async (conversationId: ConversationId): Promise => { + const conversation = conversations.get(conversationId) + if (!conversation) return + + // Update conversation state (marks as rejected, adds hidden message) + rejectLatestChange(conversationId) + + // Close any open diff buffer + await closeDiffBufferForConversation(conversationId) + + // If we're currently viewing a diff buffer, switch back to original + if (activeBuffer.isDiffBuffer) { + const originalBuffer = buffers.find( + (b) => b.id === conversation.bufferId && !b.archived, + ) + if (originalBuffer) { + await setActiveBuffer(originalBuffer) + } + } + }, + [ + conversations, + rejectLatestChange, + closeDiffBufferForConversation, + activeBuffer, + buffers, + setActiveBuffer, + ], + ) + + return ( + + {children} + + ) +} diff --git a/src/providers/AIConversationProvider/indices.ts b/src/providers/AIConversationProvider/indices.ts new file mode 100644 index 000000000..84b14018f --- /dev/null +++ b/src/providers/AIConversationProvider/indices.ts @@ -0,0 +1,40 @@ +import type { ConversationId, AIConversation, QueryKey } from "./types" + +export type ConversationIndices = { + queryIndex: Map // key: `${bufferId}:${queryKey}` + + tableIndex: Map // key: tableId +} + +/** + * Builds lookup indices from the conversations map. + * Called via useMemo whenever conversations change. + */ +export const buildIndices = ( + conversations: Map, +): ConversationIndices => { + const queryIndex = new Map() + const tableIndex = new Map() + + conversations.forEach((conv, id) => { + // Index by buffer + queryKey (if both exist) + if (conv.bufferId != null && conv.queryKey != null) { + queryIndex.set(createQueryLookupKey(conv.bufferId, conv.queryKey), id) + } + + if (conv.tableId != null) { + tableIndex.set(conv.tableId, id) + } + }) + + return { queryIndex, tableIndex } +} + +/** + * Creates a lookup key for finding conversations by buffer + queryKey. + * Used for glyph widget hasConversation checks and opening conversations. + */ +export const createQueryLookupKey = ( + bufferId: string | number, + queryKey: QueryKey, +): string => `${bufferId}:${queryKey}` diff --git a/src/providers/AIConversationProvider/types.ts b/src/providers/AIConversationProvider/types.ts new file mode 100644 index 000000000..7e120355c --- /dev/null +++ b/src/providers/AIConversationProvider/types.ts @@ -0,0 +1,62 @@ +import type { PartitionBy } from "../../utils/questdb" +import type { QueryKey } from "../../scenes/Editor/Monaco/utils" + +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" + | "text" + +export type ConversationMessage = { + role: "user" | "assistant" + content: string // Full content sent to API + timestamp: number + sql?: string | null // Current SQL after this message (null = no SQL change in this message) + explanation?: string // Explanation for this turn + tokenUsage?: TokenUsage // Token usage for assistant messages + previousSQL?: string // SQL before this change (for diff display) + isRejected?: boolean // Whether this change has been rejected + isAccepted?: boolean // Whether this change has been accepted + hideFromUI?: boolean // Whether to hide this message from UI (e.g., rejection messages) + // UI display fields - for cleaner presentation + displayType?: UserMessageDisplayType // How to render this message in UI + displaySQL?: string // SQL to show in inline editor (for fix/explain/ask requests) + displayUserMessage?: string // User's actual message/question (for ask_request) + displaySchemaData?: SchemaDisplayData // Schema data (for schema_explain_request) +} + +export type AIConversation = { + id: ConversationId // Stable identifier - never changes throughout conversation lifecycle + conversationName: string // AI-generated name for the conversation + updatedAt: number + tableId?: number // Table ID for schema conversations - lookup fresh metadata from Redux + bufferId: number | string | null // Can be null for schema/blank conversations + queryKey: QueryKey | null // Single source of truth - contains query text, start/end offsets + currentSQL: string // Current SQL with all pending changes (may differ from queryKey) + messages: ConversationMessage[] +} + +export type ChatWindowState = { + isOpen: boolean + activeConversationId: ConversationId | null + isHistoryOpen?: boolean + previousConversationId?: ConversationId | null // Chat we came from when opening history +} 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..eef0b96fe --- /dev/null +++ b/src/providers/AIStatusProvider/index.tsx @@ -0,0 +1,258 @@ +import React, { + createContext, + useCallback, + useContext, + useState, + useRef, + useEffect, + useMemo, +} from "react" +import { useEditor } from "../EditorProvider" +import { useLocalStorage } from "../LocalStorageProvider" +import { + isAiAssistantConfigured, + getSelectedModel, + hasSchemaAccess, + providerForModel, + canUseAiAssistant, +} from "../../utils/aiAssistantSettings" +import type { ConversationId } from "../AIConversationProvider/types" + +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", +} + +export type StatusArgs = + | ({ conversationId?: ConversationId } & ( + | { type: "fix" } + | { type: "explain" } + | { type: "followup" } + | { name: string } + | { name: string; section: string } + | { items: Array<{ name: string; section?: string }> } + )) + | null + +export type StatusEntry = { + type: AIOperationStatus + args?: StatusArgs +} + +export type OperationHistory = StatusEntry[] + +type BaseAIStatusContextType = { + status: AIOperationStatus | null + setStatus: (status: AIOperationStatus | null, args?: StatusArgs) => void + abortController: AbortController | null + abortOperation: () => void + hasSchemaAccess: boolean + models: string[] + currentOperation: OperationHistory + activeConversationId: ConversationId | null + 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 { editorRef } = useEditor() + 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) => { + 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) + } + } + 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) + } + }, [status, editorRef, 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 activeConversationId = + currentOperation.find((entry) => entry.args?.conversationId)?.args + ?.conversationId ?? null + + const contextValue: AIStatusContextType = isConfigured + ? { + status, + setStatus, + abortController, + abortOperation, + clearOperation, + isConfigured: true, + canUse, + hasSchemaAccess: hasSchemaAccessValue, + currentModel: currentModel!, + apiKey: apiKey!, + models, + currentOperation, + activeConversationId, + } + : { + status, + setStatus, + abortController, + abortOperation, + clearOperation, + isConfigured: false, + canUse: false, + hasSchemaAccess: hasSchemaAccessValue, + currentModel, + apiKey, + models, + currentOperation, + activeConversationId, + } + + return ( + + {children} + + ) +} diff --git a/src/providers/EditorProvider/index.tsx b/src/providers/EditorProvider/index.tsx index ec041cf7f..f09436672 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,30 @@ import { useLiveQuery } from "dexie-react-hooks" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor +export type DiffModeState = { + bufferId: number | string + queryKey: QueryKey + original: string + modified: string +} | null + +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 @@ -48,7 +79,7 @@ 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: ( @@ -65,6 +96,26 @@ export type EditorContext = { temporaryBufferId: number | null queryParamProcessedRef: MutableRefObject isNavigatingFromSearchRef: MutableRefObject + // Diff mode for AI chat integration + diffModeState: DiffModeState + enterDiffMode: ( + bufferId: number | string, + queryKey: QueryKey, + original: string, + modified: string, + ) => void + exitDiffMode: () => void + updateDiffMode: (original: string, modified: string) => void + // 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 + cleanupAllExecutionRefs: () => void } const defaultValues = { @@ -87,6 +138,16 @@ const defaultValues = { temporaryBufferId: null, queryParamProcessedRef: { current: false }, isNavigatingFromSearchRef: { current: false }, + diffModeState: null, + enterDiffMode: () => undefined, + exitDiffMode: () => undefined, + updateDiffMode: () => undefined, + showDiffBuffer: () => Promise.resolve(), + closeDiffBufferForConversation: () => Promise.resolve(), + applyAISQLChange: () => ({ success: false }), + executionRefs: { current: {} }, + cleanupExecutionRefs: () => undefined, + cleanupAllExecutionRefs: () => undefined, } const EditorContext = createContext(defaultValues) @@ -94,9 +155,31 @@ 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, ) + const [diffModeState, setDiffModeState] = useState(null) + + const enterDiffMode = useCallback( + ( + bufferId: number | string, + queryKey: QueryKey, + original: string, + modified: string, + ) => { + setDiffModeState({ bufferId, queryKey, original, modified }) + }, + [], + ) + + const exitDiffMode = useCallback(() => { + setDiffModeState(null) + }, []) + + const updateDiffMode = useCallback((original: string, modified: string) => { + setDiffModeState((prev) => (prev ? { ...prev, original, modified } : null)) + }, []) const rawBuffers = useLiveQuery(bufferStore.getAll, []) const buffers = useMemo(() => { @@ -126,6 +209,14 @@ 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()] + }, []) + + const cleanupAllExecutionRefs = useCallback(() => { + executionRefs.current = {} + }, []) + // this effect should run only once, after mount and after `buffers` and `activeBufferId` are ready from the db useEffect(() => { if (!ranOnce.current && buffers && activeBufferId) { @@ -248,6 +339,7 @@ export const EditorProvider: React.FC = ({ children }) => { const deleteAllBuffers = async () => { await bufferStore.deleteAll() + cleanupAllExecutionRefs() eventBus.publish(EventType.BUFFERS_UPDATED, { type: "deleteAll" }) } @@ -346,9 +438,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, @@ -380,6 +478,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 ( { temporaryBufferId, queryParamProcessedRef, isNavigatingFromSearchRef, + diffModeState, + enterDiffMode, + exitDiffMode, + updateDiffMode, + showDiffBuffer, + closeDiffBufferForConversation, + applyAISQLChange, + executionRefs, + cleanupExecutionRefs, + cleanupAllExecutionRefs, editorReadyTrigger: (editor) => { if (!activeBuffer.isTemporary && !isNavigatingFromSearchRef.current) { editor.focus() diff --git a/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 17040b2cf..e3b2a6a53 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: 400, } 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/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..3c2fc3aec 100644 --- a/src/scenes/Console/index.tsx +++ b/src/scenes/Console/index.tsx @@ -25,14 +25,24 @@ import { Import as ImportIcon } from "../../components/icons/import" import { useSettings, useSearch } from "../../providers" import { SearchPanel } from "../Search" import { LeftPanelType } from "../../providers/LocalStorageProvider/types" +import { AIChatWindow } from "../Editor/AIChatWindow" +import { useAIConversation } from "../../providers/AIConversationProvider" 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%; @@ -80,11 +90,14 @@ const Console = () => { updateSettings, leftPanelState, updateLeftPanelState, + aiChatPanelWidth, + updateAiChatPanelWidth, } = useLocalStorage() const result = useSelector(selectors.query.getResult) const activeBottomPanel = useSelector(selectors.console.getActiveBottomPanel) const { consoleConfig } = useSettings() const { isSearchPanelOpen, setSearchPanelOpen, searchPanelRef } = useSearch() + const { chatWindowState } = useAIConversation() const isDataSourcesPanelOpen = leftPanelState.type === LeftPanelType.DATASOURCES @@ -123,155 +136,188 @@ const Console = () => { return ( { - updateSettings(StoreKey.RESULTS_SPLITTER_BASIS, sizes[0]) + // sizes[1] is the AI chat panel width when it's open + if (chatWindowState.isOpen && 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 > - - - - - - - - - - - - - - {result && - viewModes.map(({ icon, mode, tooltipText }) => ( - { - dispatch( - actions.console.setActiveBottomPanel("result"), - ) - setResultViewMode(mode) - }} - selected={ - activeBottomPanel === "result" && - resultViewMode === mode + + + {!sm && ( + { + if (isDataSourcesPanelOpen) { + updateLeftPanelState({ + type: null, + width: leftPanelState.width, + }) + } else { + updateLeftPanelState({ + type: LeftPanelType.DATASOURCES, + width: leftPanelState.width, + }) + } + }} + selected={isDataSourcesPanelOpen} + > + + } > - {icon} - - } - > - {tooltipText} - - ))} - { - dispatch(actions.console.setActiveBottomPanel("import")) - }, - })} - selected={activeBottomPanel === "import"} - data-hook="import-panel-button" + + {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 > - - - } - > - - {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" + > + + + } + > + + {consoleConfig.readOnly + ? "To use this feature, turn off read-only mode in the configuration file" + : "Import files from CSV"} + + + + + {result && } + + + + + + + + + + + + {chatWindowState.isOpen && ( + + + + )} ) diff --git a/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx new file mode 100644 index 000000000..2485cd562 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx @@ -0,0 +1,232 @@ +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 { AIConversation } from "../../../providers/AIConversationProvider/types" + +const Container = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: 0.4rem 0.8rem; + border-radius: 4px; + cursor: pointer; + background: ${color("transparent")}; + + &:hover { + background: ${color("selection")}; + + .chat-title { + color: ${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; + overflow: hidden; +` + +const Title = styled.div.attrs({ className: "chat-title" })` + padding: 0.2rem 0.4rem; + line-height: 1.5rem; + border: 1px solid transparent; + color: ${color("offWhite")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transform: translateX(-0.4rem); +` + +const TitleInput = styled.input` + width: 100%; + line-height: 1.5rem; + color: ${color("foreground")}; + background: transparent; + border: 1px solid ${color("pinkDarker")}; + border-radius: 4px; + 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: AIConversation + subtitle?: string + isCurrent: boolean + hasOngoingProcess?: boolean + onSelect: (id: string) => void + onRename: (id: string, newName: string) => void + onDelete: (id: string) => void +} + +export const ChatHistoryItem: React.FC = ({ + conversation, + subtitle, + isCurrent, + hasOngoingProcess, + 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) { + onSelect(conversation.id) + } + } + + return ( + + + + + + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {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..d61dadafb --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatHistoryView.tsx @@ -0,0 +1,326 @@ +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 } 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 { + conversations, + openChatWindow, + updateConversationName, + deleteConversation, + closeHistoryView, + } = useAIConversation() + + const { activeConversationId: aiProcessingConversationId } = useAIStatus() + const { buffers } = useEditor() + const tables = useSelector(selectors.query.getTables) + + const conversationList = useMemo( + () => Array.from(conversations.values()), + [conversations], + ) + + const filteredConversations = useMemo( + () => filterConversations(conversationList, searchQuery), + [conversationList, searchQuery], + ) + + const groupedConversations = useGroupedConversations(filteredConversations) + + const getSubtitle = (bufferId: number | string | null, tableId?: number) => { + if (bufferId != null) { + const buffer = buffers.find((b) => b.id === bufferId) + if (buffer) { + return buffer.label + } + } + if (tableId != null) { + const table = tables.find((t) => t.id === tableId) + if (table) { + return table.table_name + } + } + return undefined + } + + const handleSelect = (id: ConversationId) => { + openChatWindow(id) + closeHistoryView() + } + + const handleRename = (id: ConversationId, newName: string) => { + updateConversationName(id, newName) + } + + const handleDeleteClick = (id: ConversationId) => { + setConversationToDelete(id) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (conversationToDelete) { + deleteConversation(conversationToDelete) + } + setDeleteDialogOpen(false) + setConversationToDelete(null) + } + + const handleCancelDelete = () => { + setDeleteDialogOpen(false) + setConversationToDelete(null) + } + + useEffect(() => { + if (currentItemRef.current) { + currentItemRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }) + } + }, []) + + if (conversationList.length === 0) { + return ( + + No conversations yet + + ) + } + + return ( + + + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape" && searchQuery) { + setSearchQuery("") + } + }} + /> + {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..6a4f13739 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatInput.tsx @@ -0,0 +1,438 @@ +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, spinAnimation } from "../../../components/Animation" +import { pinkLinearGradientHorizontal } from "../../../theme" +import type { ConversationId } from "../../../providers/AIConversationProvider/types" +import { TableIcon } from "../../Schema/table-icon" +import { selectors } from "../../../store" + +// Gradient spinner icon (same as AIStatusIndicator) +const CircleNotch = (props: React.SVGProps) => ( + + + + + + + + + +) + +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: 1px; + 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 SpinnerIcon = styled(CircleNotch)` + width: 2rem; + height: 2rem; + ${spinAnimation}; + flex-shrink: 0; + transform-origin: center; +` + +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 + conversationId?: ConversationId + 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...", + conversationId, + 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, + activeConversationId, + } = useAIStatus() + + const isOperationForThisConversation = Boolean( + conversationId && + activeConversationId && + conversationId === activeConversationId, + ) + const isAIInProgress = + isBlockingAIStatus(aiStatus) && isOperationForThisConversation + const isAborted = + aiStatus === AIOperationStatus.Aborted && isOperationForThisConversation + + 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} + /> + + + + + )} + + 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..47e971893 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/ChatMessages.tsx @@ -0,0 +1,1088 @@ +import React, { useEffect, useRef, useMemo, useState } 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 { 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 { 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" + +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)` + display: flex; + flex-direction: column; + gap: 2rem; + padding: 1.5rem; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; + width: 100%; +` + +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)` + 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; + + .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 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?: (messageIndex: number) => void + onRejectChange?: () => void + onRunQuery?: (sql: string) => void + onExpandDiff?: (original: string, modified: string) => void + // Apply SQL to editor and mark that specific message as accepted + onApplyToEditor?: (sql: string, messageIndex: number) => 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) + + // Find the latest assistant message with SQL changes and auto-expand it + const latestDiffIndex = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + // Note: previousSQL can be empty string for generate flow, so we check for undefined + if ( + msg.role === "assistant" && + msg.sql && + msg.previousSQL !== undefined + ) { + return i + } + } + return -1 + }, [messages]) + + // Track the previous latest diff index to detect new diffs + const prevLatestDiffIndexRef = useRef( + latestDiffIndex >= 0 ? latestDiffIndex : -1, + ) + + const [expandedDiffs, setExpandedDiffs] = useState>( + latestDiffIndex >= 0 ? new Set([latestDiffIndex]) : new Set(), + ) + + // Only auto-expand when a NEW diff is added (not when user manually collapses) + 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() + } + + // Only scroll to bottom when there are new visible messages + // Hidden messages (like context messages for model) shouldn't trigger scroll + const visibleMessagesCount = useMemo( + () => messages.filter((m) => !m.hideFromUI).length, + [messages], + ) + + useEffect(() => { + setTimeout( + () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }), + 50, + ) + }, [visibleMessagesCount]) + + // Filter out hidden messages for display, but keep original indices for status computation + const visibleMessages: Array<{ + message: ConversationMessage + originalIndex: number + }> = [] + messages.forEach((msg, originalIdx) => { + if (!msg.hideFromUI) { + visibleMessages.push({ message: msg, originalIndex: originalIdx }) + } + }) + + // Get the index of the last visible message (for button visibility) + const lastVisibleMessageIndex = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].originalIndex + : -1 + + 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.role}-${message.timestamp}-${originalIndex}` + if (message.role === "user") { + // Check if this is a special request type with inline SQL display + const displayType = message.displayType + const displaySQL = message.displaySQL + + // 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 (displaySQL) { + // fix_request and explain_request show SQL editor + const lineCount = displaySQL.split("\n").length + const editorHeight = Math.min(Math.max(lineCount * 20, 60), 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" && displaySQL) { + const userQuestion = message.displayUserMessage || message.content + const lineCount = displaySQL.split("\n").length + const editorHeight = Math.min(Math.max(lineCount * 20, 60), 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 + + ) + } + + // Check if this message has SQL changes to show diff + // Note: previousSQL can be empty string for generate flow, so we check for undefined/null + const hasSQLChange = + !!message.sql && message.previousSQL !== undefined + 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) + + return ( + + + + 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( + Math.max(lineCount * 20 + 16, 56), + 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" + > + {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.sql && !isOperationInProgress) { + onApplyToEditor(message.sql, originalIndex) + } + }} + title="Apply to editor" + disabled={isOperationInProgress} + style={{ + opacity: isOperationInProgress ? 0.5 : 1, + cursor: isOperationInProgress + ? "not-allowed" + : "pointer", + }} + > + + + )} + { + 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 && ( + + Reject + + )} + {onAcceptChange && ( + onAcceptChange(originalIndex)} + > + 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..07a751f40 --- /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 { AIConversation } from "../../../providers/AIConversationProvider/types" + +export type DateGroup = { + label: string + conversations: AIConversation[] +} + +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: AIConversation[], +): 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: AIConversation[], +): 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: AIConversation[], + searchQuery: string, +): AIConversation[] { + 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..d51b1be40 --- /dev/null +++ b/src/scenes/Editor/AIChatWindow/index.tsx @@ -0,0 +1,849 @@ +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, +} 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 GeneratedSQL, + type ActiveProviderSettings, + type TokenUsage, +} 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" + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + background: ${color("chatBackground")}; +` + +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; +` + +export const AIChatWindow: React.FC = () => { + const dispatch = useDispatch() + const { quest } = useContext(QuestContext) + const { + editorRef, + buffers, + activeBuffer, + setActiveBuffer, + showDiffBuffer, + closeDiffBufferForConversation, + executionRefs, + } = useEditor() + const { + conversations, + chatWindowState, + closeChatWindow, + openBlankChatWindow, + openHistoryView, + closeHistoryView, + getConversation, + addMessage, + addMessageAndUpdateSQL, + updateConversationName, + acceptSuggestion, + rejectSuggestion, + } = 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 conversation = chatWindowState.activeConversationId + ? getConversation(chatWindowState.activeConversationId) + : null + + // 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 as number | undefined + 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 ?? null) + }, [conversation?.queryKey]) + + const messages = useMemo(() => { + return conversation?.messages || [] + }, [conversation]) + + 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]) + + // Determine if we should show the messages panel + // Show it when there are messages in the conversation + const shouldShowMessages = useMemo(() => { + return messages.length > 0 + }, [messages]) + + 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 = conversations.size > 0 + + const addButtonDisabled = useMemo(() => { + if (!conversation) return false + return ( + conversation.messages.length === 0 && + !conversation.queryKey && + !conversation.tableId + ) + }, [conversation]) + + 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) { + 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 displaySQL: 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" + displaySQL = currentSQL.trim() + displayUserMessage = userMessage // Store the original user message for display + } + + const userMessageEntry = { + role: "user" as const, + content: userMessageContent, + timestamp: Date.now(), + ...(displayType && { displayType }), + ...(displaySQL && { displaySQL }), + ...(displayUserMessage && { displayUserMessage }), + } + + // Add user message immediately so it appears in the UI right away + addMessage(conversationId, userMessageEntry) + + 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) { + updateConversationName(conversationId, title) + } + }) + } + } + + // Build conversation history including the user message we just added + // Since addMessage updates state asynchronously, we manually include the new message + const conversationHistory = [ + ...conversation.messages.map((m) => ({ + role: m.role, + content: m.content, + })), + { role: "user" as const, content: userMessageContent }, + ] + + const processResponse = async () => { + const response = await continueConversation({ + // Pass the enriched message content so continueConversation doesn't double-add context + userMessage: userMessageContent, + conversationHistory, + currentSQL: currentSQL || undefined, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + conversationId: conversation.id, + }) + + if (isAiAssistantError(response)) { + const error = response + if (error.type !== "aborted") { + toast.error(error.message, { autoClose: 10000 }) + } + return + } + + // Handle different response types + const result = response as + | GeneratedSQL + | { explanation: string; sql?: string; tokenUsage?: TokenUsage } + + // Build complete assistant response content (SQL + explanation) + let assistantContent = result.explanation || "Response received" + if (result.sql) { + assistantContent = `SQL Query:\n\`\`\`sql\n${result.sql}\n\`\`\`\n\nExplanation:\n${result.explanation || ""}` + } + + // Add assistant response after API call completes + // Only include sql field if there's an actual SQL change (not null/undefined/empty) + const hasSQLInResult = + result.sql !== undefined && + result.sql !== null && + result.sql.trim() !== "" + + addMessageAndUpdateSQL(conversationId, { + role: "assistant" as const, + content: assistantContent, + timestamp: Date.now(), + ...(hasSQLInResult && { sql: result.sql }), + explanation: result.explanation, + tokenUsage: result.tokenUsage, + }) + } + + void processResponse() + } + + // Handle expand button click from chat messages + const handleExpandDiff = useCallback( + (original: string, modified: string) => { + if (!conversation?.id) return + void showDiffBuffer({ + original, + modified, + conversationId: conversation.id, + }) + }, + [showDiffBuffer, conversation?.id], + ) + + const handleAcceptChange = async (messageIndex: number) => { + if (!conversation || !chatWindowState.activeConversationId) return + + // Get the SQL from the message by index + const targetMessage = conversation.messages[messageIndex] + if (!targetMessage || !targetMessage.sql) return + + // Use unified acceptSuggestion from provider + await acceptSuggestion({ + conversationId: chatWindowState.activeConversationId, + sql: targetMessage.sql, + messageIndex, + }) + + // Clear AI suggestion request after applying changes + dispatch(actions.query.setAISuggestionRequest(null)) + } + + const handleRejectChange = async () => { + if (!chatWindowState.activeConversationId) return + + // Use unified rejectSuggestion from provider + await rejectSuggestion(chatWindowState.activeConversationId) + + // Focus the chat input so user can type corrections + setTimeout(() => { + chatInputRef.current?.focus() + }, 100) + } + + const handleRunQuery = (sql: string) => { + // Normalize the SQL (remove trailing semicolon if present) + const normalizedSQL = sql.trim().endsWith(";") + ? sql.trim().slice(0, -1) + : sql.trim() + + // Set the AI suggestion request with query and original position + dispatch( + actions.query.setAISuggestionRequest({ + query: normalizedSQL, + startOffset: queryInfo.startOffset, + }), + ) + // Trigger execution with AI_SUGGESTION type + dispatch(actions.query.toggleRunning(RunningType.AI_SUGGESTION)) + } + + // Handle applying SQL to editor without changing accepted/rejected state + // This is used for already-accepted/rejected suggestions that user wants to re-apply + // Navigate to inactive buffer - returns true if successful + 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() + + // Remove highlighting after 2 seconds + setTimeout(() => { + model.deltaDecorations(decorationIds, []) + }, 1000) + } catch (error) { + console.error("Error highlighting query:", error) + } + }, [conversation, editorRef, navigateToBuffer, queryInfo]) + + const handleApplyToEditor = useCallback( + async (sql: string, messageIndex: number) => { + if (!conversation || !chatWindowState.activeConversationId) return + + const normalizedSQL = normalizeSql(sql, false) + + try { + // Use unified acceptSuggestion with messageIndex to mark the correct message as accepted + await acceptSuggestion({ + conversationId: chatWindowState.activeConversationId, + sql, + messageIndex, // Pass messageIndex to mark the specific message as accepted + skipHiddenMessage: true, // We'll add our own custom message + }) + + // Add a custom hidden message to inform the model for the next round + addMessage(chatWindowState.activeConversationId, { + role: "user" as const, + content: `User replaced query with one of your previous suggestions. Now the query is:\n\n\`\`\`sql\n${normalizedSQL}\n\`\`\``, + timestamp: Date.now(), + hideFromUI: true, + }) + } catch (error) { + console.error("Error applying SQL to editor:", error) + toast.error("Failed to apply changes to editor") + } + }, + [ + conversation, + chatWindowState.activeConversationId, + acceptSuggestion, + addMessage, + ], + ) + + 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 (!chatWindowState.isOpen || !conversation) { + return null + } + + return ( + +
+ + + {headerTitle} + + + + + + + + + + + + +
+ + {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()} + conversationId={conversation?.id} + contextSQL={queryInfo.queryText} + contextTableId={conversation?.tableId} + onContextClick={handleContextClick} + /> + + )} + +
+ ) +} 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 eb69d86db..594d5e556 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 + onAskAI: (query?: Request) => void } export const QueryDropdown: React.FC = ({ @@ -77,8 +80,10 @@ export const QueryDropdown: React.FC = ({ positionRef, queriesRef, isContextMenuRef, + isAIDropdownRef, onRunQuery, onExplainQuery, + onAskAI, }) => { 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) => ( + onAskAI(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..bf19e4dc8 --- /dev/null +++ b/src/scenes/Editor/Monaco/glyphUtils.ts @@ -0,0 +1,165 @@ +import type { editor } from "monaco-editor" +import { + createSvgElement, + createAIGutterIcon, + applyGutterIconState, + getHoverState, + type GutterIconState, +} from "./icons" + +export type GlyphWidgetOptions = { + isCancel?: boolean + hasError?: boolean + isSuccessful?: boolean + isLoading?: boolean + showAI?: boolean + hasConversation?: boolean + isHighlighted?: boolean // For temporary highlight when conversation is first created + onRunClick: () => void + onRunContextMenu?: () => void + onAIClick?: () => void +} + +/** + * 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.style.display = "flex" + domNode.style.alignItems = "center" + domNode.style.gap = "5px" + domNode.style.cursor = "pointer" + domNode.style.marginLeft = "1rem" + domNode.style.width = "53px" + domNode.style.height = "100%" + + // AI sparkle icon (if enabled) + if (options.showAI) { + // Determine initial state based on conversation status and highlight flag + let baseState: GutterIconState = options.hasConversation + ? "active" + : "noChat" + if (options.isHighlighted) { + baseState = "highlight" + } + + const aiIconWrapper = createAIGutterIcon(baseState, 16) + aiIconWrapper.classList.add("glyph-ai-icon") + aiIconWrapper.style.position = "absolute" + aiIconWrapper.style.top = "50%" + aiIconWrapper.style.left = "0" + aiIconWrapper.style.transform = "translateY(-50%)" + aiIconWrapper.style.cursor = "pointer" + + // Track current state for hover transitions + let currentBaseState = baseState + + // Handle highlight -> active transition after delay + if (options.isHighlighted) { + setTimeout(() => { + currentBaseState = "active" + applyGutterIconState(aiIconWrapper, "active", 16) + }, 1000) + } + + aiIconWrapper.addEventListener("mouseenter", () => { + const hoverState = getHoverState(currentBaseState) + applyGutterIconState(aiIconWrapper, hoverState, 16) + }) + + aiIconWrapper.addEventListener("mouseleave", () => { + applyGutterIconState(aiIconWrapper, currentBaseState, 16) + }) + + aiIconWrapper.addEventListener("click", (e) => { + e.stopPropagation() + options.onAIClick?.() + }) + + domNode.appendChild(aiIconWrapper) + } + + // Run/Cancel/Status icon + const runIconWrapper = document.createElement("span") + runIconWrapper.className = "glyph-run-icon" + 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 = "0" + runIconWrapper.style.height = "100%" + + // Determine which icon to show + let iconType: "play" | "cancel" | "loading" | "error" | "success" = "play" + if (options.isCancel) { + iconType = "cancel" + } else if (options.isLoading) { + iconType = "loading" + } else if (options.hasError) { + iconType = "error" + } else if (options.isSuccessful) { + iconType = "success" + } + + const runSvg = createSvgElement(iconType, 22) + runIconWrapper.appendChild(runSvg) + + // Add spin animation for loading state + if (options.isLoading) { + runIconWrapper.style.animation = "glyph-spin 3s linear infinite" + } + + 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: () => `glyph-widget-${lineNumber}`, + getDomNode: () => domNode, + getPosition: () => ({ + lane: 1, // monaco.editor.GlyphMarginLane.Left + zIndex: 1, + range: { + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: 1, + }, + }), + } +} + +/** + * Removes all glyph widgets from the editor and clears the widgets array. + */ +export const clearGlyphWidgets = ( + editor: editor.IStandaloneCodeEditor, + widgetsRef: React.MutableRefObject, +): void => { + widgetsRef.current.forEach((widget) => { + editor.removeGlyphMarginWidget(widget) + }) + widgetsRef.current = [] +} diff --git a/src/scenes/Editor/Monaco/icons.tsx b/src/scenes/Editor/Monaco/icons.tsx new file mode 100644 index 000000000..90c025a95 --- /dev/null +++ b/src/scenes/Editor/Monaco/icons.tsx @@ -0,0 +1,341 @@ +import React from "react" + +// Gutter icon state types +export type GutterIconState = + | "noChat" + | "noChatHover" + | "active" + | "activeHover" + | "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 = () => ( + + + +) + +/** + * 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.setAttribute("height", `${size}px`) + svg.setAttribute("width", `${size}px`) + svg.setAttribute("fill", "none") + svg.innerHTML = ` + + ` + break + } + case "aiSparkleFilled": { + const gradientId = `aiSparkleGradient${Date.now()}` + 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 = "ai-gutter-icon" + + const wrapperSize = size + 8 // 4px padding on each side + wrapper.style.display = "inline-flex" + wrapper.style.alignItems = "center" + wrapper.style.justifyContent = "center" + wrapper.style.width = `${wrapperSize}px` + wrapper.style.height = `${wrapperSize}px` + wrapper.style.borderRadius = "4px" + wrapper.style.transition = "all 0.15s ease" + wrapper.style.boxSizing = "border-box" + + // Apply styles based on state + applyGutterIconState(wrapper, state, size) + + return wrapper +} + +export const applyGutterIconState = ( + wrapper: HTMLElement, + state: GutterIconState, + size = 16, +): void => { + // Determine if we need filled or hollow icon + const isFilled = + state === "noChatHover" || state === "activeHover" || state === "highlight" + + // Determine if we need the gradient border + const hasBorder = + state === "active" || state === "activeHover" || state === "highlight" + + // Determine if we need the glow effect + const hasGlow = state === "highlight" + + // Clear existing SVG + wrapper.innerHTML = "" + + // Create the appropriate SVG + const svg = createSvgElement( + isFilled ? "aiSparkleFilled" : "aiSparkleHollow", + size, + ) + wrapper.appendChild(svg) + + // Apply border and background styles + if (hasGlow) { + // Highlight state: gradient background fill + solid border + wrapper.style.border = "1px solid #d14671" + wrapper.style.background = + "linear-gradient(90deg, rgba(209, 70, 113, 0.24) 0%, rgba(137, 44, 108, 0.24) 100%)" + wrapper.style.boxShadow = "none" + } else if (hasBorder) { + // Active/ActiveHover state: transparent background with gradient border + wrapper.style.border = "1px solid transparent" + wrapper.style.background = ` + linear-gradient(#2c2e3d, #2c2e3d) padding-box, + linear-gradient(90deg, #D14671 0%, #892C6C 100%) border-box + ` + wrapper.style.boxShadow = "none" + } else { + // NoChat/NoChatHover state: no border, no background + wrapper.style.border = "1px solid transparent" + wrapper.style.background = "transparent" + wrapper.style.boxShadow = "none" + } +} + +export const getHoverState = (baseState: GutterIconState): GutterIconState => { + switch (baseState) { + case "noChat": + return "noChatHover" + case "active": + case "highlight": + return "activeHover" + default: + return baseState + } +} + +export const getBaseState = ( + hoverState: GutterIconState, + hasConversation: boolean, +): GutterIconState => { + if (hoverState === "noChatHover") return "noChat" + if (hoverState === "activeHover") return hasConversation ? "active" : "noChat" + return hoverState +} diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 76ddf6110..c6b56f563 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 type { editor, IDisposable } from "monaco-editor" import React, { @@ -13,7 +12,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, @@ -28,6 +27,9 @@ import { formatTiming } from "../QueryResult" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { QuestContext, useEditor } from "../../../providers" +import { useAIStatus } from "../../../providers/AIStatusProvider" +import { useAIConversation } from "../../../providers/AIConversationProvider" +import type { ConversationId } from "../../../providers/AIConversationProvider/types" import { actions, selectors } from "../../../store" import { RunningType } from "../../../store/Query/types" import type { NotificationShape } from "../../../store/Query/types" @@ -38,8 +40,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" @@ -52,6 +53,7 @@ import { getQueryFromCursor, getQueryRequestFromEditor, getQueryRequestFromLastExecutedQuery, + getQueryRequestFromAISuggestion, QuestDBLanguageName, getAllQueries, getQueriesInRange, @@ -69,6 +71,7 @@ import { import { toast } from "../../../components/Toast" import ButtonBar from "../ButtonBar" import { QueryDropdown } from "./QueryDropdown" +import { createGlyphWidget, clearGlyphWidgets } from "./glyphUtils" type IndividualQueryResult = { success: boolean @@ -78,18 +81,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; @@ -109,6 +120,12 @@ const Content = styled(PaneContent)` } } + .glyph-widget-container { + position: relative; + align-items: center; + width: 50px !important; + } + .selectionErrorHighlight { background-color: rgba(255, 85, 85, 0.15); border-radius: 2px; @@ -124,42 +141,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); } @@ -167,16 +155,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)` @@ -196,14 +174,17 @@ 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 DEFAULT_LINE_CHARS = 7 + +const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const editorContext = useEditor() + const { executionRefs, cleanupExecutionRefs } = editorContext const { buffers, editorRef, @@ -216,6 +197,14 @@ const MonacoEditor = ({ isNavigatingFromSearchRef, } = editorContext const { quest } = useContext(QuestContext) + const { canUse: canUseAI } = useAIStatus() + const { + getOrCreateConversationForQuery, + openChatWindow, + hasConversationForQuery, + findConversationByQuery, + shiftQueryKeysForBuffer, + } = useAIConversation() const [request, setRequest] = useState() const [editorReady, setEditorReady] = useState(false) const [lastExecutedQuery, setLastExecutedQuery] = useState("") @@ -224,6 +213,9 @@ const MonacoEditor = ({ const [scriptConfirmationOpen, setScriptConfirmationOpen] = useState(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) @@ -245,10 +237,19 @@ const MonacoEditor = ({ const requestRef = useRef(request) const queryNotificationsRef = useRef(queryNotifications) const activeNotificationRef = useRef(activeNotification) + const aiSuggestionRequestRef = useRef<{ + query: string + startOffset: number + } | null>(aiSuggestionRequest) + const hasConversationForQueryRef = useRef(hasConversationForQuery) + const findConversationByQueryRef = useRef(findConversationByQuery) const contentJustChangedRef = useRef(false) const cursorChangeTimeoutRef = useRef(null) + const glyphLineNumbersRef = useRef>(new Set()) const decorationCollectionRef = useRef(null) + const glyphWidgetsRef = useRef([]) + const seenConversationsRef = useRef>(new Set()) const visibleLinesRef = useRef<{ startLine: number; endLine: number }>({ startLine: 1, endLine: 1, @@ -263,8 +264,22 @@ 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 + @@ -277,6 +292,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) { @@ -379,58 +406,6 @@ const MonacoEditor = ({ } } - const beforeMount = (monaco: Monaco) => { - registerLanguageAddons(monaco) - - monaco.editor.defineTheme("dracula", dracula) - } - - const handleEditorClick = (e: React.MouseEvent) => { - if ( - isRunningScriptRef.current && - e.target instanceof Element && - e.target.classList.contains("cursorQueryGlyph") - ) { - return - } - 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") - ) { - 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) { - setCursorBeforeRunning(dropdownQueries[0]) - toggleRunning() - } - } - } - } - const handleRunQuery = (query?: Request) => { setDropdownOpen(false) @@ -454,6 +429,20 @@ const MonacoEditor = ({ toggleRunning(RunningType.EXPLAIN) } + const handleAskAI = (query?: Request) => { + setDropdownOpen(false) + if (!query || !editorRef.current) return + + const queryKey = createQueryKeyFromRequest(editorRef.current, query) + + const conversation = getOrCreateConversationForQuery({ + queryKey, + bufferId: activeBufferRef.current.id!, + }) + + openChatWindow(conversation.id) + } + const applyLineMarkings = ( monaco: Monaco, editor: editor.IStandaloneCodeEditor, @@ -470,7 +459,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) @@ -577,10 +567,11 @@ const MonacoEditor = ({ const activeBufferId = activeBufferRef.current.id as number - const allDecorations: editor.IModelDeltaDecoration[] = [] const allQueryOffsets: { startOffset: number; endOffset: number }[] = [] - // Add decorations for queries in range + clearGlyphWidgets(editor, glyphWidgetsRef) + glyphLineNumbersRef.current.clear() + if (queries.length > 0) { queries.forEach((query) => { const queryOffsets = { @@ -594,7 +585,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 = @@ -606,25 +598,94 @@ 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 conversation = canUseAI + ? findConversationByQueryRef.current(activeBufferId, queryKey) + : undefined + const hasConversation = + conversation !== undefined && conversation.messages.length > 0 + + // Check if this is a newly created conversation (for highlight animation) + const isNewlyCreatedConversation = + hasConversation && + conversation !== undefined && + !seenConversationsRef.current.has(conversation.id) + + 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, - }, - }) + + 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) { + setCursorBeforeRunning(dropdownQueries[0]) + toggleRunning() + } + } + } + + const handleAIClick = () => { + 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) { + handleAskAI(dropdownQueries[0]) + } + } + + const handleRunContextMenu = () => { + const dropdownQueries = getDropdownQueries(startLineNumber) + if (dropdownQueries.length > 0) { + dropdownQueriesRef.current = dropdownQueries + isAIDropdownRef.current = false + openDropdownAtPosition( + 0, + 0, + { lineNumber: startLineNumber, column: 1 }, + true, + ) + } + } + + if (!glyphLineNumbersRef.current.has(startLineNumber)) { + const widget = createGlyphWidget(startLineNumber, { + isCancel: isRunningQuery, + hasError, + isSuccessful, + showAI: canUseAI, + hasConversation, + isHighlighted: isNewlyCreatedConversation, + onRunClick: handleRunClick, + onRunContextMenu: handleRunContextMenu, + onAIClick: handleAIClick, + }) + editor.addGlyphMarginWidget(widget) + glyphLineNumbersRef.current.add(startLineNumber) + glyphWidgetsRef.current.push(widget) + } + + if (conversation && conversation.messages.length > 0) { + seenConversationsRef.current.add(conversation.id) + } }) } @@ -632,8 +693,7 @@ const MonacoEditor = ({ decorationCollectionRef.current.clear() } - decorationCollectionRef.current = - editor.createDecorationsCollection(allDecorations) + decorationCollectionRef.current = editor.createDecorationsCollection([]) queryOffsetsRef.current = allQueryOffsets applyLineMarkings(monaco, editor, source) @@ -642,7 +702,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, @@ -686,33 +745,6 @@ 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. @@ -753,7 +785,7 @@ const MonacoEditor = ({ 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> = [] @@ -830,8 +862,24 @@ const MonacoEditor = ({ } const currentNotifications = queryNotificationsRef.current || {} + // Calculate AI suggestion queryKey to skip it during content changes + // (it will be handled separately via updateNotificationKey after the AI suggestion is accepted) + const currentAISuggestion = aiSuggestionRequestRef.current + const aiSuggestionQueryKey = currentAISuggestion + ? createQueryKey( + normalizeQueryText(currentAISuggestion.query), + currentAISuggestion.startOffset, + ) + : null + Object.keys(currentNotifications).forEach((key) => { const queryKey = key as QueryKey + + // Skip AI suggestion notification - it will be handled by the accept flow + if (aiSuggestionQueryKey && queryKey === aiSuggestionQueryKey) { + return + } + const { queryText, startOffset, endOffset } = parseQueryKey(queryKey) const effectiveOffsetDelta = e.changes .filter((change) => change.rangeOffset < endOffset) @@ -867,9 +915,9 @@ 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) @@ -882,6 +930,23 @@ const MonacoEditor = ({ contentJustChangedRef.current = false notificationUpdates.forEach((update) => update()) + + 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) { + shiftQueryKeysForBuffer( + activeBufferId, + earliestChangeOffset, + totalDelta, + ) + } + } }) editor.onDidChangeModel(() => { @@ -1031,10 +1096,11 @@ const MonacoEditor = ({ { limit: "0,1000", explain: true }, ) - 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) } } @@ -1075,12 +1141,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, @@ -1101,12 +1168,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, @@ -1177,9 +1245,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 @@ -1325,17 +1391,22 @@ 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(() => { activeNotificationRef.current = activeNotification }, [activeNotification]) + useEffect(() => { + aiSuggestionRequestRef.current = aiSuggestionRequest + }, [aiSuggestionRequest]) + useEffect(() => { const gridNotificationKeySuffix = `@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}` queryNotificationsRef.current = queryNotifications @@ -1374,17 +1445,33 @@ 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 + + // Use the active buffer's ID for notifications and results + 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 @@ -1419,7 +1506,7 @@ const MonacoEditor = ({ ), sideContent: , }, - activeBuffer.id as number, + targetBufferId, ), ) } @@ -1440,29 +1527,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, @@ -1487,7 +1575,7 @@ const MonacoEditor = ({ isExplain: isRunningExplain, content: , }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_SCHEMA) @@ -1510,7 +1598,7 @@ const MonacoEditor = ({ sideContent: , type: NotificationType.NOTICE, }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_SCHEMA) @@ -1532,7 +1620,7 @@ const MonacoEditor = ({ ), sideContent: , }, - activeBuffer.id as number, + targetBufferId, ), ) eventBus.publish(EventType.MSG_QUERY_DATASET, result) @@ -1569,20 +1657,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, @@ -1615,7 +1701,7 @@ const MonacoEditor = ({ sideContent: , type: NotificationType.ERROR, }, - activeBuffer.id as number, + targetBufferId, ), ) } @@ -1671,6 +1757,14 @@ const MonacoEditor = ({ } }, [activeBuffer]) + useEffect(() => { + hasConversationForQueryRef.current = hasConversationForQuery + findConversationByQueryRef.current = findConversationByQuery + if (monacoRef.current && editorRef.current) { + applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + } + }, [hasConversationForQuery, findConversationByQuery]) + useEffect(() => { window.addEventListener("focus", setCompletionProvider) return () => { @@ -1696,6 +1790,12 @@ const MonacoEditor = ({ if (decorationCollectionRef.current) { decorationCollectionRef.current.clear() } + + if (editorRef.current) { + clearGlyphWidgets(editorRef.current, glyphWidgetsRef) + glyphLineNumbersRef.current.clear() + } + editorRef.current?.getModel()?.dispose() editorRef.current?.dispose() editorRef.current = null @@ -1705,51 +1805,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 && ( + + )} + + + @@ -1761,13 +1850,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} + onAskAI={handleAskAI} /> { if (buffer.metricsViewState) { return "assets/icon-chart.svg" } + if (buffer.isDiffBuffer) { + return "assets/icon-compare.svg" + } return "assets/icon-file.svg" } @@ -113,6 +116,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 @@ -216,6 +225,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 df2215e8b..71e8b432b 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 @@ -681,6 +682,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, @@ -1098,6 +1126,23 @@ export const parseQueryKey = ( } } +export const getQueryInfoFromKey = ( + queryKey: QueryKey | null, +): { 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, @@ -1198,3 +1243,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..4d8b6c1a6 100644 --- a/src/scenes/Editor/index.tsx +++ b/src/scenes/Editor/index.tsx @@ -22,20 +22,32 @@ * ******************************************************************************/ -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 { useDispatch } from "react-redux" import { actions } from "../../store" +import { QuestDBLanguageName, normalizeQueryText } from "./Monaco/utils" type Props = Readonly<{ style?: CSSProperties @@ -69,19 +81,107 @@ 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 { getConversation, acceptSuggestion, rejectSuggestion } = + useAIConversation() const handleClearNotifications = (bufferId: number) => { dispatch(actions.query.cleanupBufferNotifications(bufferId)) - delete executionRefs.current[bufferId] + cleanupExecutionRefs(bufferId) } useEffect(() => { @@ -92,14 +192,200 @@ const Editor = ({ } }, []) + // Determine if Monaco editor UI should be hidden + // Hidden when viewing diff buffer or metrics, but component stays mounted for query execution + const isMonacoHidden = + !!activeBuffer.isDiffBuffer || !!activeBuffer.metricsViewState + + // Check if the current diff buffer shows a pending AI suggestion + // A diff is "pending" if: + // 1. The diff buffer has a conversationId linking it to a conversation + // 2. That conversation has hasPendingDiff = true + // 3. The diff's modified content matches the conversation's currentSQL (handles multiple revisions) + const pendingDiffInfo = useMemo(() => { + if ( + !activeBuffer.isDiffBuffer || + !activeBuffer.diffContent?.conversationId + ) { + return null + } + + const conversationId = activeBuffer.diffContent.conversationId + const conversation = getConversation(conversationId) + + if (!conversation) { + return null + } + + const visibleMessages = conversation.messages.filter((m) => !m.hideFromUI) + if (visibleMessages.length === 0) { + return null + } + const lastVisible = visibleMessages[visibleMessages.length - 1] + const hasUnactionedDiff = + lastVisible.role === "assistant" && + lastVisible.sql !== undefined && + lastVisible.previousSQL !== undefined && + !lastVisible.isAccepted && + !lastVisible.isRejected + + if (!hasUnactionedDiff) { + return null + } + + // Compare normalized SQL to ensure this diff matches the current pending suggestion + const normalizedDiffModified = normalizeQueryText( + activeBuffer.diffContent.modified || "", + ) + const normalizedCurrentSQL = normalizeQueryText( + conversation.currentSQL || "", + ) + + if (normalizedDiffModified !== normalizedCurrentSQL) { + return null + } + + return { + conversationId, + conversation, + } + }, [activeBuffer, getConversation]) + + // Handle accept button click from diff editor button bar + const handleAcceptFromDiffEditor = useCallback(async () => { + if (!pendingDiffInfo || !activeBuffer.diffContent) return + + const { conversationId } = pendingDiffInfo + const modifiedSQL = activeBuffer.diffContent.modified + + // Use unified acceptSuggestion from provider + await acceptSuggestion({ + conversationId, + sql: modifiedSQL, + }) + }, [pendingDiffInfo, activeBuffer.diffContent, acceptSuggestion]) + + // Handle reject button click from diff editor button bar + const handleRejectFromDiffEditor = useCallback(async () => { + if (!pendingDiffInfo) return + + const { conversationId } = pendingDiffInfo + + // Use unified rejectSuggestion from provider + await rejectSuggestion(conversationId) + }, [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..c623f01ea --- /dev/null +++ b/src/scenes/Layout/AIChatButton.tsx @@ -0,0 +1,51 @@ +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" + +const ChatButton = styled(PrimaryToggleButton)` + padding: 0; +` + +const TooltipWrapper = styled(Box).attrs({ justifyContent: "center" })` + width: 100%; + height: 100%; +` + +export const AIChatButton = () => { + const { chatWindowState, openOrCreateBlankChatWindow, closeChatWindow } = + useAIConversation() + const { canUse } = useAIStatus() + + if (!canUse) { + return null + } + + const handleClick = () => { + if (chatWindowState.isOpen) { + closeChatWindow() + } else { + openOrCreateBlankChatWindow() + } + } + + return ( + + + + + } + placement="left" + tooltip="AI Assistant" + /> + + ) +} diff --git a/src/scenes/Layout/index.tsx b/src/scenes/Layout/index.tsx index df44608f7..e029c14ed 100644 --- a/src/scenes/Layout/index.tsx +++ b/src/scenes/Layout/index.tsx @@ -29,19 +29,26 @@ import Console from "../Console" import SideMenu from "../SideMenu" import { Sidebar } from "../../components/Sidebar" import { TopBar } from "../../components/TopBar" +import { AIStatusIndicator } from "../../components/AIStatusIndicator" 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; @@ -101,30 +108,34 @@ const Layout = () => { return ( - - - -
- - - - -
- - - - - - - - - - -
- - - -