- {backLink && (
-
-
-
- {backLink.title}
+ {/* Title + actions row. Hidden for breadcrumb-only headers (e.g. the SQL
+ studio, which carries its own title bar and toolbar). */}
+ {!hideTitleRow && (
+
+ );
+}
diff --git a/frontend/src/components/pages/sql/sql-editor.test.tsx b/frontend/src/components/pages/sql/sql-editor.test.tsx
new file mode 100644
index 0000000000..c47563b58c
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-editor.test.tsx
@@ -0,0 +1,102 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { SqlEditor } from './sql-editor';
+
+// CodeMirror's layout/measure loop doesn't run in jsdom; the editor surface is
+// exercised manually/e2e.
+vi.mock('@uiw/react-codemirror', () => ({
+ default: ({ value }: { value: string }) =>
{value}
,
+}));
+
+const QUERY_1_TAB = /Query 1/;
+const QUERY_2_TAB = /Query 2/;
+// Matches the Run button's accessible name including its platform Kbd hint.
+const RUN_BUTTON = /Run (Ctrl|⌘)/;
+
+const renderEditor = (onRun = vi.fn()) => {
+ render();
+ return onRun;
+};
+
+function createMemoryStorage(): Storage {
+ const values = new Map();
+ return {
+ get length() {
+ return values.size;
+ },
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ key: (index: number) => [...values.keys()][index] ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, value),
+ };
+}
+
+describe('SqlEditor', () => {
+ beforeEach(() => {
+ vi.stubGlobal('localStorage', createMemoryStorage());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test('renders the first query tab as the active tab', () => {
+ renderEditor();
+ expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active');
+ expect(screen.getByTestId('editor')).toHaveTextContent('SELECT 1;');
+ });
+
+ test('adds a tab and switches back to the first', () => {
+ renderEditor();
+ fireEvent.click(screen.getByRole('button', { name: 'New query' }));
+ expect(screen.getByRole('tab', { name: QUERY_2_TAB })).toHaveAttribute('data-state', 'active');
+ expect(screen.getByTestId('editor')).toHaveTextContent('');
+
+ fireEvent.click(screen.getByRole('tab', { name: QUERY_1_TAB }));
+ expect(screen.getByTestId('editor')).toHaveTextContent('SELECT 1;');
+ });
+
+ test('closing a tab keeps the editor on a remaining tab', () => {
+ renderEditor();
+ fireEvent.click(screen.getByRole('button', { name: 'New query' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Close Query 2' }));
+ expect(screen.queryByRole('tab', { name: QUERY_2_TAB })).not.toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active');
+ });
+
+ test('clicking the close button does not activate the closed tab', () => {
+ renderEditor();
+ fireEvent.click(screen.getByRole('button', { name: 'New query' }));
+ fireEvent.click(screen.getByRole('tab', { name: QUERY_1_TAB }));
+ fireEvent.click(screen.getByRole('button', { name: 'Close Query 2' }));
+ expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active');
+ });
+
+ test('run sends the active tab SQL and records history', async () => {
+ const onRun = renderEditor();
+ fireEvent.click(screen.getByRole('button', { name: RUN_BUTTON }));
+ expect(onRun).toHaveBeenCalledWith('SELECT 1;');
+
+ fireEvent.click(screen.getByRole('button', { name: 'History' }));
+ expect(await screen.findByText('SELECT 1;', { selector: 'span' })).toBeInTheDocument();
+ });
+
+ test('history popover shows an empty state before any run', async () => {
+ renderEditor();
+ fireEvent.click(screen.getByRole('button', { name: 'History' }));
+ expect(await screen.findByText('No queries yet')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx
new file mode 100644
index 0000000000..a42e5113ef
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-editor.tsx
@@ -0,0 +1,597 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import {
+ acceptCompletion,
+ type CompletionContext,
+ type CompletionResult,
+ startCompletion,
+} from '@codemirror/autocomplete';
+import { PostgreSQL, type SQLNamespace, sql as sqlLanguage } from '@codemirror/lang-sql';
+import { HighlightStyle, indentUnit, syntaxHighlighting, syntaxTree } from '@codemirror/language';
+import { EditorState, type Extension, Prec } from '@codemirror/state';
+import { EditorView, keymap } from '@codemirror/view';
+import { tags } from '@lezer/highlight';
+import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
+import { Button } from 'components/redpanda-ui/components/button';
+import { Kbd, KbdGroup } from 'components/redpanda-ui/components/kbd';
+import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover';
+import { Tabs, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs';
+import { Text } from 'components/redpanda-ui/components/typography';
+import { FileText, History, Play, Plus, Terminal, Wand2, X } from 'lucide-react';
+import {
+ forwardRef,
+ type MouseEvent,
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from 'react';
+import { isMacOS } from 'utils/platform';
+import { z } from 'zod';
+
+import type { Catalog, TableRef } from './sql-types';
+
+// Imperative handle exposed to the workspace so the catalog tree can open a
+// query in a new editor tab (mirrors the prototype's editorRef).
+export type SqlEditorHandle = {
+ /** Open `sql` in a new tab named `name` (or "Query N") and focus it. */
+ setQuery: (sql: string, name?: string) => void;
+};
+
+export type SqlEditorProps = {
+ /** Run a statement (the current selection if any, else the whole tab). */
+ onRun: (sql: string) => void;
+ /** Loaded catalog tree; drives schema-aware autocomplete. */
+ catalogs: Catalog[];
+ /** SQL to seed the first tab with. */
+ initialQuery?: string;
+};
+
+const HISTORY_KEY = 'rp_sql_history_v1';
+
+const HistoryEntrySchema = z.object({ sql: z.string(), at: z.number() });
+
+type HistoryEntry = z.infer;
+
+function loadHistory(): HistoryEntry[] {
+ if (typeof localStorage === 'undefined') {
+ return [];
+ }
+ try {
+ const raw: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]');
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+ return raw.flatMap((entry) => {
+ const parsed = HistoryEntrySchema.safeParse(entry);
+ return parsed.success ? [parsed.data] : [];
+ });
+ } catch {
+ return [];
+ }
+}
+
+function saveHistory(list: HistoryEntry[]): void {
+ if (typeof localStorage === 'undefined') {
+ return;
+ }
+ try {
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, 40)));
+ } catch {
+ // best-effort; ignore quota/serialization failures
+ }
+}
+
+type Tab = { id: number; name: string; sql: string };
+
+const DEFAULT_QUERY =
+ 'SELECT vin, make, model, year, price_usd\nFROM default_redpanda_catalog=>cars\nWHERE in_stock = true\nORDER BY price_usd DESC\nLIMIT 100;';
+
+// Tracks the registry `.dark` class on the document root so the editor (whose
+// highlight palette is built from theme-invariant color scales, not Tailwind
+// classes) switches theme in lockstep with the rest of the surface. Uses
+// useSyncExternalStore — no effect — per project style.
+function subscribeToColorMode(onStoreChange: () => void): () => void {
+ const observer = new MutationObserver(onStoreChange);
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+ return () => observer.disconnect();
+}
+
+function getIsDarkSnapshot(): boolean {
+ return document.documentElement.classList.contains('dark');
+}
+
+function useIsDarkMode(): boolean {
+ return useSyncExternalStore(subscribeToColorMode, getIsDarkSnapshot, () => false);
+}
+
+// Editor chrome tuned to match the SQL Studio surface: transparent editor and
+// gutter so the surrounding `bg-background` container shows through, with
+// muted gutter line numbers. CodeMirror themes are plain CSS, so registry
+// custom properties can be referenced directly and stay live.
+function editorChrome(mode: 'light' | 'dark'): Extension {
+ const gutter = mode === 'dark' ? 'var(--color-grey-600)' : 'var(--color-grey-400)';
+ const gutterActive = mode === 'dark' ? 'var(--color-grey-400)' : 'var(--color-grey-600)';
+ return EditorView.theme(
+ {
+ '&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' },
+ '&.cm-focused': { outline: 'none' },
+ '.cm-scroller': {
+ fontFamily: "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
+ lineHeight: '21px',
+ },
+ '.cm-content': { padding: '12px 0' },
+ '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: gutter },
+ '.cm-activeLineGutter': { backgroundColor: 'transparent', color: gutterActive },
+ '.cm-activeLine': {
+ backgroundColor: mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)',
+ },
+ },
+ { dark: mode === 'dark' }
+ );
+}
+
+// SQL syntax palette, mapped from the design's `.sql-*` token classes onto the
+// Lezer highlight tags the SQL grammar emits (keywords, built-ins, strings,
+// numbers, comments, operators/punctuation and identifiers).
+function sqlHighlight(mode: 'light' | 'dark'): Extension {
+ const c =
+ mode === 'dark'
+ ? {
+ keyword: 'var(--color-purple-300)',
+ fn: 'var(--color-indigo-300)',
+ str: 'var(--color-green-300)',
+ num: 'var(--color-orange-300)',
+ comment: 'var(--color-grey-400)',
+ punct: 'var(--color-grey-500)',
+ id: 'var(--color-grey-100)',
+ }
+ : {
+ keyword: 'var(--color-purple-700)',
+ fn: 'var(--color-indigo-600)',
+ str: 'var(--color-green-700)',
+ num: 'var(--color-orange-700)',
+ comment: 'var(--color-grey-600)',
+ punct: 'var(--color-grey-500)',
+ id: 'var(--color-grey-900)',
+ };
+ return syntaxHighlighting(
+ HighlightStyle.define([
+ { tag: tags.keyword, color: c.keyword, fontWeight: 'bold' },
+ { tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], color: c.fn },
+ { tag: [tags.string, tags.special(tags.string)], color: c.str },
+ { tag: tags.number, color: c.num },
+ { tag: tags.comment, color: c.comment, fontStyle: 'italic' },
+ {
+ tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket],
+ color: c.punct,
+ },
+ { tag: tags.name, color: c.id },
+ ])
+ );
+}
+
+const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight('light')];
+const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight('dark')];
+
+function tableNamespace(table: TableRef): SQLNamespace {
+ return {
+ self: { label: table.name, type: 'class' },
+ children: (table.columns ?? []).map((col) => ({ label: col.name, type: 'property', detail: col.short })),
+ };
+}
+
+// Builds the lang-sql completion schema from the loaded catalog tree: bare
+// table names → columns. Tables are deliberately NOT nested under their
+// catalog — Redpanda SQL (Oxla) addresses catalog tables with arrow notation
+// (`catalog=>table`), which catalogArrowSource below handles; dot-style
+// nesting would advertise syntax the server rejects. Bare entries still give
+// alias/column resolution (`FROM default_redpanda_catalog=>cars c` → `c.`).
+function buildSchema(catalogs: Catalog[]): SQLNamespace {
+ const root: Record = {};
+ for (const catalog of catalogs) {
+ for (const ns of catalog.namespaces) {
+ for (const table of ns.tables) {
+ if (!(table.name in root)) {
+ root[table.name] = tableNamespace(table);
+ }
+ }
+ }
+ }
+ return root;
+}
+
+// Matches an identifier followed by `=>` or `.` and a partial table name,
+// anchored at the cursor: [, name, gap1, separator, gap2, quote, partial].
+const CATALOG_REF_RE = /([A-Za-z_][\w$]*)(\s*)(=>|\.)(\s*)("?)([\w$]*)$/;
+const COMMENT_OR_STRING_NODE_RE = /Comment|String/;
+const CATALOG_REF_BOUNDARY_RE = /[\w$".]/;
+const COMPLETION_IDENTIFIER_RE = /[\w$]+/;
+const VALID_COMPLETION_RE = /^[\w$]*$/;
+const AFTER_FROM_OR_JOIN_RE = /\b(?:from|join)\s+["\w$]*$/i;
+// Cursor sits immediately after `FROM `/`JOIN ` and a single trailing space —
+// the moment to auto-open the catalog (`catalog=>`) helper.
+const FROM_JOIN_TRIGGER_RE = /\b(?:from|join)\s$/i;
+
+function catalogTableCompletionResult(
+ catalog: Catalog,
+ ref: RegExpExecArray,
+ cursorPosition: number
+): CompletionResult {
+ const [, , gap1, separator, gap2, quote, partial] = ref;
+ const separatorFrom = cursorPosition - partial.length - quote.length - gap2.length - separator.length;
+
+ return {
+ from: cursorPosition - partial.length,
+ options: catalog.namespaces
+ .flatMap((namespace) => namespace.tables)
+ .map((table) => ({
+ label: table.name,
+ type: 'class',
+ boost: 50,
+ // Replace from the separator so a typed `.` (and any stray
+ // whitespace around it) is rewritten to `=>`.
+ apply: (view: EditorView, _completion: unknown, _from: number, to: number) => {
+ view.dispatch({
+ changes: { from: separatorFrom - gap1.length, to, insert: `=>${table.name}` },
+ });
+ },
+ })),
+ validFor: VALID_COMPLETION_RE,
+ };
+}
+
+function catalogNameCompletionResult(catalogs: Catalog[], from: number, before: string): CompletionResult {
+ const afterFromClause = AFTER_FROM_OR_JOIN_RE.test(before);
+ return {
+ from,
+ options: catalogs.map((catalog) => ({
+ label: catalog.name,
+ detail: '=>',
+ type: 'namespace',
+ boost: afterFromClause ? 60 : 0,
+ // Insert the arrow with the name and chain straight into the table list.
+ apply: (view: EditorView, _completion: unknown, applyFrom: number, to: number) => {
+ view.dispatch({
+ changes: { from: applyFrom, to, insert: `${catalog.name}=>` },
+ selection: { anchor: applyFrom + catalog.name.length + 2 },
+ });
+ startCompletion(view);
+ },
+ })),
+ validFor: VALID_COMPLETION_RE,
+ };
+}
+
+// Completion source for Redpanda SQL's catalog arrow notation. The generic
+// schema completion can't model `catalog=>table`, so this source:
+// - offers catalog names (boosted right after FROM/JOIN); applying one
+// inserts `catalog=>` and immediately reopens completion for its tables
+// - offers the catalog's tables after `catalog=>` — and after a typed
+// `catalog.`, rewriting the dot to `=>` so users land on valid syntax
+function catalogArrowSource(catalogs: Catalog[]): (context: CompletionContext) => CompletionResult | null {
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: CodeMirror completion context handling is branchy by API shape.
+ return (context) => {
+ const nodeName = syntaxTree(context.state).resolveInner(context.pos, -1).name;
+ if (COMMENT_OR_STRING_NODE_RE.test(nodeName)) {
+ return null;
+ }
+ const line = context.state.doc.lineAt(context.pos);
+ const before = line.text.slice(0, context.pos - line.from);
+
+ const ref = CATALOG_REF_RE.exec(before);
+ const cleanStart = ref ? !CATALOG_REF_BOUNDARY_RE.test(before[ref.index - 1] ?? '') : false;
+ const catalog = ref && cleanStart ? catalogs.find((c) => c.name === ref[1]) : undefined;
+ if (ref && catalog) {
+ return catalogTableCompletionResult(catalog, ref, context.pos);
+ }
+
+ // Offer catalogs once the caller is right after FROM/JOIN even with no
+ // partial typed yet, so the auto-trigger below can pop `catalog=>`.
+ const word = context.matchBefore(COMPLETION_IDENTIFIER_RE);
+ const afterFromClause = AFTER_FROM_OR_JOIN_RE.test(before);
+ if (!(word || context.explicit || afterFromClause)) {
+ return null;
+ }
+ // Skip when completing a dotted member (schema completion's territory).
+ const wordFrom = word ? word.from : context.pos;
+ if (before[wordFrom - line.from - 1] === '.') {
+ return null;
+ }
+ return catalogNameCompletionResult(catalogs, wordFrom, before);
+ };
+}
+
+// Reformats the whole document through sql-formatter (dynamically imported to
+// keep it out of the initial bundle; postgresql is the closest dialect to
+// Oxla) as a single transaction, so undo restores the pre-format text.
+async function formatDocument(view: EditorView): Promise {
+ const { format } = await import('sql-formatter');
+ const current = view.state.doc.toString();
+ let next: string;
+ try {
+ next = format(current, { language: 'postgresql', keywordCase: 'upper' });
+ } catch {
+ // Unparseable SQL (mid-edit) — leave the text untouched.
+ return;
+ }
+ if (next !== current) {
+ view.dispatch({
+ changes: { from: 0, to: current.length, insert: next },
+ selection: { anchor: Math.min(view.state.selection.main.head, next.length) },
+ });
+ }
+}
+
+function useNextTabId(initialId: number) {
+ const nextId = useRef(initialId);
+ return useCallback(() => {
+ const id = nextId.current;
+ nextId.current += 1;
+ return id;
+ }, []);
+}
+
+export const SqlEditor = forwardRef(
+ function SqlEditorComponent(editorProps, forwardedRef) {
+ const { onRun: runQuery, catalogs, initialQuery } = editorProps;
+ const [tabs, setTabs] = useState([{ id: 1, name: 'Query 1', sql: initialQuery ?? DEFAULT_QUERY }]);
+ const [activeId, setActiveId] = useState(1);
+ const nextTabId = useNextTabId(2);
+ const [history, setHistory] = useState(loadHistory);
+ const [histOpen, setHistOpen] = useState(false);
+ const [hasSel, setHasSel] = useState(false);
+ const isDark = useIsDarkMode();
+
+ const editorRef = useRef(null);
+ // Latest run callback, bound into the Cmd/Ctrl+Enter keymap (built once per
+ // catalog/theme change, not per render).
+ const runRef = useRef<() => void>(() => undefined);
+
+ const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ setQuery: (queryText: string, tabName?: string) => {
+ const id = nextTabId();
+ setTabs((prev) => [...prev, { id, name: tabName ?? `Query ${id}`, sql: queryText }]);
+ setActiveId(id);
+ requestAnimationFrame(() => editorRef.current?.view?.focus());
+ },
+ }),
+ [nextTabId]
+ );
+
+ const updateSql = (queryText: string) => {
+ setTabs((prev) => prev.map((t) => (t.id === activeId ? { ...t, sql: queryText } : t)));
+ };
+
+ const runText = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return;
+ }
+ const entry: HistoryEntry = { sql: trimmed, at: Date.now() };
+ const nh = [entry, ...history.filter((h) => h.sql !== entry.sql)].slice(0, 40);
+ setHistory(nh);
+ saveHistory(nh);
+ runQuery(trimmed);
+ };
+
+ // Run the current selection if any, else the whole tab.
+ const doRun = () => {
+ const state = editorRef.current?.view?.state;
+ const sel = state?.selection.main;
+ if (state && sel && !sel.empty) {
+ runText(state.sliceDoc(sel.from, sel.to));
+ return;
+ }
+ runText(active.sql);
+ };
+
+ // The Cmd/Ctrl+Enter keymap is part of the extensions array (rebuilt only on
+ // catalog/theme changes), so it reads fresh render state through this ref
+ // without an effect hook.
+ runRef.current = doRun;
+
+ const runSelection = () => {
+ const state = editorRef.current?.view?.state;
+ const sel = state?.selection.main;
+ if (state && sel && !sel.empty) {
+ runText(state.sliceDoc(sel.from, sel.to));
+ }
+ };
+
+ const extensions = useMemo(() => {
+ const sqlSupport = sqlLanguage({ dialect: PostgreSQL, schema: buildSchema(catalogs), upperCaseKeywords: true });
+ return [
+ // Prec.highest so Mod-Enter beats the default keymap's insertBlankLine.
+ Prec.highest(
+ keymap.of([
+ {
+ key: 'Mod-Enter',
+ run: () => {
+ runRef.current();
+ return true;
+ },
+ },
+ // Tab accepts an open completion (Monaco muscle memory); falls
+ // through to the default Tab behavior when no popup is open.
+ { key: 'Tab', run: acceptCompletion },
+ {
+ key: 'Shift-Alt-f',
+ run: (view) => {
+ formatDocument(view).catch(() => undefined);
+ return true;
+ },
+ },
+ ])
+ ),
+ sqlSupport,
+ sqlSupport.language.data.of({ autocomplete: catalogArrowSource(catalogs) }),
+ isDark ? DARK_THEME : LIGHT_THEME,
+ EditorView.updateListener.of((update) => {
+ if (update.selectionSet) {
+ setHasSel(!update.state.selection.main.empty);
+ }
+ // Auto-open the catalog helper the moment the caller finishes typing
+ // `FROM `/`JOIN ` (a typed space), so `catalog=>` is suggested without
+ // a manual Ctrl+Space. Guarded to typing events to avoid re-triggering
+ // on programmatic edits (formatting, tab seeding).
+ if (update.docChanged && update.transactions.some((tr) => tr.isUserEvent('input.type'))) {
+ const pos = update.state.selection.main.head;
+ const lineText = update.state.doc.lineAt(pos);
+ if (FROM_JOIN_TRIGGER_RE.test(lineText.text.slice(0, pos - lineText.from))) {
+ startCompletion(update.view);
+ }
+ }
+ }),
+ indentUnit.of(' '),
+ EditorState.tabSize.of(2),
+ ];
+ }, [catalogs, isDark]);
+
+ const addTab = () => {
+ const id = nextTabId();
+ setTabs((prev) => [...prev, { id, name: `Query ${id}`, sql: '' }]);
+ setActiveId(id);
+ };
+
+ const closeTab = (id: number, e: MouseEvent) => {
+ e.stopPropagation();
+ setTabs((prev) => {
+ const idx = prev.findIndex((t) => t.id === id);
+ const nextTabs = prev.filter((t) => t.id !== id);
+ if (nextTabs.length === 0) {
+ const nid = nextTabId();
+ setActiveId(nid);
+ return [{ id: nid, name: `Query ${nid}`, sql: '' }];
+ }
+ if (id === activeId) {
+ setActiveId(nextTabs[Math.max(0, idx - 1)].id);
+ }
+ return nextTabs;
+ });
+ };
+
+ return (
+
+ );
+ }
+);
diff --git a/frontend/src/components/pages/sql/sql-results.css b/frontend/src/components/pages/sql/sql-results.css
new file mode 100644
index 0000000000..4af55dd638
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-results.css
@@ -0,0 +1,26 @@
+/* react-data-grid theming for the SQL results grid.
+ rdg's stylesheet is unlayered, so Tailwind's layered utilities cannot
+ override the custom properties rdg declares on .rdg — these rules must
+ be unlayered too, and more specific than rdg's own selectors. */
+.rdg.sql-results-grid {
+ --rdg-font-size: 12px;
+ --rdg-color: var(--color-foreground);
+ --rdg-border-color: var(--color-border-subtle);
+ --rdg-background-color: var(--color-card);
+ --rdg-header-background-color: var(--color-muted);
+ --rdg-row-hover-background-color: var(--color-accent-subtle);
+ --rdg-selection-color: var(--color-action-primary);
+ border: none;
+ block-size: auto;
+ flex: 1;
+ min-block-size: 0;
+}
+
+.sql-results-grid .rdg-cell {
+ padding-inline: 16px;
+}
+
+/* Zebra stripe, applied per row via rowClass. */
+.sql-results-grid .sql-results-row-alt {
+ --rdg-background-color: var(--color-background-subtle);
+}
diff --git a/frontend/src/components/pages/sql/sql-results.test.tsx b/frontend/src/components/pages/sql/sql-results.test.tsx
new file mode 100644
index 0000000000..a374181ce7
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-results.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import userEvent from '@testing-library/user-event';
+import { render, screen } from 'test-utils';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+
+import { SqlResults } from './sql-results';
+import type { QueryRunSuccess, SqlRole } from './sql-types';
+
+// react-data-grid virtualizes rows and columns against the grid root's
+// measured size, which happy-dom reports as 0 — culling every column.
+// Give the .rdg root a real viewport so all columns and rows render;
+// other elements keep zero size so 'max-content' column measuring falls
+// back to minWidth instead of exploding past the viewport.
+const GRID_RECT = { width: 1920, height: 600 };
+
+beforeAll(() => {
+ const proto = HTMLDivElement.prototype;
+ const original = proto.getBoundingClientRect;
+ const isGridRoot = (el: Element) => el.classList.contains('rdg');
+
+ proto.getBoundingClientRect = function (this: HTMLDivElement) {
+ if (!isGridRoot(this)) {
+ return original.call(this);
+ }
+ return { ...original.call(this), width: GRID_RECT.width, height: GRID_RECT.height };
+ };
+ for (const [prop, value] of [
+ ['clientWidth', GRID_RECT.width],
+ ['clientHeight', GRID_RECT.height],
+ ['offsetWidth', GRID_RECT.width],
+ ['offsetHeight', GRID_RECT.height],
+ ] as const) {
+ Object.defineProperty(proto, prop, {
+ configurable: true,
+ get(this: HTMLDivElement) {
+ return isGridRoot(this) ? value : 0;
+ },
+ });
+ }
+ Reflect.set(proto, '__rdgRectRestore', original);
+});
+
+afterAll(() => {
+ const proto = HTMLDivElement.prototype;
+ const original = Reflect.get(proto, '__rdgRectRestore') as typeof proto.getBoundingClientRect;
+ proto.getBoundingClientRect = original;
+ for (const prop of ['clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight']) {
+ Reflect.deleteProperty(proto, prop);
+ }
+ Reflect.deleteProperty(proto, '__rdgRectRestore');
+});
+
+const LONG_VALUE = `{"payload":"${'x'.repeat(120)}"}`;
+const viewer: SqlRole = 'viewer';
+
+const run: QueryRunSuccess = {
+ state: 'success',
+ token: 1,
+ columns: [
+ { name: 'id', type: 'TEXT', kind: 'str', short: 'text' },
+ { name: 'doc', type: 'TEXT', kind: 'str', short: 'text' },
+ ],
+ rows: [{ id: 'row-1', doc: LONG_VALUE }],
+ totalRows: 1,
+ elapsedMs: 3,
+ truncated: false,
+};
+
+describe('SqlResults cell clamping', () => {
+ test('short values render as plain text without a click target', () => {
+ render();
+ const short = screen.getByText('row-1');
+ expect(short.closest('button')).toBeNull();
+ });
+
+ test('long values truncate and open the full value in a popover on click', async () => {
+ render();
+ const trigger = screen.getByRole('button', { name: LONG_VALUE });
+ expect(trigger.className).toContain('truncate');
+
+ await userEvent.click(trigger);
+ const occurrences = await screen.findAllByText(LONG_VALUE);
+ expect(occurrences.length).toBeGreaterThan(1);
+ });
+});
+
+describe('SqlResults create-table hint', () => {
+ test('admin create errors can open the add-topic wizard', async () => {
+ const onAddTable = vi.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add a topic to SQL' }));
+
+ expect(onAddTable).toHaveBeenCalledOnce();
+ });
+
+ test('viewer create errors do not show the add-topic action', () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: 'Add a topic to SQL' })).toBeNull();
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx
new file mode 100644
index 0000000000..f8af062c1b
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-results.tsx
@@ -0,0 +1,489 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert';
+import { Badge } from 'components/redpanda-ui/components/badge';
+import { Button } from 'components/redpanda-ui/components/button';
+import { SyncCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic';
+import { CopyButton } from 'components/redpanda-ui/components/copy-button';
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from 'components/redpanda-ui/components/empty';
+import { Kbd, KbdGroup } from 'components/redpanda-ui/components/kbd';
+import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover';
+import { Spinner } from 'components/redpanda-ui/components/spinner';
+import { StatusDot } from 'components/redpanda-ui/components/status-dot';
+import { InlineCode, Text } from 'components/redpanda-ui/components/typography';
+import { cn } from 'components/redpanda-ui/lib/utils';
+import { Braces, CircleX, Clock, Database, Download, GitMerge, Plus, Rows3, Terminal, X } from 'lucide-react';
+import { createContext, useContext, useMemo, useState } from 'react';
+import DataGrid, { type Column } from 'react-data-grid';
+import { isMacOS } from 'utils/platform';
+
+import 'react-data-grid/lib/styles.css';
+import './sql-results.css';
+
+import type {
+ BridgeInfo,
+ CellValue,
+ ColumnDef,
+ ColumnKind,
+ QueryRun,
+ QueryRunSuccess,
+ ResultRow,
+ SqlRole,
+} from './sql-types';
+
+export type SqlResultsProps = {
+ /** Current run state: idle | running | error | success. */
+ run: QueryRun;
+ /** Effective role; gates the admin "Add a topic" CTA on CREATE errors. */
+ sqlRole: SqlRole;
+ /** Admin entry point for the add-topic wizard. */
+ onAddTable?: () => void;
+ /** Whether the Redpanda catalog has any tables; drives the idle empty state. */
+ hasTables?: boolean;
+};
+
+const fmtNum = (n: number) => n.toLocaleString('en-US');
+const offStr = (n: number) => `${fmtNum(n)} offset${n === 1 ? '' : 's'}`;
+const CSV_ESCAPE_RE = /[",\n]/;
+const CSV_QUOTE_RE = /"/g;
+// Cells starting with these are interpreted as formulas by Excel/Sheets; prefix
+// with a single quote to neutralize CSV injection.
+const CSV_FORMULA_RE = /^[=+\-@]/;
+
+// Shared inline-stat layout used across the summary bar.
+const RES_STAT =
+ 'inline-flex items-center gap-1.5 text-xs text-foreground [font-variant-numeric:tabular-nums] [&_svg]:text-muted-foreground';
+
+// Bridge-query indicator shown in the summary bar.
+function BridgeBar() {
+ return (
+
+ Bridge query
+
+ );
+}
+
+function PendingStat({ count, label }: { count: number; label: string }) {
+ return (
+
+ {fmtNum(count)} {label}
+
+ );
+}
+
+// Caption explaining how far the live topic tail runs ahead of Iceberg for a
+// bridge query. The offset counts are the real metric snapshot captured at
+// query time. Renders nothing when Iceberg is fully caught up.
+function BridgeTimeline({ bridge }: { bridge: BridgeInfo }) {
+ if (bridge.totalLag === 0) {
+ return null;
+ }
+ return (
+
+
+ Bridge query covers {offStr(bridge.totalLag)} not yet in Iceberg at query time —{' '}
+ +{' '}
+ . Bridging serves them from the topic so results
+ stay realtime.
+
+
+ );
+}
+
+function cellText(v: CellValue): string {
+ return v === null || v === undefined ? '' : String(v);
+}
+
+// Cells are clamped to this width; values long enough to truncate at it
+// (~45 mono-xs chars) open the full value in a popover on click.
+const CELL_MAX_W = 'max-w-80';
+const CELL_CLAMP_CHARS = 45;
+// rdg header row height; also the top inset of the cell-popover clip overlay so
+// popovers slide behind the header rather than over it.
+const GRID_HEADER_H = 52;
+
+// Pretty-prints a JSON string with 2-space indent, falling back to the raw
+// value when it isn't parseable JSON.
+function prettyJson(raw: string): string {
+ try {
+ return JSON.stringify(JSON.parse(raw), null, 2);
+ } catch {
+ return raw;
+ }
+}
+
+// Cell popovers portal into a clip layer that covers the grid's data area
+// (below the sticky header), so they track the anchor cell and slide behind the
+// header — staying within the table section instead of floating over the page.
+const CellPopoverContainerContext = createContext(null);
+
+// Track the anchor and clip rather than reposition, so popovers stick to the
+// cell and disappear behind the header on scroll instead of pinning in place.
+const TRACK_AND_CLIP = { side: 'flip', align: 'none' } as const;
+
+// Rich viewer for JSON/composite cells: a header with the column name + type, a
+// copy button and a close affordance, over a syntax-highlighted, formatted body.
+function JsonCellPopover({ value, name, typeLabel }: { value: string; name: string; typeLabel: string }) {
+ const [open, setOpen] = useState(false);
+ const container = useContext(CellPopoverContainerContext);
+ const pretty = useMemo(() => prettyJson(value), [value]);
+ return (
+
+
+
+
+
+
+
+ {name}
+ ·
+ {typeLabel}
+
+
+
+
+
+
+
+ );
+}
+
+function CellContent({
+ v,
+ kind,
+ name,
+ typeLabel,
+}: {
+ v: CellValue;
+ kind: ColumnKind;
+ name: string;
+ typeLabel: string;
+}) {
+ const container = useContext(CellPopoverContainerContext);
+ if (kind === 'bool' && typeof v === 'boolean') {
+ return {String(v)};
+ }
+ if (v === null || v === undefined) {
+ return NULL;
+ }
+ const s = String(v);
+ if (kind === 'json') {
+ return ;
+ }
+ if (s.length <= CELL_CLAMP_CHARS) {
+ return {s};
+ }
+ return (
+
+
+
+
+
+ {s}
+
+
+ );
+}
+
+function exportData(fmt: 'csv' | 'json', cols: ColumnDef[], rows: ResultRow[]) {
+ let blob: Blob;
+ if (fmt === 'json') {
+ blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
+ } else {
+ const head = cols.map((c) => c.name).join(',');
+ const body = rows
+ .map((r) =>
+ cols
+ .map((c) => {
+ const raw = cellText(r[c.name]);
+ const s = CSV_FORMULA_RE.test(raw) ? `'${raw}` : raw;
+ return CSV_ESCAPE_RE.test(s) ? `"${s.replace(CSV_QUOTE_RE, '""')}"` : s;
+ })
+ .join(',')
+ )
+ .join('\n');
+ blob = new Blob([`${head}\n${body}`], { type: 'text/csv' });
+ }
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `query_result.${fmt}`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+// Stable grid keys: rows are stable object references from the run, so a
+// WeakMap gives each a consistent id for rowKeyGetter without an index key.
+function buildRowKeys(rows: ResultRow[]): WeakMap {
+ const map = new WeakMap();
+ rows.forEach((r, i) => {
+ map.set(r, i);
+ });
+ return map;
+}
+
+// Key for the synthetic row-number column; double underscores avoid
+// colliding with a real result column of the same name.
+const ROWNUM_KEY = '__rownum__';
+
+function buildColumns(cols: ColumnDef[]): Column[] {
+ const rowNum: Column = {
+ key: ROWNUM_KEY,
+ name: '',
+ frozen: true,
+ resizable: false,
+ width: 'max-content',
+ renderHeaderCell: () => #,
+ renderCell: ({ rowIdx }) => rowIdx + 1,
+ cellClass: 'text-right font-mono text-disabled text-xs [user-select:none]',
+ };
+ const dataCols = cols.map((c): Column => {
+ const alignRight = c.kind === 'num';
+ return {
+ key: c.name,
+ name: c.name,
+ // At least content-sized; spare panel width is shared between columns
+ // so the grid always fills horizontally.
+ width: 'minmax(max-content, 1fr)',
+ minWidth: 96,
+ // Headers stay left-aligned for every kind; only numeric cell values
+ // right-align (so digits line up) — keep the column name readable.
+ renderHeaderCell: () => (
+
+ {c.name}
+
+ {c.short}
+
+
+ ),
+ renderCell: ({ row }) => ,
+ cellClass: cn('font-mono text-xs', alignRight && 'text-right'),
+ };
+ });
+ return [rowNum, ...dataCols];
+}
+
+// Keyed by run.token from SqlResults, so a new run resets the grid's internal
+// state (scroll position, resized column widths) by remounting. Rows render
+// in server order; ordering is the query's job (ORDER BY), not the grid's.
+function SuccessGrid({ run }: { run: QueryRunSuccess }) {
+ const cols = run.columns;
+ const bridge = run.bridge;
+
+ // Stable references across re-renders (e.g. when the bridge lag query resolves
+ // and the workspace hands down a new `run` object with the same rows/columns),
+ // so DataGrid keeps user column widths and scroll position.
+ const columns = useMemo(() => buildColumns(cols), [cols]);
+ const rowKeys = useMemo(() => buildRowKeys(run.rows), [run.rows]);
+
+ // Overlay covering the grid's data area (below the sticky header) that cell
+ // popovers portal into; its `overflow-hidden` clips them to the table section
+ // and behind the header as the anchor row scrolls.
+ const [clipEl, setClipEl] = useState(null);
+
+ return (
+
+
+
+ {bridge ? : null}
+
+ {/* Virtualized grid: rdg renders only visible rows, so the full result
+ set is handed over with no client-side pagination. */}
+
+
+ );
+}
+
+export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlResultsProps) {
+ if (run.state === 'idle') {
+ // No tables in the catalog yet: nothing to query, so prompt the caller to
+ // create one from a topic. Admins get the wizard CTA; viewers get told who can.
+ if (!hasTables) {
+ return (
+
+
+
+
+
+ No tables yet
+
+ {sqlRole === 'admin'
+ ? 'Create a table from a Redpanda topic to start querying it with SQL.'
+ : 'Ask an admin to create a table from a Redpanda topic before you can query it with SQL.'}
+
+
+ {sqlRole === 'admin' && onAddTable ? (
+
+
+
+ ) : null}
+
+ );
+ }
+
+ const modKey = isMacOS() ? '⌘' : 'Ctrl';
+
+ return (
+
+
+
+
+
+ Run a query to see results
+
+ Write a SELECT against a table in the catalog, then press{' '}
+
+ {modKey}
+ ↵
+ {' '}
+ or hit Run.
+
+
+
+ );
+ }
+
+ if (run.state === 'running') {
+ return (
+
+
+
+
+
+ Running query…
+
+
+ );
+ }
+
+ if (run.state === 'error') {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts
new file mode 100644
index 0000000000..19f68d409d
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-types.test.ts
@@ -0,0 +1,64 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { describe, expect, test } from 'vitest';
+
+import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types';
+
+describe('columnKindForPgType', () => {
+ test.each([
+ ['INT8', 'num'],
+ ['BIGINT', 'num'],
+ ['NUMERIC(10,2)', 'num'],
+ ['BOOL', 'bool'],
+ ['TIMESTAMPTZ', 'time'],
+ ['TIMESTAMP', 'time'],
+ // INTERVAL/POINT contain the INT substring but must not read as numeric.
+ ['INTERVAL', 'time'],
+ ['POINT', 'str'],
+ ['JSON', 'json'],
+ ['JSONB', 'json'],
+ ['TEXT', 'str'],
+ ['UNKNOWN_TYPE', 'str'],
+ // Composite columns arrive pre-labelled as "json"/"json[]" from the backend.
+ ['json', 'json'],
+ ['json[]', 'json'],
+ ] as const)('%s → %s', (pgType, kind) => {
+ expect(columnKindForPgType(pgType)).toBe(kind);
+ });
+
+ test.each([
+ ['TEXT[]', 'str'],
+ ['_INT4', 'num'],
+ ['JSONB[]', 'json'],
+ ['ARRAY', 'str'],
+ ['LIST', 'time'],
+ ] as const)('array %s maps to element kind %s', (pgType, kind) => {
+ expect(columnKindForPgType(pgType)).toBe(kind);
+ });
+});
+
+describe('arrayElementPgType', () => {
+ test.each([
+ ['TEXT[]', 'TEXT'],
+ ['_INT4', 'INT4'],
+ ['ARRAY', 'STRING'],
+ ['list', 'double'],
+ ] as const)('%s unwraps to %s', (pgType, element) => {
+ expect(arrayElementPgType(pgType)).toBe(element);
+ });
+
+ test('non-array types return null', () => {
+ expect(arrayElementPgType('TEXT')).toBeNull();
+ expect(isArrayPgType('TEXT')).toBe(false);
+ expect(isArrayPgType('TEXT[]')).toBe(true);
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts
new file mode 100644
index 0000000000..6ed007962f
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-types.ts
@@ -0,0 +1,165 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+// Shared types for the SQL workspace, so the leaf components (catalog tree,
+// editor, results) and the data layer in sql-workspace agree on shape without
+// importing from each other.
+
+export type Catalog = {
+ /** SQL identifier, e.g. `default_redpanda_catalog`. */
+ name: string;
+ /** Human-friendly label shown in the tree. */
+ displayLabel: string;
+ /** Backing engine — drives the glyph/color in the tree. */
+ engine: CatalogEngine;
+ namespaces: Namespace[];
+};
+
+export type CatalogEngine = 'redpanda' | 'iceberg';
+
+export type Namespace = {
+ name: string;
+ /** Stable id used for expand/collapse + pagination state. */
+ id: string;
+ tables: TableRef[];
+};
+
+export type TableRef = {
+ /** Stable id, typically `..`. */
+ id: string;
+ name: string;
+ namespaceName: string;
+ catalogName: string;
+ /** Backing Kafka topic, when the table is topic-backed. */
+ topicName?: string;
+ /** True when this Redpanda-catalog table is also Iceberg-tiered (bridge query). */
+ tiered?: boolean;
+ /** True when the catalog engine is Iceberg (dedicated Iceberg table). */
+ iceberg?: boolean;
+ /** False when the caller lacks a SELECT grant — rendered locked/disabled. */
+ allowed?: boolean;
+ /** Columns from DescribeTable; undefined until the table is expanded/fetched. */
+ columns?: ColumnDef[];
+};
+
+// Logical kind derived from the Postgres type name, used for alignment and
+// cell rendering.
+export type ColumnKind = 'num' | 'str' | 'bool' | 'time' | 'json';
+
+export type ColumnDef = {
+ name: string;
+ /** Raw Postgres type name as reported by the driver (e.g. "INT8", "TEXT"). */
+ type: string;
+ /** Derived display kind. For arrays this is the element kind. */
+ kind: ColumnKind;
+ /** Short label shown under the column name — the type name lower-cased. */
+ short: string;
+ /** True for array types (e.g. "TEXT[]", "_INT4", "ARRAY"). */
+ isArray?: boolean;
+};
+
+// A single result cell. `null` is SQL NULL; everything else is the raw string
+// (or coerced boolean) for display.
+export type CellValue = string | boolean | null;
+
+// A result row keyed by column name.
+export type ResultRow = Record;
+
+// Iceberg-lag snapshot for a bridge query. Offset-based, captured at query time.
+export type BridgeInfo = {
+ topic: string;
+ translationLag: number;
+ commitLag: number;
+ totalLag: number;
+};
+
+type QueryRunIdle = { state: 'idle' };
+type QueryRunRunning = { state: 'running'; token: number };
+type QueryRunError = {
+ state: 'error';
+ token: number;
+ title: string;
+ message: string;
+ /** Optional follow-up hint line (e.g. for CREATE → wizard). */
+ hint?: string;
+ /** When true and the caller is an admin, render the "Add a topic" CTA. */
+ hintAction?: boolean;
+};
+export type QueryRunSuccess = {
+ state: 'success';
+ token: number;
+ columns: ColumnDef[];
+ rows: ResultRow[];
+ totalRows: number;
+ elapsedMs: number;
+ /** True when the server row cap fired. */
+ truncated: boolean;
+ /** Present only for bridge (Iceberg-tiered) queries. */
+ bridge?: BridgeInfo;
+};
+
+export type QueryRun = QueryRunIdle | QueryRunRunning | QueryRunError | QueryRunSuccess;
+
+// Drives admin-only affordances (e.g. the "Add a topic" CTA).
+export type SqlRole = 'admin' | 'viewer';
+
+// Unwraps one level of array syntax — "TEXT[]", "_TEXT" (pg wire naming), or
+// "ARRAY"/"LIST" (Iceberg) — returning the element type, or null
+// when the type is not an array.
+const ARRAY_WRAPPER_RE = /^(?:ARRAY|LIST)\s*<(.+)>$/i;
+
+export function arrayElementPgType(pgType: string): string | null {
+ const t = pgType.trim();
+ if (t.endsWith('[]')) {
+ return t.slice(0, -2);
+ }
+ if (t.startsWith('_')) {
+ return t.slice(1);
+ }
+ const wrapped = ARRAY_WRAPPER_RE.exec(t);
+ return wrapped ? wrapped[1] : null;
+}
+
+export function isArrayPgType(pgType: string): boolean {
+ return arrayElementPgType(pgType) !== null;
+}
+
+// Maps a Postgres type name to a display kind. Composite columns arrive as the
+// literal "json"/"json[]" (the backend parses structure into Column.fields), so
+// they fall through to the JSON branch. Arrays map to their element kind;
+// anything unrecognized defaults to a string.
+export function columnKindForPgType(pgType: string): ColumnKind {
+ const element = arrayElementPgType(pgType);
+ if (element !== null) {
+ return columnKindForPgType(element);
+ }
+ const t = pgType.toUpperCase();
+ // Temporal first: INTERVAL would otherwise match the INT substring in NUMERIC.
+ if (TEMPORAL_TYPE.test(t)) {
+ return 'time';
+ }
+ if (NUMERIC_TYPE.test(t)) {
+ return 'num';
+ }
+ if (BOOL_TYPE.test(t)) {
+ return 'bool';
+ }
+ if (t.includes('JSON')) {
+ return 'json';
+ }
+ return 'str';
+}
+
+// Word-boundary anchored so geometric/temporal names that merely contain a
+// numeric token (POINT → INT, INTERVAL → INT) don't get misread as numeric.
+const NUMERIC_TYPE = /\b(?:INT|INTEGER|SMALLINT|BIGINT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/;
+const BOOL_TYPE = /BOOL/;
+const TEMPORAL_TYPE = /(TIMESTAMP|DATE|TIME|INTERVAL)/;
diff --git a/frontend/src/components/pages/sql/sql-wizard.test.tsx b/frontend/src/components/pages/sql/sql-wizard.test.tsx
new file mode 100644
index 0000000000..45e6d15e6b
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-wizard.test.tsx
@@ -0,0 +1,141 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import userEvent from '@testing-library/user-event';
+import { render, screen } from 'test-utils';
+import { describe, expect, test, vi } from 'vitest';
+
+import { SqlWizard, type SqlWizardProps, type WizardTopic } from './sql-wizard';
+
+vi.mock('components/redpanda-ui/components/code-block-dynamic', () => ({
+ SyncCodeBlock: ({ code }: { code: string }) =>
{code}
,
+}));
+
+const TOPICS: WizardTopic[] = [
+ { name: 'orders', partitions: 12, format: 'AVRO' },
+ { name: 'cars-telemetry.v1', partitions: 3, iceberg: true },
+];
+const ORDERS_RE = /orders/;
+const CARS_TELEMETRY_RE = /cars-telemetry/;
+const CREATE_TABLE_RE = /Create table/;
+const TABLE_NAME_ERROR_RE = /Use lowercase letters, numbers and underscores/;
+const QUERIES_ARE_RE = /Queries are/;
+const BRIDGED_RE = /bridged/;
+
+const renderWizard = (overrides: Partial = {}) => {
+ const props: SqlWizardProps = {
+ topics: TOPICS,
+ onClose: vi.fn(),
+ onCreate: vi.fn(),
+ ...overrides,
+ };
+ render();
+ return props;
+};
+
+const pickTopicAndContinue = async (topicName: string) => {
+ await userEvent.click(screen.getByRole('radio', { name: new RegExp(topicName) }));
+ await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
+};
+
+describe('SqlWizard', () => {
+ test('lists topics with details and filters them by search', async () => {
+ renderWizard();
+
+ expect(screen.getByRole('radio', { name: ORDERS_RE })).toBeInTheDocument();
+ expect(screen.getByText('12 partitions · AVRO')).toBeInTheDocument();
+
+ await userEvent.type(screen.getByPlaceholderText('Search topics'), 'cars');
+
+ expect(screen.queryByRole('radio', { name: ORDERS_RE })).toBeNull();
+ expect(screen.getByRole('radio', { name: CARS_TELEMETRY_RE })).toBeInTheDocument();
+ });
+
+ test('shows an empty message when no topic matches the search', async () => {
+ renderWizard();
+
+ await userEvent.type(screen.getByPlaceholderText('Search topics'), 'nope');
+
+ expect(screen.getByText('No topics found.')).toBeInTheDocument();
+ expect(screen.queryByRole('radio')).toBeNull();
+ });
+
+ test('continue is disabled until a topic is selected', async () => {
+ renderWizard();
+
+ expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
+
+ await userEvent.click(screen.getByRole('radio', { name: ORDERS_RE }));
+
+ expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled();
+ });
+
+ test('prefills a sanitized table name from the topic and previews the SQL', async () => {
+ renderWizard();
+
+ await pickTopicAndContinue('cars-telemetry');
+
+ expect(screen.getByLabelText('Table name')).toHaveValue('cars_telemetry_v1');
+ expect(screen.getByTestId('sql-preview')).toHaveTextContent(
+ "CREATE TABLE default_redpanda_catalog=>cars_telemetry_v1 WITH (topic='cars-telemetry.v1');"
+ );
+ });
+
+ test('rejects an invalid table name and does not create', async () => {
+ const { onCreate } = renderWizard();
+
+ await pickTopicAndContinue('orders');
+ await userEvent.clear(screen.getByLabelText('Table name'));
+ await userEvent.type(screen.getByLabelText('Table name'), 'Bad Name');
+ await userEvent.click(screen.getByRole('button', { name: CREATE_TABLE_RE }));
+
+ expect(screen.getByText(TABLE_NAME_ERROR_RE)).toBeInTheDocument();
+ expect(onCreate).not.toHaveBeenCalled();
+ });
+
+ test('creates the table with the chosen topic and edited name', async () => {
+ const { onCreate } = renderWizard();
+
+ await pickTopicAndContinue('orders');
+ await userEvent.clear(screen.getByLabelText('Table name'));
+ await userEvent.type(screen.getByLabelText('Table name'), 'orders_table');
+ await userEvent.click(screen.getByRole('button', { name: CREATE_TABLE_RE }));
+
+ expect(onCreate).toHaveBeenCalledWith({ topic: 'orders', tableName: 'orders_table' });
+ });
+
+ test('shows the iceberg badge and bridged-query notice for iceberg topics', async () => {
+ renderWizard();
+
+ expect(screen.getByTitle('Iceberg tiering enabled')).toBeInTheDocument();
+
+ await pickTopicAndContinue('cars-telemetry');
+
+ expect(screen.getByText(QUERIES_ARE_RE)).toBeInTheDocument();
+ expect(screen.getByText(BRIDGED_RE)).toBeInTheDocument();
+ });
+
+ test('renders the creation error from the parent', async () => {
+ renderWizard({ error: 'table already exists' });
+
+ await pickTopicAndContinue('orders');
+
+ expect(screen.getByRole('alert')).toHaveTextContent('table already exists');
+ });
+
+ test('close button calls onClose', async () => {
+ const { onClose } = renderWizard();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql-wizard.tsx b/frontend/src/components/pages/sql/sql-wizard.tsx
new file mode 100644
index 0000000000..1d098b7100
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql-wizard.tsx
@@ -0,0 +1,293 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert';
+import { Badge } from 'components/redpanda-ui/components/badge';
+import { Button } from 'components/redpanda-ui/components/button';
+import {
+ Choicebox,
+ ChoiceboxItem,
+ ChoiceboxItemContent,
+ ChoiceboxItemHeader,
+ ChoiceboxItemIndicator,
+ ChoiceboxItemSubtitle,
+ ChoiceboxItemTitle,
+} from 'components/redpanda-ui/components/choicebox';
+import { SyncCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic';
+import { Field, FieldDescription, FieldError, FieldLabel } from 'components/redpanda-ui/components/field';
+import { Input } from 'components/redpanda-ui/components/input';
+import { Label } from 'components/redpanda-ui/components/label';
+import { Progress } from 'components/redpanda-ui/components/progress';
+import { InlineCode, Text } from 'components/redpanda-ui/components/typography';
+import { GitBranch, GitMerge, Layers, Plus, X } from 'lucide-react';
+import { type ReactNode, useState } from 'react';
+import { Controller, type UseFormReturn, useForm, useWatch } from 'react-hook-form';
+import { z } from 'zod';
+
+export type WizardTopic = {
+ name: string;
+ partitions?: number;
+ format?: string;
+ iceberg?: boolean;
+};
+
+export type SqlWizardProps = {
+ topics: WizardTopic[];
+ onClose: () => void;
+ onCreate: (args: { topic: string; tableName: string }) => void;
+ isCreating?: boolean;
+ error?: string;
+};
+
+const CATALOG_NAME = 'default_redpanda_catalog';
+const STEPS = ['Choose a topic', 'Name the table'] as const;
+const TABLE_NAME_RE = /^[a-z_][a-z0-9_]*$/;
+
+const formSchema = z.object({
+ tableName: z
+ .string()
+ .min(1, 'Table name is required.')
+ .regex(TABLE_NAME_RE, 'Use lowercase letters, numbers and underscores; must start with a letter or underscore.'),
+});
+
+type FormValues = z.infer;
+
+/** Turn a topic name into a valid default table name (topic names may contain dots or dashes). */
+function suggestTableName(topicName: string): string {
+ const slug = topicName.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_');
+ return TABLE_NAME_RE.test(slug) ? slug : `_${slug}`;
+}
+
+export function createTableSql(tableName: string, topic: string): string {
+ return `CREATE TABLE ${CATALOG_NAME}=>${tableName || 'my_table'}\n WITH (topic='${topic}');`;
+}
+
+function describeTopic(topic: WizardTopic): string {
+ const partitions = typeof topic.partitions === 'number' ? `${topic.partitions} partitions` : 'topic';
+ return topic.format ? `${partitions} · ${topic.format}` : partitions;
+}
+
+export function SqlWizard({ topics, onClose, onCreate, isCreating, error }: SqlWizardProps) {
+ const [step, setStep] = useState(0);
+ const [selectedTopic, setSelectedTopic] = useState(null);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ mode: 'onTouched',
+ defaultValues: { tableName: '' },
+ });
+
+ const selectTopic = (topicName: string) => {
+ const topic = topics.find((t) => t.name === topicName);
+ if (!topic) {
+ return;
+ }
+ // Prefill the table name unless the user already typed their own.
+ const currentName = form.getValues('tableName');
+ if (!currentName || (selectedTopic && currentName === suggestTableName(selectedTopic.name))) {
+ form.setValue('tableName', suggestTableName(topic.name));
+ }
+ setSelectedTopic(topic);
+ };
+
+ const submit = form.handleSubmit(({ tableName }) => {
+ if (selectedTopic) {
+ onCreate({ topic: selectedTopic.name, tableName });
+ }
+ });
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/pages/sql/sql.test.tsx b/frontend/src/components/pages/sql/sql.test.tsx
new file mode 100644
index 0000000000..9690743a32
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql.test.tsx
@@ -0,0 +1,94 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { describe, expect, test } from 'vitest';
+
+import { bridgeTopicForQuery, firstKeyword, isWriteKeyword } from './sql';
+import type { Catalog } from './sql-types';
+
+const CATALOGS: Catalog[] = [
+ {
+ name: 'default_redpanda_catalog',
+ displayLabel: 'Redpanda Catalog',
+ engine: 'redpanda',
+ namespaces: [
+ {
+ id: 'default_redpanda_catalog.public',
+ name: 'public',
+ tables: [
+ {
+ id: 'default_redpanda_catalog.public.cars_table',
+ name: 'cars_table',
+ namespaceName: 'public',
+ catalogName: 'default_redpanda_catalog',
+ topicName: 'cars-telemetry.v1',
+ },
+ ],
+ },
+ ],
+ },
+];
+
+describe('sql helpers', () => {
+ test('firstKeyword skips comments and uppercases', () => {
+ expect(firstKeyword('select * from t')).toBe('SELECT');
+ expect(firstKeyword('-- a comment\nselect * from t')).toBe('SELECT');
+ expect(firstKeyword('/* block */ INSERT INTO t VALUES (1)')).toBe('INSERT');
+ expect(firstKeyword(' \n grant all on t to u')).toBe('GRANT');
+ expect(firstKeyword('-- only a comment')).toBe('');
+ expect(firstKeyword('')).toBe('');
+ });
+
+ test('firstKeyword sees past a leading paren', () => {
+ expect(firstKeyword('(SELECT * FROM t)')).toBe('SELECT');
+ expect(firstKeyword(' ( ( select 1 )')).toBe('SELECT');
+ });
+
+ test('isWriteKeyword blocks writes/DDL/DCL but passes read-shaped statements', () => {
+ // Read-shaped — not blocked.
+ expect(isWriteKeyword('SELECT * FROM t')).toBe(false);
+ expect(isWriteKeyword('WITH t AS (SELECT 1) SELECT * FROM t')).toBe(false);
+ expect(isWriteKeyword('EXPLAIN SELECT 1')).toBe(false);
+ expect(isWriteKeyword('(SELECT 1)')).toBe(false);
+ expect(isWriteKeyword('SHOW TABLES')).toBe(false);
+ // Writes / DDL / DCL — blocked.
+ expect(isWriteKeyword('INSERT INTO t VALUES (1)')).toBe(true);
+ expect(isWriteKeyword('DELETE FROM t')).toBe(true);
+ expect(isWriteKeyword('CREATE TABLE t (a int)')).toBe(true);
+ expect(isWriteKeyword('DROP TABLE t')).toBe(true);
+ expect(isWriteKeyword('GRANT ALL ON t TO u')).toBe(true);
+ });
+
+ test('bridgeTopicForQuery resolves a single Redpanda SQL table to its backing topic', () => {
+ expect(bridgeTopicForQuery('SELECT * FROM default_redpanda_catalog=>cars_table LIMIT 100', CATALOGS)).toBe(
+ 'cars-telemetry.v1'
+ );
+ });
+
+ test('bridgeTopicForQuery ignores joins and non-Redpanda catalogs', () => {
+ expect(
+ bridgeTopicForQuery(
+ 'SELECT * FROM default_redpanda_catalog=>cars_table c JOIN default_redpanda_catalog=>drivers d ON c.id = d.id',
+ CATALOGS
+ )
+ ).toBeNull();
+ expect(bridgeTopicForQuery('SELECT * FROM iceberg=>cars_table', CATALOGS)).toBeNull();
+ });
+
+ test('bridgeTopicForQuery ignores `=>` inside comments and strings', () => {
+ // A second `=>` in a trailing comment must not count as a second ref.
+ expect(
+ bridgeTopicForQuery('SELECT * FROM default_redpanda_catalog=>cars_table -- TODO cat=>orders', CATALOGS)
+ ).toBe('cars-telemetry.v1');
+ // A `=>` that only appears inside a string literal is not a table ref.
+ expect(bridgeTopicForQuery("SELECT 'cat=>x' AS note", CATALOGS)).toBeNull();
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql.tsx b/frontend/src/components/pages/sql/sql.tsx
new file mode 100644
index 0000000000..9a7ca44eb4
--- /dev/null
+++ b/frontend/src/components/pages/sql/sql.tsx
@@ -0,0 +1,91 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+// SQL helpers for the query workspace. Highlighting (editor: CodeMirror's
+// Lezer SQL grammar, wizard preview: shiki via DynamicCodeBlock), keyword
+// completion (lang-sql's PostgreSQL dialect) and formatting (sql-formatter)
+// are handled by the libraries directly.
+
+import type { Catalog } from './sql-types';
+
+// Leading whitespace, line/block comments, and opening parens — so a query
+// wrapped like `(SELECT …)` still resolves to its first keyword.
+const LEADING_COMMENTS = /^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/|\()*/;
+const FIRST_KEYWORD_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
+
+// First meaningful keyword of a statement (used to gate which statements run).
+export function firstKeyword(stmt: string): string {
+ const word = stmt.replace(LEADING_COMMENTS, '').match(FIRST_KEYWORD_RE);
+ return word ? word[0].toUpperCase() : '';
+}
+
+// Statements that mutate state or manage access. These are blocked in the
+// editor in this release — the server is the authority on what can run; this is
+// a UX guard rail, not a security control. Everything else (SELECT, WITH/CTE,
+// EXPLAIN, SHOW, …) is read-shaped and allowed through.
+const WRITE_KEYWORDS = new Set([
+ 'INSERT',
+ 'UPDATE',
+ 'DELETE',
+ 'MERGE',
+ 'UPSERT',
+ 'REPLACE',
+ 'CREATE',
+ 'DROP',
+ 'ALTER',
+ 'TRUNCATE',
+ 'RENAME',
+ 'COMMENT',
+ 'GRANT',
+ 'REVOKE',
+]);
+
+export function isWriteKeyword(stmt: string): boolean {
+ return WRITE_KEYWORDS.has(firstKeyword(stmt));
+}
+
+// Strip line/block comments and single-quoted string literals so `=>` inside
+// them isn't mistaken for a catalog reference. Leaves the structural SQL intact.
+const SQL_NOISE_RE = /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'/g;
+
+// Oxla addresses catalog tables as `catalog=>table`. A bridge indicator is
+// only meaningful for a single Redpanda-catalog table reference.
+const BRIDGE_REF_RE = /([A-Za-z_][\w$]*)\s*=>\s*"?([a-zA-Z0-9._-]+)/g;
+
+export function bridgeTopicForQuery(stmt: string, catalogs: Catalog[]): string | null {
+ const cleaned = stmt.replace(SQL_NOISE_RE, ' ');
+ const matches = [...cleaned.matchAll(BRIDGE_REF_RE)];
+ if (matches.length !== 1) {
+ return null;
+ }
+ const match = matches[0];
+ const catalogName = match?.[1];
+ const tableName = match?.[2];
+ if (!catalogName) {
+ return null;
+ }
+ if (!tableName) {
+ return null;
+ }
+ const catalog = catalogs.find((c) => c.engine === 'redpanda' && c.name === catalogName);
+ if (!catalog) {
+ return null;
+ }
+
+ for (const namespace of catalog.namespaces) {
+ const table = namespace.tables.find((t) => t.name === tableName || `${t.namespaceName}.${t.name}` === tableName);
+ if (table) {
+ return table.topicName ?? table.name;
+ }
+ }
+
+ return tableName;
+}
diff --git a/frontend/src/components/redpanda-ui/components/choicebox.tsx b/frontend/src/components/redpanda-ui/components/choicebox.tsx
index d9b20d1bca..96427851e7 100644
--- a/frontend/src/components/redpanda-ui/components/choicebox.tsx
+++ b/frontend/src/components/redpanda-ui/components/choicebox.tsx
@@ -99,6 +99,8 @@ export const ChoiceboxItemIndicator = ({
;
+} & Pick;
/**
* Synchronous variant of {@link DynamicCodeBlock}. Highlights with a pre-bundled
diff --git a/frontend/src/components/redpanda-ui/components/popover.tsx b/frontend/src/components/redpanda-ui/components/popover.tsx
index 54de2dbea9..fc19733cfd 100644
--- a/frontend/src/components/redpanda-ui/components/popover.tsx
+++ b/frontend/src/components/redpanda-ui/components/popover.tsx
@@ -105,6 +105,14 @@ type PopoverContentProps = React.ComponentProps &
align?: Align;
sideOffset?: number;
alignOffset?: number;
+ /** Keep the popup within its collision boundary when the anchor scrolls out of view. */
+ sticky?: boolean;
+ /** Space to maintain from the edge of the collision boundary. */
+ collisionPadding?: number;
+ /** Element/rect the popup is confined to (defaults to the clipping ancestors). */
+ collisionBoundary?: React.ComponentProps['collisionBoundary'];
+ /** How the popup avoids collisions; set sides to `'none'` to make it track the anchor and clip instead of repositioning. */
+ collisionAvoidance?: React.ComponentProps['collisionAvoidance'];
};
function PopoverContent({
@@ -113,6 +121,10 @@ function PopoverContent({
side = 'bottom',
sideOffset = 4,
alignOffset,
+ sticky,
+ collisionPadding,
+ collisionBoundary,
+ collisionAvoidance,
transition = { type: 'spring', stiffness: 300, damping: 25 },
children,
testId,
@@ -134,8 +146,12 @@ function PopoverContent({
alignOffset={alignOffset}
{...(anchorCtx?.hasAnchor && anchorCtx.anchorRef.current ? { anchor: anchorCtx.anchorRef } : {})}
className="z-50"
+ collisionAvoidance={collisionAvoidance}
+ collisionBoundary={collisionBoundary}
+ collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
+ sticky={sticky}
>
mounted across fullscreen↔normal
+ // navigation, so the embedded router doesn't reset to its default route.
+ const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname);
+
return (
-
-
-
+ {!isFullscreen && (
+
+
+
+ )}
-
+
-
+
-
+ {!isFullscreen && }
diff --git a/frontend/src/globals.css b/frontend/src/globals.css
index 1ecbdb6643..820a34e91d 100644
--- a/frontend/src/globals.css
+++ b/frontend/src/globals.css
@@ -25,6 +25,15 @@
}
}
+/* Chakra (@redpanda-data/ui) resets every element's border-color via an unlayered
+ `*` rule reading this var; in dark mode it stays light and leaks onto bare-border
+ dividers. This selector out-specifies Chakra's `:root[data-theme="light"]` (0,2,0)
+ so our dark token wins on specificity alone. Lives here (app entry CSS) rather
+ than the registry-synced theme.css so it survives registry re-syncs. (UX-1259) */
+:root[data-theme].dark {
+ --chakra-colors-chakra-border-color: var(--color-border);
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
diff --git a/frontend/src/react-query/api/sql.tsx b/frontend/src/react-query/api/sql.tsx
new file mode 100644
index 0000000000..8254c748c3
--- /dev/null
+++ b/frontend/src/react-query/api/sql.tsx
@@ -0,0 +1,113 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { create } from '@bufbuild/protobuf';
+import { createConnectQueryKey, useMutation, useQuery } from '@connectrpc/connect-query';
+import { useQueryClient } from '@tanstack/react-query';
+import { GetTopicConfigurationsRequestSchema } from 'protogen/redpanda/api/dataplane/v1/topic_pb';
+import { getTopicConfigurations } from 'protogen/redpanda/api/dataplane/v1/topic-TopicService_connectquery';
+import {
+ type DescribeTableRequest,
+ DescribeTableRequestSchema,
+ GetSqlIdentityRequestSchema,
+ type ListCatalogsRequest,
+ ListCatalogsRequestSchema,
+ type ListTablesRequest,
+ ListTablesRequestSchema,
+} from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb';
+import {
+ describeTable,
+ executeQuery,
+ getSqlIdentity,
+ listCatalogs,
+ listTables,
+} from 'protogen/redpanda/api/dataplane/v1alpha3/sql-SQLService_connectquery';
+import { MAX_PAGE_SIZE, type MessageInit } from 'react-query/react-query.utils';
+import { toast } from 'sonner';
+import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
+
+type SqlQueryOptions = {
+ enabled?: boolean;
+};
+
+export const useListCatalogsQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+ const request = create(ListCatalogsRequestSchema, {
+ pageSize: input?.pageSize ?? MAX_PAGE_SIZE,
+ pageToken: input?.pageToken ?? '',
+ });
+
+ return useQuery(listCatalogs, request, {
+ enabled: options?.enabled !== false,
+ });
+};
+
+// Resolves the caller's SQL identity (engine username + admin/superuser flag).
+// Admin gates write/DDL affordances like the "Add a topic" button.
+export const useGetSqlIdentityQuery = (options?: SqlQueryOptions) => {
+ const request = create(GetSqlIdentityRequestSchema, {});
+ return useQuery(getSqlIdentity, request, {
+ enabled: options?.enabled !== false,
+ });
+};
+
+export const useListTablesQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+ const request = create(ListTablesRequestSchema, {
+ catalog: input?.catalog ?? '',
+ pageSize: input?.pageSize ?? MAX_PAGE_SIZE,
+ pageToken: input?.pageToken ?? '',
+ filter: input?.filter,
+ });
+
+ return useQuery(listTables, request, {
+ enabled: options?.enabled !== false && Boolean(input?.catalog),
+ });
+};
+
+export const useDescribeTableQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+ const request = create(DescribeTableRequestSchema, {
+ catalog: input?.catalog ?? '',
+ name: input?.name ?? '',
+ });
+
+ return useQuery(describeTable, request, {
+ enabled: options?.enabled !== false && Boolean(input?.catalog) && Boolean(input?.name),
+ });
+};
+
+// RP SQL's SHOW TABLES has no per-table Iceberg flag; the authoritative signal
+// is the backing Kafka topic's `redpanda.iceberg.mode` config. Returns whether
+// the topic is Iceberg-tiered so the catalog tree can show the label.
+export const useTopicIcebergQuery = (topicName: string, options?: SqlQueryOptions) => {
+ const request = create(GetTopicConfigurationsRequestSchema, { topicName });
+ const result = useQuery(getTopicConfigurations, request, {
+ enabled: options?.enabled !== false && Boolean(topicName),
+ });
+ const mode = result.data?.configurations.find((c) => c.name === 'redpanda.iceberg.mode')?.value;
+ return { ...result, isIceberg: Boolean(mode && mode !== 'disabled') };
+};
+
+export const useExecuteQueryMutation = () =>
+ useMutation(executeQuery, {
+ onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })),
+ });
+
+// Returns a function that refreshes the catalog/table listings, e.g. after a
+// CREATE TABLE so the new table shows up in the tree.
+export const useInvalidateSqlCatalog = () => {
+ const queryClient = useQueryClient();
+ return () =>
+ Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: createConnectQueryKey({ schema: listCatalogs, cardinality: 'finite' }),
+ }),
+ queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: listTables, cardinality: 'finite' }) }),
+ ]);
+};
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index f3552c654c..a8cabfacd3 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root';
import { Route as UploadLicenseRouteImport } from './routes/upload-license';
import { Route as TrialExpiredRouteImport } from './routes/trial-expired';
import { Route as TransformsSetupRouteImport } from './routes/transforms-setup';
+import { Route as SqlRouteImport } from './routes/sql';
import { Route as SecurityRouteImport } from './routes/security';
import { Route as ReassignPartitionsRouteImport } from './routes/reassign-partitions';
import { Route as QuotasRouteImport } from './routes/quotas';
@@ -102,6 +103,11 @@ const TransformsSetupRoute = TransformsSetupRouteImport.update({
path: '/transforms-setup',
getParentRoute: () => rootRouteImport,
} as any);
+const SqlRoute = SqlRouteImport.update({
+ id: '/sql',
+ path: '/sql',
+ getParentRoute: () => rootRouteImport,
+} as any);
const SecurityRoute = SecurityRouteImport.update({
id: '/security',
path: '/security',
@@ -504,6 +510,7 @@ export interface FileRoutesByFullPath {
'/quotas': typeof QuotasRoute;
'/reassign-partitions': typeof ReassignPartitionsRoute;
'/security': typeof SecurityRouteWithChildren;
+ '/sql': typeof SqlRoute;
'/transforms-setup': typeof TransformsSetupRoute;
'/trial-expired': typeof TrialExpiredRoute;
'/upload-license': typeof UploadLicenseRoute;
@@ -582,6 +589,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute;
'/quotas': typeof QuotasRoute;
'/reassign-partitions': typeof ReassignPartitionsRoute;
+ '/sql': typeof SqlRoute;
'/transforms-setup': typeof TransformsSetupRoute;
'/trial-expired': typeof TrialExpiredRoute;
'/upload-license': typeof UploadLicenseRoute;
@@ -662,6 +670,7 @@ export interface FileRoutesById {
'/quotas': typeof QuotasRoute;
'/reassign-partitions': typeof ReassignPartitionsRoute;
'/security': typeof SecurityRouteWithChildren;
+ '/sql': typeof SqlRoute;
'/transforms-setup': typeof TransformsSetupRoute;
'/trial-expired': typeof TrialExpiredRoute;
'/upload-license': typeof UploadLicenseRoute;
@@ -743,6 +752,7 @@ export interface FileRouteTypes {
| '/quotas'
| '/reassign-partitions'
| '/security'
+ | '/sql'
| '/transforms-setup'
| '/trial-expired'
| '/upload-license'
@@ -821,6 +831,7 @@ export interface FileRouteTypes {
| '/'
| '/quotas'
| '/reassign-partitions'
+ | '/sql'
| '/transforms-setup'
| '/trial-expired'
| '/upload-license'
@@ -900,6 +911,7 @@ export interface FileRouteTypes {
| '/quotas'
| '/reassign-partitions'
| '/security'
+ | '/sql'
| '/transforms-setup'
| '/trial-expired'
| '/upload-license'
@@ -980,6 +992,7 @@ export interface RootRouteChildren {
QuotasRoute: typeof QuotasRoute;
ReassignPartitionsRoute: typeof ReassignPartitionsRoute;
SecurityRoute: typeof SecurityRouteWithChildren;
+ SqlRoute: typeof SqlRoute;
TransformsSetupRoute: typeof TransformsSetupRoute;
TrialExpiredRoute: typeof TrialExpiredRoute;
UploadLicenseRoute: typeof UploadLicenseRoute;
@@ -1063,6 +1076,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransformsSetupRouteImport;
parentRoute: typeof rootRouteImport;
};
+ '/sql': {
+ id: '/sql';
+ path: '/sql';
+ fullPath: '/sql';
+ preLoaderRoute: typeof SqlRouteImport;
+ parentRoute: typeof rootRouteImport;
+ };
'/security': {
id: '/security';
path: '/security';
@@ -1629,6 +1649,7 @@ const rootRouteChildren: RootRouteChildren = {
QuotasRoute: QuotasRoute,
ReassignPartitionsRoute: ReassignPartitionsRoute,
SecurityRoute: SecurityRouteWithChildren,
+ SqlRoute: SqlRoute,
TransformsSetupRoute: TransformsSetupRoute,
TrialExpiredRoute: TrialExpiredRoute,
UploadLicenseRoute: UploadLicenseRoute,
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
index 70c029bda2..681c0b1914 100644
--- a/frontend/src/routes/__root.tsx
+++ b/frontend/src/routes/__root.tsx
@@ -11,7 +11,7 @@
import type { Transport } from '@connectrpc/connect';
import type { QueryClient } from '@tanstack/react-query';
-import { createRootRouteWithContext, Outlet, useLocation } from '@tanstack/react-router';
+import { createRootRouteWithContext, Outlet, useLocation, useMatches } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import AnnouncementBar from 'components/builder-io/announcement-bar';
import { Toaster } from 'components/redpanda-ui/components/sonner';
@@ -31,6 +31,7 @@ import { NullFallbackBoundary } from '../components/misc/null-fallback-boundary'
import { RouterSync } from '../components/misc/router-sync';
import { SidebarInset } from '../components/redpanda-ui/components/sidebar';
import RequireAuth from '../components/require-auth';
+import { isFullscreenPath } from '../utils/fullscreen-routes';
import { ModalContainer } from '../utils/modal-container';
export type RouterContext = {
@@ -51,14 +52,10 @@ function RootLayout() {
{isEmbedded() ? : }
+ {process.env.NODE_ENV === 'development' && }
- {process.env.NODE_ENV === 'development' && (
- <>
-
-
- >
- )}
+ {process.env.NODE_ENV === 'development' && }
>
);
}
@@ -90,6 +87,26 @@ function EmbeddedLayout() {
}
function AppContent() {
+ const matches = useMatches();
+ const { pathname } = useLocation();
+ const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname);
+
+ if (isFullscreen) {
+ return (
+
+
+
+ {!isEmbedded() && }
+
+
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/frontend/src/routes/sql.tsx b/frontend/src/routes/sql.tsx
new file mode 100644
index 0000000000..501581593b
--- /dev/null
+++ b/frontend/src/routes/sql.tsx
@@ -0,0 +1,35 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { createFileRoute, redirect } from '@tanstack/react-router';
+import { SqlWorkspace } from 'components/pages/sql/sql-workspace';
+import { Database } from 'lucide-react';
+import { Feature, isSupported, useSupportedFeaturesStore } from 'state/supported-features';
+
+// allow: error-boundary [pure redirect in beforeLoad, no data fetching]
+export const Route = createFileRoute('/sql')({
+ staticData: {
+ title: 'SQL',
+ icon: Database,
+ fullscreen: true,
+ },
+ // Gate direct navigation to /sql on the same capability check as the sidebar.
+ // isSupported() returns false both when SQLService is unsupported and when the
+ // endpoint list hasn't loaded yet, so redirecting on the latter bounces a cold
+ // load off /sql before the answer is known. Only redirect once it's loaded.
+ beforeLoad: () => {
+ const { endpointCompatibility } = useSupportedFeaturesStore.getState();
+ if (endpointCompatibility !== null && !isSupported(Feature.SQLService)) {
+ throw redirect({ to: '/', replace: true });
+ }
+ },
+ component: SqlWorkspace,
+});
diff --git a/frontend/src/state/supported-features.ts b/frontend/src/state/supported-features.ts
index 9dab4744f6..1c57455944 100644
--- a/frontend/src/state/supported-features.ts
+++ b/frontend/src/state/supported-features.ts
@@ -84,6 +84,10 @@ export class Feature {
endpoint: '/api/schema-registry/contexts',
method: 'GET',
};
+ static readonly SQLService: FeatureEntry = {
+ endpoint: 'redpanda.api.dataplane.v1alpha3.SQLService',
+ method: 'POST',
+ };
}
/**
@@ -98,6 +102,7 @@ function computeSupported(f: FeatureEntry, c: EndpointCompatibility | null): { s
case Feature.TracingService.endpoint:
case Feature.GetQuotas.endpoint:
case Feature.SchemaRegistryContexts.endpoint:
+ case Feature.SQLService.endpoint:
return { supported: false };
default:
return { supported: true };
@@ -113,7 +118,8 @@ function computeSupported(f: FeatureEntry, c: EndpointCompatibility | null): { s
if (
f.endpoint.includes('.SecurityService') ||
f.endpoint.includes('.SecretService') ||
- f.endpoint.includes('.MCPServerService')
+ f.endpoint.includes('.MCPServerService') ||
+ f.endpoint.includes('.SQLService')
) {
return { supported: false };
}
@@ -141,7 +147,7 @@ export function isSupported(f: FeatureEntry): boolean {
/**
* A list of features we should hide instead of showing a disabled message.
*/
-const HIDE_IF_NOT_SUPPORTED_FEATURES = [Feature.GetQuotas, Feature.TracingService];
+const HIDE_IF_NOT_SUPPORTED_FEATURES = [Feature.GetQuotas, Feature.TracingService, Feature.SQLService];
export function shouldHideIfNotSupported(f: FeatureEntry): boolean {
return HIDE_IF_NOT_SUPPORTED_FEATURES.includes(f);
}
@@ -178,6 +184,7 @@ function computeAllFeatures(c: EndpointCompatibility | null) {
shadowLinkService: compute(Feature.ShadowLinkService),
tracingService: compute(Feature.TracingService),
schemaRegistryContexts: compute(Feature.SchemaRegistryContexts),
+ sqlApi: compute(Feature.SQLService),
featureErrors: errors,
};
}
@@ -208,6 +215,7 @@ type SupportedFeaturesStore = {
shadowLinkService: boolean;
tracingService: boolean;
schemaRegistryContexts: boolean;
+ sqlApi: boolean;
// Actions
setEndpointCompatibility: (ec: EndpointCompatibility) => void;
@@ -298,6 +306,9 @@ const Features = {
get schemaRegistryContexts() {
return useSupportedFeaturesStore.getState().schemaRegistryContexts;
},
+ get sqlApi() {
+ return useSupportedFeaturesStore.getState().sqlApi;
+ },
};
export { Features };
diff --git a/frontend/src/utils/env.ts b/frontend/src/utils/env.ts
index d7ce06fa64..93b0e15699 100644
--- a/frontend/src/utils/env.ts
+++ b/frontend/src/utils/env.ts
@@ -42,7 +42,7 @@ export const IsProd = !isDev;
export const IsDev = isDev;
export const IsCI = env.REACT_APP_BUILT_FROM_PUSH && env.REACT_APP_BUILT_FROM_PUSH !== 'false';
-const appFeatureNames = ['SINGLE_SIGN_ON', 'REASSIGN_PARTITIONS', 'SHADOW_LINKS'] as const;
+const appFeatureNames = ['SINGLE_SIGN_ON', 'REASSIGN_PARTITIONS', 'SHADOW_LINKS', 'REDPANDA_SQL'] as const;
export type AppFeature = (typeof appFeatureNames)[number];
if (env.REACT_APP_ENABLED_FEATURES) {
diff --git a/frontend/src/utils/fullscreen-routes.test.tsx b/frontend/src/utils/fullscreen-routes.test.tsx
new file mode 100644
index 0000000000..ebf3c50829
--- /dev/null
+++ b/frontend/src/utils/fullscreen-routes.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { describe, expect, test } from 'vitest';
+
+import { collectFullscreenPaths, isFullscreenPath, matchesFullscreenPath } from './fullscreen-routes';
+import { routeTree } from '../routeTree.gen';
+
+describe('collectFullscreenPaths', () => {
+ test('collects paths from built route nodes (path/staticData under options)', () => {
+ const tree = {
+ children: {
+ SqlRoute: { options: { path: '/sql', staticData: { fullscreen: true } } },
+ TopicsRoute: { options: { path: '/topics', staticData: { fullscreen: false } } },
+ QuotasRoute: { options: { path: '/quotas' } },
+ },
+ };
+ expect(collectFullscreenPaths(tree)).toEqual(['/sql']);
+ });
+
+ test('collects paths from raw route nodes (path/staticData at top level)', () => {
+ const tree = {
+ children: {
+ SqlRoute: { path: '/sql', staticData: { fullscreen: true } },
+ },
+ };
+ expect(collectFullscreenPaths(tree)).toEqual(['/sql']);
+ });
+
+ test('recurses into nested children', () => {
+ const tree = {
+ children: {
+ Parent: {
+ options: { path: '/parent' },
+ children: {
+ Child: { options: { path: '/parent/studio', staticData: { fullscreen: true } } },
+ },
+ },
+ },
+ };
+ expect(collectFullscreenPaths(tree)).toEqual(['/parent/studio']);
+ });
+
+ test('ignores a fullscreen route with no path', () => {
+ const tree = { children: { Bad: { options: { staticData: { fullscreen: true } } } } };
+ expect(collectFullscreenPaths(tree)).toEqual([]);
+ });
+
+ test('tolerates non-object input', () => {
+ expect(collectFullscreenPaths(null)).toEqual([]);
+ expect(collectFullscreenPaths(undefined)).toEqual([]);
+ });
+
+ test('derives /sql from the real route tree', () => {
+ // Guards against route-tree shape changes and against the SQL route losing
+ // its staticData.fullscreen flag.
+ expect(collectFullscreenPaths(routeTree)).toContain('/sql');
+ });
+});
+
+describe('matchesFullscreenPath', () => {
+ const paths = ['/sql'];
+
+ test('matches the exact path', () => {
+ expect(matchesFullscreenPath('/sql', paths)).toBe(true);
+ });
+
+ test('matches a nested path', () => {
+ expect(matchesFullscreenPath('/sql/query/123', paths)).toBe(true);
+ });
+
+ test('matches an embedded path with a host cluster prefix', () => {
+ expect(matchesFullscreenPath('/clusters/abc123/sql', paths)).toBe(true);
+ });
+
+ test('does not match a path that merely starts with the segment text', () => {
+ expect(matchesFullscreenPath('/sqlx', paths)).toBe(false);
+ expect(matchesFullscreenPath('/mysql', paths)).toBe(false);
+ });
+
+ test('does not match an interior segment that merely happens to be named sql', () => {
+ expect(matchesFullscreenPath('/clusters/sql/overview', paths)).toBe(false);
+ });
+
+ test('does not match unrelated paths', () => {
+ expect(matchesFullscreenPath('/topics', paths)).toBe(false);
+ });
+
+ test('returns false when there are no fullscreen paths', () => {
+ expect(matchesFullscreenPath('/sql', [])).toBe(false);
+ });
+});
+
+describe('isFullscreenPath (wired to the real route tree)', () => {
+ test('recognizes the SQL studio, standalone and embedded', () => {
+ expect(isFullscreenPath('/sql')).toBe(true);
+ expect(isFullscreenPath('/clusters/abc123/sql')).toBe(true);
+ });
+
+ test('rejects non-fullscreen routes', () => {
+ expect(isFullscreenPath('/topics')).toBe(false);
+ expect(isFullscreenPath('/overview')).toBe(false);
+ });
+});
diff --git a/frontend/src/utils/fullscreen-routes.ts b/frontend/src/utils/fullscreen-routes.ts
new file mode 100644
index 0000000000..67bfa90bc0
--- /dev/null
+++ b/frontend/src/utils/fullscreen-routes.ts
@@ -0,0 +1,90 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { routeTree } from '../routeTree.gen';
+
+/**
+ * Fullscreen routes (e.g. the SQL studio) render minimal chrome. The layout
+ * components detect them from `staticData.fullscreen` on the resolved route
+ * matches — but on soft navigation `useLocation().pathname` flips to the new
+ * route synchronously while `useMatches()` still holds the *previous* route's
+ * matches until the new match resolves. During that window staticData reports
+ * `fullscreen: false`, so the studio would flash full chrome (header row + top
+ * padding) on every in-app navigation into it.
+ *
+ * This path-based check bridges that gap. The fullscreen paths are derived from
+ * the route tree (single source of truth: `staticData.fullscreen` in the route
+ * definition) rather than hardcoded, so any future fullscreen route is covered
+ * automatically.
+ */
+
+type StaticData = { fullscreen?: boolean };
+
+/**
+ * The slice of a TanStack route node this module reads. A built route exposes
+ * `path`/`staticData` under `options`; a raw route definition exposes them at
+ * the top level — we tolerate both. `children` is keyed by generated route name.
+ */
+interface FullscreenRouteNode {
+ path?: string;
+ staticData?: StaticData;
+ options?: { path?: string; staticData?: StaticData };
+ children?: { [routeName: string]: FullscreenRouteNode };
+}
+
+const isRouteNode = (value: unknown): value is FullscreenRouteNode => typeof value === 'object' && value !== null;
+
+/** Walk the route tree and collect the paths of routes marked `staticData.fullscreen`. */
+export function collectFullscreenPaths(node: unknown): string[] {
+ const paths: string[] = [];
+ const visit = (value: unknown) => {
+ if (!isRouteNode(value)) {
+ return;
+ }
+ const path = value.options?.path ?? value.path;
+ const fullscreen = value.options?.staticData?.fullscreen ?? value.staticData?.fullscreen;
+ if (path && fullscreen) {
+ paths.push(path);
+ }
+ if (value.children) {
+ for (const child of Object.values(value.children)) {
+ visit(child);
+ }
+ }
+ };
+ visit(node);
+ return paths;
+}
+
+/**
+ * True when `pathname` is the fullscreen route itself, a route nested under it
+ * (standalone `/sql/query/123`), or the same trailing segment behind a host
+ * prefix (embedded Cloud UI `/clusters//sql`). Anchoring to the start or the
+ * trailing segment avoids matching an interior segment that merely happens to be
+ * named `sql` (e.g. `/clusters/sql/overview`), and never matches `/mysql`/`/sqlx`.
+ */
+export function matchesFullscreenPath(pathname: string, paths: string[]): boolean {
+ return paths.some((path) => pathname === path || pathname.startsWith(`${path}/`) || pathname.endsWith(path));
+}
+
+// Computed lazily, not at module load: `__root.tsx` imports this module and is
+// itself imported by `routeTree.gen`, so reading `routeTree` at module-eval time
+// races the circular import and sees it undefined. By first call (render time)
+// the tree is fully built. The result is stable, so memoize it.
+let cachedFullscreenPaths: string[] | null = null;
+
+/** Whether the given pathname belongs to a fullscreen route. See module docs. */
+export const isFullscreenPath = (pathname: string): boolean => {
+ if (cachedFullscreenPaths === null) {
+ cachedFullscreenPaths = collectFullscreenPaths(routeTree);
+ }
+ return matchesFullscreenPath(pathname, cachedFullscreenPaths);
+};
diff --git a/frontend/src/utils/route-utils.tsx b/frontend/src/utils/route-utils.tsx
index 33088e7ea6..d1af6a2ebe 100644
--- a/frontend/src/utils/route-utils.tsx
+++ b/frontend/src/utils/route-utils.tsx
@@ -15,6 +15,7 @@
*/
import type { LucideIcon } from 'lucide-react';
+import { Database } from 'lucide-react';
import type { ReactNode } from 'react';
import { type AppFeature, AppFeatures } from './env';
@@ -249,6 +250,17 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
group: SidebarSection.STREAMING,
visibilityCheck: routeVisibility(true, [Feature.GetQuotas], ['canListQuotas']),
},
+ {
+ path: '/sql',
+ title: 'SQL',
+ icon: Database,
+ group: SidebarSection.STREAMING,
+ // The enterprise backend mounts the SQLService and reports it in endpoint
+ // compatibility only when SQL is enabled (cfg.API.SQL.Enabled). This is the
+ // single source of truth for both embedded (cloud) and self-hosted, so the
+ // nav is gated on capability detection rather than a feature flag.
+ visibilityCheck: routeVisibility(true, [Feature.SQLService]),
+ },
{
path: '/connect-clusters',
title: 'Connect',