LearnNewsExamplesServices
Frontmatter
id18553
titleWorkstation auto-saves its topology but never says you have left the default, and offers no way back
stateClosed
labels
enhancementdeveloper-experienceaidashboard
assigneesneo-opus-grace
createdAtSep 9, 2026, 7:34 PM
updatedAtSep 12, 2026, 10:38 PM
githubUrlhttps://github.com/neomjs/neo/issues/18553
authorneo-opus-grace
commentsCount0
parentIssue18456
subIssues
18558 The topology bar''s undo and redo never say how far they reach
subIssuesCompleted1
subIssuesTotal1
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 18579 Restore never returns rail widths: the topology diff skips edge zones
blocking[x] 18612 The Workstation binds dock.perspective, not activeLayoutId read by hand
closedAtSep 12, 2026, 10:38 PM

Workstation auto-saves its topology but never says you have left the default, and offers no way back

Closed v13.2.0/archive-v13-2-0-chunk-20 enhancementdeveloper-experienceaidashboard
neo-opus-grace
neo-opus-grace commented on Sep 9, 2026, 7:34 PM

Serves: #18456 — Workstation as a compact, idiomatic DockLayouts consumer. Persistence now ships; its user-facing surface does not.

The Problem

apps/workstation now saves its topology to IndexedDB automatically. Reload and you get your last state back — which is the right default, and it arrives with no way to tell that this is not the app's default layout, and no way back to it.

A user drags three panes around, reloads a week later, and sees a workspace that looks like the product. Nothing distinguishes "the app ships like this" from "you made it like this". There is no reset.

Adjacent, same root: the undo and redo buttons say only whether they are enabled, never how far they reach. A control that can be pressed but gives no sense of remaining depth is a control you press hopefully.

All three are one thing: the workstation persists and mutates state, and exposes none of that state to the person it belongs to.

The Architectural Reality — most of this already exists

⚠️ Read this section before estimating. Two of the three items are readouts of data the engine already publishes, not new machinery.

Undo/redo depth is already published. Neo.manager.Transaction (:526) publishes to every window bound into the Group:

canUndo · canRedo · historyCursor · historyDepth · historyLength

The topology bar's buttons currently bind only disabled:

// apps/workstation/view/Workspace.mjs — createTopologyBar()
bind: {disabled: () => TransactionManager.getProvider(me.topologyGroupId)?.getData('canUndo') !== true}

So a depth affordance is another bind on data already flowing. ⚠️ Do not mirror it — that provider's own contract comment is explicit that getData resolves to the leaf's core.Config and re-runs on change, "no mirror to keep in step with setHistoryDepth, which publishes without a commit."

The default topology is addressable. apps/workstation/tour/denseWorkstation.mjs exports initialDocument as a frozen constant. Reset has a real target; it does not need to be reconstructed.

"Am I on the default?" is a comparison, not a heuristic — but ⚠️ NOT the one this ticket originally prescribed. An earlier revision of this body named topologyFingerprint (Persistence.mjs:208, schema neo.dock.topologyShape.v2) as the instrument. That was my assertion and it is wrong. WorkspaceDocument.computeShapeFingerprint's own docblock states it "deliberately contains no node ids, item ids, sizes, titles or window identity" — it is shape-only by contract, because it is the guardrail that must let two structurally identical layouts compare equal.

Measured at dev@2fdc57af69 against this app's real initialDocument, applying real Operations:

user operation shape fingerprint TopologyDiff.diffDockDocuments
drag the main split 0.6/0.4 → 0.25/0.75 unchanged resizes
drag the left rail 0.11 → 0.4 unchanged NOTHING (#18579)
switch the active tab unchanged activeItemChanges
reorder a tab in place unchanged tabReorders
relocate a pane across nodes changed moves
close a pane changed removes

So a fingerprint indicator would tell a user who dragged every boundary and reordered every tab that they are still on the default. The instrument is TopologyDiff.diffDockDocuments(initialDocument, live) — the engine's own semantic before/after compare, whose docblock argues explicitly against both raw equality (too strict) and the shape gate (too loose). Modified iff any mutation category is non-empty, recomputed from the live document, so undo-to-start clears it for free.

⚠️ THE INSTRUMENT IS SETTLED; THE REACTIVITY IS NOT — measured at dev@1c1ac5769e

The differ is the right instrument (below), but there is nothing to bind it to. dockModel is a plain class field — src/dashboard/dock/Workspace.mjs:411, dockModel = null — not a reactive config. A bind formatter reading me.dockModel never re-runs, so "derived, not a flag" has no reactive source and the indicator must be derived and published rather than bound.

The obvious hook is wrong, and it would ship a stale indicator. onDockZoneDocumentChange looks like the universal hook and the engine docblock calls it the advance path — but it has two branches (src/dashboard/dock/Workspace.mjs:2299), and the Group-managed one, which is the Workstation's actual path, returns without ever assigning me.dockModel:

if (set && me.topologyGroupId && document !== me.dockModel) {
    …
    return pending          // <- no me.dockModel assignment on this branch
}
const projection = me.projectDockZoneDocument(…);
me.dockModel = document;    // <- only the direct branch writes it

An override that recomputed there would read the previous document and publish the wrong answer on every Group commit.

The single writer for the main workspace is registerMainWorkspace()'s setter — apps/workstation/view/Workspace.mjs:788, setDocument: document => me.dockModel = document. Every Group-managed commit routes through it, undo and redo included, which is what makes the ticket's "undo back to the start clears it" requirement fall out for free rather than needing its own path.

So AC-2's mechanism is: compute the diff, publish to the view's own stateProvider, from exactly two call sites — that setter (every committed change) and construct() (boot, where :479 seeds dockModel from a persisted initialTopology that may already be modified). Missing the second ships an indicator that reads "default" on a cold-hydrated custom layout, which is the ticket's whole scenario.

⚠️ This is a published derivation, NOT the mirror the Avoided Traps warn against. That warning is about historyDepth, which the Transaction manager already publishes reactively; nothing publishes "is this the default arrangement", so deriving it is not duplicating a publisher.

The engine question, raised and deliberately not assumed: whether dockModel should be a reactive config at all. It would let every consumer bind topology directly instead of each one wiring its own publisher. That is src/dashboard/dock/Workspace.mjs — a shared base class with Demo A, Demo B and Workstation on it — so it is higher blast radius than this consumer ticket and belongs to whoever owns that surface, not to #18553.

⚠️ One category is missing and it is filed as #18579: an edge-zone extent change is invisible to the differ too, so a rail drag reads as unmodified. That is an engine defect with its own blast radius (RestorePlanner plans an empty restore; the diff_dock_topology MCP tool reports no change), not a consumer workaround — this ticket's AC-2 depends on it and must not hand-write an extent comparison here.

A fresh-root path exists and is the wrong one. Viewport.showRootFailure() already renders "Start a new workspace" → startBlankRoot(). But it is reachable only on a load failure, and it admits {topologyIdentity: {}} — blank, not default. Reset must not route through it.

The Fix

Surface the state that is already there:

  1. Depth on the controls — undo/redo show how many steps remain, from historyCursor / historyLength. Split to #18558.
  2. A modified-from-default indicator — derived from the fingerprint comparison, not a flag.
  3. A reset that restores initialDocument, reachable in normal use rather than only after a failure.

AC

AC-1 and AC-1b are split out to #18558 and are struck through below — they were marked independently shippable, they carry their own ACs there, and a split replaces scope rather than adding to it. What remains here is the default/reset half: AC-2 through AC-6.

  • AC-1 ⚠️ Independently shippable, and should land first. Undo and redo expose remaining depth as button badges — button.Base already ships badgeText_ and badgePosition_ (badgePositions: ['bottom-left','bottom-right','top-left','top-right']) rendering a .neo-button-badge node. So this is bind: {badgeText: …} over the already-published historyCursor / historyLength / historyDepth: no mirrored state, no new publisher, and no new affordance invented. A control at depth 0 stays disabled as it does today. (@tobiu's mechanism, not a substitute for it.)

  • ~~AC-1b ⚠️ The topology bar's buttons are under-tokenised, and it is one omission producing two complaints. resources/scss/src/apps/workstation/Workspace.scss:137 sets only --button-background-color, --button-background-color-hover, --button-border, --button-height, --button-text-color. It sets no glyph token and no disabled token, so:

    • icons carry no themed colour — --button-glyph-color / -hover / -disabled are all unset;
    • the disabled state falls to the UA cascade. button/Base.scss:367 documents exactly this: "Projecting the native disabled attribute activates the UA button:disabled cascade… Chromium exposed both sides of the gap: native-disabled painted GrayText… color inherits because the child glyph/text nodes own themed colors." The engine's contract is that the consumer supplies those child colours; Workstation does not, so the reader gets GrayText.

    The bar therefore needs, on .workstation-topologybar .neo-button: --button-glyph-color, --button-glyph-color-hover, --button-glyph-color-disabled, --button-text-color-disabled, --button-background-color-disabled, --button-border-disabled, --button-opacity-disabled, plus --button-badge-background-color and --button-badge-color for AC-1. All exist already — none of this is new engine surface.~~

  • AC-2 The workstation shows whether its live topology differs from initialDocument, derived from TopologyDiff.diffDockDocuments rather than a dirty flag or the shape fingerprint. ⚠️ Blocked on #18579 for edge-zone extents: until that lands the differ cannot see a rail drag, and shipping the indicator before it means shipping one that lies on the flagship's own three resizable edges.

  • AC-2b ⚠️ Both blind spots are witnessed, not assumed. An arm drives one split resize AND one rail drag and asserts the indicator turns on for each. The rail arm is the one that fails today; without it, AC-2 passes on a mechanism that is still half-blind.

⚠️ RESET: THREE MEASURED TRAPS — read before writing it, measured at dev@1c1ac5769e

Persistence is commit-driven and automatic: TopologyLibrary.attachGroup registers commit: data => this.persistCurrent(), so every Group commit captures the keyed topology and writes it to IndexedDB under layoutId — no explicit save needed. Three consequences, each of which produces a wrong reset if missed:

1. layoutId: 'default' is a NAME, not a claim. The auto-saved working record is stored under that id. There is no stored copy of the shipped arrangement — it exists only as initialDocument in code. Anything that reads the persisted record to decide "am I on the default?" is reading the user's own layout back to them.

2. The most copyable precedent does not persist. TourController resets with workspace.dockModel = WorkspaceDocument.clone(initialDocument) (:398, :485). That is a direct field assignment: it bypasses the Group, no commit event fires, so auto-save never runs. A reset built that way leaves the OLD topology in IndexedDB and it returns on the next reload — exactly the failure AC-5 was written to catch, sitting in this repo as the nearest example.

3. Reset cannot be a single write once a second window exists. TopologySeams.commitDockTopologyWorkspaces refuses unless the commit names exactly the registered keys:

if (Object.keys(workspaces).length !== keys.length || keys.some(key => !Object.hasOwn(workspaces, key)))
    return {errors: ['the topology must name exactly the registered workspace keys']}

So {main: initialDocument} is rejected while a popup is registered. The removal path is WorkspaceSet.unregister(workspaceId) ("explicitly retires a participant"), so reset must retire the popups first and commit last. The ordering is load-bearing.

⚠️ And that combination is the real hazard. Because auto-save fires on every commit, a multi-step reset writes its intermediate states to IndexedDB as it goes. If it fails partway — a window refuses to close, a commit rolls back — the user is left with a persisted partial reset, and their original arrangement was already overwritten by the auto-save on step one. Reset has to be recoverable by construction, not merely correct on the happy path. AC-4's confirmation is not sufficient cover for this: the user consented to a reset, not to a half-one they cannot undo.

  • AC-3 A reset restores initialDocument and is reachable in normal use. It does not go through startBlankRoot(), which produces a blank workspace and exists for the load-failure path. ⚠️ It must commit through the Group (trap 2) and retire non-main workspaces before committing (trap 3).

    Clarified 2026-09-12, after TWO corrections. First: restoring initialDocument beside an untouched popup leaves a torn-out pane owned twice — invalid topology, refused capture, a reset that reports success while being unpersistable (@neo-gpt-emmy). I then implemented trap 3 literally, retiring the other participants before committing, and justified it with a claim that a workspace handing back its only pane has no valid document. That claim was false — I had established it by pruning the last tabs node, which leaves root dangling, and generalised from one way of emptying a document to all of them. An edge root with no zones validates and projects.

    And retirement was actively worse: unregistering happens outside the transaction, so undo cannot restore it — the commit rolls main back to a document whose pane lives in a workspace that no longer exists, leaving the pane owned by nobody. Trap 3's prescription is therefore superseded on this point: the panes are reclaimed into the SAME write that restores main, every participant is retained, and a workspace left owning nothing takes the empty edge-root document. One commit, one history row, undo returns the pane to its window. Window lifetime stays out of this AC's scope — the native window is never closed by a reset, and now never unregistered either.

  • AC-4 ⚠️ REVISED — I measured this AC's own premise and it is false for the shape that landed. It read: "Reset is destructive and confirms. It discards a user's arrangement, which is exactly the state this ticket exists to make visible. A one-click reset beside an undo control that no longer reaches past it is a trap." That assumed reset would be history-clearing. Measured at dev@434480f174: WorkspaceSet.write defaults to cursorAction: 'append' and commitDockTopologyWorkspaces does not override it, so a reset lands as one ordinary history row and undo reverses it exactly as it reverses a dragged splitter. The arm drives a real committed mutation, resets, then undoes past the reset and asserts the replaced arrangement returns. So the trap does not exist, and a confirmation dialog would guard nothing — it would also be inconsistent with the rest of this workspace, where closing a pane is equally destructive-on-reload and confirms nothing. What discharges this AC is the undoability, asserted — not a dialog. The residual it honestly does NOT cover: reset followed by a reload loses the arrangement, because auto-save has already written it. That is true of every committed operation here, not of reset specifically, and it belongs to the auto-save design rather than to this AC.
  • AC-5 State what reset does to the persisted record, not only the live one: re-seeded, cleared-and-reseeded, or a new layout id. A reset that leaves the old topology in IndexedDB reappears on the next reload.
  • AC-6 ⚠️ Both directions witnessed. The indicator must be absent on a freshly-seeded default and present after one committed operation — an indicator that is always on is indistinguishable from one that works, and this ticket's whole point is a state readout being trustworthy.

Out of Scope

Named perspectives and the perspective library, which already exist and are a different feature — this is about the implicit auto-saved state, not saved-and-named ones. Cross-window topology semantics. Whether auto-save should be the default at all: it is, and this makes it legible rather than reversing it. The dock engine's own persistence contract.

Avoided Traps

⚠️ Do not restyle by overriding .neo-button paint directly in the workstation sheet — the disabled state is governed by the token set the engine already exposes, and button/Base.scss explains why color inherits rather than being set at the root. Reaching past the tokens re-opens the UA-cascade gap that comment was written to close. Do not invent a step-count affordance: badgeText exists. Do not add a historyDepth mirror on the workspace — it is published reactively and a mirror is the drift setHistoryDepth's own comment warns about. Do not implement "modified" as a boolean set on the first operation: undo back to the start should clear it, and a flag will not, whereas a recomputed comparison does for free. ⚠️ Do not use computeShapeFingerprint for that comparison — measured above, it is shape-only by contract and misses every geometry and in-place ordering change. Do not work around #18579 by hand-writing an extent comparison in this app: three call sites read that differ, and a consumer-side patch leaves the restore planner and the MCP tool exactly as wrong. Do not reuse startBlankRoot() for reset — blank is not default, and conflating them ships a reset that deletes the product's own layout. Do not fold this into the perspective library: a user who never names a perspective still needs a way home.

Decision Record impact

none — consumer-side surfacing of published state. If reset turns out to need an engine-side seam, that is a finding worth raising rather than a change to assume.

Sweeps and ownership

Live latest-open sweep plus all-state searches on reset / perspective / default / undo-depth / persistence: nothing equivalent open or closed. Parent epic #18456 confirmed epic-labelled.

Raised by @tobiu after the auto-save landed — "there is no info that this is not the 'default state' (perspective) of the app, and there is no reset button… the undo and redo buttons should indicate how many steps there are." Filed by @neo-opus-grace with the existing-machinery survey above, so the implementer starts from what is already published rather than building it twice.

Retrieval Hint: workstation IndexedDB auto-save reset default initialDocument topologyFingerprint undo redo historyDepth historyCursor

Origin Session ID: c4dc8abc-31fa-4ecd-9aef-5209ccfd16c0