style(ui): polish islands and remove terminal traffic-light dots
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -17,7 +19,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
// Current task row (set by IslandsShellViewModel via Bind)
|
||||
[ObservableProperty] private TaskRowViewModel? _task;
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
[ObservableProperty] private string _editableTitle = "";
|
||||
@@ -28,12 +32,22 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||
|
||||
// Agent strip fields
|
||||
[ObservableProperty] private string _agentStatusLabel = "Idle";
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||
private string _agentStatusLabel = "Idle";
|
||||
public bool IsRunning => AgentStatusLabel == "Running";
|
||||
public bool IsDone => AgentStatusLabel == "Done";
|
||||
public bool IsFailed => AgentStatusLabel == "Failed";
|
||||
|
||||
partial void OnAgentStatusLabelChanged(string value) => OnPropertyChanged(nameof(IsRunning));
|
||||
partial void OnAgentStatusLabelChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||
[ObservableProperty] private string? _branchLine;
|
||||
[ObservableProperty] private int _turns;
|
||||
[ObservableProperty] private int _tokens;
|
||||
@@ -61,6 +75,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||
|
||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
|
||||
// The task ID we are currently subscribed to for live log messages
|
||||
private string? _subscribedTaskId;
|
||||
|
||||
@@ -89,23 +107,92 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
// Subscribe once; filter by current task id inside the handler
|
||||
_worker.TaskMessageEvent += OnTaskMessage;
|
||||
|
||||
// Re-evaluate RunNow CanExecute when worker connection flips.
|
||||
_worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
};
|
||||
|
||||
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
||||
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
|
||||
{
|
||||
if (Task?.Id == taskId) AgentStatusLabel = "Running";
|
||||
};
|
||||
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
|
||||
{
|
||||
if (Task?.Id != taskId) return;
|
||||
FlushClaudeBuffer();
|
||||
Log.Add(new LogLineViewModel
|
||||
{
|
||||
Kind = LogKind.Done,
|
||||
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||||
});
|
||||
AgentStatusLabel = status;
|
||||
// Re-query to pick up worktree created during the run.
|
||||
_ = RefreshWorktreeAsync(taskId);
|
||||
};
|
||||
|
||||
_worker.WorktreeUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||||
};
|
||||
}
|
||||
|
||||
private void OnTaskMessage(string taskId, string line)
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
// Parse a simple prefix convention: "[sys]", "[tool]", "[claude]", etc.
|
||||
// Fall back to Msg for unrecognised lines.
|
||||
|
||||
// `[stdout] ...json...` lines are Claude CLI stream-json; parse through the
|
||||
// formatter so the user sees human text, not raw JSON.
|
||||
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var body = line["[stdout]".Length..].TrimStart();
|
||||
var formatted = _formatter.FormatLine(body);
|
||||
if (formatted is null) return; // filter noise (message_start, etc.)
|
||||
AppendClaudeText(formatted);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-stdout tagged lines: flush any buffered text then classify by prefix.
|
||||
FlushClaudeBuffer();
|
||||
|
||||
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||||
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||||
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||||
: line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stdout
|
||||
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
||||
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
||||
: LogKind.Msg;
|
||||
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
||||
}
|
||||
|
||||
private void AppendClaudeText(string chunk)
|
||||
{
|
||||
_claudeBuf.Append(chunk);
|
||||
// Emit a log entry for every completed line; keep the trailing remainder buffered.
|
||||
while (true)
|
||||
{
|
||||
var text = _claudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_claudeBuf.Clear();
|
||||
_claudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushClaudeBuffer()
|
||||
{
|
||||
if (_claudeBuf.Length == 0) return;
|
||||
var piece = _claudeBuf.ToString().TrimEnd();
|
||||
_claudeBuf.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
}
|
||||
|
||||
public void Bind(TaskRowViewModel? row)
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
@@ -117,6 +204,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Log.Clear();
|
||||
Subtasks.Clear();
|
||||
_claudeBuf.Clear();
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
@@ -137,11 +225,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var subtaskRepo = new SubtaskRepository(ctx);
|
||||
|
||||
var entity = await taskRepo.GetByIdAsync(row.Id);
|
||||
// Own query with Include so WorktreePath/BranchLine are populated.
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (entity == null) return;
|
||||
|
||||
@@ -163,6 +254,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity == null || Task?.Id != taskId) return;
|
||||
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||
row.DiffStat = stat;
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
@@ -170,6 +282,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
||||
{
|
||||
WorktreePath = WorktreePath,
|
||||
BaseRef = WorktreeBaseCommit,
|
||||
};
|
||||
await diffVm.LoadAsync();
|
||||
await ShowDiffModal(diffVm);
|
||||
@@ -178,19 +291,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
private bool CanOpenDiff() => WorktreePath != null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||
private async System.Threading.Tasks.Task OpenWorktreeAsync()
|
||||
{
|
||||
if (WorktreePath == null || ShowWorktreeModal == null) return;
|
||||
var vm = _services.GetRequiredService<WorktreeModalViewModel>();
|
||||
vm.WorktreePath = WorktreePath;
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreeModal(vm);
|
||||
}
|
||||
|
||||
private bool CanOpenWorktree() => WorktreePath != null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||
private void OpenInExplorer()
|
||||
private void OpenWorktree()
|
||||
{
|
||||
if (WorktreePath == null) return;
|
||||
try
|
||||
@@ -204,11 +305,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
catch { /* explorer open is best-effort */ }
|
||||
}
|
||||
|
||||
private bool CanOpenWorktree() => WorktreePath != null;
|
||||
|
||||
partial void OnWorktreePathChanged(string? value)
|
||||
{
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
OpenInExplorerCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -270,6 +372,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
if (Task == null) return;
|
||||
await _worker.CancelTaskAsync(Task.Id);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||
private async System.Threading.Tasks.Task RunNowAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
AgentStatusLabel = "Running";
|
||||
try
|
||||
{
|
||||
await _worker.RunNowAsync(Task.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
AgentStatusLabel = "Failed";
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanRunNow() =>
|
||||
Task != null && _worker.IsConnected && !IsRunning;
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
|
||||
Reference in New Issue
Block a user