feat(ui): surface agent roadblocks and run outcome in the detail pane

- Parse CLAUDEDO_BLOCKED roadblocks out of the run result and show them in a
  colored card between Details and Output (ApplyOutcome / ShowRoadblockCard).
- Show the run outcome summary as an OUTCOME card in the Output tab, loaded from
  the task result (falls back to the run's ErrorMarkdown) and refreshed on finish.
- Guard the Session tab so it only appears when there are child outcomes.
- Make console resize per-task and proportional (description capped at 2/3,
  console floored at ~1/3) so a long description no longer spills over the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 09:34:37 +02:00
parent a41b8de47a
commit 763732a9b3
5 changed files with 201 additions and 102 deletions

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
x:DataType="vm:DetailsIslandViewModel">
@@ -21,7 +22,7 @@
<Button Grid.Column="2"
Classes="icon-btn"
Margin="0,0,4,0"
ToolTip.Tip="Copy formatted (title + description + open steps)"
ToolTip.Tip="{loc:Tr details.copyFormattedTip}"
Click="OnCopyClick">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
@@ -30,6 +31,7 @@
<Button Grid.Column="3"
Classes="btn"
Padding="8,3"
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
Command="{Binding ToggleEditDescriptionCommand}">
<Panel>
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
@@ -40,7 +42,8 @@
</Grid>
</Border>
<!-- Body -->
<!-- Body (scrolls inside the card so the card fills its row to the divider) -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="14" Spacing="10">
<!-- Description (always visible) -->
@@ -159,6 +162,7 @@
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Border>

View File

@@ -72,22 +72,12 @@
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
Background="{DynamicResource Surface2Brush}" Height="28">
<!-- Traffic-light dots; green toggles console maximize -->
<!-- Traffic-light dots (decorative) -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
Margin="12,0" VerticalAlignment="Center">
<Ellipse Classes="dot-red" />
<Ellipse Classes="dot-yellow" />
<Button Classes="dot-btn"
Command="{Binding ToggleConsoleMaximizedCommand}"
ToolTip.Tip="{loc:Tr console.maximizeTip}">
<Panel>
<Ellipse Classes="dot-green" />
<PathIcon Data="{StaticResource Icon.ArrowOut}"
Width="6" Height="6"
Foreground="{DynamicResource MossBrightBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Panel>
</Button>
<Ellipse Classes="dot-green" />
</StackPanel>
<!-- Right cluster: info header (model · turns · diff) + status chip -->
@@ -183,6 +173,7 @@
<Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}"
Content="Session"
IsVisible="{Binding HasChildOutcomes}"
Command="{Binding SelectTabCommand}"
CommandParameter="session" />
</StackPanel>
@@ -194,6 +185,26 @@
<!-- Output: log + review footer, both gated on IsOutputTab -->
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
<!-- Session outcome: the run's result summary, incl. any roadblocks
reported (or the error for a hard failure). -->
<Border DockPanel.Dock="Top"
Margin="12,8,12,4" Padding="10,8"
IsVisible="{Binding ShowSessionOutcome}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="8">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="OUTCOME" />
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
<SelectableTextBlock Text="{Binding SessionOutcome}"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
</ScrollViewer>
</StackPanel>
</Border>
<!-- Review prompt — sits directly on the terminal, like a shell input line;
only while awaiting review. No border/fill so it reads as part of the log. -->
<Grid DockPanel.Dock="Bottom"
@@ -222,8 +233,10 @@
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
VerticalAlignment="Top" Margin="12,2,0,0">
<Button Classes="prompt-action accent" Content="[Continue]"
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
Command="{Binding RejectReviewCommand}" />
<Button Classes="prompt-action" Content="[Reset]"
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</StackPanel>
</Grid>
@@ -275,19 +288,15 @@
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<!-- Primary action: Approve flows straight into the merge.
Approve is the review-gated path; the plain Merge button covers
already-reviewed / kept worktrees. -->
<!-- Primary action: Approve flows straight into the merge. -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
@@ -336,13 +345,6 @@
</ItemsControl>
</StackPanel>
<!-- Empty state: nothing to manage yet -->
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
Classes="meta"
Foreground="{DynamicResource TextMuteBrush}"
TextWrapping="Wrap"
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
</StackPanel>
</ScrollViewer>

View File

@@ -43,24 +43,55 @@
IsVisible="{Binding IsTaskDetailVisible}"
Margin="14,12,14,12">
<Grid.RowDefinitions>
<!-- MinHeight keeps the description visible: the console can never
overlap it, whether maximized (code-behind) or dragged. -->
<RowDefinition Height="2*" MinHeight="90"/>
<!-- Auto: the description sizes to its content so the console takes
every spare pixel when it's short. Row limits are proportional
and set in code-behind (UpdateRowLimits): the description row is
capped at 2/3 of the island and the console row floored at 1/3,
so the console can be dragged down to (but not below) 1/3 and a
long description never spills over the footer. -->
<RowDefinition Height="Auto" MinHeight="90"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<detail:DescriptionStepsCard VerticalAlignment="Top"/>
</ScrollViewer>
<detail:WorkConsole Grid.Row="1" Margin="0,10,0,0"/>
<detail:DescriptionStepsCard x:Name="DescriptionCard" Grid.Row="0"/>
<!-- Console row also hosts the roadblock card (docked above the console)
so it surfaces at a glance between Details and Output. Keeping it
inside row 1 leaves the desc/console resize model untouched. -->
<DockPanel Grid.Row="1" Margin="0,10,0,0">
<Border DockPanel.Dock="Top"
IsVisible="{Binding ShowRoadblockCard}"
Margin="0,0,0,10" Padding="12,10"
Background="{DynamicResource ReviewTintBrush}"
BorderBrush="{DynamicResource ReviewTintBorderBrush}"
BorderThickness="1" CornerRadius="10">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource StatusReviewBrush}"
Width="14" Height="14" VerticalAlignment="Center"/>
<TextBlock Classes="section-label" Text="ROADBLOCK"
Foreground="{DynamicResource StatusReviewBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<SelectableTextBlock Text="{Binding Roadblocks}"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
</Border>
<detail:WorkConsole/>
</DockPanel>
<!-- Resize by dragging the console's top edge — a transparent splitter
over the gap above the console; no standalone separator bar.
Stays draggable while maximized. -->
<GridSplitter Grid.Row="1"
<GridSplitter x:Name="DetailSplitter" Grid.Row="1"
VerticalAlignment="Top"
Height="10"
HorizontalAlignment="Stretch"
ResizeDirection="Rows"
Background="Transparent"/>
Background="Transparent"
DragStarted="OnSplitterDragStarted"
DragCompleted="OnSplitterDragCompleted"/>
</Grid>
<!-- Notes mode -->

View File

@@ -1,8 +1,9 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Reactive;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
@@ -11,27 +12,42 @@ namespace ClaudeDo.Ui.Views.Islands;
public partial class DetailsIslandView : UserControl
{
private DetailsIslandViewModel? _subscribedVm;
// Per-task description height (pixels) once the user drags the splitter.
// Keyed by task id so each task keeps its own resize; tasks that were
// never dragged stay dynamic (Auto-sized description).
private readonly Dictionary<string, double> _descriptionHeights = new();
private DetailsIslandViewModel? _vm;
public DetailsIslandView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
// Keep the row limits proportional to the island height: description
// capped at 2/3, console floored at 1/3. The GridSplitter honours these
// row Min/Max during a drag, so the console stops shrinking at 1/3.
DetailBodyGrid.GetObservable(BoundsProperty)
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
}
private void UpdateRowLimits()
{
var h = DetailBodyGrid.Bounds.Height;
if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_subscribedVm is not null)
_subscribedVm.PropertyChanged -= OnVmPropertyChanged;
_subscribedVm = DataContext as DetailsIslandViewModel;
if (_subscribedVm is not null)
{
_subscribedVm.PropertyChanged += OnVmPropertyChanged;
ApplyConsoleMaximized(_subscribedVm.IsConsoleMaximized);
}
if (_vm != null)
_vm.PropertyChanged -= OnViewModelPropertyChanged;
if (DataContext is DetailsIslandViewModel vm)
{
_vm = vm;
vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask();
vm.ShowDiffModal = async (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -61,22 +77,39 @@ public partial class DetailsIslandView : UserControl
}
}
private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
// Restores the resize state for the currently-selected task: a task the
// user has dragged before gets its pinned pixel height (cap lifted); a task
// never dragged falls back to dynamic sizing (Auto row + the bound cap).
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DetailsIslandViewModel.IsConsoleMaximized)
&& sender is DetailsIslandViewModel vm)
ApplyConsoleMaximized(vm.IsConsoleMaximized);
if (e.PropertyName == nameof(DetailsIslandViewModel.Task))
ApplyResizeStateForCurrentTask();
}
// Maximized: shrink the description row to its MinHeight (the console fills
// the rest). Restored: back to the 2:1 default. The GridSplitter keeps both
// states draggable; MinHeight stops the console from ever covering it.
private void ApplyConsoleMaximized(bool maximized)
private void ApplyResizeStateForCurrentTask()
{
// A task dragged before keeps its pixel height (clamped by the row's
// 2/3 MaxHeight); a task never dragged stays Auto-sized.
DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h)
? new GridLength(h, GridUnitType.Pixel)
: GridLength.Auto;
}
// Pin the (until now Auto-sized) description row to its current pixel
// height so the splitter resizes smoothly from there.
private void OnSplitterDragStarted(object? sender, VectorEventArgs e)
{
var descRow = DetailBodyGrid.RowDefinitions[0];
descRow.Height = maximized
? new GridLength(descRow.MinHeight, GridUnitType.Pixel)
: new GridLength(2, GridUnitType.Star);
if (descRow.Height.IsAuto)
descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel);
}
// Remember the dragged height for this task so switching tasks keeps each
// task's resize independent.
private void OnSplitterDragCompleted(object? sender, VectorEventArgs e)
{
if (_vm?.Task?.Id is string id)
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
}
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)