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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</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 & 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>
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user