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();