feat(ui): add WorkConsole detail component

Standalone terminal-styled card with traffic-light title bar, roadblock
band, and three tabs (Output / Actions / Session). Renders fully via
design-time sample data; does not touch DetailsIslandView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-04 19:28:08 +02:00
parent c323953f8c
commit ce50f9fcce
3 changed files with 402 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.ViewModels.Islands.Detail;
public sealed class WorkConsoleChildOutcomeRowViewModel
{
public required string Title { get; init; }
public bool HasRoadblock { get; init; }
public string RoadblockText { get; init; } = "";
public required string StatusLabel { get; init; }
}
public sealed partial class WorkConsoleViewModel : ViewModelBase
{
// ── Tab selection ──────────────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
[NotifyPropertyChangedFor(nameof(IsActionsTab))]
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
private string _selectedTab = "output";
public bool IsOutputTab => SelectedTab == "output";
public bool IsActionsTab => SelectedTab == "actions";
public bool IsSessionTab => SelectedTab == "session";
[RelayCommand]
private void SelectTab(string tab) => SelectedTab = tab;
// ── Info header ────────────────────────────────────────────────
[ObservableProperty] private string _model = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TurnsText))]
private int _turns;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DiffAddText))]
private int _diffAdditions;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DiffDelText))]
private int _diffDeletions;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblock))]
private bool _isRunning;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblock))]
private bool _isDone;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblock))]
private bool _isFailed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblock))]
private bool _isCancelled;
[ObservableProperty] private string _sessionLabel = "";
public string TurnsText => $"{Turns} turns";
public string DiffAddText => $"+{DiffAdditions}";
public string DiffDelText => $"-{DiffDeletions}";
// ── Roadblock ──────────────────────────────────────────────────
public bool ShowRoadblock => IsFailed || IsCancelled;
[ObservableProperty] private string _roadblockMessage = "";
[ObservableProperty] private bool _showContinue;
[ObservableProperty] private bool _showResetAndRetry;
[RelayCommand] private void Continue() { }
[RelayCommand] private void ResetAndRetry() { }
// ── Actions tab ────────────────────────────────────────────────
public ObservableCollection<string> MergeTargetBranches { get; } = new();
[ObservableProperty] private string? _selectedMergeTarget;
[ObservableProperty] private bool _canMergeAll;
[ObservableProperty] private string _mergeAllDisabledReason = "";
[ObservableProperty] private string? _mergeAllError;
[RelayCommand] private void OpenDiff() { }
[RelayCommand] private void OpenWorktree() { }
[RelayCommand] private void ReviewCombinedDiff() { }
[RelayCommand] private void MergeAll() { }
// ── Session tab ────────────────────────────────────────────────
[ObservableProperty] private bool _isWaitingForReview;
[ObservableProperty] private string _reviewFeedback = "";
public ObservableCollection<WorkConsoleChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
[RelayCommand] private void ApproveReview() { }
[RelayCommand] private void RejectReview() { }
[RelayCommand] private void ParkReview() { }
[RelayCommand] private void CancelReview() { }
public ObservableCollection<LogLineViewModel> Log { get; } = new();
public WorkConsoleViewModel()
{
ChildOutcomes.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasChildOutcomes));
// ── Design-time sample data ────────────────────────────────
_model = "sonnet";
_turns = 40;
_diffAdditions = 84;
_diffDeletions = 31;
_isRunning = true;
_sessionLabel = "feat/work-console";
MergeTargetBranches.Add("main");
MergeTargetBranches.Add("develop");
_selectedMergeTarget = "main";
_canMergeAll = true;
Log.Add(new LogLineViewModel { Kind = LogKind.Sys, Text = "Starting claude session…" });
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = "Reading DetailsIslandView.axaml to understand existing layout." });
Log.Add(new LogLineViewModel { Kind = LogKind.Tool, Text = "Read(src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml)" });
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = "Building WorkConsole component with three tabs." });
Log.Add(new LogLineViewModel { Kind = LogKind.Stdout, Text = "dotnet build succeeded — 0 error(s)" });
ChildOutcomes.Add(new WorkConsoleChildOutcomeRowViewModel
{
Title = "Add WorkConsole XAML",
StatusLabel = "Done"
});
ChildOutcomes.Add(new WorkConsoleChildOutcomeRowViewModel
{
Title = "Wire ViewModel bindings",
HasRoadblock = true,
RoadblockText = "Missing token",
StatusLabel = "Failed"
});
// To preview roadblock state: _isFailed = true; _roadblockMessage = "Session ended unexpectedly"; _showResetAndRetry = true; _isRunning = false;
// To preview review state: _isWaitingForReview = true; _isDone = false; _isRunning = false;
}
}

View File

@@ -0,0 +1,249 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands.Detail"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:WorkConsoleViewModel"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.WorkConsole">
<Design.DataContext>
<vm:WorkConsoleViewModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Button.tab-btn">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button.tab-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style Selector="Button.tab-btn.active /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
</Style>
</UserControl.Styles>
<!-- Outer terminal card — Padding="0" so header/strip span edge-to-edge -->
<Border Classes="terminal" Padding="0">
<DockPanel LastChildFill="True">
<!-- ── Title bar ── -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
Background="{DynamicResource Surface2Brush}" Height="28">
<!-- Traffic-light dots -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
Margin="12,0" VerticalAlignment="Center">
<Ellipse Classes="dot-red" />
<Ellipse Classes="dot-yellow" />
<Ellipse Classes="dot-green" />
</StackPanel>
<!-- Info header: model · turns · +add -del -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{Binding Model}"
Foreground="{DynamicResource TextMuteBrush}" />
<TextBlock Classes="meta" Text="·"
Foreground="{DynamicResource TextFaintBrush}" />
<TextBlock Classes="meta" Text="{Binding TurnsText}"
Foreground="{DynamicResource TextMuteBrush}" />
<TextBlock Classes="meta" Text="·"
Foreground="{DynamicResource TextFaintBrush}" />
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
</StackPanel>
<!-- Status chips (LIVE / DONE / FAILED) -->
<Panel Grid.Column="2" Margin="0,0,8,0" VerticalAlignment="Center">
<Border Classes="live-chip pulsing"
IsVisible="{Binding IsRunning}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" />
<TextBlock Text="{loc:Tr session.chipLive}" VerticalAlignment="Center" />
</StackPanel>
</Border>
<Border Classes="live-chip done"
IsVisible="{Binding IsDone}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource MossBrush}" />
<TextBlock Text="{loc:Tr session.chipDone}" VerticalAlignment="Center"
Foreground="{DynamicResource MossBrush}" />
</StackPanel>
</Border>
<Border Classes="live-chip failed"
IsVisible="{Binding IsFailed}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource BloodBrush}" />
<TextBlock Text="{loc:Tr session.chipFailed}" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}" />
</StackPanel>
</Border>
</Panel>
</Grid>
<!-- ── Roadblock band ── -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding ShowRoadblock}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="0,1"
Padding="14,8">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource BloodBrush}"
Width="14" Height="14" VerticalAlignment="Center" />
<TextBlock Classes="meta" Text="{Binding RoadblockMessage}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowContinue}" />
<Button Classes="btn" Content="Reset &amp; Retry"
Command="{Binding ResetAndRetryCommand}"
IsVisible="{Binding ShowResetAndRetry}" />
</StackPanel>
</StackPanel>
</Border>
<!-- ── Tab strip ── -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal">
<Button Classes="tab-btn"
Classes.active="{Binding IsOutputTab}"
Content="Output"
Command="{Binding SelectTabCommand}"
CommandParameter="output" />
<Button Classes="tab-btn"
Classes.active="{Binding IsActionsTab}"
Content="Actions"
Command="{Binding SelectTabCommand}"
CommandParameter="actions" />
<Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}"
Content="Session"
Command="{Binding SelectTabCommand}"
CommandParameter="session" />
</StackPanel>
</Border>
<!-- ── Tab body ── -->
<Grid>
<!-- Output: embedded SessionTerminalView -->
<islands:SessionTerminalView
IsVisible="{Binding IsOutputTab}"
Entries="{Binding Log}"
Label="{Binding SessionLabel}"
IsRunning="{Binding IsRunning}"
IsDone="{Binding IsDone}"
IsFailed="{Binding IsFailed}" />
<!-- Actions: merge-target + worktree controls -->
<ScrollViewer IsVisible="{Binding IsActionsTab}" Padding="14,10">
<StackPanel Spacing="10">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Merge target" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</ScrollViewer>
<!-- Session: review block + child outcomes -->
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
<StackPanel Spacing="10">
<!-- Review block -->
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
<TextBlock Classes="section-label" Text="REVIEW" Margin="0,0,0,2" />
<TextBlock Classes="field-label" Text="Feedback" />
<TextBox Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="60"
MaxHeight="180"
PlaceholderText="Optional feedback for the next run…"
Padding="8"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="Approve"
Command="{Binding ApproveReviewCommand}" />
<Button Classes="btn" Content="Reject"
Command="{Binding RejectReviewCommand}" />
<Button Classes="btn" Content="Park"
Command="{Binding ParkReviewCommand}" />
<Button Classes="btn" Content="Cancel"
Command="{Binding CancelReviewCommand}" />
</StackPanel>
</StackPanel>
<!-- Child outcomes -->
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
<TextBlock Classes="section-label" Text="OUTCOMES" Margin="0,0,0,2" />
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorkConsoleChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class WorkConsole : UserControl
{
public WorkConsole() => InitializeComponent();
}