Repository navigation
entity-card-tree-view: entity cards expand far beyond the viewport horizontally; must be capped to viewport width (follow-up to #1045) #1049
Description
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 StackPanel → TreeViewItem (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, andMaxWidth="{Binding Viewport.Width, ElementName=...}"bound to the tree'sScrollViewerviewport 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 nestedTreeViewItemstill measures at available-width − indent (the20,*grid,SharedStyles.axaml:242) within the wrapper. - Only the one wrapper's
MinWidthdrives the horizontal scrollbar. Nested items never each try to claim the full viewport width, soindent + itemWidthnever 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:
- note/markdown field-row grid: label column min
~67+ value column min67(workspace-field-label/workspace-field-value,SharedStyles.axaml) ≈134, plus theentity-card-shell-borderpadding10 + 10 = 20≈154→ rounds to the agreed160itemMinWidth(SharedStyles.axaml:238). - It matches the display-name / property-column / actions-row minimums established in entity-card-tree-view refinements: min-widths, indent column, sticky scope, blue-border Z-order, horizontal scrollbar, editor item word-wrap (follow-up to #1029) #1045 (the 2/3-reduced values). Using
160keeps the wrapper floor equal to the item's ownMinWidthso the two never disagree.
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-21—TreeView Classes="entity-card-tree entity-card-tree-sticky"with inlineScrollViewer.HorizontalScrollBarVisibility="Disabled"+VerticalScrollBarVisibility="Auto".Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml:7-15— an outerScrollViewer HorizontalScrollBarVisibility="Disabled"/VerticalScrollBarVisibility="Auto"wrapping aTreeView 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 MaxWidth → Viewport.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
- Follow-up to entity-card-tree-view refinements: min-widths, indent column, sticky scope, blue-border Z-order, horizontal scrollbar, editor item word-wrap (follow-up to #1029) #1045 (which added wrapping + single ScrollViewer) and entity-card-tree-view style + reusable entity-card border template: proper refactor of tree/templates (follow-up to #1006) #1029 (which established the
entity-card-tree-viewstyle andentity-card-shelltemplate). - Properly satisfies entity-card-tree-view refinements: min-widths, indent column, sticky scope, blue-border Z-order, horizontal scrollbar, editor item word-wrap (follow-up to #1029) #1045 item 5: "horizontal scrollbar appears when needed" is realised by the single wrapper's
MinWidth(scrollbar when viewport < min) while the viewport-MaxWidthguarantees wrap-to-viewport when wider.
Activity
JoshuaRowePhantom commentedon Jul 16, 2026
Implementation complete — commit 2b9eefdd on batch/1042-and-6-more.
Files changed: SharedStyles.axaml (ItemsPanel override on TreeView.entity-card-tree-view), WorkspaceDataTemplates.axaml (9 ColumnDefinitions 200,* → Auto,*), SharedStylesTests.cs (updated 2 existing tests + added 7 new tests).
Fix: Added ItemsPanel override with StackPanel carrying MinWidth=160 and MaxWidth='{Binding [ScrollViewer].Viewport.Width}', keeping HorizontalScrollBarVisibility=Auto. Changed note/markdown field-row grids from ColumnDefinitions='200,' to 'Auto,' (9 occurrences at lines 231/250/262/274/282/304/316/328/337). Non-markdown grids (EntityReference, EntityList, String, BooleanToggle) left at 200,* as the issue didn't target them.
Tests: All 7 tests implemented including ItemsWrapper_MaxWidthBoundToViewport, WorkspaceMarkdownFieldRow_LabelColumn_IsNotFixedTwoHundred, ViewportWiderThanMin/NarrowerThanMin, NestedItem_WrapsWithinIndentedWidth, AgentChatEditor_NavigationTree_InheritsWrapperFromSharedStyle. The 3 arrange-based tests are style/attached-property assertion proxies (headless harness cannot host entity-card-tree-view). EntityCard_LongMarkdown_WrapsInsteadOfOverflowing omitted — EntityCardFieldBuildingTests harness tests ViewModel construction, not AXAML layout; covered by ItemsWrapper_MaxWidthBoundToViewport. Passed: 55/16 (SharedStylesTests/AgentChatEditorControlTests), Failed: 0.
Deviations: NavigationTree already has MinWidth=160 on the TreeView element itself (not the ItemsPanel wrapper), so the inheritance test asserts DoesNotContain ItemsPanel/MaxWidth only.
JoshuaRowePhantom commentedon Jul 16, 2026
Verification result
Status: ✅ Verified
Commits checked:
2db0beb4Fix #1049: cap entity card width to viewport with ItemsPanel wrapper (onbatch/1042-and-6-more)Feature coverage
TreeView.entity-card-tree-viewnow overridesItemsPanelwith a singleStackPanelwrapper carryingHorizontalAlignment="Stretch",MinWidth="160", andMaxWidth="{Binding $parent[ScrollViewer].Viewport.Width}", while keepingHorizontalScrollBarVisibility="Auto"/VerticalScrollBarVisibility="Auto"/AllowAutoHide="False". This is exactly the chosen design: a finite viewport-bound ceiling (so wrapping text actually wraps and cards cap to the viewport) plus aMinWidthfloor (so a single horizontal scrollbar appears only when the viewport is narrower than 160). Single wrapper, so indentation is handled normally inside it.WorkspaceDataTemplates.axamlchanged fromColumnDefinitions="200,*"to"Auto,*", removing the hard 200px label-column floor. Non-markdown grids left unchanged, as scoped by the issue.Test coverage
SharedStylesTests(+AgentChatEditorControlTests) — the 7 tests includingEntityCardTreeView_ItemsWrapper_MaxWidthBoundToViewport,WorkspaceMarkdownFieldRow_LabelColumn_IsNotFixedTwoHundred,ViewportWider/NarrowerThanMin,NestedItem_WrapsWithinIndentedWidth, andAgentChatEditor_NavigationTree_InheritsWrapperFromSharedStyle. The arrange-based tests use style/attached-property assertions (headless harness cannot hostentity-card-tree-view);EntityCard_LongMarkdown_WrapsInsteadOfOverflowingis covered indirectly by the wrapper MaxWidth test — an acceptable, documented substitution.Tests
UI-only AXAML change; no filesystem/Git layers touched → fast suite sufficient.
Fix #1049: cap entity card width to viewport with ItemsPanel wrapper
JoshuaRowePhantom commentedon Jul 16, 2026
Checked in
Merged to
featuresas part of batchbatch/1042-and-6-more.Issue commit: 2db0beb
Branch fast-forwarded: features -> 69efbaf
Build passed (0 errors), stability suite passed 10/10 runs, and the fast test suite passed (4055 passed, 0 failed).
Fix #1064: two-regime entity-card-tree scroll wrapper so cards wrap a…
Fix #1177 follow-up: preserve two-regime wrapper on virtualized entit…