mcpbeat

Reactor Docking

microsoft/reactor-docking

Reactor docking windows — `DockManager`, `DockSplit`/`DockTabGroup`/`Document`/`ToolWindow`, drag-to-float/redock, roles (`DocumentArea`/`ToolWindowStrip`), persistence. Use when building IDE-/Office-shaped layouts with dockable, tear-out tool windows and document wells. READ THIS BEFORE wiring `DockManager` — the content-vs-shape ownership model is the #1 thing people get wrong.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
604
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/microsoft/microsoft-ui-reactor --skill reactor-docking

The instruction itself

9 sections, as written by the author

Docking in Reactor

DockManager hosts a tree of dockable panes that the user can drag, split,

tear out into floating windows, re-dock, pin to a side, and persist. It is the

foundation for IDE-/Office-shaped apps (solution explorer + editor well +

tool panes).

configure: host => DockingNativeInterop.Register(host.Reconciler)  // required once at startup

The ownership model — READ THIS FIRST

This is the single most important rule, and the easiest to get wrong:

> The app owns CONTENT. The host owns SHAPE.

  • Content = which panes exist, their Title/Content/Key, which document

is active. The app declares this via manager.Layout, fresh every render,

derived from its own UseState.

  • Shape = the user's drag-modified arrangement: split orientations/ratios,

which group a tab lives in, floating windows. The **host owns this

internally** (spec 045 §2.30) and resolves the effective layout each render

by matching your content to its shape *by Key*.

❌ The anti-pattern that breaks everything

Do NOT round-trip the host's live layout back into your own state:

// ❌ WRONG — feeds the host's shape back into the content prop.
var (layout, setLayout) = UseState<DockNode?>(BuildLayout());
new DockManager {
    Layout = layout,
    OnLiveLayoutChanged = next => setLayout(next),   // ⛔ double-owns the shape
};

This double-owns the shape. Symptoms (all of these at once):

  • Drag-out-to-float works, but you can't drag back to re-dock.
  • Clicking tabs doesn't switch / selection resets.
  • Splitter drags snap back.

OnLiveLayoutChanged is for observation only (e.g. a layout inspector). It

is never a setter for manager.Layout.

✅ The correct pattern

The app holds *which documents are open* (content), not the live tree. Opening

or closing a pane changes the set of Keys in manager.Layout; the host

detects the key-set change and merges it into its shape. Reset is a

.WithKey(...) remount.

var (openDocs, setOpenDocs) = UseState(ImmutableList.Create(File.AppCs));
var (activeKey, setActiveKey) = UseState<object?>(File.AppCs.Key);
var (epoch, bumpEpoch) = UseReducer(0);

void Open(ProjectFile f) {
    if (!openDocs.Any(d => Equals(d.Key, f.Key))) setOpenDocs(openDocs.Add(f));
    setActiveKey(f.Key);                       // focus it (drives SelectedIndex)
}

var editorWell = new DockTabGroup(
    openDocs.Select(MakeDoc).ToArray(),
    SelectedIndex: openDocs.FindIndex(d => Equals(d.Key, activeKey)),
    ShowWhenEmpty: true,
    Role: DockGroupRole.DocumentArea);

new DockManager {
    Layout = BuildLayout(editorWell),
    // Sync state when the host closes a tab via its X button. The host already
    // removed it from its shape; drop it from openDocs so the key sets converge.
    OnDocumentClosing = args =>
        setOpenDocs(openDocs.RemoveAll(d => Equals(d.Key, args.Document.Key))),
}.WithKey($"dock-{epoch}");   // View ▸ Reset Layout: bumpEpoch + reset openDocs

Rules of thumb:

  • Derive manager.Layout from app state every render. Never store the host's tree.
  • Add/remove a pane ⇒ the Key set changes ⇒ the host picks it up.
  • Model-mutator additions (drag, DockHostModel.Dock) keep the same app key set,

so the host preserves them. That's why round-tripping is both unnecessary and harmful.

  • Reset / discard drag state ⇒ .WithKey($"dock-{epoch}") bump remounts the host.
  • Layout persistence across launches ⇒ PersistenceId, not OnLiveLayoutChanged.

> Controlled prop note: SelectedIndex-style element properties are

> Optional<int> under the hood. Factory parameters such as SelectedIndex:

> still accept int, but if you read an element record directly use

> .Value or .GetValueOrDefault(-1). See

> migration/050-optional-t.md.

Building a layout

new DockSplit(Orientation.Vertical, new DockNode[] {
    new DockSplit(Orientation.Horizontal, new DockNode[] {
        new DockTabGroup(new DockableContent[]{ solutionTool }, Width: 260,
            Role: DockGroupRole.ToolWindowStrip),
        editorWell,                                              // DocumentArea
        new DockTabGroup(new DockableContent[]{ propsTool, gitTool }, Width: 300,
            TabPosition: TabPosition.Bottom, CompactTabs: true,
            Role: DockGroupRole.ToolWindowStrip),
    }),
    new DockTabGroup(new DockableContent[]{ output, terminal, errors },
        Height: 220, TabPosition: TabPosition.Bottom, CompactTabs: true,
        Role: DockGroupRole.ToolWindowStrip),
});
  • Document (closable, not pinnable) vs ToolWindow (hideable, side-pinnable,

AllowedSides mask). Both reconcile through DockableContent.

  • Roles (spec 046): DocumentArea is the preferred Dock(Center) target and

survives empty (the well stays a visible drop target). ToolWindowStrip is

the preferred target for tool-window drops. Use object-initializer syntax for

Document/ToolWindow so you opt into permission flags additively.

  • Every pane needs a stable, equatable Key — it's how the host preserves

pane state across reorders, tear-out, and re-dock. No fallback to title-keying.

Pane content gotchas

Make pane bodies fill the pane

A docked pane body is content-sized by default — a plain Border/TextBox

collapses to its desired height at the top of the pane. To fill, put it in a

flex container with grow:

FlexColumn(
    editorTextBox.Flex(grow: 1, basis: 0)
).Flex(grow: 1);

Multi-line TextBox: AcceptsReturn must be an element prop, not .Set

The TextBox descriptor applies AcceptsReturn/TextWrapping before Text

on purpose — single-line mode truncates Text at the first newline. A

.Set(tb => tb.AcceptsReturn = true) lambda runs *after* Text is assigned, so

the body collapses to one line. Use the first-class modifiers:

// ✅ ordered before Text by the descriptor
TextBox(text, setText).AcceptsReturn().TextWrapping(TextWrapping.NoWrap)

// ❌ runs after Text → multi-line content truncated to the first line
TextBox(text, setText).Set(tb => tb.AcceptsReturn = true)

Generally: any prop whose *ordering relative to another prop matters* belongs on

the element (.AcceptsReturn(), .TextWrapping()), not in a .Set(...) escape

hatch — .Set always runs last.

Key APIs

| API | Purpose |

|-----|---------|

| DockManager { Layout, PersistenceId, ... } | The host element |

| DockSplit(Orientation, children) | Resizable split (ratios owned by host) |

| DockTabGroup(docs, TabPosition, CompactTabs, SelectedIndex, Width/Height, Role) | A tab group |

| Document { Title, Key, Content, CanClose } | Closable document pane |

| ToolWindow { Title, Key, Content, CanPin, AllowedSides, ... } | Hideable/pinnable tool pane |

| .WithKey($"dock-{epoch}") | Remount to reset/discard drag shape |

| OnDocumentClosing | Sync app state when the host closes a tab |

| OnLiveLayoutChanged | Observe the resolved layout (never feed back to Layout) |

| DockingNativeInterop.Register(host.Reconciler) | One-time startup registration |

See samples/apps/reactor-ide for a complete IDE-shaped app exercising all of this.

How to use it

Copy the folder

Take microsoft/reactor-docking from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.