Repository navigation
Dashboard tree management: direct Dashboard and Panel actions #429
Description
Depends on: #424, #426, #427, #447 (phases 1–2, shipped)
Blocked for phases 2–5 until PR #484 (#428) merges — it rewrites the same rows.
Cleared: #484 merged 2026-07-27 (deb552a), so phase 2 is unblocked.
Prerequisite for the focus-tile gestures only: #438
Absorbs #472, #443, #449, #431, #476, #481 — this issue is the single owner of
Dashboard-tree management and click operations (owner decision, 2026-07-27).
Each absorbed issue's own section below is the merged spec; all six are closed.
Phases
- 1 — Resolve-before-navigate and blank-title trim (openSavedQuery switches surfaces before resolving the id, and stays silent when it fails #443 + A whitespace-only tile title survives into accessible names #476) — PR fix(#429): resolve a saved query before navigating, trim blank tile titles (phase 1) #490
- 2 — Dashboard-row gesture split: chevron / name / trailing (Separate Dashboard-tree expansion from opening the Dashboard #472) — PR feat(#429): split the Dashboard tree row into chevron, name and trailing targets (phase 2) #492
- 3 — Dashboard-document metadata: one create path + rename pencil (Reconcile the two Dashboard-creation paths behind one action #481) — PR feat(#429): unify Dashboard creation and add the metadata rename pencil (phase 3) #495
- 4 — Panel-row edit: owned-query metadata pencil
- 5 — Panel-row delete: atomic tile + owned query, ownership safety
- 6 — Remove obsolete repair planner; preserve fail-closed ownership safety (Remove obsolete saved-query repair planner and retire #429 repair phase #500) — PR refactor(#429): remove obsolete repair planner (phase 6) #514
Why this order
Each phase is a vertical slice — pure planner, UI, and tests in one PR — rather
than a planners-first phase followed by a wiring phase. That is deliberate: #449
(absorbed here) exists precisely because this repo already shipped ~220 lines of
fully-covered planner with no production caller, and a planner-only phase
reproduces exactly that.
- Phase 1 first because both halves are prerequisites rather than features:
later phases add row commands overopenSavedQuery, and phase 3 is the first
code that lets a user author a title. Neither needs new UI, so it lands green
immediately — and it is the only phase that can proceed while PR feat(#428): assign a Library query to a Dashboard by dragging it #484 is open. - Phase 2 before 3–5 because every later phase hangs a control on the Dashboard
row. Re-splitting the row's click targets afterwards would rewire it twice and
re-run the whole gesture matrix. - Phase 3 pairs create with rename — one dialog primitive (
openNameDialog,
promoted out offile-menu.ts), one document level, and rename is what makes a
silently-defaulted create title recoverable, which was Reconcile the two Dashboard-creation paths behind one action #481's own argument. - Phase 4 before 5 so the metadata-dialog, stale-dialog and CAS patterns are
established on the non-destructive action before the destructive one reuses them. - Phase 6 follows the direct delete actions because its question was only
answerable once their ownership boundary existed. Remove obsolete saved-query repair planner and retire #429 repair phase #500 records the resulting
decision: delete the obsolete planner rather than wire a second mutation UX.
Which sections each phase claims
The spec below is organised by topic, not by phase. This is the mapping — a phase
delivers its sections plus the matching ## Tests subsections, and nothing else.
| Phase | Sections | Tests subsections |
|---|---|---|
| 1 | Make the open-query primitive resolve first; Blank means blank — trim before the fallback | Open-query primitive (#443); Blank titles (#476) |
| 2 | Final row behaviour → Dashboard row (revised); Reconcile with the trailing chrome that already exists (the redundant-menu-items decision) | Dashboard row gestures (#472); Rendering and event isolation |
| 3 | Dashboard-level metadata pencil; Reconcile the two Dashboard-creation paths | Dashboard metadata; the #481 Tests subsection |
| 4 | Panel pencil action; Concurrency and lifecycle | Panel edit; Concurrency |
| 5 | Panel trash action; Ownership safety; Action button rendering | Panel delete |
| 6 | Remove the obsolete repair planner | Retired repair planner (#449 / #431) |
Regressions applies to every phase. Each PR names the acceptance bullets it
claims; phase 6 claims only the planner disposition and does not absorb the
#438-gated focus work tracked separately.
Sequencing constraints
- PR feat(#428): assign a Library query to a Dashboard by dragging it #484 (Add Library drag assignment to Dashboard panels and variables #428) blocks phases 2–5. It adds ~326 lines to
src/ui/dashboard-tree.ts, ~43 tosrc/application/dashboard-tree-model.tsand
~628 test lines, wiring Library drop targets onto the same rows phase 2
re-splits. Land it first; do not run these concurrently. - Fix Dashboard focus and scrolling for flow-layout KPI tile groups #438 gates the focus-tile gestures in phases 2/4/5 on the flow layout engine
only. Either land Fix Dashboard focus and scrolling for flow-layout KPI tile groups #438 first or state per PR which engines were verified.
Goal
Own every Dashboard-tree management and click operation: the Dashboard-row gesture
split, Dashboard-document create/rename, and panel-row edit/delete.
Because #427 guarantees one Dashboard member owns one dedicated query, edits and deletion are local, predictable, and atomic. This issue must implement the complete mutation flow without requiring further product decisions.
Rescoped 2026-07-27 — curated filters are gone. #447 phase 1 ("hard
removal") has shipped:dashboard-v2REMOVED curated filter definitions, the
V4→V5 migration dropsdashboards[].filters, and a record carrying the removed
saved-query rolefilteris rejected as unsupported. So this issue's original
curated-filter row / pencil / trash sections and itsupdateOwnedFilter/
removeDashboardFiltercommands described objects that no longer exist, and
have been deleted rather than adapted.Panel tiles are now the only Dashboard members that own a dedicated query.
A variable is inferred from{name:Type}placeholders in panel-owned SQL, not
stored — its only persisted state isvariableConfigs[name], so it has no
pencil/trash symmetry with a panel. Variable-row management stays with #447
phase 3 and #465; the orphaned-option-SQL trash already shipped in #447
(dashboard-tree.tsbuildDeleteButton).
Final row behaviour
#426's Dashboard-row gestures do NOT remain unchanged — this issue revises them
(absorbs #472). Member-row gestures are unchanged from #426.
The collision that forced the merge: #426 shipped a Dashboard row where a single
click expands and a double-click/Shift-click opens, while this issue adds a
trailing pencil to that same row. #472 independently re-split the row into
chevron / name / trailing menu. Two changes rewiring one row's event handling
must be one change, or the second redoes the first.
Dashboard row (revised)
| Target | Action |
|---|---|
Disclosure chevron (> / expanded variant) |
Expand or collapse this Dashboard's hierarchy only |
| Dashboard name / primary row content (icon may be included) | Open this exact Dashboard in View mode |
| Shift-click on the primary name target | Open Edit mode (retained from #426) |
| Trailing pencil / action control | Edit Dashboard title + description; never navigates or expands |
Requirements:
- opening uses the stable id —
openDashboard({ dashboardId, mode: 'view' }),
never the title and never collection position; - clicking the name must not change expanded/collapsed state, and clicking the
chevron must not open, rerender, or focus the Dashboard surface; - double-clicking the name must not produce a duplicate navigation/history entry
or a second action; - the old delayed single-click/double-click arbitration is no longer needed on
Dashboard rows, because expansion and navigation now have separate explicit
targets. Member-row arbitration stays as Add a Databases/Dashboards switcher and dashboard hierarchy tree #426 built it; refactor shared code
only where safety requires it; - expansion state is not persisted in workspace data (Separate Dashboard-tree expansion from opening the Dashboard #472 non-goal, retained);
- Dashboard View/Edit rendering is unchanged (Separate Dashboard-tree expansion from opening the Dashboard #472 non-goal, retained).
Keyboard and screen reader:
- the disclosure control exposes
aria-expandedand an accessible name such as
Expand Executive / Collapse Executive; Enter/Space toggles expansion only; - activating the Dashboard name opens View mode;
- tree Left/Right keys keep the existing keyboard-tree collapse/expand model;
- focus styling must visibly distinguish the disclosure control from the
navigation target from the trailing action.
Panel row
| Interaction | Action |
|---|---|
| Click | Open dedicated query in Query surface |
| Double-click | Open Dashboard View and focus tile |
| Shift-click | Open Dashboard Edit and focus tile |
| Pencil | Edit dedicated query name and description |
| Trash | Remove panel and delete dedicated query atomically |
Make the open-query primitive resolve first (absorbs #443)
A panel row's plain click opens the dedicated query, so this issue owns the
primitive it calls. app.openSavedQuery(queryId) currently switches surfaces
before it knows the query exists, and reports nothing when it does not
(src/ui/app.ts):
const query = app.state.savedQueries.find((saved) => saved.id === queryId);
app.showQuerySurface(); // <- unconditional
if (query) { loadIntoNewTab(app, { ...query }); toEditorOnMobile(); }An unresolved id therefore yanks the user off whatever surface they were on,
pushes a history entry, opens no tab and shows no diagnostic — a dead click that
also loses their place.
Not reachable through the tree today: #426 made the tree cancel pending clicks on
workspace switch and on disposal, and every current caller resolves the id first
(saved-history.ts clicks a row it just rendered). It becomes live again the
moment this issue adds more row commands over the same primitive, which is why it
lands here.
Requirements:
- resolve the id first; on a miss report one diagnostic through
flashToastand
change no surface and no route; - a resolving id behaves exactly as today;
- check no caller depends on the current unconditional switch before tightening it.
Focus-tile gestures on flow layouts depend on #438
The Panel/filter tables above promise "focus tile" on double-click and
Shift-click. For a KPI tile under the flow layout engine that request
currently focuses and scrolls a display: contents element with no box, so the
Dashboard reports success while the tile stays off-screen with no focus ring
(#438 — which itself says to fix this "before #426 starts using tile-focus
navigation from the Dashboard tree").
#438 stays its own change (it is a substantial canvas focus/scroll-anchor spec).
This issue must not silently ship a focus gesture that no-ops there: either land
after #438, or state in the PR which layout engines the focus gestures are
verified on. Grafana-grid — the default engine since #321 — is unaffected.
Panel pencil action
Open the existing saved-query metadata editor or a narrow equivalent prefilled from the dedicated query:
Name
Description
The normal canonical fields are:
query.spec.name
query.spec.descriptionRequirements:
- query ID and SQL are not changed;
- Dashboard role remains
panel; - favourite remains absent/false and is not exposed as Dashboard membership;
- panel configuration, variants, size hints, time ranges, and unknown extension fields are preserved;
- do not create or modify
tile.title/tile.descriptionfor ordinary dedicated-query authoring; - preserve existing imported tile-local title/description overrides without silently deleting them;
- if an imported override currently masks query metadata, show a clear compatibility note or retain established display precedence rather than pretending the query name immediately controls the rendered tile;
- validate the complete workspace candidate;
- commit the query edit atomically;
- do not increment Dashboard revision for a query-metadata-only change unless the existing aggregate contract explicitly requires it; query mutation and Dashboard document mutation are distinct;
- refresh the tree row and any open Query/Dashboard surface after commit.
Cancel and Escape commit nothing.
Panel trash action
Show the existing destructive confirmation pattern:
Remove panel “<name>” from “<dashboard>”?
This also deletes its dedicated query copy.
On confirmation, execute one pure atomic command:
removeDashboardPanel({
workspace,
dashboardId,
tileId,
}): StoredWorkspaceV5Required transformation:
- resolve target Dashboard by ID;
- resolve tile by ID;
- resolve its query;
- verify through Separate Library queries from dashboard-owned query copies #427 ownership index that this query is owned by exactly this tile;
- remove the tile;
- remove its layout placement from primary layout;
- remove its placement from flow fallback where applicable;
- normalise layout through the active plugin;
- regenerate grafana-grid flow fallback;
- remove the dedicated query from
workspace.queries[]; - increment the Dashboard revision exactly once;
- replace only the target Dashboard;
- validate the complete candidate workspace;
- commit atomically.
Steps 8–9 of the original list — scrubbing the removed tile's id out of every
filter's explicit targets[], and preserving an absent targets as absent — are
gone with the filters themselves (#447). A variable targets nothing: it applies to
whichever panel-owned query declares its {name:Type}, so removing a tile removes
its declaration and the inference re-derives on its own. If that tile held the
last declaration of a variable name, any variableConfigs entry for it becomes an
orphan — preserved and shown but never executed, per the dashboard-v2 contract.
Do not delete orphaned config as a side effect of a tile delete; #447 gives the
user an explicit trash for it.
After success:
- remove the row from the tree;
- keep Dashboard/Panel group expansion state;
- move focus to the next panel row, previous row, or Panels group in that order;
- if the deleted tile was focused/open in Dashboard surface, keep the Dashboard open and clear the focus target;
- if its dedicated query was open in Query surface, close/replace that editor tab through the existing deleted-query behaviour;
- no Library query favourite or source query is changed.
Ownership safety
Simple delete is allowed only when the ownership index proves exactly one expected owner.
If malformed/imported data has:
- no query;
- multiple owners;
- wrong owner kind;
- mismatched Dashboard/member ID;
then:
- do not perform cascading deletion;
- show an integrity diagnostic;
- leave workspace unchanged;
- direct the user to import/repair diagnostics where an existing path exists.
Never guess which shared references should survive.
Remove the obsolete repair planner (absorbs #449 and #431)
Decision (#500): delete the unused planner; do not wire a repair dialog.
The planner predated the settled ownership model and the direct actions shipped
by #494. Under the supported model:
- Library contains only zero-owner queries, derived across the complete
Dashboard collection, so a query referenced by any Dashboard cannot expose a
Library trash action; - Panel trash removes one tile plus its exactly-owned query atomically;
- Dashboard trash removes the document plus only the dedicated queries whose
ownership it can prove; - malformed or shared ownership remains fail-closed rather than being repaired
by guessing; and - strict whole-workspace validation rejects query edits that would invalidate a
live Panel, leaving the workspace unchanged.
The original #431 route — deleting an owned query from Library while another
Dashboard references it — is no longer reachable through the Library UI, and
every Dashboard is reachable through the tree. The old planner therefore
duplicates the supported destructive actions without providing a production
path.
It is also unsafe to wire unchanged: one repair object was applied across every
Dashboard while tile ids are Dashboard-local, so coincidentally matching ids
could rewrite an unrelated tile. Its choices were derived from generic
tile-scoped diagnostics rather than from the requested mutation. Phase 6 removes
that implementation and its dedicated tests, while adding production-path tests
for the current ownership and validation boundary.
References: #500; src/state.ts (deleteSaved / commitSavedQuery);
src/dashboard/model/query-ownership.ts; and #494's direct removal commands.
Dashboard-level metadata pencil
Also add a pencil action to each Dashboard row for:
Dashboard title
Dashboard description
Requirements:
- Dashboard ID and order remain unchanged;
- increment Dashboard revision exactly once;
- preserve layout, tiles,
variableConfigs, and unknown fields; - update toolbar/tree title after commit;
- blank title follows current schema/validation requirements and safe fallback rules;
- no Dashboard trash action in this issue.
Blank means blank — trim before the fallback (absorbs #476)
This issue ships the first UI that authors a title, so it settles the
whitespace rule once for every consumer rather than per dialog.
A non-empty but whitespace-only title is currently truthy and wins the
fallback chain unfiltered — unlike queryName(), which trims before falling back
to Untitled. In src/dashboard/application/dashboard-viewer-session.ts:550:
const title = (typeof tile.title === 'string' && tile.title) || (query ? queryName(query) : tile.queryId) || tile.id;dashboardTileV1.title carries no minLength in schemas/, so such a document
is schema-legal. ts.title then composes accessible names directly — the
pre-existing 'Remove ' + ts.title + ' from the dashboard' and #471's
'Open ' + ts.title + ' in Workbench' — so a screen reader announces
"Open, — , in Workbench", and the visible .dash-tile-name loses its heading too.
Requirements:
-
trim before the fallback, so a whitespace-only authored title behaves exactly
like an absent one:const authored = typeof tile.title === 'string' ? tile.title.trim() : ''; const title = authored || (query ? queryName(query) : tile.queryId) || tile.id;
-
apply the same rule to any title this issue's dialogs write, so a user cannot
author a blank-but-truthy title through the new pencils; -
this is a behaviour change for existing documents carrying such a title —
they start showing the query name — which is why it needs its own test rather
than riding along silently. No shipped UI writestile.titletoday, so the
only affected documents are hand-authored or imported.
The row's navigation click/double-click/Shift-click remains unchanged; pencil stops propagation.
Reconcile the two Dashboard-creation paths (absorbs #481)
Two code paths create a Dashboard and they disagree. This issue owns the
Dashboard row / Dashboard-document level, and renameDashboard above is the
direct sibling of the missing create action, so the reconcile lands here rather
than in its own change (inbox #481, closed as absorbed).
Verified state on main at the time of folding this in:
- File ▸ New dashboard… (
src/ui/file-menu.ts:702-736) prompts through
openNameDialog(src/ui/file-menu.ts:1055) withDEFAULT_DASHBOARD_NAME
offered for editing, then commits through the additive pure
appendDashboard(src/workspace/workspace-operations.ts:57). - The empty-Dashboard placeholder's Create dashboard button
(src/ui/dashboard.ts:411-431) appliescreateEmptyDashboard's default title
silently and commits throughwithCompatibilityDashboard
(src/workspace/workspace-dashboards.ts:153), seating the document at entry 0.
Two consumers of one operation, two write primitives, two UX contracts —
CLAUDE.md hard rule 5 puts the shared primitive here.
What the user actually sees today
The placeholder renders only for an empty collection, so it is always the
first Dashboard in a fresh workspace that is created with no name prompt and
silently titled Dashboard; every later one prompts. Until this issue's rename
pencil exists, that first title can only be changed by export → edit → import.
The reachability guard this must not regress
renderMissingDashboard (src/ui/dashboard.ts:403) is reached only when no
document resolves (src/ui/dashboard.ts:502-508):
- with
dashboardId === null, that requiresdashboards[0]to be absent —
an empty collection (src/workspace/workspace-dashboards.ts:32-37); - a stale explicit id never gets here:
reconcileMainSurface
(src/application/main-surface.ts:227-241) maps any Dashboard surface whose id
is missing or duplicate toQUERY_SURFACE, after every committed
projection and every workspace switch.
That is what makes the placeholder's "collection already non-empty" refusal
unreachable today — and it stays unreachable after this issue, whose non-goals
keep excluding Dashboard deletion. It is still a latent trap worth removing
rather than carrying: the guard
if (!latest || resolveCompatibilityDashboard(latest).dashboard) return null
returns null, which mutateWorkspace answers as
{ ok: false, aborted: true } with no toast and no diagnostic
(src/ui/app.ts:2009-2011). If a future Dashboard delete ever makes that state
reachable, the button is a silent dead end, not an error.
Requirements
- one application command both callers invoke — a pure planner plus its thin
action wrapper, per the "Suggested application commands" contract below; - both paths prompt for a name, seeded with the same editable default, and
both commit throughappendDashboard; no caller reaches
withCompatibilityDashboardordashboards[0]to create; openNameDialogis promoted out ofsrc/ui/file-menu.tsinto a module both
the File menu and the Dashboard surface can import, with no behaviour change
(Escape / outside click / Cancel still commit nothing, focus still deferred);- the placeholder keeps revealing what it created — select the new id from the
mutation's owndatachannel, never re-derived from collection position — and
the File menu keepsrevealDashboard's sidebar swap; - delete the now-unreachable "collection is already non-empty" branch rather
than leaving a silentabortedno-op behind the shared command; - no new creation entry point is added here (the sidebar
+stays out of scope,
see Non-goals).
Tests
- both entry points produce a Dashboard titled exactly what was typed;
- accepting the default at either entry point yields the same title, and doing
it twice is two Dashboards (identity is the id, duplicates are legal); - cancelling at either entry point commits nothing;
- creating from the placeholder in a workspace that already holds Dashboards
appends and preserves every existing Dashboard and query in place; - the created id is what gets opened, under a concurrent commit that reorders
the collection; reconcileMainSurface's missing/duplicate → Query fallback still holds, so
the placeholder is still reached only for an empty collection.
Action button rendering
Use icon buttons consistent with existing Library rows:
- pencil icon;
- trash icon;
- Dashboard row gets pencil only;
- buttons visible on hover, row focus-within, and current keyboard action state;
- reserve or overlay space using the established list convention to avoid text jumping;
- long names remain ellipsised;
- buttons have accessible labels such as:
Edit panel Revenue;Remove panel Revenue;Edit dashboard Executive.
Buttons must be reachable by keyboard and retain visible focus indication.
Reconcile with the trailing chrome that already exists
This section was written before #426/#447 shipped their own row controls, so it
must not be read as building on a bare row. What is already there, in
src/ui/dashboard-tree.ts:
buildMenuButton— a trailing⋯on every row (dash-tree-menu-btn,
aria-haspopup="menu", labelActions for <row>), projectingrow.menufrom
src/application/dashboard-tree-model.ts. Dashboard rows currently offer
Open in View / Open in Edit; panel rows offer open / view / edit. Unavailable
entries renderdisabled+aria-disabledrather than merely greyed.buildDeleteButton— a trash rendered for an orphaned variable only,
whose confirmation isopenMenu-anchored on the trigger itself.
Consequences for this issue:
- the destructive confirmation pattern is already established and is
openMenu-based, not a dialog. Reuse it for the panel trash. Do not invent a
new shell:file-menu.ts's.fm-dialog-*is module-private, andwindow.confirm
is browser chrome this stylesheet cannot reach; - decide whether the new pencil is a third trailing control beside
⋯and
trash, or a row in the existing⋯menu. A third icon on a narrow sidebar row
is the reason to think twice — see the ellipsis/pinning constraint in
.dash-toolbar > *and the long-title rules; - once the name target opens View and Shift-click opens Edit (Separate Dashboard-tree expansion from opening the Dashboard #472), the
Dashboard row's Open in View / Open in Edit menu items are redundant.
Either drop them or keep them deliberately as the keyboard-discoverable
equivalent, and say which — they exist today precisely because a pointer-only
gesture would otherwise hide the operation; - any new markup-bearing module must be added to the source list in
tests/unit/typography-contract.test.js, or it escapes the "every rendered class
has a CSS rule" gate and can ship as unstyled user-agent chrome.
Concurrency and lifecycle
- dialogs capture workspace ID/generation and resource IDs, not object references as source of truth;
- re-resolve on confirm;
- if resource changed/deleted while dialog is open, cancel or show a stale-resource message;
- use optimistic concurrency/CAS repository flow;
- failed persistence or validation commits nothing;
- stale dialog completion cannot overwrite a newer workspace;
- only the targeted Dashboard revision changes where Dashboard content changes;
- other Dashboards remain canonical-value unchanged.
Suggested application commands
createDashboard({ name }); // #481: the ONE create path, commits via appendDashboard
renameDashboard({ dashboardId, title, description? });
updateOwnedPanelQueryMetadata({
dashboardId,
tileId,
name,
description?,
});
removeDashboardPanel({ dashboardId, tileId });updateOwnedFilter / removeDashboardFilter were dropped with the curated-filter
model (#447). Nothing replaces them here: a variable is inferred, and its only
persisted state already has a writer — withVariableConfig in
src/workspace/workspace-dashboards.ts, with the mutateWorkspace plumbing in
src/application/dashboard-variable-config.ts (#457).
Commands should be pure planners returning a valid candidate or diagnostics. UI must not splice arrays directly.
Tests
Rendering and event isolation
- Dashboard rows show pencil only;
- panel rows show pencil and trash;
- variable rows are unchanged by this issue — the Replace curated Dashboard filters with inferred Variables and batched option queries #447 orphan trash still renders for an orphaned variable and for no other row;
- actions appear on hover/focus;
- action clicks do not dispatch row navigation or drag;
- accessible labels include resource names;
- keyboard focus reaches every action.
Panel edit
- edits query name/description only;
- preserves SQL, role, panel config, variants, time ranges, unknown fields, and ID;
- does not create tile overrides;
- cancel changes nothing;
- tree and open surfaces refresh;
- other queries/Dashboards remain unchanged.
Panel delete
- removes tile and exactly owned query;
- removes primary/fallback placement;
- normalises layout and regenerates fallback;
- leaves an orphaned
variableConfigsentry in place when the removed tile held the last declaration of that variable name; - increments revision once;
- preserves source Library queries and favourites;
- handles open Query/Dashboard surface state;
- restores sensible tree focus;
- failed ownership check leaves everything unchanged.
Dashboard metadata
- edits title/description;
- preserves ID/order/content;
- increments revision once;
- updates tree and toolbar;
- cancel changes nothing.
Concurrency
- stale dialogs cannot commit;
- resource deleted while dialog open is handled safely;
- CAS conflict does not partially mutate;
- validation failure commits nothing;
- non-target Dashboards are unchanged.
Dashboard row gestures (#472)
- clicking only the chevron expands/collapses and does not navigate, rerender or
focus the Dashboard surface; - clicking the name opens View mode by stable id and preserves expansion state;
- Shift-click on the name opens Edit mode;
- repeated name activation causes no duplicate route/history entries;
- the trailing action is isolated from both expansion and navigation;
- chevron exposes
aria-expandedwith an Expand/Collapse accessible name, and
Enter/Space on it toggles expansion only; - keyboard users can expand the hierarchy and open the Dashboard independently;
- covered for collapsed and expanded rows, and for active and inactive Dashboards.
Open-query primitive (#443)
openSavedQuerywith an id absent fromstate.savedQueriesleaves
app.mainSurfaceand the route untouched and surfaces exactly one diagnostic;- a resolving id behaves exactly as before.
Retired repair planner (#449 / #431)
- the unused planner and its dedicated tests are deleted, with none of its API
or repair vocabulary left in production or test code; - Library projection excludes a query owned only by a different Dashboard,
and that query exposes no Library trash control; - deleting an ordinary zero-owner Library query still commits and reconciles
linked tabs while preserving every Dashboard; - deleting or invalidating a query owned only by a different Dashboard is
rejected by whole-workspace validation with state byte-identical; and - Dashboard tree: replace overflow menus with direct Panel focus, edit and delete actions (#429 phase 4) #494's direct Panel/Dashboard commands remain the only paths that cascade from
a Dashboard member to its dedicated queries.
Blank titles (#476)
- a whitespace-only
tile.titlefalls back to the query name, in both the
visible heading and every composed accessible name; - the new pencils cannot author a blank-but-truthy title;
- an absent title behaves as it does today.
Regressions
- row click/double-click/Shift-click contract remains unchanged;
- Library pencil/trash/star behaviour remains unchanged;
- Dashboard viewer/editor continues to work after edits/deletes;
- no owned query appears in Library after metadata edits.
Acceptance criteria
- Dashboard rows can edit title/description through a pencil action.
- Exactly one command creates a Dashboard; both the File menu and the empty-Dashboard placeholder prompt for its name and append (Reconcile the two Dashboard-creation paths behind one action #481).
- Panel rows can edit their dedicated query metadata and delete tile plus owned query atomically.
- Layout, fallback, revision, focus, open-editor, and concurrency cleanup are complete.
- Every destructive action verifies exact one-owner integrity and refuses ambiguous data.
- Action buttons do not interfere with tree navigation gestures.
- The Dashboard row's chevron, name and trailing action are three independent targets, each keyboard-operable and separately announced (Separate Dashboard-tree expansion from opening the Dashboard #472).
openSavedQueryresolves before it navigates and reports a miss (openSavedQuery switches surfaces before resolving the id, and stays silent when it fails #443).- The obsolete repair planner is removed; Library exposes deletion only for
zero-owner queries across the complete Dashboard collection, invalidating
edits remain atomic rejections, and Dashboard tree: replace overflow menus with direct Panel focus, edit and delete actions (#429 phase 4) #494 remains the sole cascading
Panel/Dashboard deletion UX (planSavedQueryMutation still has no production caller; deleteSaved rejects instead of offering its repairs #449, A Dashboard the UI cannot reach can make a saved query permanently undeletable #431, Remove obsolete saved-query repair planner and retire #429 repair phase #500). - A whitespace-only title never reaches a heading or an accessible name (A whitespace-only tile title survives into accessible names #476).
Non-goals
- Dashboard deletion, duplication, or reordering.
- New Dashboard-creation entry points — the sidebar
+button included.
Creation appears here only as the Reconcile the two Dashboard-creation paths behind one action #481 reconcile of the two paths that already
exist (File ▸ New dashboard… and the empty-Dashboard placeholder) behind one
command; this narrows the original "creation" non-goal deliberately rather
than opening the surface up. - Dragging existing members between Dashboards.
- Sharing a query among members.
- Editing SQL inside metadata dialogs; SQL remains edited through Query surface.
- Touch-specific interaction.
- Tree-based panel/filter reordering.
- Tile layout editing from the tree.
- Moving or redesigning the trailing Dashboard action menu as a control, and
persisting tree expansion state in workspace data (both retained from Separate Dashboard-tree expansion from opening the Dashboard #472). - Changing panel/filter row navigation gestures — only the Dashboard row's are
revised (Separate Dashboard-tree expansion from opening the Dashboard #472 non-goal, retained). - Flow-layout KPI focus and scroll mechanics (Fix Dashboard focus and scrolling for flow-layout KPI tile groups #438) and flow-layout KPI band
chrome — grip, resize, title (Flow layout's KPI band members have no delete, grip, resize or title chrome #475). This issue's tree trash does give those
members the delete path Flow layout's KPI band members have no delete, grip, resize or title chrome #475 lists as missing, but giving.dash-kpi-membera
real box stays Flow layout's KPI band members have no delete, grip, resize or title chrome #475's job. - The keyboard-accessible Add to dashboard… command for Library rows
(Keyboard-accessible "Add to dashboard…" command for Library queries #483) — that is a Library-tree affordance over Add Library drag assignment to Dashboard panels and variables #428's assignment command
layer, not Dashboard-tree management.
Activity
BorisTyshkevich commentedon Jul 26, 2026
Re-scoping note from #447 phase 1 (PR #458).
This issue asks for edit and delete actions for Dashboard panels and curated
filters. The curated-filter half no longer has a subject:
from the
{name:Type}placeholders in its panels' own SQL, so it appears anddisappears with those declarations — deleting one from the tree is explicitly a
non-goal of Replace curated Dashboard filters with inferred Variables and batched option queries #447 ("manually creating, renaming, deleting, or changing the type of
an active inferred variable from the tree");
variable configuration (stored option SQL whose name no panel declares any more)
has a trailing trash icon that confirms first and removes only
variableConfigs[name];editor scoped to that variable, and saving blank SQL removes the configuration.
So what remains here is the panel half: the trash action that deletes a tile
and the dedicated saved-query copy it owns as one atomic operation. #427's
CHANGELOG entry hands that over explicitly — removing a panel currently leaves its
owned copy with no owner, so the copy reappears in the Library beside the original
it was cloned from, and this issue is where that becomes a single deliberate delete.
Worth editing the title/body to drop "and curated filters" when this is picked up.
BorisTyshkevich commentedon Jul 27, 2026
Ship log — #429
Branch model: one PR per phase off
main. Every phase in the issue's plan landsgreen and self-consistent on its own (phase 1 is two primitive corrections; 2–5 are
additive row chrome; 6 is a decision), so no integration branch is needed. Each
phase gets its own PR and its own human merge gate.
⋯removed: panel pencil + panel/Dashboard trash (#494)Phases 4 and 5 were re-cut by #494, which supersedes the old "panel edit" /
"panel delete" split and folds the
⋯-menu removal in with them: the row chrome,both commands and the focus navigation had to land as one vertical slice or the
tree would carry two competing interaction models. What is left of #494 after
phase 4 is the Open-in-Dashboard button alone, which #438 gates.
The sequencing constraint that blocked phases 2–5 is cleared: PR #484 (#428,
Library-drag assignment) merged on 2026-07-27 as
deb552a, before phase 1 started.Phase 2 is unblocked.
Phase 1 — handoff
Landed (2 commits on
fix/resolve-before-navigate-429p1):src/ui/app.ts—openSavedQueryresolves the id againststate.savedQueriesbefore
showQuerySurface(). A miss raises oneflashToastand changes nosurface, no route, no tab.
src/dashboard/application/dashboard-viewer-session.ts:561— the authoredtile.titleis trimmed before the fallback chain, in the one place a tile'sdisplay title is resolved.
src/ui/app.types.ts(the miss contract) andsrc/ui/dashboard.ts:2185(ts.titleis "never blank", not "never empty").tests/unit/app.test.ts,tests/unit/dashboard-viewer-session.test.ts,tests/unit/dashboard.test.ts;CHANGELOG.md[Unreleased]→ Fixed ×2.Decisions taken (not in the issue):
That query is no longer part of this workspace.— copiedverbatim in shape from
openDashboard's existing missing-Dashboard toast(
app.ts:2366) rather than inventing a fourth phrasing next to it andfile-menu.ts's two. Reuse this wording for the phase-5 delete diagnostics.production callers, none dependent on the unconditional switch:
dashboard-tree.ts:289(rowopen-query),dashboard-tree.ts:350(post-assignment reveal — safe because
mutateWorkspaceprojects synchronouslyinside the awaited chain and returns
ok:falseif the route moved), anddashboard.ts:1855(Open in Workbench — the button isnullunlessqueryById.has(ts.queryId)). Phases 2, 4 and 5 add callers to this sameprimitive; they now inherit the report-and-stay-put behaviour for free.
app.ts:1895had its rationale reconciled, not just its code. The Add a Databases/Dashboards switcher and dashboard hierarchy tree #426comment justified cancelling deferred tree clicks partly on "a deleted panel's
deferred open would reach
openSavedQuerywith a dead id" — that hazard nowlives at the callee, so the comment says so. If a later phase revisits click
cancellation, that half of the argument is gone.
flashToastreusesONE
.share-toastelement per document (src/ui/toast.ts:35-41), soquerySelectorAll('.share-toast').lengthcan never exceed 1 and anycount-the-elements assertion is unfalsifiable. The test materialises the element
and spies on
classList.add, counting how many times a report raises it —verified to fail against a deliberate double-report. Every later phase's
"shows exactly one diagnostic" test must do the same; the obvious assertion
silently passes.
Deviations from the spec: none. Both requirement lists implemented verbatim,
including the issue's own suggested
authored || …expression.Deferred acceptance criteria — phase 1 claims exactly two bullets:
openSavedQueryresolves before it navigates and reports a miss (#443); awhitespace-only title never reaches a heading or an accessible name (#476).
Everything else → phases 2–6.
The issue's Blank-titles test list also carries "the new pencils cannot author a
blank-but-truthy title". No pencil exists yet, so that bullet is carried to
the phases that build them: phase 3 (Dashboard title) and phase 4 (panel query
name). Trim on write there; the read side is already settled.
#438 / layout engines: phase 1 adds no focus-tile gesture, so nothing needed
verifying on the flow engine. The obligation stands for phases 2, 4 and 5.
Verification:
npm test5816 passed / 177 files, per-file gate held;tsc --noEmitclean;npm run buildclean;npm run test:e2e281 passed onchromium + webkit. Sabotage-checked — with both production changes reverted,
exactly the four new tests fail and nothing else, so no existing test was
depending on the old behaviour.
Follow-ups filed: #489 (
inbox) —tile.descriptionhas the identicaluntrimmed-fallback shape one line below the title
(
dashboard-viewer-session.ts:562-563); a whitespace-only description still beatsthe query description and renders an empty
.dash-tile-desc. Out of #476's scope(it corrupts no accessible name), so not folded into this PR.
For phase 3, not filed as an issue because phase 3 already owns it:
src/ui/file-menu.tsreadsdashboard.titleuntrimmed at:630,:648,:1010and
:1044— the last one produces a whitespace export filename — whilesrc/application/dashboard-tree-model.ts:382trims it. Phase 3 is already requiredto "apply the same rule to any title this issue's dialogs write", so fix these four
when the rename pencil lands rather than leaving the File menu disagreeing with the
tree.
Phase 2 — handoff
Landed (2 commits on
feat/dashboard-row-gestures-429p2, PR #492):src/application/dashboard-tree-model.ts— the Dashboard row now emitssingle: open-dashboard(view),double: null, unchangedshift, and aone-item menu.
double === nullIS the view's arbitration switch — no newfield was added, and every later phase inherits it.
src/ui/dashboard-tree.ts—pressRowarbitrates only whenrow.double !== null(i.e. panel rows only);
buildChevronrenders a real<button>;toggleFromChevronmoves the keyboard owner and restores focus to the chevron;
syncRovingTabindexsyncs chevrons with their row.
src/styles.css—.dash-tree-chevbutton reset + a focus ring in a channel therow does not use.
tests/unit/dashboard-tree.test.ts(newdescribeblock for the disclosurecontrol),
tests/unit/dashboard-tree-model.test.ts,tests/unit/app.test.ts,tests/e2e/dashboard-tree.spec.js,tests/e2e/tile-open-workbench.spec.js(its
openDashboardhelper useddblclick);CHANGELOG.md→ Changed.Decisions taken (the issue delegated these to this phase):
Open in Viewdropped,Open in Editkept. View is the row's primary press now (pointer andEnter),so nothing about it is hidden — which was the item's only justification. Edit
exists only as Shift-click /
Shift+Enter, both hidden modifiers, so the menu iswhere it is discoverable at all. Consequence for phase 3: the Dashboard row
still carries exactly ONE trailing control, so the "third icon on a narrow sidebar
row" worry is not yet triggered — phase 3 may add a pencil icon or a second row
in this menu. The non-goal "moving or redesigning the trailing menu as a control"
was respected: only its contents changed.
<button>per row would have added one Tab stop per chevron for the wholecollection, breaking the ARIA tree's single-composite-tab-stop contract. It now
carries the same
tabindexas its row, so Tab walks the FOCUSED row's threetargets and then leaves.
syncRovingTabindexhad to learn about it too.commitUirepaints through
replaceChildren, and the generic restore lands on the row —so without this, one
Spaceexpanded and the nextEnternavigated. Thechevron toggle therefore also moves the keyboard owner to its own row (as the row's
own click already did), which is what keeps that button in the Tab order.
dispatches the same idempotent
open-dashboard(view)twice;app.openDashboardalready collapses a repeated same-id/same-mode open into a no-op. That is now
pinned by a REPEATED identical open pushes no second history entry in
tests/unit/app.test.ts(sabotage-checked) instead of being assumed. The treedeliberately does not duplicate the guard — it cannot see the route. Phases 4/5
add more callers of the same primitives and inherit this.
(and the coverage gate would have flagged two of them anyway):
the chevron's
cancelFor, the Add Library drag assignment to Dashboard panels and variables #428 drop handler'scancelFor, andtoggleRow'sDashboard arm (now
toggleGroupRow, assertingrow.group!). The reasoning, whicha later phase must not silently invalidate: only a row with a
doubleis everarbitrated, and that now means a PANEL row — which has no chevron and accepts no
drop. If any later phase gives a Dashboard row a double-click action, all three
guards come back.
Deviations from the spec: none. One reading worth recording: the global
## Tests→## Regressionsbullet "row click/double-click/Shift-click contractremains unchanged" cannot apply to Dashboard rows, since
### Dashboard row (revised)exists precisely to change them; it is read as scoping to the rows this issue does not
revise, and panel/variable/group rows are genuinely untouched (their tests are
unmodified).
The bug that only a real browser could show — worth internalising before phases
3–5 add more row chrome:
Deferred acceptance criteria — phase 2 claims exactly two bullets: the three
independent targets (#472), and action buttons do not interfere with tree navigation
gestures for the controls that exist today (chevron,
⋯, the #447 orphan trash;phases 4/5 re-assert it for the pencil and panel trash). The Dashboard pencil
itself is phase 3 — the
### Dashboard row (revised)table's fourth row and theRendering and event isolation bullets about pencils are deliberately not delivered
here.
#438 / layout engines: phase 2 adds no tile-focus gesture (a Dashboard row
navigates to a Dashboard, never to a member; member rows are untouched), so nothing
needed verifying on the flow engine. The obligation stands for phases 4 and 5.
Verification:
npm test5827 passed / 177 files, per-file gate held;tsc --noEmitclean;npm run buildclean;npm run test:e2e442 passed, 0failed on chromium + webkit + firefox. Sabotage-checked six ways (Enter/Space
bubbling past the chevron, no focus restore, no keyboard-owner move, every chevron
tabbable, the model split reverted,
Open in Viewrestored) — each caught by 1–6tests and nothing else. Also driven in the real served app against live
ClickHouse: chevron
BUTTONlabelledExpand Sales revenue, Tab order row →chevron →
⋯, three distinct focus treatments, chevron click expands withouttouching the URL, name click routes to
?surface=dashboard&mode=view, no page errors.Environment note for the next phase: the dependabot Playwright bump left all
three browser binaries stale, which presents as ~435 e2e failures at 1ms each with
Executable doesn't exist. Runnpx playwright install chromium webkit firefox.Firefox now launches in this sandbox — the old
unshare CLONE_NEWPIDEPERM isgone, so all three browsers are a local signal again.
An accessibility trap phases 3–5 will hit again. A
treeitemtakes itsaccessible name from its CONTENTS, so every labelled control you add to a row gets
folded into that row's own announcement. Measured with Chromium's accname engine
after the chevron landed:
Fixed by giving the row an explicit
aria-labelfromrowAccessibleName()(
ui/dashboard-tree.ts), composed from the MODEL's strings in paint order — name,count, status word, meta, marker label. Phase 3's pencil and phases 4/5's
pencil+trash need NO change to this, because the row's name no longer depends on
its children at all; but if you add row CONTENT (not a control) that should be
announced, add it to
rowAccessibleNameor it becomes invisible to a screen reader.Unit tests assert the exact composed name for a dashboard, group, orphan-variable and
broken-panel row; the e2e assertion goes through
getByRole, which is the onlycross-engine way to test this (happy-dom cannot compute an accessible name).
Review pass. An independent read-only audit against this phase's contract came
back clean on under-delivery, scope creep, non-goal breaches and rule-softening. Its
substantive finding was the accessible name above. Two smaller ones were taken:
#472's test list asks for coverage across collapsed/expanded × active/inactive and the
expanded-AND-current corner was missing, and a real
dblclick(not two synthesizedclicks) is now asserted to dispatch one repeated command.
Follow-ups filed: none. No out-of-scope defect surfaced that phases 3–6 do not
already own.
Phase 3 — handoff
Landed (1 commit on
feat/dashboard-metadata-429p3, PR #495):src/dashboard/application/empty-dashboard.ts—createEmptyDashboardgainsan optional
titleparam (defaultDEFAULT_DASHBOARD_TITLE, now exported).src/ui/dialog-shell.ts(new) —openDialogShell/openNameDialogpromotedout of
file-menu.ts, generalized with an explicitreturnFocusToand anonClosehook;closeOpenDialogShell()replaces thedialogClosersWeakMap +
app.dom.fileDialogbookkeeping with a single module-local"current open dialog" slot (with its own stale-vs-newer-dialog guard).
src/ui/file-menu.ts— repointed ontodialog-shell.ts(
openFileDialogShellsuppliesreturnFocusTo: app.dom.fileBtn ?? null,byte-identical to the old hardcoded restore);
doNewDashboarduses thewidened
createEmptyDashboardsignature.src/ui/dashboard.ts— the empty-workspace placeholder's Create button nowopens
openNameDialogand commits throughappendDashboard, dropping thedead
resolveCompatibilityDashboard(latest).dashboardguard (the!latesthalf stays — type-required, provably dead at runtime).
src/workspace/workspace-dashboards.ts—renameDashboard(pure planner).src/application/dashboard-title.ts(new) —commitDashboardRename, themutateWorkspacewrapper, modeled 1:1 oncommitVariableConfig.src/application/dashboard-tree-model.ts—DashboardTreeRow.renamable(true for the Dashboard row only).
src/ui/dashboard-tree.ts—buildRenameButton+openDashboardMetadataDialog(the two-field title/description dialog, built directly on
openDialogShellrather than folded into it — one consumer today);
DashboardTreeAppgainedacquireKeyboardOwner.src/styles.css—.dash-tree-rename-btn(shares.dash-tree-menu-btn'sreveal rule + its own
[aria-expanded="true"]override),.fm-dialog-textarea.src/ui/app.types.ts— removed the now-deadAppDom.fileDialogfield.tests/unit/dialog-shell.test.ts(new),dashboard-title.test.ts(new),
workspace-dashboards.test.ts,dashboard-tree-model.test.ts,dashboard-tree.test.ts,dashboard.test.ts,app.test.ts,typography-contract.test.js(addeddialog-shell.tsto the scannedsources);
tests/e2e/dashboard-tree.spec.js;CHANGELOG.md→ Added + Changed.Decisions taken (the issue delegated these to this phase):
promoted
openNameDialog.openNameDialogstays single-field (the shapeevery create-flow caller needs); the rename dialog is built directly on the
lower-level
openDialogShellprimitive and colocated indashboard-tree.ts— hard rule 5's "extract a shared primitive only when a SECOND consumer
appears" argues against forcing one shape to cover both today.
:focus-within-revealed icon buttonbefore the
⋯, not a new⋯menu item — matching the issue's own"Action button rendering" pencil/trash icon convention (pencil icon,
revealed on hover/focus,
Edit dashboard <name>label) even though thatsection is formally phase 5's. This keeps the Dashboard row at TWO trailing
controls (pencil +
⋯), not three, so the "third icon on a narrow row"concern the issue flags never triggers here. The existing one-item
⋯menu(Open in Edit) is untouched.
renameDashboardrefuses (commits nothing) on a blank-after-trim title,matching
renameSaved's precedent and unreachable through the dialog itself(whose own confirm stays disabled) — defense in depth, not a UX path, since
the Dashboard
titleschema field carries nominLengthfloor.description: undefined(the argument omitted) leaves the stored valueuntouched; an explicit string trims and clears on empty — the one
production caller (the pencil dialog) always passes an explicit string, so
today only the "clears on empty" branch is reachable; the omitted-leaves-
untouched branch is unit-tested at the pure-planner layer for a future
title-only caller (there is none yet).
app.dom.fileDialogto a single module-local slot is deliberate, notincidental: only one modal dialog is ever meaningfully open at a time, and
the single slot lets a Dashboard-tree-opened dialog participate in the same
"force-close on a surface transition" teardown File-menu's own dialogs
already had — a real gap, since the tree (unlike File-menu's overlays)
persists across Query/Dashboard surface switches and had no dialog of its
own before this phase.
The bug that only a real browser could show — worth internalising before
phase 4/5's own pencil/trash controls hit the same trap:
Deviations from the spec: none. The "Blank means blank" write-side bullet
(carried here from phase 1's handoff, "the new pencils cannot author a
blank-but-truthy title") is satisfied —
renameDashboardtrims and refusesblank, exactly like phase 1's read-side trim.
Deferred acceptance criteria — phase 3 claims exactly two bullets:
Dashboard rows can edit title/description through a pencil action; exactly
one command creates a Dashboard, both entry points prompt and append (#481).
Everything else → phases 4–6.
#438 / layout engines: phase 3 adds no tile-focus gesture (the pencil
edits Dashboard-level metadata, never a member), so nothing needed verifying
on the flow engine. The obligation stands for phases 4 and 5.
Verification:
npm test5877 passed / 179 files, per-file gate held;tsc --noEmitclean;npm run buildclean;npm run test:e2efulldashboard-tree.spec.jsgreen on chromium + webkit + firefox, full suite152/152 on chromium (webkit clean; a handful of unrelated Firefox specs
flaked under full parallel load and passed individually on retry — known
sandbox flakiness, none touch this phase's files). An independent read-only
review pass against the diff found no defects across correctness, the
dialog-shell staleness guard, coverage-gate honesty, boundary rules, and
scope. Driven in the real served app against live ClickHouse (local
ch-26.6): created a Dashboard via File ▸ New dashboard…, renamed it via the
pencil (title + description), tree label updated, toast fired, no console
errors — including moving the mouse away right after Save, to exercise the
focus-restore fix above.
Follow-ups filed: none. No out-of-scope defect surfaced that phases 4–6
do not already own.
Phase 4 — handoff
Landed (4 commits on
feat/dashboard-tree-direct-actions-494, PR #505). Twodistinct bodies of work, deliberately in one PR because the second builds three
more buttons and two more dialogs on the first:
The four #495 post-merge review findings (
c778cba):ui/dashboard-tree.ts—isolateActivationKeyson every nested control, plusa guard in
handleTreeKeydownthat ignores an Enter originating on abutton. Two independent layers; each is separately tested, including against a
deliberately naive button that installs no handler of its own.
ui/dialog-shell.ts—openMetadataDialog(the two-field dialog, promoted outof
dashboard-tree.tsnow that the panel pencil is a second consumer) AWAITSits commit and keeps the card open with the typed values on every unsuccessful
outcome;
role="dialog"/aria-modal/aria-labelledbyon the shell.application/dashboard-create.ts(new) — the ONE creation command + a puredashboardCreateMessage. Both entry points call it.#494 itself (
1d7d1e7,a4c0ff5,f058150): the model'sactions, the puredashboard/application/dashboard-removal.ts, the commit wrappersapplication/dashboard-delete.tsandapplication/dashboard-panel-metadata.ts,the view's one
buildActionButton, and the CSS cluster. Full file list in the PR.Decisions taken (not in the issue):
of the phase, recorded in Dashboard tree: replace overflow menus with direct Panel focus, edit and delete actions (#429 phase 4) #494's body: it dispatches
focus: { kind: 'tile' }, which Fix Dashboard focus and scrolling for flow-layout KPI tile groups #438 proves is a no-op on flow-layout KPItiles, and Dashboard tree: replace overflow menus with direct Panel focus, edit and delete actions (#429 phase 4) #494 forbids shipping a known no-op focus action. This is the whole
of phase 5 now. It is not a regression — the capability shipped inside the
⋯menu, and a panel row's double-click / Shift-click still do it.menu,deletableandrenamablecollapsed into oneactionslistrather than a fourth boolean being added. Availability is answered once, in the
model, through
buildQueryOwnershipIndex—OwnershipWorkspace.querieswasnarrowed to
{ id }(andlibraryQueriesmade generic) so the tree's loosenedTreeQuerycan be indexed by the SAME ownership rule instead of a private copyof it. Any later phase adding a row control adds a
DashboardTreeAction, nota boolean.
action.confirm),because it names resources by their resolved display titles — the panel's AND
its owning Dashboard's — and the view holds only the row it is painting. Same
reason
diagnosticis composed there.(
.dash-tree-act-static) while the four new controls reveal onhover/
:focus-within. Dashboard tree: replace overflow menus with direct Panel focus, edit and delete actions (#429 phase 4) #494 says "preserve" that control, and its visibility ISthe affordance that says an orphaned configuration can be dropped.
renameSavedrather than growing a secondmetadata writer — that is what keeps a linked Query tab reconciling (clean tabs
adopt, dirty tabs conflict-flag). What it needed was proof of ownership at
DEQUEUE time, so
patchSavedSpecgained an optionalguard(base). It isevaluated BEFORE the existing
index < 0test, which makesdeletedExternallyunreachable for a guarded caller — documented at the call site rather than
handled.
removeDashboardDocumentkeeps a query that a second Dashboard alsoreferences (the invalid multi-owner state). Deleting it would trade one
integrity problem for a worse one.
The bugs only a real browser could show — internalise these before phase 5:
Deviations from the spec: one, above (the focus button). Everything else in
#494 is implemented; its acceptance criteria are ticked in the issue body with
the two focus bullets left open and the reason recorded there.
Deferred acceptance criteria: the two Open-in-Dashboard bullets → phase 5,
after #438.
Review pass. Two independent read-only reviews (correctness, then
accessibility/interaction). Both came back clean on scope, layer rules and
under-delivery. Their substantive findings — the two focus bugs and the
confirmation default above, an unavailable delete that lost its destructive
presentation because
destructivewas derived fromact.confirmrather thanfrom the KIND, and four assertions that could not fail (the same-row
cancelFor,the pencil/trash glyph mapping, two
not.toBe('')checks that also pass on amissing attribute, and an e2e post-delete "focus" check that asserted row TEXT) —
are all fixed and sabotage-checked in
f058150.Verification:
npm test6035 passed / 184 files, per-file gate held;tsc --noEmitclean;npm run buildclean;npm run test:e2e484 passed, 0failed on chromium + webkit + firefox (
--workers=1). Sabotage-checked: thefocus-resolver fix, and the narrow-pane layout test (against a label pinned to
flex: 0 0 auto— note thatflex-shrink: 0alone changes NOTHING there,because
.tree-row .labelisflex: 1, i.e. basis 0). Driven in the realserved app against local ClickHouse 26.6: File-menu create → toast; Library
drag → panel; open the Dashboard; tree pencil rename → the rendered tile heading
AND the tree row both followed; tree trash → the tile left the rendered
Dashboard, focus fell back to the Panels group, the Library source query was
untouched, console clean.
Follow-ups filed: none. The one gap either review could name that this PR
does not close — no test on the new path for "the tile was the current Dashboard
member" / "its query was open in the Query surface" reconciliation — holds today
through existing machinery (
main-surface.ts'sretainMember,reconcileTabsWithSavedQueries) and was verified live, but is not pinned by atest of its own. Phase 5 should pin it while it is in the same code.
Phase 6 — handoff
In review (commit
22d27faonrefactor/remove-repair-planner-429p6, PR #514):src/dashboard/application/saved-query-mutation.tsplanner and its dedicated478-line unit suite. No production caller existed.
test comments. The historical release note that records the planner's original
introduction remains intact.
[Unreleased]removal note and a source/test architecture guard thatprevents the retired API and repair vocabulary from returning unnoticed.
query-ownership.test.ts,saved-history.test.tsandstate.test.ts: ownership only ondashboards[1]hides the query and its Library trash; zero-owner Librarydeletion preserves both Dashboards and reconciles its linked tab; deleting or
invalidating the remotely-owned query is rejected with committed/state/tab
truth unchanged.
final remove-planner decision and retire the stale repair-dialog/navigation
requirement.
Decision taken: follow #500's delete, do not wire branch. #494's direct
Panel/Dashboard trash commands are the sole cascading delete UX. Library derives
zero-owner queries across the complete Dashboard collection, so #431's old
"delete an owned query from Library while another Dashboard references it" path
is no longer reachable.
deleteSavedandcommitSavedQuerystill run strictwhole-workspace validation and fail atomically for malformed or invalidating
data.
Why the old planner is not a fallback: its one repair object applied across
all Dashboards while tile ids are Dashboard-local; matching ids could rewrite an
unrelated Dashboard. Its generic tile-scoped suggestions were not specific to
the requested mutation, and wiring it would duplicate #494 with a broader,
unsafe ownership model.
Deviations from the phase-6 decision in #500: none. The original #429 text
still offered wire-or-delete and required navigation or a repair chooser; that
was the stale requirement #500 explicitly superseded, and the issue body now
records the settled decision.
Acceptance claimed: only the phase-6 planner disposition. The
Open-in-Dashboard focus button remains phase 5, blocked on #438; this PR uses
Part of #429, notCloses #429.Review pass: two independent read-only audits. One was clean; the other
found that the first replacement mutation tests used only the compatibility
Dashboard and therefore would not catch a stale-current narrowing regression.
All three state-path tests now load committed truth with an empty first
Dashboard and ownership only on the second. Sabotage-checking
baselineWorkspaceto ignore committedlatestmakes exactly those three testsfail.
Verification:
npm test6089 passed / 183 files, TypeScript clean and theper-file coverage gate held (100% statements/lines);
npm run buildclean;Chromium + WebKit e2e 341 passed / 1 skipped with
--workers=1; repositorysearch finds no retired planner API or repair vocabulary in source/tests.
Follow-ups filed: none. No out-of-scope defect surfaced.
Merge pull request #490 from Altinity/fix/resolve-before-navigate-429p1
fix(#429): give the tree row its own accessible name, so its buttons …
Merge pull request #492 from Altinity/feat/dashboard-row-gestures-429p2
BorisTyshkevich commentedon Jul 27, 2026
The issue body now supersedes this amendment and links #494 as the authoritative Phase 4 implementation contract.
[-]Add edit and delete actions for dashboard panels and curated filters[/-][+]Dashboard tree management: direct Dashboard and Panel actions[/+]Merge pull request #495 from Altinity/feat/dashboard-metadata-429p3
BorisTyshkevich commentedon Jul 27, 2026
Phase 6 has been re-reviewed against the current ownership model and revised direct-action work in #494.
Decision: remove the unused repair planner; do not build a repair chooser. Tracked in #500.
Why:
planSavedQueryMutationstill has no production caller and documents an unsafe cross-Dashboard tile-ID collision in its globalswitch-variantrepair model;#500 is the authoritative implementation issue for deleting the planner/tests, preserving validation, strengthening ownership-projection coverage, and retiring the stale repair-UI requirement from #429.
Merge pull request #514 from Altinity/refactor/remove-repair-planner-…
fix(Altinity#429): resolve a saved query before navigating, trim blan…
fix(Altinity#429): make the single-diagnostic claim falsifiable, alig…
feat(Altinity#429): split the Dashboard tree row into chevron, name a…
fix(Altinity#429): rotate the chevron GLYPH, not the disclosure button
feat(Altinity#429): unify Dashboard creation and add the metadata ren…
fix(Altinity#429): handle rename outcomes, isolate button keys, unify…
refactor(Altinity#429): remove obsolete repair planner