diff --git a/CHANGELOG.md b/CHANGELOG.md
index df881644..e1681fbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md
index 3b5870b9..c60c172c 100644
--- a/docs/ADR-0003-dashboard-viewing.md
+++ b/docs/ADR-0003-dashboard-viewing.md
@@ -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
@@ -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
diff --git a/src/application/main-surface.ts b/src/application/main-surface.ts
new file mode 100644
index 00000000..aae05e19
--- /dev/null
+++ b/src/application/main-surface.ts
@@ -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 };
+}
diff --git a/src/styles.css b/src/styles.css
index ad6bf967..f87fd805 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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);
@@ -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; }
@@ -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); }
diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts
index 88613f8a..43f081b3 100644
--- a/src/ui/app-header.ts
+++ b/src/ui/app-header.ts
@@ -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. */
diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts
new file mode 100644
index 00000000..69cfbbad
--- /dev/null
+++ b/src/ui/app-shell.ts
@@ -0,0 +1,252 @@
+// The persistent application frame (#425 follow-up prep) — the header slot,
+// the sidebar (schema + saved/library panes and their splitters), and the
+// mobile bottom-nav, plus the reactive effects that repaint them and the
+// catalog bootstrap-load tail. Split out of `ui/workbench/workbench-shell.ts`'s
+// former `mountWorkbenchShell` body so a later commit can keep this frame
+// mounted while swapping only the workbench column for a Dashboard host —
+// `queryHost`/`dashboardHost` below are the two swappable slots. Every line
+// is moved byte-identically (ported originally from `app.ts`'s own
+// `renderApp` — #276 Phase 5); see the individual comments for their
+// original rationale, carried over unchanged.
+//
+// This module does NOT build the header itself: `ui/app.ts`'s `renderApp`
+// calls `buildAppHeader(app)` and hands the result to `setHeader()` AFTER
+// both this shell and the workbench shell are mounted. That means the
+// `app.dom = {}` reset below still happens exactly once, before any header
+// exists (satisfying every other module that reaches into `app.dom.*`
+// directly) — but it also means the `libraryName`/`libraryDirty` effect
+// below can observe a null `app.dom.libraryTitle`/`dashboardNav` on its
+// first, registration-time run, well before `setHeader` ever populates them.
+// `renderLibraryTitle`/`renderDashboardNav` (file-menu.ts) are already
+// null-safe for exactly this reason; the effect re-runs (and paints for
+// real) on the next `libraryName`/`libraryDirty` change, by which point
+// `setHeader` has long since run.
+//
+// `deps.app` is kept for the same reasons `mountWorkbenchShell` keeps it
+// (see that module's own header comment): the render-module pass-through
+// (renderSchema/renderSavedHistory/renderLibraryTitle/renderDashboardNav all
+// still take the full `App`), and the `app.dom` reset + population other
+// modules read `app.dom.*` off of directly.
+
+import { h } from './dom.js';
+import { Icon } from './icons.js';
+import { MOBILE_BREAKPOINT_PX } from '../state.js';
+import type { AppState as State } from '../state.js';
+import { effect } from '@preact/signals-core';
+import { renderSchema } from './schema.js';
+import { renderSavedHistory } from './saved-history.js';
+import { renderLibraryTitle, renderDashboardNav } from './file-menu.js';
+import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitters.js';
+import { startDrag } from './splitters.js';
+import type { App } from './app.types.js';
+import type { SchemaCatalogService } from '../application/schema-catalog-service.js';
+import type { AppPreferences, PreferenceKey } from '../application/app-preferences.js';
+
+/** `mountAppShell`'s dependency bag. See this file's header comment for the
+ * `app` field's rationale — every other field is read directly by this
+ * shell's own logic, never through `app.*`. */
+export interface AppShellDeps {
+ /** Kept ONLY for: the render-module pass-through (renderSchema/
+ * renderSavedHistory/renderLibraryTitle/renderDashboardNav), and the
+ * `app.dom` reset + population (other modules read `app.dom.*`
+ * directly — see the header comment). */
+ app: App;
+ root: Element | null;
+ document: Document;
+ state: State;
+ catalog: Pick