From a8670ee23a2bb4ba62a81dbb3dcc06d6e32ebe59 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 08:14:09 +0200 Subject: [PATCH] feat(daily-prep): add live prep-output mode to the Details island Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Localization/locales/de.json | 3 +- src/ClaudeDo.Localization/locales/en.json | 3 +- .../Islands/DetailsIslandViewModel.cs | 57 +++++++++++-- .../Views/Islands/DetailsIslandView.axaml | 27 +++++- .../ViewModels/DetailsIslandPrepModeTests.cs | 83 +++++++++++++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 10fc822..3b7bc8f 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -138,7 +138,8 @@ "toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten", "previewBtn": "Vorschau", "editBtn": "Bearbeiten", - "descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)..." + "descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...", + "prepTitle": "Tagesvorbereitung" }, "agent": { "stopTip": "Agent stoppen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ea4a852..2d0f3aa 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -138,7 +138,8 @@ "toggleEditPreviewTip": "Toggle edit/preview", "previewBtn": "Preview", "editBtn": "Edit", - "descriptionPlaceholder": "Add task details (markdown supported)..." + "descriptionPlaceholder": "Add task details (markdown supported)...", + "prepTitle": "Daily prep" }, "agent": { "stopTip": "Stop agent", diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 2d2a537..238edbe 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -54,6 +54,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private readonly INotesApi _notesApi; [ObservableProperty] private bool _isNotesMode; + [ObservableProperty] private bool _isPrepMode; + [ObservableProperty] private bool _isPrepRunning; + public ObservableCollection PrepLog { get; } = new(); + public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; + + partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); + partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); + public NotesEditorViewModel Notes { get; private set; } = null!; // Current task row (set by IslandsShellViewModel via Bind) @@ -207,6 +215,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Claude CLI stream-json parser + buffer for partial text deltas private readonly StreamLineFormatter _formatter = new(); private readonly StringBuilder _claudeBuf = new(); + private readonly StringBuilder _prepClaudeBuf = new(); // The task ID we are currently subscribed to for live log messages private string? _subscribedTaskId; @@ -285,6 +294,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Subscribe once; filter by current task id inside the handler _worker.TaskMessageEvent += OnTaskMessage; + _worker.PrepStartedEvent += OnPrepStarted; + _worker.PrepLineEvent += OnPrepLine; + _worker.PrepFinishedEvent += OnPrepFinished; // Re-evaluate CanExecute when worker connection flips. _worker.PropertyChanged += (_, e) => @@ -345,9 +357,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase 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); + AppendStdoutLine(Log, body); return; } @@ -363,20 +373,40 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase Log.Add(new LogLineViewModel { Kind = kind, Text = line }); } - private void AppendClaudeText(string chunk) + private void AppendStdoutLine(ObservableCollection target, string line) { - _claudeBuf.Append(chunk); + var formatted = _formatter.FormatLine(line); + if (formatted is null) return; + var buf = ReferenceEquals(target, Log) ? _claudeBuf : _prepClaudeBuf; + AppendClaudeText(formatted, target, buf); + } + + private void OnPrepStarted() + { + PrepLog.Clear(); + IsPrepRunning = true; + } + + private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line); + + private void OnPrepFinished(bool success) => IsPrepRunning = false; + + private void AppendClaudeText(string chunk) => AppendClaudeText(chunk, Log, _claudeBuf); + + private static void AppendClaudeText(string chunk, ObservableCollection target, StringBuilder buf) + { + buf.Append(chunk); // Emit a log entry for every completed line; keep the trailing remainder buffered. while (true) { - var text = _claudeBuf.ToString(); + var text = buf.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)..]); + target.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + buf.Clear(); + buf.Append(text[(nl + 1)..]); } } @@ -478,13 +508,22 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase public void ShowNotes() { Bind(null); + IsPrepMode = false; IsNotesMode = true; _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); } + public void ShowPrep() + { + Bind(null); + IsNotesMode = false; + IsPrepMode = true; + } + public void Bind(TaskRowViewModel? row) { IsNotesMode = false; + IsPrepMode = false; _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 67f5369..42d0610 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -127,10 +127,10 @@ - + + IsVisible="{Binding IsTaskDetailVisible}"> @@ -299,6 +299,29 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs new file mode 100644 index 0000000..1e49d3e --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs @@ -0,0 +1,83 @@ +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class DetailsIslandPrepModeTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandPrepModeTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_prep_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class DefaultStub : StubWorkerClient { } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub) + { + var factory = new TestDbFactory(NewContext); + return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi()); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + [Fact] + public void PrepLine_event_appends_to_PrepLog() + { + var stub = new DefaultStub(); + var vm = NewDetailsVm(stub); + + stub.RaisePrepLine("{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}"); + + Assert.NotEmpty(vm.PrepLog); + } + + [Fact] + public void ShowPrep_sets_prep_mode_and_clears_notes_mode() + { + var vm = NewDetailsVm(new DefaultStub()); + vm.ShowPrep(); + + Assert.True(vm.IsPrepMode); + Assert.False(vm.IsNotesMode); + Assert.False(vm.IsTaskDetailVisible); + } +}