Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
016f9c8
feat: adds client tools
EnxDev Aug 5, 2026
4669e95
feat(dashboard-v2): let a container say how it arranges its children
EnxDev Aug 5, 2026
e91358d
fix(dashboard-v2): show the layout control on a blank dashboard
EnxDev Aug 5, 2026
1765ae1
feat(dashboard-v2): give the builder an editing shell
EnxDev Aug 5, 2026
07a6a21
feat(dashboard-v2): let the dashboard be named, beside History
EnxDev Aug 6, 2026
6303bb2
feat(dashboard-v2): let a block be given its content in Properties
EnxDev Aug 6, 2026
ee29863
style(dashboard-v2): size the header controls down
EnxDev Aug 6, 2026
5c8b3d2
feat(dashboard-v2): make the palette drag, and let a block be removed
EnxDev Aug 6, 2026
ecda147
fix(dashboard-v2): give a flex child the box every block is drawn in
EnxDev Aug 6, 2026
0bb9705
feat(dashboard-v2): give every block a header that names it
EnxDev Aug 6, 2026
b30ed4e
feat(dashboard-v2): scroll the outline to the block it selects
EnxDev Aug 6, 2026
03873de
feat(dashboard-v2): let a block be put in front of the one it overlaps
EnxDev Aug 6, 2026
0a24a9c
style(dashboard-v2): draw one frame around a block, with its name inside
EnxDev Aug 6, 2026
7760261
feat(dashboard-v2): ask the dashboard for the properties a dashboard has
EnxDev Aug 6, 2026
6cb15c5
feat(dashboard-v2): let the editor panel be got out of the way
EnxDev Aug 6, 2026
c3c1dee
feat(dashboard-v2): sort the header by what each control acts on
EnxDev Aug 6, 2026
e27290b
feat: adds the copy button for the json
EnxDev Aug 6, 2026
085fa18
style: adds color to the icon
EnxDev Aug 6, 2026
80a58e3
style(dashboard-v2): improve the style of the controls
EnxDev Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions superset-frontend/packages/superset-core/src/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,57 @@ export declare const onDidChangeDisplayMode: Event<DisplayMode>;
*/
export declare const onDidResizePanel: Event<{ width: number }>;

// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.
/** The normalized answer returned to the assistant by a browser-owned tool. */
export interface ClientToolResult {
content: string;
isError?: boolean;
}

/** The part of a browser-owned tool definition that is safe to send to a model. */
export interface ClientToolSpec {
/** Unique tool name. Later registrations take precedence on collisions. */
name: string;
/** Explain when the model should use the tool and what it returns. */
description: string;
/** JSON Schema describing the tool's argument object. */
inputSchema: Record<string, unknown>;
}

/**
* A tool implemented by the page in the user's browser.
*
* The handler is never sent to the chat extension's backend. It executes in
* the active Superset page and should return concise, model-readable content.
*/
export interface ClientTool extends ClientToolSpec {
execute: (
args: Record<string, unknown>,
) => Promise<ClientToolResult> | ClientToolResult;
}

/**
* Registers browser-owned tools for the lifetime of a page or component.
* Dispose the returned registration when that owner unmounts. If names
* collide, the most recently registered group wins until it is disposed.
*/
export declare function registerClientTools(
tools: readonly ClientTool[],
): Disposable;

/** Returns the available client-tool descriptions without their handlers. */
export declare function getClientTools(): ClientToolSpec[];

/**
* Executes a registered browser-owned tool.
*
* This promise always resolves. Unknown tools, thrown handlers, rejected
* handlers, and invalid results are normalized to `isError: true` so an
* assistant can always continue its tool-call loop.
*/
export declare function executeClientTool(
name: string,
args: Record<string, unknown>,
): Promise<ClientToolResult>;

/** Fires whenever the resolved set of browser-owned tools changes. */
export declare const onDidChangeClientTools: Event<ClientToolSpec[]>;
66 changes: 60 additions & 6 deletions superset-frontend/packages/superset-core/src/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,52 @@

import type { Event } from '../common';

/**
* How a container arranges its own children.
*
* - `grid` (the default, and what every node written before this field
* existed still means) — children occupy cells of the container's column
* grid and are compacted upward, so the space a removed or moved block
* leaves behind closes itself.
* - `free` — the same cells, uncompacted. A child stays exactly where it was
* put and may overlap a sibling. It reads the same four child fields
* `grid` does, so switching a container between the two never discards a
* position an author or an agent set.
* - `flex` — children flow along a line and wrap, sharing that line in
* proportion to their `colSpan` instead of occupying named cells. Their
* position is their order in `children`, not a coordinate.
*
* A flow is still not a mode, and the two that are here are the two that
* genuinely could not be expressed as a grid: `free` because compaction is a
* property of the container rather than of any child's coordinates, and
* `flex` because a proportional line has no cells to name.
*/
export type LayoutMode = 'grid' | 'free' | 'flex';

/** Where `flex` children sit along the line they flow down. */
export type FlexJustify =
'start' | 'center' | 'end' | 'space-between' | 'space-around';

/** Where `flex` children sit across the line they flow down. */
export type FlexAlign = 'start' | 'center' | 'end' | 'stretch';

/**
* Layout of a single node: a grid it lays out its own children in (only
* meaningful when the node is a container — ignored on leaf nodes), plus
* where the node itself sits within its *parent's* grid. A node can be both
* at once — a `canvas` nested inside another `canvas` both holds a grid for
* its own children and occupies cells in its parent's.
*
* There is no separate "flow" or "absolute" mode: a single-column grid with
* every child left at its default full-width span behaves like a plain
* top-to-bottom stack — it falls out of the same schema rather than
* requiring a different one.
* There is no separate "flow" mode: a single-column grid with every child
* left at its default full-width span behaves like a plain top-to-bottom
* stack — it falls out of the same schema rather than requiring a different
* one. {@link LayoutMode} exists only for the arrangements that do not.
*/
export interface LayoutProps {
// --- Container side: how this node arranges its own children. Ignored
// on a node with no `children`. ---
/** How this node arranges its children. Default: `grid`. */
mode?: LayoutMode;
/** Number of equal fractional column tracks. Default: 24. */
columns?: number;
gap?: number;
Expand All @@ -79,8 +110,22 @@ export interface LayoutProps {
*/
rowUnit?: number;

// --- Container side, `flex` only. Ignored in every other mode, rather
// than quietly changing what a grid does. ---
/** Which way `flex` children flow. Default: `row`. */
direction?: 'row' | 'column';
/** Whether a `flex` line wraps once it is full. Default: true. */
wrap?: boolean;
justify?: FlexJustify;
align?: FlexAlign;

// --- Child side: where this node sits within its parent's grid. ---
/** How many of the parent's columns this node spans. Default: every column (full width). */
/**
* How many of the parent's columns this node spans. Default: every column
* (full width). In a `flex` parent there are no columns to occupy, so this
* is read as the node's share of the line instead — two siblings at 6 and
* 12 take a third and two thirds of it.
*/
colSpan?: number;
/** How many row tracks this node spans. Default: 1. */
rowSpan?: number;
Expand Down Expand Up @@ -112,7 +157,16 @@ export interface DashboardNode {
* repositioning a node on the canvas never changes this array.
*/
children?: string[];
/** Leaf/building-block nodes only — functional/content config. */
/**
* Functional/content config. Leaf/building-block nodes carry whatever
* their renderer reads.
*
* The root `canvas` is the one container that also carries some: it is the
* only node a fact about the dashboard *itself* can belong to, so that is
* where its `title` lives. A title placed as a `markdown` block is a
* different thing — that one is content, arranged like any other block;
* this one is what the dashboard is called.
*/
props?: Record<string, unknown>;
/** Leaf/building-block nodes only — visual customization. */
style?: Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'explore'`, `'dashboard'`, `'dashboard_v2'` and `'dataset'` are the
* single-entity editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
Expand All @@ -41,6 +41,7 @@ import { Event } from '../common';
*/
export type Page =
| 'dashboard'
| 'dashboard_v2'
| 'dashboard_list'
| 'explore'
| 'chart_list'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
FundProjectionScreenOutlined,
FunctionOutlined,
HighlightOutlined,
HolderOutlined,
HomeOutlined,
InfoCircleOutlined,
InfoCircleFilled,
Expand Down Expand Up @@ -121,6 +122,7 @@ import {
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
RedoOutlined,
ReloadOutlined,
RightOutlined,
SaveOutlined,
Expand All @@ -137,6 +139,7 @@ import {
TagsOutlined,
TableOutlined,
LockOutlined,
UndoOutlined,
UnlockOutlined,
UploadOutlined,
UpOutlined,
Expand Down Expand Up @@ -245,6 +248,7 @@ const AntdIcons = {
GoogleOutlined,
GroupOutlined,
HighlightOutlined,
HolderOutlined,
HomeOutlined,
InfoCircleOutlined,
InfoCircleFilled,
Expand Down Expand Up @@ -281,6 +285,7 @@ const AntdIcons = {
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,
RedoOutlined,
RightOutlined,
SaveOutlined,
SearchOutlined,
Expand All @@ -296,6 +301,7 @@ const AntdIcons = {
TagsOutlined,
TableOutlined,
LockOutlined,
UndoOutlined,
UploadOutlined,
UnlockOutlined,
UpOutlined,
Expand Down
104 changes: 104 additions & 0 deletions superset-frontend/src/core/chat/ChatProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,101 @@ test('setDisplayMode updates mode and fires event only on change', () => {
expect(modeChanged).toHaveBeenCalledWith('panel');
});

test('client tools are exposed without their browser handlers', () => {
const provider = ChatProvider.getInstance();
const execute = jest.fn(() => ({ content: 'outline' }));

provider.registerClientTools([
{
name: 'dashboard_get_state',
description: 'Reads the active dashboard.',
inputSchema: { type: 'object', properties: {} },
execute,
},
]);

expect(provider.getClientTools()).toEqual([
{
name: 'dashboard_get_state',
description: 'Reads the active dashboard.',
inputSchema: { type: 'object', properties: {} },
},
]);
});

test('the latest client-tool registration wins until it is disposed', async () => {
const provider = ChatProvider.getInstance();
provider.registerClientTools([
{
name: 'dashboard_get_state',
description: 'Earlier tool.',
inputSchema: { type: 'object' },
execute: () => ({ content: 'earlier' }),
},
]);
const latest = provider.registerClientTools([
{
name: 'dashboard_get_state',
description: 'Latest tool.',
inputSchema: { type: 'object' },
execute: () => ({ content: 'latest' }),
},
]);

await expect(
provider.executeClientTool('dashboard_get_state', {}),
).resolves.toEqual({ content: 'latest' });

latest.dispose();
await expect(
provider.executeClientTool('dashboard_get_state', {}),
).resolves.toEqual({ content: 'earlier' });
});

test('client-tool changes fire after registration and disposal', () => {
const provider = ChatProvider.getInstance();
const listener = jest.fn();
provider.onDidChangeClientTools(listener);
const registration = provider.registerClientTools([
{
name: 'dashboard_get_state',
description: 'Reads the active dashboard.',
inputSchema: { type: 'object' },
execute: () => ({ content: 'outline' }),
},
]);

expect(listener).toHaveBeenLastCalledWith([
expect.objectContaining({ name: 'dashboard_get_state' }),
]);

registration.dispose();
expect(listener).toHaveBeenLastCalledWith([]);
});

test('client-tool execution always resolves with a model-readable result', async () => {
const provider = ChatProvider.getInstance();
provider.registerClientTools([
{
name: 'throws',
description: 'Throws.',
inputSchema: { type: 'object' },
execute: () => {
throw new Error('bad arguments');
},
},
]);

await expect(provider.executeClientTool('missing', {})).resolves.toEqual({
content: 'Unknown client tool "missing".',
isError: true,
});
await expect(provider.executeClientTool('throws', {})).resolves.toEqual({
content: 'Client tool "throws" failed: bad arguments',
isError: true,
});
});

test('state reflects changes after registration and open', () => {
const provider = ChatProvider.getInstance();

Expand All @@ -248,10 +343,19 @@ test('reset clears all state', () => {
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.setDisplayMode('panel');
provider.registerClientTools([
{
name: 'dashboard_get_state',
description: 'Reads the active dashboard.',
inputSchema: { type: 'object' },
execute: () => ({ content: 'outline' }),
},
]);

provider.reset();

expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
expect(provider.getDisplayMode()).toBe('floating');
expect(provider.getClientTools()).toEqual([]);
});
Loading