From c8c8bb4a47050e16981c2abb5dbd73cfe57efea0 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 13:07:54 +0200 Subject: [PATCH] feat(ui): replay persisted task log when selecting a task Read the task's LogPath on selection and feed each line through the live-stream parser so Claude output stays visible across app restarts. Tail-caps at 2000 lines to avoid flooding the UI. --- .../Islands/DetailsIslandViewModel.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 46653ef..461710b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -378,6 +378,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Subscribe only after DB load confirms the task exists _subscribedTaskId = row.Id; + // Replay the latest run's persisted log so output is visible across app restarts. + await ReplayLogFileAsync(entity.LogPath, ct); + var subs = await subtaskRepo.GetByTaskIdAsync(row.Id); ct.ThrowIfCancellationRequested(); foreach (var s in subs) @@ -386,6 +389,59 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase catch (OperationCanceledException) { } } + private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(logPath)) return; + var expanded = ExpandUserPath(logPath); + if (!System.IO.File.Exists(expanded)) return; + + try + { + const int maxLines = 2000; + string[] all; + await using (var fs = new System.IO.FileStream( + expanded, + System.IO.FileMode.Open, + System.IO.FileAccess.Read, + System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete)) + using (var reader = new System.IO.StreamReader(fs)) + { + var list = new List(); + while (await reader.ReadLineAsync(ct) is { } line) + list.Add(line); + all = list.ToArray(); + } + ct.ThrowIfCancellationRequested(); + + var start = Math.Max(0, all.Length - maxLines); + for (int i = start; i < all.Length; i++) + { + ct.ThrowIfCancellationRequested(); + if (_subscribedTaskId is null) return; + // Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts + // it with a "[stdout] " prefix. Match the live-stream format so the same + // stream-json parser handles both. + var line = all[i]; + var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line; + OnTaskMessage(_subscribedTaskId, normalized); + } + FlushClaudeBuffer(); + } + catch (OperationCanceledException) { throw; } + catch { /* best-effort replay */ } + } + + private static string ExpandUserPath(string path) + { + if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal)) + return System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + path[2..]); + if (path == "~") + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return path; + } + private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId) { try