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

@@ -62,14 +62,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));
partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));
// Console maximize: green dot shrinks the description row to its MinHeight so
// the WorkConsole fills the rest. The row stays draggable and never overlaps.
// Applied in DetailsIslandView code-behind (RowDefinitions can't bind).
[ObservableProperty] private bool _isConsoleMaximized;
[RelayCommand]
private void ToggleConsoleMaximized() => IsConsoleMaximized = !IsConsoleMaximized;
public NotesEditorViewModel Notes { get; private set; } = null!;
// Current task row (set by IslandsShellViewModel via Bind)
@@ -161,14 +153,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
public bool ShowMergeSection =>
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
// Nothing to manage yet (idle/queued/running standalone): show a hint.
public bool ShowSessionEmpty =>
!IsWaitingForReview && !ShowMergeSection && !HasChildOutcomes;
private void NotifySessionSections()
{
OnPropertyChanged(nameof(HasChildOutcomes));
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(ShowSessionEmpty));
// The Session tab is only visible when it has outcomes; if it just
// emptied while selected, fall back to Output so the body isn't blank.
if (!HasChildOutcomes && SelectedTab == "session")
SelectedTab = "output";
}
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
@@ -184,6 +177,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
IsFailed ? "The session ended with an error." :
IsCancelled ? "The session was cancelled." : "";
// The session's outcome summary — the task's Result minus any roadblock
// section (those get their own card), falling back to the run's
// ErrorMarkdown for hard failures. Shown once a run has finished.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
private string? _sessionOutcome;
public bool ShowSessionOutcome =>
!string.IsNullOrWhiteSpace(SessionOutcome)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
// The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the
// run result so they can surface as a distinct colored card.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
private string? _roadblocks;
public bool ShowRoadblockCard =>
!string.IsNullOrWhiteSpace(Roadblocks)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
// Worker writes roadblocks into the result under this header
// (TaskRunner.ComposeReviewResult). Split it back out for display.
private const string RoadblockMarker = "Roadblocks reported during the run:";
private void ApplyOutcome(string? result, string? errorFallback)
{
if (string.IsNullOrWhiteSpace(result))
{
SessionOutcome = errorFallback;
Roadblocks = null;
return;
}
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
if (idx < 0)
{
SessionOutcome = result;
Roadblocks = null;
return;
}
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
}
public string SessionLabel => "claude-session";
// Short task-id badge, e.g. "#T1A"
@@ -230,6 +270,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(IsAgentSectionEnabled));
OnPropertyChanged(nameof(ShowRoadblock));
OnPropertyChanged(nameof(RoadblockMessage));
OnPropertyChanged(nameof(ShowSessionOutcome));
OnPropertyChanged(nameof(ShowRoadblockCard));
NotifySessionSections();
}
[ObservableProperty] private string? _model;
@@ -364,9 +406,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
public bool ShowMergePreviewMuted =>
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
public bool ShowSingleMerge =>
WorktreePath != null && Task?.IsPlanningParent != true;
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
@@ -439,6 +478,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
catch { }
}
// Reload the session outcome (task Result incl. roadblocks, or the run's
// error) so it appears as soon as a run finishes.
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
if (Task?.Id != taskId) return;
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
}
catch { }
}
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi)
{
_dbFactory = dbFactory;
@@ -492,6 +546,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Re-query to pick up worktree created during the run.
_ = RefreshWorktreeAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
_ = RefreshOutcomeAsync(taskId);
};
_worker.WorktreeUpdatedEvent += taskId =>
@@ -755,6 +810,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
CanMergeAll = false;
MergeAllDisabledReason = null;
MergeAllError = null;
SessionOutcome = null;
Roadblocks = null;
_claudeBuf.Clear();
if (row == null)
@@ -824,6 +881,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId;
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
// Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id;
@@ -1170,34 +1228,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}
[RelayCommand]
private async System.Threading.Tasks.Task MergeAsync()
{
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
try
{
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
else
{
await RefreshMergePreviewAsync();
}
}
catch { /* broadcast reconciles */ }
}
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
@@ -1244,7 +1274,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
NotifySessionSections();
OnPropertyChanged(nameof(ShowSingleMerge));
}
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();

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)