Summary
MainWindowViewModel (features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs, ~4,790 lines) is a god-object that owns a large amount of logic that is really per-workspace-pane concern: restoring/populating a pane's tabs, walking and migrating the pane's ContentLayout, opening/closing/cycling tabs within a pane, serializing a pane's persisted layout, per-pane tab lifecycle, and — critically — the per-pane tabId → WorkspaceDocument registry and the pane-scoped part of navigate-to-tab-by-id. All of this should move onto the per-pane view-model WorkspacePaneViewModel (features\Phantom.Workspaces\ViewModels\WorkspacePaneViewModel.cs), which already owns Id, Tabs, SelectedTab, ContentLayout, aggregations, SaveCommand, and Populated — making it the natural owner of the per-pane document registry and per-pane navigation. WorkspacePaneDocument remains the thin outer-dock Document wrapper for the pane VM. MainWindowViewModel keeps only window-global collection management (the WorkspacePanes collection, selection, outer-dock ordering, the shared WorkspaceDockFactory, Phase-1 pane resolution for navigation, and thin command delegations that XAML binds). This is a structural refactor with no intended behaviour change (except it structurally eliminates one of the mechanisms tracked in #1340 — see below); existing per-pane tests should relocate to a new WorkspacePaneViewModelTests file.
Current per-pane types (containment)
Outer DockControl (MainWindow) → WorkspacesPaneDock (ItemsSource = MainWindowViewModel.WorkspacePanes) → per-pane WorkspacePaneDocument (extends Dock's Document; created by WorkspacePaneDocumentGenerator, WorkspacePaneDocumentGenerator.cs:25-48) → each holds a WorkspacePaneViewModel (WorkspacePaneDocument.WorkspacePane, WorkspacePaneDocument.cs:54) → WorkspacePaneViewModel.ContentLayout is an inner IRootDock whose primary region is a WorkspaceContentDock bound to pane.Tabs → WorkspaceDocument(s) (the tabs), created by WorkspaceDocumentGenerator.
WorkspacePaneDocument (WorkspacePaneDocument.cs, 75 lines) wraps the pane VM, caches a TabHeaderViewModel (running/notification indicators), forwards Title/AnyTabIsRunning/AnyTabHasUnreadNotification PropertyChanged, and cascades DisposeAsync into the pane. It stays a thin outer-dock Document wrapper — it is NOT the target of the move.
WorkspacePaneViewModel (WorkspacePaneViewModel.cs, 277 lines) already owns Id/Title/Entity/IsReadOnly/IsSaving/CloseCommand/SaveCommand, the Tabs collection, SelectedTab, ContentLayout, aggregation flags, the Populated TCS, tab-subscription bookkeeping, and recursive DisposeAsync. Because it already owns Tabs + ContentLayout + Id, it is the natural home for the per-pane tab→document registry and per-pane navigation.
Recommended target
Everything that can be moved off MainWindowViewModel moves onto WorkspacePaneViewModel. That includes not only the per-pane logic previously enumerated below but also (new): the per-pane tabId → WorkspaceDocument registry and Phase-2 of navigate-to-tab-by-id. MainWindowViewModel keeps only window-global concerns (the WorkspacePanes collection, SelectedWorkspacePane, outer-dock Layout, the shared WorkspaceDockFactory, the outer-dock paneId → WorkspacePaneDocument map, Phase-1 pane resolution by workspace tab id, and thin command delegations). WorkspacePaneDocument stays a thin outer-dock Document wrapper.
Per-pane tab→document registry (key structural change)
Today WorkspaceDockFactory.documentsByTabId (features\Phantom.Workspaces\ViewModels\WorkspaceDockFactory.cs:29) is a single window-scoped dictionary that outlives individual panes. WorkspaceDocumentGenerator calls factory.RegisterDocument(tab.Id, document) / UnregisterDocument(tab.Id) (see WorkspaceDockFactory.cs:62-67, 88-89, 107, 190-191) and, on materialization, guards against duplicates with existing = factory.GetDocumentForTab(tab.Id) + !ReferenceEquals(existing.Owner, dock) (WorkspaceDocumentGenerator.cs:40-42). Because the registry outlives panes, a closed-then-reopened pane can still find a stale prior-owner entry and skip materialization — the mechanism (A) of #1340.
Move it. Each WorkspacePaneViewModel gets its own Dictionary<string, WorkspaceDocument> (its tabId → WorkspaceDocument registry). The pane's inner-dock WorkspaceDocumentGenerator registers/unregisters into the owning pane's registry (the generator is already constructed per pane and can be handed a reference to the pane VM at construction time).
Consequences to state explicitly:
WorkspaceDockFactory.paneDocumentsByPaneId (paneId → WorkspacePaneDocument, WorkspaceDockFactory.cs:35) STAYS window-scoped — it is the outer-dock pane-resolution concern (needed for Phase-1 of navigation and for restoring WorkspacePaneDocuments by id), not a per-pane concern.
- The
WorkspaceDocumentGenerator collision guard (WorkspaceDocumentGenerator.cs:40-42) is retained but resolves against the owning pane's registry. It still prevents duplicate materialization when the same tab id appears in multiple split regions of the same pane's ContentLayout, but it no longer false-positives across panes.
- The
DockableLocator coupling in WorkspaceDockFactory.RegisterDocument/UnregisterDocument (WorkspaceDockFactory.cs:54, 65-66) — the id→IDockable locator used by Dock's restore-by-id — must be repointed to the per-pane registry (or made pane-scoped: each pane's inner-dock has its own ContextLocator/DockableLocator, so this is a local change). Nothing outside the pane should be resolving tab ids by direct dictionary lookup after this refactor.
Relationship to #1340
Closing a pane (RemoveWorkspacePaneAsync, MainWindowViewModel.cs:2288) currently leaves entries in documentsByTabId for all tabs the pane owned; #1340 mechanism (A) is that those stale entries cause the reopened pane's WorkspaceDocumentGenerator to short-circuit (existing != null && !ReferenceEquals(existing.Owner, dock)) and skip materialization — the reopened pane shows "no documents open" even though its Tabs collection is populated. With the registry moved onto WorkspacePaneViewModel, closing the pane discards its registry wholesale, and a reopened pane starts with an empty registry — so the collision guard cannot trip against a stale prior-owner entry and the restored documents materialize.
State clearly: this move eliminates #1340's mechanism (A) by construction — no per-document eviction is needed, and this is preferred over #1340's previously-proposed explicit UnregisterDocument-enumeration-on-close workaround. #1340 should be marked as resolved-by / dependent-on #1341 for mechanism (A). This does NOT address #1340's autosave-on-close concern, which is explicitly out of scope here and tracked separately (see the "Out of scope" note below).
Navigate-to-tab by id (two-phase request)
Today MainWindowViewModel.ActivateTabByIdAsync/ActivateTabWhenLoaded and OpenTabAsync's dedup path do a window-global dockFactory.GetDocumentForTab(tabId) lookup (MainWindowViewModel.cs:2212, 2240, 2261, 2278, 2399), then FindDocumentDock + SetActiveDockable / SetFocusedDockable. Moving the registry per-pane removes the global lookup. Navigation becomes two-phase, driven by a request that carries both a workspace tab id (= pane id) and a document tab id (= tab id):
Request shape (realign existing scaffolding, do not reinvent — see features\Phantom.Workspaces\Services\Navigation\NavigationHistoryService.cs:10, 23, 54, which already has NavigationTarget with TabId/WorkspacePaneId and NavigationEntry(string tabId, string paneId); and MainWindowTabNavigator.NavigateAsync at MainWindowTabNavigator.cs:29-59 which already pushes new NavigationEntry(tabId, paneId)):
public record NavigationRequest(string WorkspaceTabId, string DocumentTabId);
// where WorkspaceTabId == the pane id (aka WorkspacePaneDocument id, WorkspacePaneViewModel.Id)
// DocumentTabId == the WorkspaceDocument tab id inside that pane
Phase 1 — MWVM identifies the workspace pane by WorkspaceTabId. Uses WorkspacePanes / WorkspaceDockFactory.paneDocumentsByPaneId to resolve the WorkspacePaneDocument, opening the pane first if needed (per #1157). MWVM activates the pane in the outer dock.
Phase 2 — the pane resolves the document tab id against its OWN registry and activates/focuses it within its own ContentLayout. New method on WorkspacePaneViewModel:
public Task<bool> NavigateToDocumentTabAsync(string documentTabId)
{
// 1. Look up documentTabId in THIS pane's tabId -> WorkspaceDocument registry.
// 2. If found: FindDocumentDock over ContentLayout, SetActiveDockable, SetFocusedDockable.
// 3. If not-yet-materialized but present in Tabs (deferred), install ActivateTabWhenLoaded
// hook on THIS pane and return true.
// 4. Otherwise return false.
}
ITabNavigatorHost.ActivateTabByIdAsync(string tabId, string? workspacePaneId) (ITabNavigatorHost.cs:16) is refactored so the host only does Phase-1 pane resolution and then delegates Phase-2 to WorkspacePaneViewModel.NavigateToDocumentTabAsync. The signature is realigned to NavigationRequest:
Task<bool> ActivateTabByRequestAsync(NavigationRequest request);
MainWindowTabNavigator.NavigateAsync (MainWindowTabNavigator.cs:29-59) is updated to build a NavigationRequest(WorkspaceTabId: paneId, DocumentTabId: tabId) from the current signals; NavigationHistoryService.NavigationTarget/NavigationEntry are renamed to use WorkspaceTabId/DocumentTabId for clarity but keep the same on-disk semantics (paneId, tabId).
Fallback for unknown WorkspaceTabId. Today the "search all panes" fallback lives at MainWindowViewModel.cs:2234-2249. It becomes: ask each pane whether it owns the DocumentTabId (each pane exposes bool OwnsDocumentTab(string documentTabId) that consults its own registry). This remains possible without a global registry.
Deferred activation. ActivateTabWhenLoaded (MainWindowViewModel.cs:2252-2286) — currently a MWVM-level hook that awaits pane.Tabs change — moves onto WorkspacePaneViewModel and is triggered from NavigateToDocumentTabAsync when the document tab id is present in Tabs but not yet materialized in ContentLayout.
Members to move / keep / split
Classification: (a) clearly per-pane → move; (b) shared/global → stays; (c) coordination → split (part moves, thin delegation stays). All line numbers are in MainWindowViewModel.cs unless noted.
| Member |
Lines |
Description |
Class |
OnCloseActiveTab |
1995–2041 |
Closes active tab via FindFocusedDocumentDock/FindDocumentDock/dockFactory.CloseDockable; MRU nav |
(a) → WorkspacePaneViewModel.CloseActiveTabAsync |
OnCycleTab |
2043–2079 |
Cycles active dockable in pane's ContentLayout |
(a) → WorkspacePaneViewModel.CycleTab(delta) |
ActivateTabByIdAsync (per-pane portions) |
2212, 2234–2249 |
Global GetDocumentForTab + per-pane search |
(c) → Phase-1 stays on MWVM; Phase-2 (per-pane lookup + activate/focus) → WorkspacePaneViewModel.NavigateToDocumentTabAsync |
ActivateTabWhenLoaded |
2252–2286 |
Deferred activation once a tab materialises in pane.Tabs |
(a) → move to WorkspacePaneViewModel (private helper invoked from NavigateToDocumentTabAsync) |
SubscribeToInnerDockChanges / Unsubscribe |
2766–2779 |
Empty stubs (#1107) still called on restore/remove |
(a) → move as stubs, then delete |
WriteBackWorkspaceTabs |
2786–2903 |
Serializes pane tabs + dock layout |
(c) — serialization body → WorkspacePaneViewModel.BuildPersistedTabsSnapshotAsync; the entityBroker.UpdateAsync write stays on MWVM |
SaveWorkspacePaneAsync |
2905–2906 |
Wraps WriteBackWorkspaceTabs; already injected as saveAsync (ctor at 3865) |
(c) — becomes pane.SaveAsync |
AppendWorkspaceTabRelationshipChanges |
2908–~2970 |
Reconciles pane tab entities → relationship changes |
(a) → move |
FindDocumentDock |
3017–3037 |
DFS to first IDocumentDock, always over pane.ContentLayout |
(a) → move |
FindFocusedDocumentDock |
3048–3066 |
Same |
(a) → move |
EnumerateAllDocuments / EnumerateContentDocks |
3078–3121 |
Static DFS over a pane layout tree |
(a) → move (static) |
MigrateBaseDocumentDocksToWorkspaceContentDock, ConvertToWorkspaceContentDock, s_documentDockCopyableProperties, BuildDocumentDockCopyableProperties |
3141–3278 |
Per-pane restore-time layout migration |
(a) → move (static) |
PopulateWorkspacePaneTabsAsync |
3470–3565 |
Loads tabs into pane.Tabs, activates saved active tab |
(a) → WorkspacePaneViewModel.PopulateTabsAsync |
TryRestoreFromDockLayoutAsync |
3573–3694 |
Rebuilds ContentLayout from JSON, migrates, wires content docks, populates tabs |
(a) → move |
CreateTabViewModelFromDescriptorAsync |
3700–3777 |
Descriptor → tab VM |
(c) → behind injected IWorkspaceTabFactory |
TryFetchWorkspaceTabAsync, CreateTabFromEntityAsync |
~3779–3859 |
Same |
(c) → IWorkspaceTabFactory |
TryFocusExistingWebTabAsync |
(per-pane URL lookup) |
Focus existing web tab by URL |
(a) → move |
OnDockableTabClosed |
3296–3334 |
Loops panes to find owner, removes tab, MRU nav |
(c) — owner-find stays on MWVM (window-global concern); "remove one tab + MRU-nav-if-active" → WorkspacePaneViewModel.HandleChildTabClosed |
OpenTabAsync |
2376–~2500+ |
Pane resolution + per-pane tab insert with anchor + dedup via GetDocumentForTab (2399) |
(c) — pane resolution stays; dedup uses per-pane registry (via WorkspacePaneViewModel.OwnsDocumentTab); per-pane insert body → WorkspacePaneViewModel.OpenTabAsync |
AddWorkspacePaneToDock |
1945–1967 |
Activates the pane's document in the outer dock |
(c) — keep on MWVM, call SelectedWorkspacePane.PaneDocument.Activate() |
CreateWorkspacePaneAsync |
3861–~3910 |
Constructs pane, seeds ContentLayout, defers to populate/restore |
(c) — pane-construction body → pane-VM factory (Populate/Restore now live on the pane VM) |
ActiveTabId / ActiveAgentViewModel |
321–343 |
Query selectedWorkspacePane.ContentLayout |
(c) — trivial delegations to SelectedWorkspacePane.* |
Stays on MainWindowViewModel (window-global): WorkspacePanes, selectedWorkspacePane/SelectedWorkspacePane, Layout, the shared dockFactory (WorkspaceDockFactory), SyncWorkspacePanesOrderFromDock + suppressWorkspaceDockOrderSync (987–1040), RemoveWorkspacePaneAsync collection-removal + re-select (2288–2329), DismissLoadingPane (1918–1943), GetOrCreateLoadingWorkspacePane (2331–2353), FindWorkspacePaneIdForTab (3280–3288) — now implemented as "ask each pane via OwnsDocumentTab", OnCloseWorkspace/CanCloseWorkspace command wiring (1969–1993), NavigationHistoryService interaction, ActivateTabByRequestAsync Phase-1 pane resolution, and the CloseWorkspaceCommand/CloseActiveTabCommand/CycleTab*Command RelayCommand objects (bodies delegate into the pane VM).
Stays on WorkspacePaneDocument (thin outer-dock wrapper): wraps WorkspacePaneViewModel, caches TabHeaderViewModel, forwards a few property-changed events, and cascades DisposeAsync into the pane VM. No new logic is added here.
Dependencies that complicate the move (must be injected into WorkspacePaneViewModel)
| Needed by |
MWVM member |
Injection target |
| Populate/Restore/CreateTab/Fetch |
entityBroker (field 53, entityBrokerTask 46) |
EntityBroker |
| CreateTabFromDescriptor / restore |
openAgentSessionShortcutHandler (63), shortcutManager (62), entityTypeViewCatalog (56), fieldEditorFactory (57) |
an IWorkspaceTabFactory bundling these |
| Open/Restore/active-dock resolution |
dockFactory (75): SetActiveDockable, SetFocusedDockable, ContextLocator, DockState, WireContentDock, CreateWorkspaceContentLayout (note: GetDocumentForTab removed in favour of the pane's own registry) |
WorkspaceDockFactory (window-scoped; pass in) |
| Tab close / MRU nav |
notificationService (93), navigationHistoryService (95), navigatingViaHistory (96), IsTabOpen |
INotificationService, NavigationHistoryService, Func<string,bool> isTabOpen |
| WriteBack write |
entityBroker.UpdateAsync |
keep write on MWVM (or inject IWorkspacePersistence) |
| Cross-pane concerns |
WorkspacePanes collection |
must remain on MWVM |
Registry location. After this refactor, WorkspaceDockFactory.documentsByTabId (WorkspaceDockFactory.cs:29) is removed and its contents live per-pane on WorkspacePaneViewModel. paneDocumentsByPaneId (WorkspaceDockFactory.cs:35) stays on the factory. The WorkspaceDocumentGenerator (constructed per pane) is handed a reference to the owning WorkspacePaneViewModel and registers/unregisters into pane.RegisterDocument/pane.UnregisterDocument.
Construction hookup. WorkspacePaneViewModel is currently constructed by MainWindowViewModel.CreateWorkspacePaneAsync (~3861). Pass a WorkspacePaneEnvironment (bundling EntityBroker, WorkspaceDockFactory, INotificationService, NavigationHistoryService, IWorkspaceTabFactory, dispatcher) into the pane VM's constructor. WorkspacePaneDocumentGenerator remains unchanged in shape; the pane VM already reaches its WorkspacePaneDocument via existing wiring.
XAML impact
Confirmed bindings in MainWindow.axaml: CloseActiveTabCommand (line 21), CycleTabForwardCommand/CycleTabBackwardCommand (22–23) are already MainWindowViewModel RelayCommands — leave the command objects on MWVM and delegate their bodies to SelectedWorkspacePane.*, so no XAML changes are required. SelectedWorkspacePane.SaveCommand/CanSaveWorkspace (28, 161–162) are unchanged. The outer DockControl binds Layout and WorkspacePanes — unchanged. No template files reference the moved internals.
Out of scope
Autosave-on-close is NOT part of this refactor. Do not mention or depend on WriteBackWorkspaceTabs-on-close as a companion change. It is being filed as its own separate bug and must not be conflated with this move. #1340's autosave-on-close concern (mechanism (B)) is explicitly not addressed by #1341; only mechanism (A) (stale registry entries) is resolved structurally.
Affected Files
| File |
Contribution |
features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs |
Source of all moved members; keeps Phase-1 navigation, window-global concerns |
features\Phantom.Workspaces\ViewModels\WorkspacePaneViewModel.cs |
Destination for per-pane logic AND the per-pane tab→document registry AND NavigateToDocumentTabAsync/OwnsDocumentTab |
features\Phantom.Workspaces\ViewModels\WorkspacePaneDocument.cs |
Unchanged in role — thin outer-dock Document wrapper |
features\Phantom.Workspaces\ViewModels\WorkspacePaneDocumentGenerator.cs |
Unchanged in shape |
features\Phantom.Workspaces\ViewModels\WorkspaceDocumentGenerator.cs |
Register/Unregister targets the owning pane's registry (line 40–42 collision guard rewired) |
features\Phantom.Workspaces\ViewModels\WorkspaceDockFactory.cs |
documentsByTabId (line 29) removed; paneDocumentsByPaneId (line 35) stays; RegisterDocument/UnregisterDocument (lines 54, 62–67, 88–89) removed or repointed; DockableLocator coupling (lines 54, 65–66) made pane-scoped |
features\Phantom.Workspaces\Services\Navigation\NavigationHistoryService.cs |
NavigationTarget/NavigationEntry renamed to WorkspaceTabId/DocumentTabId (lines 10, 23, 54) |
features\Phantom.Workspaces\Services\Navigation\ITabNavigatorHost.cs |
ActivateTabByIdAsync refactored to ActivateTabByRequestAsync(NavigationRequest) (line 16); Phase-1 only in host |
features\Phantom.Workspaces\Services\Navigation\MainWindowTabNavigator.cs |
Builds NavigationRequest; delegates Phase-2 to pane VM (lines 29–59) |
(new) IWorkspaceTabFactory |
Bundles descriptor→tab creation services |
(new) NavigationRequest(string WorkspaceTabId, string DocumentTabId) |
Two-phase navigation payload |
Tests to relocate
A per-pane test class does not exist yet; create features\Phantom.Workspaces.Tests\WorkspacePaneViewModelTests.cs for the moved logic (the existing WorkspacePaneViewModelTests.cs file, if present today, already mixes some pane-VM assertions — the moved-behaviour tests below live in the same file or a companion partial as appropriate). WorkspacePaneViewModelTests.cs already contains WorkspacePaneDocument_* assertions (lines 167, 178, 189, 203, 217, 234, 252, 270) — retarget those to the pane VM.
Relocate from features\Phantom.Workspaces.Tests\MainWindowViewModelTests.cs → WorkspacePaneViewModelTests.cs:
lines 28, 100, 130 (Alt/badge ordering per-pane), 213, 228, 245, 265, 296, 320, 347 (multi-region restore/open-new-window/navigation), 681, 702, 728, 742, 757, 771, 781 (TryFocusExistingWebTabAsync_*), 802, 816, 841, 859, 877 (pane dispose/close/reopen).
Relocate from features\Phantom.Workspaces.Tests\MainWindowDockTemplateTests.cs → WorkspacePaneViewModelTests.cs:
lines 471, 500, 530, 567, 599, 666, 698 (close-active-tab / split-region / MRU), 1161, 1219, 1259 (migrate base DocumentDock), 1457 (multi-region restore), 1875 (ctrl-click new window in restored region), 1958 (uniform wiring). Keep the pure XAML-template tests where they are.
Stays on MainWindowViewModelTests.cs (window-global): MainWindow_AltShiftDigit_ActivatesWorkspacePaneHostTab (57), MainWindow_F7F8_NotificationNavigation_StillWorks (180), LeftPane/collapser tests (559–626), refresh-tick tests (424, 442), NavigateToHistoryEntry_* (914), template tests. Leave WorkspaceDockPersistenceTests.cs / WorkspaceDockPersistenceIntegrationTests.cs in place (they target DockLayoutCanonicalizer directly, not MWVM).
Expected Tests
New/relocated tests in WorkspacePaneViewModelTests:
| Test Name |
Class |
What It Verifies |
WorkspacePaneViewModel_OwnsDocumentRegistry_RegistersTabDocumentOnAdd |
WorkspacePaneViewModelTests |
Adding a tab registers its WorkspaceDocument in the pane's own registry |
WorkspacePaneViewModel_Dispose_DiscardsDocumentRegistry |
WorkspacePaneViewModelTests |
Disposing the pane drops all its registrations (no window-scoped residue) |
WorkspaceReopen_AfterClose_WithRestoredAgentSessionTab_MaterializesDocument_NoStaleEntry |
MainWindowIntegrationTests |
Close→reopen materializes the restored tab because the fresh pane has an empty registry (structural #1340(A) guard) |
WorkspacePaneViewModel_NavigateToDocumentTabAsync_ActivatesAndFocusesWithinOwnLayout |
WorkspacePaneViewModelTests |
Phase-2 navigation activates/focuses the document tab using the pane's own registry |
MainWindowViewModel_NavigateByRequest_ResolvesPaneByWorkspaceTabId_ThenDelegatesToPane |
MainWindowViewModelTests |
Phase-1 resolves the pane by workspace tab id and delegates to the pane |
MainWindowViewModel_NavigateByRequest_UnknownDocumentTabId_FallsBackToAllPanesOwnershipQuery |
MainWindowViewModelTests |
Fallback asks each pane whether it owns the document tab id |
WorkspacePaneViewModel_CloseActiveTab_WhenActiveTabExists_RoutesThroughFactoryCloseDockable |
WorkspacePaneViewModelTests |
Close-active-tab routes through dockFactory.CloseDockable |
WorkspacePaneViewModel_CloseActiveTab_WhenLastTabInSplitDockClosed_RemovesEmptyDockAndSplitter |
WorkspacePaneViewModelTests |
Closing the last tab in a split region removes the empty dock + splitter |
WorkspacePaneViewModel_CloseActiveTab_WhenFocusedInSplitRegion_ClosesOnlyFocusedRegionActiveTab |
WorkspacePaneViewModelTests |
Focus-scoped close affects only the focused region |
WorkspacePaneViewModel_CycleTab_WhenTwoTabsExist_ActivatesNextInVisibleOrder |
WorkspacePaneViewModelTests |
Cycle activates next dockable in visible order |
WorkspacePaneViewModel_PopulateTabsAsync_WithLegacyRegionsJson_AddsTabsInDeclarationOrder |
WorkspacePaneViewModelTests |
Legacy regions JSON populates tabs in order |
WorkspacePaneViewModel_PopulateTabsAsync_WithActiveTabId_ActivatesSavedTab |
WorkspacePaneViewModelTests |
Saved active-tab id is activated |
WorkspacePaneViewModel_PopulateTabsAsync_WhenPaneClosedDuringLoad_DisposesLoadedTabs |
WorkspacePaneViewModelTests |
Racing close during load disposes loaded tabs |
WorkspacePaneViewModel_TryRestoreFromDockLayout_WithMultiRegionSplit_RestoresAllRegionsAsWorkspaceContentDock |
WorkspacePaneViewModelTests |
Multi-region split restore yields typed docks for every region |
WorkspacePaneViewModel_TryRestoreFromDockLayout_WithLegacyBaseDocumentDock_MigratesToWorkspaceContentDock |
WorkspacePaneViewModelTests |
Legacy base DocumentDock migrates |
WorkspacePaneViewModel_TryRestoreFromDockLayout_CanonicalizesDuplicateDocuments |
WorkspacePaneViewModelTests |
Duplicate documents are canonicalised on restore |
WorkspacePaneViewModel_OpenTabAsync_WhenAnchorTabProvided_InsertsAfterAnchorInSameRegion |
WorkspacePaneViewModelTests |
Anchor insert lands in the same region after the anchor |
WorkspacePaneViewModel_OpenTabAsync_WhenTabAlreadyOpen_JustActivates |
WorkspacePaneViewModelTests |
Re-open of an open tab just activates (dedup via per-pane registry) |
WorkspacePaneViewModel_ActivateTabWhenLoaded_WhenTabAddedLater_ActivatesOnce |
WorkspacePaneViewModelTests |
Deferred activation fires exactly once |
WorkspacePaneViewModel_TryFocusExistingWebTabAsync_SameUrlOpen_ActivatesAndReturnsTrue |
WorkspacePaneViewModelTests |
Same-URL web tab is focused |
WorkspacePaneViewModel_TryFocusExistingWebTabAsync_DifferentUrl_ReturnsFalse |
WorkspacePaneViewModelTests |
Different URL returns false |
WorkspacePaneViewModel_BuildPersistedTabsSnapshot_SerializesLayoutCanonically |
WorkspacePaneViewModelTests |
Snapshot serializes the layout canonically |
WorkspacePaneViewModel_BuildPersistedTabsSnapshot_IncludesActiveTabIdAndDropsLegacyFocusedTabId |
WorkspacePaneViewModelTests |
Snapshot includes active-tab id, drops legacy focused-tab id |
WorkspacePaneViewModel_BuildPersistedTabsSnapshot_AppendsRelationshipChangesForLiveTabEntities |
WorkspacePaneViewModelTests |
Snapshot appends relationship changes for live tab entities |
WorkspacePaneViewModel_DisposeAsync_CascadesToPaneAndReleasesAgentLeases |
WorkspacePaneViewModelTests |
Dispose cascades to pane and releases agent leases (relocated) |
WorkspacePaneViewModel_HandleChildTabClosed_WhenActive_ActivatesMruTab |
WorkspacePaneViewModelTests |
Closing the active child tab activates the MRU tab |
Considered / Background
An earlier revision of this issue proposed relocating the moved logic onto WorkspacePaneDocument (the outer-dock Document facade) rather than WorkspacePaneViewModel. The containment analysis behind that proposal is still valid and is preserved here for context:
WorkspacePaneDocument is the outer-dock facade for a pane (it is what the outer DockControl binds to as an IDocument), and one could describe per-pane behaviour as "everything a pane does inside the window that owns the outer DockControl" — which reads as WorkspacePaneDocument's responsibility.
- Under that proposal,
WorkspacePaneDocument would receive the moved logic and WorkspacePaneViewModel would keep the lean tab-bag + aggregations + SaveCommand + Populated role.
Why WorkspacePaneViewModel was chosen instead: WorkspacePaneViewModel already owns Id, Tabs, SelectedTab, ContentLayout, and SaveCommand — the exact state that the moved logic operates on. Adding the per-pane tab→document registry and NavigateToDocumentTabAsync to the same type keeps state and behaviour co-located, avoids a second layer of forwarding through WorkspacePaneDocument, and makes the natural lifetime boundary (the pane VM's DisposeAsync) also be the registry's lifetime boundary — which is what makes #1340 mechanism (A) go away by construction. WorkspacePaneDocument stays a thin outer-dock wrapper (Document + TabHeaderViewModel + forwarded PropertyChanged), which is a better fit for what Dock expects an IDocument to be.
Summary
MainWindowViewModel(features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs, ~4,790 lines) is a god-object that owns a large amount of logic that is really per-workspace-pane concern: restoring/populating a pane's tabs, walking and migrating the pane'sContentLayout, opening/closing/cycling tabs within a pane, serializing a pane's persisted layout, per-pane tab lifecycle, and — critically — the per-panetabId → WorkspaceDocumentregistry and the pane-scoped part of navigate-to-tab-by-id. All of this should move onto the per-pane view-modelWorkspacePaneViewModel(features\Phantom.Workspaces\ViewModels\WorkspacePaneViewModel.cs), which already ownsId,Tabs,SelectedTab,ContentLayout, aggregations,SaveCommand, andPopulated— making it the natural owner of the per-pane document registry and per-pane navigation.WorkspacePaneDocumentremains the thin outer-dockDocumentwrapper for the pane VM.MainWindowViewModelkeeps only window-global collection management (theWorkspacePanescollection, selection, outer-dock ordering, the sharedWorkspaceDockFactory, Phase-1 pane resolution for navigation, and thin command delegations that XAML binds). This is a structural refactor with no intended behaviour change (except it structurally eliminates one of the mechanisms tracked in #1340 — see below); existing per-pane tests should relocate to a newWorkspacePaneViewModelTestsfile.Current per-pane types (containment)
Outer
DockControl(MainWindow) →WorkspacesPaneDock(ItemsSource =MainWindowViewModel.WorkspacePanes) → per-paneWorkspacePaneDocument(extends Dock'sDocument; created byWorkspacePaneDocumentGenerator,WorkspacePaneDocumentGenerator.cs:25-48) → each holds aWorkspacePaneViewModel(WorkspacePaneDocument.WorkspacePane,WorkspacePaneDocument.cs:54) →WorkspacePaneViewModel.ContentLayoutis an innerIRootDockwhose primary region is aWorkspaceContentDockbound topane.Tabs→WorkspaceDocument(s) (the tabs), created byWorkspaceDocumentGenerator.WorkspacePaneDocument(WorkspacePaneDocument.cs, 75 lines) wraps the pane VM, caches aTabHeaderViewModel(running/notification indicators), forwardsTitle/AnyTabIsRunning/AnyTabHasUnreadNotificationPropertyChanged, and cascadesDisposeAsyncinto the pane. It stays a thin outer-dockDocumentwrapper — it is NOT the target of the move.WorkspacePaneViewModel(WorkspacePaneViewModel.cs, 277 lines) already ownsId/Title/Entity/IsReadOnly/IsSaving/CloseCommand/SaveCommand, theTabscollection,SelectedTab,ContentLayout, aggregation flags, thePopulatedTCS, tab-subscription bookkeeping, and recursiveDisposeAsync. Because it already ownsTabs+ContentLayout+Id, it is the natural home for the per-pane tab→document registry and per-pane navigation.Recommended target
Everything that can be moved off
MainWindowViewModelmoves ontoWorkspacePaneViewModel. That includes not only the per-pane logic previously enumerated below but also (new): the per-panetabId → WorkspaceDocumentregistry and Phase-2 of navigate-to-tab-by-id.MainWindowViewModelkeeps only window-global concerns (theWorkspacePanescollection,SelectedWorkspacePane, outer-dockLayout, the sharedWorkspaceDockFactory, the outer-dockpaneId → WorkspacePaneDocumentmap, Phase-1 pane resolution by workspace tab id, and thin command delegations).WorkspacePaneDocumentstays a thin outer-dockDocumentwrapper.Per-pane tab→document registry (key structural change)
Today
WorkspaceDockFactory.documentsByTabId(features\Phantom.Workspaces\ViewModels\WorkspaceDockFactory.cs:29) is a single window-scoped dictionary that outlives individual panes.WorkspaceDocumentGeneratorcallsfactory.RegisterDocument(tab.Id, document)/UnregisterDocument(tab.Id)(seeWorkspaceDockFactory.cs:62-67, 88-89, 107, 190-191) and, on materialization, guards against duplicates withexisting = factory.GetDocumentForTab(tab.Id)+!ReferenceEquals(existing.Owner, dock)(WorkspaceDocumentGenerator.cs:40-42). Because the registry outlives panes, a closed-then-reopened pane can still find a stale prior-owner entry and skip materialization — the mechanism (A) of #1340.Move it. Each
WorkspacePaneViewModelgets its ownDictionary<string, WorkspaceDocument>(itstabId → WorkspaceDocumentregistry). The pane's inner-dockWorkspaceDocumentGeneratorregisters/unregisters into the owning pane's registry (the generator is already constructed per pane and can be handed a reference to the pane VM at construction time).Consequences to state explicitly:
WorkspaceDockFactory.paneDocumentsByPaneId(paneId →WorkspacePaneDocument,WorkspaceDockFactory.cs:35) STAYS window-scoped — it is the outer-dock pane-resolution concern (needed for Phase-1 of navigation and for restoringWorkspacePaneDocuments by id), not a per-pane concern.WorkspaceDocumentGeneratorcollision guard (WorkspaceDocumentGenerator.cs:40-42) is retained but resolves against the owning pane's registry. It still prevents duplicate materialization when the same tab id appears in multiple split regions of the same pane'sContentLayout, but it no longer false-positives across panes.DockableLocatorcoupling inWorkspaceDockFactory.RegisterDocument/UnregisterDocument(WorkspaceDockFactory.cs:54, 65-66) — the id→IDockablelocator used by Dock's restore-by-id — must be repointed to the per-pane registry (or made pane-scoped: each pane's inner-dock has its ownContextLocator/DockableLocator, so this is a local change). Nothing outside the pane should be resolving tab ids by direct dictionary lookup after this refactor.Relationship to #1340
Closing a pane (
RemoveWorkspacePaneAsync,MainWindowViewModel.cs:2288) currently leaves entries indocumentsByTabIdfor all tabs the pane owned; #1340 mechanism (A) is that those stale entries cause the reopened pane'sWorkspaceDocumentGeneratorto short-circuit (existing != null && !ReferenceEquals(existing.Owner, dock)) and skip materialization — the reopened pane shows "no documents open" even though itsTabscollection is populated. With the registry moved ontoWorkspacePaneViewModel, closing the pane discards its registry wholesale, and a reopened pane starts with an empty registry — so the collision guard cannot trip against a stale prior-owner entry and the restored documents materialize.State clearly: this move eliminates #1340's mechanism (A) by construction — no per-document eviction is needed, and this is preferred over #1340's previously-proposed explicit
UnregisterDocument-enumeration-on-close workaround. #1340 should be marked as resolved-by / dependent-on #1341 for mechanism (A). This does NOT address #1340's autosave-on-close concern, which is explicitly out of scope here and tracked separately (see the "Out of scope" note below).Navigate-to-tab by id (two-phase request)
Today
MainWindowViewModel.ActivateTabByIdAsync/ActivateTabWhenLoadedandOpenTabAsync's dedup path do a window-globaldockFactory.GetDocumentForTab(tabId)lookup (MainWindowViewModel.cs:2212, 2240, 2261, 2278, 2399), thenFindDocumentDock+SetActiveDockable/SetFocusedDockable. Moving the registry per-pane removes the global lookup. Navigation becomes two-phase, driven by a request that carries both a workspace tab id (= pane id) and a document tab id (= tab id):Request shape (realign existing scaffolding, do not reinvent — see
features\Phantom.Workspaces\Services\Navigation\NavigationHistoryService.cs:10, 23, 54, which already hasNavigationTargetwithTabId/WorkspacePaneIdandNavigationEntry(string tabId, string paneId); andMainWindowTabNavigator.NavigateAsyncatMainWindowTabNavigator.cs:29-59which already pushesnew NavigationEntry(tabId, paneId)):Phase 1 — MWVM identifies the workspace pane by
WorkspaceTabId. UsesWorkspacePanes/WorkspaceDockFactory.paneDocumentsByPaneIdto resolve theWorkspacePaneDocument, opening the pane first if needed (per #1157). MWVM activates the pane in the outer dock.Phase 2 — the pane resolves the document tab id against its OWN registry and activates/focuses it within its own
ContentLayout. New method onWorkspacePaneViewModel:ITabNavigatorHost.ActivateTabByIdAsync(string tabId, string? workspacePaneId)(ITabNavigatorHost.cs:16) is refactored so the host only does Phase-1 pane resolution and then delegates Phase-2 toWorkspacePaneViewModel.NavigateToDocumentTabAsync. The signature is realigned toNavigationRequest:MainWindowTabNavigator.NavigateAsync(MainWindowTabNavigator.cs:29-59) is updated to build aNavigationRequest(WorkspaceTabId: paneId, DocumentTabId: tabId)from the current signals;NavigationHistoryService.NavigationTarget/NavigationEntryare renamed to useWorkspaceTabId/DocumentTabIdfor clarity but keep the same on-disk semantics (paneId, tabId).Fallback for unknown
WorkspaceTabId. Today the "search all panes" fallback lives atMainWindowViewModel.cs:2234-2249. It becomes: ask each pane whether it owns theDocumentTabId(each pane exposesbool OwnsDocumentTab(string documentTabId)that consults its own registry). This remains possible without a global registry.Deferred activation.
ActivateTabWhenLoaded(MainWindowViewModel.cs:2252-2286) — currently a MWVM-level hook that awaitspane.Tabschange — moves ontoWorkspacePaneViewModeland is triggered fromNavigateToDocumentTabAsyncwhen the document tab id is present inTabsbut not yet materialized inContentLayout.Members to move / keep / split
Classification: (a) clearly per-pane → move; (b) shared/global → stays; (c) coordination → split (part moves, thin delegation stays). All line numbers are in
MainWindowViewModel.csunless noted.OnCloseActiveTabFindFocusedDocumentDock/FindDocumentDock/dockFactory.CloseDockable; MRU navWorkspacePaneViewModel.CloseActiveTabAsyncOnCycleTabContentLayoutWorkspacePaneViewModel.CycleTab(delta)ActivateTabByIdAsync(per-pane portions)GetDocumentForTab+ per-pane searchWorkspacePaneViewModel.NavigateToDocumentTabAsyncActivateTabWhenLoadedpane.TabsWorkspacePaneViewModel(private helper invoked fromNavigateToDocumentTabAsync)SubscribeToInnerDockChanges/UnsubscribeWriteBackWorkspaceTabsWorkspacePaneViewModel.BuildPersistedTabsSnapshotAsync; theentityBroker.UpdateAsyncwrite stays on MWVMSaveWorkspacePaneAsyncWriteBackWorkspaceTabs; already injected assaveAsync(ctor at 3865)pane.SaveAsyncAppendWorkspaceTabRelationshipChangesFindDocumentDockIDocumentDock, always overpane.ContentLayoutFindFocusedDocumentDockEnumerateAllDocuments/EnumerateContentDocksMigrateBaseDocumentDocksToWorkspaceContentDock,ConvertToWorkspaceContentDock,s_documentDockCopyableProperties,BuildDocumentDockCopyablePropertiesPopulateWorkspacePaneTabsAsyncpane.Tabs, activates saved active tabWorkspacePaneViewModel.PopulateTabsAsyncTryRestoreFromDockLayoutAsyncContentLayoutfrom JSON, migrates, wires content docks, populates tabsCreateTabViewModelFromDescriptorAsyncIWorkspaceTabFactoryTryFetchWorkspaceTabAsync,CreateTabFromEntityAsyncIWorkspaceTabFactoryTryFocusExistingWebTabAsyncOnDockableTabClosedWorkspacePaneViewModel.HandleChildTabClosedOpenTabAsyncGetDocumentForTab(2399)WorkspacePaneViewModel.OwnsDocumentTab); per-pane insert body →WorkspacePaneViewModel.OpenTabAsyncAddWorkspacePaneToDockSelectedWorkspacePane.PaneDocument.Activate()CreateWorkspacePaneAsyncContentLayout, defers to populate/restoreActiveTabId/ActiveAgentViewModelselectedWorkspacePane.ContentLayoutSelectedWorkspacePane.*Stays on
MainWindowViewModel(window-global):WorkspacePanes,selectedWorkspacePane/SelectedWorkspacePane,Layout, the shareddockFactory(WorkspaceDockFactory),SyncWorkspacePanesOrderFromDock+suppressWorkspaceDockOrderSync(987–1040),RemoveWorkspacePaneAsynccollection-removal + re-select (2288–2329),DismissLoadingPane(1918–1943),GetOrCreateLoadingWorkspacePane(2331–2353),FindWorkspacePaneIdForTab(3280–3288) — now implemented as "ask each pane viaOwnsDocumentTab",OnCloseWorkspace/CanCloseWorkspacecommand wiring (1969–1993),NavigationHistoryServiceinteraction,ActivateTabByRequestAsyncPhase-1 pane resolution, and theCloseWorkspaceCommand/CloseActiveTabCommand/CycleTab*CommandRelayCommand objects (bodies delegate into the pane VM).Stays on
WorkspacePaneDocument(thin outer-dock wrapper): wrapsWorkspacePaneViewModel, cachesTabHeaderViewModel, forwards a few property-changed events, and cascadesDisposeAsyncinto the pane VM. No new logic is added here.Dependencies that complicate the move (must be injected into
WorkspacePaneViewModel)entityBroker(field 53,entityBrokerTask46)EntityBrokeropenAgentSessionShortcutHandler(63),shortcutManager(62),entityTypeViewCatalog(56),fieldEditorFactory(57)IWorkspaceTabFactorybundling thesedockFactory(75):SetActiveDockable,SetFocusedDockable,ContextLocator,DockState,WireContentDock,CreateWorkspaceContentLayout(note:GetDocumentForTabremoved in favour of the pane's own registry)WorkspaceDockFactory(window-scoped; pass in)notificationService(93),navigationHistoryService(95),navigatingViaHistory(96),IsTabOpenINotificationService,NavigationHistoryService,Func<string,bool> isTabOpenentityBroker.UpdateAsyncIWorkspacePersistence)WorkspacePanescollectionRegistry location. After this refactor,
WorkspaceDockFactory.documentsByTabId(WorkspaceDockFactory.cs:29) is removed and its contents live per-pane onWorkspacePaneViewModel.paneDocumentsByPaneId(WorkspaceDockFactory.cs:35) stays on the factory. TheWorkspaceDocumentGenerator(constructed per pane) is handed a reference to the owningWorkspacePaneViewModeland registers/unregisters intopane.RegisterDocument/pane.UnregisterDocument.Construction hookup.
WorkspacePaneViewModelis currently constructed byMainWindowViewModel.CreateWorkspacePaneAsync(~3861). Pass aWorkspacePaneEnvironment(bundlingEntityBroker,WorkspaceDockFactory,INotificationService,NavigationHistoryService,IWorkspaceTabFactory, dispatcher) into the pane VM's constructor.WorkspacePaneDocumentGeneratorremains unchanged in shape; the pane VM already reaches itsWorkspacePaneDocumentvia existing wiring.XAML impact
Confirmed bindings in
MainWindow.axaml:CloseActiveTabCommand(line 21),CycleTabForwardCommand/CycleTabBackwardCommand(22–23) are alreadyMainWindowViewModelRelayCommands — leave the command objects on MWVM and delegate their bodies toSelectedWorkspacePane.*, so no XAML changes are required.SelectedWorkspacePane.SaveCommand/CanSaveWorkspace(28, 161–162) are unchanged. The outerDockControlbindsLayoutandWorkspacePanes— unchanged. No template files reference the moved internals.Out of scope
Autosave-on-close is NOT part of this refactor. Do not mention or depend on
WriteBackWorkspaceTabs-on-close as a companion change. It is being filed as its own separate bug and must not be conflated with this move. #1340's autosave-on-close concern (mechanism (B)) is explicitly not addressed by #1341; only mechanism (A) (stale registry entries) is resolved structurally.Affected Files
features\Phantom.Workspaces\ViewModels\MainWindowViewModel.csfeatures\Phantom.Workspaces\ViewModels\WorkspacePaneViewModel.csNavigateToDocumentTabAsync/OwnsDocumentTabfeatures\Phantom.Workspaces\ViewModels\WorkspacePaneDocument.csDocumentwrapperfeatures\Phantom.Workspaces\ViewModels\WorkspacePaneDocumentGenerator.csfeatures\Phantom.Workspaces\ViewModels\WorkspaceDocumentGenerator.csfeatures\Phantom.Workspaces\ViewModels\WorkspaceDockFactory.csdocumentsByTabId(line 29) removed;paneDocumentsByPaneId(line 35) stays;RegisterDocument/UnregisterDocument(lines 54, 62–67, 88–89) removed or repointed;DockableLocatorcoupling (lines 54, 65–66) made pane-scopedfeatures\Phantom.Workspaces\Services\Navigation\NavigationHistoryService.csNavigationTarget/NavigationEntryrenamed toWorkspaceTabId/DocumentTabId(lines 10, 23, 54)features\Phantom.Workspaces\Services\Navigation\ITabNavigatorHost.csActivateTabByIdAsyncrefactored toActivateTabByRequestAsync(NavigationRequest)(line 16); Phase-1 only in hostfeatures\Phantom.Workspaces\Services\Navigation\MainWindowTabNavigator.csNavigationRequest; delegates Phase-2 to pane VM (lines 29–59)IWorkspaceTabFactoryNavigationRequest(string WorkspaceTabId, string DocumentTabId)Tests to relocate
A per-pane test class does not exist yet; create
features\Phantom.Workspaces.Tests\WorkspacePaneViewModelTests.csfor the moved logic (the existingWorkspacePaneViewModelTests.csfile, if present today, already mixes some pane-VM assertions — the moved-behaviour tests below live in the same file or a companion partial as appropriate).WorkspacePaneViewModelTests.csalready containsWorkspacePaneDocument_*assertions (lines 167, 178, 189, 203, 217, 234, 252, 270) — retarget those to the pane VM.Relocate from
features\Phantom.Workspaces.Tests\MainWindowViewModelTests.cs→WorkspacePaneViewModelTests.cs:lines 28, 100, 130 (Alt/badge ordering per-pane), 213, 228, 245, 265, 296, 320, 347 (multi-region restore/open-new-window/navigation), 681, 702, 728, 742, 757, 771, 781 (
TryFocusExistingWebTabAsync_*), 802, 816, 841, 859, 877 (pane dispose/close/reopen).Relocate from
features\Phantom.Workspaces.Tests\MainWindowDockTemplateTests.cs→WorkspacePaneViewModelTests.cs:lines 471, 500, 530, 567, 599, 666, 698 (close-active-tab / split-region / MRU), 1161, 1219, 1259 (migrate base DocumentDock), 1457 (multi-region restore), 1875 (ctrl-click new window in restored region), 1958 (uniform wiring). Keep the pure XAML-template tests where they are.
Stays on
MainWindowViewModelTests.cs(window-global):MainWindow_AltShiftDigit_ActivatesWorkspacePaneHostTab(57),MainWindow_F7F8_NotificationNavigation_StillWorks(180), LeftPane/collapser tests (559–626), refresh-tick tests (424, 442),NavigateToHistoryEntry_*(914), template tests. LeaveWorkspaceDockPersistenceTests.cs/WorkspaceDockPersistenceIntegrationTests.csin place (they targetDockLayoutCanonicalizerdirectly, not MWVM).Expected Tests
New/relocated tests in
WorkspacePaneViewModelTests:WorkspacePaneViewModel_OwnsDocumentRegistry_RegistersTabDocumentOnAddWorkspacePaneViewModelTestsWorkspaceDocumentin the pane's own registryWorkspacePaneViewModel_Dispose_DiscardsDocumentRegistryWorkspacePaneViewModelTestsWorkspaceReopen_AfterClose_WithRestoredAgentSessionTab_MaterializesDocument_NoStaleEntryMainWindowIntegrationTestsWorkspacePaneViewModel_NavigateToDocumentTabAsync_ActivatesAndFocusesWithinOwnLayoutWorkspacePaneViewModelTestsMainWindowViewModel_NavigateByRequest_ResolvesPaneByWorkspaceTabId_ThenDelegatesToPaneMainWindowViewModelTestsMainWindowViewModel_NavigateByRequest_UnknownDocumentTabId_FallsBackToAllPanesOwnershipQueryMainWindowViewModelTestsWorkspacePaneViewModel_CloseActiveTab_WhenActiveTabExists_RoutesThroughFactoryCloseDockableWorkspacePaneViewModelTestsdockFactory.CloseDockableWorkspacePaneViewModel_CloseActiveTab_WhenLastTabInSplitDockClosed_RemovesEmptyDockAndSplitterWorkspacePaneViewModelTestsWorkspacePaneViewModel_CloseActiveTab_WhenFocusedInSplitRegion_ClosesOnlyFocusedRegionActiveTabWorkspacePaneViewModelTestsWorkspacePaneViewModel_CycleTab_WhenTwoTabsExist_ActivatesNextInVisibleOrderWorkspacePaneViewModelTestsWorkspacePaneViewModel_PopulateTabsAsync_WithLegacyRegionsJson_AddsTabsInDeclarationOrderWorkspacePaneViewModelTestsWorkspacePaneViewModel_PopulateTabsAsync_WithActiveTabId_ActivatesSavedTabWorkspacePaneViewModelTestsWorkspacePaneViewModel_PopulateTabsAsync_WhenPaneClosedDuringLoad_DisposesLoadedTabsWorkspacePaneViewModelTestsWorkspacePaneViewModel_TryRestoreFromDockLayout_WithMultiRegionSplit_RestoresAllRegionsAsWorkspaceContentDockWorkspacePaneViewModelTestsWorkspacePaneViewModel_TryRestoreFromDockLayout_WithLegacyBaseDocumentDock_MigratesToWorkspaceContentDockWorkspacePaneViewModelTestsWorkspacePaneViewModel_TryRestoreFromDockLayout_CanonicalizesDuplicateDocumentsWorkspacePaneViewModelTestsWorkspacePaneViewModel_OpenTabAsync_WhenAnchorTabProvided_InsertsAfterAnchorInSameRegionWorkspacePaneViewModelTestsWorkspacePaneViewModel_OpenTabAsync_WhenTabAlreadyOpen_JustActivatesWorkspacePaneViewModelTestsWorkspacePaneViewModel_ActivateTabWhenLoaded_WhenTabAddedLater_ActivatesOnceWorkspacePaneViewModelTestsWorkspacePaneViewModel_TryFocusExistingWebTabAsync_SameUrlOpen_ActivatesAndReturnsTrueWorkspacePaneViewModelTestsWorkspacePaneViewModel_TryFocusExistingWebTabAsync_DifferentUrl_ReturnsFalseWorkspacePaneViewModelTestsWorkspacePaneViewModel_BuildPersistedTabsSnapshot_SerializesLayoutCanonicallyWorkspacePaneViewModelTestsWorkspacePaneViewModel_BuildPersistedTabsSnapshot_IncludesActiveTabIdAndDropsLegacyFocusedTabIdWorkspacePaneViewModelTestsWorkspacePaneViewModel_BuildPersistedTabsSnapshot_AppendsRelationshipChangesForLiveTabEntitiesWorkspacePaneViewModelTestsWorkspacePaneViewModel_DisposeAsync_CascadesToPaneAndReleasesAgentLeasesWorkspacePaneViewModelTestsWorkspacePaneViewModel_HandleChildTabClosed_WhenActive_ActivatesMruTabWorkspacePaneViewModelTestsConsidered / Background
An earlier revision of this issue proposed relocating the moved logic onto
WorkspacePaneDocument(the outer-dockDocumentfacade) rather thanWorkspacePaneViewModel. The containment analysis behind that proposal is still valid and is preserved here for context:WorkspacePaneDocumentis the outer-dock facade for a pane (it is what the outerDockControlbinds to as anIDocument), and one could describe per-pane behaviour as "everything a pane does inside the window that owns the outer DockControl" — which reads asWorkspacePaneDocument's responsibility.WorkspacePaneDocumentwould receive the moved logic andWorkspacePaneViewModelwould keep the lean tab-bag + aggregations +SaveCommand+Populatedrole.Why
WorkspacePaneViewModelwas chosen instead:WorkspacePaneViewModelalready ownsId,Tabs,SelectedTab,ContentLayout, andSaveCommand— the exact state that the moved logic operates on. Adding the per-pane tab→document registry andNavigateToDocumentTabAsyncto the same type keeps state and behaviour co-located, avoids a second layer of forwarding throughWorkspacePaneDocument, and makes the natural lifetime boundary (the pane VM'sDisposeAsync) also be the registry's lifetime boundary — which is what makes #1340 mechanism (A) go away by construction.WorkspacePaneDocumentstays a thin outer-dock wrapper (Document+TabHeaderViewModel+ forwarded PropertyChanged), which is a better fit for what Dock expects anIDocumentto be.