The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues

Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.

Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 18:55:34 -04:00
parent 78c32776d4
commit 1fd19dfb12
13 changed files with 926 additions and 201 deletions
+25 -6
View File
@@ -236,6 +236,12 @@ struct BoardWindowHost: View {
// purge-alert host, which the trash's two confirmed commands raise, and its search
// field, which Edit Find focuses and the caret-chord commands yield to.
.focusedSceneValue(\.boardStore, store)
// **What Edit Undo/Redo cross with this window in front** the session's stack, which
// those rows now read for themselves (13-native-undo.md Rules the command-surface
// bullet, re-ruled 2026-08-08; `UndoCommands.swift`). Read afresh from the model rather
// than stored, for `windowUndoManager`'s reason exactly: a torn-down board answers
// nothing rather than a stack with no board behind it.
.focusedSceneValue(\.undoStack, appModel.session(for: ref)?.undoManager)
.focusedSceneValue(\.boardSearch, boardSearch)
// The window's identity beside its store File Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing.
@@ -709,11 +715,20 @@ struct BoardWindowHost: View {
}
}
// This window's answer to "what does Z act on" (13-native-undo.md Rules; 06 Undo
// routing) the *session's* stack, read afresh on every ask so a torn-down board answers
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager.
// This window's answer to "what does Z act on" (13-native-undo.md Rules; Undo routing)
// the *session's* stack, read afresh on every ask so a torn-down board answers nothing
// rather than a stack with no board behind it.
//
// **It no longer lights the command surface**, and that sentence used to be this comment's
// whole point (13 Rules the command-surface bullet, re-ruled 2026-08-08): the Edit
// menu's rows and the toolbar's pair were nil-target `undo:`/`redo:` resolving through
// `NSWindow.undoManager`, which a SwiftUI window latches empty during creation, before this
// controller's delegate can install so `windowWillReturnUndoManager` is never consulted
// and the surface validated against a stack nothing registers into. Both faces read the
// session's manager directly now: the rows through `FocusedValues.undoStack` published a few
// lines up in `body`, the toolbar pair through the explicit target `BoardToolbar` is handed
// below. This closure stays wired because it is still the right answer wherever *AppKit*
// asks a delegate for a manager.
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
@@ -750,7 +765,11 @@ struct BoardWindowHost: View {
search: boardSearch,
zoom: appModel.zoom,
appearance: appModel.appearance,
session: appModel.dragSession
session: appModel.dragSession,
// The same manager the Edit menu's rows reach through the focus system one object, two
// faces, which is what "the toolbar mirrors the menu" means for this pair now that
// neither of them goes through the responder chain (13 Rules, re-ruled 2026-08-08).
undo: appModel.session(for: ref)?.undoManager
))
}