Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
the file (in file order) instead of asking which single one to keep.

### Added
- **A Dashboard is now a full-size main work surface, selected by stable id**
(#425). Opening one replaces the complete SQL editor and result/data-drawer
area — the left sidebar stays visible — and a new toolbar carries
**Back to query**, the Dashboard title, and the View/Edit switch. Returning
finds the Query surface exactly as it was: the same editor contents,
selection, scroll, active tab, result view, and editor/results split, because
the surface is hidden rather than rebuilt. A surface change no longer cancels
the query running in the editor, and Back/Forward between surfaces of one
workspace no longer tears the editor down. Any Dashboard in the workspace can
be opened by its stable id in View or Edit mode, with an optional navigation
target that focuses, scrolls to, and briefly highlights one panel tile (by
tile id, never query id) or one curated filter (by filter id). Selection is
session state — never persisted, cleared on sign-out, and re-validated against
every committed workspace, so a deleted or ambiguous selection falls back to
Query mode instead of silently switching to another Dashboard; URLs are
unchanged. Export Dashboard and Import Dashboard now address the selected
Dashboard rather than the workspace's first one; the favourite star, which
still drives panel membership through the first Dashboard, declines with an
explanation while a different one is open (it is rewired in #427).
- Surface-aware keyboard shortcuts for SQL Browser and Dashboard (#417). The
shared, platform-aware shortcut catalog now drives both help and dispatch;
Dashboard gains refresh, View/Edit, and `G` navigation commands while stale
Expand Down
56 changes: 53 additions & 3 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# ADR-0003: Dashboard viewing and unified `/sql` routes

- **Status:** Accepted; detached-snapshot decision superseded by #407 on
2026-07-23
- **Date:** 2026-07-18; revised 2026-07-23
- **Context tracking:** roadmap #68; #288, #302, #406, #407
2026-07-23; surface lifecycle amended by #425 on 2026-07-25 (see the addendum)
- **Date:** 2026-07-18; revised 2026-07-23, 2026-07-25
- **Context tracking:** roadmap #68; #288, #302, #406, #407, #425

## Context

Expand Down Expand Up @@ -89,6 +89,56 @@ records.
- OAuth uses one `/sql` redirect URI. Callback cleanup retains route parameters
while removing only OAuth callback parameters.

## Addendum (#425, 2026-07-25): surfaces are hosts in one persistent shell

The consequence above — "Dashboard route resources are disposed when switching
surfaces or rebuilding the current surface; the Workbench shell likewise disposes
signal and media listeners before remounting" — described a model where each
surface owned the whole page and every switch was a dispose-and-remount. #425
amends it, because a Dashboard must own the complete editor-plus-results area
*while the left sidebar stays visible*, and returning to the Query surface must
not reconstruct it.

What changes:

- One persistent shell (`ui/app-shell.ts`) owns `#root`: a header slot, the
sidebar, the mobile nav, and two sibling hosts — the query column
(`ui/workbench/workbench-shell.ts`) and the Dashboard. Exactly one host is
exposed; the hidden one keeps its DOM and its state and contributes no layout.
- The query column is mounted once per signed-in workspace. A surface switch no
longer disposes it and no longer calls `workbench.destroy()` — that aborts the
in-flight request and issues `KILL QUERY`, and a presentation change must never
cancel the query in the editor. Real end-of-life events (a workspace switch,
workspace-not-found/loading, sign-out) still tear everything down, and every
path that replaces `#root` wholesale must forget the shell handle so the next
render re-mounts.
- The Dashboard surface is still disposed when left: its viewer session, window
listeners, and pending focus work go, and its host is emptied — the host
outlives the surface, so a disposed Dashboard must not leave DOM behind.
- Each surface still builds its own header, now into the shell's slot, so only
one header is ever mounted.

Selected-Dashboard state (`application/main-surface.ts`) is session state: which
Dashboard, in which mode, with an optional focus target, identified only by
`DashboardDocumentV1.id` and never by collection position. It is not persisted —
`StoredWorkspaceV3` gains no `activeDashboardId`/`defaultDashboardId` — is cleared
on sign-out, and is re-validated against every committed workspace, falling back
to Query mode rather than silently retargeting another Dashboard. It is also the
single writer of the route's `surface`/`mode`, so the URL is always derived from
it. **Routes are unchanged:** the URL still carries only `ws`, `surface`, and
`mode`, which is why Back/Forward inside the Dashboard surface deliberately
preserves the explicit selection instead of re-deriving one from the
compatibility selector.

Two consequences worth recording:

- Edit mode renders the same single filter bar as View, so #425's "focus the
filter editor/control in Edit mode" collapses to one control per filter — not a
dropped requirement.
- The schema tree is no longer refetched on a Dashboard→Workbench round trip
(`catalog.loadSchema()` moved to the shell's one-time mount). The #343
external-change refresh path still covers staleness.

## Alternatives considered

- **Durable detached snapshots:** rejected because they silently diverge from
Expand Down
137 changes: 137 additions & 0 deletions src/application/main-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// The main work surface's SESSION state (#425): which of the two mutually
// exclusive surfaces — Query (SQL editor + result/data drawer) or Dashboard —
// owns the right-hand work area, and, for a Dashboard, WHICH stored Dashboard
// is selected, in which presentation mode, with an optional navigation focus
// target.
//
// Deliberately session state, never persisted workspace content:
// `StoredWorkspaceV3` carries no `activeDashboardId`/`defaultDashboardId`, and
// a Dashboard is identified ONLY by its stable `DashboardDocumentV1.id` —
// never by its position in `dashboards[]`. Sign-out and a workspace switch
// therefore clear or re-validate the selection rather than migrating it.
//
// Pure: no DOM, no persistence, no globals. Lives in `src/application/` (not
// `src/core/`) because it resolves against the workspace aggregate, and the
// dependency direction is `workspace <- application <- UI`; `src/core/` must
// never import `src/workspace/` (build/check-boundaries.mjs).

import { findDashboardStrict, type WorkspaceDashboards } from '../workspace/workspace-dashboards.js';
import type { SqlRoute } from '../core/sql-route.js';

/** Where a caller wants navigation to land INSIDE the opened Dashboard. A tile
* is addressed by its Dashboard-local TILE id (never the saved-query id it
* renders); a curated filter by its filter-definition id. */
export type DashboardFocusTarget =
| { kind: 'tile'; id: string }
| { kind: 'filter'; id: string };

/** View is a presentation choice over the same live document, not an
* authorization boundary (ADR-0003). */
export type DashboardSurfaceMode = 'view' | 'edit';

export type MainSurfaceState =
| { kind: 'query' }
| {
kind: 'dashboard';
dashboardId: string;
mode: DashboardSurfaceMode;
focus: DashboardFocusTarget | null;
};

/** The one application-level Dashboard navigation request (#425). */
export interface OpenDashboardRequest {
dashboardId: string;
mode: DashboardSurfaceMode;
focus?: DashboardFocusTarget;
}

/** The Query surface carries no parameters, so one frozen value serves every
* transition to it — and identity comparison is a legitimate test for "we
* fell back to Query mode". */
export const QUERY_SURFACE: MainSurfaceState = Object.freeze({ kind: 'query' as const });

/** `resolveOpenDashboard`'s outcome. `missing`/`duplicate` are reported through
* the caller's diagnostic path and change NO state: opening a Dashboard must
* never mutate anything, and an ambiguous id must never be resolved by a
* guess. */
export type OpenDashboardResolution =
| { status: 'ok'; surface: MainSurfaceState }
| { status: 'missing' }
| { status: 'duplicate' };

/**
* Resolve an open request against the ACTIVE workspace's Dashboard collection,
* by exact id. A `null` workspace (none loaded, or a corrupt/not-found route)
* resolves as `missing` — there is nothing to address an id against.
*/
export function resolveOpenDashboard(
workspace: WorkspaceDashboards | null, request: OpenDashboardRequest,
): OpenDashboardResolution {
if (!workspace) return { status: 'missing' };
const lookup = findDashboardStrict(workspace, request.dashboardId);
if (lookup.status !== 'ok') return { status: lookup.status };
return {
status: 'ok',
surface: {
kind: 'dashboard',
dashboardId: request.dashboardId,
mode: request.mode,
focus: request.focus ?? null,
},
};
}

/**
* Re-validate a selection against committed truth. A selected Dashboard that
* was removed — or whose id became ambiguous — falls back to **Query** mode
* rather than silently retargeting to another Dashboard. Called after every
* committed workspace projection and after a workspace switch, which is exactly
* what makes "switching workspaces clears the selection unless the new
* workspace contains the same explicitly selected id" fall out for free.
*/
export function reconcileMainSurface(
surface: MainSurfaceState, workspace: WorkspaceDashboards | null,
): MainSurfaceState {
if (surface.kind === 'query') return surface;
if (workspace && findDashboardStrict(workspace, surface.dashboardId).status === 'ok') return surface;
return QUERY_SURFACE;
}

/** The canonical `/sql` route for a surface. #425 leaves URLs unchanged: the
* selected Dashboard id is session state and never appears in the URL, so the
* route still carries only workspace + surface + mode. */
export function mainSurfaceRoute(
surface: MainSurfaceState, workspaceKey: string | null,
): SqlRoute {
return surface.kind === 'dashboard'
? { surface: 'dashboard', workspaceKey, mode: surface.mode }
: { surface: 'workspace', workspaceKey };
}

/** The selected Dashboard id, or `null` in Query mode — the render target's
* `dashboardId`, where `null` also covers "this workspace has no Dashboard
* yet" and lands on the Create-dashboard placeholder. */
export function selectedDashboardId(surface: MainSurfaceState): string | null {
return surface.kind === 'dashboard' ? surface.dashboardId : null;
}

/** True when an open request targets the ALREADY-selected Dashboard in the
* already-active mode — the caller then keeps the live viewer session and only
* applies the new focus target, so a repeated open never builds a duplicate
* Dashboard session. */
export function isSameDashboardSelection(
surface: MainSurfaceState, request: OpenDashboardRequest,
): boolean {
return surface.kind === 'dashboard'
&& surface.dashboardId === request.dashboardId
&& surface.mode === request.mode;
}

/** Drop a consumed focus target, keeping the selection. Applied once the focus
* has been delivered (or reported missing) so a later repaint — an external
* workspace change, a style switch — cannot re-focus and re-highlight a tile
* the user has since navigated away from. */
export function withoutFocus(surface: MainSurfaceState): MainSurfaceState {
if (surface.kind !== 'dashboard' || surface.focus === null) return surface;
return { ...surface, focus: null };
}
63 changes: 58 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,22 @@ body {
instead of a brittle hard-coded viewport `top`. No effect on the desktop
flex layout. */
.main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; position: relative; }
/* #425: the persistent shell mounts the surface-specific header into a stable
slot so it can be replaced without rebuilding the sidebar around it.
`display: contents` keeps `.app-header` ITSELF the direct child of `#root`'s
flex column — an ordinary block wrapper would become the flex item instead and
lose the header's own `flex-shrink: 0`, letting it squash. */
.app-header-slot { display: contents; }
/* #425: the two mutually exclusive main work-surface hosts — the Query surface
(SQL editor + result/data drawer) and the Dashboard — as siblings of the
sidebar, so a Dashboard owns the whole right-hand work area while the sidebar
stays visible. Exactly one is exposed at a time; `[hidden]` needs the explicit
override because a class rule's `display: flex` beats the UA stylesheet's bare
`[hidden]` (the same reason `.document-editor[hidden]` carries one). */
.query-host, .dashboard-host {
flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0;
}
.query-host[hidden], .dashboard-host[hidden] { display: none !important; }
.sidebar {
display: flex; flex-direction: column;
background: var(--bg-side);
Expand Down Expand Up @@ -2474,11 +2490,23 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
/* ---- Full-screen view switching (main-row[data-mobile-view]) ---- */
/* Tables → sidebar fills; workbench hidden. Sidebar is a normal flow child
here (not the desktop resizable column), full width. */
.main-row[data-mobile-view="tables"] .workbench { display: none; }
/* #425: hide the HOST, not just `.workbench` inside it — the host is the
flex:1 sibling of the sidebar now, so leaving it laid out would fight the
full-width sidebar below. */
.main-row[data-mobile-view="tables"] .query-host { display: none; }
.main-row[data-mobile-view="tables"] .sidebar { display: flex; width: 100% !important; }
/* Editor / Results → sidebar hidden, workbench fills. */
.main-row[data-mobile-view="editor"] .sidebar,
.main-row[data-mobile-view="results"] .sidebar { display: none; }
/* #425: a Dashboard is full-bleed on mobile (#248) — the sidebar and the
bottom Tables/Editor/Results nav belong to the Query surface, and its three
`data-mobile-view` values say nothing about a Dashboard. Hiding both keeps
the pre-#425 mobile Dashboard presentation exactly as it was, and takes
precedence over the `[data-mobile-view="tables"]` sidebar rule above by
being later with equal specificity. */
.main-row[data-surface="dashboard"] .sidebar,
.main-row[data-surface="dashboard"] .col-resize { display: none; }
.main-row[data-surface="dashboard"] ~ .mobile-nav { display: none; }
/* Editor view: query tabs + toolbar + editor; hide the results half. */
.main-row[data-mobile-view="editor"] .results-region,
.main-row[data-mobile-view="editor"] .editor-results-split { display: none; }
Expand Down Expand Up @@ -2536,11 +2564,36 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
.docs-panel { width: 100vw !important; min-width: 0; }
}

/* ── Dashboard (#149 D1 / #407) ─────────────────────────────────────────────
The `/sql?surface=dashboard` surface: sticky header + responsive tiles. */
/* The dashboard's own scroll container: #root is a fixed overflow:hidden flex
column (the workbench shell), so the dashboard fills it and scrolls itself. */
/* ── Dashboard (#149 D1 / #407 / #425) ──────────────────────────────────────
The Dashboard main work surface: sticky toolbars + responsive tiles. */
/* The dashboard's own scroll container. #425: it now fills `.dashboard-host` —
the flex:1 sibling of the sidebar inside `.main-row` — rather than all of
`#root`, so a Dashboard owns the whole editor-plus-results area while the
sidebar stays visible. The host is itself a `min-height:0` flex column, so
`height:100%` here still resolves and the page still scrolls itself. */
.dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); }
/* #425 — the Dashboard surface's own toolbar row: Back to query, the Dashboard
title, then the View/Edit switch pushed right by the shared spacer. */
.dash-surface-toolbar { gap: 8px; }
.dash-surface-title {
margin: 0; font-size: 13px; font-weight: 600; color: var(--fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The title is the ONLY control in this row allowed to shrink, so a long
Dashboard name ellipsizes instead of pushing View/Edit off a narrow screen.
Two-class specificity is required to beat `.dash-toolbar > * { flex-shrink: 0 }`
below, which correctly pins every other toolbar child. */
.dash-surface-toolbar .dash-surface-title { flex: 0 1 auto; min-width: 0; }
.dash-back-to-query { flex-shrink: 0; }
/* #425 — the temporary navigation highlight marking the tile or filter a caller
navigated to, cleared after a bounded interval or on the next user interaction.
A `box-shadow` ring, NOT an outline: an element has exactly one outline, so an
outline here would replace the focus ring rather than sit alongside it, and
#425 asks for a highlight in ADDITION to the normal focus indicator. */
.is-nav-target {
box-shadow: 0 0 0 2px var(--accent), 0 0 0 5px color-mix(in srgb, var(--accent) 25%, transparent);
border-radius: 6px;
}
/* The one-row application header and optional filter toolbar share one sticky
top bar so Dashboard controls stay visible while the grid scrolls. */
.dash-topbar { position: sticky; top: 0; z-index: 40; background: var(--bg-header); }
Expand Down
17 changes: 7 additions & 10 deletions src/ui/app-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,17 @@ export function routeButton(

function surfaceSwitch(app: App): HTMLElement {
const dashboard = app.sqlRoute.surface === 'dashboard';
// The header stays mounted when File → New workspace swaps the active
// aggregate. Resolve at click time so its route never retains the workspace
// key from the header's original render.
const workspaceKey = (): string => app.currentWorkspace?.key ?? app.state.workspaceKey;
// #425: both controls go through the main-surface navigation API rather than
// writing a route themselves, so the session surface stays the ONE writer of
// the URL. The Dashboard side has no chooser yet (#426 adds the tree), so it
// opens the compatibility Dashboard by id. Resolved at click time — the header
// stays mounted when File → New workspace swaps the active aggregate.
return h('div', {
class: 'editor-mode-switch app-surface-switch',
role: 'group', 'aria-label': 'Application surface',
},
routeButton('SQL Browser', !dashboard, () => {
void app.navigateSqlRoute({ surface: 'workspace', workspaceKey: workspaceKey() }, 'push');
}),
routeButton('Dashboard', dashboard, () => {
void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: workspaceKey(), mode: 'edit' }, 'push');
}));
routeButton('SQL Browser', !dashboard, () => { app.showQuerySurface(); }),
routeButton('Dashboard', dashboard, () => { app.showDashboardSurface('edit'); }));
}

/** The one application header used by both Workbench and Dashboard. */
Expand Down
Loading
Loading