feat(ui,worker): surface task numbers in row/detail UI and worker log

Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed
"#123" before the row title; DetailsIslandViewModel.TaskIdBadge now
shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the
curated WorkerLog business events in TaskRunner, TaskMergeService, and
TaskResetService prefix their quoted title with "#<Number>".
This commit is contained in:
mika kuns
2026-08-11 13:51:20 +02:00
parent a1aeb21481
commit 38af549a80
11 changed files with 361 additions and 38 deletions
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string SessionLabel => "claude-session";
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private int _number;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done;
@@ -351,6 +352,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number;
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
@@ -66,14 +66,18 @@
<!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0"
Classes="task-title"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding Number, StringFormat='#{0}'}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1"
Classes="task-title"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
</Grid>
<!-- Badges: DRAFT and planning session -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -315,7 +315,7 @@ public sealed class TaskMergeService
"Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}",
candidate.Id, wt.BranchName, targetBranch, detail);
await _broadcaster.WorkerLog(
$"Auto-rebase failed for \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
$"Auto-rebase failed for #{candidate.Number} \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
WorkerLogLevel.Warn, DateTime.UtcNow);
return;
}
@@ -429,7 +429,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
@@ -438,7 +438,7 @@ public sealed class TaskMergeService
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Merged #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
@@ -523,7 +523,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
@@ -625,7 +625,7 @@ public sealed class TaskMergeService
_logger.LogInformation(
"Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}",
taskId, wt.MergeCommit, revertSha);
await _broadcaster.WorkerLog($"Reverted merge of \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Reverted merge of #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
return new RevertResult(StatusReverted, revertSha, Array.Empty<string>(), null);
}
@@ -55,7 +55,7 @@ public sealed class TaskResetService
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
{
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Discarded worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
worktreeChanged = true;
}
@@ -66,6 +66,6 @@ public sealed class TaskResetService
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Reset task {TaskId} to Idle (worktree discarded: {Discarded})", taskId, worktreeChanged);
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Reset #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
}
}
+22 -22
View File
@@ -73,7 +73,7 @@ public sealed class TaskRunner
list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
{
await MarkFailed(task.Id, task.Title, slot, "List not found.");
await MarkFailed(task.Id, task.Number, task.Title, slot, "List not found.");
return;
}
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
@@ -117,7 +117,7 @@ public sealed class TaskRunner
var prep = await PrepareRunDirectoryAsync(task, list, ct);
if (prep.FailureReason is not null)
{
await MarkFailed(task.Id, task.Title, slot, prep.FailureReason);
await MarkFailed(task.Id, task.Number, task.Title, slot, prep.FailureReason);
return;
}
var wtCtx = prep.WtCtx;
@@ -135,7 +135,7 @@ public sealed class TaskRunner
attachmentPaths);
// Run 1.
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
var result = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
if (result.IsSuccess)
{
@@ -150,7 +150,7 @@ public sealed class TaskRunner
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
var retryResult = await RunOnceAsync(task.Id, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
var retryResult = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess)
{
@@ -158,13 +158,13 @@ public sealed class TaskRunner
}
else
{
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
await MarkFailed(task.Id, task.Number, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason));
}
}
else
{
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
await MarkFailed(task.Id, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
}
}
@@ -174,12 +174,12 @@ public sealed class TaskRunner
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
await MarkFailed(task.Id, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
await MarkFailed(task.Id, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
}
finally
{
@@ -264,7 +264,7 @@ public sealed class TaskRunner
try
{
var nextRunNumber = lastRun.RunNumber + 1;
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
var result = await RunOnceAsync(taskId, task.Number, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess)
{
@@ -272,7 +272,7 @@ public sealed class TaskRunner
}
else
{
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
await MarkFailed(taskId, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
}
@@ -281,12 +281,12 @@ public sealed class TaskRunner
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
await MarkFailed(taskId, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
await MarkFailed(taskId, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}");
await MarkFailed(taskId, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
}
finally
{
@@ -329,7 +329,7 @@ public sealed class TaskRunner
try
{
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Created worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id);
@@ -348,7 +348,7 @@ public sealed class TaskRunner
}
private async Task<RunResult> RunOnceAsync(
string taskId, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
string taskId, int taskNumber, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct)
{
var runId = Guid.NewGuid().ToString();
@@ -383,7 +383,7 @@ public sealed class TaskRunner
try
{
await _broadcaster.WorkerLog($"Started Claude for \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Started Claude for #{taskNumber} \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
var result = await _claude.RunAsync(
arguments,
prompt,
@@ -486,7 +486,7 @@ public sealed class TaskRunner
committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed)
{
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Committed changes in #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorktreeUpdated(task.Id);
}
}
@@ -505,7 +505,7 @@ public sealed class TaskRunner
if (wtCtx is not null && !committed && deniedWrites.Count > 0)
{
await MarkFailed(
task.Id, task.Title, slot,
task.Id, task.Number, task.Title, slot,
$"All edits were blocked by permission denials ({string.Join(", ", deniedWrites)}) and nothing " +
"was changed. Check the run's permission mode (get_effective_run_config).",
result.TurnCount);
@@ -542,20 +542,20 @@ public sealed class TaskRunner
foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog(
$"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
$"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
}
else if (isStandalone)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
@@ -563,7 +563,7 @@ public sealed class TaskRunner
}
private async Task MarkFailed(
string taskId, string taskTitle, string slot, string? error, int turnCount = 0,
string taskId, int taskNumber, string taskTitle, string slot, string? error, int turnCount = 0,
int? maxTurns = null, string failureReason = "error")
{
// Terminal write for a failed task: never cancel (the status must always
@@ -573,7 +573,7 @@ public sealed class TaskRunner
var finishedAt = DateTime.UtcNow;
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
failureReason, turnCount > 0 ? turnCount : null, maxTurns);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{taskNumber} \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
}