Part of the ADR-0004 vanilla-shell investment track (see docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.
Goal
Make the committed workspace aggregate (state.dashboard / currentWorkspace) itself reactive — or make a computed projection of it the reactive source subscribers read — and retire dashboardTreeRevision, the hand-maintained repaint counter that stands in for that missing reactivity today.
Context
src/state.ts:391–399 declares the counter and says why in its own comment:
#426 — the Dashboard tree's EXPLICIT repaint invalidation. The tree is a projection of the committed workspace aggregate plus main-surface navigation state, neither of which is a signal, so it cannot depend on incidental unrelated signal changes. Every trigger the issue lists ... bumps this instead.
It's instantiated at src/state.ts:725 (dashboardTreeRevision: signal(0)), and every commit path that should invalidate the tree must remember to bump it by hand through the single funnel at src/ui/app.ts:2206–2207:
const invalidateDashboardTree = (): void => { app.state.dashboardTreeRevision.value += 1; };
app.invalidateDashboardTree = invalidateDashboardTree;
— called manually from five sites today (app.ts:2290, :2806, :2838, :2849, :2901). Three effects in src/ui/app-shell.ts read the counter as their only signal into an otherwise non-reactive aggregate:
:240–246 — the upper-role tab-count effect (Databases/Dashboards counts).
:252–260 — the Dashboard tree repaint effect. Its own comment: "the ONE reactive input the tree has ... Expansion/search/scroll are deliberately NOT reactive — the tree repaints itself directly for those."
:275–279 — the saved/history side-panel repaint effect. The #427 comment immediately above it (:270–274) explains why: Library membership is now a projection of dashboards[] (a query is in the Library exactly while no Dashboard member references it), so a committed Dashboard change can move a query in/out of this list with no savedQueries signal change at all — this same counter is the only thing that tells that effect to re-run.
The bug class this invites: any future commit path that mutates dashboard/currentWorkspace and forgets to call invalidateDashboardTree() leaves the Dashboard tree, tab counts, and Library/History panel stale — silently, with every existing test green, because nothing asserts the counter was bumped for a new mutation path; it only asserts behavior for the mutation paths that already remember.
Deliverables
- Make
state.dashboard / currentWorkspace reactive at the source (a signal, or wrapped so mutation is observable), or introduce one computed projection derived from it that the three app-shell.ts effects and any future subscriber read directly — no second hand-maintained counter standing in for either.
- Delete
dashboardTreeRevision and its five manual bump call sites (app.ts:2290, :2806, :2838, :2849, :2901) and the invalidateDashboardTree funnel, once nothing depends on it.
- Preserve the deliberate non-reactivity of tree expansion/search/scroll/keyboard state (
dashboardTreeUi, src/state.ts:400–407 — a plain Map, explicitly NOT a signal because observing it would lose the search caret and repaint on every scroll frame). This issue changes how the tree learns that the underlying data changed, not how it renders its own transient UI state.
- Preserve the
#427 Library-projection semantics exactly (Library membership = queries with no dashboards[] member reference) — the new reactive source must fire for that transformation too, not just for direct savedQueries edits.
Tests
- A test that adds a new mutation path touching
dashboard/currentWorkspace and asserts the tree/tab-count/saved-history effects re-run without an explicit manual invalidation call — proving the reactivity is structural, not remembered.
- Existing coverage for the three
app-shell.ts effects (tab counts, tree repaint, saved/history repaint) continues to pass unmodified in intent.
- Regression test for the
#427 Library-membership projection: a Dashboard-only mutation (adding/removing a panel reference to a query, no savedQueries write) still repaints the saved/history panel.
dashboardTreeUi (expansion/search/scroll) is asserted to remain un-reactive — no new effect subscribes to it.
npm test (coverage gate) and tsc --noEmit pass.
Acceptance criteria
dashboardTreeRevision and its manual bump call sites no longer exist.
- The Dashboard tree, upper-role tab counts, and saved/history panel repaint from a structural dependency on the committed aggregate (or its computed projection), not a manually incremented counter.
dashboardTreeUi (tree expansion/search/scroll/keyboard) remains deliberately non-reactive, unchanged.
- The
#427 Library-projection semantics (membership derived from dashboards[]) are preserved exactly.
npm test (coverage gate) and tsc --noEmit pass.
Non-goals
- Migrating every remaining non-signal slice of
AppState to signals — scope is the committed workspace aggregate and its three known subscribers.
- Changing
mutateWorkspace's commit/validation contract, result shape, or conflict handling.
- Any visual or behavioral change to the Dashboard tree, tab counts, or saved/history panel beyond how they learn to repaint.
Related
Risk notes
batch() discipline matters: converting the counter bump into a reactive read means multiple field writes during one commit must not fire subscribers once per field — verify the new source (or its computed projection) settles within the same batch() the commit already uses.
- Per the
open-time-snapshot-is-not-a-gate lesson, destructive-commit re-validation belongs inside the mutateWorkspace transform itself, not in a UI-layer check that happens to run after a stale snapshot — this refactor must not accidentally move that validation to a point where it can be skipped.
Part of the ADR-0004 vanilla-shell investment track (see
docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.Goal
Make the committed workspace aggregate (
state.dashboard/currentWorkspace) itself reactive — or make a computed projection of it the reactive source subscribers read — and retiredashboardTreeRevision, the hand-maintained repaint counter that stands in for that missing reactivity today.Context
src/state.ts:391–399declares the counter and says why in its own comment:It's instantiated at
src/state.ts:725(dashboardTreeRevision: signal(0)), and every commit path that should invalidate the tree must remember to bump it by hand through the single funnel atsrc/ui/app.ts:2206–2207:— called manually from five sites today (
app.ts:2290,:2806,:2838,:2849,:2901). Three effects insrc/ui/app-shell.tsread the counter as their only signal into an otherwise non-reactive aggregate::240–246— the upper-role tab-count effect (Databases/Dashboards counts).:252–260— the Dashboard tree repaint effect. Its own comment: "the ONE reactive input the tree has ... Expansion/search/scroll are deliberately NOT reactive — the tree repaints itself directly for those.":275–279— the saved/history side-panel repaint effect. The#427comment immediately above it (:270–274) explains why: Library membership is now a projection ofdashboards[](a query is in the Library exactly while no Dashboard member references it), so a committed Dashboard change can move a query in/out of this list with nosavedQueriessignal change at all — this same counter is the only thing that tells that effect to re-run.The bug class this invites: any future commit path that mutates
dashboard/currentWorkspaceand forgets to callinvalidateDashboardTree()leaves the Dashboard tree, tab counts, and Library/History panel stale — silently, with every existing test green, because nothing asserts the counter was bumped for a new mutation path; it only asserts behavior for the mutation paths that already remember.Deliverables
state.dashboard/currentWorkspacereactive at the source (a signal, or wrapped so mutation is observable), or introduce one computed projection derived from it that the threeapp-shell.tseffects and any future subscriber read directly — no second hand-maintained counter standing in for either.dashboardTreeRevisionand its five manual bump call sites (app.ts:2290,:2806,:2838,:2849,:2901) and theinvalidateDashboardTreefunnel, once nothing depends on it.dashboardTreeUi,src/state.ts:400–407— a plainMap, explicitly NOT a signal because observing it would lose the search caret and repaint on every scroll frame). This issue changes how the tree learns that the underlying data changed, not how it renders its own transient UI state.#427Library-projection semantics exactly (Library membership = queries with nodashboards[]member reference) — the new reactive source must fire for that transformation too, not just for directsavedQueriesedits.Tests
dashboard/currentWorkspaceand asserts the tree/tab-count/saved-history effects re-run without an explicit manual invalidation call — proving the reactivity is structural, not remembered.app-shell.tseffects (tab counts, tree repaint, saved/history repaint) continues to pass unmodified in intent.#427Library-membership projection: a Dashboard-only mutation (adding/removing a panel reference to a query, nosavedQuerieswrite) still repaints the saved/history panel.dashboardTreeUi(expansion/search/scroll) is asserted to remain un-reactive — no new effect subscribes to it.npm test(coverage gate) andtsc --noEmitpass.Acceptance criteria
dashboardTreeRevisionand its manual bump call sites no longer exist.dashboardTreeUi(tree expansion/search/scroll/keyboard) remains deliberately non-reactive, unchanged.#427Library-projection semantics (membership derived fromdashboards[]) are preserved exactly.npm test(coverage gate) andtsc --noEmitpass.Non-goals
AppStateto signals — scope is the committed workspace aggregate and its three known subscribers.mutateWorkspace's commit/validation contract, result shape, or conflict handling.Related
workspace-session.ts) lands first and this issue touches a smaller, already-separated surface.docs/ADR-0004-ui-shell.md)Risk notes
batch()discipline matters: converting the counter bump into a reactive read means multiple field writes during one commit must not fire subscribers once per field — verify the new source (or its computed projection) settles within the samebatch()the commit already uses.open-time-snapshot-is-not-a-gatelesson, destructive-commit re-validation belongs inside themutateWorkspacetransform itself, not in a UI-layer check that happens to run after a stale snapshot — this refactor must not accidentally move that validation to a point where it can be skipped.