Skip to content

entity-card-tree-view: entity cards expand far beyond the viewport horizontally; must be capped to viewport width (follow-up to #1045) #1049

Description

@JoshuaRowePhantom

Related to #1029, #1045.

Summary

Entity cards in the shared entity-card-tree-view style expand far beyond the viewport horizontally, pushing markdown/note content off the right edge with no working horizontal scrollbar and no width cap.

Evidence (attached picture): an entity card titled "Agent Definitions" (type note) whose markdown content — heading "# Agent Definitions" and body "Agent definitions are concrete agent configurations with their tools already reso…" — runs off the right edge of the pane. The card is far wider than the visible viewport and the text is clipped.

This refines #1045 items 5 and 6 (single outer ScrollViewer + word-wrapping). The goal is to make the card fit the viewport and wrap when the viewport is wide enough, yet still expose a single horizontal scrollbar when the viewport is narrower than an agreed minimum width — i.e. keep the useful "scroll when genuinely too narrow" behaviour that #1045 item 5 asked for, without the infinite-width overflow this bug describes.

Root Cause (confirmed — tightened)

All line numbers are current features/ HEAD.

The shared style enables horizontal auto-scroll on the tree:

Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml:264-269

<Style Selector="TreeView.entity-card-tree-view">
    <Setter Property="Background" Value="{DynamicResource Theme.Surface.EntityPane.Background}" />
    <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto" />
    <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
    <Setter Property="ScrollViewer.AllowAutoHide" Value="False" />
</Style>

In Avalonia, a ScrollViewer whose HorizontalScrollBarVisibility is Auto (or Visible) makes its ScrollContentPresenter set CanHorizontallyScroll = true, which measures its content with double.PositiveInfinity available width so the horizontal extent can be discovered. Every descendant is therefore measured unconstrained horizontally. This makes TextWrapping="Wrap" on the note/markdown body inert — the text reports its full single-line desired width — and makes HorizontalAlignment="Stretch" inert too (Stretch only fills available width, and the available width here is infinite). That oversized desired width propagates up through the entity-card-shell Border (SharedStyles.axaml:210-217) → entity-card-tree-item StackPanelTreeViewItem (SharedStyles.axaml:236-263), so the whole card grows to the intrinsic width of its longest line and overflows the viewport.

The note/markdown body is rendered by SafeSelectableTextBlock inside a fixed-label field-row grid (Phantom.Workspaces/Templates/WorkspaceDataTemplates.axaml, ColumnDefinitions="200,*" at :231, :250, :262, :274, :282, :304, :316, :328, :337); even with TextWrapping="Wrap" set, it cannot wrap under the infinite measure, and the fixed 200px label column adds a hard floor on top.

In short: the diagnosis is the infinite-width measurement caused by HorizontalScrollBarVisibility="Auto". That is what makes wrapping and stretch inert.

Design / Fix (chosen)

Adopt the common web layout pattern — content stretches to fill when the viewport is larger than a minimum, and a horizontal scrollbar appears when the viewport is narrower than the minimum — but adapted for wrapping text.

The naive web recipe is: wrap content in a ScrollViewer HorizontalScrollBarVisibility="Auto", put the content in a container with a MinWidth (e.g. <Grid MinWidth="800">), children HorizontalAlignment="Stretch". That recipe alone does NOT work for wrapping text in Avalonia (see "Why MinWidth alone is not enough" below): under Auto horizontal scroll the content is measured at infinite width, so MinWidth sets the floor but nothing sets a ceiling, and wrapping text measures its full single-line width and overflows arbitrarily — exactly this bug.

The adaptation: give the single content wrapper both a MinWidth and a MaxWidth bound to the ScrollViewer's own viewport width.

The rule

Keep ScrollViewer.HorizontalScrollBarVisibility="Auto" (and vertical Auto, AllowAutoHide="False"). Introduce one items-region wrapper — a single container placed around the whole items area inside the tree's own ScrollViewer — with:

  • HorizontalAlignment="Stretch",
  • MinWidth="160" — the summed agreed minimum widths (derivation below); this is the floor at which the horizontal scrollbar appears, and
  • MaxWidth="{Binding Viewport.Width, ElementName=...}" bound to the tree's ScrollViewer viewport width (optionally minus a small vertical-scrollbar gutter); this is the ceiling that caps wrapping content to the viewport.

The two regimes

Regime 1 — viewport ≥ MinWidth (normal / wide): MaxWidth = Viewport.Width caps the wrapper at the visible width. Because the measure is now finite, Stretch fills the viewport and TextWrapping="Wrap" actually wraps. Extent == viewport ⇒ no horizontal scrollbar. Content fills and wraps.

Regime 2 — viewport < MinWidth (narrow): now MinWidth (160) > MaxWidth (=Viewport.Width). In Avalonia layout clamping (the MinMax used by Layoutable.MeasureCore, equivalent to clamp(desired, min, max) where min wins when min > max), the wrapper resolves to MinWidth. So the wrapper is 160 wide, wider than the viewport ⇒ extent > viewport ⇒ a single horizontal scrollbar appears, and content wraps at 160 (not at infinity). This is precisely the "scroll only when genuinely too narrow" behaviour #1045 item 5 wanted.

Why ONE wrapper, not per-item

The wrapper + MinWidth + viewport-MaxWidth is applied once around the whole items region (a single container inside the tree's ScrollViewer), not per TreeViewItem. This is what resolves the earlier objection that a per-item viewport-width binding fights indentation:

  • With a single wrapper constrained to [MinWidth, Viewport.Width], indentation is handled normally inside it. Each nested TreeViewItem still measures at available-width − indent (the 20,* grid, SharedStyles.axaml:242) within the wrapper.
  • Only the one wrapper's MinWidth drives the horizontal scrollbar. Nested items never each try to claim the full viewport width, so indent + itemWidth never independently overflows.

Where to apply it (concrete)

The tree's ScrollViewer lives inside the default TreeView template (ScrollViewer named PART_ScrollViewer, containing the ItemsPresenter). Reach the items host via the shared style and constrain it. Two equivalent placements:

(a) Constrain the TreeView's items panel (recommended — smallest surface). Override the ItemsPanel on the entity-card-tree-view style so the root items host is the single wrapper. $parent[ScrollViewer] from inside the panel resolves to PART_ScrollViewer (the tree's own ScrollViewer):

<Style Selector="TreeView.entity-card-tree-view">
    <Setter Property="Background" Value="{DynamicResource Theme.Surface.EntityPane.Background}" />
    <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto" />
    <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
    <Setter Property="ScrollViewer.AllowAutoHide" Value="False" />
    <Setter Property="ItemsPanel">
        <ItemsPanelTemplate>
            <StackPanel Orientation="Vertical"
                        HorizontalAlignment="Stretch"
                        MinWidth="160"
                        MaxWidth="{Binding $parent[ScrollViewer].Viewport.Width}" />
        </ItemsPanelTemplate>
    </Setter>
</Style>

(b) Or wrap/constrain the ItemsPresenter in the TreeView template (if the ItemsPanel override is insufficient, retemplate the TreeView and put the MinWidth + viewport-MaxWidth on a Panel/Border immediately around PART_ItemsPresenter, still inside PART_ScrollViewer):

<ScrollViewer x:Name="PART_ScrollViewer" ...>
    <Panel HorizontalAlignment="Stretch"
           MinWidth="160"
           MaxWidth="{Binding #PART_ScrollViewer.Viewport.Width}">
        <ItemsPresenter x:Name="PART_ItemsPresenter" ... />
    </Panel>
</ScrollViewer>

ScrollViewer.Viewport is a Size; bind MaxWidth to Viewport.Width. The viewport size is driven by the ScrollViewer's own arranged bounds (outer layout), not by the content's desired width, so binding MaxWidth → Viewport.Width does not create a feedback loop in either regime. When a vertical scrollbar is present (VerticalScrollBarVisibility="Auto", AllowAutoHide="False"), Viewport.Width already excludes the vertical-scrollbar column, so content will not sit under it; if any overlap is observed, subtract a small gutter (e.g. Viewport.Width − 12) to honour #1045's "scrollbars must not overlap content".

Recommended MinWidth value and derivation

MinWidth="160". This equals the intrinsic minimum of the card's own min-width columns and the agreed item minimum from #1045:

If the card's minimum columns change, keep this wrapper MinWidth equal to the summed intrinsic minimum of those columns (label-min + value-min + card padding), or to the item MinWidth, whichever is authoritative.

Why MinWidth alone is not enough (the critical nuance)

The web example's content does not wrap (a Button and a plain TextBlock — bounded content whose desired width is finite). For our wrapping content (TextWrapping="Wrap" note/markdown bodies and header summaries), under HorizontalScrollBarVisibility="Auto" the ScrollContentPresenter sets CanHorizontallyScroll = true and measures the child at infinite available width. Wrapping text then reports its full single-line desired width and never wraps; MinWidth only raises the floor, it never caps the maximum, so the card still overflows arbitrarily (this bug's exact symptom). That is why the wrapper needs the viewport-bound MaxWidth in addition to MinWidth: the MaxWidth supplies the finite ceiling that both caps the card to the viewport and lets the text actually wrap. Answer: yes — plain-MinWidth-only fails for wrapping text; the viewport-MaxWidth is mandatory.

Secondary — soften the fixed 200px markdown label column

In the note/markdown field-row grids (WorkspaceDataTemplates.axaml, ColumnDefinitions="200,*" at :231, :250, :262, :274, :282, :304, :316, :328, :337) the 200px label column hard-codes a floor. At narrow viewports this floor plus the value column can still exceed MinWidth. Change it to Auto,* (or a MinWidth-bounded proportional column consistent with the 67px minimums from #1045) so the label never imposes a floor wider than the agreed minimum.

Secondary — un-wrappable content scrolls/wraps locally

Genuinely un-wrappable inner content (a single very long unbroken token, a URL, or a code block) must wrap or scroll within the card rather than exporting an oversized desired width. The workspace-markdown-viewer control already disables its own nested scrollers (SharedStyles.axaml:755-758), so under a finite measure it wraps. Such remaining content should get its own local wrap or a local scroller so it clips/scrolls inside the card; it must not reintroduce an infinite-width horizontal measure at the card level.

Considered / Background

(a) HorizontalScrollBarVisibility="Disabled" + wrap (simpler alternative — gives up the H-scrollbar)

Set the tree's horizontal scroll to Disabled; the ScrollContentPresenter then measures at the finite viewport width, and the existing Stretch + TextWrapping="Wrap" wrap for free, with indentation accounted for automatically by the 20,* grid. This is simpler but removes the horizontal scrollbar entirely — at very narrow viewports the card compresses/clips rather than offering a scrollbar. The sibling entity-card-tree consumers already use exactly this:

  • Phantom.Workspaces.Agent.Gui/Controls/AgentChatToolsDetailControl.axaml:16-21TreeView Classes="entity-card-tree entity-card-tree-sticky" with inline ScrollViewer.HorizontalScrollBarVisibility="Disabled" + VerticalScrollBarVisibility="Auto".
  • Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml:7-15 — an outer ScrollViewer HorizontalScrollBarVisibility="Disabled" / VerticalScrollBarVisibility="Auto" wrapping a TreeView Classes="entity-card-tree entity-card-tree-entity".

The chosen approach differs from this by keeping Auto + a single wrapper with MinWidth, so it retains the scrollbar-when-narrow behaviour the maintainer wants and satisfies #1045 item 5 properly, while still wrapping when the viewport is wide.

(b) Per-item MaxWidthViewport.Width binding (REJECTED)

An earlier proposal bound each TreeViewItem's MaxWidth to the ScrollViewer's Viewport.Width while keeping Auto horizontal scroll. Rejected: it fights indentation. Every TreeViewItem is indented by its nesting level (the 20px indent column per level, SharedStyles.axaml:242). Forcing an item's width to the full viewport means a nested item occupies indent + full-viewport-width, which still overflows. Correcting it would require subtracting the cumulative indent per level — exactly the bookkeeping the single-wrapper approach avoids, because only the one wrapper is bound to the viewport and indentation is handled normally by the grid inside it.

Affected Files

File Change
Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml (chosen fix) On TreeView.entity-card-tree-view (:264-269) keep HorizontalScrollBarVisibility="Auto" / VerticalScrollBarVisibility="Auto" / AllowAutoHide="False", and add a single items-region wrapper — either via an ItemsPanel override (recommended) or a retemplated PART_ItemsPresenter wrapper — carrying HorizontalAlignment="Stretch", MinWidth="160", and MaxWidth="{Binding $parent[ScrollViewer].Viewport.Width}" (optionally minus a scrollbar gutter).
Phantom.Workspaces/Templates/WorkspaceDataTemplates.axaml (secondary) Soften the fixed 200px label column in the note/markdown field-row grids (ColumnDefinitions="200,*" at :231, :250, :262, :274, :282, :304, :316, :328, :337) to Auto,* / min-bounded proportional.
Phantom.Workspaces.Gui.Shared/Controls/SafeSelectableTextBlock.cs No change required; under the now-finite (viewport-capped) measure its existing TextWrapping="Wrap" wraps.
Phantom.Workspaces.Agent.Gui/Controls/AgentChatEditorControl.axaml Consumer NavigationTree (:251-259); no change — the fix is inherited from the shared style; it already delegates ScrollViewer config per #1045.

Precedent to mirror for the simpler Disabled alternative (no change): AgentChatToolsDetailControl.axaml:16-21 and EntityBrowserWorkspaceTabView.axaml:7-15. No existing control yet uses the MinWidth + viewport-MaxWidth + Auto wrapper pattern, so this introduces it.

Expected Tests

Follow the existing string-based AXAML-assertion pattern and the PhantomAvaloniaFact layout pattern (host.Measure/host.Arrange, then inspect visual descendants) and Subject_Scenario_ExpectedOutcome naming in Phantom.Workspaces.Gui.Shared.Tests/SharedStylesTests.cs and Phantom.Workspaces.Agent.Gui.Tests/AgentChatEditorControlTests.cs.

Note: two existing tests currently assert plain Auto and must be updated to also assert the new wrapper: SharedStylesTests.EntityCardTreeViewStyle_HorizontalScrollBar_OnlyWhenMinWidthHit_AndNotOverlapping (:715-732) and SharedStylesTests.EntityCardTreeViewStyle_HorizontalScrollBar_ConfiguredOnceOnStyle (:924-954) — keep the Auto / AllowAutoHide="False" / single-source assertions and add the MinWidth + viewport-MaxWidth wrapper assertion.

Test Name Class What It Verifies
EntityCardTreeView_ViewportWiderThanMin_ContentFillsAndWraps_NoHScroll SharedStylesTests (PhantomAvaloniaFact) With a viewport wider than 160, a wide single-line note/markdown card arranges to a width == viewport (capped by the wrapper MaxWidth), wraps (height grows), and the tree's horizontal ScrollBar is not visible (extent == viewport).
EntityCardTreeView_ViewportNarrowerThanMin_ShowsHorizontalScrollBar SharedStylesTests (PhantomAvaloniaFact) With a viewport narrower than 160, the items wrapper resolves to MinWidth="160" (> viewport), producing a horizontal extent > viewport so a single horizontal scrollbar appears and content wraps at 160.
EntityCardTreeView_NestedItem_WrapsWithinIndentedWidth SharedStylesTests (PhantomAvaloniaFact) A nested (child) TreeViewItem arranges within wrapper-width − indent — i.e. indent + itemWidth <= wrapperWidth — confirming indentation is handled inside the single wrapper without any per-item viewport binding.
EntityCardTreeView_ItemsWrapper_MaxWidthBoundToViewport SharedStylesTests The entity-card-tree-view items host (ItemsPanel override or templated wrapper) declares MinWidth="160" and MaxWidth="{Binding ...Viewport.Width}" bound to the tree's ScrollViewer, with HorizontalScrollBarVisibility="Auto" retained.
EntityCard_LongMarkdown_WrapsInsteadOfOverflowing EntityCardFieldBuildingTests The SafeSelectableTextBlock rendering long markdown wraps (measured height grows, measured width stays bounded to available width) rather than reporting a single-line desired width; it is not wrapped in a horizontal scroller.
WorkspaceMarkdownFieldRow_LabelColumn_IsNotFixedTwoHundred SharedStylesTests The note/markdown field-row grids no longer hard-code ColumnDefinitions="200,*" (use Auto,* / min-bounded) so the label does not impose an oversized floor.
AgentChatEditor_NavigationTree_InheritsWrapperFromSharedStyle AgentChatEditorControlTests NavigationTree (consuming entity-card-tree-view) does not redeclare ScrollViewer settings or the wrapper and therefore inherits the Auto + MinWidth/viewport-MaxWidth wrapper from the shared style.

Where a property is purely visual (exact pixel arrange, scrollbar gutter), assert the style/attached-property value and the measured/arranged width bound as above, and note remaining pixel behaviour as a manual/visual check.

Relationship to other issues

Metadata

Metadata

Labels

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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions