+
History is empty
) : (
@@ -638,6 +677,37 @@ export const Tabs = () => {
importedCount={importedCount}
skippedTabs={skippedTabs}
/>
+
+
+
+
+ {
+ void addBuffer()
+ }}
+ >
+
+ Editor tab
+
+ {
+ void addBuffer({
+ notebookViewState: createDefaultNotebookViewState(),
+ })
+ }}
+ >
+
+ Notebook tab
+
+
+
+
)
}
diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts
index 02883a90f..38f128262 100644
--- a/src/scenes/Editor/Monaco/utils.test.ts
+++ b/src/scenes/Editor/Monaco/utils.test.ts
@@ -1,5 +1,66 @@
import { describe, it, expect } from "vitest"
-import { isCursorInComment, isCursorInQuotedIdentifier } from "./utils"
+import {
+ getQueriesFromText,
+ isCursorInComment,
+ isCursorInQuotedIdentifier,
+} from "./utils"
+
+describe("getQueriesFromText", () => {
+ it("splits two simple statements", () => {
+ expect(getQueriesFromText("SELECT 1; SELECT 2;")).toEqual([
+ "SELECT 1",
+ "SELECT 2",
+ ])
+ })
+
+ it("returns empty for empty input", () => {
+ expect(getQueriesFromText("")).toEqual([])
+ expect(getQueriesFromText(" \n ")).toEqual([])
+ })
+
+ it("ignores semicolons inside strings", () => {
+ expect(getQueriesFromText("SELECT ';'; SELECT 'a;b'")).toEqual([
+ "SELECT ';'",
+ "SELECT 'a;b'",
+ ])
+ })
+
+ it("ignores semicolons in line comments", () => {
+ expect(
+ getQueriesFromText("SELECT 1 -- comment with ;\n; SELECT 2"),
+ ).toEqual(["SELECT 1 -- comment with ;", "SELECT 2"])
+ })
+
+ it("ignores semicolons in block comments", () => {
+ expect(getQueriesFromText("SELECT 1 /* a; b; */; SELECT 2")).toEqual([
+ "SELECT 1 /* a; b; */",
+ "SELECT 2",
+ ])
+ })
+
+ it("handles trailing statement with no semicolon", () => {
+ expect(getQueriesFromText("SELECT 1;\nSELECT 2")).toEqual([
+ "SELECT 1",
+ "SELECT 2",
+ ])
+ })
+
+ it("preserves WITH..SELECT and DECLARE prefixes (VWAP example)", () => {
+ const sql = `declare
+ @symbol := 'BTC-USDT'
+WITH sampled AS (SELECT 1 FROM trades)
+SELECT * FROM sampled;
+declare
+ @symbol := 'ETH-USDT'
+WITH sampled AS (SELECT 2 FROM trades)
+SELECT * FROM sampled;`
+ const out = getQueriesFromText(sql)
+ expect(out).toHaveLength(2)
+ expect(out[0]).toContain("BTC-USDT")
+ expect(out[0]).toContain("WITH sampled")
+ expect(out[1]).toContain("ETH-USDT")
+ })
+})
describe("isCursorInComment", () => {
it("returns false when cursor is in normal SQL", () => {
diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts
index 9107c8501..d34e57e95 100644
--- a/src/scenes/Editor/Monaco/utils.ts
+++ b/src/scenes/Editor/Monaco/utils.ts
@@ -67,6 +67,77 @@ export const stripSQLComments = (text: string): string =>
return match
})
+export const getQueriesFromText = (text: string): string[] => {
+ if (!text) return []
+ const queries: string[] = []
+ let buf = ""
+ let inSingle = false
+ let inDouble = false
+ let inLineComment = false
+ let inBlockComment = false
+
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]
+ const next = text[i + 1]
+
+ if (inLineComment) {
+ buf += ch
+ if (ch === "\n") inLineComment = false
+ continue
+ }
+ if (inBlockComment) {
+ buf += ch
+ if (ch === "*" && next === "/") {
+ buf += next
+ i++
+ inBlockComment = false
+ }
+ continue
+ }
+ if (inSingle) {
+ buf += ch
+ if (ch === "'") inSingle = false
+ continue
+ }
+ if (inDouble) {
+ buf += ch
+ if (ch === '"') inDouble = false
+ continue
+ }
+
+ if (ch === "-" && next === "-") {
+ buf += ch
+ inLineComment = true
+ continue
+ }
+ if (ch === "/" && next === "*") {
+ buf += ch
+ inBlockComment = true
+ continue
+ }
+ if (ch === "'") {
+ inSingle = true
+ buf += ch
+ continue
+ }
+ if (ch === '"') {
+ inDouble = true
+ buf += ch
+ continue
+ }
+ if (ch === ";") {
+ const trimmed = buf.trim()
+ if (trimmed) queries.push(trimmed)
+ buf = ""
+ continue
+ }
+ buf += ch
+ }
+ const tail = buf.trim()
+ if (tail) queries.push(tail)
+ return queries
+}
+
export const getSelectedText = (
editor: IStandaloneCodeEditor,
): string | undefined => {
@@ -169,7 +240,6 @@ export const getQueriesFromPosition = (
column: editorPosition.column,
}
- // Calculate starting position - default to beginning if not provided
const start = startPosition
? {
row: startPosition.lineNumber - 1,
@@ -177,14 +247,13 @@ export const getQueriesFromPosition = (
}
: { row: 0, column: 1 }
- // Convert start position to character index
let startCharIndex = 0
if (startPosition) {
const lines = text.split("\n")
const maxRow = Math.min(start.row, lines.length - 1)
for (let i = 0; i < maxRow; i++) {
if (lines[i] !== undefined) {
- startCharIndex += lines[i].length + 1 // +1 for newline character
+ startCharIndex += lines[i].length + 1
}
}
if (lines[maxRow] !== undefined) {
@@ -412,8 +481,6 @@ export const getQueriesFromPosition = (
}
}
- // lastStackItem is the last query that is completed before the current cursor position.
- // nextSql is the next query that is not completed before the current cursor position, or started after the current cursor position.
if (!nextSql) {
const sqlText =
startPos === -1
@@ -708,6 +775,28 @@ export const getQueryRequestFromLastExecutedQuery = (
}
}
+export const getQueryRequestFromAISuggestion = (
+ editor: IStandaloneCodeEditor,
+ aiSuggestion: { query: string; startOffset: number },
+): Request | undefined => {
+ const model = editor.getModel()
+ if (!model) return undefined
+
+ const position = model.getPositionAt(aiSuggestion.startOffset)
+
+ const lines = aiSuggestion.query.split("\n")
+ const endRow = lines.length
+ const endColumn = lines[lines.length - 1].length + 1
+
+ return {
+ query: aiSuggestion.query,
+ 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,
@@ -800,48 +889,222 @@ const insertText = ({
])
}
-export const appendQuery = (editor: IStandaloneCodeEditor, query: string) => {
- const model = editor.getModel()
- const position = editor.getPosition()
+const getTextFixes = ({
+ appendAt,
+ model,
+ position,
+}: {
+ appendAt: AppendQueryOptions["appendAt"]
+ model: ReturnType
+ position: IPosition
+}): {
+ prefix: number
+ suffix: number
+ lineStartOffset: number
+ selectStartOffset: number
+} => {
+ const isFirstLine = position.lineNumber === 1
+ const isLastLine =
+ position.lineNumber === model?.getValue().split("\n").length
+ const lineAtCursor = model?.getLineContent(position.lineNumber)
+ const nextLine = isLastLine
+ ? undefined
+ : model?.getLineContent(position.lineNumber + 1)
+ const inMiddle = !isFirstLine && !isLastLine
+
+ type Rule = {
+ when: () => boolean
+ then: () => {
+ prefix?: number
+ suffix?: number
+ lineStartOffset?: number
+ selectStartOffset?: number
+ }
+ }
- if (model && position) {
- const existing = model.getValue()
- const isFirstLine = position.lineNumber === 1
+ const defaultResult = {
+ prefix: 1,
+ suffix: 2,
+ lineStartOffset: 1,
+ selectStartOffset: 0,
+ }
- const lastPosition = getLastPosition(editor)
- const { nextSql } = lastPosition
- ? getQueriesFromPosition(editor, lastPosition)
- : { nextSql: null }
- const needsSemicolon = nextSql !== null
+ const rules: { [key in AppendQueryOptions["appendAt"]]: Rule[] } = {
+ end: [
+ {
+ when: () => isFirstLine,
+ then: () => ({ prefix: 1, suffix: 0 }),
+ },
- const newQueryLines = query.split("\n")
- const prefix = isFirstLine ? 1 : 2
- const selectStartOffset = isFirstLine ? 0 : 1
+ {
+ when: () => true,
+ then: () => ({ prefix: 2, suffix: 0, selectStartOffset: 1 }),
+ },
+ ],
- const lineStart = existing.split("\n").length + 1
- const positionSelect = {
- lineStart: lineStart + selectStartOffset,
- lineEnd: lineStart + selectStartOffset + (newQueryLines.length - 1),
+ cursor: [
+ {
+ when: () => model?.getValue() === "",
+ then: () => ({ prefix: 0, suffix: 1, lineStartOffset: 0 }),
+ },
+
+ {
+ when: () => isFirstLine && lineAtCursor === "",
+ then: () => ({
+ prefix: 0,
+ lineStartOffset: 0,
+ suffix: nextLine === "" ? 0 : 1,
+ }),
+ },
+
+ {
+ when: () => isFirstLine && lineAtCursor !== "",
+ then: () => ({
+ prefix: nextLine === "" ? 1 : 2,
+ suffix: 1,
+ selectStartOffset: 1,
+ }),
+ },
+
+ {
+ when: () => inMiddle && lineAtCursor === "",
+ then: () => ({
+ prefix: 0,
+ suffix: nextLine === "" ? 1 : 2,
+ }),
+ },
+
+ {
+ when: () => inMiddle && lineAtCursor !== "" && nextLine !== "",
+ then: () => ({ prefix: 1, suffix: 2 }),
+ },
+
+ {
+ when: () => inMiddle && lineAtCursor !== "" && nextLine === "",
+ then: () => ({ prefix: 1, suffix: 1, selectStartOffset: 1 }),
+ },
+
+ {
+ when: () => isLastLine,
+ then: () => ({
+ prefix: lineAtCursor === "" ? 1 : 2,
+ suffix: 1,
+ lineStartOffset: 1,
+ selectStartOffset: lineAtCursor === "" ? 0 : 1,
+ }),
+ },
+ ],
+ }
+
+ const result = (
+ rules[appendAt].find(({ when }) => when()) ?? { then: () => defaultResult }
+ ).then()
+
+ return {
+ ...defaultResult,
+ ...result,
+ }
+}
+
+const getInsertPosition = ({
+ model,
+ position,
+ lineStartOffset,
+ newQueryLines,
+ appendAt,
+}: {
+ model: ReturnType
+ position: IPosition
+ lineStartOffset: number
+ appendAt: AppendQueryOptions["appendAt"]
+ newQueryLines: string[]
+}): {
+ lineStart: number
+ lineEnd: number
+ columnStart: number
+ columnEnd: number
+} => {
+ if (appendAt === "cursor") {
+ return {
+ lineStart: position.lineNumber + lineStartOffset,
+ lineEnd: position.lineNumber + newQueryLines.length,
columnStart: 1,
columnEnd: newQueryLines[newQueryLines.length - 1].length + 1,
}
+ }
- insertText({
- editor,
- lineNumber: lineStart,
- column: 1,
- text: `${needsSemicolon ? ";" : ""}${"\n".repeat(prefix)}${query}`,
- })
+ const lineStart =
+ (model?.getValue().split("\n").length ?? 0) + lineStartOffset
+ return {
+ lineStart,
+ lineEnd: lineStart + newQueryLines.length,
+ columnStart: 1,
+ columnEnd: newQueryLines[newQueryLines.length - 1].length + 1,
+ }
+}
- editor.setSelection({
- startLineNumber: positionSelect.lineStart,
- endLineNumber: positionSelect.lineEnd,
- startColumn: positionSelect.columnStart,
- endColumn: positionSelect.columnEnd,
- })
+export type AppendQueryOptions = {
+ appendAt: "cursor" | "end"
+}
+
+export const appendQuery = (
+ editor: IStandaloneCodeEditor,
+ query: string,
+ options: AppendQueryOptions = { appendAt: "cursor" },
+) => {
+ const model = editor.getModel()
+
+ if (model) {
+ const position = editor.getPosition()
+
+ if (position) {
+ const newQueryLines = query.split("\n")
+
+ const { prefix, suffix, lineStartOffset, selectStartOffset } =
+ getTextFixes({
+ appendAt: options.appendAt,
+ model,
+ position,
+ })
+
+ const positionInsert = getInsertPosition({
+ model,
+ position,
+ lineStartOffset,
+ appendAt: options.appendAt,
+ newQueryLines,
+ })
+
+ const positionSelect = {
+ lineStart: positionInsert.lineStart + selectStartOffset,
+ lineEnd:
+ positionInsert.lineStart +
+ selectStartOffset +
+ (newQueryLines.length - 1),
+ columnStart: 1,
+ columnEnd: positionInsert.columnEnd,
+ }
+
+ insertText({
+ editor,
+ lineNumber: positionInsert.lineStart,
+ column: positionInsert.columnStart,
+ text: `${"\n".repeat(prefix)}${query}${"\n".repeat(suffix)}`,
+ })
+
+ editor.setSelection({
+ startLineNumber: positionSelect.lineStart,
+ endLineNumber: positionSelect.lineEnd,
+ startColumn: positionSelect.columnStart,
+ endColumn: positionSelect.columnEnd,
+ })
+ }
editor.focus()
- editor.revealLine(model.getLineCount())
+
+ if (options.appendAt === "end") {
+ editor.revealLine(model.getLineCount())
+ }
}
}
@@ -888,14 +1151,7 @@ export const normalizeQueryText = (query: string) => {
}
export const findMatches = (model: editor.ITextModel, needle: string) =>
- model.findMatches(
- needle /* searchString */,
- true /* searchOnlyEditableRange */,
- false /* isRegex */,
- true /* matchCase */,
- null /* wordSeparators */,
- true /* captureMatches */,
- ) ?? null
+ model.findMatches(needle, true, false, true, null, true) ?? null
export const getLastPosition = (
editor: IStandaloneCodeEditor,
@@ -1159,7 +1415,6 @@ export const validateQueryJIT = (
const queryText = normalizeQueryText(queryAtCursor.query)
const version = model.getVersionId()
- // Skip if already validated this exact query+version
const cached = validationRefs[bufferKey]
if (cached && cached.queryText === queryText && cached.version === version) {
return
@@ -1173,7 +1428,6 @@ export const validateQueryJIT = (
return
}
- // Skip if execution result already exists for this query
const queryKey = createQueryKeyFromRequest(editor, queryAtCursor)
const bufferExecutions = getBufferExecutions()
if (bufferExecutions[queryKey]) {
@@ -1184,7 +1438,6 @@ export const validateQueryJIT = (
return
}
- // Abort any previous in-flight validation for this buffer
validationControllers[bufferKey]?.abort()
const controller = new AbortController()
validationControllers[bufferKey] = controller
@@ -1205,7 +1458,6 @@ export const validateQueryJIT = (
return
}
- // Query was executed while validation was in flight — skip
if (getBufferExecutions()[queryKey]) return
if ("error" in result) {
@@ -1241,15 +1493,12 @@ export const validateQueryJIT = (
}
})
.catch(() => {
- // Abort or network error — silently ignore
if (validationControllers[bufferKey] === controller) {
delete validationControllers[bufferKey]
}
})
}
-// Creates a QueryKey for schema explanation conversations
-// Uses DDL hash so same schema = same queryKey = cached conversation
export const createSchemaQueryKey = (
tableName: string,
ddl: string,
@@ -1258,37 +1507,29 @@ export const createSchemaQueryKey = (
return `schema:${tableName}:${ddlHash}@0-0` as QueryKey
}
-/**
- * Check if cursor is inside a line comment (--) or block comment.
- * Skips over string literals and quoted identifiers so quotes
- * inside comments don't cause false positives.
- */
export function isCursorInComment(text: string, cursorOffset: number): boolean {
let i = 0
const end = Math.min(cursorOffset, text.length)
while (i < end) {
const ch = text[i]
const next = text[i + 1]
- // Line comment: -- until end of line
if (ch === "-" && next === "-") {
i += 2
while (i < end && text[i] !== "\n") i++
if (i >= cursorOffset) return true
continue
}
- // Block comment: /* until */
if (ch === "/" && next === "*") {
i += 2
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
if (i >= cursorOffset) return true
- i += 2 // skip */
+ i += 2
continue
}
- // Skip over string literals and quoted identifiers so quotes inside comments don't confuse us
if (ch === "'" || ch === '"') {
i++
while (i < text.length && text[i] !== ch) i++
- i++ // skip closing quote
+ i++
continue
}
i++
@@ -1296,12 +1537,7 @@ export function isCursorInComment(text: string, cursorOffset: number): boolean {
return false
}
-/**
- * Check if cursor is inside a double-quoted identifier (e.g. "my-table").
- * Tracks quote state from `startOffset` to `cursorOffset`, handling
- * escaped quotes (""), single-quoted strings, and comments.
- * Returns the offset of the opening " if inside, or -1 if not.
- */
+// Returns the offset of the opening `"` if cursor is inside a double-quoted identifier, else -1.
export function isCursorInQuotedIdentifier(
text: string,
startOffset: number,
@@ -1322,14 +1558,12 @@ export function isCursorInQuotedIdentifier(
if (ch === '"' && next === '"') i++
else if (ch === '"') inDouble = false
} else if (ch === "-" && next === "-") {
- // Skip line comment
i += 2
while (i < cursorOffset && text[i] !== "\n") i++
} else if (ch === "/" && next === "*") {
- // Skip block comment
i += 2
while (i < cursorOffset && !(text[i] === "*" && text[i + 1] === "/")) i++
- i++ // skip past */
+ i++
} else if (ch === '"') {
inDouble = true
openQuoteOffset = i
@@ -1339,3 +1573,46 @@ export function isCursorInQuotedIdentifier(
}
return inDouble ? openQuoteOffset : -1
}
+
+export const pinMonacoContextMenu = (
+ editor: IStandaloneCodeEditor,
+): (() => void) => {
+ const cleanups: Array<() => void> = []
+ const containerDomNode = editor.getContainerDomNode()
+ const contextMenuObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of Array.from(mutation.addedNodes)) {
+ if (
+ node instanceof HTMLElement &&
+ node.classList.contains("context-view") &&
+ node.classList.contains("monaco-menu-container")
+ ) {
+ const rect = node.getBoundingClientRect()
+ node.style.position = "fixed"
+ node.style.left = `${rect.left}px`
+ node.style.top = `${rect.top}px`
+
+ // Monaco reuses the node on subsequent opens, resetting styles.
+ const styleObserver = new MutationObserver(() => {
+ if (node.style.position !== "fixed") {
+ const rect = node.getBoundingClientRect()
+ node.style.position = "fixed"
+ node.style.left = `${rect.left}px`
+ node.style.top = `${rect.top}px`
+ }
+ })
+ styleObserver.observe(node, {
+ attributes: true,
+ attributeFilter: ["style"],
+ })
+ cleanups.push(() => styleObserver.disconnect())
+ }
+ }
+ }
+ })
+ contextMenuObserver.observe(containerDomNode, { childList: true })
+ cleanups.push(() => contextMenuObserver.disconnect())
+ return () => {
+ for (const c of cleanups) c()
+ }
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx b/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx
new file mode 100644
index 000000000..870194e1f
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/ChartActions.tsx
@@ -0,0 +1,155 @@
+import React from "react"
+import styled from "styled-components"
+import {
+ ArrowClockwiseIcon,
+ ArrowsInLineVerticalIcon,
+ ArrowsOutLineVerticalIcon,
+ GearIcon,
+} from "@phosphor-icons/react"
+import { Reset } from "@styled-icons/boxicons-regular"
+import { Button } from "../../../../components"
+import { Switch } from "../../../../components/Switch"
+import { CellToolbar } from "../cells/CellToolbar"
+
+// Chart maximize uses ArrowsOutLineVertical/ArrowsInLineVertical (maximize INTO the cell)
+// to stay visually distinct from the cell's CornersOut/CornersIn (maximize INTO the buffer).
+const Bar = styled.div<{ $maximized: boolean }>`
+ position: absolute;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ background: ${({ theme }) => theme.color.backgroundDarker};
+
+ ${({ $maximized, theme }) =>
+ $maximized
+ ? `
+ top: 0;
+ left: 0;
+ right: 0;
+ min-height: 3.4rem;
+ padding: 0.5rem 1rem;
+ justify-content: space-between;
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+ `
+ : `
+ top: 0;
+ left: 0;
+ padding: 0.3rem 0.6rem;
+ border: 1px solid ${theme.color.selection};
+ border-radius: 0.6rem;
+ `}
+`
+
+const ToggleGroup = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+`
+
+const Cluster = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+`
+
+const ToggleLabel = styled.span`
+ font-size: 1.1rem;
+ color: ${({ theme }) => theme.color.gray2};
+ user-select: none;
+`
+
+const ResetIcon = styled(Reset)`
+ width: 1.8rem;
+ height: 1.8rem;
+`
+
+type Props = {
+ autoRefresh: boolean
+ onAutoRefreshChange: (value: boolean) => void
+ onManualRefresh: () => void
+ isMaximized: boolean
+ onMaximizedChange: (maximized: boolean) => void
+ onOpenSettings: () => void
+ canResetZoom?: boolean
+ onResetZoom?: () => void
+ cellId?: string
+}
+
+export const ChartActions: React.FC = ({
+ autoRefresh,
+ onAutoRefreshChange,
+ onManualRefresh,
+ isMaximized,
+ onMaximizedChange,
+ onOpenSettings,
+ canResetZoom,
+ onResetZoom,
+ cellId,
+}) => (
+
+
+
+ Auto-refresh
+
+
+ {canResetZoom && onResetZoom && (
+
+ )}
+ {!autoRefresh && (
+
+ )}
+
+
+
+
+ {isMaximized && cellId !== undefined && (
+
+ )}
+
+
+)
diff --git a/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx
new file mode 100644
index 000000000..c67973deb
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/ChartRenderer.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useImperativeHandle, useMemo, useRef } from "react"
+import ReactECharts from "echarts-for-react/lib/core"
+import type { EChartsOption } from "echarts"
+import { echarts, QUESTDB_THEME } from "./echartsSetup"
+
+export type ChartRendererHandle = {
+ resetZoom: () => void
+}
+
+type Props = {
+ option: EChartsOption
+ height?: number | string
+ onZoomChange?: (start: number, end: number) => void
+ isFocused?: boolean
+}
+
+// Structural fingerprint — we remount on changes here so stale series from
+// the prior option don't linger; routine data refreshes keep the key stable
+// so dataZoom state survives the merge.
+const structuralKey = (option: EChartsOption): string => {
+ const rawSeries = option.series
+ const series = Array.isArray(rawSeries)
+ ? rawSeries
+ : rawSeries
+ ? [rawSeries]
+ : []
+ const seriesTypes = series.map((s) => (s as { type?: string }).type ?? "")
+ const xAxis = Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis
+ const yAxis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis
+ const hasZoom = Array.isArray(option.dataZoom) && option.dataZoom.length > 0
+ return [
+ seriesTypes.join("|"),
+ (xAxis as { type?: string } | undefined)?.type ?? "",
+ (yAxis as { type?: string } | undefined)?.type ?? "",
+ hasZoom ? "z" : "nz",
+ ].join("::")
+}
+
+type DataZoomEvent = {
+ start?: number
+ end?: number
+ batch?: Array<{ start?: number; end?: number }>
+}
+
+export const ChartRenderer = React.forwardRef(
+ function ChartRenderer(
+ { option, height = "100%", onZoomChange, isFocused = true },
+ ref,
+ ) {
+ const reactEchartsRef = useRef(null)
+ const wrapperRef = useRef(null)
+
+ // Capture-phase wheel listener must intercept BEFORE ECharts' inner
+ // listeners so the page scrolls instead of ECharts preventDefaulting.
+ useEffect(() => {
+ if (isFocused) return
+ const node = wrapperRef.current
+ if (!node) return
+ const stop = (e: WheelEvent) => {
+ e.stopPropagation()
+ }
+ node.addEventListener("wheel", stop, { capture: true })
+ return () => node.removeEventListener("wheel", stop, { capture: true })
+ }, [isFocused])
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetZoom: () => {
+ const instance = reactEchartsRef.current?.getEchartsInstance()
+ instance?.dispatchAction({
+ type: "dataZoom",
+ start: 0,
+ end: 100,
+ })
+ },
+ }),
+ [],
+ )
+
+ const key = useMemo(() => structuralKey(option), [option])
+
+ const events = useMemo(() => {
+ if (!onZoomChange) return undefined
+ return {
+ datazoom: (evt: unknown) => {
+ const e = evt as DataZoomEvent
+ const first = e.batch?.[0] ?? e
+ if (
+ typeof first.start === "number" &&
+ typeof first.end === "number"
+ ) {
+ onZoomChange(first.start, first.end)
+ }
+ },
+ }
+ }, [onZoomChange])
+
+ return (
+
+
+
+ )
+ },
+)
diff --git a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx
new file mode 100644
index 000000000..9cbf0aba7
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx
@@ -0,0 +1,324 @@
+import React, { useEffect, useRef, useState } from "react"
+import styled, { keyframes } from "styled-components"
+import { XIcon } from "@phosphor-icons/react"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import { Button, Input, MultiSelect, Text } from "../../../../components"
+import { Select } from "../../../../components/Select"
+import type { ChartConfig, ChartType } from "./chartTypes"
+import { availableChartTypes, groupColumns } from "./inferChartConfig"
+
+const TYPE_LABELS: Record = {
+ line: "Line",
+ area: "Area",
+ bar: "Bar",
+ stackedBar: "Stacked bar",
+ scatter: "Scatter",
+ pie: "Pie",
+ candlestick: "Candlestick",
+}
+
+const fadeIn = keyframes`
+ from { opacity: 0; }
+ to { opacity: 1; }
+`
+
+const slideIn = keyframes`
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+`
+
+const Backdrop = styled.div`
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ background: rgba(0, 0, 0, 0.35);
+ animation: ${fadeIn} 0.2s ease both;
+`
+
+const Panel = styled.div`
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: min(36rem, 90%);
+ z-index: 4;
+ background: ${({ theme }) => theme.color.backgroundDarker};
+ border-left: 1px solid ${({ theme }) => theme.color.selection};
+ display: flex;
+ flex-direction: column;
+ animation: ${slideIn} 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
+`
+
+const Header = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.2rem;
+ border-bottom: 1px solid ${({ theme }) => theme.color.selection};
+`
+
+const Title = styled.h3`
+ margin: 0;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: ${({ theme }) => theme.color.foreground};
+`
+
+const Body = styled.form`
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.4rem;
+`
+
+const Field = styled.label`
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+`
+
+const FieldLabel = styled.span`
+ font-size: 1.1rem;
+ color: ${({ theme }) => theme.color.gray2};
+`
+
+const Footer = styled.div`
+ padding: 1rem 1.2rem;
+ border-top: 1px solid ${({ theme }) => theme.color.selection};
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.8rem;
+`
+
+type Props = {
+ open: boolean
+ onClose: () => void
+ columns: ColumnDefinition[]
+ config: ChartConfig
+ onSave: (next: ChartConfig) => void
+}
+
+export const ChartSettingsDrawer: React.FC = ({
+ open,
+ onClose,
+ columns,
+ config,
+ onSave,
+}) => {
+ const [draft, setDraft] = useState(config)
+ // Snapshot at open so commit diffs against it and only writes user-changed fields; otherwise external updates (auto-refresh columns, AI tool calls) would be wiped on Save.
+ const openSnapshotRef = useRef(config)
+ const latestConfigRef = useRef(config)
+ latestConfigRef.current = config
+ useEffect(() => {
+ if (open) {
+ setDraft(config)
+ openSnapshotRef.current = config
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return
+ if (
+ document.querySelector("[data-radix-popper-content-wrapper]") !== null
+ ) {
+ return
+ }
+ onClose()
+ e.stopImmediatePropagation()
+ }
+ window.addEventListener("keydown", onKey, { capture: true })
+ return () => window.removeEventListener("keydown", onKey, { capture: true })
+ }, [open, onClose])
+
+ if (!open) return null
+
+ const groups = groupColumns(columns)
+ const hasOhlc = !!draft.ohlc
+ const types = availableChartTypes(groups, hasOhlc)
+
+ const xCandidates =
+ draft.type === "candlestick" || draft.type === "line"
+ ? [...groups.temporal, ...groups.categorical]
+ : draft.type === "scatter"
+ ? groups.numeric
+ : [...groups.categorical, ...groups.temporal, ...groups.numeric]
+
+ const yCandidates = groups.numeric
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ commit()
+ }
+
+ const commit = () => {
+ const snapshot = openSnapshotRef.current
+ const latest = latestConfigRef.current
+ const userChanges: Partial = {}
+ for (const key of Object.keys(draft) as Array) {
+ if (draft[key] !== snapshot[key]) {
+ ;(userChanges as Record)[key] = draft[key]
+ }
+ }
+ onSave({ ...latest, ...userChanges })
+ onClose()
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ Name
+
+ setDraft((d) => ({ ...d, name: e.target.value }))
+ }
+ />
+
+
+
+ Type
+
+
+ {draft.type !== "pie" && (
+
+ X-axis
+
+ )}
+
+ {draft.type === "pie" && (
+
+ Category
+
+ )}
+
+ {draft.type !== "candlestick" && (
+
+
+ {draft.type === "pie" ? "Value" : "Series"}
+ {yCandidates.length > 8 && (
+
+ {" "}
+ · {draft.yColumns.length}/{yCandidates.length}
+
+ )}
+
+ {draft.type === "pie" ? (
+
+ )}
+
+ {(draft.type === "line" ||
+ draft.type === "area" ||
+ draft.type === "bar" ||
+ draft.type === "stackedBar") &&
+ groups.categorical.length > 0 && (
+
+ Partition by
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts
new file mode 100644
index 000000000..9f755e0ed
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/buildEchartsOption.ts
@@ -0,0 +1,416 @@
+import type { EChartsOption } from "echarts"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import type { ChartConfig } from "./chartTypes"
+import {
+ MAX_PARTITION_SERIES,
+ classifyColumn,
+ groupColumns,
+} from "./inferChartConfig"
+
+export type ExtraSeriesSource = {
+ label: string
+ columns: ColumnDefinition[]
+ dataset: (boolean | string | number | null)[][]
+}
+
+const DATAZOOM_THRESHOLD = 200
+
+// Bottom-band layout: legend → dataZoom slider → x-axis labels, stacking
+// up from the container bottom. Gaps absorb rendering jitter so the three
+// never overlap at fontSize=12.
+const LEGEND_BOTTOM = 3
+const SLIDER_HEIGHT = 18
+const SLIDER_BOTTOM = 40
+const GRID_BOTTOM_NO_ZOOM = 48
+const GRID_BOTTOM_WITH_ZOOM = 78
+
+type Dataset = (boolean | string | number | null)[][]
+
+const indexByName = (columns: ColumnDefinition[]): Map => {
+ const m = new Map()
+ columns.forEach((c, i) => m.set(c.name, i))
+ return m
+}
+
+const toNumberOrNull = (v: unknown): number | null => {
+ if (v === null || v === undefined) return null
+ if (typeof v === "number") return v
+ if (typeof v === "boolean") return v ? 1 : 0
+ const n = Number(v)
+ return Number.isFinite(n) ? n : null
+}
+
+const toLabel = (v: unknown): string => {
+ if (v === null || v === undefined) return ""
+ if (typeof v === "string") return v
+ if (typeof v === "number" || typeof v === "boolean") return String(v)
+ return JSON.stringify(v)
+}
+
+const extractColumn = (dataset: Dataset, idx: number): unknown[] =>
+ dataset.map((row) => row[idx])
+
+export const buildEchartsOption = (
+ config: ChartConfig,
+ columns: ColumnDefinition[],
+ dataset: Dataset,
+ extraSources: ExtraSeriesSource[] = [],
+): EChartsOption => {
+ const idx = indexByName(columns)
+ const xIdx = config.xColumn != null ? idx.get(config.xColumn) : undefined
+ const xCol =
+ config.xColumn != null
+ ? columns.find((c) => c.name === config.xColumn)
+ : undefined
+ const xRole = xCol ? classifyColumn(xCol) : "other"
+ const xIsTime = xRole === "temporal"
+
+ const CHART_FONT_SIZE = 12
+ const chartText = { fontSize: CHART_FONT_SIZE }
+ const axisLabel = { fontSize: CHART_FONT_SIZE }
+ const axisName = { fontSize: CHART_FONT_SIZE }
+
+ const baseTooltip: EChartsOption["tooltip"] =
+ config.type === "pie" || config.type === "candlestick"
+ ? { trigger: "item", textStyle: chartText }
+ : { trigger: "axis", textStyle: chartText }
+
+ const baseLegend = {
+ type: "scroll" as const,
+ bottom: LEGEND_BOTTOM,
+ textStyle: chartText,
+ }
+
+ const hasZoom = dataset.length > DATAZOOM_THRESHOLD
+ const sliderZoom = {
+ type: "slider" as const,
+ height: SLIDER_HEIGHT,
+ bottom: SLIDER_BOTTOM,
+ textStyle: chartText,
+ }
+
+ const trimmedName = config.name?.trim() ?? ""
+ const hasName = trimmedName.length > 0
+ const baseTitle: EChartsOption["title"] | undefined = hasName
+ ? {
+ text: trimmedName,
+ left: "center",
+ top: 8,
+ textStyle: { color: "#f8f8f2", fontSize: 18, fontWeight: "bold" },
+ }
+ : undefined
+
+ // Reserves space past the plot for two things containLabel doesn't:
+ // the x-axis name (nameLocation:"end") and the rightmost tick label's
+ // overhang on long timestamp formats.
+ const NAME_CHAR_WIDTH = 6
+ const NAME_GAP = 15
+ const TICK_OVERHANG_FUDGE = 12
+ const MAX_RIGHT = 120
+ const xAxisNameLen = (config.xColumn ?? "").length
+ const rightPadding =
+ xAxisNameLen > 0
+ ? Math.min(
+ MAX_RIGHT,
+ NAME_GAP + xAxisNameLen * NAME_CHAR_WIDTH + TICK_OVERHANG_FUDGE,
+ )
+ : TICK_OVERHANG_FUDGE
+
+ const baseGrid: EChartsOption["grid"] = {
+ left: 24,
+ right: rightPadding,
+ top: hasName ? 72 : 24,
+ bottom: hasZoom ? GRID_BOTTOM_WITH_ZOOM : GRID_BOTTOM_NO_ZOOM,
+ containLabel: true,
+ }
+
+ if (config.type === "pie" && xIdx !== undefined && config.yColumns[0]) {
+ const yIdx = idx.get(config.yColumns[0])
+ if (yIdx === undefined) return { tooltip: baseTooltip }
+ const data = dataset.map((row) => ({
+ name: toLabel(row[xIdx]),
+ value: toNumberOrNull(row[yIdx]) ?? 0,
+ }))
+ return {
+ title: baseTitle,
+ tooltip: { trigger: "item", textStyle: chartText },
+ legend: baseLegend,
+ series: [
+ {
+ name: config.yColumns[0],
+ type: "pie",
+ radius: ["35%", "70%"],
+ avoidLabelOverlap: true,
+ label: {
+ show: true,
+ formatter: "{b}: {d}%",
+ fontSize: CHART_FONT_SIZE,
+ },
+ data,
+ },
+ ],
+ }
+ }
+
+ // echarts candlestick order: [open, close, low, high].
+ if (config.type === "candlestick" && config.ohlc && xIdx !== undefined) {
+ const oIdx = idx.get(config.ohlc.open)
+ const cIdx = idx.get(config.ohlc.close)
+ const lIdx = idx.get(config.ohlc.low)
+ const hIdx = idx.get(config.ohlc.high)
+ if (
+ oIdx === undefined ||
+ cIdx === undefined ||
+ lIdx === undefined ||
+ hIdx === undefined
+ ) {
+ return { tooltip: baseTooltip }
+ }
+ const xData = extractColumn(dataset, xIdx)
+ const candleData = dataset.map((row) => [
+ toNumberOrNull(row[oIdx]) ?? 0,
+ toNumberOrNull(row[cIdx]) ?? 0,
+ toNumberOrNull(row[lIdx]) ?? 0,
+ toNumberOrNull(row[hIdx]) ?? 0,
+ ])
+ return {
+ title: baseTitle,
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "cross" },
+ textStyle: chartText,
+ },
+ legend: { bottom: LEGEND_BOTTOM, textStyle: chartText },
+ grid: baseGrid,
+ xAxis: xIsTime
+ ? {
+ type: "time",
+ name: config.xColumn ?? "",
+ axisLabel,
+ nameTextStyle: axisName,
+ // Extend the time axis 5% on each side so candle bodies sit
+ // inside the plot (time axis is boundaryGap:false by default).
+ min: (v) => v.min - (v.max - v.min) * 0.05,
+ max: (v) => v.max + (v.max - v.min) * 0.05,
+ }
+ : {
+ type: "category",
+ data: xData.map(toLabel),
+ name: config.xColumn ?? "",
+ axisLabel,
+ nameTextStyle: axisName,
+ boundaryGap: true,
+ },
+ yAxis: {
+ type: "value",
+ scale: true,
+ axisLabel,
+ nameTextStyle: axisName,
+ },
+ dataZoom: hasZoom ? [{ type: "inside" }, sliderZoom] : undefined,
+ series: [
+ {
+ name: "OHLC",
+ type: "candlestick",
+ data: (xIsTime
+ ? candleData.map((v, i) => [xData[i], ...v])
+ : candleData) as never,
+ },
+ ],
+ }
+ }
+
+ if (config.type === "scatter" && xIdx !== undefined) {
+ const series = config.yColumns
+ .map((name) => {
+ const yIdx = idx.get(name)
+ if (yIdx === undefined) return null
+ return {
+ name,
+ type: "scatter" as const,
+ large: true,
+ data: dataset.map((row) => [
+ toNumberOrNull(row[xIdx]) ?? 0,
+ toNumberOrNull(row[yIdx]) ?? 0,
+ ]),
+ }
+ })
+ .filter((s): s is NonNullable => s !== null)
+ return {
+ title: baseTitle,
+ tooltip: { trigger: "item", textStyle: chartText },
+ legend: baseLegend,
+ grid: baseGrid,
+ xAxis: {
+ type: "value",
+ scale: true,
+ name: config.xColumn ?? "",
+ axisLabel,
+ nameTextStyle: axisName,
+ },
+ yAxis: {
+ type: "value",
+ scale: true,
+ axisLabel,
+ nameTextStyle: axisName,
+ },
+ series,
+ }
+ }
+
+ if (xIdx === undefined) return { tooltip: baseTooltip }
+
+ const xData = extractColumn(dataset, xIdx)
+ const isLineFamily = config.type === "line" || config.type === "area"
+ const isStacked = config.type === "stackedBar"
+ const seriesType: "line" | "bar" = isLineFamily ? "line" : "bar"
+
+ const lineExtras =
+ config.type === "area"
+ ? { areaStyle: {}, smooth: true, symbol: "none" as const }
+ : {}
+
+ const perfExtras: { sampling?: "lttb"; large?: boolean } = isLineFamily
+ ? { sampling: "lttb" }
+ : { large: true }
+
+ // Long → wide pivot: each distinct partitionByColumn value → its own series.
+ const partIdx =
+ config.partitionByColumn != null
+ ? idx.get(config.partitionByColumn)
+ : undefined
+
+ const series: {
+ name: string
+ type: "line" | "bar"
+ data: never
+ areaStyle?: object
+ smooth?: boolean
+ symbol?: "none"
+ stack?: string
+ sampling?: "lttb"
+ large?: boolean
+ }[] = []
+
+ if (partIdx !== undefined) {
+ const groups = new Map<
+ string,
+ Map
+ >()
+ for (const row of dataset) {
+ const partVal = toLabel(row[partIdx])
+ let metricMap = groups.get(partVal)
+ if (!metricMap) {
+ metricMap = new Map()
+ groups.set(partVal, metricMap)
+ }
+ for (const yName of config.yColumns) {
+ const yIdx = idx.get(yName)
+ if (yIdx === undefined) continue
+ let pts = metricMap.get(yName)
+ if (!pts) {
+ pts = []
+ metricMap.set(yName, pts)
+ }
+ const yVal = toNumberOrNull(row[yIdx])
+ pts.push(xIsTime ? [row[xIdx], yVal] : [yVal])
+ }
+ }
+
+ // Alphabetical sort so the truncation is deterministic across runs.
+ const sortedKeys = [...groups.keys()].sort().slice(0, MAX_PARTITION_SERIES)
+ for (const partVal of sortedKeys) {
+ const metricMap = groups.get(partVal)
+ if (!metricMap) continue
+ for (const [yName, pts] of metricMap) {
+ series.push({
+ name: config.yColumns.length > 1 ? `${partVal} · ${yName}` : partVal,
+ type: seriesType,
+ data: (xIsTime ? pts : pts.map((p) => p[0])) as never,
+ ...lineExtras,
+ ...perfExtras,
+ ...(isStacked && { stack: "total" }),
+ })
+ }
+ }
+ } else {
+ for (const name of config.yColumns) {
+ const yIdx = idx.get(name)
+ if (yIdx === undefined) continue
+ const data = xIsTime
+ ? dataset.map((row) => [row[xIdx], toNumberOrNull(row[yIdx])])
+ : dataset.map((row) => toNumberOrNull(row[yIdx]))
+ series.push({
+ name,
+ type: seriesType,
+ data: data as never,
+ ...lineExtras,
+ ...perfExtras,
+ ...(isStacked && { stack: "total" }),
+ })
+ }
+ }
+
+ // Sibling-tab overlays only make sense with a temporal x-axis (echarts
+ // merges by time so we don't have to align categories).
+ if (xIsTime && extraSources.length > 0) {
+ const yColumnSet = new Set(config.yColumns)
+ const anchorColumnNames = new Set(columns.map((c) => c.name))
+ for (const src of extraSources) {
+ const srcGroups = groupColumns(src.columns)
+ const srcXCol = srcGroups.temporal[0]
+ if (!srcXCol) continue
+ const srcXIdx = src.columns.findIndex((c) => c.name === srcXCol.name)
+ if (srcXIdx < 0) continue
+ for (const numericCol of srcGroups.numeric) {
+ if (!yColumnSet.has(numericCol.name)) continue
+ // Skip names already emitted by the anchor branch above to avoid
+ // duplicate legend entries.
+ if (anchorColumnNames.has(numericCol.name)) continue
+ const yI = src.columns.findIndex((c) => c.name === numericCol.name)
+ if (yI < 0) continue
+ const data = src.dataset.map((row) => [
+ row[srcXIdx],
+ toNumberOrNull(row[yI]),
+ ])
+ series.push({
+ name: numericCol.name,
+ type: seriesType,
+ data: data as never,
+ ...lineExtras,
+ ...perfExtras,
+ })
+ }
+ }
+ }
+
+ return {
+ title: baseTitle,
+ tooltip: baseTooltip,
+ legend: baseLegend,
+ grid: baseGrid,
+ xAxis: xIsTime
+ ? {
+ type: "time",
+ name: config.xColumn ?? "",
+ axisLabel,
+ nameTextStyle: axisName,
+ }
+ : {
+ type: "category",
+ data: xData.map(toLabel),
+ name: config.xColumn ?? "",
+ boundaryGap: seriesType === "bar",
+ axisLabel,
+ nameTextStyle: axisName,
+ },
+ yAxis: {
+ type: "value",
+ scale: true,
+ axisLabel,
+ nameTextStyle: axisName,
+ },
+ dataZoom: hasZoom ? [{ type: "inside" }, sliderZoom] : undefined,
+ series,
+ }
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/chartTypes.ts b/src/scenes/Editor/Notebook/CellChart/chartTypes.ts
new file mode 100644
index 000000000..d0e163517
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/chartTypes.ts
@@ -0,0 +1,34 @@
+export const CHART_TYPES = [
+ "line",
+ "area",
+ "bar",
+ "stackedBar",
+ "scatter",
+ "pie",
+ "candlestick",
+] as const
+
+export type ChartType = (typeof CHART_TYPES)[number]
+
+export type ColumnRole = "temporal" | "numeric" | "categorical" | "other"
+
+export type ChartConfig = {
+ type: ChartType
+ xColumn: string | null
+ yColumns: string[]
+ // For candlestick — explicit OHLC mapping
+ ohlc?: {
+ open: string
+ high: string
+ low: string
+ close: string
+ }
+ autoRefresh?: boolean
+ // Categorical column whose distinct values become separate series — the
+ // long-format pivot QuestDB users know as `PARTITION BY`. With this set,
+ // every distinct value of the column produces its own line.
+ partitionByColumn?: string
+ // User-provided chart name, rendered as the echarts title. When empty a
+ // placeholder is shown instead.
+ name?: string
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/echartsSetup.ts b/src/scenes/Editor/Notebook/CellChart/echartsSetup.ts
new file mode 100644
index 000000000..a862663af
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/echartsSetup.ts
@@ -0,0 +1,40 @@
+import * as echarts from "echarts/core"
+import {
+ BarChart,
+ LineChart,
+ PieChart,
+ ScatterChart,
+ CandlestickChart,
+} from "echarts/charts"
+import {
+ GridComponent,
+ TooltipComponent,
+ LegendComponent,
+ DataZoomComponent,
+ ToolboxComponent,
+ TitleComponent,
+} from "echarts/components"
+import { CanvasRenderer } from "echarts/renderers"
+import { questdbTheme } from "./questdbTheme"
+
+echarts.use([
+ BarChart,
+ LineChart,
+ PieChart,
+ ScatterChart,
+ CandlestickChart,
+ GridComponent,
+ TooltipComponent,
+ LegendComponent,
+ DataZoomComponent,
+ ToolboxComponent,
+ TitleComponent,
+ CanvasRenderer,
+])
+
+export const QUESTDB_THEME = "questdb"
+echarts.registerTheme(QUESTDB_THEME, questdbTheme)
+
+// Pair with `echarts-for-react/lib/core` — importing the default wrapper
+// instead would pull the full echarts catalog and undo the tree-shaking.
+export { echarts }
diff --git a/src/scenes/Editor/Notebook/CellChart/inferChartConfig.test.ts b/src/scenes/Editor/Notebook/CellChart/inferChartConfig.test.ts
new file mode 100644
index 000000000..95677d820
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/inferChartConfig.test.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect } from "vitest"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import {
+ inferChartConfig,
+ isResultChartable,
+ classifyColumn,
+} from "./inferChartConfig"
+
+const col = (name: string, type: string): ColumnDefinition => ({ name, type })
+
+describe("classifyColumn", () => {
+ it("identifies temporal", () => {
+ expect(classifyColumn(col("ts", "TIMESTAMP"))).toBe("temporal")
+ expect(classifyColumn(col("ts", "TIMESTAMP_NS"))).toBe("temporal")
+ expect(classifyColumn(col("d", "DATE"))).toBe("temporal")
+ })
+ it("identifies numeric", () => {
+ expect(classifyColumn(col("p", "DOUBLE"))).toBe("numeric")
+ expect(classifyColumn(col("p", "LONG"))).toBe("numeric")
+ })
+ it("identifies categorical", () => {
+ expect(classifyColumn(col("s", "SYMBOL"))).toBe("categorical")
+ expect(classifyColumn(col("s", "VARCHAR"))).toBe("categorical")
+ })
+ it("flags arrays/uuid as other", () => {
+ expect(classifyColumn(col("u", "UUID"))).toBe("other")
+ expect(classifyColumn(col("a", "ARRAY"))).toBe("other")
+ })
+})
+
+describe("isResultChartable", () => {
+ it("true with at least one numeric/temporal/categorical", () => {
+ expect(isResultChartable([col("p", "DOUBLE")])).toBe(true)
+ })
+ it("false when all columns are non-chartable", () => {
+ expect(isResultChartable([col("u", "UUID"), col("b", "BINARY")])).toBe(
+ false,
+ )
+ })
+})
+
+describe("inferChartConfig", () => {
+ it("picks candlestick for OHLC + temporal", () => {
+ const columns = [
+ col("ts", "TIMESTAMP"),
+ col("open", "DOUBLE"),
+ col("high", "DOUBLE"),
+ col("low", "DOUBLE"),
+ col("close", "DOUBLE"),
+ ]
+ const config = inferChartConfig(
+ columns,
+ [],
+ "SELECT ts, open, high, low, close FROM t SAMPLE BY 1h",
+ )
+ expect(config.type).toBe("candlestick")
+ expect(config.xColumn).toBe("ts")
+ expect(config.ohlc).toEqual({
+ open: "open",
+ high: "high",
+ low: "low",
+ close: "close",
+ })
+ })
+
+ it("OHLC detection is case-insensitive", () => {
+ const columns = [
+ col("ts", "TIMESTAMP"),
+ col("Open", "DOUBLE"),
+ col("HIGH", "DOUBLE"),
+ col("Low", "DOUBLE"),
+ col("CLOSE", "DOUBLE"),
+ ]
+ const config = inferChartConfig(columns, [], "SELECT * FROM t")
+ expect(config.type).toBe("candlestick")
+ })
+
+ it("picks line for temporal + numeric without OHLC", () => {
+ const columns = [col("ts", "TIMESTAMP"), col("price", "DOUBLE")]
+ const config = inferChartConfig(
+ columns,
+ [],
+ "SELECT ts, price FROM trades SAMPLE BY 1m",
+ )
+ expect(config.type).toBe("line")
+ expect(config.xColumn).toBe("ts")
+ expect(config.yColumns).toEqual(["price"])
+ expect(config.partitionByColumn).toBeUndefined()
+ })
+
+ it("auto-partitions long-format temporal + categorical + numeric", () => {
+ const columns = [
+ col("time", "TIMESTAMP"),
+ col("symbol", "SYMBOL"),
+ col("rsi_14", "DOUBLE"),
+ ]
+ const dataset: (string | number)[][] = [
+ ["2024-01-01", "BTC-USDT", 60],
+ ["2024-01-01", "ETH-USDT", 55],
+ ["2024-01-02", "BTC-USDT", 62],
+ ["2024-01-02", "ETH-USDT", 50],
+ ]
+ const config = inferChartConfig(
+ columns,
+ dataset,
+ "SELECT time, symbol, rsi_14 FROM ...",
+ )
+ expect(config.type).toBe("line")
+ expect(config.xColumn).toBe("time")
+ expect(config.partitionByColumn).toBe("symbol")
+ expect(config.yColumns).toEqual(["rsi_14"])
+ })
+
+ it("does not auto-partition when categorical cardinality is too high", () => {
+ const columns = [
+ col("time", "TIMESTAMP"),
+ col("trade_id", "SYMBOL"),
+ col("price", "DOUBLE"),
+ ]
+ const dataset: (string | number)[][] = Array.from(
+ { length: 50 },
+ (_, i) => ["2024-01-01", `id-${i}`, i],
+ )
+ const config = inferChartConfig(
+ columns,
+ dataset,
+ "SELECT time, trade_id, price FROM ...",
+ )
+ expect(config.type).toBe("line")
+ expect(config.partitionByColumn).toBeUndefined()
+ expect(config.yColumns).toEqual(["price"])
+ })
+
+ it("picks pie for low-cardinality categorical + numeric", () => {
+ const columns = [col("symbol", "SYMBOL"), col("c", "LONG")]
+ const dataset: (string | number)[][] = [
+ ["BTC", 10],
+ ["ETH", 8],
+ ["SOL", 5],
+ ]
+ const config = inferChartConfig(
+ columns,
+ dataset,
+ "SELECT symbol, count() FROM trades",
+ )
+ expect(config.type).toBe("pie")
+ expect(config.xColumn).toBe("symbol")
+ expect(config.yColumns).toEqual(["c"])
+ })
+
+ it("picks bar for high-cardinality categorical + numeric", () => {
+ const columns = [col("symbol", "SYMBOL"), col("c", "LONG")]
+ const dataset: (string | number)[][] = Array.from(
+ { length: 30 },
+ (_, i) => [`S${i}`, i],
+ )
+ const config = inferChartConfig(
+ columns,
+ dataset,
+ "SELECT symbol, count() FROM trades",
+ )
+ expect(config.type).toBe("bar")
+ })
+
+ it("picks bar for LATEST ON snapshot", () => {
+ const columns = [col("symbol", "SYMBOL"), col("price", "DOUBLE")]
+ const config = inferChartConfig(
+ columns,
+ [
+ ["BTC", 1],
+ ["ETH", 2],
+ ],
+ "SELECT symbol, price FROM trades LATEST ON ts PARTITION BY symbol",
+ )
+ expect(config.type).toBe("bar")
+ expect(config.xColumn).toBe("symbol")
+ })
+
+ it("picks scatter when only numerics, no temporal", () => {
+ const columns = [col("x", "DOUBLE"), col("y", "DOUBLE")]
+ const config = inferChartConfig(columns, [], "SELECT x, y FROM t")
+ expect(config.type).toBe("scatter")
+ expect(config.xColumn).toBe("x")
+ expect(config.yColumns).toEqual(["y"])
+ })
+
+ it("falls back to bar for unclassifiable shapes", () => {
+ const columns = [col("u", "UUID"), col("b", "BINARY")]
+ const config = inferChartConfig(columns, [], "SELECT u, b FROM t")
+ expect(config.type).toBe("bar")
+ expect(config.xColumn).toBe("u")
+ expect(config.yColumns).toEqual(["b"])
+ })
+
+ it("caps default series at 8", () => {
+ const columns = [
+ col("ts", "TIMESTAMP"),
+ ...Array.from({ length: 12 }, (_, i) => col(`m${i}`, "DOUBLE")),
+ ]
+ const config = inferChartConfig(columns, [], "SELECT * FROM t")
+ expect(config.type).toBe("line")
+ expect(config.yColumns).toHaveLength(8)
+ })
+
+ it("does not throw on empty columns", () => {
+ const config = inferChartConfig([], [], "")
+ expect(config.type).toBe("bar")
+ expect(config.xColumn).toBeNull()
+ expect(config.yColumns).toEqual([])
+ })
+})
diff --git a/src/scenes/Editor/Notebook/CellChart/inferChartConfig.ts b/src/scenes/Editor/Notebook/CellChart/inferChartConfig.ts
new file mode 100644
index 000000000..a7c1b4965
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/inferChartConfig.ts
@@ -0,0 +1,229 @@
+import { parseToAst } from "@questdb/sql-parser"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import type { ChartConfig, ChartType, ColumnRole } from "./chartTypes"
+
+const TEMPORAL = new Set(["TIMESTAMP", "TIMESTAMP_NS", "DATE"])
+const NUMERIC = new Set([
+ "DOUBLE",
+ "FLOAT",
+ "INT",
+ "LONG",
+ "SHORT",
+ "BYTE",
+ "DECIMAL",
+])
+const CATEGORICAL = new Set(["SYMBOL", "STRING", "VARCHAR", "CHAR", "BOOLEAN"])
+
+const MAX_DEFAULT_SERIES = 8
+const PIE_THRESHOLD = 12
+export const MAX_AUTO_PARTITION_CARDINALITY = 20
+export const MAX_PARTITION_SERIES = 30
+
+export const classifyColumn = (col: ColumnDefinition): ColumnRole => {
+ const t = col.type?.toUpperCase()
+ if (!t) return "other"
+ if (TEMPORAL.has(t)) return "temporal"
+ if (NUMERIC.has(t)) return "numeric"
+ if (CATEGORICAL.has(t)) return "categorical"
+ return "other"
+}
+
+export type ColumnGroups = {
+ temporal: ColumnDefinition[]
+ numeric: ColumnDefinition[]
+ categorical: ColumnDefinition[]
+ other: ColumnDefinition[]
+}
+
+export const groupColumns = (columns: ColumnDefinition[]): ColumnGroups => {
+ const groups: ColumnGroups = {
+ temporal: [],
+ numeric: [],
+ categorical: [],
+ other: [],
+ }
+ for (const col of columns) {
+ groups[classifyColumn(col)].push(col)
+ }
+ return groups
+}
+
+export const isResultChartable = (columns: ColumnDefinition[]): boolean => {
+ const g = groupColumns(columns)
+ return (
+ g.numeric.length > 0 || g.categorical.length > 0 || g.temporal.length > 0
+ )
+}
+
+const findOhlc = (
+ numeric: ColumnDefinition[],
+): ChartConfig["ohlc"] | undefined => {
+ const byName = new Map()
+ for (const c of numeric) byName.set(c.name.toLowerCase(), c.name)
+ const open = byName.get("open")
+ const high = byName.get("high")
+ const low = byName.get("low")
+ const close = byName.get("close")
+ if (open && high && low && close) return { open, high, low, close }
+ return undefined
+}
+
+type QueryHints = {
+ hasSampleBy?: boolean
+ hasLatest?: boolean
+}
+
+// AST-based so SAMPLE BY / LATEST ON in literals/comments/subqueries
+// don't false-positive. `LATEST BY ` (legacy) also maps to latestOn.
+const parseQueryHints = (query: string): QueryHints => {
+ try {
+ const { ast } = parseToAst(query)
+ const topSelect = ast.find((s) => s?.type === "select")
+ if (topSelect?.type !== "select") return {}
+ return {
+ hasSampleBy: !!topSelect.sampleBy,
+ hasLatest: !!topSelect.latestOn,
+ }
+ } catch {
+ return {}
+ }
+}
+
+const distinctCount = (
+ dataset: (boolean | string | number | null)[][],
+ colIndex: number,
+ cap = PIE_THRESHOLD,
+): number => {
+ const seen = new Set()
+ for (const row of dataset) {
+ seen.add(row[colIndex])
+ if (seen.size > cap) return seen.size
+ }
+ return seen.size
+}
+
+export const inferChartConfig = (
+ columns: ColumnDefinition[],
+ dataset: (boolean | string | number | null)[][],
+ query: string,
+): ChartConfig => {
+ const groups = groupColumns(columns)
+ const hints = parseQueryHints(query)
+
+ const capSeries = (cols: ColumnDefinition[]): string[] =>
+ cols.slice(0, MAX_DEFAULT_SERIES).map((c) => c.name)
+
+ if (groups.temporal.length > 0 && groups.numeric.length >= 4) {
+ const ohlc = findOhlc(groups.numeric)
+ if (ohlc) {
+ return {
+ type: "candlestick",
+ xColumn: groups.temporal[0].name,
+ yColumns: [ohlc.open, ohlc.high, ohlc.low, ohlc.close],
+ ohlc,
+ }
+ }
+ }
+
+ if (
+ groups.temporal.length > 0 &&
+ groups.categorical.length > 0 &&
+ groups.numeric.length > 0
+ ) {
+ const catCol = groups.categorical[0]
+ const catIdx = columns.findIndex((c) => c.name === catCol.name)
+ if (
+ catIdx >= 0 &&
+ distinctCount(dataset, catIdx, MAX_AUTO_PARTITION_CARDINALITY) <=
+ MAX_AUTO_PARTITION_CARDINALITY
+ ) {
+ return {
+ type: "line",
+ xColumn: groups.temporal[0].name,
+ yColumns: capSeries(groups.numeric),
+ partitionByColumn: catCol.name,
+ }
+ }
+ }
+
+ if (groups.temporal.length > 0 && groups.numeric.length > 0) {
+ return {
+ type: "line",
+ xColumn: groups.temporal[0].name,
+ yColumns: capSeries(groups.numeric),
+ }
+ }
+
+ if (
+ hints.hasLatest &&
+ groups.categorical.length > 0 &&
+ groups.numeric.length > 0
+ ) {
+ return {
+ type: "bar",
+ xColumn: groups.categorical[0].name,
+ yColumns: capSeries(groups.numeric),
+ }
+ }
+
+ if (groups.categorical.length > 0 && groups.numeric.length === 1) {
+ const xIdx = columns.findIndex((c) => c.name === groups.categorical[0].name)
+ if (xIdx >= 0 && distinctCount(dataset, xIdx) < PIE_THRESHOLD) {
+ return {
+ type: "pie",
+ xColumn: groups.categorical[0].name,
+ yColumns: [groups.numeric[0].name],
+ }
+ }
+ }
+
+ if (groups.categorical.length > 0 && groups.numeric.length > 0) {
+ return {
+ type: "bar",
+ xColumn: groups.categorical[0].name,
+ yColumns: capSeries(groups.numeric),
+ }
+ }
+
+ if (groups.numeric.length >= 2 && groups.temporal.length === 0) {
+ return {
+ type: "scatter",
+ xColumn: groups.numeric[0].name,
+ yColumns: capSeries(groups.numeric.slice(1)),
+ }
+ }
+
+ const x = columns[0]?.name ?? null
+ const ys = columns.slice(1, 1 + MAX_DEFAULT_SERIES).map((c) => c.name)
+ return {
+ type: "bar",
+ xColumn: x,
+ yColumns: ys,
+ }
+}
+
+export const ensureChartConfig = (
+ existing: ChartConfig | undefined,
+ columns: ColumnDefinition[],
+ dataset: (boolean | string | number | null)[][],
+ query: string,
+): ChartConfig => existing ?? inferChartConfig(columns, dataset, query)
+
+export const __testing = { parseQueryHints, findOhlc }
+
+export const availableChartTypes = (
+ groups: ColumnGroups,
+ hasOhlc: boolean,
+): ChartType[] => {
+ const types: ChartType[] = []
+ const hasNumeric = groups.numeric.length > 0
+ const hasTemporal = groups.temporal.length > 0
+ const hasCategorical = groups.categorical.length > 0
+ if (hasNumeric && (hasTemporal || hasCategorical)) {
+ types.push("line", "area", "bar", "stackedBar")
+ }
+ if (hasNumeric && groups.numeric.length >= 2) types.push("scatter")
+ if (hasNumeric && hasCategorical) types.push("pie")
+ if (hasOhlc && hasTemporal) types.push("candlestick")
+ return types.length ? types : ["bar"]
+}
diff --git a/src/scenes/Editor/Notebook/CellChart/questdbTheme.ts b/src/scenes/Editor/Notebook/CellChart/questdbTheme.ts
new file mode 100644
index 000000000..f5f38defd
--- /dev/null
+++ b/src/scenes/Editor/Notebook/CellChart/questdbTheme.ts
@@ -0,0 +1,112 @@
+const FOREGROUND = "#f8f8f2"
+const GRAY2 = "#bbbbbb"
+const COMMENT = "#6272a4"
+const SELECTION = "#44475a"
+const SPLIT_LINE = "rgba(68, 71, 90, 0.4)"
+const BACKGROUND_DARKER = "#22222c"
+
+const SERIES_PALETTE = [
+ "#8be9fd",
+ "#d14671",
+ "#ffb86c",
+ "#bd93f9",
+ "#f1fa8c",
+ "#ff79c6",
+ "#50fa7b",
+ "#ff5555",
+]
+
+const UP_FILL = "#50fa7b"
+const UP_BORDER = "#00aa3b"
+const DOWN_FILL = "#ff5555"
+const DOWN_BORDER = "#be2f5b"
+
+const axis = {
+ axisLine: { lineStyle: { color: SELECTION } },
+ axisTick: { lineStyle: { color: SELECTION } },
+ axisLabel: { color: GRAY2, fontSize: 12 },
+ splitLine: { lineStyle: { color: SPLIT_LINE } },
+ splitArea: {
+ areaStyle: { color: ["transparent", "rgba(255,255,255,0.02)"] },
+ },
+ nameTextStyle: { color: GRAY2, fontSize: 12 },
+ fontSize: undefined,
+}
+
+export const questdbTheme = {
+ color: SERIES_PALETTE,
+ backgroundColor: "#282a36",
+ // Canvas 2D font shorthand silently rejects the whole string if any token is malformed
+ // (CSS keywords, leading-hyphen families, unquoted hyphenated names) and falls back to
+ // "10px sans-serif", which then eats every fontSize. Keep to standard family names only.
+ textStyle: {
+ color: FOREGROUND,
+ fontFamily: "Helvetica, Arial, sans-serif",
+ fontSize: 12,
+ },
+ legend: {
+ textStyle: { color: GRAY2, fontSize: 12 },
+ inactiveColor: COMMENT,
+ pageTextStyle: { color: GRAY2, fontSize: 12 },
+ pageIconColor: GRAY2,
+ pageIconInactiveColor: COMMENT,
+ },
+ tooltip: {
+ appendToBody: true,
+ backgroundColor: BACKGROUND_DARKER,
+ borderColor: SELECTION,
+ borderWidth: 1,
+ textStyle: { color: FOREGROUND, fontSize: 12 },
+ axisPointer: {
+ lineStyle: { color: COMMENT },
+ crossStyle: { color: COMMENT },
+ label: { backgroundColor: SELECTION, color: FOREGROUND },
+ },
+ },
+ categoryAxis: axis,
+ valueAxis: axis,
+ timeAxis: axis,
+ logAxis: axis,
+ line: {
+ itemStyle: { borderWidth: 0 },
+ lineStyle: { width: 1.5 },
+ showSymbol: false,
+ symbol: "circle",
+ symbolSize: 6,
+ smooth: false,
+ textStyle: { fontSize: 12 },
+ },
+ bar: {
+ itemStyle: { barBorderWidth: 0, barBorderColor: SELECTION },
+ },
+ pie: {
+ itemStyle: { borderColor: BACKGROUND_DARKER, borderWidth: 1 },
+ label: { color: FOREGROUND },
+ labelLine: { lineStyle: { color: COMMENT } },
+ },
+ scatter: {
+ itemStyle: { borderWidth: 0 },
+ },
+ candlestick: {
+ itemStyle: {
+ color: UP_FILL,
+ color0: DOWN_FILL,
+ borderColor: UP_BORDER,
+ borderColor0: DOWN_BORDER,
+ borderWidth: 1,
+ },
+ },
+ dataZoom: {
+ backgroundColor: "transparent",
+ dataBackgroundColor: SELECTION,
+ fillerColor: "rgba(98, 114, 164, 0.2)",
+ handleColor: COMMENT,
+ handleSize: "100%",
+ textStyle: { color: GRAY2, fontSize: 12 },
+ borderColor: SELECTION,
+ },
+ toolbox: {
+ iconStyle: { borderColor: GRAY2 },
+ emphasis: { iconStyle: { borderColor: FOREGROUND } },
+ },
+}
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts
new file mode 100644
index 000000000..cf5c4cb69
--- /dev/null
+++ b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect } from "vitest"
+import { resultsEquivalent, successResults } from "./drawCanvasUtils"
+import type { QueryExecResult } from "../../../../hooks/useQueryExecution"
+
+const dql = (
+ columns: { name: string; type: string }[],
+ dataset: (number | string | boolean | null)[][],
+ query = "q",
+): QueryExecResult => ({
+ type: "dql",
+ query,
+ columns,
+ dataset,
+ count: dataset.length,
+})
+
+describe("successResults", () => {
+ it("keeps only DQL with non-empty datasets", () => {
+ const rows: (QueryExecResult | null)[] = [
+ dql([{ name: "x", type: "INT" }], [[1]]),
+ null,
+ { type: "error", query: "bad", columns: [], dataset: [], count: 0 },
+ dql([{ name: "y", type: "INT" }], []),
+ {
+ type: "ddl",
+ query: "CREATE TABLE x (y INT)",
+ columns: [],
+ dataset: [],
+ count: 0,
+ },
+ ]
+ expect(successResults(rows)).toHaveLength(1)
+ expect(successResults(rows)[0].columns[0].name).toBe("x")
+ })
+
+ it("returns empty when nothing qualifies", () => {
+ expect(successResults([null, null])).toEqual([])
+ expect(successResults([])).toEqual([])
+ })
+})
+
+describe("resultsEquivalent", () => {
+ it("returns false on length mismatch", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1]])]
+ const b: QueryExecResult[] = []
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("returns true for identical shape + values", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1], [2], [3]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[1], [2], [3]])]
+ expect(resultsEquivalent(a, b)).toBe(true)
+ })
+
+ it("returns false when column names differ", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1]])]
+ const b = [dql([{ name: "y", type: "INT" }], [[1]])]
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("returns false when column count differs", () => {
+ const a = [
+ dql(
+ [
+ { name: "x", type: "INT" },
+ { name: "y", type: "INT" },
+ ],
+ [[1, 2]],
+ ),
+ ]
+ const b = [dql([{ name: "x", type: "INT" }], [[1]])]
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("returns false when dataset length differs", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1], [2]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[1]])]
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("returns true when both datasets are empty (no rows to compare)", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [])]
+ const b = [dql([{ name: "x", type: "INT" }], [])]
+ expect(resultsEquivalent(a, b)).toBe(true)
+ })
+
+ it("returns false when first row differs but length matches", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1], [9]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[2], [9]])]
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("returns false when last row differs", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1], [2], [9]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[1], [2], [10]])]
+ expect(resultsEquivalent(a, b)).toBe(false)
+ })
+
+ it("does not compare middle rows (shape+endpoints heuristic)", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[1], [2], [3]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[1], [99], [3]])]
+ expect(resultsEquivalent(a, b)).toBe(true)
+ })
+
+ it("compares multiple results side-by-side", () => {
+ const a = [
+ dql([{ name: "x", type: "INT" }], [[1]]),
+ dql([{ name: "y", type: "INT" }], [[2]]),
+ ]
+ const b = [
+ dql([{ name: "x", type: "INT" }], [[1]]),
+ dql([{ name: "y", type: "INT" }], [[2]]),
+ ]
+ expect(resultsEquivalent(a, b)).toBe(true)
+ })
+
+ it("handles null cell values", () => {
+ const a = [dql([{ name: "x", type: "INT" }], [[null], [null]])]
+ const b = [dql([{ name: "x", type: "INT" }], [[null], [null]])]
+ expect(resultsEquivalent(a, b)).toBe(true)
+
+ const c = [dql([{ name: "x", type: "INT" }], [[null], [null]])]
+ const d = [dql([{ name: "x", type: "INT" }], [[null], [1]])]
+ expect(resultsEquivalent(c, d)).toBe(false)
+ })
+})
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts
new file mode 100644
index 000000000..6903c7ee4
--- /dev/null
+++ b/src/scenes/Editor/Notebook/DrawCanvas/drawCanvasUtils.ts
@@ -0,0 +1,43 @@
+import type { QueryExecResult } from "../../../../hooks/useQueryExecution"
+
+// Keep only DQL results that actually returned rows. The chart can't render
+// empty datasets, and non-DQL results (error/ddl/dml) have no columns.
+export const successResults = (
+ results: (QueryExecResult | null)[],
+): QueryExecResult[] =>
+ results.filter(
+ (r): r is QueryExecResult =>
+ r !== null && r.type === "dql" && r.dataset.length > 0,
+ )
+
+// Shape/value equality: same column names, same dataset length, same first
+// and last row by identity. Skipping a re-render when the shape matches
+// avoids resetting echarts tooltip/zoom state on every unchanged poll tick.
+export const resultsEquivalent = (
+ a: QueryExecResult[],
+ b: QueryExecResult[],
+): boolean => {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i]
+ const y = b[i]
+ if (x.columns.length !== y.columns.length) return false
+ for (let c = 0; c < x.columns.length; c++) {
+ if (x.columns[c].name !== y.columns[c].name) return false
+ }
+ if (x.dataset.length !== y.dataset.length) return false
+ if (x.dataset.length === 0) continue
+ const firstA = x.dataset[0]
+ const firstB = y.dataset[0]
+ if (firstA.length !== firstB.length) return false
+ for (let c = 0; c < firstA.length; c++) {
+ if (firstA[c] !== firstB[c]) return false
+ }
+ const lastA = x.dataset[x.dataset.length - 1]
+ const lastB = y.dataset[y.dataset.length - 1]
+ for (let c = 0; c < lastA.length; c++) {
+ if (lastA[c] !== lastB[c]) return false
+ }
+ }
+ return true
+}
diff --git a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx
new file mode 100644
index 000000000..965e12574
--- /dev/null
+++ b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx
@@ -0,0 +1,304 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import styled from "styled-components"
+import type { NotebookCell } from "../../../../store/notebook"
+import type { ChartConfig } from "../CellChart/chartTypes"
+import {
+ buildEchartsOption,
+ type ExtraSeriesSource,
+} from "../CellChart/buildEchartsOption"
+import {
+ classifyColumn,
+ ensureChartConfig,
+} from "../CellChart/inferChartConfig"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import {
+ ChartRenderer,
+ type ChartRendererHandle,
+} from "../CellChart/ChartRenderer"
+import { ChartActions } from "../CellChart/ChartActions"
+import { ChartSettingsDrawer } from "../CellChart/ChartSettingsDrawer"
+import { useAdaptivePoll } from "../../../../hooks/useAdaptivePoll"
+import { useQueryExecution } from "../../../../hooks/useQueryExecution"
+import type { QueryExecResult } from "../../../../hooks/useQueryExecution"
+import { getQueriesFromText } from "../../Monaco/utils"
+import { resultsEquivalent, successResults } from "./drawCanvasUtils"
+import { useNotebookActions } from "../NotebookProvider"
+import { useValidateWithGlobals } from "../globals/useValidateWithGlobals"
+
+const REFRESH_MIN_MS = 2000
+const REFRESH_MAX_MS = 60000
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ position: relative;
+ background: ${({ theme }) => theme.color.backgroundLighter};
+
+ & [data-hook="chart-actions"] {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+ }
+ &:hover [data-hook="chart-actions"],
+ & [data-hook="chart-actions"]:focus-within {
+ opacity: 1;
+ pointer-events: auto;
+ }
+`
+
+const Canvas = styled.div`
+ flex: 1;
+ min-height: 0;
+ position: relative;
+`
+
+const EmptyState = styled.div`
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${({ theme }) => theme.color.gray2};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+`
+
+type Props = {
+ cell: NotebookCell
+ isFocused: boolean
+ onConfigChange: (config: ChartConfig) => void
+ onAutoRefreshChange: (value: boolean) => void
+ onMaximizedChange: (value: boolean) => void
+}
+
+const ANCHOR_EMPTY = { columns: [], dataset: [] as never[][], query: "" }
+
+export const DrawCanvas: React.FC = ({
+ cell,
+ isFocused,
+ onConfigChange,
+ onAutoRefreshChange,
+ onMaximizedChange,
+}) => {
+ const { getVariables } = useNotebookActions()
+ const { executeSingle } = useQueryExecution(getVariables())
+ const validateWithGlobals = useValidateWithGlobals()
+ const [results, setResults] = useState([])
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const [settledKey, setSettledKey] = useState(null)
+ const [lastFetchHadError, setLastFetchHadError] = useState(false)
+ const [classifyBlock, setClassifyBlock] = useState<
+ | { kind: "write"; queryType: string }
+ | { kind: "failed"; message: string }
+ | null
+ >(null)
+ const classifyCache = useMemo(
+ () => new Map(),
+ [cell.value],
+ )
+
+ const chartRendererRef = useRef(null)
+ const [zoomStart, setZoomStart] = useState(0)
+ const [zoomEnd, setZoomEnd] = useState(100)
+ const isZoomed = zoomStart > 0 || zoomEnd < 100
+
+ const handleZoomChange = useCallback((start: number, end: number) => {
+ setZoomStart(start)
+ setZoomEnd(end)
+ }, [])
+
+ const handleResetZoom = useCallback(() => {
+ chartRendererRef.current?.resetZoom()
+ setZoomStart(0)
+ setZoomEnd(100)
+ }, [])
+
+ const queries = useMemo(() => getQueriesFromText(cell.value), [cell.value])
+ const queriesKey = queries.join("\u0001")
+
+ const inFlightRef = useRef(null)
+
+ const fetchAll = useCallback(async () => {
+ if (queries.length === 0) {
+ setResults((prev) => (prev.length === 0 ? prev : []))
+ setSettledKey(queriesKey)
+ setLastFetchHadError(false)
+ setClassifyBlock(null)
+ return
+ }
+ inFlightRef.current?.abort()
+ const ac = new AbortController()
+ inFlightRef.current = ac
+ try {
+ // Runtime backstop: a user typing DDL into an already-draw cell would
+ // otherwise reach executeSingle on the next poll tick.
+ try {
+ await Promise.all(
+ queries.map(async (q) => {
+ if (classifyCache.has(q)) return
+ const res = await validateWithGlobals(q, ac.signal)
+ if ("error" in res) classifyCache.set(q, "ERROR")
+ else if ("columns" in res) classifyCache.set(q, "DQL")
+ else classifyCache.set(q, "DDL_DML")
+ }),
+ )
+ } catch (e) {
+ if (ac.signal.aborted) return
+ const message = e instanceof Error ? e.message : "validate failed"
+ setClassifyBlock({ kind: "failed", message })
+ setSettledKey(queriesKey)
+ return
+ }
+ if (ac.signal.aborted) return
+ const offender = queries
+ .map((q) => ({ q, klass: classifyCache.get(q) }))
+ .find((x) => x.klass === "DDL_DML")
+ if (offender) {
+ const validateResult = await validateWithGlobals(
+ offender.q,
+ ac.signal,
+ ).catch(() => null)
+ if (ac.signal.aborted) return
+ const queryType =
+ validateResult && "queryType" in validateResult
+ ? validateResult.queryType
+ : "write"
+ setClassifyBlock({ kind: "write", queryType })
+ setResults((prev) => (prev.length === 0 ? prev : []))
+ setLastFetchHadError(false)
+ setSettledKey(queriesKey)
+ return
+ }
+ setClassifyBlock(null)
+ const out = await Promise.all(
+ queries.map((q) => executeSingle(q, ac.signal).catch(() => null)),
+ )
+ if (ac.signal.aborted) return
+ const next = successResults(out)
+ setResults((prev) => (resultsEquivalent(prev, next) ? prev : next))
+ setLastFetchHadError(out.some((r) => r === null))
+ setSettledKey(queriesKey)
+ } finally {
+ if (inFlightRef.current === ac) inFlightRef.current = null
+ }
+ }, [classifyCache, executeSingle, validateWithGlobals, queries, queriesKey])
+
+ useEffect(() => {
+ void fetchAll()
+ }, [fetchAll])
+
+ useEffect(
+ () => () => {
+ inFlightRef.current?.abort()
+ },
+ [],
+ )
+
+ const autoRefresh = cell.autoRefresh ?? true
+
+ useAdaptivePoll({
+ fetchFn: fetchAll,
+ enabled: autoRefresh && queries.length > 0,
+ key: `${cell.id}:${queriesKey}`,
+ minIntervalMs: REFRESH_MIN_MS,
+ maxIntervalMs: REFRESH_MAX_MS,
+ })
+
+ const anchor = results[0] ?? ANCHOR_EMPTY
+ const extras: ExtraSeriesSource[] = useMemo(
+ () =>
+ results.slice(1).map((r, i) => ({
+ label: `Q${i + 2}`,
+ columns: r.columns,
+ dataset: r.dataset,
+ })),
+ [results],
+ )
+
+ const pickerColumns: ColumnDefinition[] = useMemo(() => {
+ const out = [...anchor.columns]
+ const seen = new Set(out.map((c) => c.name))
+ for (const src of extras) {
+ for (const col of src.columns) {
+ if (classifyColumn(col) !== "numeric") continue
+ if (seen.has(col.name)) continue
+ seen.add(col.name)
+ out.push(col)
+ }
+ }
+ return out
+ }, [anchor.columns, extras])
+
+ const config = useMemo(() => {
+ const base = ensureChartConfig(
+ cell.chartConfig,
+ anchor.columns,
+ anchor.dataset,
+ anchor.query,
+ )
+ if (cell.chartConfig) return base
+ const all = pickerColumns
+ .filter((c) => classifyColumn(c) === "numeric")
+ .map((c) => c.name)
+ const merged = Array.from(new Set([...base.yColumns, ...all]))
+ return { ...base, yColumns: merged }
+ }, [cell.chartConfig, anchor, pickerColumns])
+
+ const option = useMemo(
+ () => buildEchartsOption(config, anchor.columns, anchor.dataset, extras),
+ [config, anchor.columns, anchor.dataset, extras],
+ )
+
+ const empty =
+ classifyBlock !== null || queries.length === 0 || results.length === 0
+ const settledForCurrentQueries = settledKey === queriesKey
+ let emptyMessage: string
+ if (classifyBlock?.kind === "write") {
+ emptyMessage = `Cannot draw a write query ('${classifyBlock.queryType}'). Switch to Run mode to execute this SQL.`
+ } else if (classifyBlock?.kind === "failed") {
+ emptyMessage = `Cannot classify cell SQL (${classifyBlock.message}). Refusing to draw until the query can be classified safely.`
+ } else if (queries.length === 0) {
+ emptyMessage = "Type a query to draw."
+ } else if (!settledForCurrentQueries) {
+ emptyMessage = "Drawing\u2026"
+ } else if (lastFetchHadError) {
+ emptyMessage = "Query failed — check the SQL editor for the error."
+ } else {
+ emptyMessage = "No data to plot."
+ }
+
+ return (
+
+ void fetchAll()}
+ isMaximized={!!cell.isChartMaximized}
+ onMaximizedChange={onMaximizedChange}
+ onOpenSettings={() => setSettingsOpen(true)}
+ canResetZoom={isZoomed}
+ onResetZoom={handleResetZoom}
+ cellId={cell.id}
+ />
+ {empty ? (
+ {emptyMessage}
+ ) : (
+
+ )}
+ setSettingsOpen(false)}
+ columns={pickerColumns}
+ config={config}
+ onSave={onConfigChange}
+ />
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx
new file mode 100644
index 000000000..755aa0527
--- /dev/null
+++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx
@@ -0,0 +1,527 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import { unstable_batchedUpdates } from "react-dom"
+import { useEditor } from "../../../providers/EditorProvider"
+import type {
+ CellLayoutItem,
+ NotebookCell,
+ NotebookVariable,
+ NotebookViewState,
+ NotebookSettings,
+ CellMode,
+} from "../../../store/notebook"
+import type { ChartConfig } from "./CellChart/chartTypes"
+import { useQueryExecution } from "../../../hooks/useQueryExecution"
+import { useCellsStore } from "./useCellsStore"
+import { useCellExecution } from "./useCellExecution"
+import { useNotebookPersistence } from "./useNotebookPersistence"
+import {
+ registerController,
+ unregisterController,
+ type ApplyNotebookStateRequest,
+ type NotebookController,
+} from "../../../utils/notebookAIBridge"
+import { sanitizeForPromptContext } from "../../../utils/ai/notebookSnapshot"
+import {
+ buildAppliedCells,
+ buildAppliedLayout,
+ computeResultBottomHeight,
+ DEFAULT_CHART_BOTTOM_HEIGHT,
+} from "./notebookUtils"
+
+// State and actions live in SEPARATE contexts: action-only consumers never
+// re-render when state changes (the actions value is ref-stable for life).
+
+export type NotebookState = {
+ cells: NotebookCell[]
+ settings: NotebookSettings
+ focusedCellId: string | null
+ maximizedCellId: string | null
+ runningCellIds: Set
+}
+
+export type NotebookActions = {
+ getVariables: () => NotebookVariable[] | undefined
+ updateSettings: (updates: Partial) => void
+ addCell: (afterCellId?: string, value?: string) => string
+ deleteCell: (cellId: string) => void
+ updateCell: (cellId: string, updates: Partial) => void
+ moveCellUp: (cellId: string) => void
+ moveCellDown: (cellId: string) => void
+ duplicateCell: (cellId: string) => string
+ runCell: (
+ cellId: string,
+ sql?: string,
+ signal?: AbortSignal,
+ ) => Promise
+ cancelCell: (cellId: string) => void
+ cancelQuery: (cellId: string, index: number) => void
+ setActiveResultIndex: (cellId: string, index: number) => void
+ setCellMode: (cellId: string, mode: CellMode) => void
+ setCellChartConfig: (cellId: string, config: ChartConfig) => void
+ setCellAutoRefresh: (cellId: string, value: boolean) => void
+ setCellChartMaximized: (cellId: string, value: boolean) => void
+ setCellLayout: (
+ cellId: string,
+ pos: { x: number; y: number; w: number; h: number },
+ ) => void
+ setFocusedCell: (cellId: string | null) => void
+ setMaximizedCellId: (cellId: string | null) => void
+}
+
+export type NotebookContextType = NotebookState & NotebookActions
+
+const NOOP_ACTIONS: NotebookActions = {
+ getVariables: () => undefined,
+ updateSettings: () => undefined,
+ addCell: () => "",
+ deleteCell: () => undefined,
+ updateCell: () => undefined,
+ moveCellUp: () => undefined,
+ moveCellDown: () => undefined,
+ duplicateCell: () => "",
+ runCell: () => Promise.resolve(false),
+ cancelCell: () => undefined,
+ cancelQuery: () => undefined,
+ setActiveResultIndex: () => undefined,
+ setCellMode: () => undefined,
+ setCellChartConfig: () => undefined,
+ setCellAutoRefresh: () => undefined,
+ setCellChartMaximized: () => undefined,
+ setCellLayout: () => undefined,
+ setFocusedCell: () => undefined,
+ setMaximizedCellId: () => undefined,
+}
+
+const EMPTY_STATE: NotebookState = {
+ cells: [],
+ settings: {},
+ focusedCellId: null,
+ maximizedCellId: null,
+ runningCellIds: new Set(),
+}
+
+const NotebookStateContext = createContext(EMPTY_STATE)
+const NotebookActionsContext = createContext(NOOP_ACTIONS)
+
+export const useNotebookState = () => useContext(NotebookStateContext)
+export const useNotebookActions = () => useContext(NotebookActionsContext)
+
+export const useNotebook = (): NotebookContextType => {
+ const state = useNotebookState()
+ const actions = useNotebookActions()
+ return useMemo(() => ({ ...state, ...actions }), [state, actions])
+}
+
+export const NotebookProvider: React.FC<{
+ initialState: NotebookViewState
+ bufferId: number
+}> = ({ initialState, bufferId, children }) => {
+ const { updateBuffer } = useEditor()
+
+ const [focusedCellId, setFocusedCellState] = useState(
+ initialState.focusedCellId ?? null,
+ )
+ const [maximizedCellId, setMaximizedCellIdState] = useState(
+ initialState.maximizedCellId ?? null,
+ )
+ const [settings, setSettingsState] = useState(
+ initialState.settings ?? {},
+ )
+
+ const { executeSingle } = useQueryExecution(settings.variables)
+
+ const focusedCellIdRef = useRef(focusedCellId)
+ const maximizedCellIdRef = useRef(maximizedCellId)
+ const settingsRef = useRef(settings)
+
+ // Forward ref breaks the circular dep between useCellsStore (needs
+ // persistCells) and useNotebookPersistence (needs cellsRef).
+ const cellsRefForPersist = useRef(initialState.cells)
+
+ const { persistCells, persistImmediately } = useNotebookPersistence({
+ bufferId,
+ updateBuffer,
+ cellsRef: cellsRefForPersist,
+ focusedCellIdRef,
+ maximizedCellIdRef,
+ settingsRef,
+ })
+
+ const store = useCellsStore({
+ initialCells: initialState.cells,
+ persistCells,
+ })
+
+ cellsRefForPersist.current = store.cellsRef.current
+
+ const execution = useCellExecution({
+ cellsRef: store.cellsRef,
+ executeSingle,
+ updateCellResult: store.updateCellResult,
+ updateCell: store.updateCell,
+ updateCells: store.updateCells,
+ markCancelledAll: store.markCancelledAll,
+ markCancelledOne: store.markCancelledOne,
+ setScriptSummary: store.setScriptSummary,
+ })
+
+ // Refs let the AI-bridge controller effect depend on [bufferId] alone —
+ // store/execution return fresh literals each render, which would otherwise
+ // cycle register/unregister and race waitForController waiters.
+ const storeRef = useRef(store)
+ storeRef.current = store
+ const executionRef = useRef(execution)
+ executionRef.current = execution
+
+ const setFocusedCell = useCallback((cellId: string | null) => {
+ focusedCellIdRef.current = cellId
+ setFocusedCellState(cellId)
+ }, [])
+
+ const updateSettings = useCallback(
+ (updates: Partial) => {
+ const next = { ...settingsRef.current, ...updates }
+ settingsRef.current = next
+ setSettingsState(next)
+ persistImmediately()
+ },
+ [persistImmediately],
+ )
+
+ const setMaximizedCellId = useCallback(
+ (cellId: string | null) => {
+ maximizedCellIdRef.current = cellId
+ setMaximizedCellIdState(cellId)
+ persistImmediately()
+ },
+ [persistImmediately],
+ )
+
+ const setCellLayout = useCallback(
+ (cellId: string, pos: { x: number; y: number; w: number; h: number }) => {
+ const prev = settingsRef.current
+ const layout = prev.layout ?? []
+ const idx = layout.findIndex((l) => l.i === cellId)
+ const nextLayout: CellLayoutItem[] =
+ idx >= 0
+ ? layout.map((l) => (l.i === cellId ? { ...l, ...pos } : l))
+ : [...layout, { i: cellId, ...pos }]
+ const next = { ...prev, layout: nextLayout }
+ settingsRef.current = next
+ setSettingsState(next)
+ persistImmediately()
+ },
+ [persistImmediately],
+ )
+
+ // In grid mode, freshly added cells must land in `settings.layout` so
+ // mergeCellLayout has a real position entry to read. The `h` value
+ // written here is a placeholder — the actual rendered grid h is derived
+ // at render time from cell.topHeight + cell.bottomHeight (see
+ // computeCellGridH).
+ const addCell = useCallback(
+ (afterCellId?: string, value?: string): string => {
+ let newId = ""
+ unstable_batchedUpdates(() => {
+ newId = store.addCell(afterCellId, value)
+ if (settingsRef.current.layoutMode !== "grid") return
+ const layout = settingsRef.current.layout ?? []
+ const maxY =
+ layout.length > 0 ? Math.max(...layout.map((l) => l.y + l.h)) : 0
+ // h = 1 sentinel; computeCellGridH overrides at render.
+ setCellLayout(newId, { x: 0, y: maxY, w: 12, h: 1 })
+ })
+ return newId
+ },
+ [store, setCellLayout],
+ )
+
+ // Mode toggle: when a cell flips between run and draw, seed bottomHeight
+ // with the mode-appropriate default. This puts the cell into double-view
+ // immediately for draw, and back to single-view (or result-double-view)
+ // for run.
+ const setCellMode = useCallback(
+ (cellId: string, mode: CellMode) => {
+ store.setCellMode(cellId, mode)
+ if (mode === "draw") {
+ store.updateCell(cellId, { bottomHeight: DEFAULT_CHART_BOTTOM_HEIGHT })
+ } else {
+ // back to run mode: size the bottom slot based on what the
+ // existing result actually contains (DQL-with-rows → 10-row
+ // height; DDL/DML/error/empty → notification-only). No result
+ // yet → drop to single-view by clearing bottomHeight.
+ const cell = store.cellsRef.current.find((c) => c.id === cellId)
+ store.updateCell(cellId, {
+ bottomHeight: cell?.result
+ ? computeResultBottomHeight(cell.result)
+ : undefined,
+ })
+ }
+ },
+ [store],
+ )
+
+ const runCell = useCallback(
+ async (cellId: string, sql?: string, signal?: AbortSignal) => {
+ const ok = await execution.runCell(cellId, sql, signal)
+ // Every run (success OR error) puts the cell into result-double-view.
+ // Height is conditioned on the actual result shape so DDL/DML/empty-
+ // DQL/error results don't reserve 10 blank rows of space. Per rule
+ // 6.c, any prior user drag of the bottom handle is discarded on
+ // re-run.
+ const cell = store.cellsRef.current.find((c) => c.id === cellId)
+ if (cell && cell.mode !== "draw") {
+ store.updateCell(cellId, {
+ bottomHeight: computeResultBottomHeight(cell.result),
+ })
+ }
+ return ok
+ },
+ [execution, store],
+ )
+
+ const deleteCell = useCallback(
+ (cellId: string) => {
+ store.removeCellById(cellId)
+ if (focusedCellIdRef.current === cellId) {
+ setFocusedCell(null)
+ }
+ if (maximizedCellIdRef.current === cellId) {
+ setMaximizedCellId(null)
+ }
+ },
+ [store, setMaximizedCellId],
+ )
+
+ const duplicateCell = useCallback(
+ (cellId: string): string => {
+ // batchedUpdates ensures the cells append and the layout entry land
+ // in a single render. React 17 doesn't auto-batch outside event
+ // handlers (Radix DropdownMenu defers onSelect via setTimeout), so
+ // without this the duplicate flashes at the bottom before snapping
+ // into place.
+ let newId = ""
+ unstable_batchedUpdates(() => {
+ newId = store.duplicateCell(cellId)
+ if (settingsRef.current.layoutMode !== "grid") return
+ const layout = settingsRef.current.layout ?? []
+ const original = layout.find((l) => l.i === cellId)
+ if (!original) return
+ setCellLayout(newId, {
+ x: original.x,
+ y: original.y,
+ w: original.w,
+ h: original.h,
+ })
+ })
+ return newId
+ },
+ [store, setCellLayout],
+ )
+
+ const stateValue = useMemo(
+ () => ({
+ cells: store.cells,
+ settings,
+ focusedCellId,
+ maximizedCellId,
+ runningCellIds: execution.runningCellIds,
+ }),
+ [
+ store.cells,
+ settings,
+ focusedCellId,
+ maximizedCellId,
+ execution.runningCellIds,
+ ],
+ )
+
+ // Indirect through a ref so the action object identity never changes —
+ // consumers don't re-render when the underlying callbacks are recreated.
+ const liveActionsRef = useRef(NOOP_ACTIONS)
+ liveActionsRef.current = {
+ getVariables: () => settingsRef.current.variables,
+ updateSettings,
+ addCell,
+ deleteCell,
+ updateCell: store.updateCell,
+ moveCellUp: store.moveCellUp,
+ moveCellDown: store.moveCellDown,
+ duplicateCell,
+ runCell,
+ cancelCell: execution.cancelCell,
+ cancelQuery: execution.cancelQuery,
+ setActiveResultIndex: execution.setActiveResultIndex,
+ setCellMode,
+ setCellChartConfig: store.setCellChartConfig,
+ setCellAutoRefresh: store.setCellAutoRefresh,
+ setCellChartMaximized: store.setCellChartMaximized,
+ setCellLayout,
+ setFocusedCell,
+ setMaximizedCellId,
+ }
+
+ const actionsValue = useMemo(
+ () => ({
+ getVariables: () => liveActionsRef.current.getVariables(),
+ updateSettings: (...args) =>
+ liveActionsRef.current.updateSettings(...args),
+ addCell: (...args) => liveActionsRef.current.addCell(...args),
+ deleteCell: (...args) => liveActionsRef.current.deleteCell(...args),
+ updateCell: (...args) => liveActionsRef.current.updateCell(...args),
+ moveCellUp: (...args) => liveActionsRef.current.moveCellUp(...args),
+ moveCellDown: (...args) => liveActionsRef.current.moveCellDown(...args),
+ duplicateCell: (...args) => liveActionsRef.current.duplicateCell(...args),
+ runCell: (...args) => liveActionsRef.current.runCell(...args),
+ cancelCell: (...args) => liveActionsRef.current.cancelCell(...args),
+ cancelQuery: (...args) => liveActionsRef.current.cancelQuery(...args),
+ setActiveResultIndex: (...args) =>
+ liveActionsRef.current.setActiveResultIndex(...args),
+ setCellMode: (...args) => liveActionsRef.current.setCellMode(...args),
+ setCellChartConfig: (...args) =>
+ liveActionsRef.current.setCellChartConfig(...args),
+ setCellAutoRefresh: (...args) =>
+ liveActionsRef.current.setCellAutoRefresh(...args),
+ setCellChartMaximized: (...args) =>
+ liveActionsRef.current.setCellChartMaximized(...args),
+ setCellLayout: (...args) => liveActionsRef.current.setCellLayout(...args),
+ setFocusedCell: (...args) =>
+ liveActionsRef.current.setFocusedCell(...args),
+ setMaximizedCellId: (...args) =>
+ liveActionsRef.current.setMaximizedCellId(...args),
+ }),
+ [],
+ )
+
+ useEffect(() => {
+ const controller: NotebookController = {
+ bufferId,
+ addCell: (valueArg, afterCellId) =>
+ liveActionsRef.current.addCell(afterCellId, valueArg),
+ updateCell: (cellId, updates) =>
+ storeRef.current.updateCell(cellId, updates),
+ deleteCell: (cellId) => liveActionsRef.current.deleteCell(cellId),
+ moveCellUp: (cellId) => storeRef.current.moveCellUp(cellId),
+ moveCellDown: (cellId) => storeRef.current.moveCellDown(cellId),
+ duplicateCell: (cellId) => liveActionsRef.current.duplicateCell(cellId),
+ runCell: async (cellId, signal) => {
+ const cellBefore = storeRef.current.cellsRef.current.find(
+ (c) => c.id === cellId,
+ )
+ const priorResult = cellBefore?.result ?? null
+ await liveActionsRef.current.runCell(cellId, undefined, signal)
+ const cell = storeRef.current.cellsRef.current.find(
+ (c) => c.id === cellId,
+ )
+ const freshResult =
+ cell?.result && cell.result !== priorResult ? cell.result : null
+ if (!freshResult) {
+ return { success: false, queryCount: 0, results: [] }
+ }
+ const results = freshResult.results.map((r) => {
+ if (r.type === "cancelled") return "cancelled"
+ if (r.type === "error") {
+ const trimmed =
+ r.error.length > 200 ? `${r.error.slice(0, 197)}...` : r.error
+ return `ERROR: ${sanitizeForPromptContext(trimmed)}`
+ }
+ return "success"
+ })
+ const success =
+ results.length > 0 && results.every((r) => r === "success")
+ return {
+ success,
+ queryCount: results.length,
+ results,
+ }
+ },
+ setLayoutMode: (mode) =>
+ liveActionsRef.current.updateSettings({ layoutMode: mode }),
+ setVariables: (variables) =>
+ liveActionsRef.current.updateSettings({ variables }),
+ setCellLayout: (cellId, pos) =>
+ liveActionsRef.current.setCellLayout(cellId, pos),
+ setCellMode: (cellId, mode) => {
+ liveActionsRef.current.setCellMode(cellId, mode)
+ // Match Cell.tsx user-click — entering Draw auto-maximizes.
+ if (mode === "draw") {
+ storeRef.current.setCellChartMaximized(cellId, true)
+ }
+ },
+ setCellChartConfig: (cellId, cfg) =>
+ storeRef.current.setCellChartConfig(cellId, cfg),
+ setCellAutoRefresh: (cellId, value) =>
+ storeRef.current.setCellAutoRefresh(cellId, value),
+ setCellChartMaximized: (cellId, value) =>
+ storeRef.current.setCellChartMaximized(cellId, value),
+ setCellMaximized: (cellId) =>
+ liveActionsRef.current.setMaximizedCellId(cellId),
+ applyNotebookState: (request: ApplyNotebookStateRequest) => {
+ // All-or-nothing: buildAppliedCells throws before any mutation.
+ const prev = storeRef.current.cellsRef.current
+ const { nextCells, diff } = buildAppliedCells(prev, request)
+ const targetLayoutMode =
+ request.layoutMode === undefined || request.layoutMode === null
+ ? settingsRef.current.layoutMode
+ : request.layoutMode
+ storeRef.current.updateCells(() => nextCells)
+ if (targetLayoutMode === "grid") {
+ const nextLayout = buildAppliedLayout(
+ request,
+ nextCells,
+ settingsRef.current.layout,
+ { gridCols: 12, rowHeight: 10, marginY: 20 },
+ )
+ liveActionsRef.current.updateSettings({
+ layoutMode: "grid",
+ layout: nextLayout,
+ })
+ } else if (
+ request.layoutMode !== undefined &&
+ request.layoutMode !== null
+ ) {
+ liveActionsRef.current.updateSettings({
+ layoutMode: request.layoutMode,
+ })
+ }
+ if (request.maximizedCellId !== undefined) {
+ liveActionsRef.current.setMaximizedCellId(
+ request.maximizedCellId ?? null,
+ )
+ } else if (
+ maximizedCellIdRef.current &&
+ !nextCells.some((c) => c.id === maximizedCellIdRef.current)
+ ) {
+ liveActionsRef.current.setMaximizedCellId(null)
+ }
+ if (request.variables !== undefined) {
+ liveActionsRef.current.updateSettings({
+ variables: request.variables ?? [],
+ })
+ }
+ return { applied: diff }
+ },
+ getCellsSnapshot: () => storeRef.current.cellsRef.current.slice(),
+ getSettings: () => ({ ...settingsRef.current }),
+ getMaximizedCellId: () => maximizedCellIdRef.current,
+ }
+ registerController(controller)
+ return () => unregisterController(bufferId)
+ }, [bufferId])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx
new file mode 100644
index 000000000..d06a81b22
--- /dev/null
+++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx
@@ -0,0 +1,148 @@
+import React from "react"
+import styled, { css } from "styled-components"
+import { Box, Button } from "../../../components"
+import { AISparkle } from "../../../components/AISparkle"
+import { ListIcon, SquaresFourIcon } from "@phosphor-icons/react"
+import { color } from "../../../utils"
+import { useNotebookActions, useNotebookState } from "./NotebookProvider"
+import type { NotebookLayoutMode } from "../../../store/notebook"
+import { useEditor } from "../../../providers/EditorProvider"
+import { useAIConversation } from "../../../providers/AIConversationProvider"
+import {
+ isBlockingAIStatus,
+ useAIStatus,
+} from "../../../providers/AIStatusProvider"
+import { emitUserAction } from "../../../utils/notebookAIBridge"
+import { VariablesPopover } from "./globals/VariablesPopover"
+
+const Toolbar = styled(Box).attrs({
+ align: "center",
+ justifyContent: "space-between",
+})`
+ width: 100%;
+ height: 4.5rem;
+ padding: 0 2.5rem;
+ background: ${color("backgroundLighter")};
+ border-bottom: 1px solid ${color("backgroundDarker")};
+ box-shadow: 0 2px 10px 0 rgba(23, 23, 23, 0.35);
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+`
+
+// Mirrors SchemaAIButton's skin; kept inline because SchemaAIButton carries schema-access gating that doesn't apply here.
+const BuildWithAIButton = styled(Button).attrs({
+ skin: "gradient",
+ prefixIcon: ,
+})`
+ border: 1px solid ${({ theme }) => theme.color.pinkDarker};
+ &:hover:not([disabled]) {
+ border: 1px solid ${({ theme }) => theme.color.pinkDarker};
+ }
+`
+
+const ToggleGroup = styled.div`
+ display: flex;
+ border: 1px solid ${color("selection")};
+ border-radius: 0.4rem;
+ overflow: hidden;
+`
+
+const ToggleButton = styled.button<{ $active: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3.2rem;
+ height: 2.8rem;
+ border: none;
+ background: transparent;
+ color: ${color("gray2")};
+ cursor: pointer;
+ transition: all 0.1s;
+
+ &:not(:last-child) {
+ border-right: 1px solid ${color("selection")};
+ }
+
+ &:hover {
+ background: ${color("selection")};
+ color: ${color("foreground")};
+ }
+
+ ${({ $active }) =>
+ $active &&
+ css`
+ background: ${color("selection")};
+ color: ${color("foreground")};
+ `}
+`
+
+export const NotebookToolbar: React.FC = () => {
+ const { settings } = useNotebookState()
+ const { updateSettings } = useNotebookActions()
+ const { activeBuffer } = useEditor()
+ const { openNotebookChat } = useAIConversation()
+ const { canUse, status: aiStatus } = useAIStatus()
+ const isOperationInProgress = isBlockingAIStatus(aiStatus)
+ const mode: NotebookLayoutMode = settings.layoutMode ?? "list"
+
+ // User-origin only: tool-driven set_layout_mode bypasses this handler so it doesn't appear in the AI digest.
+ const handleModeChange = (next: NotebookLayoutMode) => {
+ if (next === mode) return
+ updateSettings({ layoutMode: next })
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_changed_layout_mode",
+ bufferId: activeBuffer.id,
+ mode: next,
+ })
+ }
+ }
+
+ const handleBuildWithAI = () => {
+ if (typeof activeBuffer.id !== "number") return
+ void openNotebookChat(activeBuffer.id)
+ }
+
+ return (
+
+
+ Build with AI
+
+
+
+
+ handleModeChange("list")}
+ title="List layout"
+ >
+
+
+ handleModeChange("grid")}
+ title="Grid layout"
+ >
+
+
+
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/NotebookWorkspaceBridge.tsx b/src/scenes/Editor/Notebook/NotebookWorkspaceBridge.tsx
new file mode 100644
index 000000000..c3d149d7c
--- /dev/null
+++ b/src/scenes/Editor/Notebook/NotebookWorkspaceBridge.tsx
@@ -0,0 +1,67 @@
+import React, { useEffect, useRef } from "react"
+import { useEditor } from "../../../providers/EditorProvider"
+import {
+ registerWorkspace,
+ unregisterWorkspace,
+ waitForController,
+ type NotebookWorkspaceBufferMeta,
+} from "../../../utils/notebookAIBridge"
+
+export const NotebookWorkspaceBridge: React.FC = () => {
+ const { buffers, activeBuffer, addBuffer, setActiveBuffer } = useEditor()
+
+ const buffersRef = useRef(buffers)
+ buffersRef.current = buffers
+ const activeBufferRef = useRef(activeBuffer)
+ activeBufferRef.current = activeBuffer
+
+ useEffect(() => {
+ registerWorkspace({
+ async createNotebook(label, signal) {
+ // AI-created notebooks start with no cells; the agent populates via add_cell / apply_notebook_state.
+ const buffer = await addBuffer({
+ label: label ?? "Notebook",
+ notebookViewState: { cells: [] },
+ })
+ if (!buffer?.id) {
+ throw new Error("Failed to create notebook buffer")
+ }
+ await waitForController(buffer.id, 5000, signal)
+ return { bufferId: buffer.id, label: buffer.label }
+ },
+ async activateNotebook(bufferId) {
+ const target = buffersRef.current.find(
+ (b) => b.id === bufferId && !b.archived,
+ )
+ if (!target) return false
+ await setActiveBuffer(target)
+ return true
+ },
+ getBufferMeta(bufferId): NotebookWorkspaceBufferMeta {
+ const b = buffersRef.current.find((x) => x.id === bufferId)
+ if (!b) return { kind: "deleted" }
+ if (b.archived) return { kind: "archived", label: b.label }
+ if (!b.notebookViewState) return null
+ const kind: "active" | "inactive" =
+ activeBufferRef.current?.id === b.id ? "active" : "inactive"
+ return {
+ kind,
+ label: b.label,
+ notebookViewState: b.notebookViewState,
+ }
+ },
+ listNotebookBuffers() {
+ return buffersRef.current
+ .filter((b) => !!b.notebookViewState && typeof b.id === "number")
+ .map((b) => ({
+ bufferId: b.id as number,
+ label: b.label,
+ archived: !!b.archived,
+ }))
+ },
+ })
+ return () => unregisterWorkspace()
+ }, [addBuffer, setActiveBuffer])
+
+ return null
+}
diff --git a/src/scenes/Editor/Notebook/__fixtures__/declare-cases.json b/src/scenes/Editor/Notebook/__fixtures__/declare-cases.json
new file mode 100644
index 000000000..d13d6398c
--- /dev/null
+++ b/src/scenes/Editor/Notebook/__fixtures__/declare-cases.json
@@ -0,0 +1,91 @@
+[
+ "CREATE VIEW foo AS (DECLARE @x := 1, @y := 2 SELECT @x + @y)",
+ "DECLARE OVERRIDABLE @max_spread := 2.0 SELECT ts, symbol, bid, ask FROM quotes WHERE (ask - bid) <= @max_spread",
+ "DECLARE OVERRIDABLE @side := 'BUY' SELECT ts, symbol, price, side FROM trade_log WHERE side = @side",
+ "DECLARE @threshold := 6, @multiplier := 10\nWITH filtered AS (SELECT ts, v FROM view1)\nSELECT ts, v * @multiplier as scaled_v FROM filtered\n",
+ "DECLARE @x := 2\nSELECT * FROM (\n DECLARE @x := 5, @y := 3\n SELECT * FROM (\n DECLARE @x := 8, @z := 1\n SELECT @x + @y + @z as result FROM long_sequence(1)\n )\n)\n",
+ "DECLARE @x := 2, @y := 100\nSELECT @x + @y as outer_result, inner_result FROM (\n DECLARE @x := 5\n SELECT @x + @y as inner_result FROM long_sequence(1)\n)\n",
+ "DECLARE OVERRIDABLE @min_qty := 50 SELECT ts, symbol, price, qty FROM trades WHERE qty >= @min_qty LATEST ON ts PARTITION BY symbol",
+ "DECLARE OVERRIDABLE @min_value := 15.0 SELECT ts, sensor, avg(value) as avg_val FROM samples WHERE value > @min_value SAMPLE BY 1m",
+ "DECLARE @x := 6, @y := 2\nSELECT * FROM (\n DECLARE @z := 100\n SELECT ts, v, @z as marker FROM view1\n) WHERE v > @y\n",
+ "DECLARE OVERRIDABLE @default_val := 0 SELECT ts, category, coalesce(value, @default_val) as value FROM nullable_data",
+ "CREATE TABLE foo AS (DECLARE @x := 1, @y := 2 SELECT @x + @y)",
+ "CREATE VIEW foo AS (DECLARE @x := 1, @y := 2 SELECT @x + @y)",
+ "INSERT INTO foo SELECT * FROM (DECLARE @x := 1, @y := 2 SELECT @x + @y as x)",
+ "DECLARE OVERRIDABLE @x := 5 SELECT @x",
+ "DECLARE overridable @x := 5 SELECT @x",
+ "DECLARE Overridable @x := 5 SELECT @x",
+ "DECLARE OVERRIDABLE @x := 5 SELECT @x",
+ "DECLARE OVERRIDABLE @x := 5, @y := 10 SELECT @x, @y",
+ "DECLARE OVERRIDABLE @x := 5, OVERRIDABLE @y := 10 SELECT @x, @y",
+ "declare @ts := '2025-07-02T13:00:00.000000Z', @int := interval(@ts, @ts)select @int",
+ "DECLARE @foo := foo, @bah := bah SELECT foo.ts, foo.x FROM @foo ASOF JOIN @bah",
+ "DECLARE @x := 2, @y := 5 WITH a AS (SELECT @x + @y) SELECT * FROM a",
+ "DECLARE @x := 1, @y := 5, @z := 2 SELECT CASE WHEN @x = @X THEN @y ELSE @z END",
+ "DECLARE @x := 2::timestamp SELECT @x",
+ "DECLARE @x := 5 SELECT CAST(@x AS timestamp)",
+ "DECLARE @x := symbol SELECT DISTINCT symbol FROM trades",
+ "DECLARE @x := 123.456 SELECT @x",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) EXCEPT (SELECT @a + @b)",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) EXCEPT ALL (SELECT @a + @b)",
+ "EXPLAIN DECLARE @x := 5 SELECT @x",
+ "DECLARE @lo := '2025-01-01', @hi := '2025-01-02', @unit := '1d' SELECT * FROM generate_series(@lo, @hi, @unit)",
+ "DECLARE @x := timestamp, @y := symbol SELECT timestamp, symbol, price FROM trades GROUP BY @x, @y, price",
+ "DECLARE @x := 1, @y := 2 SELECT timestamp, symbol, price FROM trades GROUP BY @x, @y, 3",
+ "DECLARE @x := 5 SELECT @x",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) INTERSECT (SELECT @a + @b)",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) INTERSECT ALL (SELECT @a + @b)",
+ "DECLARE @x := foo.x, @y := bah.y SELECT foo.ts, foo.x FROM foo JOIN bah on @x = @y",
+ "DECLARE @x := $1, @y := $2 SELECT @x, @y",
+ "DECLARE @ts := timestamp SELECT * FROM trades LATEST BY @ts;",
+ "DECLARE @ts := timestamp, @sym := symbol SELECT * FROM trades LATEST ON @ts PARTITION BY @sym;",
+ "DECLARE @lo := 2, @hi := 5 SELECT * FROM trades LIMIT @lo, @hi",
+ "DECLARE @x := 2, @y := 5 WITH a AS (SELECT @x + @y as col1), b AS (SELECT (@x - @y) + col1 as col2 FROM a) SELECT * FROM b",
+ "DECLARE @x := 1, @y := 2 SELECT @x, @y",
+ "DECLARE @x := 1, @y := 2 SELECT @x + @y",
+ "DECLARE @x := 1, @y := 2 SELECT @x * @y + @x / @y",
+ "DECLARE @lo := -5, @hi := -2 SELECT * FROM trades LIMIT @lo, @hi",
+ "DECLARE @lo := 2, @hi := 5 SELECT * FROM trades LIMIT -@lo, -@hi",
+ "DECLARE @x := timestamp, @y := symbol SELECT timestamp, symbol, price FROM trades ORDER BY @x, @y, price",
+ "DECLARE @x := 1, @y := 2 SELECT timestamp, symbol, price FROM trades ORDER BY @x, @y, 3",
+ "DECLARE @unit := 1h SELECT timestamp, symbol, avg(price) FROM trades SAMPLE BY @unit",
+ "DECLARE @unit := 1h SELECT timestamp, symbol, avg(price) FROM trades SAMPLE BY @unit ALIGN TO FIRST OBSERVATION",
+ "DECLARE @unit := 1h, @from := '2008-12-28', @to := '2009-01-05', @fill := null SELECT timestamp, symbol, avg(price) FROM trades SAMPLE BY @unit FROM @from TO @to FILL(@fill)",
+ "DECLARE @offset := '10:00' SELECT timestamp, symbol, avg(price) FROM trades SAMPLE BY 1h ALIGN TO CALENDAR WITH OFFSET @offset",
+ "DECLARE @tz := 'Antarctica/McMurdo' SELECT timestamp, symbol, avg(price) FROM trades SAMPLE BY 1h ALIGN TO CALENDAR TIME ZONE @tz",
+ "DECLARE @x := 2, @y := 5 SELECT * FROM (SELECT @x + @y)",
+ "DECLARE @x := 2, @y := 5 SELECT * FROM (DECLARE @x:= 7 SELECT @x + @y)",
+ "DECLARE @x := 2, @y := 5 SELECT @x - @y as foo, * FROM (DECLARE @x:= 7 SELECT @x + @y)",
+ "DECLARE @table_name := foo, @ts := ts, @x := x, SELECT @ts, @x FROM @table_name",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) UNION (SELECT @a + @b)",
+ "DECLARE @a := 1, @b := 2 (SELECT @a + @b) UNION ALL (SELECT @a + @b)",
+ "DECLARE @x := 2, @y := 5 SELECT @x + @y FROM long_sequence(1) WHERE @x < @y",
+ "DECLARE @x := 2::timestamp, @y := 5::timestamp SELECT @x + @y FROM long_sequence(1) WHERE @x < @y",
+ "DECLARE @max_price := max(price) SELECT timestamp, symbol, @max_price FROM trades",
+ "DECLARE\n @today := today(),\n @start := interval_start(@today),\n @end := interval_end(@today)\n SELECT @today = interval(@start, @end)",
+ "DECLARE\n @ts := timestamp,\n @bid_price := bid_px_00,\n @bid_size := bid_sz_00,\n @avg_time_range := '5',\n @updates_period := '100',\n @volume_2sec := '2'\nSELECT\n @ts,\n @bid_price,\n AVG(@bid_price) OVER (\n ORDER BY @ts\n RANGE BETWEEN @avg_time_range MINUTE PRECEDING AND CURRENT ROW\n ) AS avg_5min,\n COUNT(*) OVER (\n ORDER BY @ts\n RANGE BETWEEN @updates_period MILLISECOND PRECEDING AND CURRENT ROW\n ) AS updates_100ms,\n SUM(@bid_size) OVER (\n ORDER BY @ts\n RANGE BETWEEN @volume_2sec SECOND PRECEDING AND CURRENT ROW\n ) AS volume_2sec\nFROM AAPL_orderbook\nWHERE @bid_price > 0\nLIMIT 10;",
+ "DECLARE\n @partition_col := bid_px_00,\n @order_col := timestamp\nSELECT\n @order_col,\n @partition_col,\n ROW_NUMBER() OVER (\n PARTITION BY @partition_col\n ORDER BY @order_col\n ) AS row_num\nFROM AAPL_orderbook\nLIMIT 5;",
+ "DECLARE @x := (SELECT 1 as y) SELECT * FROM @x",
+ "DECLARE @x := (DECLARE @y := 4 SELECT @y as y) SELECT * FROM @x",
+ "DECLARE @x := 5, @y := (DECLARE @y := 4 SELECT @y + @x as z) SELECT * FROM @y",
+ "DECLARE @y := 2, @y2 := (@y * @y) SELECT @y, @y2",
+ "declare @ts := (timestamp > '2024-01-01') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01' select timestamp, count() from trades where timestamp > @lo;",
+ "declare @ts := (timestamp > '2024-01-01' and timestamp < '2024-08-23') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01', @hi := '2024-08-23' select timestamp, count() from trades where timestamp > @lo and timestamp < @hi;",
+ "declare @ts := (timestamp < '2024-08-23') select timestamp, count() from trades where @ts;",
+ "declare @hi := '2024-08-23' select timestamp, count() from trades where timestamp < @hi;",
+ "declare @ts := (timestamp > '2024-01-01') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01' select timestamp, count() from trades where timestamp > @lo;",
+ "declare @ts := (timestamp >= '2024-01-01' and timestamp <= '2024-08-23') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01', @hi := '2024-08-23' select timestamp, count() from trades where timestamp >= @lo and timestamp <= @hi;",
+ "declare @ts := (timestamp <= '2024-08-23') select timestamp, count() from trades where @ts;",
+ "declare @hi := '2024-08-23' select timestamp, count() from trades where timestamp <= @hi;",
+ "declare @ts := (timestamp >= '2024-01-01') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01' select timestamp, count() from trades where timestamp >= @lo;",
+ "declare @ts := (timestamp between '2024-01-01' and '2024-08-23') select timestamp, count() from trades where @ts;",
+ "declare @lo := '2024-01-01', @hi := '2024-08-23' select timestamp, count() from trades where timestamp between @lo and @hi;",
+ "declare @id := id, @val := 4 select * from x where @id < @val",
+ "declare @expr := (id < 4) select * from x where @expr",
+ "declare @x := '2023-01-01T09:04:00.000000Z' select t.sym, t.price, t.ts, sum(p.price) as window_price from trades t window join prices p on (t.sym = p.sym) range between 1 minute preceding and 1 minute following "
+]
diff --git a/src/scenes/Editor/Notebook/cells/AddCellButton.tsx b/src/scenes/Editor/Notebook/cells/AddCellButton.tsx
new file mode 100644
index 000000000..7e0ff7af2
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/AddCellButton.tsx
@@ -0,0 +1,138 @@
+import React from "react"
+import styled from "styled-components"
+import { Plus } from "@styled-icons/boxicons-regular"
+import { color } from "../../../../utils"
+import { useNotebookActions } from "../NotebookProvider"
+import { useEditor } from "../../../../providers/EditorProvider"
+import { emitUserAction } from "../../../../utils/notebookAIBridge"
+
+const BottomButton = styled.div<{ $alignCenter?: boolean }>`
+ display: flex;
+ justify-content: center;
+ height: ${({ $alignCenter }) => ($alignCenter ? "100%" : "auto")};
+ padding: 0.8rem 0;
+ margin-top: 1rem;
+`
+
+const AddButton = styled.button`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 1rem;
+ border: none;
+ border-radius: 0.3rem;
+ background: transparent;
+ color: ${color("comment")};
+ cursor: pointer;
+ font-family: ${({ theme }) => theme.font};
+
+ &:hover {
+ color: ${color("foreground")};
+ background: ${color("selection")};
+ }
+
+ svg {
+ width: 1.4rem;
+ height: 1.4rem;
+ }
+`
+
+const BetweenWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 1.2rem;
+ position: relative;
+ opacity: 0;
+ transition: opacity 0.1s;
+
+ &:hover {
+ opacity: 1;
+ }
+`
+
+const BetweenLine = styled.div`
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: ${color("selection")};
+`
+
+const BetweenButton = styled.button`
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.8rem;
+ height: 1.8rem;
+ border: 1px solid ${color("selection")};
+ border-radius: 50%;
+ background: ${color("editorBackground")};
+ color: ${color("comment")};
+ cursor: pointer;
+ padding: 0;
+
+ &:hover {
+ border-color: ${color("comment")};
+ color: ${color("foreground")};
+ }
+`
+
+// User-origin only: tool-driven add_cell goes through NotebookController directly and doesn't emit here.
+const useUserAddCell = () => {
+ const { addCell } = useNotebookActions()
+ const { activeBuffer } = useEditor()
+ return (afterCellId?: string) => {
+ const cellId = addCell(afterCellId)
+ if (typeof activeBuffer.id === "number" && cellId) {
+ emitUserAction({
+ kind: "user_added_cell",
+ bufferId: activeBuffer.id,
+ cellId,
+ })
+ }
+ }
+}
+
+type AddCellBottomProps = {
+ alignCenter?: boolean
+ afterCellId?: string
+}
+
+export const AddCellBottom: React.FC = ({
+ afterCellId,
+ alignCenter = false,
+}) => {
+ const addCell = useUserAddCell()
+
+ return (
+
+ addCell(afterCellId)}>
+
+ Add Cell
+
+
+ )
+}
+
+type AddCellBetweenProps = {
+ afterCellId: string
+}
+
+export const AddCellBetween: React.FC = ({
+ afterCellId,
+}) => {
+ const addCell = useUserAddCell()
+
+ return (
+
+
+ addCell(afterCellId)} aria-label="Add cell">
+
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/cells/Cell.tsx b/src/scenes/Editor/Notebook/cells/Cell.tsx
new file mode 100644
index 000000000..129f54712
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/Cell.tsx
@@ -0,0 +1,787 @@
+import React, { useCallback, useEffect, useRef, useState } from "react"
+import styled, { css, useTheme } from "styled-components"
+import { color } from "../../../../utils"
+import { Editor } from "@monaco-editor/react"
+import {
+ QuestDBLanguageName,
+ getQueryFromCursor,
+ normalizeQueryText,
+ stripSQLComments,
+} from "../../Monaco/utils"
+import { PlayIcon, CancelIcon } from "../../Monaco/icons"
+import { ChartLineUpIcon } from "@phosphor-icons/react"
+import { QuestContext } from "../../../../providers/QuestProvider"
+import { useNotebookActions } from "../NotebookProvider"
+import { CellToolbar } from "./CellToolbar"
+import { InlineResultTable } from "../result-table"
+import { ResizeHandle } from "../resize"
+import { CellWrapper } from "./CellWrapper"
+import { DrawCanvas } from "../DrawCanvas"
+import type { ChartConfig } from "../CellChart/chartTypes"
+import type { NotebookCell } from "../../../../store/notebook"
+import { useCellResize } from "./useCellResize"
+import { useCellSelectionDecoration } from "./useCellSelectionDecoration"
+import { useMonacoCellEditor } from "./useMonacoCellEditor"
+import { useEditor } from "../../../../providers/EditorProvider"
+import { emitUserAction } from "../../../../utils/notebookAIBridge"
+import { requireAllDQL } from "../../../../utils/tools/permissions"
+import { toast } from "../../../../components/Toast"
+import { eventBus } from "../../../../modules/EventBus"
+import { EventType } from "../../../../modules/EventBus/types"
+import {
+ DEFAULT_TOP_HEIGHT,
+ defaultBottomHeightFor,
+ isDoubleView,
+ MIN_BOTTOM_HEIGHT_PX,
+ partitionCellHeights,
+ scaleCellHeights,
+ upsertColumnSizing,
+} from "../notebookUtils"
+import { useValidateWithGlobals } from "../globals/useValidateWithGlobals"
+
+// Minimum content area heights. `MIN_EDITOR_HEIGHT` matches Monaco's reported
+// content height for an empty editor (one line + padding); the previous
+// `MAX_EDITOR_HEIGHT` cap is gone — the editor now auto-grows freely with
+// pasted content (user-confirmed: unbounded).
+const MIN_EDITOR_HEIGHT = 72
+
+const RunBar = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ background: ${({ theme }) => theme.color.backgroundDarker};
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+`
+
+const RunButton = styled.button`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 0.3rem;
+ background: transparent;
+ cursor: pointer;
+ padding: 0;
+
+ &:hover {
+ filter: brightness(1.3);
+ }
+
+ &:disabled {
+ opacity: 0.3;
+ cursor: default;
+ filter: none;
+ }
+
+ svg {
+ width: 2.4rem;
+ height: 2.4rem;
+ }
+`
+
+const RunButtonGroup = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+`
+
+const DrawButton = styled.button<{ $active: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 0.3rem;
+ background: transparent;
+ color: ${({ $active, theme }) =>
+ $active ? theme.color.cyan : theme.color.cyan};
+ cursor: pointer;
+ padding: 0;
+
+ &:hover {
+ filter: brightness(1.3);
+ }
+
+ &:disabled {
+ opacity: 0.3;
+ cursor: default;
+ filter: none;
+ }
+`
+
+// Editor slot. Numeric sizing (`height` in list/grid, `flex` in spotlight)
+// is applied via the inline `style` prop on the call site so styled-
+// components doesn't mint a new generated class for every distinct pixel
+// value during a resize drag. Only static, boolean-switched rules live
+// in the template here.
+const EditorContainer = styled.div<{ $spotlight: boolean }>`
+ overflow: hidden;
+ background: ${color("editorBackground")};
+ ${({ $spotlight }) =>
+ $spotlight
+ ? css`
+ min-height: 0;
+ `
+ : css`
+ min-height: ${MIN_EDITOR_HEIGHT}px;
+ flex-shrink: 0;
+ `}
+`
+
+const BottomSlot = styled.div<{ $spotlight: boolean }>`
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ ${({ $spotlight }) =>
+ $spotlight
+ ? null
+ : css`
+ flex-shrink: 0;
+ `}
+`
+
+type Props = {
+ cell: NotebookCell
+ layoutMode?: "list" | "grid"
+ isFocused: boolean
+ isMaximized: boolean
+ isRunning: boolean
+}
+
+const CellInner: React.FC = ({
+ cell,
+ layoutMode = "list",
+ isFocused,
+ isMaximized,
+ isRunning,
+}) => {
+ const {
+ runCell,
+ cancelCell,
+ cancelQuery,
+ setActiveResultIndex,
+ setCellMode,
+ setCellChartConfig,
+ setCellAutoRefresh,
+ setCellChartMaximized,
+ updateCell,
+ setFocusedCell,
+ } = useNotebookActions()
+ const theme = useTheme()
+ const { quest } = React.useContext(QuestContext)
+ const { activeBuffer } = useEditor()
+ const bufferIdForEvents =
+ typeof activeBuffer.id === "number" ? activeBuffer.id : undefined
+ const isDrawMode = cell.mode === "draw"
+ const isChartMaximized = isDrawMode && !!cell.isChartMaximized
+
+ const editorContainerRef = useRef(null)
+ const resultRef = useRef(null)
+
+ const contentHeightGetterRef = useRef<() => number | null>(() => null)
+
+ const validateWithGlobals = useValidateWithGlobals()
+
+ const topResize = useCellResize(
+ MIN_EDITOR_HEIGHT,
+ useCallback(
+ (height: number) =>
+ updateCell(cell.id, { topHeight: height, topResized: true }),
+ [cell.id, updateCell],
+ ),
+ // Write Monaco's CURRENT content height directly on reset, rather
+ // than setting `topHeight: undefined` and waiting for the next
+ // `onContentHeightChange` to fill it in. The wait creates a
+ // one-frame flicker where `topHeight` falls back to
+ // DEFAULT_TOP_HEIGHT (72 px) and the bottom slot jumps up.
+ useCallback(() => {
+ const contentH = contentHeightGetterRef.current()
+ const next =
+ contentH != null
+ ? Math.max(MIN_EDITOR_HEIGHT, contentH)
+ : MIN_EDITOR_HEIGHT
+ updateCell(cell.id, { topHeight: next, topResized: false })
+ }, [cell.id, updateCell]),
+ )
+ const bottomResize = useCellResize(
+ MIN_BOTTOM_HEIGHT_PX,
+ useCallback(
+ (height: number) => updateCell(cell.id, { bottomHeight: height }),
+ [cell.id, updateCell],
+ ),
+ useCallback(
+ () => updateCell(cell.id, { bottomHeight: undefined }),
+ [cell.id, updateCell],
+ ),
+ )
+
+ const handleColumnSizingCommit = useCallback(
+ (sizing: Record, query: string) => {
+ updateCell(cell.id, {
+ columnSizing: upsertColumnSizing(cell.columnSizing, query, sizing),
+ })
+ },
+ [cell.id, cell.columnSizing, updateCell],
+ )
+
+ const handleChartConfigChange = useCallback(
+ (config: ChartConfig) => setCellChartConfig(cell.id, config),
+ [cell.id, setCellChartConfig],
+ )
+ const handleAutoRefreshChange = useCallback(
+ (value: boolean) => setCellAutoRefresh(cell.id, value),
+ [cell.id, setCellAutoRefresh],
+ )
+ const handleChartMaximizedChange = useCallback(
+ (value: boolean) => setCellChartMaximized(cell.id, value),
+ [cell.id, setCellChartMaximized],
+ )
+
+ const validatingDrawRef = useRef(false)
+
+ const handleDrawClick = useCallback(async () => {
+ if (isDrawMode) {
+ setCellMode(cell.id, "run")
+ if (bufferIdForEvents !== undefined) {
+ emitUserAction({
+ kind: "user_changed_cell_mode",
+ bufferId: bufferIdForEvents,
+ cellId: cell.id,
+ mode: "run",
+ })
+ }
+ return
+ }
+ if (validatingDrawRef.current) return
+ validatingDrawRef.current = true
+ try {
+ const decision = await requireAllDQL(cell.value, (s) =>
+ validateWithGlobals(s),
+ )
+ if (!decision.granted) {
+ toast.error(decision.reason)
+ return
+ }
+ setCellMode(cell.id, "draw")
+ setCellChartMaximized(cell.id, true)
+ if (bufferIdForEvents !== undefined) {
+ emitUserAction({
+ kind: "user_changed_cell_mode",
+ bufferId: bufferIdForEvents,
+ cellId: cell.id,
+ mode: "draw",
+ })
+ }
+ } finally {
+ validatingDrawRef.current = false
+ }
+ }, [
+ cell.id,
+ cell.value,
+ isDrawMode,
+ setCellMode,
+ setCellChartMaximized,
+ bufferIdForEvents,
+ validateWithGlobals,
+ ])
+
+ const exitDrawIfNeeded = useCallback(() => {
+ if (isDrawMode) setCellMode(cell.id, "run")
+ }, [cell.id, isDrawMode, setCellMode])
+
+ const liveTopHeight = topResize.liveHeight
+ const liveBottomHeight = bottomResize.liveHeight
+
+ const topHeight = liveTopHeight ?? cell.topHeight ?? DEFAULT_TOP_HEIGHT
+ const bottomHeight =
+ liveBottomHeight ?? cell.bottomHeight ?? defaultBottomHeightFor(cell)
+ const doubleView = isDoubleView(cell)
+
+ const [spotlightLiveRatio, setSpotlightLiveRatio] = useState(
+ null,
+ )
+ const spotlightEditorRatio =
+ spotlightLiveRatio ??
+ cell.spotlightEditorRatio ??
+ topHeight / (topHeight + bottomHeight)
+
+ // Editor height pipeline (hard-cap model):
+ // - When NOT user-resized: cell.topHeight tracks Monaco's content height
+ // exactly. Editor auto-grows / auto-shrinks with content.
+ // - When user-resized (topResized === true): cell.topHeight is FIXED at
+ // the user's drag value. Monaco's content-size events are ignored;
+ // content overflow is handled by Monaco's internal scrollbar.
+ // Per user spec: configured top-height should not expand to fit content —
+ // the user's resize is a hard cap, scrolling stays inside Monaco.
+ const handleContentHeightChange = useCallback(
+ (px: number) => {
+ if (isMaximized) return
+ if (cell.topResized) return
+ const next = Math.max(MIN_EDITOR_HEIGHT, px)
+ if (next === cell.topHeight) return
+ updateCell(cell.id, { topHeight: next })
+ },
+ [isMaximized, cell.id, cell.topResized, cell.topHeight, updateCell],
+ )
+
+ const { editorRef, monacoRef, handleEditorMount } = useMonacoCellEditor({
+ cellId: cell.id,
+ editorViewState: cell.editorViewState,
+ quest,
+ onFocus: useCallback(
+ () => setFocusedCell(cell.id),
+ [cell.id, setFocusedCell],
+ ),
+ onSaveViewState: useCallback(
+ (state) => updateCell(cell.id, { editorViewState: state }),
+ [cell.id, updateCell],
+ ),
+ onRunAtCursor: () => void handleRunAtCursor(),
+ onRunAll: () => void handleRunAll(),
+ onContentHeightChange: handleContentHeightChange,
+ validate: validateWithGlobals,
+ })
+
+ const { applyHighlight, clearHighlight } = useCellSelectionDecoration(
+ editorRef,
+ monacoRef,
+ )
+
+ // Install the content-height getter that `topResize.resetHeight`
+ // reads (declared above, before `editorRef` is in scope). The
+ // closure over the stable `editorRef` means we don't need to
+ // re-install when `editorRef.current` later transitions from null
+ // to the mounted instance.
+ useEffect(() => {
+ contentHeightGetterRef.current = () =>
+ editorRef.current?.getContentHeight() ?? null
+ }, [editorRef])
+
+ const tryRunSelection = useCallback(async (): Promise => {
+ const ed = editorRef.current
+ if (!ed) return false
+ const selection = ed.getSelection()
+ const model = ed.getModel()
+ if (!selection || !model || selection.isEmpty()) return false
+
+ const selectedText = model.getValueInRange(selection)
+ const normalized = normalizeQueryText(selectedText)
+ if (!normalized) return false
+
+ clearHighlight()
+ const success = await runCell(cell.id, normalized)
+ applyHighlight(success)
+ return true
+ }, [cell.id, runCell, editorRef, applyHighlight, clearHighlight])
+
+ const emitRanEvent = useCallback(
+ (success: boolean) => {
+ if (bufferIdForEvents === undefined) return
+ emitUserAction({
+ kind: "user_ran_cell",
+ bufferId: bufferIdForEvents,
+ cellId: cell.id,
+ status: success ? "success" : "error",
+ })
+ },
+ [bufferIdForEvents, cell.id],
+ )
+
+ const handleRunAll = useCallback(async () => {
+ if (await tryRunSelection()) return
+ if (!editorRef.current) return
+
+ clearHighlight()
+
+ const ok = await runCell(cell.id)
+ emitRanEvent(ok)
+ }, [
+ cell.id,
+ runCell,
+ tryRunSelection,
+ editorRef,
+ clearHighlight,
+ emitRanEvent,
+ ])
+
+ const handleRunAtCursor = useCallback(async () => {
+ if (await tryRunSelection()) return
+ const ed = editorRef.current
+ if (!ed) return
+
+ clearHighlight()
+
+ const queryAtCursor = getQueryFromCursor(ed)
+ if (queryAtCursor) {
+ void runCell(cell.id, normalizeQueryText(queryAtCursor.query))
+ } else {
+ void runCell(cell.id)
+ }
+ }, [cell.id, runCell, tryRunSelection, editorRef, clearHighlight])
+
+ // 500 ms-debounced — one event per cell per typing burst keeps the digest tiny.
+ const updateEmitTimerRef = useRef | null>(null)
+ const scheduleUpdateEvent = useCallback(() => {
+ if (bufferIdForEvents === undefined) return
+ if (updateEmitTimerRef.current !== null) {
+ clearTimeout(updateEmitTimerRef.current)
+ }
+ updateEmitTimerRef.current = setTimeout(() => {
+ updateEmitTimerRef.current = null
+ emitUserAction({
+ kind: "user_updated_cell",
+ bufferId: bufferIdForEvents,
+ cellId: cell.id,
+ })
+ }, 500)
+ }, [bufferIdForEvents, cell.id])
+ useEffect(() => {
+ return () => {
+ if (updateEmitTimerRef.current !== null) {
+ clearTimeout(updateEmitTimerRef.current)
+ }
+ }
+ }, [])
+
+ const handleEditorChange = useCallback(
+ (value: string | undefined) => {
+ if (value !== undefined) {
+ const viewState = editorRef.current?.saveViewState() ?? undefined
+ updateCell(cell.id, { value, editorViewState: viewState })
+ scheduleUpdateEvent()
+ }
+ },
+ [cell.id, updateCell, editorRef, scheduleUpdateEvent],
+ )
+
+ // only seeds on mount — push external edits (AI
+ // tools, duplicate clone, remote load) through executeEdits so undo
+ // history survives.
+ useEffect(() => {
+ const ed = editorRef.current
+ if (!ed) return
+ if (ed.getValue() === cell.value) return
+ const model = ed.getModel()
+ if (!model) return
+ ed.executeEdits("external-sync", [
+ {
+ range: model.getFullModelRange(),
+ text: cell.value,
+ forceMoveMarkers: true,
+ },
+ ])
+ }, [cell.value, editorRef])
+
+ useEffect(() => {
+ editorRef.current?.updateOptions({
+ scrollbar: { handleMouseWheel: isFocused },
+ })
+ }, [isFocused, editorRef])
+
+ // Esc blurs the cell. Respect defaultPrevented so a Radix popper close
+ // doesn't double up with cell blur on the same keystroke.
+ useEffect(() => {
+ if (!isFocused) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return
+ if (e.defaultPrevented) return
+ setFocusedCell(null)
+ ;(document.activeElement as HTMLElement | null)?.blur?.()
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [isFocused, setFocusedCell])
+
+ const middleSum = useCallback(() => {
+ if (!isMaximized) return topHeight + bottomHeight
+ const editorH =
+ editorContainerRef.current?.getBoundingClientRect().height ?? 0
+ const bottomH = resultRef.current?.getBoundingClientRect().height ?? 0
+ return editorH + bottomH
+ }, [isMaximized, topHeight, bottomHeight])
+
+ const middleResizeLive = useCallback(
+ (height: number) => {
+ const sum = middleSum()
+ const { top, bottom } = partitionCellHeights(
+ sum,
+ height,
+ MIN_EDITOR_HEIGHT,
+ MIN_BOTTOM_HEIGHT_PX,
+ )
+ if (isMaximized) {
+ setSpotlightLiveRatio(top / (top + bottom))
+ return
+ }
+ topResize.resizeLive(top)
+ bottomResize.resizeLive(bottom)
+ },
+ [isMaximized, middleSum, topResize, bottomResize],
+ )
+
+ const middleResizeEnd = useCallback(
+ (height: number) => {
+ const sum = middleSum()
+ const { top, bottom } = partitionCellHeights(
+ sum,
+ height,
+ MIN_EDITOR_HEIGHT,
+ MIN_BOTTOM_HEIGHT_PX,
+ )
+ if (isMaximized) {
+ setSpotlightLiveRatio(null)
+ updateCell(cell.id, { spotlightEditorRatio: top / (top + bottom) })
+ return
+ }
+ topResize.resizeEnd(top)
+ bottomResize.resizeEnd(bottom)
+ },
+ [isMaximized, middleSum, topResize, bottomResize, cell.id, updateCell],
+ )
+
+ const resetToDefaults = useCallback(() => {
+ if (isMaximized) {
+ setSpotlightLiveRatio(null)
+ updateCell(cell.id, { spotlightEditorRatio: undefined })
+ return
+ }
+ bottomResize.resetHeight()
+ topResize.resetHeight()
+ }, [isMaximized, bottomResize, topResize, cell.id, updateCell])
+
+ const bottomEdgeResizeLive = useCallback(
+ (newResultHeight: number) => {
+ const { top, bottom } = scaleCellHeights(
+ topHeight,
+ bottomHeight,
+ topHeight + newResultHeight,
+ MIN_EDITOR_HEIGHT,
+ MIN_BOTTOM_HEIGHT_PX,
+ )
+ topResize.resizeLive(top)
+ bottomResize.resizeLive(bottom)
+ },
+ [topHeight, bottomHeight, topResize, bottomResize],
+ )
+
+ const bottomEdgeResizeEnd = useCallback(
+ (newResultHeight: number) => {
+ const { top, bottom } = scaleCellHeights(
+ topHeight,
+ bottomHeight,
+ topHeight + newResultHeight,
+ MIN_EDITOR_HEIGHT,
+ MIN_BOTTOM_HEIGHT_PX,
+ )
+ topResize.resizeEnd(top)
+ bottomResize.resizeEnd(bottom)
+ },
+ [topHeight, bottomHeight, topResize, bottomResize],
+ )
+
+ useEffect(() => {
+ const handler = (payload?: { cellId?: string }) => {
+ if (payload?.cellId !== cell.id) return
+ resetToDefaults()
+ }
+ eventBus.subscribe(EventType.NOTEBOOK_CELL_RESET_SIZE, handler)
+ return () =>
+ eventBus.unsubscribe(EventType.NOTEBOOK_CELL_RESET_SIZE, handler)
+ }, [cell.id, resetToDefaults])
+
+ return (
+ {
+ if (!(e.target as HTMLElement).closest?.(".cell-drag-handle")) {
+ e.stopPropagation()
+ }
+ setFocusedCell(cell.id)
+ }}
+ >
+ {!isChartMaximized && (
+ {
+ if (layoutMode !== "grid") return
+ if (
+ (e.target as HTMLElement).closest(
+ "button, a, input, select, textarea, .cell-toolbar",
+ )
+ )
+ return
+ eventBus.publish(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, {
+ cellId: cell.id,
+ kind: "full",
+ })
+ }}
+ >
+
+ {isRunning ? (
+ {
+ e.stopPropagation()
+ cancelCell(cell.id)
+ }}
+ title="Cancel"
+ >
+
+
+ ) : (
+ {
+ e.stopPropagation()
+ exitDrawIfNeeded()
+ void handleRunAll()
+ }}
+ title="Run (Ctrl+Enter)"
+ >
+
+
+ )}
+ {
+ e.stopPropagation()
+ void handleDrawClick()
+ }}
+ title={
+ isDrawMode
+ ? (cell.autoRefresh ?? true)
+ ? "Drawing — auto-refresh on"
+ : "Refresh chart"
+ : "Draw (auto-refresh chart)"
+ }
+ aria-label="Draw"
+ >
+
+
+
+
+
+ )}
+ {!isChartMaximized && (
+
+
+
+ )}
+ {/* Inner-top resize handle (between editor and bottom slot). Only
+ rendered in double-view, since there's nothing below in single-
+ view. Renders in every layout mode (list / grid / spotlight). */}
+ {!isChartMaximized && doubleView && (
+
+ )}
+ {/* Bottom slot: result grid OR chart, OR chart filling the whole cell
+ when expanded. */}
+ {(doubleView || isChartMaximized) && (
+
+ {isDrawMode ? (
+
+ ) : (
+ cell.result && (
+ setActiveResultIndex(cell.id, index)}
+ onCancelQuery={(index) => {
+ cancelQuery(cell.id, index)
+ }}
+ columnSizing={cell.columnSizing}
+ onColumnSizingCommit={handleColumnSizingCommit}
+ />
+ )
+ )}
+
+ )}
+ {!isChartMaximized && !isMaximized && layoutMode !== "grid" && (
+
+ )}
+
+ )
+}
+
+export const Cell = React.memo(CellInner)
diff --git a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx
new file mode 100644
index 000000000..c0a3f5146
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx
@@ -0,0 +1,180 @@
+import React, { useState } from "react"
+import styled, { css } from "styled-components"
+import {
+ ChevronUp,
+ ChevronDown,
+ CopyAlt,
+ Trash,
+} from "@styled-icons/boxicons-regular"
+import {
+ DotsThreeVerticalIcon,
+ CornersOutIcon,
+ CornersInIcon,
+} from "@phosphor-icons/react"
+import { DropdownMenu, Button } from "../../../../components"
+import { useNotebookActions, useNotebookState } from "../NotebookProvider"
+import { useEditor } from "../../../../providers/EditorProvider"
+import { emitUserAction } from "../../../../utils/notebookAIBridge"
+
+const ToolbarWrapper = styled.div<{
+ $inline?: boolean
+ $forceVisible?: boolean
+}>`
+ display: flex;
+ align-items: center;
+
+ ${({ $inline, $forceVisible }) =>
+ $inline
+ ? ""
+ : css`
+ position: absolute;
+ top: 0.4rem;
+ right: 0.6rem;
+ z-index: 2;
+ opacity: ${$forceVisible ? 1 : 0};
+ transition: opacity 0.1s;
+ `}
+`
+
+const IconWrapper = styled.span`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`
+
+type Props = {
+ cellId: string
+ inline?: boolean
+}
+
+export const CellToolbar: React.FC = ({ cellId, inline }) => {
+ const {
+ moveCellUp,
+ moveCellDown,
+ duplicateCell,
+ deleteCell,
+ setMaximizedCellId,
+ } = useNotebookActions()
+ const { cells, maximizedCellId, settings } = useNotebookState()
+ const cellIndex = cells.findIndex((c) => c.id === cellId)
+ const totalCells = cells.length
+ // Grid mode positions cells via settings.layout[i].{x,y,w,h}, so swapping array
+ // order doesn't move them visually — hide move up/down in grid mode.
+ const isGridMode = settings.layoutMode === "grid"
+ const { activeBuffer } = useEditor()
+
+ const isMaximized = maximizedCellId === cellId
+ const [menuOpen, setMenuOpen] = useState(false)
+
+ const handleMoveUp = () => {
+ moveCellUp(cellId)
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_moved_cell",
+ bufferId: activeBuffer.id,
+ cellId,
+ })
+ }
+ }
+ const handleMoveDown = () => {
+ moveCellDown(cellId)
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_moved_cell",
+ bufferId: activeBuffer.id,
+ cellId,
+ })
+ }
+ }
+ const handleDuplicate = () => {
+ const newCellId = duplicateCell(cellId)
+ if (typeof activeBuffer.id === "number" && newCellId) {
+ emitUserAction({
+ kind: "user_duplicated_cell",
+ bufferId: activeBuffer.id,
+ cellId,
+ newCellId,
+ })
+ }
+ }
+ const handleDelete = () => {
+ deleteCell(cellId)
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_deleted_cell",
+ bufferId: activeBuffer.id,
+ cellId,
+ })
+ }
+ }
+
+ return (
+
+
+ {!isMaximized && (
+
+
+
+
+
+
+ {!isGridMode && (
+ <>
+
+
+
+
+ Move up
+
+
+
+
+
+ Move down
+
+ >
+ )}
+
+
+
+
+ Duplicate
+
+
+
+
+
+ Delete
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/cells/CellWrapper.tsx b/src/scenes/Editor/Notebook/cells/CellWrapper.tsx
new file mode 100644
index 000000000..783fc1302
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/CellWrapper.tsx
@@ -0,0 +1,62 @@
+import styled, { css } from "styled-components"
+import { color } from "../../../../utils"
+
+// `data-notebook-cell` marker is read by the container's click-outside-to-blur logic to detect clicks landing inside a cell.
+export const CellWrapper = styled.div.attrs({
+ "data-notebook-cell": "true",
+})<{
+ $focused: boolean
+ $maximized: boolean
+ $gridMode?: boolean
+}>`
+ position: relative;
+ border: 1px solid ${color("selection")};
+ background: ${color("backgroundLighter")};
+ border-radius: 0.6rem;
+ overflow: hidden;
+ min-width: 0;
+ transition: all 0.15s ease;
+
+ ${({ $focused }) =>
+ $focused &&
+ css`
+ border-color: ${color("pinkDarker")};
+ box-shadow: 0px 0px 10px 1px ${color("backgroundDarker")};
+ `}
+
+ ${({ $focused }) =>
+ !$focused &&
+ css`
+ &:hover {
+ box-shadow: 0px 0px 10px 1px ${color("backgroundDarker")};
+ }
+ `}
+
+ ${({ $maximized }) =>
+ $maximized &&
+ css`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ border: none;
+ border-radius: 0;
+ `}
+
+ ${({ $gridMode }) =>
+ $gridMode &&
+ css`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ > *:last-child {
+ flex-grow: 1;
+ }
+ `}
+
+ &:hover .cell-toolbar,
+ &:focus-within .cell-toolbar {
+ opacity: 1;
+ }
+`
diff --git a/src/scenes/Editor/Notebook/cells/useCellResize.ts b/src/scenes/Editor/Notebook/cells/useCellResize.ts
new file mode 100644
index 000000000..548175c7a
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/useCellResize.ts
@@ -0,0 +1,40 @@
+import { useCallback, useState } from "react"
+
+export type ResizeController = {
+ /** Live drag value that should override any persisted height. Null when idle. */
+ liveHeight: number | null
+ /** Called per mousemove with the latest clamped height. */
+ resizeLive: (height: number) => void
+ /** Called on drop — clears liveHeight and writes the persisted height. */
+ resizeEnd: (height: number) => void
+ /** Called on double-click — clears liveHeight and removes the override. */
+ resetHeight: () => void
+}
+
+export const useCellResize = (
+ minHeight: number,
+ commit: (height: number) => void,
+ commitReset: () => void,
+): ResizeController => {
+ const [liveHeight, setLiveHeight] = useState(null)
+
+ const resizeLive = useCallback(
+ (height: number) => setLiveHeight(Math.max(minHeight, height)),
+ [minHeight],
+ )
+
+ const resizeEnd = useCallback(
+ (height: number) => {
+ setLiveHeight(null)
+ commit(Math.max(minHeight, height))
+ },
+ [minHeight, commit],
+ )
+
+ const resetHeight = useCallback(() => {
+ setLiveHeight(null)
+ commitReset()
+ }, [commitReset])
+
+ return { liveHeight, resizeLive, resizeEnd, resetHeight }
+}
diff --git a/src/scenes/Editor/Notebook/cells/useCellSelectionDecoration.ts b/src/scenes/Editor/Notebook/cells/useCellSelectionDecoration.ts
new file mode 100644
index 000000000..7931cc94a
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/useCellSelectionDecoration.ts
@@ -0,0 +1,47 @@
+import { useCallback, useRef } from "react"
+import type { Monaco } from "@monaco-editor/react"
+import type { editor } from "monaco-editor"
+
+export const useCellSelectionDecoration = (
+ editorRef: React.MutableRefObject,
+ monacoRef: React.MutableRefObject,
+) => {
+ const decorationIdsRef = useRef([])
+
+ const applyHighlight = useCallback(
+ (success: boolean) => {
+ const ed = editorRef.current
+ const mon = monacoRef.current
+ if (!ed || !mon) return
+ const selection = ed.getSelection()
+ const model = ed.getModel()
+ if (!selection || !model || selection.isEmpty()) return
+
+ decorationIdsRef.current = ed.deltaDecorations(decorationIdsRef.current, [
+ {
+ range: new mon.Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ ),
+ options: {
+ isWholeLine: false,
+ className: success
+ ? "selectionSuccessHighlight"
+ : "selectionErrorHighlight",
+ },
+ },
+ ])
+ },
+ [editorRef, monacoRef],
+ )
+
+ const clearHighlight = useCallback(() => {
+ const ed = editorRef.current
+ if (!ed) return
+ decorationIdsRef.current = ed.deltaDecorations(decorationIdsRef.current, [])
+ }, [editorRef])
+
+ return { applyHighlight, clearHighlight }
+}
diff --git a/src/scenes/Editor/Notebook/cells/useMonacoCellEditor.ts b/src/scenes/Editor/Notebook/cells/useMonacoCellEditor.ts
new file mode 100644
index 000000000..b4739a072
--- /dev/null
+++ b/src/scenes/Editor/Notebook/cells/useMonacoCellEditor.ts
@@ -0,0 +1,157 @@
+import { useCallback, useEffect, useRef } from "react"
+import type { Monaco } from "@monaco-editor/react"
+import type { editor } from "monaco-editor"
+import type * as QuestDB from "../../../../utils/questdb"
+import {
+ clearModelMarkers,
+ pinMonacoContextMenu,
+ validateQueryJIT,
+} from "../../Monaco/utils"
+
+const VALIDATION_DEBOUNCE_MS = 300
+
+type ValidateFn = (
+ sql: string,
+ signal?: AbortSignal,
+) => ReturnType
+
+export type UseMonacoCellEditorOptions = {
+ cellId: string
+ editorViewState?: editor.ICodeEditorViewState
+ quest: QuestDB.Client
+ onFocus: () => void
+ onSaveViewState: (state: editor.ICodeEditorViewState) => void
+ onRunAtCursor: () => void
+ onRunAll: () => void
+ // Fires every time Monaco reports a content-size change. Cell.tsx uses
+ // this to drive cell.topHeight (the auto-grow path). Naturally rate-
+ // limited by Monaco to per-line-count changes; no debouncing needed
+ // here. Caller is responsible for guarding on cell.topResized.
+ onContentHeightChange?: (px: number) => void
+ // Optional override for the JIT validator. Notebook passes a wrapped
+ // function that prepends global variables as DECLARE so `@symbol`
+ // references resolve. Falls back to quest.validateQuery if absent.
+ validate?: ValidateFn
+}
+
+export const useMonacoCellEditor = ({
+ cellId,
+ editorViewState,
+ quest,
+ onFocus,
+ onSaveViewState,
+ onRunAtCursor,
+ onRunAll,
+ onContentHeightChange,
+ validate,
+}: UseMonacoCellEditorOptions) => {
+ const editorRef = useRef(null)
+ const monacoRef = useRef(null)
+ const validationTimeoutRef = useRef(null)
+ const contextMenuCleanupRef = useRef<(() => void) | null>(null)
+
+ // Refs for handlers so the once-mounted Monaco listeners always read
+ // the latest callbacks instead of capturing stale closures.
+ const onRunAtCursorRef = useRef(onRunAtCursor)
+ onRunAtCursorRef.current = onRunAtCursor
+ const onRunAllRef = useRef(onRunAll)
+ onRunAllRef.current = onRunAll
+ const onContentHeightChangeRef = useRef(onContentHeightChange)
+ onContentHeightChangeRef.current = onContentHeightChange
+ const validateRef = useRef(validate)
+ validateRef.current = validate
+
+ const triggerValidation = useCallback(() => {
+ if (!monacoRef.current || !editorRef.current) return
+ validateQueryJIT(
+ monacoRef.current,
+ editorRef.current,
+ cellId.charCodeAt(0),
+ () => ({}),
+ (q, signal) =>
+ validateRef.current
+ ? validateRef.current(q, signal)
+ : quest.validateQuery(q, signal),
+ )
+ }, [cellId, quest])
+
+ const scheduleValidation = useCallback(() => {
+ if (validationTimeoutRef.current) {
+ window.clearTimeout(validationTimeoutRef.current)
+ }
+ validationTimeoutRef.current = window.setTimeout(() => {
+ triggerValidation()
+ validationTimeoutRef.current = null
+ }, VALIDATION_DEBOUNCE_MS)
+ }, [triggerValidation])
+
+ const handleEditorMount = useCallback(
+ (ed: editor.IStandaloneCodeEditor, monaco: Monaco) => {
+ editorRef.current = ed
+ monacoRef.current = monaco
+
+ if (editorViewState) {
+ ed.restoreViewState(editorViewState)
+ }
+
+ const reportHeight = () => {
+ onContentHeightChangeRef.current?.(ed.getContentHeight())
+ }
+
+ ed.onDidContentSizeChange(reportHeight)
+ reportHeight()
+
+ contextMenuCleanupRef.current = pinMonacoContextMenu(ed)
+
+ ed.onDidFocusEditorWidget(onFocus)
+ ed.onDidChangeCursorPosition(scheduleValidation)
+ ed.onDidChangeModelContent(scheduleValidation)
+
+ // Clear built-in Ctrl+Shift+Enter handler (same pattern as editor-addons.ts)
+ ed.addCommand(
+ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter,
+ () => {},
+ )
+
+ ed.addAction({
+ id: "notebook-run",
+ label: "Run Query",
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
+ run: () => void onRunAtCursorRef.current(),
+ })
+
+ ed.addAction({
+ id: "notebook-run-all",
+ label: "Run All",
+ keybindings: [
+ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter,
+ ],
+ run: () => void onRunAllRef.current(),
+ })
+ },
+ [editorViewState, onFocus, scheduleValidation],
+ )
+
+ useEffect(() => {
+ return () => {
+ if (validationTimeoutRef.current) {
+ window.clearTimeout(validationTimeoutRef.current)
+ }
+ contextMenuCleanupRef.current?.()
+ contextMenuCleanupRef.current = null
+ if (editorRef.current && monacoRef.current) {
+ clearModelMarkers(monacoRef.current, editorRef.current)
+ const state = editorRef.current.saveViewState()
+ if (state) {
+ onSaveViewState(state)
+ }
+ }
+ }
+ }, [])
+
+ return {
+ editorRef,
+ monacoRef,
+ handleEditorMount,
+ }
+}
diff --git a/src/scenes/Editor/Notebook/declareUtils.corpus.test.ts b/src/scenes/Editor/Notebook/declareUtils.corpus.test.ts
new file mode 100644
index 000000000..785996553
--- /dev/null
+++ b/src/scenes/Editor/Notebook/declareUtils.corpus.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest"
+import { prependGlobalsDeclare } from "./declareUtils"
+import queries from "./__fixtures__/declare-cases.json"
+
+const cases: string[] = queries
+
+const startsWithDeclare = (q: string) => /^\s*DECLARE\b/i.test(q)
+const startsWithExplain = (q: string) => /^\s*EXPLAIN\b/i.test(q)
+const hasInnerDeclare = (q: string) => /\bDECLARE\b/i.test(q)
+
+const GLOBAL = [{ name: "x", value: "999" }]
+
+describe("prependGlobalsDeclare — corpus regression (declare-cases.json)", () => {
+ it("EXPLAIN-led statements remain EXPLAIN-led", () => {
+ const explains = cases.filter(startsWithExplain)
+ expect(explains.length).toBeGreaterThan(0)
+ for (const c of explains) {
+ const { sql } = prependGlobalsDeclare(c, GLOBAL)
+ expect(/^\s*EXPLAIN\b/i.test(sql)).toBe(true)
+ }
+ })
+
+ it("wrapped shapes (CREATE VIEW/TABLE AS (, INSERT INTO ... SELECT * FROM () are left untouched", () => {
+ const wrapped = cases.filter(
+ (c) =>
+ !startsWithDeclare(c) && !startsWithExplain(c) && hasInnerDeclare(c),
+ )
+ expect(wrapped.length).toBeGreaterThan(0)
+ for (const c of wrapped) {
+ const r = prependGlobalsDeclare(c, GLOBAL)
+ expect(r.sql).toBe(c)
+ expect(r.insertedRange).toBeNull()
+ }
+ })
+
+ it("statements starting with DECLARE either merge `@x` once or no-op (shadowed/invalid)", () => {
+ const starts = cases.filter(startsWithDeclare)
+ expect(starts.length).toBeGreaterThan(40)
+ for (const c of starts) {
+ const { sql, insertedRange } = prependGlobalsDeclare(c, GLOBAL)
+ const userHasX = /@x\s*:=/.test(c)
+ if (userHasX) {
+ expect(sql).toBe(c)
+ expect(insertedRange).toBeNull()
+ } else if (insertedRange === null) {
+ expect(sql).toBe(c)
+ } else {
+ // Merged: the rendered block contains exactly one `@x := 999`.
+ const matches = sql.match(/@x\s*:=\s*999/g) ?? []
+ expect(matches.length).toBe(1)
+ }
+ // Result still starts with DECLARE.
+ expect(/^\s*DECLARE\b/i.test(sql)).toBe(true)
+ }
+ })
+
+ it("insertedRange.delta always matches the actual byte delta", () => {
+ for (const c of cases) {
+ const { sql, insertedRange } = prependGlobalsDeclare(c, GLOBAL)
+ expect(sql.length - c.length).toBe(insertedRange?.delta ?? 0)
+ }
+ })
+})
diff --git a/src/scenes/Editor/Notebook/declareUtils.test.ts b/src/scenes/Editor/Notebook/declareUtils.test.ts
new file mode 100644
index 000000000..cea21ba22
--- /dev/null
+++ b/src/scenes/Editor/Notebook/declareUtils.test.ts
@@ -0,0 +1,600 @@
+import { describe, expect, it } from "vitest"
+import {
+ isValidVariableName,
+ normalizeVariables,
+ parseDeclareBlock,
+ prependGlobalsDeclare,
+ renderDeclareBlock,
+ stripLeadingAt,
+ validateVariableShape,
+} from "./declareUtils"
+
+const vars = (values: Record) =>
+ Object.entries(values).map(([name, value]) => ({ name, value }))
+
+describe("isValidVariableName", () => {
+ it.each(["x", "X", "_x", "symbol", "from_time", "v1", "_"])(
+ "accepts %s",
+ (n) => {
+ expect(isValidVariableName(n)).toBe(true)
+ },
+ )
+ it.each([
+ "café",
+ "日本語",
+ "Σx",
+ "naïve",
+ "한국",
+ "Москва",
+ "ø",
+ "θ_x",
+ "x_中文",
+ ])(
+ "accepts Unicode identifier %s (BMP above 0x80, like QuestDB lexer)",
+ (n) => {
+ expect(isValidVariableName(n)).toBe(true)
+ },
+ )
+ it.each([
+ "",
+ "1x", // leading digit
+ "-x", // leading hyphen (would be UnaryMinus)
+ "+x", // leading plus
+ ".x", // leading dot
+ "@x", // leading @
+ " x", // leading space
+ "x-y", // QuestDB Minus operator
+ "x+y", // QuestDB Plus operator
+ "x.y", // QuestDB Dot operator
+ "x:y", // QuestDB Colon / :=
+ "x y", // whitespace
+ "x'y", // single-quote starts string
+ 'x"y', // double-quote starts identifier
+ "x`y", // backtick starts identifier
+ "x@y", // our reference-start marker
+ "x\\y", // escape safety
+ ])("rejects %s", (n) => {
+ expect(isValidVariableName(n)).toBe(false)
+ })
+})
+
+describe("stripLeadingAt", () => {
+ it("strips a leading @", () => {
+ expect(stripLeadingAt("@symbol")).toBe("symbol")
+ })
+ it("leaves names without @ untouched", () => {
+ expect(stripLeadingAt("symbol")).toBe("symbol")
+ })
+})
+
+describe("normalizeVariables", () => {
+ it("preserves ordered variable arrays", () => {
+ expect(
+ normalizeVariables([
+ { name: "base", value: "10" },
+ { name: "derived", value: "@base + 1" },
+ ]),
+ ).toEqual(vars({ base: "10", derived: "@base + 1" }))
+ })
+
+ it("upgrades old object-shaped variables without crashing", () => {
+ expect(normalizeVariables({ x: "1", y: "@x + 1" })).toEqual(
+ vars({ x: "1", y: "@x + 1" }),
+ )
+ })
+})
+
+describe("parseDeclareBlock", () => {
+ it("returns {} when no DECLARE is present", () => {
+ expect(parseDeclareBlock("SELECT 1 FROM t")).toEqual([])
+ })
+
+ it("returns {} for empty / whitespace input", () => {
+ expect(parseDeclareBlock("")).toEqual([])
+ expect(parseDeclareBlock(" \n ")).toEqual([])
+ })
+
+ it("parses a single assignment", () => {
+ expect(parseDeclareBlock("DECLARE @x := 10")).toEqual(vars({ x: "10" }))
+ })
+
+ it("parses multiple assignments", () => {
+ expect(parseDeclareBlock("DECLARE @x := 10, @y := 'BTC'")).toEqual(
+ vars({ x: "10", y: "'BTC'" }),
+ )
+ })
+
+ it("preserves nested function values", () => {
+ expect(
+ parseDeclareBlock(
+ "DECLARE @from := dateadd('d', -7, now()), @to := now()",
+ ),
+ ).toEqual(vars({ from: "dateadd('d', -7, now())", to: "now()" }))
+ })
+
+ it("ignores trailing SELECT", () => {
+ expect(
+ parseDeclareBlock("DECLARE @x := 10, @y := 5 SELECT @x + @y FROM t"),
+ ).toEqual(vars({ x: "10", y: "5" }))
+ })
+
+ it("ignores trailing WITH", () => {
+ expect(
+ parseDeclareBlock("DECLARE @x := 10 WITH cte AS (SELECT 1) SELECT @x"),
+ ).toEqual(vars({ x: "10" }))
+ })
+
+ it("ignores trailing semicolon", () => {
+ expect(parseDeclareBlock("DECLARE @x := 10 ;")).toEqual(vars({ x: "10" }))
+ })
+
+ it("strips OVERRIDABLE modifier", () => {
+ expect(
+ parseDeclareBlock("DECLARE OVERRIDABLE @x := 1, OVERRIDABLE @y := 2"),
+ ).toEqual(vars({ x: "1", y: "2" }))
+ })
+
+ it("rejects assignments using `=` (server requires `:=`)", () => {
+ // QuestDB's parser refuses `DECLARE @x = …` with
+ // `expected variable assignment operator ':='`.
+ // We mirror that: `=` assignments are dropped from the parsed map.
+ expect(parseDeclareBlock("DECLARE @x = 1, @y := 2")).toEqual(
+ vars({ y: "2" }),
+ )
+ expect(parseDeclareBlock("DECLARE @x = 1")).toEqual([])
+ })
+
+ it("handles multi-line input", () => {
+ const text = `
+DECLARE
+ @from := dateadd('d', -7, now()),
+ @to := now(),
+ @symbol := 'BTCUSD'
+SELECT * FROM trades`
+ expect(parseDeclareBlock(text)).toEqual(
+ vars({
+ from: "dateadd('d', -7, now())",
+ to: "now()",
+ symbol: "'BTCUSD'",
+ }),
+ )
+ })
+
+ it("ignores text before DECLARE", () => {
+ // (Pragmatic: clipboard might have whitespace or stray content.)
+ expect(parseDeclareBlock(" DECLARE @x := 1")).toEqual(vars({ x: "1" }))
+ })
+
+ it("preserves commas inside bracket subscripts (array indexing)", () => {
+ // QuestDB's array-indexing syntax `bids[1,1]` has a comma at paren
+ // depth 0 but bracket depth 1 — must NOT split the assignment.
+ expect(parseDeclareBlock("DECLARE @best_bid := bids[1,1]")).toEqual(
+ vars({ best_bid: "bids[1,1]" }),
+ )
+ })
+
+ it("handles multiple bracket-indexed values from QuestDB docs", () => {
+ expect(
+ parseDeclareBlock(
+ "DECLARE @best_bid := bids[1,1], @volume_l1 := bids[2,1]",
+ ),
+ ).toEqual(vars({ best_bid: "bids[1,1]", volume_l1: "bids[2,1]" }))
+ })
+
+ it("handles array slice syntax with colon", () => {
+ expect(parseDeclareBlock("DECLARE @first_four := bids[1:4]")).toEqual(
+ vars({ first_four: "bids[1:4]" }),
+ )
+ })
+
+ it("captures a subquery value without truncating it", () => {
+ expect(parseDeclareBlock("DECLARE @c := (SELECT count(*) FROM t)")).toEqual(
+ vars({ c: "(SELECT count(*) FROM t)" }),
+ )
+ })
+
+ it("does not swallow the next assignment when a comma is missing", () => {
+ // `DECLARE @x := 5 @y := 10` is malformed — the server would reject it.
+ // We should at minimum NOT capture `@y` into `@x`'s value. Whichever
+ // assignment we extract should hold its own value.
+ const out = parseDeclareBlock("DECLARE @x := 5 @y := 10")
+ expect(out.find((v) => v.name === "x")?.value).not.toBe("5 @y := 10")
+ })
+
+ it("rejects `@x = …` for both assignments (the server only accepts `:=`)", () => {
+ expect(parseDeclareBlock("DECLARE @x = 1, @y = 2")).toEqual([])
+ })
+})
+
+describe("renderDeclareBlock", () => {
+ it("returns empty string for empty input", () => {
+ expect(renderDeclareBlock([])).toBe("")
+ })
+ it("emits a multi-line DECLARE statement", () => {
+ expect(renderDeclareBlock(vars({ x: "10", y: "'BTC'" }))).toBe(
+ "DECLARE\n @x := 10,\n @y := 'BTC'",
+ )
+ })
+ it("indents a single assignment on its own line", () => {
+ expect(renderDeclareBlock(vars({ x: "10" }))).toBe("DECLARE\n @x := 10")
+ })
+ it("drops invalid identifier names silently", () => {
+ expect(renderDeclareBlock(vars({ "bad-name": "1", good: "2" }))).toBe(
+ "DECLARE\n @good := 2",
+ )
+ })
+ it("round-trips through parseDeclareBlock", () => {
+ const input = {
+ from: "dateadd('d', -7, now())",
+ to: "now()",
+ symbol: "'BTCUSD'",
+ }
+ expect(parseDeclareBlock(renderDeclareBlock(vars(input)))).toEqual(
+ vars(input),
+ )
+ })
+})
+
+describe("prependGlobalsDeclare", () => {
+ it("is a no-op when globals are empty", () => {
+ const sql = "SELECT @x FROM t"
+ expect(prependGlobalsDeclare(sql, [])).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("is a no-op for empty / whitespace / comment-only input", () => {
+ const globals = vars({ x: "1" })
+ expect(prependGlobalsDeclare("", globals).insertedRange).toBeNull()
+ expect(prependGlobalsDeclare(" \n ", globals).insertedRange).toBeNull()
+ expect(
+ prependGlobalsDeclare("-- only a comment", globals).insertedRange,
+ ).toBeNull()
+ expect(
+ prependGlobalsDeclare("/* block only */", globals).insertedRange,
+ ).toBeNull()
+ })
+
+ it("prepends DECLARE before a bare SELECT", () => {
+ const original = "SELECT @x FROM t"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe("DECLARE\n @x := 1\nSELECT @x FROM t")
+ const blockLen = "DECLARE\n @x := 1\n".length
+ expect(insertedRange).toEqual({
+ start: 0,
+ end: blockLen,
+ delta: blockLen,
+ })
+ })
+
+ it("prepends DECLARE before a bare WITH", () => {
+ const { sql } = prependGlobalsDeclare(
+ "WITH cte AS (SELECT 1) SELECT @x FROM cte",
+ vars({ x: "1" }),
+ )
+ expect(sql.startsWith("DECLARE\n @x := 1\nWITH ")).toBe(true)
+ })
+
+ it("preserves a leading line comment, with insertion offset AFTER the comment", () => {
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ "-- header\nSELECT @x",
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe("-- header\nDECLARE\n @x := 1\nSELECT @x")
+ // Start must point at the first non-trivia char (the 'S' of SELECT),
+ // not at 0. Otherwise marker classification of trivia-area errors breaks.
+ expect(insertedRange?.start).toBe("-- header\n".length)
+ })
+
+ it("preserves a leading block comment and inserts DECLARE after it", () => {
+ const { sql } = prependGlobalsDeclare(
+ "/* header */ SELECT @x",
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe("/* header */ DECLARE\n @x := 1\nSELECT @x")
+ })
+
+ it("no-ops when every global is shadowed by a user local", () => {
+ const sql = "DECLARE @x := 99 SELECT @x"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("merges non-shadowed globals into the user's DECLARE block (globals first)", () => {
+ const original = "DECLARE @y := 99 SELECT @x + @y"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "1", y: "10" }),
+ )
+ // Globals listed first; `y` is dropped because the user declared it.
+ expect(sql).toBe("DECLARE\n @x := 1,\n @y := 99 SELECT @x + @y")
+ expect(insertedRange?.start).toBe(0)
+ // The wire-block range covers the FULL merged DECLARE; `delta` is just
+ // the size change (used for the body shift after the block ends).
+ const wireBlock = "DECLARE\n @x := 1,\n @y := 99"
+ expect(insertedRange?.end).toBe(wireBlock.length)
+ expect(insertedRange?.delta).toBe(sql.length - original.length)
+ })
+
+ it("merge with multiple user assignments: wire block covers all locals, delta = total byte change", () => {
+ // The merge case where the simple `position - delta` math would mis-map
+ // positions inside the user's @a — the canonical `,\n ` separator we
+ // emit doesn't match the user's `, ` separator, so shifts vary.
+ // `insertedRange.end` extending past the last user assignment ensures
+ // the validator wrapper treats any error inside the block as
+ // "in DECLARE block" rather than back-mapping incorrectly.
+ const original = "DECLARE @a := 1, @b := 2 SELECT @a + @b"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "99" }),
+ )
+ const wireBlock = "DECLARE\n @x := 99,\n @a := 1,\n @b := 2"
+ expect(sql).toBe(`${wireBlock} SELECT @a + @b`)
+ expect(insertedRange).toEqual({
+ start: 0,
+ end: wireBlock.length,
+ delta: sql.length - original.length,
+ })
+ // A position INSIDE @a (in wire coords) must be inside [start, end) so
+ // the validator wrapper falls through to the "in DECLARE block" branch
+ // — back-mapping with `delta` would point at the wrong column.
+ const wireAtPosOfA = wireBlock.indexOf("@a")
+ expect(wireAtPosOfA).toBeGreaterThanOrEqual(insertedRange!.start)
+ expect(wireAtPosOfA).toBeLessThan(insertedRange!.end)
+ })
+
+ it("preserves OVERRIDABLE on a user assignment when merging in a new global", () => {
+ // P1 fix: the merge must not re-render the user's assignment from a plain
+ // name/value map — that would silently drop OVERRIDABLE and change view
+ // semantics (caller-can-override flag).
+ const { sql } = prependGlobalsDeclare(
+ "DECLARE OVERRIDABLE @y := 99 SELECT @x + @y",
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe(
+ "DECLARE\n @x := 1,\n OVERRIDABLE @y := 99 SELECT @x + @y",
+ )
+ })
+
+ it("preserves OVERRIDABLE across multiple user assignments in a merge", () => {
+ const { sql } = prependGlobalsDeclare(
+ "DECLARE OVERRIDABLE @a := 1, @b := 2 SELECT @a + @b + @c",
+ vars({ c: "3" }),
+ )
+ expect(sql).toBe(
+ "DECLARE\n @c := 3,\n OVERRIDABLE @a := 1,\n @b := 2 SELECT @a + @b + @c",
+ )
+ })
+
+ it("no-ops when the user's leading DECLARE contains an invalid `=` assignment", () => {
+ // P1 fix: don't silently rewrite the user's broken SQL into something
+ // executable just because we have a global. Let the server emit
+ // `expected variable assignment operator ':='`.
+ const sql = "DECLARE @x = 1 SELECT @x"
+ expect(prependGlobalsDeclare(sql, vars({ x: "10" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("no-ops when ANY user assignment is invalid, even if globals are non-shadowed", () => {
+ const sql = "DECLARE @x := 1, @y = 2 SELECT @x + @y + @z"
+ expect(prependGlobalsDeclare(sql, vars({ z: "3" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("EXPLAIN: recurses into the suffix and inserts before the inner SELECT", () => {
+ const original = "EXPLAIN SELECT @x"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe("EXPLAIN DECLARE\n @x := 1\nSELECT @x")
+ const blockLen = "DECLARE\n @x := 1\n".length
+ expect(insertedRange).toEqual({
+ start: "EXPLAIN ".length,
+ end: "EXPLAIN ".length + blockLen,
+ delta: blockLen,
+ })
+ })
+
+ it("EXPLAIN with user DECLARE: drops shadowed global, no-op when all shadowed", () => {
+ const sql = "EXPLAIN DECLARE @x := 99 SELECT @x"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("EXPLAIN with user DECLARE: merges non-shadowed globals", () => {
+ const { sql } = prependGlobalsDeclare(
+ "EXPLAIN DECLARE @y := 99 SELECT @x + @y",
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe("EXPLAIN DECLARE\n @x := 1,\n @y := 99 SELECT @x + @y")
+ })
+
+ it("INSERT INTO is a no-op (phase-1 fallback)", () => {
+ const sql = "INSERT INTO t SELECT @x"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("WITH cte ... INSERT INTO is a no-op (WITH-led but not a SELECT body)", () => {
+ // `WITH ... INSERT INTO ... SELECT ...` is valid user SQL but is NOT a
+ // valid DECLARE target — the grammar is `DECLARE ... [WITH ...] SELECT`.
+ // Prepending would produce a server error.
+ const sql = "WITH cte AS (SELECT 1) INSERT INTO t SELECT * FROM cte"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("EXPLAIN WITH ... INSERT is a no-op (recursion delegates to analyzer)", () => {
+ const sql = "EXPLAIN WITH cte AS (SELECT 1) INSERT INTO t SELECT * FROM cte"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("WITH cte ... SELECT IS prepended (valid DECLARE target)", () => {
+ const original = "WITH cte AS (SELECT 1 AS v) SELECT @x + v FROM cte"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "1" }),
+ )
+ expect(sql).toBe(
+ `DECLARE\n @x := 1\nWITH cte AS (SELECT 1 AS v) SELECT @x + v FROM cte`,
+ )
+ expect(insertedRange?.start).toBe(0)
+ })
+
+ it("DECLARE @x := 1 INSERT INTO ... is a no-op (parse-recovery sham)", () => {
+ // Parser recovery produces a declareClause + an orphan insertStatement.
+ // We must NOT silently rewrite the user's invalid SQL into something
+ // executable just because `x` doesn't shadow.
+ const sql = "DECLARE @x := 1 INSERT INTO t SELECT @x"
+ // Use a non-shadowing global so the merge would otherwise try to fire.
+ expect(prependGlobalsDeclare(sql, vars({ y: "2" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("DECLARE @x := 1 (no body) is a no-op (parser error, not a SELECT)", () => {
+ const sql = "DECLARE @x := 1"
+ expect(prependGlobalsDeclare(sql, vars({ y: "2" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("CREATE TABLE AS is a no-op (phase-1 fallback)", () => {
+ const sql = "CREATE TABLE foo AS (DECLARE @x := 99 SELECT @x)"
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ })
+
+ it("UPDATE / DROP / ALTER / SHOW: all no-ops", () => {
+ for (const sql of [
+ "UPDATE t SET v = 1",
+ "DROP TABLE t",
+ "ALTER TABLE t ADD COLUMN c INT",
+ "SHOW TABLES",
+ ]) {
+ expect(prependGlobalsDeclare(sql, vars({ x: "1" }))).toEqual({
+ sql,
+ insertedRange: null,
+ })
+ }
+ })
+
+ it("drops globals with invalid identifier names without affecting valid ones", () => {
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ "SELECT @x, @good FROM t",
+ vars({ x: "1", "bad-name": "2", good: "3" }),
+ )
+ expect(sql).toBe(
+ "DECLARE\n @x := 1,\n @good := 3\nSELECT @x, @good FROM t",
+ )
+ expect(insertedRange?.delta).toBeGreaterThan(0)
+ })
+
+ it("insertedRange.delta matches the actual byte difference", () => {
+ const original = "SELECT @x"
+ const { sql, insertedRange } = prependGlobalsDeclare(
+ original,
+ vars({ x: "1" }),
+ )
+ expect(sql.length - original.length).toBe(insertedRange?.delta)
+ })
+
+ it("does not substitute @x in string literals or comments (server resolves it)", () => {
+ // We don't do substitution anymore — the server handles @x reference resolution.
+ // Just confirm we don't accidentally rewrite comments / string literals.
+ const { sql } = prependGlobalsDeclare(
+ "SELECT @x, 'has @x in string', /* @x in comment */ @x",
+ vars({ x: "1" }),
+ )
+ expect(sql).toContain("'has @x in string'")
+ expect(sql).toContain("/* @x in comment */")
+ })
+})
+
+describe("validateVariableShape", () => {
+ it("accepts a plain integer literal", () => {
+ expect(validateVariableShape({ name: "x", value: "1" })).toBeNull()
+ })
+
+ it("accepts a function call value", () => {
+ expect(validateVariableShape({ name: "from", value: "now()" })).toBeNull()
+ })
+
+ it("accepts a string literal value", () => {
+ expect(validateVariableShape({ name: "symbol", value: "'BTC'" })).toBeNull()
+ })
+
+ it("accepts a value referencing another @variable", () => {
+ expect(
+ validateVariableShape({ name: "derived", value: "@base + 1" }),
+ ).toBeNull()
+ })
+
+ it("accepts parenthesised commas (row expression)", () => {
+ expect(validateVariableShape({ name: "x", value: "(1, 2)" })).toBeNull()
+ })
+
+ it("accepts a string literal containing := and ,", () => {
+ expect(
+ validateVariableShape({ name: "x", value: "'a := b, c'" }),
+ ).toBeNull()
+ })
+
+ it("rejects multi-assignment injection via top-level comma", () => {
+ const err = validateVariableShape({
+ name: "x",
+ value: "1, @evil := 999",
+ })
+ expect(err).toEqual({ kind: "count", actual: 2 })
+ })
+
+ it("rejects multi-assignment injection with newlines", () => {
+ const err = validateVariableShape({
+ name: "x",
+ value: "1,\n @evil := 999",
+ })
+ expect(err).toEqual({ kind: "count", actual: 2 })
+ })
+
+ it("rejects multi-assignment injection with a subquery payload", () => {
+ const err = validateVariableShape({
+ name: "x",
+ value: "1, @api_key := (select 1)",
+ })
+ expect(err).toEqual({ kind: "count", actual: 2 })
+ })
+
+ it("rejects an empty value", () => {
+ const err = validateVariableShape({ name: "x", value: "" })
+ expect(err).not.toBeNull()
+ })
+
+ it("rejects an invalid variable name", () => {
+ const err = validateVariableShape({ name: "bad-name", value: "1" })
+ expect(err).toEqual({ kind: "parse" })
+ })
+})
diff --git a/src/scenes/Editor/Notebook/declareUtils.ts b/src/scenes/Editor/Notebook/declareUtils.ts
new file mode 100644
index 000000000..43b899373
--- /dev/null
+++ b/src/scenes/Editor/Notebook/declareUtils.ts
@@ -0,0 +1,320 @@
+import { parse, tokenize } from "@questdb/sql-parser"
+import type { CstNode, IToken } from "@chevrotain/types"
+import type { NotebookVariable } from "../../../store/notebook"
+
+const NAME_RE = /^[a-zA-Z\u0080-\uFFFF_][a-zA-Z0-9\u0080-\uFFFF_]*$/
+
+export const isValidVariableName = (name: string): boolean => NAME_RE.test(name)
+
+const isVariableLike = (value: unknown): value is NotebookVariable => {
+ if (!value || typeof value !== "object") return false
+ const candidate = value as { name?: unknown; value?: unknown }
+ return (
+ typeof candidate.name === "string" && typeof candidate.value === "string"
+ )
+}
+
+export const normalizeVariables = (raw: unknown): NotebookVariable[] => {
+ if (Array.isArray(raw)) {
+ return raw.flatMap((v) =>
+ isVariableLike(v) ? [{ name: v.name, value: v.value }] : [],
+ )
+ }
+ if (raw && typeof raw === "object") {
+ return Object.entries(raw).flatMap(([name, value]) =>
+ typeof value === "string" ? [{ name, value }] : [],
+ )
+ }
+ return []
+}
+
+export const stripLeadingAt = (raw: string): string =>
+ raw.startsWith("@") ? raw.slice(1) : raw
+
+export const renderDeclareBlock = (variables: NotebookVariable[]): string => {
+ const valid = variables.filter(({ name }) => isValidVariableName(name))
+ if (valid.length === 0) return ""
+ const lines = valid.map(({ name, value }) => ` @${name} := ${value}`)
+ return `DECLARE\n${lines.join(",\n")}`
+}
+
+export const renderDeclareValidationQuery = (
+ variables: NotebookVariable[],
+): string => {
+ const block = renderDeclareBlock(variables)
+ return block ? `${block}\nSELECT 1` : "SELECT 1"
+}
+
+const isToken = (n: CstNode | IToken): n is IToken =>
+ (n as IToken).image !== undefined
+
+const findFirstNode = (root: CstNode, name: string): CstNode | null => {
+ if (root.name === name) return root
+ const children = root.children
+ for (const key of Object.keys(children)) {
+ for (const child of children[key]) {
+ if (isToken(child)) continue
+ const found = findFirstNode(child, name)
+ if (found) return found
+ }
+ }
+ return null
+}
+
+const subtreeRange = (node: CstNode): { start: number; end: number } | null => {
+ let start = Infinity
+ let end = -1
+ const visit = (n: CstNode): void => {
+ for (const key of Object.keys(n.children)) {
+ for (const child of n.children[key]) {
+ if (isToken(child)) {
+ if (child.startOffset < start) start = child.startOffset
+ const ce = child.endOffset ?? child.startOffset
+ if (ce > end) end = ce
+ } else {
+ visit(child)
+ }
+ }
+ }
+ }
+ visit(node)
+ return end < 0 ? null : { start, end }
+}
+
+type UserDeclareAssignment = {
+ name: string
+ originalText: string
+}
+
+type LeadingDeclareInfo = {
+ assignments: UserDeclareAssignment[]
+ hasInvalidAssignment: boolean
+ blockStart: number
+ bodyStart: number
+}
+
+const extractLeadingDeclare = (text: string): LeadingDeclareInfo | null => {
+ const { cst } = parse(text)
+ if (!cst) return null
+
+ const decl = findFirstNode(cst, "declareClause")
+ if (!decl) return null
+
+ const range = subtreeRange(decl)
+ if (!range) return null
+
+ const assignments: UserDeclareAssignment[] = []
+ let hasInvalidAssignment = false
+ const rawAssignments = decl.children.declareAssignment ?? []
+ for (const a of rawAssignments) {
+ if (isToken(a)) continue
+ const aRange = subtreeRange(a)
+ if (!aRange) {
+ hasInvalidAssignment = true
+ continue
+ }
+ const nameTok = a.children.VariableReference?.[0] as IToken | undefined
+ const exprNode = a.children.expression?.[0] as CstNode | undefined
+ const hasColonEquals = Boolean(a.children.ColonEquals)
+ if (!nameTok || !exprNode || !hasColonEquals) {
+ hasInvalidAssignment = true
+ continue
+ }
+ const name = nameTok.image.slice(1)
+ if (!isValidVariableName(name)) {
+ hasInvalidAssignment = true
+ continue
+ }
+ assignments.push({
+ name,
+ originalText: text.slice(aRange.start, aRange.end + 1),
+ })
+ }
+ return {
+ assignments,
+ hasInvalidAssignment,
+ blockStart: range.start,
+ bodyStart: range.end + 1,
+ }
+}
+
+export const parseDeclareBlock = (text: string): NotebookVariable[] => {
+ const info = extractLeadingDeclare(text)
+ if (!info) return []
+ const out: NotebookVariable[] = []
+ for (const a of info.assignments) {
+ // Strip leading OVERRIDABLE for the map-style consumer (popover etc.).
+ const eqIdx = a.originalText.indexOf(":=")
+ if (eqIdx < 0) continue
+ out.push({ name: a.name, value: a.originalText.slice(eqIdx + 2).trim() })
+ }
+ return out
+}
+
+export type VariableShapeError =
+ | { kind: "parse" }
+ | { kind: "count"; actual: number }
+ | { kind: "name"; expected: string; actual: string }
+ | { kind: "value"; expected: string; actual: string }
+
+export const validateVariableShape = (
+ variable: NotebookVariable,
+): VariableShapeError | null => {
+ if (!isValidVariableName(variable.name)) return { kind: "parse" }
+ const block = renderDeclareBlock([variable])
+ const info = extractLeadingDeclare(block)
+ if (!info || info.hasInvalidAssignment) return { kind: "parse" }
+ if (info.assignments.length !== 1) {
+ return { kind: "count", actual: info.assignments.length }
+ }
+ const a = info.assignments[0]
+ if (a.name !== variable.name) {
+ return { kind: "name", expected: variable.name, actual: a.name }
+ }
+ const expectedText = `@${variable.name} := ${variable.value.trim()}`
+ if (a.originalText.trim() !== expectedText) {
+ return {
+ kind: "value",
+ expected: expectedText,
+ actual: a.originalText.trim(),
+ }
+ }
+ return null
+}
+
+export type PreparedSql = {
+ sql: string
+ // Information about the rewrite, or null if no rewrite happened.
+ insertedRange: { start: number; end: number; delta: number } | null
+}
+
+const NO_OP = (sql: string): PreparedSql => ({ sql, insertedRange: null })
+
+const renderMergedDeclare = (
+ globals: NotebookVariable[],
+ userAssignments: UserDeclareAssignment[],
+): string => {
+ const validGlobalLines = globals
+ .filter(({ name }) => isValidVariableName(name))
+ .map(({ name, value }) => ` @${name} := ${value}`)
+ const userLines = userAssignments.map((a) => ` ${a.originalText}`)
+ const lines = [...validGlobalLines, ...userLines]
+ if (lines.length === 0) return ""
+ return `DECLARE\n${lines.join(",\n")}`
+}
+
+type StatementShape =
+ | { kind: "select" }
+ | { kind: "leadingDeclare"; info: LeadingDeclareInfo }
+ | { kind: "skip" }
+
+const analyzeStatement = (text: string): StatementShape => {
+ const r = parse(text)
+ // Any parse error or recovery-induced multi-statement shape: refuse to
+ // rewrite. The user's broken SQL surfaces server errors directly.
+ if (r.parseErrors.length > 0) return { kind: "skip" }
+ if (!r.cst) return { kind: "skip" }
+ const stmtChildren = (r.cst.children.statement ?? []).filter(
+ (s): s is CstNode => !isToken(s),
+ )
+ if (stmtChildren.length !== 1) return { kind: "skip" }
+ const stmt = stmtChildren[0]
+
+ if (stmt.children.selectStatement) {
+ const sel = stmt.children.selectStatement[0]
+ if (isToken(sel) || !sel.children.selectBody) return { kind: "skip" }
+ if (sel.children.declareClause) {
+ const info = extractLeadingDeclare(text)
+ if (!info || info.hasInvalidAssignment) return { kind: "skip" }
+ return { kind: "leadingDeclare", info }
+ }
+ return { kind: "select" }
+ }
+
+ if (stmt.children.withStatement) {
+ const w = stmt.children.withStatement[0]
+ if (isToken(w)) return { kind: "skip" }
+ // WITH … . Only a SELECT body is safe to prepend DECLARE before
+ // (DECLARE grammar is `DECLARE … [WITH …] SELECT …`). WITH … INSERT,
+ // WITH … UPDATE, etc. are valid user SQL but not valid DECLARE targets.
+ if (w.children.selectBody) return { kind: "select" }
+ return { kind: "skip" }
+ }
+
+ // INSERT / CREATE / UPDATE / DELETE / ALTER / DROP / SHOW / TRUNCATE / etc.
+ return { kind: "skip" }
+}
+
+export const prependGlobalsDeclare = (
+ sql: string,
+ globals: NotebookVariable[],
+): PreparedSql => {
+ if (globals.length === 0) return NO_OP(sql)
+
+ const { tokens } = tokenize(sql)
+ if (tokens.length === 0) return NO_OP(sql)
+
+ const first = tokens[0]
+
+ // EXPLAIN is the one form we handle by stripping and recursing — the inner
+ // statement's grammar is what matters (`EXPLAIN DECLARE … SELECT …` is the
+ // only valid shape; `EXPLAIN WITH … INSERT …` is valid user SQL but not a
+ // DECLARE target). The recursive call's analyzer makes that call.
+ if (first.tokenType.name === "Explain") {
+ // QuestDBLexer is configured with `positionTracking: "full"`, so every
+ // token has both startOffset and endOffset as numbers. Asserting `!`
+ // here documents that invariant; the recursion is bounded by sql.length
+ // because suffixStart is always >= 1 (endOffset >= startOffset >= 0).
+ const suffixStart = first.endOffset! + 1
+ const recursed = prependGlobalsDeclare(sql.slice(suffixStart), globals)
+ if (!recursed.insertedRange) return NO_OP(sql)
+ return {
+ sql: sql.slice(0, suffixStart) + recursed.sql,
+ insertedRange: {
+ start: suffixStart + recursed.insertedRange.start,
+ end: suffixStart + recursed.insertedRange.end,
+ delta: recursed.insertedRange.delta,
+ },
+ }
+ }
+
+ const shape = analyzeStatement(sql)
+ if (shape.kind === "skip") return NO_OP(sql)
+
+ if (shape.kind === "select") {
+ const valid = globals.filter(({ name }) => isValidVariableName(name))
+ if (valid.length === 0) return NO_OP(sql)
+ const block = renderDeclareBlock(valid) + "\n"
+ return {
+ sql:
+ sql.slice(0, first.startOffset) + block + sql.slice(first.startOffset),
+ insertedRange: {
+ start: first.startOffset,
+ end: first.startOffset + block.length,
+ delta: block.length,
+ },
+ }
+ }
+
+ // shape.kind === "leadingDeclare"
+ const { info } = shape
+ const userNames = new Set(info.assignments.map((a) => a.name))
+ const filtered = globals.filter(
+ ({ name }) => isValidVariableName(name) && !userNames.has(name),
+ )
+ if (filtered.length === 0) return NO_OP(sql)
+ const newBlock = renderMergedDeclare(filtered, info.assignments)
+ const originalBlock = sql.slice(info.blockStart, info.bodyStart)
+ return {
+ sql: sql.slice(0, info.blockStart) + newBlock + sql.slice(info.bodyStart),
+ insertedRange: {
+ start: info.blockStart,
+ // `end` covers the full wire DECLARE block (globals + user locals).
+ // We can't back-map inside this range uniformly because we may have
+ // rewritten the inter-assignment separators; treat any error inside
+ // as "in DECLARE block" and only shift positions AFTER it.
+ end: info.blockStart + newBlock.length,
+ delta: newBlock.length - originalBlock.length,
+ },
+ }
+}
diff --git a/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx b/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx
new file mode 100644
index 000000000..5466f5756
--- /dev/null
+++ b/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx
@@ -0,0 +1,674 @@
+import React, {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import styled from "styled-components"
+import {
+ AtIcon,
+ ClipboardTextIcon,
+ DotsSixVerticalIcon,
+ PlusIcon,
+ XIcon,
+} from "@phosphor-icons/react"
+import { CheckboxCircle } from "@styled-icons/remix-fill"
+import { Button, Popover } from "../../../../components"
+import { CopyButton } from "../../../../components/CopyButton"
+import { Input } from "../../../../components/Input"
+import { toast } from "../../../../components/Toast"
+import { QuestContext } from "../../../../providers/QuestProvider"
+import { color } from "../../../../utils"
+import {
+ canReadFromClipboard,
+ readFromClipboard,
+} from "../../../../utils/copyToClipboard"
+import type { NotebookVariable } from "../../../../store/notebook"
+import { useNotebookActions, useNotebookState } from "../NotebookProvider"
+import {
+ isValidVariableName,
+ normalizeVariables,
+ parseDeclareBlock,
+ renderDeclareBlock,
+ renderDeclareValidationQuery,
+ stripLeadingAt,
+ validateVariableShape,
+} from "../declareUtils"
+
+const Trigger = styled(Button).attrs({ skin: "secondary" })`
+ svg {
+ transform: translateY(1px);
+ }
+`
+
+const Body = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: min(56rem, calc(100vw - 4rem));
+ background: ${color("backgroundDarker")};
+`
+
+const Subtitle = styled.div`
+ font-size: 1.4rem;
+ padding: 1.2rem 1.6rem 0;
+ color: ${color("pinkLighter")};
+ font-family: ${({ theme }) => theme.fontMonospace};
+`
+
+const SubtitleStrong = styled.span`
+ font-family: ${({ theme }) => theme.fontMonospace};
+ color: ${color("foreground")};
+`
+
+const RowList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ padding: 1rem 1.6rem;
+ max-height: 40vh;
+ overflow-y: auto;
+`
+
+const Row = styled.div<{ $dragging?: boolean; $dragInProgress?: boolean }>`
+ display: grid;
+ grid-template-columns: auto auto 14rem auto 1fr auto;
+ gap: 0.6rem;
+ align-items: center;
+ padding: 0.2rem 0.4rem;
+ margin: 0 -0.4rem;
+ border-radius: 0.4rem;
+ border-left: 2px solid transparent;
+ transition:
+ background-color 0.12s ease,
+ opacity 0.12s ease,
+ border-color 0.12s ease,
+ box-shadow 0.12s ease;
+
+ ${({ $dragging, theme }) =>
+ $dragging &&
+ `
+ opacity: 0.55;
+ background-color: ${theme.color.backgroundDarker};
+ border-left-color: ${theme.color.pink};
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+ `}
+
+ /* Reveal the handle on hover */
+ &:hover [data-hook="drag-handle"] {
+ opacity: 1;
+ }
+
+ /* While any drag is in flight, keep all handles fully visible so the user
+ can see exactly where they're about to drop. */
+ ${({ $dragInProgress }) =>
+ $dragInProgress && `& [data-hook="drag-handle"] { opacity: 1; }`}
+`
+
+const DragHandle = styled.button.attrs({ "data-hook": "drag-handle" })`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.8rem;
+ height: 2.4rem;
+ padding: 0;
+ border: 0;
+ border-radius: 0.4rem;
+ background: transparent;
+ color: ${color("gray2")};
+ cursor: grab;
+ opacity: 0.35;
+ transition:
+ opacity 0.15s ease,
+ color 0.15s ease,
+ background-color 0.15s ease,
+ transform 0.1s ease;
+
+ &:hover {
+ color: ${color("foreground")};
+ background: ${color("selection")};
+ transform: scale(1.08);
+ }
+
+ &:active {
+ cursor: grabbing;
+ transform: scale(0.96);
+ background: ${color("backgroundDarker")};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${color("pink")};
+ outline-offset: 1px;
+ opacity: 1;
+ }
+`
+
+const AtLabel = styled.span`
+ font-family: ${({ theme }) => theme.fontMonospace};
+ color: ${color("purple")};
+ font-size: 1.4rem;
+ user-select: none;
+`
+
+const AssignSymbol = styled.span`
+ font-family: ${({ theme }) => theme.fontMonospace};
+ color: ${color("pinkLighter")};
+ font-size: 1.4rem;
+ user-select: none;
+`
+
+const NameInput = styled(Input)`
+ font-family: ${({ theme }) => theme.fontMonospace};
+ font-size: 1.3rem;
+`
+
+const ValueInput = styled(Input)`
+ font-family: ${({ theme }) => theme.fontMonospace};
+ font-size: 1.3rem;
+`
+
+const DeleteButton = styled(Button).attrs({ skin: "transparent" })`
+ padding: 0.4rem;
+ color: ${color("gray2")};
+`
+
+const AddRow = styled.div`
+ padding: 0 1.6rem 1rem;
+`
+
+const Footer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem 1.6rem 1.2rem;
+ border-top: 1px solid ${color("background")};
+ gap: 0.8rem;
+`
+
+const FooterLeft = styled.div`
+ display: flex;
+ gap: 0.8rem;
+ align-items: center;
+`
+
+const FooterRight = styled.div`
+ display: flex;
+ gap: 0.8rem;
+ align-items: center;
+`
+
+const AddButton = styled(Button).attrs({
+ skin: "transparent",
+ prefixIcon: ,
+})``
+
+const ImportButton = styled(Button)`
+ position: relative;
+`
+
+const ImportedTick = styled(CheckboxCircle)`
+ position: absolute;
+ top: 0;
+ right: 0;
+ transform: translate(50%, -50%);
+ color: ${({ theme }) => theme.color.green};
+`
+
+const InlineHint = styled.div`
+ font-size: 1.2rem;
+ color: ${color("red")};
+ padding-left: 2.4rem;
+ grid-column: 3 / span 4;
+`
+
+type Draft = {
+ key: string
+ name: string
+ value: string
+ // One-shot "@-was-stripped" hint per row.
+ showStripHint?: boolean
+}
+
+let DRAFT_KEY = 0
+const newKey = () => `v${++DRAFT_KEY}`
+
+const draftsFromVariables = (vars: unknown): Draft[] => {
+ const normalized = normalizeVariables(vars)
+ if (normalized.length === 0) return [{ key: newKey(), name: "", value: "" }]
+ return normalized.map(({ name, value }) => ({ key: newKey(), name, value }))
+}
+
+const isDuplicate = (drafts: Draft[], idx: number): boolean => {
+ const name = drafts[idx].name
+ if (!name) return false
+ // Flag only the LAST occurrence of a duplicated name. Earlier rows stay
+ // clean; user fixes/removes the most recent conflict.
+ const hasPrior = drafts.slice(0, idx).some((d) => d.name === name)
+ if (!hasPrior) return false
+ const hasLater = drafts.slice(idx + 1).some((d) => d.name === name)
+ return !hasLater
+}
+
+const hasInvalidShape = (draft: Draft): boolean => {
+ if (draft.value === "") return false
+ return (
+ validateVariableShape({ name: draft.name, value: draft.value }) !== null
+ )
+}
+
+const isRowInvalid = (drafts: Draft[], idx: number): boolean => {
+ const d = drafts[idx]
+ if (d.name === "") return false // empty rows are just pending, not invalid
+ if (!isValidVariableName(d.name)) return true
+ if (isDuplicate(drafts, idx)) return true
+ return hasInvalidShape(d)
+}
+
+const buildPersistList = (drafts: Draft[]): NotebookVariable[] => {
+ const seen = new Set()
+ const out: NotebookVariable[] = []
+ for (const d of drafts) {
+ if (!d.name) continue
+ if (!isValidVariableName(d.name)) continue
+ if (seen.has(d.name)) continue
+ seen.add(d.name)
+ out.push({ name: d.name, value: d.value })
+ }
+ return out
+}
+
+const variablesEqual = (
+ a: NotebookVariable[] | undefined,
+ b: NotebookVariable[],
+): boolean => {
+ const aa = a ?? []
+ if (aa.length !== b.length) return false
+ for (let i = 0; i < b.length; i += 1) {
+ if (aa[i].name !== b[i].name || aa[i].value !== b[i].value) return false
+ }
+ return true
+}
+
+export const VariablesPopover: React.FC = () => {
+ const { quest } = useContext(QuestContext)
+ const { settings } = useNotebookState()
+ const { updateSettings } = useNotebookActions()
+ const [open, setOpen] = useState(false)
+ const [drafts, setDrafts] = useState([])
+ const [serverErrors, setServerErrors] = useState>({})
+ const [validating, setValidating] = useState(false)
+ const [draggingKey, setDraggingKey] = useState(null)
+ const [importedFlash, setImportedFlash] = useState(false)
+ const importedTimeoutRef = useRef | null>(null)
+ const draggingKeyRef = useRef(null)
+ const cancelledRef = useRef(false)
+
+ useEffect(
+ () => () => {
+ if (importedTimeoutRef.current) clearTimeout(importedTimeoutRef.current)
+ },
+ [],
+ )
+
+ useEffect(() => {
+ if (open) setDrafts(draftsFromVariables(settings.variables))
+ }, [open])
+
+ const handleAdd = useCallback(() => {
+ const key = newKey()
+ setDrafts((prev) => [...prev, { key, name: "", value: "" }])
+ }, [])
+
+ const handleDelete = useCallback((idx: number) => {
+ setDrafts((prev) => prev.filter((_, i) => i !== idx))
+ setServerErrors({})
+ }, [])
+
+ const moveDraftToIndex = useCallback((key: string, toIndex: number) => {
+ setDrafts((prev) => {
+ const idx = prev.findIndex((d) => d.key === key)
+ if (idx < 0) return prev
+ const clamped = Math.max(0, Math.min(toIndex, prev.length))
+ const adjusted = idx < clamped ? clamped - 1 : clamped
+ if (idx === adjusted) return prev
+ const next = prev.slice()
+ const [moved] = next.splice(idx, 1)
+ next.splice(adjusted, 0, moved)
+ return next
+ })
+ setServerErrors({})
+ }, [])
+
+ const handleDragStart = useCallback(
+ (e: React.DragEvent, key: string) => {
+ draggingKeyRef.current = key
+ setDraggingKey(key)
+ e.dataTransfer.effectAllowed = "move"
+ e.dataTransfer.setData("text/plain", key)
+ },
+ [],
+ )
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent, idx: number) => {
+ const key = draggingKeyRef.current
+ if (!key) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = "move"
+ const rect = e.currentTarget.getBoundingClientRect()
+ const toIndex = e.clientY > rect.top + rect.height / 2 ? idx + 1 : idx
+ moveDraftToIndex(key, toIndex)
+ },
+ [moveDraftToIndex],
+ )
+
+ const handleDragEnd = useCallback(() => {
+ draggingKeyRef.current = null
+ setDraggingKey(null)
+ }, [])
+
+ const handleNameChange = useCallback((idx: number, raw: string) => {
+ setDrafts((prev) => {
+ const next = prev.slice()
+ const stripped = stripLeadingAt(raw)
+ const showStripHint = raw !== stripped
+ next[idx] = {
+ ...next[idx],
+ name: stripped,
+ showStripHint: showStripHint || next[idx].showStripHint,
+ }
+ return next
+ })
+ setServerErrors({})
+ }, [])
+
+ const handleValueChange = useCallback((idx: number, raw: string) => {
+ setDrafts((prev) => {
+ const next = prev.slice()
+ next[idx] = { ...next[idx], value: raw }
+ return next
+ })
+ setServerErrors({})
+ }, [])
+
+ const handleEnterOnValue = (idx: number) => {
+ const isLast = idx === drafts.length - 1
+ if (isLast) {
+ handleAdd()
+ } else {
+ setDrafts((p) => p.slice())
+ }
+ }
+
+ const validateOnServer = async (
+ list: NotebookVariable[],
+ ): Promise => {
+ const nextErrors: Record = {}
+ for (let i = 0; i < list.length; i += 1) {
+ const shapeError = validateVariableShape(list[i])
+ if (shapeError) {
+ const draft = drafts.find((d) => d.name === list[i].name)
+ if (draft) {
+ nextErrors[draft.key] =
+ "Value must be a single expression — wrap in parentheses if you need commas."
+ }
+ setServerErrors(nextErrors)
+ return false
+ }
+ const prefix = list.slice(0, i + 1)
+ const result = await quest.validateQuery(
+ renderDeclareValidationQuery(prefix),
+ )
+ if ("error" in result) {
+ const draft = drafts.find((d) => d.name === list[i].name)
+ if (draft) nextErrors[draft.key] = result.error
+ setServerErrors(nextErrors)
+ return false
+ }
+ }
+ setServerErrors({})
+ return true
+ }
+
+ const handleApply = async () => {
+ const list = buildPersistList(drafts)
+ cancelledRef.current = false
+ setValidating(true)
+ try {
+ if (!(await validateOnServer(list))) return
+ if (cancelledRef.current) return
+ if (!variablesEqual(normalizeVariables(settings.variables), list)) {
+ updateSettings({ variables: list })
+ }
+ setOpen(false)
+ } finally {
+ setValidating(false)
+ }
+ }
+
+ const handleCancel = () => {
+ cancelledRef.current = true
+ setOpen(false)
+ }
+
+ const handleOpenChange = (next: boolean) => {
+ if (!next) cancelledRef.current = true
+ setOpen(next)
+ }
+
+ const handleImport = async () => {
+ try {
+ const text = await readFromClipboard()
+ const parsed = parseDeclareBlock(text)
+ if (parsed.length === 0) {
+ toast.error("No DECLARE block found in clipboard")
+ return
+ }
+ setDrafts((prev) => {
+ const next = prev.filter((d) => d.name !== "" || d.value !== "")
+ for (const { name, value } of parsed) {
+ const idx = next.findIndex((d) => d.name === name)
+ if (idx >= 0) {
+ next[idx] = { ...next[idx], value }
+ } else {
+ next.push({ key: newKey(), name, value })
+ }
+ }
+ return next.length > 0 ? next : [{ key: newKey(), name: "", value: "" }]
+ })
+ setImportedFlash(true)
+ if (importedTimeoutRef.current) clearTimeout(importedTimeoutRef.current)
+ importedTimeoutRef.current = setTimeout(
+ () => setImportedFlash(false),
+ 2000,
+ )
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Could not read clipboard")
+ }
+ }
+
+ const declareBlock = useMemo(
+ () => renderDeclareBlock(buildPersistList(drafts)),
+ [drafts],
+ )
+
+ const clipboardReadable = useMemo(() => canReadFromClipboard(), [])
+
+ const count = useMemo(
+ () => normalizeVariables(settings.variables).length,
+ [settings.variables],
+ )
+
+ const { canApply, hasInvalid, hasValid } = useMemo(() => {
+ let invalid = false
+ let valid = false
+ for (let i = 0; i < drafts.length; i++) {
+ if (isRowInvalid(drafts, i) || Boolean(serverErrors[drafts[i].key])) {
+ invalid = true
+ } else if (drafts[i].name !== "") valid = true
+ }
+ const list = buildPersistList(drafts)
+ const dirty = !variablesEqual(normalizeVariables(settings.variables), list)
+ return {
+ hasInvalid: invalid,
+ hasValid: valid,
+ canApply: !invalid && dirty,
+ }
+ }, [drafts, serverErrors, settings.variables])
+
+ return (
+ }>
+ Variables
+ {count > 0 ? ` (${count})` : ""}
+
+ }
+ >
+
+ DECLARE
+
+ {drafts.map((d, idx) => {
+ const clientInvalid = isRowInvalid(drafts, idx)
+ const serverError = serverErrors[d.key]
+ const invalid = clientInvalid || Boolean(serverError)
+ return (
+
+ handleDragOver(e, idx)}
+ onDrop={handleDragEnd}
+ >
+ handleDragStart(e, d.key)}
+ onDragEnd={handleDragEnd}
+ >
+
+
+ @
+ handleNameChange(idx, e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ ;(
+ e.currentTarget.nextElementSibling
+ ?.nextElementSibling as HTMLInputElement | null
+ )?.focus()
+ }
+ }}
+ />
+ :=
+ handleValueChange(idx, e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleEnterOnValue(idx)
+ }
+ }}
+ />
+ {drafts.length > 1 ? (
+ handleDelete(idx)}
+ >
+
+
+ ) : (
+
+ )}
+
+ {d.showStripHint && (
+
+ @ is added automatically —
+ just type the name.
+
+ )}
+ {(invalid || serverError) && d.name !== "" && (
+
+ {serverError
+ ? serverError
+ : isDuplicate(drafts, idx)
+ ? "Name already used"
+ : !isValidVariableName(d.name)
+ ? "Names start with a letter, underscore, or Unicode character; then letters, digits, underscores, or Unicode characters."
+ : hasInvalidShape(d)
+ ? "Value must be a single expression — wrap in parentheses if you need commas."
+ : "Value failed QuestDB validation."}
+
+ )}
+
+ )
+ })}
+
+
+ Add variable
+
+
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/globals/useValidateWithGlobals.ts b/src/scenes/Editor/Notebook/globals/useValidateWithGlobals.ts
new file mode 100644
index 000000000..27c231c7b
--- /dev/null
+++ b/src/scenes/Editor/Notebook/globals/useValidateWithGlobals.ts
@@ -0,0 +1,51 @@
+import { useCallback, useContext } from "react"
+import { QuestContext } from "../../../../providers/QuestProvider"
+import { useNotebookActions } from "../NotebookProvider"
+import { normalizeVariables, prependGlobalsDeclare } from "../declareUtils"
+
+// Wraps quest.validateQuery so callers can pass the user's original SQL while
+// the server sees the wire form (with notebook globals injected as a DECLARE
+// block). When the server reports an error position, we translate it back to
+// the original-SQL coordinate system so Monaco markers land on the user's
+// typo, not on our injected DECLARE prefix.
+export const useValidateWithGlobals = () => {
+ const { quest } = useContext(QuestContext)
+ const { getVariables } = useNotebookActions()
+
+ return useCallback(
+ async (sql: string, signal?: AbortSignal) => {
+ const variables = normalizeVariables(getVariables())
+ const prepared =
+ variables.length > 0
+ ? prependGlobalsDeclare(sql, variables)
+ : { sql, insertedRange: null }
+ const result = await quest.validateQuery(prepared.sql, signal)
+ if (!("error" in result) || !prepared.insertedRange) return result
+ const { start, end, delta } = prepared.insertedRange
+ const { position } = result
+ if (position < start) {
+ // Error landed in the leading trivia BEFORE our insertion point.
+ // Coordinates already match the user's SQL — pass through.
+ return result
+ }
+ if (position < end) {
+ // Error is inside the wire DECLARE block. For a bare-SELECT cell
+ // this is purely our injected content; for a merge it may also be
+ // inside the user's own local assignment. Positions inside the
+ // block can't be back-mapped uniformly (separator rewrites shift
+ // them non-linearly), so we point the marker at the block start
+ // and annotate. The underlying server message still describes the
+ // root cause.
+ return {
+ ...result,
+ position: start,
+ error: `${result.error} (in DECLARE block)`,
+ }
+ }
+ // Error after the wire DECLARE block — simple shift back into
+ // user-SQL coordinates.
+ return { ...result, position: position - delta }
+ },
+ [quest, getVariables],
+ )
+}
diff --git a/src/scenes/Editor/Notebook/index.tsx b/src/scenes/Editor/Notebook/index.tsx
new file mode 100644
index 000000000..9bb1b7ebe
--- /dev/null
+++ b/src/scenes/Editor/Notebook/index.tsx
@@ -0,0 +1,480 @@
+import React, { useCallback, useEffect, useMemo } from "react"
+import styled from "styled-components"
+import {
+ ResponsiveGridLayout,
+ useContainerWidth,
+ type Layout,
+ type LayoutItem,
+ verticalCompactor,
+} from "react-grid-layout"
+import { absoluteStrategy } from "react-grid-layout/core"
+import "react-grid-layout/css/styles.css"
+import "react-resizable/css/styles.css"
+import { useEditor } from "../../../providers/EditorProvider"
+import type { CellLayoutItem } from "../../../store/notebook"
+import { color } from "../../../utils"
+import {
+ NotebookProvider,
+ useNotebookActions,
+ useNotebookState,
+} from "./NotebookProvider"
+import { Cell } from "./cells/Cell"
+import { AddCellBottom, AddCellBetween } from "./cells/AddCellButton"
+import { NotebookToolbar } from "./NotebookToolbar"
+import { renderEdgeHandle } from "./resize"
+import {
+ CELL_CHROME_PX,
+ computeCellGridH,
+ defaultBottomHeightFor,
+ DEFAULT_TOP_HEIGHT,
+ generateDefaultLayout as generateDefaultLayoutPure,
+ isDoubleView,
+ mergeCellLayout,
+ MIN_BOTTOM_HEIGHT_PX,
+ scaleCellHeights,
+} from "./notebookUtils"
+import { emitUserAction } from "../../../utils/notebookAIBridge"
+import { eventBus } from "../../../modules/EventBus"
+import { EventType } from "../../../modules/EventBus/types"
+
+const GRID_COLS = 12
+// 10 px row increments — fine enough that `computeCellGridH`'s `Math.ceil`
+// adds at most 9 px of trailing whitespace per cell (vs. 49 px with the
+// previous 50 px rows). Makes drag-resize feel smoother too. All
+// row-unit constants below scale up 5× to keep the same pixel-equivalent
+// defaults and minimums.
+const ROW_HEIGHT = 10
+const DEFAULT_CELL_H = 30 // 30 × 10 = 300 px (was 6 × 50 = 300 px)
+
+const NotebookWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+`
+
+const CellListContainer = styled.div<{ $maximized?: boolean }>`
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow-y: ${({ $maximized }) => ($maximized ? "hidden" : "auto")};
+ padding: ${({ $maximized }) => ($maximized ? "0" : "2rem")};
+ gap: 2rem;
+ background: ${color("editorBackground")};
+`
+
+const CellItem = styled.div<{ $maximized?: boolean }>`
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ ${({ $maximized }) => $maximized && `flex: 1; min-height: 0;`}
+`
+
+type GridCellWrapperProps = React.HTMLAttributes & {
+ cellId: string
+}
+const GridCellWrapper = React.forwardRef(
+ ({ children, style, className, cellId, ...rest }, ref) => (
+
+ {children}
+
+ ),
+)
+
+const GridScrollContainer = styled.div`
+ flex: 1;
+ overflow-y: auto;
+ padding: 2rem;
+ background: ${color("editorBackground")};
+
+ .react-grid-item.react-grid-placeholder {
+ background: ${color("selection")};
+ opacity: 0.25;
+ }
+
+ .react-grid-item {
+ border-radius: 0.4rem;
+ }
+`
+
+const renderResizeHandle = renderEdgeHandle
+
+const MIN_CELL_W = 2
+// Minimum grid rows. Sized so the rendered floor is ~editor + chrome
+// (`MIN_EDITOR_HEIGHT 72 + CELL_CHROME_PX 40 ≈ 112 px`), letting the
+// user collapse a cell down to "just the editor". Rendered px for
+// h rows in rgl with our margins = h*rowHeight + (h-1)*marginY
+// = 5*10 + 4*20 = 130 px. Earlier value (20 rows) translated to 580 px
+// once the marginY-aware math landed, which the user reported as an
+// unwanted hard floor in grid mode.
+const MIN_CELL_H = 5
+
+const GRID_MARGIN_X = 20
+const GRID_MARGIN_Y = 20
+const GRID_MARGIN: [number, number] = [GRID_MARGIN_X, GRID_MARGIN_Y]
+const GRID_CONTAINER_PADDING: [number, number] = [0, 0]
+const GRID_BREAKPOINTS = { lg: 0 }
+const GRID_COLS_MAP = { lg: GRID_COLS }
+const DRAG_CONFIG = {
+ enabled: true,
+ handle: ".cell-drag-handle",
+ cancel: "button, a, input, select, textarea, .cell-toolbar",
+}
+const RESIZE_CONFIG = {
+ enabled: true,
+ handles: ["se", "s", "e", "w"] as const,
+ handleComponent: renderResizeHandle,
+}
+
+const LAYOUT_OPTS = {
+ gridCols: GRID_COLS,
+ defaultCellH: DEFAULT_CELL_H,
+ minW: MIN_CELL_W,
+ minH: MIN_CELL_H,
+}
+
+const generateDefaultLayout = (cells: { id: string }[]): CellLayoutItem[] =>
+ generateDefaultLayoutPure(cells, LAYOUT_OPTS)
+
+const useScrollAddedCellIntoView = (cells: { id: string }[]) => {
+ const prev = React.useRef>(new Set(cells.map((c) => c.id)))
+ useEffect(() => {
+ const added = cells.filter((c) => !prev.current.has(c.id))
+ prev.current = new Set(cells.map((c) => c.id))
+ if (added.length !== 1) return
+ const targetId = added[0].id
+ requestAnimationFrame(() => {
+ document
+ .querySelector(
+ `[data-notebook-cell][data-cell-id="${CSS.escape(targetId)}"]`,
+ )
+ ?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ })
+ }, [cells])
+}
+
+const ListLayout: React.FC = () => {
+ const { cells, focusedCellId, maximizedCellId, runningCellIds } =
+ useNotebookState()
+ const { setFocusedCell } = useNotebookActions()
+ useScrollAddedCellIntoView(cells)
+
+ return (
+ {
+ const target = e.target as HTMLElement
+ if (!target.closest("[data-notebook-cell]")) setFocusedCell(null)
+ }}
+ >
+ {cells.map((cell, index) => (
+
+
+ |
+
+ {index < cells.length - 1 && }
+
+ ))}
+ 0 ? cells[cells.length - 1].id : undefined}
+ />
+
+ )
+}
+
+const GridLayout: React.FC = () => {
+ const { cells, settings, focusedCellId, maximizedCellId, runningCellIds } =
+ useNotebookState()
+ const { setFocusedCell, updateSettings, updateCell } = useNotebookActions()
+ const { activeBuffer } = useEditor()
+ const { width: containerWidth, containerRef } = useContainerWidth({
+ initialWidth: 800,
+ })
+ useScrollAddedCellIntoView(cells)
+
+ // Derive each cell's grid h from topHeight + bottomHeight + chrome.
+ // The h value in settings.layout is ignored on read — it's a stale
+ // shadow that only matters when react-grid-layout consumes it. We
+ // recompute every render so a content-driven topHeight or run-driven
+ // bottomHeight change immediately resizes the grid cell.
+ const currentLayout = useMemo(() => {
+ const base = mergeCellLayout(
+ settings.layout && settings.layout.length > 0
+ ? settings.layout
+ : generateDefaultLayout(cells),
+ cells,
+ LAYOUT_OPTS,
+ ) as LayoutItem[]
+ const cellById = new Map(cells.map((c) => [c.id, c]))
+ return base.map((item) => {
+ const cell = cellById.get(item.i)
+ if (!cell) return item
+ const minBottomPx = isDoubleView(cell) ? MIN_BOTTOM_HEIGHT_PX : 0
+ const minTotalPx = DEFAULT_TOP_HEIGHT + minBottomPx + CELL_CHROME_PX
+ const minH = Math.max(
+ MIN_CELL_H,
+ Math.ceil((minTotalPx + GRID_MARGIN_Y) / (ROW_HEIGHT + GRID_MARGIN_Y)),
+ )
+ return {
+ ...item,
+ h: computeCellGridH(cell, ROW_HEIGHT, GRID_MARGIN_Y),
+ minH,
+ }
+ })
+ }, [settings.layout, cells])
+
+ const mapLayoutXYW = useCallback(
+ (rglLayout: Layout): CellLayoutItem[] =>
+ [...rglLayout]
+ .filter((item: LayoutItem) => cells.some((c) => c.id === item.i))
+ .map((item: LayoutItem) => ({
+ i: item.i,
+ x: item.x,
+ y: item.y,
+ w: item.w,
+ // Persist whatever h react-grid-layout reports; it's recomputed
+ // on next render by computeCellGridH anyway. Keeping it here
+ // means we don't have to special-case the persisted shape.
+ h: item.h,
+ })),
+ [cells],
+ )
+
+ // Drag-stop = move (x/y change). Just persist positions.
+ const handleDragStop = useCallback(
+ (newLayout: Layout, ..._args: unknown[]) => {
+ updateSettings({ layout: mapLayoutXYW(newLayout) })
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_changed_grid_layout",
+ bufferId: activeBuffer.id,
+ })
+ }
+ },
+ [mapLayoutXYW, updateSettings, activeBuffer.id],
+ )
+
+ const handleResizeStop = useCallback(
+ (newLayout: Layout, ..._args: unknown[]) => {
+ updateSettings({ layout: mapLayoutXYW(newLayout) })
+ const cellById = new Map(cells.map((c) => [c.id, c]))
+ for (const item of newLayout) {
+ const cell = cellById.get(item.i)
+ if (!cell) continue
+ const derivedH = computeCellGridH(cell, ROW_HEIGHT, GRID_MARGIN_Y)
+ if (item.h === derivedH) continue
+ // Rendered px for an h-row cell is `h*rowHeight + (h-1)*marginY`,
+ // accounting for the inter-row gaps react-grid-layout inserts.
+ // Plain `h * rowHeight` is too small and made the resulting
+ // bottomHeight ~marginY-per-row shorter than the user dragged.
+ const rowsToPx = (h: number) => h * ROW_HEIGHT + (h - 1) * GRID_MARGIN_Y
+ const targetTotalPx = rowsToPx(item.h)
+ if (isDoubleView(cell)) {
+ const { top: nextTop, bottom: nextBottom } = scaleCellHeights(
+ cell.topHeight ?? DEFAULT_TOP_HEIGHT,
+ cell.bottomHeight ?? defaultBottomHeightFor(cell),
+ targetTotalPx - CELL_CHROME_PX,
+ DEFAULT_TOP_HEIGHT,
+ MIN_BOTTOM_HEIGHT_PX,
+ )
+ updateCell(cell.id, {
+ topHeight: nextTop,
+ bottomHeight: nextBottom,
+ topResized: true,
+ })
+ } else {
+ // Single-view: no bottom slot — south drag grows the editor.
+ // Mark topResized so Monaco's content-driven auto-grow stops
+ // snapping the cell back to MIN_EDITOR_HEIGHT after the user's
+ // drag. Without this, the dragged size only lingered in rgl's
+ // internal state and disappeared on the next layouts-prop
+ // reconciliation (e.g. when another cell was added).
+ const nextTopHeight = Math.max(
+ DEFAULT_TOP_HEIGHT,
+ targetTotalPx - CELL_CHROME_PX,
+ )
+ updateCell(cell.id, {
+ topHeight: nextTopHeight,
+ topResized: true,
+ })
+ }
+ }
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_changed_grid_layout",
+ bufferId: activeBuffer.id,
+ })
+ }
+ },
+ [mapLayoutXYW, updateSettings, updateCell, cells, activeBuffer.id],
+ )
+
+ useEffect(() => {
+ const handler = (payload?: {
+ cellId?: string
+ kind?: "full" | "right" | "left"
+ }) => {
+ const cellId = payload?.cellId
+ const kind = payload?.kind
+ if (!cellId || !kind) return
+ const cur = currentLayout.find((l) => l.i === cellId)
+ if (!cur) return
+ let nextX = cur.x
+ let nextW = cur.w
+ if (kind === "full") {
+ nextX = 0
+ nextW = GRID_COLS
+ } else if (kind === "right") {
+ nextW = GRID_COLS - cur.x
+ } else if (kind === "left") {
+ nextX = 0
+ nextW = cur.x + cur.w
+ }
+ if (nextX === cur.x && nextW === cur.w) return
+ const nextLayout: CellLayoutItem[] = currentLayout.map((l) =>
+ l.i === cellId
+ ? { i: l.i, x: nextX, y: l.y, w: nextW, h: l.h }
+ : { i: l.i, x: l.x, y: l.y, w: l.w, h: l.h },
+ )
+ updateSettings({ layout: nextLayout })
+ if (typeof activeBuffer.id === "number") {
+ emitUserAction({
+ kind: "user_changed_grid_layout",
+ bufferId: activeBuffer.id,
+ })
+ }
+ }
+ eventBus.subscribe(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, handler)
+ return () =>
+ eventBus.unsubscribe(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, handler)
+ }, [currentLayout, updateSettings, activeBuffer.id])
+
+ return (
+ }
+ onMouseDown={(e) => {
+ const target = e.target as HTMLElement
+ const gridItem = target.closest("[data-cell-id]")
+ if (gridItem?.dataset.cellId) {
+ setFocusedCell(gridItem.dataset.cellId)
+ } else {
+ setFocusedCell(null)
+ }
+ }}
+ >
+ {/* No `key` on ResponsiveGridLayout: keying it on cellIds would
+ * unmount every Cell/DrawCanvas on add/remove and wipe chart state.
+ * rgl reconciles children by their own key matched against
+ * layouts.lg[].i. */}
+
+ {cells.map((cell) => (
+
+ |
+
+ ))}
+
+ 0 ? cells[cells.length - 1].id : undefined}
+ />
+
+ )
+}
+
+const NotebookContent: React.FC = () => {
+ const { cells, settings, focusedCellId, maximizedCellId, runningCellIds } =
+ useNotebookState()
+ const layoutMode = settings.layoutMode ?? "list"
+
+ if (cells.length === 0) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ if (maximizedCellId) {
+ const cell = cells.find((c) => c.id === maximizedCellId)
+ if (!cell) return null
+
+ return (
+
+
+
+ |
+
+
+
+ )
+ }
+
+ return (
+
+
+ {layoutMode === "grid" ? : }
+
+ )
+}
+
+export const Notebook: React.FC = () => {
+ const { activeBuffer } = useEditor()
+
+ if (!activeBuffer.notebookViewState || !activeBuffer.id) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts
new file mode 100644
index 000000000..9d73588c5
--- /dev/null
+++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts
@@ -0,0 +1,1050 @@
+import { describe, it, expect } from "vitest"
+import {
+ ApplyNotebookStateError,
+ attachScriptSummary,
+ buildAppliedCells,
+ buildAppliedLayout,
+ buildInitialScriptResults,
+ buildPersistPayload,
+ cancelAllInCell,
+ cancelOneInCell,
+ computeCellGridH,
+ computeResultBottomHeight,
+ duplicateCellAt,
+ generateDefaultLayout,
+ insertCell,
+ isDoubleView,
+ mergeCellLayout,
+ removeCell,
+ setResultAt,
+ singleResultFromExec,
+ stripCellResults,
+ swapCellDown,
+ swapCellUp,
+ upsertColumnSizing,
+} from "./notebookUtils"
+import type { NotebookCell } from "../../../store/notebook"
+import type { QueryExecResult } from "../../../hooks/useQueryExecution"
+
+const cell = (
+ id: string,
+ value = "",
+ result?: NotebookCell["result"],
+): NotebookCell => ({
+ id,
+ position: 0,
+ value,
+ result,
+})
+
+describe("singleResultFromExec", () => {
+ it("maps dql exec to DqlQueryResult preserving columns/dataset/count/timings", () => {
+ const exec: QueryExecResult = {
+ type: "dql",
+ query: "SELECT 1",
+ columns: [{ name: "x", type: "INT" }],
+ dataset: [[1]],
+ count: 1,
+ timings: {
+ compiler: 100,
+ execute: 200,
+ authentication: 10,
+ fetch: 5,
+ count: 1,
+ },
+ }
+ expect(singleResultFromExec(exec, "SELECT 1")).toEqual({
+ type: "dql",
+ query: "SELECT 1",
+ columns: exec.columns,
+ dataset: exec.dataset,
+ count: 1,
+ timings: exec.timings,
+ })
+ })
+
+ it("maps error exec to ErrorQueryResult with error message", () => {
+ const exec: QueryExecResult = {
+ type: "error",
+ query: "SELECT boom",
+ columns: [],
+ dataset: [],
+ count: 0,
+ error: "syntax error",
+ }
+ expect(singleResultFromExec(exec, "SELECT boom")).toEqual({
+ type: "error",
+ query: "SELECT boom",
+ error: "syntax error",
+ })
+ })
+
+ it("falls back to 'Unknown error' when error exec has no message", () => {
+ const exec: QueryExecResult = {
+ type: "error",
+ query: "SELECT ?",
+ columns: [],
+ dataset: [],
+ count: 0,
+ }
+ expect(singleResultFromExec(exec, "SELECT ?")).toEqual({
+ type: "error",
+ query: "SELECT ?",
+ error: "Unknown error",
+ })
+ })
+
+ it("maps ddl exec to DdlDmlQueryResult without data fields", () => {
+ const exec: QueryExecResult = {
+ type: "ddl",
+ query: "CREATE TABLE t (x INT)",
+ columns: [],
+ dataset: [],
+ count: 0,
+ }
+ expect(singleResultFromExec(exec, "CREATE TABLE t (x INT)")).toEqual({
+ type: "ddl",
+ query: "CREATE TABLE t (x INT)",
+ })
+ })
+
+ it("maps dml exec to DdlDmlQueryResult", () => {
+ const exec: QueryExecResult = {
+ type: "dml",
+ query: "INSERT INTO t VALUES (1)",
+ columns: [],
+ dataset: [],
+ count: 0,
+ }
+ expect(singleResultFromExec(exec, "INSERT INTO t VALUES (1)")).toEqual({
+ type: "dml",
+ query: "INSERT INTO t VALUES (1)",
+ })
+ })
+})
+
+describe("stripCellResults", () => {
+ it("removes result from every cell", () => {
+ const cells: NotebookCell[] = [
+ cell("a", "SELECT 1", {
+ results: [
+ {
+ type: "dql",
+ query: "SELECT 1",
+ columns: [{ name: "x", type: "INT" }],
+ dataset: [[1]],
+ count: 1,
+ },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ cell("b", "SELECT 2"),
+ ]
+ const out = stripCellResults(cells)
+ expect(out[0].result).toBeUndefined()
+ expect(out[1].result).toBeUndefined()
+ })
+
+ it("returns an empty array for empty input", () => {
+ expect(stripCellResults([])).toEqual([])
+ })
+})
+
+describe("buildPersistPayload", () => {
+ it("packs cells, focusedCellId, maximizedCellId and settings; results are stripped", () => {
+ const cells: NotebookCell[] = [
+ cell("a", "SELECT 1", {
+ results: [{ type: "running", query: "SELECT 1" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ]
+ const payload = buildPersistPayload(cells, "a", null, {
+ layoutMode: "list",
+ })
+ expect(payload).toEqual({
+ cells: [{ ...cells[0], result: undefined }],
+ focusedCellId: "a",
+ maximizedCellId: undefined,
+ settings: { layoutMode: "list" },
+ })
+ })
+
+ it("coerces null focusedCellId/maximizedCellId to undefined", () => {
+ const payload = buildPersistPayload([], null, null, {})
+ expect(payload.focusedCellId).toBeUndefined()
+ expect(payload.maximizedCellId).toBeUndefined()
+ })
+})
+
+describe("generateDefaultLayout", () => {
+ it("stacks each cell in its own row at x=0", () => {
+ const cells = [{ id: "a" }, { id: "b" }, { id: "c" }]
+ expect(
+ generateDefaultLayout(cells, { gridCols: 12, defaultCellH: 6 }),
+ ).toEqual([
+ { i: "a", x: 0, y: 0, w: 12, h: 6 },
+ { i: "b", x: 0, y: 6, w: 12, h: 6 },
+ { i: "c", x: 0, y: 12, w: 12, h: 6 },
+ ])
+ })
+
+ it("returns empty for no cells", () => {
+ expect(
+ generateDefaultLayout([], { gridCols: 12, defaultCellH: 6 }),
+ ).toEqual([])
+ })
+})
+
+describe("mergeCellLayout", () => {
+ const opts = { gridCols: 12, defaultCellH: 6, minW: 2, minH: 2 }
+
+ it("preserves saved entries for existing cells and adds minW/minH", () => {
+ const saved = [{ i: "a", x: 3, y: 4, w: 8, h: 10 }]
+ const cells = [{ id: "a" }]
+ expect(mergeCellLayout(saved, cells, opts)).toEqual([
+ { i: "a", x: 3, y: 4, w: 8, h: 10, minW: 2, minH: 2 },
+ ])
+ })
+
+ it("stacks new cells below the current max-y+h and defaults w/h", () => {
+ const saved = [{ i: "a", x: 0, y: 0, w: 12, h: 6 }]
+ const cells = [{ id: "a" }, { id: "b" }]
+ expect(mergeCellLayout(saved, cells, opts)).toEqual([
+ { i: "a", x: 0, y: 0, w: 12, h: 6, minW: 2, minH: 2 },
+ { i: "b", x: 0, y: 6, w: 12, h: 6, minW: 2, minH: 2 },
+ ])
+ })
+
+ it("preserves order from cells, not saved layout", () => {
+ const saved = [
+ { i: "a", x: 0, y: 0, w: 12, h: 6 },
+ { i: "b", x: 0, y: 6, w: 12, h: 6 },
+ ]
+ const cells = [{ id: "b" }, { id: "a" }]
+ const out = mergeCellLayout(saved, cells, opts)
+ expect(out.map((l) => l.i)).toEqual(["b", "a"])
+ })
+
+ it("starts at y=0 when saved layout is empty", () => {
+ expect(mergeCellLayout([], [{ id: "a" }, { id: "b" }], opts)).toEqual([
+ { i: "a", x: 0, y: 0, w: 12, h: 6, minW: 2, minH: 2 },
+ { i: "b", x: 0, y: 6, w: 12, h: 6, minW: 2, minH: 2 },
+ ])
+ })
+
+ it("drops entries for cells that no longer exist", () => {
+ const saved = [
+ { i: "a", x: 0, y: 0, w: 12, h: 6 },
+ { i: "b", x: 0, y: 6, w: 12, h: 6 },
+ ]
+ const cells = [{ id: "a" }]
+ expect(mergeCellLayout(saved, cells, opts).map((l) => l.i)).toEqual(["a"])
+ })
+})
+
+// Deterministic factory so tests can assert exact ids/positions.
+const fakeFactory = (position: number, value = ""): NotebookCell => ({
+ id: `cell-${position}`,
+ position,
+ value,
+})
+
+describe("insertCell", () => {
+ it("appends a new cell when afterCellId is undefined", () => {
+ const start: NotebookCell[] = [cell("a")]
+ const out = insertCell(start, undefined, fakeFactory)
+ expect(out).toHaveLength(2)
+ expect(out[0].id).toBe("a")
+ expect(out.map((c) => c.position)).toEqual([0, 1])
+ })
+
+ it("inserts right after the named cell", () => {
+ const start: NotebookCell[] = [cell("a"), cell("b"), cell("c")]
+ const out = insertCell(start, "a", fakeFactory)
+ expect(out).toHaveLength(4)
+ expect(out.map((c) => c.id)).toEqual(["a", "cell-1", "b", "c"])
+ expect(out.map((c) => c.position)).toEqual([0, 1, 2, 3])
+ })
+
+ it("inserts at the top when afterCellId is unknown (findIndex -1 + 1 = 0)", () => {
+ // Locks original provider behaviour: unknown afterCellId inserts at index 0, not the end.
+ const start: NotebookCell[] = [cell("a")]
+ const out = insertCell(start, "missing", fakeFactory)
+ expect(out).toHaveLength(2)
+ expect(out[1].id).toBe("a")
+ expect(out.map((c) => c.position)).toEqual([0, 1])
+ })
+
+ it("uses the override id when provided", () => {
+ const start: NotebookCell[] = [cell("a")]
+ const out = insertCell(start, undefined, fakeFactory, { id: "forced-id" })
+ expect(out[1].id).toBe("forced-id")
+ })
+
+ it("uses the override value when provided", () => {
+ const start: NotebookCell[] = [cell("a")]
+ const out = insertCell(start, undefined, fakeFactory, {
+ value: "SELECT 42",
+ })
+ expect(out[1].value).toBe("SELECT 42")
+ })
+
+ it("applies both id and value overrides together", () => {
+ const start: NotebookCell[] = [cell("a")]
+ const out = insertCell(start, undefined, fakeFactory, {
+ id: "forced",
+ value: "SELECT 1",
+ })
+ expect(out[1].id).toBe("forced")
+ expect(out[1].value).toBe("SELECT 1")
+ })
+})
+
+describe("removeCell", () => {
+ it("removes the target cell and re-numbers positions", () => {
+ const out = removeCell([cell("a"), cell("b"), cell("c")], "b")
+ expect(out.map((c) => c.id)).toEqual(["a", "c"])
+ expect(out.map((c) => c.position)).toEqual([0, 1])
+ })
+
+ it("refuses to delete the last remaining cell (returns original)", () => {
+ const start: NotebookCell[] = [cell("a")]
+ expect(removeCell(start, "a")).toBe(start)
+ })
+
+ it("returns the original when the id is unknown", () => {
+ const start: NotebookCell[] = [cell("a"), cell("b")]
+ expect(removeCell(start, "missing")).toBe(start)
+ })
+})
+
+describe("swapCellUp / swapCellDown", () => {
+ const start = [cell("a"), cell("b"), cell("c")] as NotebookCell[]
+
+ it("swapCellUp swaps with the previous cell and renumbers positions", () => {
+ const out = swapCellUp(start, "b")
+ expect(out.map((c) => c.id)).toEqual(["b", "a", "c"])
+ expect(out.map((c) => c.position)).toEqual([0, 1, 2])
+ })
+
+ it("swapCellUp is a no-op at the top", () => {
+ expect(swapCellUp(start, "a")).toBe(start)
+ })
+
+ it("swapCellDown swaps with the next cell", () => {
+ const out = swapCellDown(start, "b")
+ expect(out.map((c) => c.id)).toEqual(["a", "c", "b"])
+ })
+
+ it("swapCellDown is a no-op at the bottom", () => {
+ expect(swapCellDown(start, "c")).toBe(start)
+ })
+
+ it("unknown ids are no-ops for both directions", () => {
+ expect(swapCellUp(start, "missing")).toBe(start)
+ expect(swapCellDown(start, "missing")).toBe(start)
+ })
+})
+
+describe("duplicateCellAt", () => {
+ it("inserts a copy immediately after the original with the provided id", () => {
+ const start: NotebookCell[] = [cell("a", "SELECT 1"), cell("b")]
+ const out = duplicateCellAt(start, "a", "new-id")
+ expect(out.map((c) => c.id)).toEqual(["a", "new-id", "b"])
+ expect(out.map((c) => c.position)).toEqual([0, 1, 2])
+ })
+
+ it("drops the `result` blob on the copy", () => {
+ const original = cell("a", "SELECT 1", {
+ results: [{ type: "running", query: "SELECT 1" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ const out = duplicateCellAt([original], "a", "new-id")
+ const copy = out[1]
+ expect(copy.id).toBe("new-id")
+ expect(copy.result).toBe(null)
+ })
+
+ it("returns the original when the id is unknown", () => {
+ const start: NotebookCell[] = [cell("a")]
+ expect(duplicateCellAt(start, "missing", "x")).toBe(start)
+ })
+})
+
+describe("setResultAt", () => {
+ const withResult = (results: NotebookCell["result"]): NotebookCell =>
+ cell("a", "SELECT 1", results)
+
+ it("replaces the result at the given index", () => {
+ const cells: NotebookCell[] = [
+ withResult({
+ results: [
+ { type: "running", query: "q1" },
+ { type: "running", query: "q2" },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ]
+ const next = {
+ type: "dql" as const,
+ query: "q2",
+ columns: [{ name: "x", type: "INT" }],
+ dataset: [[1]],
+ count: 1,
+ }
+ const out = setResultAt(cells, "a", 1, next)
+ expect(out[0].result?.results[1]).toEqual(next)
+ expect(out[0].result?.results[0].type).toBe("running")
+ })
+
+ it("updates activeResultIndex when provided", () => {
+ const cells: NotebookCell[] = [
+ withResult({
+ results: [
+ { type: "running", query: "q1" },
+ { type: "running", query: "q2" },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ]
+ const out = setResultAt(cells, "a", 1, { type: "running", query: "q2" }, 1)
+ expect(out[0].result?.activeResultIndex).toBe(1)
+ })
+
+ it("does nothing when the cell has no result", () => {
+ const cells: NotebookCell[] = [cell("a")]
+ expect(setResultAt(cells, "a", 0, { type: "running", query: "q" })).toEqual(
+ cells,
+ )
+ })
+
+ it("does nothing when the cell id is unknown", () => {
+ const cells: NotebookCell[] = [cell("a")]
+ expect(
+ setResultAt(cells, "missing", 0, { type: "running", query: "q" }),
+ ).toEqual(cells)
+ })
+})
+
+describe("cancelAllInCell", () => {
+ it("flips running and queued results to cancelled, leaves terminal types alone", () => {
+ const c = cell("a", "", {
+ results: [
+ { type: "dql", query: "q1", columns: [], dataset: [[1]], count: 1 },
+ { type: "running", query: "q2" },
+ { type: "queued", query: "q3" },
+ { type: "error", query: "q4", error: "nope" },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ const out = cancelAllInCell([c], "a")
+ const results = out[0].result!.results
+ expect(results.map((r) => r.type)).toEqual([
+ "dql",
+ "cancelled",
+ "cancelled",
+ "error",
+ ])
+ expect(results[1].query).toBe("q2")
+ expect(results[2].query).toBe("q3")
+ })
+
+ it("is a no-op for cells without a result", () => {
+ const cells: NotebookCell[] = [cell("a")]
+ expect(cancelAllInCell(cells, "a")).toEqual(cells)
+ })
+})
+
+describe("cancelOneInCell", () => {
+ it("marks only the running result at the given index as cancelled", () => {
+ const c = cell("a", "", {
+ results: [
+ { type: "running", query: "q1" },
+ { type: "running", query: "q2" },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ const out = cancelOneInCell([c], "a", 0)
+ const results = out[0].result!.results
+ expect(results[0].type).toBe("cancelled")
+ expect(results[1].type).toBe("running")
+ })
+
+ it("is a no-op when the result isn't running (queued, dql, error)", () => {
+ const c = cell("a", "", {
+ results: [{ type: "queued", query: "q1" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ expect(cancelOneInCell([c], "a", 0)).toEqual([c])
+ })
+
+ it("is a no-op at an out-of-range index", () => {
+ const c = cell("a", "", {
+ results: [{ type: "running", query: "q" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ expect(cancelOneInCell([c], "a", 5)).toEqual([c])
+ })
+})
+
+describe("buildInitialScriptResults", () => {
+ it("marks the first as running and the rest queued", () => {
+ expect(buildInitialScriptResults(["q1", "q2", "q3"])).toEqual([
+ { type: "running", query: "q1" },
+ { type: "queued", query: "q2" },
+ { type: "queued", query: "q3" },
+ ])
+ })
+
+ it("returns an empty list for no queries", () => {
+ expect(buildInitialScriptResults([])).toEqual([])
+ })
+
+ it("returns a single running result for one query", () => {
+ expect(buildInitialScriptResults(["only"])).toEqual([
+ { type: "running", query: "only" },
+ ])
+ })
+})
+
+describe("attachScriptSummary", () => {
+ it("attaches the summary to the cell's existing result", () => {
+ const cells: NotebookCell[] = [
+ cell("a", "", {
+ results: [
+ { type: "dql", query: "q", columns: [], dataset: [], count: 0 },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ]
+ const out = attachScriptSummary(cells, "a", {
+ successCount: 2,
+ failedCount: 0,
+ durationMs: 123,
+ })
+ expect(out[0].result?.script).toEqual({
+ successCount: 2,
+ failedCount: 0,
+ durationMs: 123,
+ })
+ })
+
+ it("is a no-op when the cell has no result", () => {
+ const cells: NotebookCell[] = [cell("a")]
+ expect(
+ attachScriptSummary(cells, "a", {
+ successCount: 0,
+ failedCount: 0,
+ durationMs: 0,
+ }),
+ ).toEqual(cells)
+ })
+
+ it("is a no-op when the cell id is unknown", () => {
+ const cells: NotebookCell[] = [cell("a")]
+ expect(
+ attachScriptSummary(cells, "missing", {
+ successCount: 0,
+ failedCount: 0,
+ durationMs: 0,
+ }),
+ ).toEqual(cells)
+ })
+})
+
+describe("buildAppliedCells", () => {
+ const dql = (query: string): NotebookCell["result"] => ({
+ results: [
+ {
+ type: "dql" as const,
+ query,
+ columns: [],
+ dataset: [],
+ count: 0,
+ },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+
+ it("inserts new cells with generated ids when id is omitted", () => {
+ const prev: NotebookCell[] = []
+ const { nextCells, diff } = buildAppliedCells(prev, {
+ cells: [{ value: "SELECT 1" }, { value: "SELECT 2" }],
+ })
+ expect(nextCells.map((c) => c.value)).toEqual(["SELECT 1", "SELECT 2"])
+ expect(nextCells.map((c) => c.position)).toEqual([0, 1])
+ expect(diff.added).toHaveLength(2)
+ expect(diff.updated).toEqual([])
+ expect(diff.deleted).toEqual([])
+ })
+
+ it("updates existing cells in place and preserves result when value unchanged", () => {
+ const prev: NotebookCell[] = [
+ { id: "a", position: 0, value: "x", result: dql("x") },
+ { id: "b", position: 1, value: "y", result: dql("y") },
+ ]
+ const { nextCells, diff } = buildAppliedCells(prev, {
+ cells: [
+ { id: "a", value: "x" }, // unchanged → result preserved
+ { id: "b", value: "y2" }, // changed → result dropped
+ ],
+ })
+ expect(nextCells[0].result).toEqual(dql("x"))
+ expect(nextCells[1].result).toBeNull()
+ expect(diff.updated).toEqual(["a", "b"])
+ expect(diff.added).toEqual([])
+ expect(diff.deleted).toEqual([])
+ })
+
+ it("deletes cells whose ids are missing from the request", () => {
+ const prev: NotebookCell[] = [
+ { id: "a", position: 0, value: "" },
+ { id: "b", position: 1, value: "" },
+ { id: "c", position: 2, value: "" },
+ ]
+ const { nextCells, diff } = buildAppliedCells(prev, {
+ cells: [
+ { id: "a", value: "" },
+ { id: "c", value: "" },
+ ],
+ })
+ expect(nextCells.map((c) => c.id)).toEqual(["a", "c"])
+ expect(diff.deleted.sort()).toEqual(["b"])
+ })
+
+ it("throws when ids are duplicated within a request", () => {
+ expect(() =>
+ buildAppliedCells([], {
+ cells: [
+ { id: "x", value: "a" },
+ { id: "x", value: "b" },
+ ],
+ }),
+ ).toThrow(ApplyNotebookStateError)
+ })
+
+ it("throws when a draw-mode cell has no chart_config", () => {
+ expect(() =>
+ buildAppliedCells([], {
+ cells: [{ value: "SELECT 1", mode: "draw" }],
+ }),
+ ).toThrow(/no chart_config/)
+ })
+
+ it("throws when candlestick has no ohlc and y_columns are not 4", () => {
+ expect(() =>
+ buildAppliedCells([], {
+ cells: [
+ {
+ value: "SELECT 1",
+ mode: "draw",
+ chartConfig: {
+ type: "candlestick",
+ xColumn: "ts",
+ yColumns: ["a", "b"],
+ },
+ },
+ ],
+ }),
+ ).toThrow(/ohlc/)
+ })
+
+ it("auto-derives ohlc from 4 yColumns for candlestick", () => {
+ const { nextCells } = buildAppliedCells([], {
+ cells: [
+ {
+ value: "SELECT 1",
+ mode: "draw",
+ chartConfig: {
+ type: "candlestick",
+ xColumn: "ts",
+ yColumns: ["o", "h", "l", "c"],
+ },
+ },
+ ],
+ })
+ expect(nextCells[0].chartConfig?.ohlc).toEqual({
+ open: "o",
+ high: "h",
+ low: "l",
+ close: "c",
+ })
+ })
+
+ it("defaults isChartMaximized and autoRefresh to true on new draw cells", () => {
+ const { nextCells } = buildAppliedCells([], {
+ cells: [
+ {
+ value: "SELECT 1",
+ mode: "draw",
+ chartConfig: { type: "line", xColumn: "ts", yColumns: ["v"] },
+ },
+ ],
+ })
+ expect(nextCells[0].autoRefresh).toBe(true)
+ expect(nextCells[0].isChartMaximized).toBe(true)
+ })
+
+ it("refuses an empty cells array", () => {
+ expect(() => buildAppliedCells([], { cells: [] })).toThrow(
+ /at least one cell/,
+ )
+ })
+})
+
+describe("isDoubleView", () => {
+ it("returns true for run cell with a result", () => {
+ expect(
+ isDoubleView({
+ id: "x",
+ position: 0,
+ value: "",
+ result: { results: [], activeResultIndex: 0, timestamp: 0 },
+ }),
+ ).toBe(true)
+ })
+ it("returns false for run cell with no result", () => {
+ expect(isDoubleView({ id: "x", position: 0, value: "" })).toBe(false)
+ })
+ it("returns true for draw cell, whether chart-expanded or not", () => {
+ expect(
+ isDoubleView({ id: "x", position: 0, value: "", mode: "draw" }),
+ ).toBe(true)
+ expect(
+ isDoubleView({
+ id: "x",
+ position: 0,
+ value: "",
+ mode: "draw",
+ isChartMaximized: true,
+ }),
+ ).toBe(true)
+ })
+})
+
+describe("computeResultBottomHeight", () => {
+ // Layout constants (kept in sync with notebookUtils.ts):
+ // TAB_BAR_PX = 40
+ // NOTIFICATION_PX = 44
+ // GRID_HEADER_PX = 44
+ // GRID_ROW_PX = 28
+ // MAX_RESERVED_ROWS = 10
+
+ it("null/undefined/empty result → notification-only", () => {
+ expect(computeResultBottomHeight(null)).toBe(44)
+ expect(computeResultBottomHeight(undefined)).toBe(44)
+ expect(
+ computeResultBottomHeight({
+ results: [],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(44)
+ })
+
+ it("single error → notification-only", () => {
+ expect(
+ computeResultBottomHeight({
+ results: [{ type: "error", query: "X", error: "boom" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(44)
+ })
+
+ it("single DDL/DML/notice → notification-only", () => {
+ for (const type of ["ddl", "dml"] as const) {
+ expect(
+ computeResultBottomHeight({
+ results: [{ type, query: "X" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(44)
+ }
+ })
+
+ it("single DQL with 0 rows → notification-only (no grid header, no rows)", () => {
+ expect(
+ computeResultBottomHeight({
+ results: [
+ {
+ type: "dql",
+ query: "SELECT 1",
+ columns: [],
+ dataset: [],
+ count: 0,
+ },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(44)
+ })
+
+ it("single DQL with N rows → notification + header + N×row (N capped at 10)", () => {
+ const make = (rowCount: number) => ({
+ results: [
+ {
+ type: "dql" as const,
+ query: "SELECT 1",
+ columns: [],
+ dataset: Array.from({ length: rowCount }, () => [1]),
+ count: rowCount,
+ },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ })
+ // 1 row: 44 + 44 + 1*28 = 116
+ expect(computeResultBottomHeight(make(1))).toBe(116)
+ // 5 rows: 44 + 44 + 5*28 = 228
+ expect(computeResultBottomHeight(make(5))).toBe(228)
+ // 10 rows: 44 + 44 + 10*28 = 368
+ expect(computeResultBottomHeight(make(10))).toBe(368)
+ // 50 rows: cap at 10 → still 368
+ expect(computeResultBottomHeight(make(50))).toBe(368)
+ })
+
+ it("multi-statement, first DQL with rows → tab + notification + header + 10 rows", () => {
+ // 40 + 44 + 44 + 10*28 = 408
+ expect(
+ computeResultBottomHeight({
+ results: [
+ {
+ type: "dql",
+ query: "Q1",
+ columns: [],
+ dataset: [[1]],
+ count: 1,
+ },
+ { type: "ddl", query: "Q2" },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(408)
+ })
+
+ it("multi-statement, first is error → tab + notification only (we never saw rows)", () => {
+ // 40 + 44 = 84
+ expect(
+ computeResultBottomHeight({
+ results: [
+ { type: "error", query: "Q1", error: "boom" },
+ { type: "dql", query: "Q2", columns: [], dataset: [[1]], count: 1 },
+ ],
+ activeResultIndex: 1,
+ timestamp: 0,
+ }),
+ ).toBe(84)
+ })
+
+ it("multi-statement, first DQL with 0 rows → tab + notification only", () => {
+ // Same shape as first-is-error: the first query produced no rows, so
+ // we never reserved 10-row space.
+ expect(
+ computeResultBottomHeight({
+ results: [
+ { type: "dql", query: "Q1", columns: [], dataset: [], count: 0 },
+ { type: "dql", query: "Q2", columns: [], dataset: [[1]], count: 1 },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }),
+ ).toBe(84)
+ })
+})
+
+describe("computeCellGridH", () => {
+ it("single-view (run, no result): topHeight + chrome rounded up", () => {
+ // 72 + 56 = 128 → ceil(128/50) = 3
+ expect(computeCellGridH({ id: "x", position: 0, value: "" }, 50)).toBe(3)
+ })
+ it("double-view (run with empty result): tight notification-only bottom", () => {
+ // result.results = [] (empty after run) → bottom = NOTIFICATION_PX (44).
+ // 72 + 56 + 44 = 172 → ceil(172/50) = 4
+ expect(
+ computeCellGridH(
+ {
+ id: "x",
+ position: 0,
+ value: "",
+ result: { results: [], activeResultIndex: 0, timestamp: 0 },
+ },
+ 50,
+ ),
+ ).toBe(4)
+ })
+ it("respects explicit topHeight and bottomHeight overrides", () => {
+ // 200 + 56 + 300 = 556 → ceil(556/50) = 12
+ expect(
+ computeCellGridH(
+ {
+ id: "x",
+ position: 0,
+ value: "",
+ topHeight: 200,
+ bottomHeight: 300,
+ result: { results: [], activeResultIndex: 0, timestamp: 0 },
+ },
+ 50,
+ ),
+ ).toBe(12)
+ })
+ it("draw cell uses chart default 350 when bottomHeight is unset", () => {
+ // 72 + 56 + 350 = 478 → ceil(478/50) = 10
+ expect(
+ computeCellGridH({ id: "x", position: 0, value: "", mode: "draw" }, 50),
+ ).toBe(10)
+ })
+ it("returns at least 1 row even for an empty cell", () => {
+ expect(
+ computeCellGridH({ id: "x", position: 0, value: "", topHeight: 0 }, 50),
+ ).toBeGreaterThanOrEqual(1)
+ })
+ it("accounts for marginY (inter-row gaps from react-grid-layout)", () => {
+ // Same cell as the "respects explicit … overrides" test above
+ // (topHeight=200, bottomHeight=300, chrome=56 → totalPx=556).
+ // With rowHeight=10 and NO margin: 556/10 = 56 rows. With marginY=20,
+ // each row occupies (10+20)=30 px effective, so h = ceil((556+20)/30)
+ // = ceil(576/30) = 20. Rendered px = 20*10 + 19*20 = 200 + 380 = 580,
+ // which fits the 556-px content. Without the marginY term, h would
+ // be 56 → rendered 56*10 + 55*20 = 1660 px (~3× too tall).
+ expect(
+ computeCellGridH(
+ {
+ id: "x",
+ position: 0,
+ value: "",
+ topHeight: 200,
+ bottomHeight: 300,
+ result: { results: [], activeResultIndex: 0, timestamp: 0 },
+ },
+ 10,
+ 20,
+ ),
+ ).toBe(20)
+ })
+})
+
+describe("buildAppliedLayout", () => {
+ it("uses request.grid when provided, otherwise derives h from topHeight + bottomHeight", () => {
+ const cells: NotebookCell[] = [
+ { id: "a", position: 0, value: "" },
+ { id: "b", position: 1, value: "" },
+ ]
+ const layout = buildAppliedLayout(
+ {
+ cells: [
+ { id: "a", value: "", grid: { x: 0, y: 0, w: 6, h: 4 } },
+ { id: "b", value: "" },
+ ],
+ },
+ cells,
+ [],
+ { gridCols: 12, rowHeight: 50 },
+ )
+ expect(layout[0]).toEqual({ i: "a", x: 0, y: 0, w: 6, h: 4 })
+ // Run-mode cell, no result yet → single-view → only topHeight (72) + chrome (40)
+ // = 112 px → ceil(112 / 50) = 3 rows. Stacks below 'a' which had y+h = 4.
+ expect(layout[1]).toEqual({ i: "b", x: 0, y: 4, w: 12, h: 3 })
+ })
+
+ it("preserves prevLayout entry when request omits grid", () => {
+ const cells: NotebookCell[] = [{ id: "a", position: 0, value: "" }]
+ const layout = buildAppliedLayout(
+ { cells: [{ id: "a", value: "" }] },
+ cells,
+ [{ i: "a", x: 3, y: 4, w: 8, h: 5 }],
+ { gridCols: 12, rowHeight: 50 },
+ )
+ expect(layout).toEqual([{ i: "a", x: 3, y: 4, w: 8, h: 5 }])
+ })
+
+ it("gives draw-mode cells a taller default than run-mode cells (no result yet)", () => {
+ // buildAppliedCells seeds bottomHeight = DEFAULT_CHART_BOTTOM_HEIGHT for
+ // draw cells, so they're double-view from creation. Run cells stay
+ // single-view (no result) and only count topHeight + chrome.
+ const cells: NotebookCell[] = [
+ { id: "run-cell", position: 0, value: "", mode: "run" },
+ {
+ id: "draw-cell",
+ position: 1,
+ value: "",
+ mode: "draw",
+ bottomHeight: 350,
+ },
+ ]
+ const layout = buildAppliedLayout(
+ {
+ cells: [
+ { id: "run-cell", value: "" },
+ { id: "draw-cell", value: "" },
+ ],
+ },
+ cells,
+ [],
+ { gridCols: 12, rowHeight: 50 },
+ )
+ // run: 72 + 40 = 112 → 3 rows
+ expect(layout[0].h).toBe(3)
+ // draw: 72 + 350 + 40 = 462 → 10 rows
+ expect(layout[1].h).toBe(10)
+ expect(layout[1].y).toBe(3) // stacked below the run cell
+ })
+})
+
+describe("upsertColumnSizing", () => {
+ it("creates the map when no prior sizing exists", () => {
+ const out = upsertColumnSizing(undefined, "select 1", { col_0: 200 })
+ expect(out).toEqual({ "select 1": { col_0: 200 } })
+ })
+
+ it("overwrites an existing entry under the same key", () => {
+ const prev = { "select 1": { col_0: 100 } }
+ const out = upsertColumnSizing(prev, "select 1", { col_0: 300, col_1: 150 })
+ expect(out).toEqual({ "select 1": { col_0: 300, col_1: 150 } })
+ })
+
+ it("moves an updated key to the tail (most-recent)", () => {
+ // Iteration order = insertion order. Re-inserting "a" should put it last.
+ const prev = {
+ a: { col_0: 1 },
+ b: { col_0: 2 },
+ c: { col_0: 3 },
+ }
+ const out = upsertColumnSizing(prev, "a", { col_0: 99 })
+ expect(Object.keys(out ?? {})).toEqual(["b", "c", "a"])
+ })
+
+ it("evicts oldest entries when exceeding the LRU cap", () => {
+ let acc: ReturnType = undefined
+ for (let i = 0; i < 25; i++) {
+ acc = upsertColumnSizing(acc, `q${i}`, { col_0: i }, 20)
+ }
+ const keys = Object.keys(acc ?? {})
+ // Earliest 5 (q0..q4) dropped; newest 20 (q5..q24) retained, q24 last.
+ expect(keys).toHaveLength(20)
+ expect(keys[0]).toBe("q5")
+ expect(keys[19]).toBe("q24")
+ })
+
+ it("never mutates the input", () => {
+ const prev = { a: { col_0: 1 } }
+ const frozen = Object.freeze(prev)
+ expect(() => upsertColumnSizing(frozen, "b", { col_0: 2 })).not.toThrow()
+ expect(prev).toEqual({ a: { col_0: 1 } })
+ })
+})
diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts
new file mode 100644
index 000000000..105a31eb3
--- /dev/null
+++ b/src/scenes/Editor/Notebook/notebookUtils.ts
@@ -0,0 +1,643 @@
+import type { QueryExecResult } from "../../../hooks/useQueryExecution"
+import type {
+ CellLayoutItem,
+ CellMode,
+ CellResult,
+ NotebookCell,
+ NotebookViewState,
+ SingleQueryResult,
+} from "../../../store/notebook"
+import { createCell } from "../../../store/notebook"
+import type { ChartConfig } from "./CellChart/chartTypes"
+
+export const singleResultFromExec = (
+ exec: QueryExecResult,
+ query: string,
+): SingleQueryResult => {
+ switch (exec.type) {
+ case "dql":
+ return {
+ type: "dql",
+ query,
+ columns: exec.columns,
+ dataset: exec.dataset,
+ count: exec.count,
+ timings: exec.timings,
+ }
+ case "error":
+ return { type: "error", query, error: exec.error ?? "Unknown error" }
+ default:
+ return { type: exec.type, query }
+ }
+}
+
+export const stripCellResults = (cells: NotebookCell[]): NotebookCell[] =>
+ cells.map((cell) => ({ ...cell, result: undefined }))
+
+export const buildPersistPayload = (
+ cells: NotebookCell[],
+ focusedCellId: string | null,
+ maximizedCellId: string | null,
+ settings: NotebookViewState["settings"],
+): NotebookViewState => ({
+ cells: stripCellResults(cells),
+ focusedCellId: focusedCellId ?? undefined,
+ maximizedCellId: maximizedCellId ?? undefined,
+ settings,
+})
+
+type MergeLayoutOptions = {
+ gridCols: number
+ defaultCellH: number
+ minW: number
+ minH: number
+}
+
+export const mergeCellLayout = (
+ savedLayout: CellLayoutItem[],
+ cells: { id: string }[],
+ opts: MergeLayoutOptions,
+): (CellLayoutItem & { minW: number; minH: number })[] => {
+ const layoutMap = new Map(savedLayout.map((l) => [l.i, l]))
+ const maxY =
+ savedLayout.length > 0 ? Math.max(...savedLayout.map((l) => l.y + l.h)) : 0
+ let nextY = maxY
+ return cells.map((cell) => {
+ const existing = layoutMap.get(cell.id)
+ if (existing) {
+ return { ...existing, minW: opts.minW, minH: opts.minH }
+ }
+ const item = {
+ i: cell.id,
+ x: 0,
+ y: nextY,
+ w: opts.gridCols,
+ h: opts.defaultCellH,
+ minW: opts.minW,
+ minH: opts.minH,
+ }
+ nextY += opts.defaultCellH
+ return item
+ })
+}
+
+export const generateDefaultLayout = (
+ cells: { id: string }[],
+ opts: Pick,
+): CellLayoutItem[] =>
+ cells.map((cell, i) => ({
+ i: cell.id,
+ x: 0,
+ y: i * opts.defaultCellH,
+ w: opts.gridCols,
+ h: opts.defaultCellH,
+ }))
+
+// Identity-preserving so React.memo'd siblings skip re-render when one
+// cell is added or removed.
+const reindex = (cells: NotebookCell[]): NotebookCell[] =>
+ cells.map((c, i) => (c.position === i ? c : { ...c, position: i }))
+
+export const insertCell = (
+ cells: NotebookCell[],
+ afterCellId: string | undefined,
+ factory: typeof createCell = createCell,
+ override?: { id?: string; value?: string },
+): NotebookCell[] => {
+ const insertIndex =
+ afterCellId !== undefined
+ ? cells.findIndex((c) => c.id === afterCellId) + 1
+ : cells.length
+ const base = factory(insertIndex, override?.value ?? "")
+ const patch: Partial = {}
+ if (override?.id) patch.id = override.id
+ if (override?.value !== undefined) patch.value = override.value
+ const newCell: NotebookCell =
+ Object.keys(patch).length > 0 ? { ...base, ...patch } : base
+ const next = [...cells]
+ next.splice(insertIndex, 0, newCell)
+ return reindex(next)
+}
+
+export const removeCell = (
+ cells: NotebookCell[],
+ cellId: string,
+): NotebookCell[] => {
+ if (cells.length <= 1) return cells
+ const found = cells.some((c) => c.id === cellId)
+ if (!found) return cells
+ return reindex(cells.filter((c) => c.id !== cellId))
+}
+
+export const swapCellUp = (
+ cells: NotebookCell[],
+ cellId: string,
+): NotebookCell[] => {
+ const idx = cells.findIndex((c) => c.id === cellId)
+ if (idx <= 0) return cells
+ const next = [...cells]
+ ;[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]
+ return reindex(next)
+}
+
+export const swapCellDown = (
+ cells: NotebookCell[],
+ cellId: string,
+): NotebookCell[] => {
+ const idx = cells.findIndex((c) => c.id === cellId)
+ if (idx < 0 || idx >= cells.length - 1) return cells
+ const next = [...cells]
+ ;[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]
+ return reindex(next)
+}
+
+export const duplicateCellAt = (
+ cells: NotebookCell[],
+ cellId: string,
+ newId: string,
+): NotebookCell[] => {
+ const idx = cells.findIndex((c) => c.id === cellId)
+ if (idx < 0) return cells
+ const original = cells[idx]
+ const copy: NotebookCell = {
+ ...original,
+ id: newId,
+ position: idx + 1,
+ result: null,
+ }
+ const next = [...cells]
+ next.splice(idx + 1, 0, copy)
+ return reindex(next)
+}
+
+export const setResultAt = (
+ cells: NotebookCell[],
+ cellId: string,
+ index: number,
+ result: SingleQueryResult,
+ activeIndex?: number,
+): NotebookCell[] =>
+ cells.map((c) => {
+ if (c.id !== cellId || !c.result) return c
+ const results = [...c.result.results]
+ results[index] = result
+ const nextCellResult: CellResult = {
+ ...c.result,
+ results,
+ ...(activeIndex !== undefined && { activeResultIndex: activeIndex }),
+ }
+ return { ...c, result: nextCellResult }
+ })
+
+export const cancelAllInCell = (
+ cells: NotebookCell[],
+ cellId: string,
+): NotebookCell[] =>
+ cells.map((c) => {
+ if (c.id !== cellId || !c.result) return c
+ return {
+ ...c,
+ result: {
+ ...c.result,
+ results: c.result.results.map((r) =>
+ r.type === "running" || r.type === "queued"
+ ? { type: "cancelled", query: r.query }
+ : r,
+ ),
+ },
+ }
+ })
+
+export const cancelOneInCell = (
+ cells: NotebookCell[],
+ cellId: string,
+ index: number,
+): NotebookCell[] =>
+ cells.map((c) => {
+ if (c.id !== cellId || !c.result) return c
+ const results = [...c.result.results]
+ const target = results[index]
+ if (target?.type !== "running") return c
+ results[index] = { type: "cancelled", query: target.query }
+ return { ...c, result: { ...c.result, results } }
+ })
+
+export const buildInitialScriptResults = (
+ queries: string[],
+): SingleQueryResult[] =>
+ queries.map((q, i) => ({
+ type: i === 0 ? "running" : "queued",
+ query: q,
+ }))
+
+type ApplyCellRequest = {
+ id?: string | null
+ value: string
+ mode?: CellMode | null
+ autoRefresh?: boolean | null
+ isChartMaximized?: boolean | null
+ chartConfig?: ChartConfig | null
+ grid?: { x: number; y: number; w: number; h: number } | null
+}
+
+type ApplyRequest = {
+ layoutMode?: "list" | "grid" | null
+ maximizedCellId?: string | null
+ cells: ApplyCellRequest[]
+}
+
+type AppliedDiff = {
+ added: string[]
+ updated: string[]
+ deleted: string[]
+}
+
+export class ApplyNotebookStateError extends Error {
+ readonly field?: string
+ constructor(message: string, field?: string) {
+ super(message)
+ this.name = "ApplyNotebookStateError"
+ this.field = field
+ }
+}
+
+const generateId = (): string =>
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2)
+
+const normalizeChartConfig = (
+ cfg: ChartConfig | null | undefined,
+): ChartConfig | undefined => {
+ if (!cfg) return undefined
+ const next: ChartConfig = {
+ type: cfg.type,
+ xColumn: cfg.xColumn ?? null,
+ yColumns: cfg.yColumns ?? [],
+ }
+ if (cfg.partitionByColumn) next.partitionByColumn = cfg.partitionByColumn
+ if (cfg.name) next.name = cfg.name
+ if (cfg.ohlc) {
+ next.ohlc = cfg.ohlc
+ } else if (
+ cfg.type === "candlestick" &&
+ cfg.yColumns &&
+ cfg.yColumns.length === 4
+ ) {
+ const [open, high, low, close] = cfg.yColumns
+ next.ohlc = { open, high, low, close }
+ }
+ return next
+}
+
+export const buildAppliedCells = (
+ prev: NotebookCell[],
+ request: ApplyRequest,
+): { nextCells: NotebookCell[]; diff: AppliedDiff } => {
+ const prevById = new Map(prev.map((c) => [c.id, c]))
+ const seenIds = new Set()
+ const added: string[] = []
+ const updated: string[] = []
+
+ const nextCells: NotebookCell[] = request.cells.map((req, index) => {
+ const requestedId =
+ typeof req.id === "string" && req.id.length > 0 ? req.id : undefined
+ if (requestedId && seenIds.has(requestedId)) {
+ throw new ApplyNotebookStateError(
+ `Duplicate cell id "${requestedId}" in request.`,
+ "cells",
+ )
+ }
+
+ const existing = requestedId ? prevById.get(requestedId) : undefined
+ const id = requestedId ?? generateId()
+ if (requestedId && !existing) seenIds.add(requestedId)
+ else if (!existing) seenIds.add(id)
+ else seenIds.add(existing.id)
+
+ const resolvedMode: CellMode | undefined =
+ req.mode === undefined || req.mode === null ? existing?.mode : req.mode
+
+ const chartConfig = normalizeChartConfig(
+ req.chartConfig ?? existing?.chartConfig,
+ )
+
+ if (resolvedMode === "draw" && !chartConfig) {
+ throw new ApplyNotebookStateError(
+ `Cell at index ${index} has mode='draw' but no chart_config.`,
+ "cells",
+ )
+ }
+ if (chartConfig?.type === "candlestick" && !chartConfig.ohlc) {
+ throw new ApplyNotebookStateError(
+ `Cell at index ${index} has chart_config.type='candlestick' but no ohlc mapping.`,
+ "cells",
+ )
+ }
+
+ const isDraw = resolvedMode === "draw"
+ const isChartMaximized =
+ req.isChartMaximized !== undefined && req.isChartMaximized !== null
+ ? req.isChartMaximized
+ : (existing?.isChartMaximized ?? (isDraw ? true : undefined))
+ const autoRefresh =
+ req.autoRefresh !== undefined && req.autoRefresh !== null
+ ? req.autoRefresh
+ : (existing?.autoRefresh ?? (isDraw ? true : undefined))
+
+ if (existing) {
+ updated.push(existing.id)
+ const valueChanged = existing.value !== req.value
+ const next: NotebookCell = {
+ ...existing,
+ id: existing.id,
+ position: index,
+ value: req.value,
+ result: valueChanged ? null : existing.result,
+ }
+ if (resolvedMode !== undefined) next.mode = resolvedMode
+ else delete next.mode
+ if (chartConfig !== undefined) next.chartConfig = chartConfig
+ else delete next.chartConfig
+ if (autoRefresh !== undefined) next.autoRefresh = autoRefresh
+ else delete next.autoRefresh
+ if (isChartMaximized !== undefined)
+ next.isChartMaximized = isChartMaximized
+ else delete next.isChartMaximized
+ return next
+ }
+
+ added.push(id)
+ const created: NotebookCell = {
+ id,
+ position: index,
+ value: req.value,
+ }
+ if (resolvedMode !== undefined) created.mode = resolvedMode
+ if (chartConfig !== undefined) created.chartConfig = chartConfig
+ if (autoRefresh !== undefined) created.autoRefresh = autoRefresh
+ if (isChartMaximized !== undefined)
+ created.isChartMaximized = isChartMaximized
+ // Draw cells are double-view from creation (chart visible immediately),
+ // so seed bottomHeight with the chart default. Run cells stay single-
+ // view (no bottomHeight) until the user runs them.
+ if (resolvedMode === "draw") {
+ created.bottomHeight = DEFAULT_CHART_BOTTOM_HEIGHT
+ }
+ return created
+ })
+
+ if (nextCells.length === 0) {
+ throw new ApplyNotebookStateError(
+ "Request cells array is empty; a notebook must have at least one cell.",
+ "cells",
+ )
+ }
+
+ const deleted = prev.filter((c) => !seenIds.has(c.id)).map((c) => c.id)
+
+ return { nextCells, diff: { added, updated, deleted } }
+}
+
+// === Cell sizing model ======================================================
+// Cells are in one of two view states:
+//
+// - Single-view: only the editor (or only the expanded chart) is visible.
+// Total cell height = topHeight + chrome.
+// - Double-view: editor on top + result/chart on bottom.
+// Total cell height = topHeight + bottomHeight + chrome.
+//
+// `topHeight` and `bottomHeight` live on NotebookCell; they replace the four
+// `custom*Height` fields from the old model. In grid mode, the grid h (rows)
+// is *derived* from topHeight + bottomHeight on every render.
+// ============================================================================
+
+// Approximate fixed chrome around the editor in a cell, in pixels.
+// Breakdown:
+// - RunBar: 40 px (1rem vertical padding + ~24 px button glyph)
+// - CellWrapper top/bottom border: 2 px
+// - Inner-top ResizeHandle (only when double-view): 10 px
+// - Safety margin for sub-pixel rounding, grid row bottom-border,
+// and any single-pixel adornments inside the result panel: 4 px
+// Stays a single conservative constant rather than state-aware: the
+// extra ~14 px in single-view is at most a half-row of trailing
+// whitespace at our 10 px grid granularity — invisible — but avoids
+// the "row cut by 2-3 px" symptom in double-view cells.
+export const CELL_CHROME_PX = 56
+
+// Default editor height for a newly-created cell, before any content arrives.
+// Matches MIN_EDITOR_HEIGHT used by Monaco; kept here so layout math doesn't
+// need to import Cell.tsx constants.
+export const DEFAULT_TOP_HEIGHT = 72
+
+// Default chart height for draw mode (experimental — per user spec).
+export const DEFAULT_CHART_BOTTOM_HEIGHT = 350
+
+export const MIN_BOTTOM_HEIGHT_PX = 88
+
+// Pixel sizes of the result panel's chrome. Kept in sync with the styled-
+// components in result-table/styles.ts; if those constants change, update
+// here.
+const TAB_BAR_PX = 40 // TabBarWrapper height = 4rem
+const NOTIFICATION_PX = 44 // StatusNotification (compact=true → 4rem + 1-2 px borders)
+const GRID_HEADER_PX = 44 // result-table HEADER_HEIGHT
+const GRID_ROW_PX = 28 // result-table ROW_HEIGHT
+const MAX_RESERVED_ROWS = 10 // cap for "tight-fit" single-query results
+
+const isDqlWithRows = (r: SingleQueryResult): boolean =>
+ r.type === "dql" && r.dataset.length > 0
+
+const dqlRowCount = (r: SingleQueryResult): number =>
+ r.type === "dql" ? r.dataset.length : 0
+
+// Computes the bottom slot height for a result based on its content.
+//
+// Rules:
+// 1. Single-statement, no data (error / DDL / DML / notice / 0-row DQL):
+// just the notification bar — no wasted blank space.
+// 2. Single-statement DQL with N rows: notification + grid header + min(N, 10)
+// rows. Shrinks for small results, caps at 10 for large ones.
+// 3. Multi-statement (script) run:
+// - tab bar always visible.
+// - If the first result is non-data (error / DDL / DML / notice), height
+// is just tab bar + notification (the user never saw rows).
+// - Otherwise reserve a full 10 rows worth of space regardless of how
+// many rows the active tab actually has (avoids jitter when switching
+// between tabs that have different row counts).
+export const computeResultBottomHeight = (
+ result: CellResult | null | undefined,
+): number => {
+ if (!result || result.results.length === 0) return NOTIFICATION_PX
+ const isMulti = result.results.length > 1
+ const tabBar = isMulti ? TAB_BAR_PX : 0
+
+ if (isMulti) {
+ const first = result.results[0]
+ if (!first || !isDqlWithRows(first)) {
+ // First query failed / wasn't DQL — we never saw any rows, no point
+ // reserving 10 rows of space. The tab bar still shows so the user can
+ // click through other tabs.
+ return tabBar + NOTIFICATION_PX
+ }
+ return (
+ tabBar +
+ NOTIFICATION_PX +
+ GRID_HEADER_PX +
+ MAX_RESERVED_ROWS * GRID_ROW_PX
+ )
+ }
+
+ // Single-statement: tight-fit up to 10 rows.
+ const only = result.results[0]
+ if (!only || !isDqlWithRows(only)) {
+ return NOTIFICATION_PX
+ }
+ const rows = Math.min(MAX_RESERVED_ROWS, dqlRowCount(only))
+ return NOTIFICATION_PX + GRID_HEADER_PX + rows * GRID_ROW_PX
+}
+
+// Returns the appropriate default bottom-slot height for a cell, based on
+// what the bottom slot will contain. Used as the render-time fallback when
+// cell.bottomHeight is undefined (first paint of a freshly-loaded cell).
+// Always agrees with what runCell would have written into cell.bottomHeight.
+export const defaultBottomHeightFor = (cell: NotebookCell): number =>
+ cell.mode === "draw"
+ ? DEFAULT_CHART_BOTTOM_HEIGHT
+ : computeResultBottomHeight(cell.result)
+
+// True iff this cell occupies vertical space for a bottom slot — i.e. its
+// total height includes bottomHeight. This includes the chart-expanded case
+// (chart fills both slots; the cell footprint still spans topHeight +
+// bottomHeight).
+export const isDoubleView = (cell: NotebookCell): boolean => {
+ if (cell.mode === "draw") return true
+ return cell.result != null
+}
+
+// Derives the react-grid-layout `h` (row count) for a cell from its
+// topHeight + bottomHeight + chrome. Recomputed at render time on every
+// state change.
+//
+// react-grid-layout inserts `marginY` BETWEEN rows, so the actual
+// rendered px of an h-row cell is `h * rowHeight + (h - 1) * marginY`,
+// NOT `h * rowHeight`. To fit a content of `totalPx` we therefore need
+// `ceil((totalPx + marginY) / (rowHeight + marginY))` rows. Forgetting
+// the marginY term inflated cell heights by ~3× at rowHeight=10,
+// marginY=20 (a 500-px content asked for 50 rows that rendered as
+// ~1480 px). Default marginY=0 keeps backwards-compat for tests/callers
+// that ignore margins.
+export const computeCellGridH = (
+ cell: NotebookCell,
+ rowHeight: number,
+ marginY: number = 0,
+): number => {
+ const top = cell.topHeight ?? DEFAULT_TOP_HEIGHT
+ const bottom = isDoubleView(cell)
+ ? (cell.bottomHeight ?? defaultBottomHeightFor(cell))
+ : 0
+ const totalPx = CELL_CHROME_PX + top + bottom
+ return Math.max(1, Math.ceil((totalPx + marginY) / (rowHeight + marginY)))
+}
+
+export const partitionCellHeights = (
+ sum: number,
+ requestedTop: number,
+ minTop: number,
+ minBottom: number,
+): { top: number; bottom: number } => {
+ let top = Math.max(minTop, requestedTop)
+ let bottom = sum - top
+ if (bottom < minBottom) {
+ bottom = minBottom
+ top = sum - bottom
+ }
+ return { top, bottom }
+}
+
+export const scaleCellHeights = (
+ oldTop: number,
+ oldBottom: number,
+ newContent: number,
+ minTop: number,
+ minBottom: number,
+): { top: number; bottom: number } => {
+ const oldContent = oldTop + oldBottom
+ const clampedContent = Math.max(minTop + minBottom, newContent)
+ const scale = oldContent > 0 ? clampedContent / oldContent : 1
+ let top = Math.round(oldTop * scale)
+ let bottom = clampedContent - top
+ if (top < minTop) {
+ top = minTop
+ bottom = clampedContent - top
+ }
+ if (bottom < minBottom) {
+ bottom = minBottom
+ top = clampedContent - bottom
+ }
+ return { top, bottom }
+}
+
+export const buildAppliedLayout = (
+ request: ApplyRequest,
+ nextCells: NotebookCell[],
+ prevLayout: CellLayoutItem[] | undefined,
+ defaults: { gridCols: number; rowHeight: number; marginY?: number },
+): CellLayoutItem[] => {
+ const prevById = new Map((prevLayout ?? []).map((l) => [l.i, l]))
+ let nextY = 0
+ return nextCells.map((cell, i) => {
+ const req = request.cells[i]
+ if (req?.grid) {
+ const item = { i: cell.id, ...req.grid }
+ nextY = Math.max(nextY, req.grid.y + req.grid.h)
+ return item
+ }
+ const existing = prevById.get(cell.id)
+ if (existing) {
+ nextY = Math.max(nextY, existing.y + existing.h)
+ return existing
+ }
+ const cellH = computeCellGridH(cell, defaults.rowHeight, defaults.marginY)
+ const item: CellLayoutItem = {
+ i: cell.id,
+ x: 0,
+ y: nextY,
+ w: defaults.gridCols,
+ h: cellH,
+ }
+ nextY += cellH
+ return item
+ })
+}
+
+export const attachScriptSummary = (
+ cells: NotebookCell[],
+ cellId: string,
+ summary: NonNullable,
+): NotebookCell[] =>
+ cells.map((c) => {
+ if (c.id !== cellId || !c.result) return c
+ return { ...c, result: { ...c.result, script: summary } }
+ })
+
+export const COLUMN_SIZING_LRU_MAX = 20
+
+export const upsertColumnSizing = (
+ prev: NotebookCell["columnSizing"] | undefined,
+ key: string,
+ next: Record,
+ max: number = COLUMN_SIZING_LRU_MAX,
+): NotebookCell["columnSizing"] => {
+ const { [key]: _evicted, ...rest } = prev ?? {}
+ const merged: Record> = {
+ ...rest,
+ [key]: next,
+ }
+ const keys = Object.keys(merged)
+ if (keys.length <= max) return merged
+ const dropCount = keys.length - max
+ for (let i = 0; i < dropCount; i++) {
+ delete merged[keys[i]]
+ }
+ return merged
+}
diff --git a/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx b/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx
new file mode 100644
index 000000000..600424bd0
--- /dev/null
+++ b/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx
@@ -0,0 +1,144 @@
+import React from "react"
+import styled from "styled-components"
+import { color } from "../../../../utils"
+import { eventBus } from "../../../../modules/EventBus"
+import { EventType } from "../../../../modules/EventBus/types"
+
+const Handle = styled.div<{ $axis: string }>`
+ position: absolute;
+ z-index: 2;
+ outline: none;
+
+ &::after {
+ content: "";
+ position: absolute;
+ background: transparent;
+ transition: background 0.1s;
+ }
+
+ &:hover::after,
+ &:focus-visible::after,
+ &:active::after,
+ &:hover::before,
+ &:focus-visible::before,
+ &:active::before {
+ background: ${color("pinkDarker")};
+ }
+
+ ${({ $axis }) =>
+ $axis === "s" &&
+ `
+ bottom: -10px;
+ left: 10px;
+ right: 10px;
+ height: 20px;
+ cursor: ns-resize;
+ &::after {
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 6px;
+ }
+ `}
+
+ ${({ $axis }) =>
+ $axis === "e" &&
+ `
+ right: -10px;
+ top: 10px;
+ bottom: 10px;
+ width: 20px;
+ cursor: ew-resize;
+ &::after {
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 6px;
+ }
+ `}
+
+ ${({ $axis }) =>
+ $axis === "w" &&
+ `
+ left: -10px;
+ top: 10px;
+ bottom: 10px;
+ width: 20px;
+ cursor: ew-resize;
+ &::after {
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 6px;
+ }
+ `}
+
+ ${({ $axis }) =>
+ $axis === "se" &&
+ `
+ right: -10px;
+ bottom: -10px;
+ width: 20px;
+ height: 20px;
+ cursor: se-resize;
+ background: transparent;
+
+ &::before {
+ content: "";
+ position: absolute;
+ background: transparent;
+ transition: background 0.1s;
+ left: 50%;
+ top: 0;
+ width: 6px;
+ height: 50%;
+ transform: translateX(-50%);
+ }
+ &::after {
+ top: 50%;
+ left: 0;
+ width: 50%;
+ height: 6px;
+ transform: translateY(-50%);
+ }
+ `}
+`
+
+const dispatchAxisDoubleClick = (axis: string, target: HTMLElement): void => {
+ const cellEl = target.closest("[data-cell-id]")
+ const cellId = cellEl?.dataset.cellId
+ if (!cellId) return
+ if (axis === "s") {
+ eventBus.publish(EventType.NOTEBOOK_CELL_RESET_SIZE, { cellId })
+ return
+ }
+ if (axis === "e" || axis === "w") {
+ eventBus.publish(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, {
+ cellId,
+ kind: axis === "e" ? "right" : "left",
+ })
+ }
+}
+
+// Signature matches react-grid-layout's handleComponent prop.
+export const renderEdgeHandle = (axis: string, ref: React.Ref) => (
+ }
+ $axis={axis}
+ data-axis={axis}
+ role="separator"
+ aria-orientation={axis === "s" || axis === "se" ? "horizontal" : "vertical"}
+ aria-label={`Resize cell (${axis}) — drag to resize`}
+ tabIndex={0}
+ onDoubleClick={
+ axis === "s" || axis === "e" || axis === "w"
+ ? (e) => dispatchAxisDoubleClick(axis, e.currentTarget as HTMLElement)
+ : undefined
+ }
+ />
+)
+
+export const EdgeHandle = Handle
diff --git a/src/scenes/Editor/Notebook/resize/ResizeHandle.tsx b/src/scenes/Editor/Notebook/resize/ResizeHandle.tsx
new file mode 100644
index 000000000..90de10867
--- /dev/null
+++ b/src/scenes/Editor/Notebook/resize/ResizeHandle.tsx
@@ -0,0 +1,116 @@
+import React, { useCallback, useRef } from "react"
+import styled from "styled-components"
+import { color } from "../../../../utils"
+
+const Handle = styled.div<{ $background?: string; $doubleView?: boolean }>`
+ height: ${({ $doubleView }) => ($doubleView ? "6px" : "10px")};
+ cursor: ns-resize;
+ position: relative;
+ flex-shrink: 0;
+ background: ${({ $background }) => $background ?? color("backgroundLighter")};
+ outline: none;
+
+ &::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 100%;
+ transform: translateX(-50%) translateY(-50%);
+ width: 100%;
+ height: 50%;
+ transition: background 0.1s;
+ background: transparent;
+ }
+
+ &:hover::after,
+ &:focus-visible::after {
+ background: ${color("pinkDarker")};
+ }
+`
+
+type Props = {
+ targetRef: React.RefObject
+ onResize: (height: number) => void
+ onResizeEnd: (height: number) => void
+ onDoubleClick: () => void
+ minHeight?: number
+ background?: string
+ doubleView?: boolean
+}
+
+export const ResizeHandle: React.FC = ({
+ targetRef,
+ onResize,
+ onResizeEnd,
+ onDoubleClick,
+ minHeight = 48,
+ background,
+ doubleView,
+}) => {
+ const startYRef = useRef(0)
+ const startHeightRef = useRef(0)
+ const lastHeightRef = useRef(0)
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ startYRef.current = e.clientY
+
+ if (!targetRef.current) return
+ startHeightRef.current = targetRef.current.getBoundingClientRect().height
+ lastHeightRef.current = startHeightRef.current
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const delta = moveEvent.clientY - startYRef.current
+ const newHeight = Math.max(minHeight, startHeightRef.current + delta)
+ lastHeightRef.current = newHeight
+ onResize(newHeight)
+ }
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove)
+ document.removeEventListener("mouseup", handleMouseUp)
+ document.body.style.cursor = ""
+ document.body.style.userSelect = ""
+ onResizeEnd(lastHeightRef.current)
+ }
+
+ document.body.style.cursor = "ns-resize"
+ document.body.style.userSelect = "none"
+ document.addEventListener("mousemove", handleMouseMove)
+ document.addEventListener("mouseup", handleMouseUp)
+ },
+ [targetRef, onResize, onResizeEnd, minHeight],
+ )
+
+ // Enter/Space on a focused handle resets to default. This is the only
+ // keyboard-accessible interaction we offer — mouse-drag resizing has no
+ // ergonomic keyboard equivalent here, so don't pretend otherwise in
+ // the aria-label.
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ onDoubleClick()
+ }
+ },
+ [onDoubleClick],
+ )
+
+ return (
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/resize/index.ts b/src/scenes/Editor/Notebook/resize/index.ts
new file mode 100644
index 000000000..4b611aea7
--- /dev/null
+++ b/src/scenes/Editor/Notebook/resize/index.ts
@@ -0,0 +1,2 @@
+export { ResizeHandle } from "./ResizeHandle"
+export { EdgeHandle, renderEdgeHandle } from "./EdgeHandle"
diff --git a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx
new file mode 100644
index 000000000..8f1a2393d
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx
@@ -0,0 +1,64 @@
+import React from "react"
+import type { CellResult } from "../../../../store/notebook"
+import { ResultGrid } from "./ResultGrid"
+import { StatusNotification } from "./StatusNotification"
+import { TabBar } from "./TabBar"
+import { ResultWrapper, SuccessMessage } from "./styles"
+
+type Props = {
+ result: CellResult
+ isFocused?: boolean
+ onTabChange?: (index: number) => void
+ onCancelQuery?: (index: number) => void
+ columnSizing: Record> | undefined
+ onColumnSizingCommit: (sizing: Record, query: string) => void
+}
+
+export const InlineResultTable: React.FC = ({
+ result,
+ isFocused,
+ onTabChange,
+ onCancelQuery,
+ columnSizing,
+ onColumnSizingCommit,
+}) => {
+ if (result.results.length === 0) {
+ return (
+
+ OK
+
+ )
+ }
+
+ const activeResult =
+ result.results[result.activeResultIndex] ?? result.results[0]
+ const isMultiQuery = result.results.length > 1
+ const isActiveDql = activeResult?.type === "dql"
+
+ return (
+
+ {isMultiQuery && }
+
+ {activeResult && (
+
+ )}
+
+ {isActiveDql && activeResult && (
+
+ onColumnSizingCommit(sizing, activeResult.query)
+ }
+ />
+ )}
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/result-table/ResultGrid.tsx b/src/scenes/Editor/Notebook/result-table/ResultGrid.tsx
new file mode 100644
index 000000000..e26757aa2
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/ResultGrid.tsx
@@ -0,0 +1,366 @@
+import React, {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import {
+ useReactTable,
+ getCoreRowModel,
+ flexRender,
+ type ColumnDef,
+} from "@tanstack/react-table"
+
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+import type { DqlQueryResult } from "../../../../store/notebook"
+import {
+ computeColumnWidths,
+ isLeftAligned,
+ isTimestampColumn,
+ formatCellValue,
+ formatColumnType,
+} from "./inlineGridUtils"
+import { useGridKeyboardNav } from "./useGridKeyboardNav"
+import {
+ Cell,
+ ColResizer,
+ DatasetRow,
+ GridContainer,
+ HeaderCell,
+ HeaderName,
+ HeaderNameRow,
+ HeaderRow,
+ HeaderType,
+ HEADER_HEIGHT,
+ Row,
+ ROW_HEIGHT,
+ ScrollContainer,
+ StyledCopyButton,
+} from "./styles"
+
+// Typed ColumnMeta so `columnDef.meta.col` is narrowed instead of `any`.
+declare module "@tanstack/react-table" {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ interface ColumnMeta {
+ col?: ColumnDefinition
+ }
+}
+
+type Props = {
+ data: DqlQueryResult
+ isFocused?: boolean
+ initialColumnSizing?: Record
+ onColumnSizingCommit: (sizing: Record) => void
+}
+
+export const ResultGrid: React.FC = ({
+ data,
+ isFocused = true,
+ initialColumnSizing,
+ onColumnSizingCommit,
+}) => {
+ const gridRef = useRef(null)
+ const scrollRef = useRef(null)
+ const [containerWidth, setContainerWidth] = useState(800)
+ const [hoverRow, setHoverRow] = useState(null)
+ const [scrolledDown, setScrolledDown] = useState(false)
+ const [shadowLeft, setShadowLeft] = useState(false)
+
+ const handleScroll = useCallback(() => {
+ if (scrollRef.current) {
+ setScrolledDown(scrollRef.current.scrollTop > 0)
+ setShadowLeft(scrollRef.current.scrollLeft > 0)
+ }
+ }, [])
+
+ useLayoutEffect(() => {
+ if (gridRef.current) {
+ setContainerWidth(gridRef.current.getBoundingClientRect().width)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!gridRef.current) return
+ const observer = new ResizeObserver(([entry]) => {
+ setContainerWidth(entry.contentRect.width)
+ })
+ observer.observe(gridRef.current)
+ return () => observer.disconnect()
+ }, [])
+
+ const columnDefs = useMemo[]>(() => {
+ const widths = computeColumnWidths(
+ data.columns,
+ data.dataset,
+ containerWidth,
+ )
+ return data.columns.map((col, i) => ({
+ id: `col_${i}`,
+ accessorFn: (row: DatasetRow) => row[i],
+ header: col.name,
+ size: widths[i],
+ minSize: 60,
+ meta: { col },
+ }))
+ }, [data.columns, data.dataset, containerWidth])
+
+ const table = useReactTable({
+ data: data.dataset,
+ columns: columnDefs,
+ columnResizeMode: "onChange",
+ initialState: initialColumnSizing
+ ? { columnSizing: initialColumnSizing }
+ : undefined,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const headerGroups = table.getHeaderGroups()
+ const headers = headerGroups[0]?.headers ?? []
+ const rows = table.getRowModel().rows
+
+ const getData = useCallback(
+ (row: number, col: number) => data.dataset[row]?.[col] ?? null,
+ [data.dataset],
+ )
+
+ const scrollContextRef = useRef<{
+ scrollElement: HTMLElement
+ rowHeight: number
+ headerHeight: number
+ getColumnOffset: (col: number) => number
+ getColumnWidth: (col: number) => number
+ } | null>(null)
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollContextRef.current = {
+ scrollElement: scrollRef.current,
+ rowHeight: ROW_HEIGHT,
+ headerHeight: HEADER_HEIGHT,
+ getColumnOffset: (col: number) => {
+ let offset = 0
+ for (let i = 0; i < col; i++) {
+ offset += headers[i]?.getSize() ?? 0
+ }
+ return offset
+ },
+ getColumnWidth: (col: number) => headers[col]?.getSize() ?? 0,
+ }
+ }
+ }, [headers])
+
+ const { focusedCell, copyPulse, onCellClick, onKeyDown, onBlur } =
+ useGridKeyboardNav(
+ rows.length,
+ data.columns.length,
+ getData,
+ scrollContextRef,
+ )
+
+ const isCellFocused = (row: number, col: number) =>
+ focusedCell?.row === row && focusedCell?.col === col
+
+ const isCellPulsing = (row: number, col: number) =>
+ copyPulse?.row === row && copyPulse?.col === col
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: 5,
+ })
+
+ const columnSizing = table.getState().columnSizing
+ const isResizingColumn = !!table.getState().columnSizingInfo.isResizingColumn
+
+ const wasResizingRef = useRef(isResizingColumn)
+ useEffect(() => {
+ if (wasResizingRef.current && !isResizingColumn) {
+ onColumnSizingCommit(columnSizing)
+ }
+ wasResizingRef.current = isResizingColumn
+ }, [isResizingColumn, columnSizing, onColumnSizingCommit])
+
+ const columnVirtualizer = useVirtualizer({
+ horizontal: true,
+ count: headers.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: (index) => headers[index]?.getSize() ?? 100,
+ overscan: 3,
+ })
+
+ useEffect(() => {
+ columnVirtualizer.measure()
+ }, [columnSizing, columnVirtualizer])
+
+ const totalWidth = columnVirtualizer.getTotalSize()
+ const totalHeight = rowVirtualizer.getTotalSize()
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const virtualColumns = columnVirtualizer.getVirtualItems()
+
+ return (
+
+
+
+ {virtualColumns.map((virtualCol) => {
+ const header = headers[virtualCol.index]
+ if (!header) return null
+ const col = header.column.columnDef.meta?.col
+ const colType = col?.type ?? ""
+ const align = isLeftAligned(colType) ? "left" : "right"
+ return (
+
+
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+
+
+ {col ? formatColumnType(col) : colType}
+ {
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return
+ e.preventDefault()
+ const step = e.shiftKey ? 40 : 10
+ const delta = e.key === "ArrowRight" ? step : -step
+ const current = header.getSize()
+ const next = Math.max(60, current + delta)
+ const nextSizing = {
+ ...table.getState().columnSizing,
+ [header.column.id]: next,
+ }
+ table.setColumnSizing(nextSizing)
+ onColumnSizingCommit(nextSizing)
+ }}
+ role="separator"
+ aria-orientation="vertical"
+ aria-label={`Resize column ${col?.name ?? ""}`}
+ tabIndex={0}
+ />
+
+ )
+ })}
+
+
+
+ {virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+ if (!row) return null
+ const rowIndex = virtualRow.index
+ return (
+ setHoverRow(rowIndex)}
+ onMouseLeave={() => setHoverRow(null)}
+ style={{
+ position: "absolute",
+ top: virtualRow.start,
+ width: totalWidth,
+ }}
+ role="row"
+ aria-rowindex={rowIndex + 2}
+ >
+ {virtualColumns.map((virtualCol) => {
+ const colIdx = virtualCol.index
+ const cell = row.getVisibleCells()[colIdx]
+ if (!cell) return null
+ const col = cell.column.columnDef.meta?.col
+ const colType = col?.type ?? ""
+ const rawValue = data.dataset[rowIndex]?.[colIdx]
+ const colWidth = cell.column.getSize()
+ const displayValue = formatCellValue(
+ rawValue ?? null,
+ col,
+ colWidth,
+ )
+ const align = isLeftAligned(colType) ? "left" : "right"
+ const active = isCellFocused(rowIndex, colIdx)
+
+ return (
+ | onCellClick(rowIndex, colIdx)}
+ role="gridcell"
+ aria-colindex={colIdx + 1}
+ aria-selected={active}
+ >
+ {displayValue}
+ |
+ )
+ })}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx
new file mode 100644
index 000000000..66954f9a6
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx
@@ -0,0 +1,140 @@
+import React from "react"
+import { Stop } from "@styled-icons/remix-line"
+import { Queue } from "@phosphor-icons/react"
+import { Box, Text } from "../../../../components"
+import Notification from "../../../Notifications/Notification"
+import { NotificationType } from "../../../../store/Query/types"
+import type { CellResult, SingleQueryResult } from "../../../../store/notebook"
+import QueryResult from "../../QueryResult"
+import { CancelButton, LiveRegion, NotificationContainer } from "./styles"
+
+const liveRegionMessage = (result: SingleQueryResult): string => {
+ switch (result.type) {
+ case "running":
+ return "Query running"
+ case "queued":
+ return "Query queued"
+ case "cancelled":
+ return "Query cancelled by user"
+ case "error":
+ return `Query failed: ${result.error}`
+ case "dql":
+ return `Query succeeded: ${result.dataset.length} rows`
+ default:
+ return "Query succeeded"
+ }
+}
+
+type Props = {
+ timestamp: number
+ activeResult: SingleQueryResult
+ activeIndex: CellResult["activeResultIndex"]
+ onCancelQuery?: (index: number) => void
+}
+
+export const StatusNotification: React.FC = ({
+ timestamp,
+ activeResult,
+ activeIndex,
+ onCancelQuery,
+}) => {
+ const { type } = activeResult
+ const isError = type === "error"
+ const isCancelled = type === "cancelled"
+
+ const baseProps = {
+ query: "@0-0" as const,
+ createdAt: new Date(timestamp),
+ compact: true,
+ isMinimized: true,
+ }
+
+ let body: React.ReactElement
+ if (type === "running") {
+ body = (
+
+ Running...
+ {onCancelQuery && (
+ onCancelQuery(activeIndex)}
+ >
+
+
+ )}
+
+ }
+ type={NotificationType.LOADING}
+ />
+ )
+ } else if (type === "queued") {
+ body = (
+
+
+ Queued
+
+ }
+ type={NotificationType.INFO}
+ />
+ )
+ } else if (isCancelled) {
+ body = (
+ Cancelled by user}
+ type={NotificationType.ERROR}
+ />
+ )
+ } else if (isError) {
+ body = (
+ {activeResult.error}}
+ type={NotificationType.ERROR}
+ />
+ )
+ } else if (activeResult.type === "dql" && activeResult.timings) {
+ body = (
+
+ }
+ type={NotificationType.SUCCESS}
+ />
+ )
+ } else {
+ body = (
+ OK}
+ type={NotificationType.SUCCESS}
+ />
+ )
+ }
+
+ return (
+
+ {liveRegionMessage(activeResult)}
+ {body}
+
+ )
+}
diff --git a/src/scenes/Editor/Notebook/result-table/TabBar.tsx b/src/scenes/Editor/Notebook/result-table/TabBar.tsx
new file mode 100644
index 000000000..a5ecbbf64
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/TabBar.tsx
@@ -0,0 +1,81 @@
+import React from "react"
+import { CheckmarkOutline, CloseOutline } from "@styled-icons/evaicons-outline"
+import { Queue } from "@phosphor-icons/react"
+import type { CellResult, SingleQueryResult } from "../../../../store/notebook"
+import { LoadingIconSvg } from "../../Monaco/icons"
+import {
+ CancelledIcon,
+ Tab,
+ TabBarWrapper,
+ TabLabel,
+ TabSpinner,
+ TabStatusIcon,
+} from "./styles"
+
+const truncateQuery = (query: string, maxLen = 30): string => {
+ const oneLine = query.replace(/\s+/g, " ").trim()
+ return oneLine.length > maxLen
+ ? oneLine.substring(0, maxLen) + "..."
+ : oneLine
+}
+
+const StatusIcon: React.FC<{ type: SingleQueryResult["type"] }> = ({
+ type,
+}) => {
+ if (type === "running") {
+ return (
+
+
+
+ )
+ }
+ if (type === "queued") {
+ return (
+
+
+
+ )
+ }
+ if (type === "cancelled") {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {type === "error" ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+type Props = {
+ result: CellResult
+ onTabChange?: (index: number) => void
+}
+
+export const TabBar: React.FC = ({ result, onTabChange }) => (
+
+ {result.results.map((r, i) => (
+ onTabChange?.(i)}
+ title={r.query}
+ role="tab"
+ aria-selected={i === result.activeResultIndex}
+ >
+
+ {truncateQuery(r.query)}
+
+ ))}
+
+)
diff --git a/src/scenes/Editor/Notebook/result-table/index.ts b/src/scenes/Editor/Notebook/result-table/index.ts
new file mode 100644
index 000000000..b3b4127aa
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/index.ts
@@ -0,0 +1 @@
+export { InlineResultTable } from "./InlineResultTable"
diff --git a/src/scenes/Editor/Notebook/result-table/inlineGridUtils.test.ts b/src/scenes/Editor/Notebook/result-table/inlineGridUtils.test.ts
new file mode 100644
index 000000000..3ef6e39b0
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/inlineGridUtils.test.ts
@@ -0,0 +1,249 @@
+import { describe, it, expect } from "vitest"
+import {
+ computeColumnWidths,
+ formatCellValue,
+ formatCellValueForCopy,
+ formatColumnType,
+ isLeftAligned,
+ isTimestampColumn,
+} from "./inlineGridUtils"
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+
+const col = (
+ name: string,
+ type: string,
+ extra: Partial = {},
+): ColumnDefinition => ({ name, type, ...extra })
+
+describe("isLeftAligned", () => {
+ it("returns true for string-like types", () => {
+ expect(isLeftAligned("STRING")).toBe(true)
+ expect(isLeftAligned("SYMBOL")).toBe(true)
+ expect(isLeftAligned("VARCHAR")).toBe(true)
+ expect(isLeftAligned("ARRAY")).toBe(true)
+ })
+ it("returns true regardless of case", () => {
+ expect(isLeftAligned("string")).toBe(true)
+ expect(isLeftAligned("Symbol")).toBe(true)
+ })
+ it("returns false for numeric/timestamp/boolean types", () => {
+ expect(isLeftAligned("INT")).toBe(false)
+ expect(isLeftAligned("DOUBLE")).toBe(false)
+ expect(isLeftAligned("TIMESTAMP")).toBe(false)
+ expect(isLeftAligned("BOOLEAN")).toBe(false)
+ })
+})
+
+describe("isTimestampColumn", () => {
+ it("is true only for exact TIMESTAMP (case-insensitive)", () => {
+ expect(isTimestampColumn("TIMESTAMP")).toBe(true)
+ expect(isTimestampColumn("timestamp")).toBe(true)
+ expect(isTimestampColumn("DATE")).toBe(false)
+ expect(isTimestampColumn("")).toBe(false)
+ })
+})
+
+describe("formatColumnType", () => {
+ it("lowercases non-array types", () => {
+ expect(formatColumnType(col("x", "INT"))).toBe("int")
+ expect(formatColumnType(col("x", "TIMESTAMP"))).toBe("timestamp")
+ })
+
+ it("renders 1-D arrays as elemType[]", () => {
+ expect(
+ formatColumnType(col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" })),
+ ).toBe("double[]")
+ })
+
+ it("renders 2-D arrays as elemType[][]", () => {
+ expect(
+ formatColumnType(col("x", "ARRAY", { dim: 2, elemType: "DOUBLE" })),
+ ).toBe("double[][]")
+ })
+
+ it("renders dim>2 arrays with numeric dim form", () => {
+ expect(
+ formatColumnType(col("x", "ARRAY", { dim: 3, elemType: "double" })),
+ ).toBe("ARRAY(DOUBLE,3)")
+ })
+
+ it("falls back to 'unknown' when elemType is missing", () => {
+ expect(formatColumnType(col("x", "ARRAY", { dim: 1 }))).toBe("unknown[]")
+ })
+})
+
+describe("formatCellValue", () => {
+ it("returns 'null' for null", () => {
+ expect(formatCellValue(null)).toBe("null")
+ })
+
+ it("returns 'true'/'false' for booleans", () => {
+ expect(formatCellValue(true)).toBe("true")
+ expect(formatCellValue(false)).toBe("false")
+ })
+
+ it("returns string of number by default", () => {
+ expect(formatCellValue(42)).toBe("42")
+ expect(formatCellValue(3.14)).toBe("3.14")
+ })
+
+ it("adds .0 for integer-valued FLOAT/DOUBLE", () => {
+ expect(formatCellValue(5, col("x", "FLOAT"))).toBe("5.0")
+ expect(formatCellValue(5, col("x", "DOUBLE"))).toBe("5.0")
+ })
+
+ it("does not alter non-integer float values", () => {
+ expect(formatCellValue(5.2, col("x", "FLOAT"))).toBe("5.2")
+ })
+
+ it("does not apply float suffix to non-float types", () => {
+ expect(formatCellValue(5, col("x", "INT"))).toBe("5")
+ })
+
+ it("formats 1-D array values", () => {
+ expect(
+ formatCellValue(
+ [1, 2, 3] as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "INT" }),
+ ),
+ ).toBe("ARRAY[1,2,3]")
+ })
+
+ it("adds .0 to integer elements of float arrays", () => {
+ expect(
+ formatCellValue(
+ [1, 2] as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }),
+ ),
+ ).toBe("ARRAY[1.0,2.0]")
+ })
+
+ it("renders null arrays as 'null'", () => {
+ expect(
+ formatCellValue(null, col("x", "ARRAY", { dim: 1, elemType: "INT" })),
+ ).toBe("null")
+ })
+
+ it("truncates array content when columnWidth is tight", () => {
+ // columnWidth=200 → maxArrayTextLength = ceil(200/8.3) = 25, minus 7
+ // overhead = 18 chars of content, which is less than a 100-element
+ // integer array stringified.
+ const longArray = Array.from({ length: 100 }, (_, i) => i)
+ const out = formatCellValue(
+ longArray as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "INT" }),
+ 200,
+ )
+ expect(out.startsWith("ARRAY[")).toBe(true)
+ expect(out.endsWith("]")).toBe(true)
+ expect(out).toContain("...")
+ })
+
+ it("leaves array untruncated when columnWidth leaves ≤3 chars of content", () => {
+ // columnWidth=80 → maxContentLength drops to 3; the truncation branch is
+ // skipped (guard: maxContentLength > 3) and the full array is returned.
+ const longArray = Array.from({ length: 100 }, (_, i) => i)
+ const out = formatCellValue(
+ longArray as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "INT" }),
+ 80,
+ )
+ expect(out).not.toContain("...")
+ expect(out).toContain("99")
+ })
+
+ it("does not truncate when columnWidth is absent", () => {
+ const longArray = Array.from({ length: 50 }, (_, i) => i)
+ const out = formatCellValue(
+ longArray as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "INT" }),
+ )
+ expect(out).not.toContain("...")
+ })
+})
+
+describe("formatCellValueForCopy", () => {
+ it("returns 'null' for null", () => {
+ expect(formatCellValueForCopy(null)).toBe("null")
+ })
+
+ it("returns the same as formatCellValue for primitives", () => {
+ expect(formatCellValueForCopy(true)).toBe("true")
+ expect(formatCellValueForCopy(42)).toBe("42")
+ expect(formatCellValueForCopy("hi")).toBe("hi")
+ })
+
+ it("returns the full array without truncation", () => {
+ const longArray = Array.from({ length: 100 }, (_, i) => i)
+ const out = formatCellValueForCopy(
+ longArray as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "INT" }),
+ )
+ expect(out.startsWith("ARRAY[")).toBe(true)
+ expect(out.endsWith("]")).toBe(true)
+ expect(out).not.toContain("...")
+ expect(out).toContain("0,1,2")
+ expect(out).toContain("99")
+ })
+
+ it("preserves float suffix in copy form", () => {
+ const out = formatCellValueForCopy(
+ [1, 2] as unknown as number,
+ col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }),
+ )
+ expect(out).toBe("ARRAY[1.0,2.0]")
+ })
+})
+
+describe("computeColumnWidths", () => {
+ it("returns one entry per column", () => {
+ const columns = [col("a", "INT"), col("b", "STRING"), col("c", "DOUBLE")]
+ const widths = computeColumnWidths(columns, [], 1000)
+ expect(widths).toHaveLength(3)
+ })
+
+ it("respects the MIN_COLUMN_WIDTH floor", () => {
+ const widths = computeColumnWidths([col("a", "INT")], [], 1000)
+ expect(widths[0]).toBeGreaterThanOrEqual(60)
+ })
+
+ it("clamps at maxWidth (containerWidth * 0.5)", () => {
+ const longValue = "x".repeat(1000)
+ const widths = computeColumnWidths(
+ [col("long", "STRING")],
+ [[longValue]],
+ 1000,
+ )
+ expect(widths[0]).toBeLessThanOrEqual(500)
+ })
+
+ it("widens for longer data values", () => {
+ const widthShort = computeColumnWidths(
+ [col("a", "STRING")],
+ [["hi"]],
+ 1000,
+ )[0]
+ const widthLong = computeColumnWidths(
+ [col("a", "STRING")],
+ [["hello world this is longer"]],
+ 1000,
+ )[0]
+ expect(widthLong).toBeGreaterThan(widthShort)
+ })
+
+ it("includes header + type length when sizing", () => {
+ const narrow = computeColumnWidths([col("x", "INT")], [[1]], 1000)[0]
+ const wide = computeColumnWidths(
+ [col("extremely_long_column_name", "INT")],
+ [[1]],
+ 1000,
+ )[0]
+ expect(wide).toBeGreaterThan(narrow)
+ })
+
+ it("handles empty dataset", () => {
+ const widths = computeColumnWidths([col("a", "INT")], [], 1000)
+ expect(widths).toHaveLength(1)
+ expect(widths[0]).toBeGreaterThanOrEqual(60)
+ })
+})
diff --git a/src/scenes/Editor/Notebook/result-table/inlineGridUtils.ts b/src/scenes/Editor/Notebook/result-table/inlineGridUtils.ts
new file mode 100644
index 000000000..26d0a2a6f
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/inlineGridUtils.ts
@@ -0,0 +1,162 @@
+import type { ColumnDefinition } from "../../../../utils/questdb/types"
+
+const CELL_WIDTH_MULTIPLIER = 9.6
+const ARRAY_CELL_WIDTH_MULTIPLIER = 8.3
+const MIN_COLUMN_WIDTH = 60
+const MAX_WIDTH_RATIO = 0.5
+
+const LEFT_ALIGNED_TYPES = new Set(["STRING", "SYMBOL", "VARCHAR", "ARRAY"])
+
+const FLOAT_TYPES = new Set(["FLOAT", "DOUBLE"])
+
+const isArrayColumn = (col: ColumnDefinition): boolean => col.type === "ARRAY"
+
+const getCellWidth = (textLength: number, isArray = false): number => {
+ const multiplier = isArray
+ ? ARRAY_CELL_WIDTH_MULTIPLIER
+ : CELL_WIDTH_MULTIPLIER
+ return Math.max(MIN_COLUMN_WIDTH, Math.ceil(textLength * multiplier))
+}
+
+// Matches grid.js getArrayString: float .0 suffix for ints in float arrays, quotes stripped from strings.
+const getArrayString = (value: unknown, isFloatElem: boolean): string => {
+ const json = JSON.stringify(value, (_, val: unknown) => {
+ if (typeof val === "number" && isFloatElem && Number.isInteger(val)) {
+ return val.toString() + ".0"
+ }
+ return val
+ })
+ return json.replace(/"/g, "")
+}
+
+const formatArrayFull = (value: unknown, col: ColumnDefinition): string => {
+ if (value === null) return "null"
+ const dim = col.dim ?? 1
+ const isFloatElem =
+ col.elemType != null && FLOAT_TYPES.has(col.elemType.toUpperCase())
+
+ const arrayString = getArrayString(value, isFloatElem)
+ const content = arrayString.slice(dim, -dim)
+ return `ARRAY${"[".repeat(dim)}${content}${"]".repeat(dim)}`
+}
+
+// Matches grid.js getDisplayedCellValue: truncates content with "..." preserving ARRAY[...] structure.
+const formatArrayValue = (
+ value: unknown,
+ col: ColumnDefinition,
+ columnWidth?: number,
+): string => {
+ if (value === null) return "null"
+ const dim = col.dim ?? 1
+ const isFloatElem =
+ col.elemType != null && FLOAT_TYPES.has(col.elemType.toUpperCase())
+
+ const arrayString = getArrayString(value, isFloatElem)
+ const content = arrayString.slice(dim, -dim)
+ const full = `ARRAY${"[".repeat(dim)}${content}${"]".repeat(dim)}`
+
+ if (!columnWidth) return full
+
+ const maxArrayTextLength = Math.ceil(
+ columnWidth / ARRAY_CELL_WIDTH_MULTIPLIER,
+ )
+ // Subtract "ARRAY" (5) + opening brackets (dim) + closing brackets (dim).
+ const maxContentLength = maxArrayTextLength - (dim * 2 + 5)
+
+ if (content.length > maxContentLength && maxContentLength > 3) {
+ const truncated = content.slice(0, maxContentLength)
+ return `ARRAY${"[".repeat(dim)}${truncated}...${"[".repeat(0)}${"]".repeat(dim)}`
+ }
+
+ return full
+}
+
+export const computeColumnWidths = (
+ columns: ColumnDefinition[],
+ dataset: (boolean | string | number | null)[][],
+ containerWidth: number,
+): number[] => {
+ const maxWidth = containerWidth * MAX_WIDTH_RATIO
+ const maxTextLenRegular = Math.ceil(maxWidth / CELL_WIDTH_MULTIPLIER)
+ const maxTextLenArray = Math.ceil(maxWidth / ARRAY_CELL_WIDTH_MULTIPLIER)
+
+ return columns.map((col, colIdx) => {
+ const isArray = isArrayColumn(col)
+ const maxTextLen = isArray ? maxTextLenArray : maxTextLenRegular
+ const headerLen = col.name.length + formatColumnType(col).length
+ let w = getCellWidth(headerLen, isArray)
+
+ for (const row of dataset) {
+ const val = row[colIdx]
+ let displayLen: number
+ if (isArray) {
+ const formatted = formatArrayValue(val, col)
+ displayLen = Math.min(formatted.length, maxTextLen)
+ } else {
+ const formatted = formatCellValue(val, col)
+ displayLen = Math.min(formatted.length, maxTextLen)
+ }
+ w = Math.max(w, getCellWidth(displayLen, isArray))
+ if (w >= maxWidth) {
+ w = maxWidth
+ break
+ }
+ }
+ return Math.min(w, maxWidth)
+ })
+}
+
+export const isLeftAligned = (type: string): boolean =>
+ LEFT_ALIGNED_TYPES.has(type.toUpperCase())
+
+export const isTimestampColumn = (type: string): boolean =>
+ type.toUpperCase() === "TIMESTAMP"
+
+export const formatColumnType = (col: ColumnDefinition): string => {
+ if (col.type !== "ARRAY") {
+ return col.type.toLowerCase()
+ }
+ const dim = col.dim ?? 1
+ const elemType = col.elemType ?? "unknown"
+ if (dim > 2) {
+ return `ARRAY(${elemType.toUpperCase()},${dim})`
+ }
+ return elemType.toLowerCase() + "[]".repeat(dim)
+}
+
+export const formatCellValue = (
+ value: boolean | string | number | null,
+ col?: ColumnDefinition,
+ columnWidth?: number,
+): string => {
+ if (value === null) return "null"
+ if (typeof value === "boolean") return value ? "true" : "false"
+
+ if (col && isArrayColumn(col)) {
+ return formatArrayValue(value, col, columnWidth)
+ }
+
+ if (
+ col &&
+ typeof value === "number" &&
+ FLOAT_TYPES.has(col.type.toUpperCase()) &&
+ Number.isInteger(value)
+ ) {
+ return value.toFixed(1)
+ }
+
+ return String(value)
+}
+
+export const formatCellValueForCopy = (
+ value: boolean | string | number | null,
+ col?: ColumnDefinition,
+): string => {
+ if (value === null) return "null"
+
+ if (col && isArrayColumn(col)) {
+ return formatArrayFull(value, col)
+ }
+
+ return formatCellValue(value, col)
+}
diff --git a/src/scenes/Editor/Notebook/result-table/styles.ts b/src/scenes/Editor/Notebook/result-table/styles.ts
new file mode 100644
index 000000000..edacb085c
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/styles.ts
@@ -0,0 +1,318 @@
+import styled, { css, keyframes } from "styled-components"
+import { color } from "../../../../utils"
+import { Button } from "../../../../components"
+import { CopyButton } from "../../../../components/CopyButton"
+
+export type DatasetRow = (boolean | string | number | null)[]
+
+export const ROW_HEIGHT = 28
+export const HEADER_HEIGHT = 44
+
+export const ResultWrapper = styled.div`
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+`
+
+export const SuccessMessage = styled.div`
+ padding: 0.6rem 0.8rem;
+ color: ${color("green")};
+ font-size: ${({ theme }) => theme.fontSize.sm};
+ background: ${color("backgroundDarker")};
+`
+
+export const TabBarWrapper = styled.div`
+ display: flex;
+ flex-shrink: 0;
+ overflow-x: auto;
+ gap: 0;
+ height: 4rem;
+ border-top: 1px solid ${color("backgroundDarker")};
+
+ &::-webkit-scrollbar {
+ height: 0;
+ }
+`
+
+export const TabLabel = styled.span`
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`
+
+export const Tab = styled.button<{ $active: boolean }>`
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ border: none;
+ background: transparent;
+ color: ${color("gray2")};
+ cursor: pointer;
+ max-width: 20rem;
+ min-width: 15rem;
+ border-bottom: 2px solid transparent;
+
+ border-right: 1px solid ${color("selection")};
+ flex-shrink: 0;
+ gap: 0.8rem;
+ overflow: hidden;
+ position: relative;
+ transition: all 0.2s ease;
+
+ ${({ $active }) =>
+ $active &&
+ css`
+ color: ${color("foreground")};
+ background: ${color("selection")};
+ border-bottom: 2px solid ${color("pinkPrimary")};
+ `}
+
+ ${({ $active }) =>
+ !$active &&
+ css`
+ &:hover {
+ background: ${color("selectionDarker")};
+ border-bottom: 2px solid ${color("selection")};
+ }
+ `}
+`
+
+export const TabStatusIcon = styled.span<{ $success: boolean }>`
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ color: ${({ $success }) => ($success ? color("green") : color("red"))};
+`
+
+export const TabSpinner = styled.span`
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ animation: tab-spin 3s linear infinite;
+
+ @keyframes tab-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+`
+
+export const CancelledIcon = styled.span`
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ color: ${color("gray2")};
+`
+
+export const CancelButton = styled(Button)`
+ padding: 1.2rem 0.6rem;
+`
+
+export const NotificationContainer = styled.div`
+ border-top: 1px solid ${color("backgroundDarker")};
+ border-bottom: 1px solid ${color("backgroundDarker")};
+`
+
+export const LiveRegion = styled.div`
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+`
+
+export const GridContainer = styled.div<{
+ $shadowLeft: boolean
+}>`
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ outline: none;
+ font-size: ${({ theme }) => theme.fontSize.xs};
+ position: relative;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 6px;
+ background: linear-gradient(to right, rgba(0, 0, 0, 0.25), transparent);
+ z-index: 3;
+ pointer-events: none;
+ opacity: ${({ $shadowLeft }) => ($shadowLeft ? 1 : 0)};
+ transition: opacity 0.15s;
+ }
+`
+
+export const ScrollContainer = styled.div<{ $scrollable: boolean }>`
+ flex: 1;
+ overflow: ${({ $scrollable }) => ($scrollable ? "auto" : "hidden")};
+`
+
+export const HeaderRow = styled.div<{ $shadowBottom: boolean }>`
+ display: flex;
+ background: ${color("backgroundDarker")};
+ border-bottom: 1px solid ${color("selection")};
+ flex-shrink: 0;
+ height: ${HEADER_HEIGHT}px;
+ box-shadow: ${({ $shadowBottom }) =>
+ $shadowBottom ? "0 2px 4px rgba(0, 0, 0, 0.3)" : "none"};
+ transition: box-shadow 0.15s;
+`
+
+export const HeaderCell = styled.div<{ $align: string }>`
+ position: relative;
+ flex-shrink: 0;
+ padding: 0.5rem 1rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ user-select: none;
+ text-align: ${({ $align }) => $align};
+ border-right: 1px solid ${color("selection")};
+
+ &:hover .header-copy-btn,
+ .header-copy-btn[data-copied="true"] {
+ visibility: visible;
+ }
+`
+
+export const HeaderNameRow = styled.div<{ $align: string }>`
+ display: flex;
+ align-items: center;
+ flex-direction: ${({ $align }) =>
+ $align === "right" ? "row-reverse" : "row"};
+ justify-content: flex-start;
+ gap: 4px;
+`
+
+export const HeaderName = styled.span`
+ color: ${color("cyan")};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ font-size: 1.4rem;
+`
+
+export const HeaderType = styled.span`
+ color: ${color("gray2")};
+ font-size: 1rem;
+ white-space: nowrap;
+ text-transform: lowercase;
+`
+
+export const StyledCopyButton = styled(CopyButton)`
+ visibility: hidden;
+ flex-shrink: 0;
+ padding: 0;
+
+ &:hover {
+ background: transparent !important;
+ }
+`
+
+export const ColResizer = styled.div`
+ position: absolute;
+ right: -10px;
+ top: 0;
+ bottom: 0;
+ width: 20px;
+ cursor: col-resize;
+ touch-action: none;
+ user-select: none;
+ z-index: 2;
+
+ &::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 25%;
+ transform: translateX(-50%);
+ width: 5px;
+ height: 50%;
+ border-radius: 2px;
+ background: transparent;
+ transition: background 0.1s;
+ }
+
+ &:hover::after {
+ background: ${color("comment")};
+ }
+`
+
+export const Row = styled.div<{ $hover: boolean; $active: boolean }>`
+ display: flex;
+ height: ${ROW_HEIGHT}px;
+
+ ${({ $active }) =>
+ $active &&
+ css`
+ background: ${color("selection")};
+ `}
+
+ ${({ $hover, $active }) =>
+ $hover &&
+ !$active &&
+ css`
+ background: ${color("selectionDarker")};
+ `}
+`
+
+const pulseAnim = keyframes`
+ 0% { box-shadow: #8be9fd 0 0 0 1px; }
+ 75% { box-shadow: rgba(241, 250, 140, 0) 0 0 0 16px; }
+`
+
+export const Cell = styled.div<{
+ $isNull: boolean
+ $isTimestamp: boolean
+ $isActive: boolean
+ $isPulsing: boolean
+}>`
+ flex-shrink: 0;
+ height: ${ROW_HEIGHT}px;
+ line-height: ${ROW_HEIGHT}px;
+ padding: 0 0.6rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 1.3rem;
+ color: ${({ $isNull, $isTimestamp }) =>
+ $isNull ? "#939393" : $isTimestamp ? color("green") : color("foreground")};
+ border-right: 1px solid ${color("selectionDarker")};
+ border-bottom: 1px solid ${color("selectionDarker")};
+ box-sizing: border-box;
+
+ ${({ $isActive }) =>
+ $isActive &&
+ css`
+ background: ${color("tableSelection")};
+ box-shadow: inset 0 0 0 1px ${color("cyan")};
+ border-radius: 0.4rem;
+ `}
+
+ ${({ $isPulsing }) =>
+ $isPulsing &&
+ css`
+ animation: ${pulseAnim} 1s ease-out;
+ `}
+`
diff --git a/src/scenes/Editor/Notebook/result-table/useGridKeyboardNav.ts b/src/scenes/Editor/Notebook/result-table/useGridKeyboardNav.ts
new file mode 100644
index 000000000..fec4e6a0b
--- /dev/null
+++ b/src/scenes/Editor/Notebook/result-table/useGridKeyboardNav.ts
@@ -0,0 +1,174 @@
+import { useCallback, useState } from "react"
+import { copyToClipboard } from "../../../../utils/copyToClipboard"
+import { toast } from "../../../../components/Toast"
+import { formatCellValueForCopy } from "./inlineGridUtils"
+
+export type CellCoord = { row: number; col: number }
+
+type ScrollContext = {
+ scrollElement: HTMLElement
+ rowHeight: number
+ headerHeight: number
+ getColumnOffset: (col: number) => number
+ getColumnWidth: (col: number) => number
+}
+
+const scrollCellIntoView = (cell: CellCoord, ctx: ScrollContext) => {
+ const {
+ scrollElement,
+ rowHeight,
+ headerHeight,
+ getColumnOffset,
+ getColumnWidth,
+ } = ctx
+
+ // Vertical: cell positions are relative to body div which starts after header
+ // In scroll coordinates, cell top = headerHeight + row * rowHeight
+ const cellTop = headerHeight + cell.row * rowHeight
+ const cellBottom = cellTop + rowHeight
+ const viewTop = scrollElement.scrollTop + headerHeight
+ const viewBottom = scrollElement.scrollTop + scrollElement.clientHeight
+
+ if (cellTop < viewTop) {
+ scrollElement.scrollTop = cellTop - headerHeight
+ } else if (cellBottom > viewBottom) {
+ scrollElement.scrollTop = cellBottom - scrollElement.clientHeight
+ }
+
+ const cellLeft = getColumnOffset(cell.col)
+ const cellRight = cellLeft + getColumnWidth(cell.col)
+ const viewLeft = scrollElement.scrollLeft
+ const viewRight = scrollElement.scrollLeft + scrollElement.clientWidth
+
+ if (cellLeft < viewLeft) {
+ scrollElement.scrollLeft = cellLeft
+ } else if (cellRight > viewRight) {
+ scrollElement.scrollLeft = cellRight - scrollElement.clientWidth
+ }
+}
+
+export const useGridKeyboardNav = (
+ rowCount: number,
+ colCount: number,
+ getData: (row: number, col: number) => boolean | string | number | null,
+ scrollContextRef: React.RefObject,
+) => {
+ const [focusedCell, setFocusedCell] = useState(null)
+ const [copyPulse, setCopyPulse] = useState(null)
+
+ const moveTo = useCallback(
+ (row: number, col: number) => {
+ const next = { row, col }
+ setFocusedCell(next)
+ if (scrollContextRef.current) {
+ scrollCellIntoView(next, scrollContextRef.current)
+ }
+ },
+ [scrollContextRef],
+ )
+
+ const onCellClick = useCallback((row: number, col: number) => {
+ setFocusedCell({ row, col })
+ }, [])
+
+ const onBlur = useCallback(() => {
+ setFocusedCell(null)
+ }, [])
+
+ const onKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!focusedCell) return
+
+ const { row, col } = focusedCell
+
+ switch (e.key) {
+ case "ArrowUp":
+ e.preventDefault()
+ if (e.metaKey || e.ctrlKey) {
+ moveTo(0, col)
+ } else if (row > 0) {
+ moveTo(row - 1, col)
+ }
+ break
+ case "ArrowDown":
+ e.preventDefault()
+ if (e.metaKey || e.ctrlKey) {
+ moveTo(rowCount - 1, col)
+ } else if (row < rowCount - 1) {
+ moveTo(row + 1, col)
+ }
+ break
+ case "ArrowLeft":
+ e.preventDefault()
+ if (col > 0) moveTo(row, col - 1)
+ break
+ case "ArrowRight":
+ e.preventDefault()
+ if (col < colCount - 1) moveTo(row, col + 1)
+ break
+ case "Home":
+ e.preventDefault()
+ if (e.metaKey || e.ctrlKey) {
+ moveTo(0, 0)
+ } else {
+ moveTo(row, 0)
+ }
+ break
+ case "End":
+ e.preventDefault()
+ if (e.metaKey || e.ctrlKey) {
+ moveTo(rowCount - 1, colCount - 1)
+ } else {
+ moveTo(row, colCount - 1)
+ }
+ break
+ case "PageUp": {
+ e.preventDefault()
+ const ctx = scrollContextRef.current
+ if (ctx) {
+ const pageRows = Math.floor(
+ (ctx.scrollElement.clientHeight - ctx.headerHeight) /
+ ctx.rowHeight,
+ )
+ moveTo(Math.max(0, row - pageRows), col)
+ }
+ break
+ }
+ case "PageDown": {
+ e.preventDefault()
+ const ctx = scrollContextRef.current
+ if (ctx) {
+ const pageRows = Math.floor(
+ (ctx.scrollElement.clientHeight - ctx.headerHeight) /
+ ctx.rowHeight,
+ )
+ moveTo(Math.min(rowCount - 1, row + pageRows), col)
+ }
+ break
+ }
+ case "c":
+ if (e.metaKey || e.ctrlKey) {
+ e.preventDefault()
+ const value = getData(row, col)
+ const text = formatCellValueForCopy(value)
+ void copyToClipboard(text).then(() => {
+ toast.success("Copied to clipboard")
+ setCopyPulse({ row, col })
+ setTimeout(() => setCopyPulse(null), 1000)
+ })
+ }
+ break
+ }
+ },
+ [focusedCell, rowCount, colCount, getData, moveTo, scrollContextRef],
+ )
+
+ return {
+ focusedCell,
+ setFocusedCell,
+ copyPulse,
+ onCellClick,
+ onKeyDown,
+ onBlur,
+ }
+}
diff --git a/src/scenes/Editor/Notebook/useCellExecution.ts b/src/scenes/Editor/Notebook/useCellExecution.ts
new file mode 100644
index 000000000..6d2a86317
--- /dev/null
+++ b/src/scenes/Editor/Notebook/useCellExecution.ts
@@ -0,0 +1,265 @@
+import { useCallback, useEffect, useRef, useState } from "react"
+import type { MutableRefObject } from "react"
+import type {
+ CellResult,
+ NotebookCell,
+ SingleQueryResult,
+} from "../../../store/notebook"
+import type { QueryExecResult } from "../../../hooks/useQueryExecution"
+import { eventBus } from "../../../modules/EventBus"
+import { EventType } from "../../../modules/EventBus/types"
+import { getQueriesFromText } from "../Monaco/utils"
+import {
+ buildInitialScriptResults,
+ singleResultFromExec,
+} from "./notebookUtils"
+
+// Schema panel + completions listen for MSG_QUERY_SCHEMA to refresh.
+const publishSchemaIfMutating = (type: QueryExecResult["type"]): void => {
+ if (type === "ddl" || type === "dml") {
+ eventBus.publish(EventType.MSG_QUERY_SCHEMA)
+ }
+}
+
+type Options = {
+ cellsRef: MutableRefObject
+ executeSingle: (sql: string, signal?: AbortSignal) => Promise
+ updateCellResult: (
+ cellId: string,
+ index: number,
+ result: SingleQueryResult,
+ activeIndex?: number,
+ ) => void
+ updateCell: (cellId: string, updates: Partial) => void
+ updateCells: (updater: (prev: NotebookCell[]) => NotebookCell[]) => void
+ markCancelledAll: (cellId: string) => void
+ markCancelledOne: (cellId: string, index: number) => void
+ setScriptSummary: (
+ cellId: string,
+ summary: { successCount: number; failedCount: number; durationMs: number },
+ ) => void
+}
+
+export const useCellExecution = ({
+ cellsRef,
+ executeSingle,
+ updateCellResult,
+ updateCell,
+ updateCells,
+ markCancelledAll,
+ markCancelledOne,
+ setScriptSummary,
+}: Options) => {
+ const [runningCellIds, setRunningCellIds] = useState>(new Set())
+
+ const abortControllersRef = useRef