From 596b44afed568770d56f658f7acb7114845f9dcd Mon Sep 17 00:00:00 2001
From: Julin <142230457+c-julin@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:02:30 +0100
Subject: [PATCH 01/11] sql: render "record" composite columns with the JSON
tree viewer [UX-1330]
The backend now labels composite (struct) columns "record"/"record[]"
instead of "json"/"json[]". Map those type strings to the json display
kind so composite cells keep the nested JSON tree viewer and the catalog
tree/cell popover show the "record" label.
---
frontend/src/components/pages/sql/catalog-tree.test.tsx | 6 +++---
frontend/src/components/pages/sql/sql-types.test.ts | 7 ++++---
frontend/src/components/pages/sql/sql-types.ts | 7 ++++---
3 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/frontend/src/components/pages/sql/catalog-tree.test.tsx b/frontend/src/components/pages/sql/catalog-tree.test.tsx
index 30e719752c..e481ae4531 100644
--- a/frontend/src/components/pages/sql/catalog-tree.test.tsx
+++ b/frontend/src/components/pages/sql/catalog-tree.test.tsx
@@ -98,7 +98,7 @@ describe('CatalogTree', () => {
});
test('expanding a table lists its columns with type labels', async () => {
- // Types arrive lower-cased from the backend; composite columns as "json"
+ // Types arrive lower-cased from the backend; composite columns as "record"
// with their nested fields parsed server-side.
vi.mocked(useDescribeTableQuery).mockReturnValue({
data: {
@@ -106,7 +106,7 @@ describe('CatalogTree', () => {
{ name: 'id', type: 'bigint' },
{ name: 'payload', type: 'jsonb' },
{ name: 'tags', type: 'text[]' },
- { name: 'customer', type: 'json', fields: [{ name: 'street', type: 'text' }] },
+ { name: 'customer', type: 'record', fields: [{ name: 'street', type: 'text' }] },
],
},
isLoading: false,
@@ -119,7 +119,7 @@ describe('CatalogTree', () => {
expect(screen.getByText('jsonb')).toBeInTheDocument();
expect(screen.getByText('text[]')).toBeInTheDocument();
- // Composite column shows "json" and expands into its nested fields.
+ // Composite column shows "record" and expands into its nested fields.
const customerRow = screen.getByRole('button', { name: CUSTOMER_RE });
expect(customerRow).toBeInTheDocument();
expect(screen.queryByText('street')).not.toBeInTheDocument();
diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts
index 19f68d409d..1056be12b9 100644
--- a/frontend/src/components/pages/sql/sql-types.test.ts
+++ b/frontend/src/components/pages/sql/sql-types.test.ts
@@ -28,9 +28,10 @@ describe('columnKindForPgType', () => {
['JSONB', 'json'],
['TEXT', 'str'],
['UNKNOWN_TYPE', 'str'],
- // Composite columns arrive pre-labelled as "json"/"json[]" from the backend.
- ['json', 'json'],
- ['json[]', 'json'],
+ // Composite columns arrive pre-labelled as "record"/"record[]" from the
+ // backend and render with the JSON tree viewer.
+ ['record', 'json'],
+ ['record[]', 'json'],
] as const)('%s → %s', (pgType, kind) => {
expect(columnKindForPgType(pgType)).toBe(kind);
});
diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts
index 6ed007962f..d65b77ce3c 100644
--- a/frontend/src/components/pages/sql/sql-types.ts
+++ b/frontend/src/components/pages/sql/sql-types.ts
@@ -133,8 +133,8 @@ export function isArrayPgType(pgType: string): boolean {
}
// 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;
+// literal "record"/"record[]" (the backend parses structure into Column.fields)
+// and render with the JSON tree viewer. Arrays map to their element kind;
// anything unrecognized defaults to a string.
export function columnKindForPgType(pgType: string): ColumnKind {
const element = arrayElementPgType(pgType);
@@ -152,7 +152,8 @@ export function columnKindForPgType(pgType: string): ColumnKind {
if (BOOL_TYPE.test(t)) {
return 'bool';
}
- if (t.includes('JSON')) {
+ // RECORD/RECORD[] are composite columns; JSON kept for any real json scalar.
+ if (t.includes('RECORD') || t.includes('JSON')) {
return 'json';
}
return 'str';
From faea0bd1404ecb4e9f17e7672a56156616ab7c6d Mon Sep 17 00:00:00 2001
From: Julin <142230457+c-julin@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:33:34 +0100
Subject: [PATCH 02/11] sql: show the query-error hint on its own line
[UX-1330]
The backend now appends an actionable hint after a blank line. Split it
off the error message and render it in the existing styled hint slot
below the alert, instead of inline in the message body.
---
.../src/components/pages/sql/sql-types.test.ts | 16 +++++++++++++++-
frontend/src/components/pages/sql/sql-types.ts | 8 ++++++++
.../src/components/pages/sql/sql-workspace.tsx | 4 +++-
3 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts
index 1056be12b9..de321898d3 100644
--- a/frontend/src/components/pages/sql/sql-types.test.ts
+++ b/frontend/src/components/pages/sql/sql-types.test.ts
@@ -11,7 +11,7 @@
import { describe, expect, test } from 'vitest';
-import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types';
+import { arrayElementPgType, columnKindForPgType, isArrayPgType, splitQueryError } from './sql-types';
describe('columnKindForPgType', () => {
test.each([
@@ -63,3 +63,17 @@ describe('arrayElementPgType', () => {
expect(isArrayPgType('TEXT[]')).toBe(true);
});
});
+
+describe('splitQueryError', () => {
+ test('splits the trailing hint onto its own field', () => {
+ const { message, hint } = splitQueryError("operator does not exist: record -> unknown\n\nHint: use (user).id");
+ expect(message).toBe('operator does not exist: record -> unknown');
+ expect(hint).toBe('use (user).id');
+ });
+
+ test('leaves a hintless message intact', () => {
+ const { message, hint } = splitQueryError('syntax error at or near "SELCT"');
+ expect(message).toBe('syntax error at or near "SELCT"');
+ expect(hint).toBeUndefined();
+ });
+});
diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts
index d65b77ce3c..c8a3ca78e0 100644
--- a/frontend/src/components/pages/sql/sql-types.ts
+++ b/frontend/src/components/pages/sql/sql-types.ts
@@ -159,6 +159,14 @@ export function columnKindForPgType(pgType: string): ColumnKind {
return 'str';
}
+// The backend appends an actionable hint after a blank line ("\n\nHint: …");
+// split it off so it can render on its own line instead of inline in the message.
+export function splitQueryError(message: string): { message: string; hint?: string } {
+ const sep = '\n\nHint: ';
+ const i = message.indexOf(sep);
+ return i === -1 ? { message } : { message: message.slice(0, i), hint: message.slice(i + sep.length) };
+}
+
// 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)/;
diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx
index 0d71a27b8b..8a7e26fda9 100644
--- a/frontend/src/components/pages/sql/sql-workspace.tsx
+++ b/frontend/src/components/pages/sql/sql-workspace.tsx
@@ -53,6 +53,7 @@ import {
isArrayPgType,
type QueryRun,
type ResultRow,
+ splitQueryError,
type SqlRole,
type TableRef,
} from './sql-types';
@@ -546,7 +547,8 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) {
if (latestRunToken.current !== token) {
return;
}
- setRun({ state: 'error', token, title: 'Query failed', message: error.message });
+ const { message, hint } = splitQueryError(error.message);
+ setRun({ state: 'error', token, title: 'Query failed', message, hint });
},
});
},
From b86969cc16cf297726a72902c0db01273f653427 Mon Sep 17 00:00:00 2001
From: Julin <142230457+c-julin@users.noreply.github.com>
Date: Thu, 25 Jun 2026 10:45:44 +0100
Subject: [PATCH 03/11] sql: render the query-error hint as a labelled info
callout [UX-1330]
Show the hint in an info Alert with a lightbulb icon and a "Hint" title
instead of loose muted text, so it reads clearly as guidance rather than
part of the error.
---
.../src/components/pages/sql/sql-results.tsx | 21 +++++++++++--------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx
index f40fea2ecf..3c74a71514 100644
--- a/frontend/src/components/pages/sql/sql-results.tsx
+++ b/frontend/src/components/pages/sql/sql-results.tsx
@@ -28,7 +28,7 @@ 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 { Braces, CircleX, Clock, Database, Download, GitMerge, Lightbulb, 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';
@@ -476,14 +476,17 @@ export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlRe
{run.message}
{run.hint ? (
-