Skip to content

Inner document-dock tab headers render no icon/favicon/status — only Running+Notification templates are registered in the inner ItemsControl.DataTemplates #1324

Description

@JoshuaRowePhantom

Summary — REOPENED, new direction: centralized dock-TYPE management

Inner document-dock workspace tabs still render no header icons at all (no icon, no favicon, no status glyph, no agent-running / notification indicators — only title + close ✕). The outer workspace-pane tab row is unaffected.

The confirmed runtime cause has not changed: at runtime the inner tab strip falls through to the generic IDocumentDock fallback DataTemplate at Phantom.Workspaces/Templates/DockDataTemplates.axaml:165-167 (<DataTemplate DataType="dmc:IDocumentDock"><dock:DocumentDockControl/></DataTemplate>) which sets NO HeaderTemplate, so Dock.Avalonia's default headerless header renders. The reason the fallback is being selected is that the inner dock instance is NOT a WorkspaceContentDock at runtime — it is a base Dock.Model.Mvvm.Controls.DocumentDock re-hydrated by MainWindowViewModel.TryRestoreFromDockLayoutAsync from a pre-#1307 persisted layout.

The owner directive for the fix has changed: rather than stamping the correct HeaderTemplate on every IDocumentDock-matching template as a per-template patch, centralize template management via the workspace-specific dock TYPE. The workspace-specific document dock types already exist (WorkspaceContentDock, WorkspacesPaneDock). Make deserialization guarantee every document-dock region in the live tree IS one of those workspace-specific types, and then one authoritative HeaderTemplate on that type covers every dock region (inner panes, split docks, restored docks). The generic IDocumentDock fallback stays only as a belt-and-suspenders safety net.

Root cause (unchanged, kept for record)

The inner WorkspacePaneDocument DockControl (DockDataTemplates.axaml:112, AutoCreateDataTemplates="False", no ancestor walk-up per #1130) resolves its child-dock template against its own DockControl.DataTemplates collection. Two templates can match an IDocumentDock:

  • DockDataTemplates.axaml:123 — concrete <DataTemplate DataType="vm:WorkspaceContentDock"><dock:DocumentControl> with an inline HeaderTemplate. Repaired by 3c9063db.
  • DockDataTemplates.axaml:165-167 — generic fallback <DataTemplate DataType="dmc:IDocumentDock"><dock:DocumentDockControl />. No HeaderTemplate. Renders exactly the plain title + close-✕ shape in the screenshot.

First-match-wins by declaration order: any IDocumentDock implementation that is NOT WorkspaceContentDock — including base Dock.Model.Mvvm.Controls.DocumentDock — falls through to line 165.

Runtime path that produces the base DocumentDock: MainWindowViewModel.TryRestoreFromDockLayoutAsync (MainWindowViewModel.cs:3353-3419) deserializes WorkspacePane.ContentLayout using DockSerializer + WorkspaceDockTypeInfoResolver. That resolver (WorkspaceDockTypeInfoResolver.cs:40-56) configures IDock polymorphism with UnknownDerivedTypeHandling.FallBackToBaseType and IgnoreUnrecognizedTypeDiscriminators = true. It honors whatever concrete $type the persisted JSON records. Layouts created and persisted before #1307 landed contain $type = "Dock.Model.Mvvm.Controls.DocumentDock" nodes (splits went through the un-overridden Factory.CreateDocumentDock() which returned base DocumentDock). Nothing migrates them on restore. MainWindowViewModel.cs:3411 (FindDocumentDock(layout) as WorkspaceContentDock) already implicitly acknowledges this — the as silently returns null when the primary dock is a base DocumentDock, and sibling split docks keep whatever concrete type they were persisted with. From that point the affected tab strips render through the generic fallback at line 165.

Why 3c9063db did not fix it

3c9063db (previous attempt) correctly centralized the per-item Icon/Favicon/Status/Running/Notification templates in TabHeaderItemTemplates.axaml and re-wired the concrete WorkspaceContentDock header path. Every one of its assertions is still true. It does not fix this bug because it only touched the WorkspaceContentDock template branch — nothing forces the runtime dock instance to be a WorkspaceContentDock after a persisted-layout restore.

Why the +291 tests passed

The MainWindowDockTemplateTests.cs suite exercises only the WorkspaceContentDock branch:

  • TabHeaderItemTemplates_HasKeyedResource_ForEveryTabHeaderItemViewModelSubtype (:835) — reflection over Application.Current.Resources, no dock scope.
  • TabHeaderTemplate_ItemsControlDataTemplates_ResolvesEveryTabHeaderItemViewModelSubtype (:858) and BuildTabHeaderItemsControl (:818) — resolve TabHeaderTemplate in isolation and call Build(...). Never instantiate the nested WorkspacePaneDocument DockControl and never prove the ItemsControl materializes a child visual container per header-item VM.
  • Inner-render tests at :916/948/979/1010 construct a fresh MainWindowViewModel and open a tab via viewModel.OpenTabAsync(...), which routes through WorkspaceDockFactory.CreateWorkspaceContentLayout (WorkspaceDockFactory.cs:134-160) whose primary content dock is new WorkspaceContentDock(...). Line 123 always matches; line 165 is never touched.
  • The split-dock regression at :1041-1122 explicitly calls factory.CreateDocumentDock() (line 1070) which returns WorkspaceContentDock — again line 123 matches.

Test gap: no test deserializes a persisted layout whose $type is base Dock.Model.Mvvm.Controls.DocumentDock and asserts (a) the restored live dock is materialized as WorkspaceContentDock, or (b) the tab strip anchored to a restored/base dock renders per-item glyphs.

Affected files

File Role in fix
Phantom.Workspaces/ViewModels/WorkspaceDockTypeInfoResolver.cs Primary site. Add deserialization-time type substitution so any persisted $type = "Dock.Model.Mvvm.Controls.DocumentDock" (or any other base IDocumentDock discriminator that is not the workspace-specific subclass) is materialized as WorkspaceContentDock. Same for the outer workspace-pane region → WorkspacesPaneDock.
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs TryRestoreFromDockLayoutAsync (:3353-3419, in particular the FindDocumentDock(layout) as WorkspaceContentDock at :3411). With type substitution in place, this cast becomes guaranteed. Alternative: a post-load migration walk here — see Design §Alternative.
Phantom.Workspaces/ViewModels/WorkspaceContentDock.cs, WorkspacesPaneDock.cs Existing custom dock types (WorkspaceContentDock : DocumentDock at WorkspaceContentDock.cs:14; WorkspacesPaneDock : DocumentDock at WorkspacesPaneDock.cs:14). No structural change; they are the authoritative targets of the type-substitution and of the single centralized header template.
Phantom.Workspaces/ViewModels/WorkspaceDockFactory.cs Already guarantees the workspace type for every non-restore creation path: CreateLayout (:80-108) creates the outer WorkspacesPaneDock at :82; CreateWorkspaceContentLayout (:134-160) creates WorkspaceContentDock at :136 for the inner region; CreateDocumentDock (:120-126, added by #1307) returns WorkspaceContentDock for splits. Restore is the only remaining hole.
Phantom.Workspaces/Templates/DockDataTemplates.axaml With the dock-type guarantee, the existing vm:WorkspaceContentDock template at :123 and the outer-scope vm:WorkspacesPaneDock template are the single centralized header sites. Give the generic IDocumentDock fallback (:102-104 outer, :165-167 inner) the same centralized HeaderTemplate as a safety net so no dock region can ever render headerless even if an unforeseen base type slips in.
Phantom.Workspaces/Templates/TabHeaderItemTemplates.axaml Home of the centralized keyed per-item templates from 3c9063db — kept as-is (the per-item Icon/Favicon/Status/Running/Notification templates are correctly centralized here). Optionally lift the shared HeaderTemplate body (ContentControl binding EffectiveTabHeader + type-selecting WebTabHeaderTemplate / TabHeaderTemplate) into one keyed WorkspaceDocumentHeaderTemplate here so both the WorkspaceContentDock template and the safety-net fallback reference the same keyed resource.
Phantom.Workspaces.Tests/MainWindowDockTemplateTests.cs New tests locking (a) restore-time type substitution, (b) the restored-base-DocumentDock render path, (c) the safety-net fallback. See Expected Tests.

Design / Fix — centralized dock-TYPE management

Guiding principle (owner directive, new). Do not distribute the correct HeaderTemplate across every IDocumentDock-matching template. Instead: guarantee that every document-dock region in the live layout tree is the workspace-specific dock type (WorkspaceContentDock for content regions, WorkspacesPaneDock for the pane row). Once that invariant holds, a single centralized HeaderTemplate on the workspace dock type covers every region — inner panes, split docks, restored docks — and the generic IDocumentDock fallback becomes a pure safety net.

1. Primary: deserialization-time type substitution (preferred)

Make WorkspaceDockTypeInfoResolver (WorkspaceDockTypeInfoResolver.cs:141-154, CreateOptions) map the base Dock.Model.Mvvm.Controls.DocumentDock $type discriminator to WorkspaceContentDock (and any base pane-dock discriminator, if present in persisted JSON, to WorkspacesPaneDock) at the polymorphism layer, so the deserializer instantiates the workspace-specific subclass directly. Because WorkspaceContentDock : DocumentDock (no non-nullable required fields, same public surface plus shadowed Owner / StyleKey / ItemsSource / ItemContainerGenerator), a base-DocumentDock JSON payload deserializes into WorkspaceContentDock without loss.

Concrete sketch (inside CreateOptions for typeof(IDock), i.e. s_dockOptions):

// Register the workspace subclass under BOTH its own FullName AND the base
// Dock.Model.Mvvm.Controls.DocumentDock FullName so persisted pre-#1307 layouts
// materialize as WorkspaceContentDock.
options.DerivedTypes.Add(new JsonDerivedType(
    typeof(WorkspaceContentDock),
    typeof(WorkspaceContentDock).FullName!));
options.DerivedTypes.Add(new JsonDerivedType(
    typeof(WorkspaceContentDock),
    typeof(Dock.Model.Mvvm.Controls.DocumentDock).FullName!));
// Same for WorkspacesPaneDock if the outer pane region is ever persisted as base DocumentDock.

Notes:

  • The current GetDerivedTypes(...) reflection scan (WorkspaceDockTypeInfoResolver.cs:178-199) auto-registers the base Dock.Model.Mvvm.Controls.DocumentDock under its own FullName. The substitution above must REPLACE that registration (skip the base type in GetDerivedTypes when a workspace-specific subclass is registered under the same discriminator), so JsonPolymorphismOptions.DerivedTypes does not contain two entries with the same discriminator string.
  • Same treatment for IDockable polymorphism (s_dockableOptions at :40-41) — the discriminator is registered at the interface level, so the swap must happen there too.
  • With IgnoreUnrecognizedTypeDiscriminators = true and FallBackToBaseType still on, unknown discriminators fall back to the interface base — but that path is no longer reached for the specific base-DocumentDock case that produces this bug.

Why this mechanism is preferred (owner directive): nothing downstream ever sees a base DocumentDock. MainWindowViewModel.cs:3411's FindDocumentDock(layout) as WorkspaceContentDock becomes a guaranteed non-null cast; the template selection at DockDataTemplates.axaml:123 always wins; the fallback at :165-167 is unreachable via restore. Behaviour is centralized in exactly one class (WorkspaceDockTypeInfoResolver) at exactly one lifecycle point (deserialization).

1. Alternative: post-load migration walk

If for any reason type substitution cannot be done at the polymorphism layer (e.g., a Dock.Serializer.SystemTextJson internal path re-materializes base DocumentDock regardless), do a migration walk in TryRestoreFromDockLayoutAsync immediately after serializer.Deserialize<IRootDock>(dockLayoutJson) at MainWindowViewModel.cs:3364 and before workspacePane.ContentLayout = layout; at :3403:

// After deserialization: replace any base DocumentDock in the tree with WorkspaceContentDock,
// copying Id / Title / VisibleDockables / ActiveDockable / CanCreateDocument / IsCollapsable /
// Proportion / etc. Re-parent VisibleDockables onto the new instance.
MigrateBaseDocumentDocksToWorkspaceContentDock(layout);

The walk uses the same enumeration as EnumerateAllDocuments (see :3374), and the parent-relinking is straightforward: for each IDock parent, iterate VisibleDockables and substitute in place. This makes the invariant enforced at exactly one anchor (:3364-3403) but requires a bit more code than the resolver change.

The resolver change (§1) is preferred; the migration walk (§Alternative) is the fallback if the resolver approach cannot fully cover the internal Dock serializer paths. In either case, once the invariant holds, MainWindowViewModel.cs:3411's as WorkspaceContentDock is guaranteed non-null and can be simplified accordingly.

2. Confirm the factory already guarantees the type on the non-restore paths

  • Outer pane row: WorkspaceDockFactory.CreateLayout() (WorkspaceDockFactory.cs:80-108) constructs the outer document dock as new WorkspacesPaneDock { ... } at :82.
  • Inner content region: WorkspaceDockFactory.CreateWorkspaceContentLayout(...) (:134-160) constructs the primary content dock as new WorkspaceContentDock { ... } at :136.
  • Splits (Web tabs in newly-created horizontal/vertical docks use wrong tab header template (no web icon, unconstrained width) #1307): WorkspaceDockFactory.CreateDocumentDock() (:120-126) returns new WorkspaceContentDock { Id = Guid.NewGuid().ToString() } for Dock's NewHorizontalDocumentDock / NewVerticalDocumentDock split paths (commit 2d330f54).

So the ONLY way a base DocumentDock enters the live tree is deserialization of pre-#1307 persisted layouts. §1 closes that hole.

3. Centralized header template on the workspace dock type

With the type invariant in place, the existing DockDataTemplates.axaml:123 <DataTemplate DataType="vm:WorkspaceContentDock"> template is authoritative for every content dock region — inner pane, split, restored — because there is no other kind of content dock in the tree. Same for the outer vm:WorkspacesPaneDock template. One centralized header template per workspace dock type; no per-region divergence.

Recommended cleanup: move the inline HeaderTemplate body currently at DockDataTemplates.axaml:125-145 into a keyed WorkspaceDocumentHeaderTemplate in TabHeaderItemTemplates.axaml, and reference it via HeaderTemplate="{StaticResource WorkspaceDocumentHeaderTemplate}" from the WorkspaceContentDock template (and from the safety-net fallback, see §4). This keeps the header body in the same dictionary as the per-item keyed templates that 3c9063db correctly centralized there.

4. Safety net — give the generic IDocumentDock fallback the same centralized HeaderTemplate

Belt-and-suspenders: even with §1 enforcing the type invariant, patch the generic fallback DataTemplates at DockDataTemplates.axaml:102-104 (outer) and :165-167 (inner) to use the same centralized HeaderTemplate reference:

<!-- inner DockControl.DataTemplates safety net -->
<DataTemplate DataType="dmc:IDocumentDock">
    <dock:DocumentControl HeaderTemplate="{StaticResource WorkspaceDocumentHeaderTemplate}" />
</DataTemplate>

This guarantees that if some future path (e.g. a Dock.Avalonia internal float / dock-manager operation, or a new deserialization case) ever produces a base DocumentDock, it still renders the correct header. This is a safety net, NOT the primary fix.

What survives from 3c9063db

Keep the keyed per-item templates in TabHeaderItemTemplates.axaml (AgentRunningIndicatorTabHeaderItemTemplate, NotificationIndicatorTabHeaderItemTemplate, IconTabHeaderItemTemplate, FaviconTabHeaderItemTemplate, StatusControlTabHeaderItemTemplate) and the shared TabHeaderTemplate / WebTabHeaderTemplate bodies. These are correct centralizations. The change here is orthogonal: it centralizes the outer template selection at the dock-TYPE layer.

Considered / Background (superseded)

  • Per-template HeaderTemplate stamping. Adding the correct HeaderTemplate to every IDocumentDock-matching DataTemplate (concrete WorkspaceContentDock/WorkspacesPaneDock plus outer and inner generic fallbacks) — surfaced as one candidate design. Superseded because it distributes the fix across every template site and does not address the deeper invariant (there should not be a base DocumentDock in a workspace layout tree at all). Retained only as the §4 safety net.
  • Keyed-only per-item template centralization (3c9063db). Correctly centralized the per-item templates and the two header bodies; correctly repaired the WorkspaceContentDock code path. Insufficient on its own because it never forces the runtime dock instance to BE a WorkspaceContentDock after restore. Kept as a precondition of the current fix.
  • The prior diagnosis ("per-item template provisioning is split across three files and diverges between the outer and inner DockControl scopes") was accurate but incomplete: it never asked what happens when the inner dock is not a WorkspaceContentDock. Recorded here for context; do not re-apply on its own.

Expected tests (Subject_Scenario_ExpectedOutcome)

CRITICAL new tests (these reproduce the runtime failure the previous suite missed):

Test Scenario Expected outcome
MainWindowViewModel_RestorePersistedLayoutWithBaseDocumentDock_MaterializesWorkspaceContentDock Feed TryRestoreFromDockLayoutAsync a WorkspacePane.ContentLayout JSON whose sibling document dock is "$type": "Dock.Model.Mvvm.Controls.DocumentDock"; run restore; walk the resulting workspacePane.ContentLayout tree Every IDocumentDock in the tree Is<WorkspaceContentDock>(); Id, Title, VisibleDockables count, ActiveDockable identity are preserved
WorkspaceDockTypeInfoResolver_BaseDocumentDockDiscriminator_MapsToWorkspaceContentDock Directly deserialize a minimal JSON with "$type": "Dock.Model.Mvvm.Controls.DocumentDock" under an IDock slot using new WorkspaceDockTypeInfoResolver() Result is Assert.IsType<WorkspaceContentDock>(...) (locks the polymorphism-layer substitution)
DockTemplates_RestoredBaseDocumentDockTab_RendersHeaderIconsForEachItemType Build a real MainWindow; restore a WorkspacePane.ContentLayout whose persisted JSON encodes a base DocumentDock hosting a WorkspaceDocument whose EffectiveTabHeader.Items carries all five per-item VMs (AgentRunning, Notification, Icon (🚀), Favicon (🌐 via WebTabHeaderViewModel), Status); anchor by reference to the restored dock instance and locate the tab-strip descendants Non-null visual/container materializes for EACH per-item VM type (pulsating-brain ProgressBar, exclamation-indicator ProgressBar, "🚀" TextBlock, "🌐" TextBlock, StatusControl). Explicit ItemContainerGenerator container-count vs Items-count check on the header ItemsControl.
DockTemplates_GenericDocumentDockFallback_HasHeaderTemplate Reflect the IDataTemplate in DockDataTemplates whose .Match(new Dock.Model.Mvvm.Controls.DocumentDock()) succeeds via the generic IDocumentDock fallback (both outer and inner DockControl.DataTemplates scopes) The template's built control is a DocumentControl whose HeaderTemplate is the same reference as the centralized keyed WorkspaceDocumentHeaderTemplate (or, if the keyed extraction is not adopted, has a non-null HeaderTemplate whose body binds EffectiveTabHeader)
MainWindowViewModel_FindDocumentDockAfterRestore_ReturnsNonNullWorkspaceContentDock After the base-DocumentDock restore scenario, call FindDocumentDock(workspacePane.ContentLayout) and cast to WorkspaceContentDock Non-null (locks the MainWindowViewModel.cs:3411 invariant that the type-substitution is intended to guarantee)

Preserve existing tests (TabHeaderItemTemplates_HasKeyedResource_ForEveryTabHeaderItemViewModelSubtype, TabHeaderTemplate_ItemsControlDataTemplates_ResolvesEveryTabHeaderItemViewModelSubtype, WebTabHeaderTemplate_…, TabHeaderTemplateAndWebTabHeaderTemplate_ShareTheSameKeyedItemTemplates, WorkspaceDataTemplates_DoNotDeclareImplicitTabHeaderItemTemplates, the four inner-WorkspaceContentDock render tests at MainWindowDockTemplateTests.cs:916/948/979/1010, and MainWindowSplitDocumentTabStrip_AfterHorizontalSplit_RendersAllFiveHeaderItemTypes). They correctly lock the WorkspaceContentDock branch centralized by 3c9063db. They under-tested the restore/base-type path, which the new tests above fill.

Relationship to prior work

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiednext-upverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions