fix(ui): stop clipping the last log line in scrollable output

Avalonia 12 leaves ScrollViewer.Padding out of the scroll Extent, so at
maximum offset the content still overhangs the viewport by the padding
height — the last lines were cut off and unreachable even after
ScrollToEnd(). Move the inset onto the content (Margin on the inner
ItemsControl/StackPanel) and leave the ScrollViewer padding-free.

Affects the work console Output/Git/Session tabs, the reusable session
terminal (task + prep log) and the log visualizer.
This commit is contained in:
mika kuns
2026-08-11 09:26:06 +02:00
parent 1657a70962
commit 6927805600
4 changed files with 16 additions and 12 deletions
+1
View File
@@ -111,6 +111,7 @@ snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**. - **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`. - **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible.
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout. - **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**. - **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md). - **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
@@ -269,11 +269,13 @@
Command="{Binding RejectReviewCommand}" /> Command="{Binding RejectReviewCommand}" />
</Grid> </Grid>
<!-- Inset lives on the content, NOT on the ScrollViewer: Avalonia 12 leaves
ScrollViewer.Padding out of the Extent, so at max scroll the last lines
sit below the viewport and stay unreachable. -->
<ScrollViewer Name="LogScroll" <ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible"
AllowAutoHide="False" AllowAutoHide="False">
Padding="12,8,12,4"> <ItemsControl ItemsSource="{Binding Log}" Margin="12,8,12,4">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel"> <DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1"> <Grid ColumnDefinitions="60,*" Margin="0,1">
@@ -293,8 +295,8 @@
</DockPanel> </DockPanel>
<!-- Git: the review + merge cockpit --> <!-- Git: the review + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10"> <ScrollViewer IsVisible="{Binding IsGitTab}">
<StackPanel Spacing="14"> <StackPanel Spacing="14" Margin="14,10">
<!-- Merge controls — shown whenever there's a worktree / unit to merge. <!-- Merge controls — shown whenever there's a worktree / unit to merge.
Header reads REVIEW while a decision is pending, otherwise MERGE. --> Header reads REVIEW while a decision is pending, otherwise MERGE. -->
@@ -393,8 +395,8 @@
</ScrollViewer> </ScrollViewer>
<!-- Session: subtask outcomes (review lives in Output, merge in Git) --> <!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10"> <ScrollViewer IsVisible="{Binding IsSessionTab}">
<StackPanel Spacing="14"> <StackPanel Spacing="14" Margin="14,10">
<!-- Attention band: a child failed, was cancelled, still needs its own <!-- Attention band: a child failed, was cancelled, still needs its own
review, or reported roadblocks. The parent stays waiting until resolved. --> review, or reported roadblocks. The parent stays waiting until resolved. -->
@@ -51,11 +51,12 @@
</Grid> </Grid>
<!-- ── Log output ── --> <!-- ── Log output ── -->
<!-- Inset on the content, not on the ScrollViewer — Avalonia 12 excludes
ScrollViewer.Padding from the Extent, which clips the last log lines. -->
<ScrollViewer Name="LogScroll" <ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible"
AllowAutoHide="False" AllowAutoHide="False">
Padding="10,8,10,12"> <ItemsControl ItemsSource="{Binding #Root.Entries}" Margin="10,8,10,12">
<ItemsControl ItemsSource="{Binding #Root.Entries}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel"> <DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1"> <Grid ColumnDefinitions="60,*" Margin="0,1">
@@ -42,8 +42,8 @@
<!-- Last 30 min, newest-first --> <!-- Last 30 min, newest-first -->
<Border Classes="terminal" Margin="14,0,14,14"> <Border Classes="terminal" Margin="14,0,14,14">
<ScrollViewer VerticalScrollBarVisibility="Visible" AllowAutoHide="False" Padding="10,8"> <ScrollViewer VerticalScrollBarVisibility="Visible" AllowAutoHide="False">
<ItemsControl ItemsSource="{Binding Rows}"> <ItemsControl ItemsSource="{Binding Rows}" Margin="10,8">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogVisualizerRow"> <DataTemplate DataType="vm:LogVisualizerRow">
<Grid ColumnDefinitions="64,*" Margin="0,1"> <Grid ColumnDefinitions="64,*" Margin="0,1">