| Status | Draft — 2026-05-23 |
| Owner | @codemonkeychris |
| Related | 045 (docking design — this spec is a P3 additive amendment per §6.4), feedback note pix/winui-port/feedback/reactor-docking-center-targets-leftmost-group.md |
Reactor's docking system today treats every DockTabGroup as interchangeable. DockHostModel.Dock(content, DockTarget.Center) routes into the leftmost descendant tab group of the root — regardless of what that group contains. In a Visual Studio-shaped layout ([ left tool window | empty doc area | right tool window ]), programmatically opening a document tabs it next to the left tool window, not into the empty middle group.
This spec introduces a small additive vocabulary on top of the existing DockNode algebra so that apps can express the document-area-vs-tool-window-strip distinction that every IDE-class docking framework supports (AvalonDock, Avalonia Dock, DevExpress, Telerik, Eclipse, IntelliJ, Qt). The shape:
- A
DockGroupRoletag onDockTabGroup(General|DocumentArea|ToolWindowStrip). - An
AllowedSidesmask onToolWindow(Qt-style placement constraint). - A routing rule that prefers role-matched groups for
Dock(Center)and similar. - Reserved-center semantics:
DocumentAreagroups survive empty without being culled. - Drag-drop overlay filters incompatible drop targets.
Default Role = General and unconstrained AllowedSides preserve today's behavior for callers that don't opt in. No breaking change.
DockLayoutMutator.AddAsTab in src/Reactor/Docking/Native/DockLayoutMutator.cs:336-366 unconditionally recurses into s.Children[0] when the root is a DockSplit:
case DockSplit s:
{
if (s.Children.Count == 0) return pane;
var newChildren = new DockNode[s.Children.Count];
for (int i = 0; i < s.Children.Count; i++) newChildren[i] = s.Children[i];
newChildren[0] = AddAsTab(s.Children[0], pane);
return s with { Children = newChildren };
}So Dock(Center) is really "leftmost descendant tab group". A VS-style layout
DockSplit Horizontal
├─ DockTabGroup [ ToolWindow "Gallery Items" ] (leftmost)
├─ DockTabGroup [ ] (intended doc area)
└─ DockTabGroup [ ToolWindow "Configuration" ]
routes documents into the Gallery Items group every time.
DockHostModel's public mutators are Dock(target), Float, Hide, Show, Close, Activate, PinToSide. None lets the caller name a target DockTabGroup. IDockLayoutStrategy.BeforeInsertDocument gets the model handle but has the same Dock(target) available, so it can't route either.
Restructuring the layout doesn't fix this. To make Dock(Center) land in the doc area, the doc area's DockTabGroup must be the leftmost descendant of the root — conflicting with the natural "tool window on the left, documents in the middle" arrangement every IDE uses.
A related issue: closing the last document in a tab group runs the post-close cull pass and removes the group entirely, collapsing the layout. In a VS-style shell, the document well should remain as a visible empty surface ready to accept the next document. Today this requires ShowWhenEmpty = true on the group, which is a flag the app has to remember to set and doesn't compose with the routing problem above.
- G1.
Dock(content, Center)lands in the right place when the layout has a designated document area, without the caller holding group references. - G2. Document-area groups survive empty without per-group
ShowWhenEmptybookkeeping. - G3. Apps can constrain where tool windows are allowed to dock (drag-drop and programmatic), Qt-style.
- G4. Drag-drop overlay greys out incompatible drop targets so users get visual feedback before releasing the drag.
- G5. Tagging round-trips through layout JSON; default-value handling preserves old layouts.
- G6. No breaking changes for callers that don't opt in. Default
Role = Generalproduces today's behavior. - G7. Add the public group-targeting overload that already exists internally (
Dock(content, DockTabGroup, DockTarget)), so strategies and advanced apps have a hatch.
- N1. A predicate /
Acceptsfunction onDockTabGroup(option 3 from §5). Punted to a future spec if v1 enums prove insufficient. - N2. Named-region / template-style layouts (option 2 from §5). Larger redesign; not motivated by current scenarios.
- N3. First-party
VisualStudioLayoutStrategy(option 4 from §5). The role+mask vocabulary makes most VS-style behavior the default; a starter strategy can ship later if needed. - N4. Restructuring
Document/ToolWindowinto separate node types (AvalonDock'sLayoutDocumentPanevsLayoutAnchorablePaneshape). Spec 045 §5.3.1 already chose the "category-on-content + tag-on-group" model; this spec follows through on it rather than reopening it. - N5. Drag-drop changes that affect the drag session (preview, snap-back, threshold). Only the drop-target filter changes.
Surveyed in conversation prior to drafting; summarized here for spec self-containedness.
- AvalonDock (WPF) —
LayoutDocumentPanevsLayoutAnchorablePane. Document panes survive empty. Routing by type. - Avalonia Dock —
DocumentDockvsToolDock.DocumentDock.CanCreateDocumentlets the host construct new docs in place. - DevExpress DockManager —
DocumentGroupvsLayoutGroup.DocumentGroupis the reserved center; never collapses. - Telerik RadDocking —
RadDocumentHostis the reserved document well. - Syncfusion DockingManager —
DocumentContainer = trueflag on a child.
The common pattern: container kind IS the policy. Type enforcement is structural, default placement is "find the container of matching type," reserved space is "DocumentDock survives empty."
- Qt
QDockWidget.setAllowedAreas(...)— content declares where it can land; host enforces. - WinForms DockPanel Suite —
DockContent.DockAreasflags +ShowHint = DockState.Documentfor default placement. - IntelliJ Platform —
ToolWindowAnchordeclared at registration; editor area is a separate subsystem entirely.
This composes cleanly with §4.1 and is worth adopting alongside typed groups regardless.
- Named slots / part-placeholders (Eclipse e4, VS Code view containers, NetBeans
Mode) — most expressive, but a bigger redesign than the current pain warrants. - Predicate-based drop targeting (GoldenLayout, FlexLayout
onTabDrag) — useful escape hatch, but rarely the primary mechanism. Function-valued props don't serialize cleanly. - Strategy + low-level primitives only — fine for power users; makes the common case feel like a chore.
See §5 for the full decision matrix.
Four options were sketched before settling on the recommendation. Recorded here so future readers see what was rejected and why.
DockGroupRole enum on DockTabGroup; existing Document / ToolWindow subclasses supply the category. Router prefers role-matched groups for Dock(Center). Role.DocumentArea implies reserved.
- Pro: smallest mental-model jump, persistable as enum, matches what porters from AvalonDock expect.
- Con: closed enum forces VS-shaped vocabulary.
Custom+RoleTagwould be the escape hatch if needed; punted to a follow-up.
Layout declares typed, named regions (new DockRegion("documents", Accepts: Docs, IsDefault: true, …)). Content references regions by name.
- Pro: first-class document-area concept, deterministic Reset Layout (skeleton is data).
- Con: introduces a new node type alongside
DockTabGroup; two ways to author layouts. Bigger redesign than the current pain warrants.
Func<DockableContent, bool>? Accepts + Func<DockableContent, int>? PlacementScore on DockTabGroup.
- Pro: maximum expressivity with a tiny vocabulary.
- Con: function-valued props don't serialize — must be re-attached on each mount. Wrong primitive to lead with; right primitive to add later as an escape hatch.
Expose Dock(content, DockTabGroup, DockTarget) publicly; ship a VisualStudioLayoutStrategy that wires VS behavior on top.
- Pro: keeps platform concept count low.
- Con: drag-drop enforcement still needs a platform hook (a strategy can't intercept overlay targeting). Most apps want the same policy; making it opt-in feels like exporting a chore.
| Default placement | Reserved space | Type enforcement | JSON round-trip | |
|---|---|---|---|---|
| A Roles+Categories | Role↔Category match | Role.DocumentArea implies |
Role/category matrix | ✓ |
| B Named Regions | IsDefault per region |
Region Reserved |
Region Accepts |
✓ |
| C Predicates | PlacementScore |
Reserved flag |
Accepts func |
partial |
| D Strategy + primitives | App's strategy | Strategy + ShowWhenEmpty |
Strategy + new drag hook | n/a |
Option A wins on the bottom row (JSON round-trip) and the "no app code required for the common case" axis. The targeting primitive from D is included alongside A as the explicit-control hatch (§6.4).
namespace Microsoft.UI.Reactor.Docking;
/// <summary>
/// Categorizes a <see cref="DockTabGroup"/> for routing and reserved-empty
/// behavior. Default <see cref="General"/> preserves pre-046 semantics.
/// </summary>
public enum DockGroupRole
{
/// <summary>Untyped group. Today's behavior — accepts any content,
/// removed from the layout when empty.</summary>
General,
/// <summary>The document well. Preferred target for
/// <see cref="Document"/> inserts via <c>Dock(Center)</c>; rejects
/// <see cref="ToolWindow"/> drops by default. Survives empty
/// (implicit <c>ShowWhenEmpty = true</c>; exempt from cull).</summary>
DocumentArea,
/// <summary>An edge strip of tool windows. Rejects <see cref="Document"/>
/// drops by default; routes tool windows here when their
/// <see cref="ToolWindow.AllowedSides"/> matches the host's resolved
/// side.</summary>
ToolWindowStrip,
}DockTabGroup gains:
public sealed record DockTabGroup(
IReadOnlyList<DockableContent> Documents,
TabPosition TabPosition = TabPosition.Top,
bool CompactTabs = false,
bool ShowWhenEmpty = false,
int SelectedIndex = -1,
double? Width = null,
double? Height = null,
TabChrome TabChrome = TabChrome.Win11,
DockGroupRole Role = DockGroupRole.General) : DockNode;[Flags]
public enum DockSides
{
None = 0,
Left = 1 << 0,
Top = 1 << 1,
Right = 1 << 2,
Bottom = 1 << 3,
All = Left | Top | Right | Bottom,
}
public sealed record ToolWindow : DockableContent
{
// … existing fields …
/// <summary>
/// Edges this tool window may dock to. Affects drag-drop drop-target
/// eligibility and programmatic <see cref="DockHostModel.PinToSide"/>.
/// Default <see cref="DockSides.All"/> preserves today's behavior.
/// </summary>
public DockSides AllowedSides { get; init; } = DockSides.All;
}Documents are not edge-constrained, so the mask only lives on ToolWindow.
Replace DockLayoutMutator.AddAsTab's "always recurse into Children[0]" with a role-aware search:
AddAsTab(root, pane):
category = CategoryOf(pane) // Document | ToolWindow | DockableContent
return InsertInto(root, pane, category)
InsertInto(node, pane, category):
match node:
case DockTabGroup g:
if AcceptsCategory(g.Role, category): append pane to g
else: return null // signal "not me"
case DockSplit s:
// First pass: prefer role-matched group anywhere in the subtree
for child in s.Children:
if PreferredFor(child, category):
recurse → return updated s with child replaced
// Second pass: first group that accepts the category
for child in s.Children:
result = InsertInto(child, pane, category)
if result != null: return updated s
return null
case DockableContent leaf:
wrap leaf+pane as new DockTabGroup
Acceptance table:
| Pane category | General |
DocumentArea |
ToolWindowStrip |
|---|---|---|---|
Document |
✓ | ✓ (preferred) | ✗ |
ToolWindow |
✓ | ✗ | ✓ (preferred) |
Base DockableContent |
✓ | ✓ | ✓ |
Untyped DockableContent accepts everywhere for back-compat (P1 callers).
If no group accepts (e.g., document insert into a layout with only ToolWindowStrip groups), fall back to today's behavior: append to the leftmost group. Emit a DockOperationLog diagnostic noting the fallback.
Expose what's already internal:
public partial class DockHostModel
{
/// <summary>
/// Insert <paramref name="content"/> into <paramref name="targetGroup"/>
/// at the given target. The group reference must come from the current
/// layout snapshot (e.g. captured at layout-build time or resolved
/// from <see cref="Layout"/>).
/// </summary>
public void Dock(DockableContent content, DockTabGroup targetGroup,
DockTarget target = DockTarget.Center) { … }
}Implementation queues a new PendingMutation.DockToGroupOp that dispatches to the existing DockLayoutMutator.MovePaneToGroupTarget / InsertPaneIntoGroup. Same identity protocol as the drag-drop path.
This is the explicit-control escape hatch for cases the role/category routing can't express.
Role.DocumentArea implies the existing ShowWhenEmpty = true behavior and exempts the group from the post-close cull pass — but only enough exemption to guarantee that at most one DocumentArea group always remains in the tree as the reserved well. Two cases:
- Author sets
ShowWhenEmpty = trueexplicitly. Unchanged today's behavior; group survives empty. - Author sets
Role = DocumentAreabut leavesShowWhenEmpty = false. EffectiveShowWhenEmptyis treated astruefor cull purposes. Persisted JSON reflects what the author wrote (no normalization at serialize time).
The cull pass (DockLayoutMutator's "remove empty groups" sweep — single call site to identify during implementation) gets one additional check: if (group.Role == DockGroupRole.DocumentArea) skip. After the recursive remove, a post-pass PruneRedundantEmptyDocumentAreas walks the tree once: if more than one empty DocumentArea group exists, the first (tree-order) is preserved and the rest cull.
Why the "first wins" prune rule. Split-on-drag (§2.3) creates new sibling DocumentArea groups when the user drags a Document to a Split* edge of an existing DocumentArea. Without the prune pass, closing all documents in BOTH sibling groups would leave two empty wells stacked next to each other — visually noisy, structurally meaningless. The prune rule is local to "empty DocumentArea groups that are redundant"; non-empty wells are never culled, regardless of count. The "first" preference matches reading order so the user's mental model of "the well I had first" survives.
This is a refinement of the literal spec text — discovered during implementation when the unrefined rule produced empty-well clutter in the dock-showcase Scene J. Apps that need every DocumentArea preserved unconditionally should set ShowWhenEmpty = true explicitly (case 1 above) — explicit ShowWhenEmpty bypasses the prune pass.
Drop-target rendering (DockDropTargetOverlayElement + caller sites in DockHostNativeComponent) gets a single filter point:
bool CanDropInto(DockTabGroup target, DockableContent payload)
{
var category = CategoryOf(payload);
if (!AcceptsCategory(target.Role, category)) return false;
if (payload is ToolWindow tw && target.ResolvedSide is DockSide s
&& !tw.AllowedSides.HasFlag(s.ToFlag())) return false;
return true;
}Filtered drop targets render with the existing disabled/dimmed adornment style — no new visual treatment needed. Hit-testing skips them.
DockLayoutJson schema additions:
DockTabGroup.role— string, one of"general" | "documentArea" | "toolWindowStrip". Omitted from JSON whenGeneral(the default).ToolWindow.allowedSides— array of strings, e.g.["left", "right"]. Omitted whenAll.
Old layouts deserialize unchanged (omitted fields → defaults). No migration entry needed in DockLayoutMigrationRegistry; this is a purely additive read-side change.
var layout = new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new DockTabGroup(
new[] { galleryItemsToolWindow },
Width: 260,
Role: DockGroupRole.ToolWindowStrip),
new DockTabGroup(
Array.Empty<DockableContent>(),
Role: DockGroupRole.DocumentArea), // implies ShowWhenEmpty
new DockTabGroup(
new[] { configurationToolWindow },
Width: 320,
Role: DockGroupRole.ToolWindowStrip),
});
// Programmatic open lands in the document area:
model.Dock(new Document { Title = "Mesh Viewer", Key = "doc:meshviewer" },
DockTarget.Center);A tool window with AllowedSides = DockSides.Bottom:
var errors = new ToolWindow {
Title = "Errors", Key = "tool:errors",
AllowedSides = DockSides.Bottom
};— users can only drag it to the bottom strip; other drop targets dim during drag.
- Default behavior unchanged.
Role = GeneralandAllowedSides = Allmean callers who don't opt in see today's routing. Document/ToolWindowsubclasses already exist (spec 045 §5.3.1). Category detection in the router usespane is Document/pane is ToolWindow; baseDockableContentis treated as unconstrained.- No JSON migration. Old layout JSON deserializes with default values for the new fields.
- No public API removal. All additions are additive.
- Selftest impact. The dock host selftest fixture should be re-evaluated to confirm no fixture depends on the leftmost-descendant routing accidentally. If a fixture does, fix it to use the explicit group-target overload.
Estimated total: 5–7 days of focused work.
- Add
DockGroupRoleenum (src/Reactor/Docking/Enums.csor co-locate withDockNode.cs). - Add
DockSides[Flags]enum. - Add
RoletoDockTabGrouprecord. - Add
AllowedSidestoToolWindowrecord. - Build clean. No behavior change yet.
- Rewrite
DockLayoutMutator.AddAsTabper §6.3. - Add
CategoryOf(DockableContent)andAcceptsCategory(Role, category)private helpers. - Add diagnostic log on the no-acceptor fallback path.
- Locate the post-close cull pass (grep for empty-group removal in
DockLayoutMutator/DockHostModel). - Skip cull when
Role == DocumentArea. - Confirm
ShowWhenEmptyrendering already handles the visual case; no renderer change should be needed.
- Add
DockHostModel.Dock(content, DockTabGroup, DockTarget). - Plumb to a new
PendingMutation.DockToGroupOp. - Reconciler dispatches to existing
MovePaneToGroupTarget/InsertPaneIntoGroup.
- Add
CanDropInto(group, payload)helper (§6.6). - Call from
DockDropTargetOverlayElementadornment rendering — filtered targets use the existing disabled style. - Call from
DockHostNativeComponentdrop hit-testing — filtered targets ignore the release. - Manual verify on the gallery sample (drag a tool window across a
DocumentAreagroup; drag a document across aToolWindowStrip; drag aBottom-only tool window across aLeftstrip).
- Add
rolefield toDockLayoutJsonwriter (omit whenGeneral). - Add
allowedSidesarray toToolWindowJSON shape (omit whenAll). - Reader populates defaults when fields absent.
- Round-trip test: layout → JSON → layout, deep equal.
- Unit: extend
DockLayoutMutatortests with role-aware routing cases (the leftmost-descendant repro from §2.1, plus the inverse with tool-window-only strips, plus the fallback path). - Unit:
AllowedSidesenforcement (PinToSide reject + drop-target filter). - Unit: cull-pass skip for
DocumentArea. - Unit: JSON round-trip with new fields populated and omitted.
- Selftest: visual fixture for the VS-style layout from §6.8 with a "close last document" sequence proving the center stays visible.
- Edit
docs/_pipeline/templates/docking.md.dt(or whatever the docking topic file is — verify path before editing). Do not hand-editdocs/guide/— seefeedback_docs_pipeline.mdmemory. - Add a "Shaping a document well" subsection with the §6.8 example.
- Add a callout for
AllowedSidesin the tool-window section. - Run
mur docs compileto regenerate the published guide. - Update spec 045 to cross-reference this spec from §5.3.1 (Document/ToolWindow split discussion) and §6.4 (algebra extensions).
A detailed task file lives at docs/specs/tasks/046-docking-content-types-implementation.md (created alongside the implementation branch; not authored as part of this spec draft).
-
Q1. Should
Role.ToolWindowStripreject documents, or just deprioritize them? Current §6.3 rejects. Rejection makes drag-drop feedback clearer ("you can't drop a doc here"), but means a layout with noGeneralorDocumentAreagroup can't accept programmatic documents at all — they'd hit the fallback path and log. Tentative answer: reject, with a fallback that emits a clear diagnostic. Revisit if the diagnostic becomes a frequent customer complaint. -
Q2. Should we add
Role.Custom+RoleTag : object?now, or wait? Adding now costs a property and a default-value branch in the router; not adding it means apps with non-VS taxonomies have to use the group-target overload (§6.4). Tentative answer: wait. The escape hatch exists; addCustomif a real scenario asks for it. -
Q3. When a layout strategy returns
truefromBeforeInsertDocument(signaling it handled placement), should we still validate against role compatibility, or trust the strategy? Tentative answer: trust. A strategy that opts in is taking responsibility for its choice; second-guessing it defeats the hook. -
Q4.
AllowedSideson tool windows — does it apply to programmaticPinToSidecalls too, or only drag-drop? Tentative answer: both, withPinToSidethrowing on an invalid combination. Strategies that need to bypass can clear the mask before calling. -
Q5. Should
DockGroupRole.DocumentAreaalso set a defaultMinWidthso the empty document well has a sensible visible footprint? Or leave sizing to the author? Tentative answer: leave to the author. Adding implicit sizing surprises authors; the explicitWidth/MinWidthprops onDockTabGroupare the right place.
- OS1. A predicate
AcceptsonDockTabGroup. May land in a future spec if v1 enums prove insufficient. - OS2. Named-region authoring (Eclipse e4-style). Larger redesign; not motivated by current scenarios.
- OS3. First-party
VisualStudioLayoutStrategyclass. The role+mask vocabulary handles the common case; a starter strategy can ship later. - OS4. Restructuring
Document/ToolWindowinto separateDockNodetypes. Spec 045 chose category-on-content; this spec extends that choice rather than reopening it. - OS5. Cross-app drag-drop policy. Spec 045 N3 already excludes cross-process docking.
- OS6. New devtools introspection for role/category. Static layout introspection (spec 045 §8.2) already exposes the full tree; the new fields will appear there automatically once serialized.