diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 33210ab8..df630b64 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -165,13 +165,13 @@ sealed class Program sc.AddSingleton(sp => new TasksIslandViewModel( sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); sc.AddSingleton(sp => new DetailsIslandViewModel( sp.GetRequiredService>(), sp.GetRequiredService(), sp, - sp.GetRequiredService(), sp.GetRequiredService())); sc.AddSingleton(sp => new UsagePillViewModel(sp.GetRequiredService())); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 84488995..22f5c5f5 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -177,7 +177,6 @@ "listSettingsTip": "Listeneinstellungen", "addPlaceholder": "Aufgabe hinzufügen…", "enterKey": "ENTER", - "notesPinnedRow": "Notizen (Tagesnotizen)", "clearDayTip": "Tag leeren", "planMyDayTip": "Meinen Tag planen", "overdue": "ÜBERFÄLLIG", @@ -299,8 +298,11 @@ }, "notes": { "today": "Heute", - "add": "Hinzufügen", - "newNotePlaceholder": "Neue Notiz…" + "yesterday": "Gestern", + "newNotePlaceholder": "Neue Notiz…", + "emptyHint": "Noch nichts notiert. Tippen, Enter, fertig.", + "deleteTip": "Notiz löschen", + "fromAgentTip": "Von Claude notiert" }, "session": { "chipLive": "LIVE", @@ -763,7 +765,7 @@ "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, - "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, + "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } }, "ops": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 85ccbab0..fb55c9bd 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -177,7 +177,6 @@ "listSettingsTip": "List settings", "addPlaceholder": "Add a task…", "enterKey": "ENTER", - "notesPinnedRow": "Notes (daily notes)", "clearDayTip": "Clear day", "planMyDayTip": "Plan My Day", "overdue": "OVERDUE", @@ -299,8 +298,11 @@ }, "notes": { "today": "Today", - "add": "Add", - "newNotePlaceholder": "New note…" + "yesterday": "Yesterday", + "newNotePlaceholder": "New note…", + "emptyHint": "Nothing noted yet. Type, hit Enter, done.", + "deleteTip": "Delete note", + "fromAgentTip": "Written by Claude" }, "session": { "chipLive": "LIVE", @@ -763,7 +765,7 @@ "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" }, - "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" }, + "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" }, "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } }, "ops": { diff --git a/src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs b/src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs index f5e121d4..1a94844f 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs @@ -5,6 +5,8 @@ namespace ClaudeDo.Ui.Services.Interfaces; public interface INotesApi { Task> ListAsync(DateOnly day); + /// Both ends inclusive — today plus its history in one call. + Task> ListBetweenAsync(DateOnly start, DateOnly end); Task AddAsync(DateOnly day, string text); Task UpdateAsync(string id, string text); Task DeleteAsync(string id); diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index f49d8bee..049e0376 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -30,6 +30,9 @@ public interface IWorkerClient : INotifyPropertyChanged /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). event Action, string>? HandoffRequestedEvent; + /// An agent added a note through the MCP tool — an open notes editor reloads. + event Action? NotesUpdatedEvent; + event Action? PrepStartedEvent; event Action? PrepLineEvent; event Action? PrepFinishedEvent; @@ -172,6 +175,8 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetAppSettingsAsync(); Task UpdateAppSettingsAsync(AppSettingsDto dto); Task> GetDailyNotesAsync(DateOnly day); + /// Both ends inclusive. + Task> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end); Task AddDailyNoteAsync(DateOnly day, string text); Task UpdateDailyNoteAsync(string id, string text); Task DeleteDailyNoteAsync(string id); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index dfbfc65c..3f5bab8a 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -54,6 +54,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? ListUpdatedEvent; public event Action? WorkerLogReceivedEvent; + public event Action? NotesUpdatedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; public event Action? PrepFinishedEvent; @@ -213,6 +214,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when))); }); + _hub.On("NotesUpdated", () => Dispatcher.UIThread.Post(() => NotesUpdatedEvent?.Invoke())); _hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke())); _hub.On("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line))); _hub.On("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok))); @@ -475,6 +477,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task> GetDailyNotesAsync(DateOnly day) => await TryInvokeAsync>("GetDailyNotes", IsoDay(day)) ?? new List(); + public async Task> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) + => await TryInvokeAsync>("GetDailyNotesBetween", IsoDay(start), IsoDay(end)) ?? new List(); + public async Task AddDailyNoteAsync(DateOnly day, string text) => await InvokeTimedAsync("AddDailyNote", () => _hub.InvokeAsync("AddDailyNote", IsoDay(day), text)); diff --git a/src/ClaudeDo.Ui/Services/WorkerNotesApi.cs b/src/ClaudeDo.Ui/Services/WorkerNotesApi.cs index d63ddec2..276a86bb 100644 --- a/src/ClaudeDo.Ui/Services/WorkerNotesApi.cs +++ b/src/ClaudeDo.Ui/Services/WorkerNotesApi.cs @@ -7,6 +7,7 @@ public sealed class WorkerNotesApi : INotesApi private readonly IWorkerClient _client; public WorkerNotesApi(IWorkerClient client) => _client = client; public Task> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day); + public Task> ListBetweenAsync(DateOnly start, DateOnly end) => _client.GetDailyNotesBetweenAsync(start, end); public Task AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text); public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text); public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index bdcf61e4..77541452 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -20,7 +20,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient _worker; private readonly IServiceProvider _services; - private readonly INotesApi _notesApi; private readonly MergeCoordinator _merge; // ── Section view models ─────────────────────────────────────────────────── @@ -39,29 +38,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable private readonly Action _workerPlanningMergeAbortedHandler; private readonly Action _workerPlanningCompletedHandler; - [ObservableProperty] private bool _isNotesMode; [ObservableProperty] private bool _isPrepMode; - public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; + public bool IsTaskDetailVisible => !IsPrepMode; - /// Centered placeholder shown when no task is selected — hidden in Notes/Prep mode, + /// Centered placeholder shown when no task is selected — hidden in Prep mode, /// where is already false. public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible; - partial void OnIsNotesModeChanged(bool value) - { - OnPropertyChanged(nameof(IsTaskDetailVisible)); - OnPropertyChanged(nameof(IsEmptyStateVisible)); - } - partial void OnIsPrepModeChanged(bool value) { OnPropertyChanged(nameof(IsTaskDetailVisible)); OnPropertyChanged(nameof(IsEmptyStateVisible)); } - public NotesEditorViewModel Notes { get; private set; } = null!; - // Current task row (set by IslandsShellViewModel via Bind) [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] @@ -324,13 +314,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable IDbContextFactory dbFactory, IWorkerClient worker, IServiceProvider services, - INotesApi notesApi, MergeCoordinator merge) { _dbFactory = dbFactory; _worker = worker; _services = services; - _notesApi = notesApi; _merge = merge; Monitor = new TaskMonitorViewModel(dbFactory, worker); @@ -374,8 +362,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged(); }; - Notes = new NotesEditorViewModel(_notesApi); - Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); }; Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); @@ -592,18 +578,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable catch { } } - public void ShowNotes() - { - Bind(null, "notes"); - IsPrepMode = false; - IsNotesMode = true; - _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); - } - public void ShowPrep() { Bind(null, "prep"); - IsNotesMode = false; IsPrepMode = true; _ = Prep.LoadLastPrepLogIfEmptyAsync(); } @@ -612,7 +589,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable /// logged with the bind timing so rebind churn can be attributed to a trigger. public void Bind(TaskRowViewModel? row, string source = "?") { - IsNotesMode = false; IsPrepMode = false; _loadCts?.Cancel(); _loadCts?.Dispose(); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 82ea1643..27f5b6c6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -22,6 +22,10 @@ public sealed record MergeHelperRequest(string ListId, IReadOnlyList Tas public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { + /// The day-log list. A smart list with no tasks in it: selecting it makes the tasks + /// island show the notes editor instead of rows. + public const string NotesListId = "smart:notes"; + private readonly IDbContextFactory _dbFactory; private readonly IServiceProvider? _services; private readonly IWorkerClient? _worker; @@ -255,6 +259,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable "smart:my-day" => "vm.lists.smartMyDay", "smart:important" => "vm.lists.smartImportant", "smart:planned" => "vm.lists.smartPlanned", + NotesListId => "vm.lists.smartNotes", "virtual:queued" => "vm.lists.virtualQueue", "virtual:running" => "vm.lists.virtualRunning", "virtual:review" => "vm.lists.virtualReview", @@ -279,6 +284,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" }, new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" }, new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" }, + new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" }, new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" }, new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" }, new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs index 63db65fe..7f9bcf5c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs @@ -1,25 +1,57 @@ using System.Collections.ObjectModel; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Islands; +/// Date separator between the note groups. Rows carry these inline (like the task list's +/// HeaderRow) so one ItemsControl renders today and the history without a nested control per day. +public sealed class NoteDayHeaderViewModel +{ + public DateOnly Day { get; } + public string Label { get; } + public bool IsToday { get; } + + public NoteDayHeaderViewModel(DateOnly day, DateOnly today) + { + Day = day; + IsToday = day == today; + Label = day == today ? Loc.T("notes.today") + : day == today.AddDays(-1) ? Loc.T("notes.yesterday") + : day.ToString("dddd, dd.MM."); + } +} + public sealed partial class NoteBulletViewModel : ViewModelBase { - public string Id { get; } + /// Empty until the note is persisted — a row the user started but hasn't typed into + /// yet exists only in the list, so an abandoned Enter leaves nothing behind in the database. + public string Id { get; set; } + + public DateOnly Day { get; } + public bool FromAgent { get; } + public bool IsNew => Id.Length == 0; [ObservableProperty] private string _text; - public NoteBulletViewModel(string id, string text) + public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false) { Id = id; + Day = day; _text = text; + FromAgent = fromAgent; } } public sealed partial class NotesEditorViewModel : ViewModelBase { + /// How far the history scrolls back. Older notes stay in the database and in the week + /// report; they are simply not worth the round trip in a day log. + public const int HistoryDays = 14; + private readonly INotesApi _api; public NotesEditorViewModel(INotesApi api) => _api = api; @@ -27,32 +59,58 @@ public sealed partial class NotesEditorViewModel : ViewModelBase // Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync). public event Action? ErrorReported; - public ObservableCollection Bullets { get; } = new(); + /// Raised after a row is inserted so the view can move the caret into it. + public event Action? FocusRequested; + + /// NoteDayHeaderViewModel | NoteBulletViewModel, newest day first. + public ObservableCollection Rows { get; } = new(); - [ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private string _newBulletText = ""; + [ObservableProperty] private bool _isEmpty = true; - public DateTime CurrentDate + public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today); + + /// Loads today plus days of history. + public async Task LoadAsync() { - get => CurrentDay.ToDateTime(TimeOnly.MinValue); - set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); } + Today = DateOnly.FromDateTime(DateTime.Today); + List notes; + try + { + notes = await _api.ListBetweenAsync(Today.AddDays(-HistoryDays), Today); + } + catch (Exception ex) + { + ErrorReported?.Invoke(ex.Message); + return; + } + + Rows.Clear(); + // Today always gets a header, even with nothing under it — it is where typing lands. + Rows.Add(new NoteDayHeaderViewModel(Today, Today)); + foreach (var n in notes.Where(n => Day(n) == Today).OrderBy(n => n.SortOrder)) + Rows.Add(Bullet(n)); + + foreach (var group in notes.Where(n => Day(n) != Today) + .GroupBy(Day).OrderByDescending(g => g.Key)) + { + Rows.Add(new NoteDayHeaderViewModel(group.Key, Today)); + foreach (var n in group.OrderBy(n => n.SortOrder)) + Rows.Add(Bullet(n)); + } + + RefreshEmpty(); } - public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy"); + private static DateOnly Day(DailyNoteDto n) => DateOnly.Parse(n.Date); - public async Task LoadDayAsync(DateOnly day) - { - CurrentDay = day; - OnPropertyChanged(nameof(CurrentDate)); - OnPropertyChanged(nameof(CurrentDayLabel)); - Bullets.Clear(); - foreach (var dto in await _api.ListAsync(day)) - Bullets.Add(MakeBullet(dto.Id, dto.Text)); - } + private static NoteBulletViewModel Bullet(DailyNoteDto n) => + new(n.Id, Day(n), n.Text, n.FromAgent); - private NoteBulletViewModel MakeBullet(string id, string text) => - new(id, text); + private void RefreshEmpty() => IsEmpty = !Rows.OfType().Any(); + /// The box above the list: Enter files the note under today and keeps the box focused, + /// so a burst of notes is one uninterrupted stream of typing. [RelayCommand] private async Task AddBullet() { @@ -60,9 +118,10 @@ public sealed partial class NotesEditorViewModel : ViewModelBase if (text.Length == 0) return; try { - var dto = await _api.AddAsync(CurrentDay, text); - if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); + var dto = await _api.AddAsync(Today, text); + if (dto is not null) Rows.Insert(1, Bullet(dto)); NewBulletText = ""; + RefreshEmpty(); } catch (Exception ex) { @@ -70,21 +129,68 @@ public sealed partial class NotesEditorViewModel : ViewModelBase } } - [RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); - [RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1)); - [RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); + /// Enter inside a note: save it, then open an empty row underneath and focus it. + [RelayCommand] + private async Task SplitBullet(NoteBulletViewModel? b) + { + if (b is null) return; + await CommitBullet(b); + if (!Rows.Contains(b)) return; // committing an emptied note removed it + + var fresh = new NoteBulletViewModel("", b.Day, ""); + Rows.Insert(Rows.IndexOf(b) + 1, fresh); + FocusRequested?.Invoke(fresh); + } + + /// Backspace in an already-empty note: drop it and put the caret in the one above. + [RelayCommand] + private async Task DeleteBullet(NoteBulletViewModel? b) + { + if (b is null) return; + var index = Rows.IndexOf(b); + await RemoveAsync(b); + + for (var i = index - 1; i >= 0; i--) + if (Rows[i] is NoteBulletViewModel previous) + { + FocusRequested?.Invoke(previous); + return; + } + } [RelayCommand] private async Task CommitBullet(NoteBulletViewModel? b) { if (b is null) return; var text = b.Text?.Trim() ?? ""; - if (text.Length == 0) + try { - await _api.DeleteAsync(b.Id); - Bullets.Remove(b); - return; + if (text.Length == 0) + { + await RemoveAsync(b); + return; + } + + if (b.IsNew) + { + var dto = await _api.AddAsync(b.Day, text); + if (dto is not null) b.Id = dto.Id; + } + else + { + await _api.UpdateAsync(b.Id, text); + } } - await _api.UpdateAsync(b.Id, text); + catch (Exception ex) + { + ErrorReported?.Invoke(ex.Message); + } + } + + private async Task RemoveAsync(NoteBulletViewModel b) + { + if (!b.IsNew) await _api.DeleteAsync(b.Id); + Rows.Remove(b); + RefreshEmpty(); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index df72b56b..2a554036 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -9,6 +9,7 @@ using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -62,18 +63,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable public event EventHandler? FocusAddTaskRequested; public event EventHandler? TasksChanged; - public event Action? NotesRequested; public event Action? PrepRequested; public event Action? ErrorReported; public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); - [RelayCommand] - private void OpenNotes() - { - SelectFrom(null, "notes"); - NotesRequested?.Invoke(); - } - [RelayCommand] private void ShowPrepLog() => PrepRequested?.Invoke(); @@ -118,8 +111,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable private bool _hasCompleted; [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = ""; - [ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _isMyDayList; + + /// The notes list is content, not a task list — the island swaps its rows for the + /// notes editor instead of showing an empty task list. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] + private bool _isNotesList; + + /// Null only in tests that construct the island without a notes API. + public NotesEditorViewModel? Notes { get; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] private bool _isLetClaudeVisible; @@ -128,7 +130,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable /// No visible tasks below the add-task row — every item lands in one of /// Overdue/Open/Completed, so all-false here always means the list has zero items. - public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted; + public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted && !IsNotesList; /// Extra empty-state line for a User list with no linked working dir, where the /// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived @@ -182,10 +184,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable private readonly EventHandler _langChangedHandler; - public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) + public TasksIslandViewModel( + IDbContextFactory dbFactory, + IWorkerClient? worker = null, + INotesApi? notesApi = null) { _dbFactory = dbFactory; _worker = worker; + if (notesApi is not null) + { + Notes = new NotesEditorViewModel(notesApi); + Notes.ErrorReported += msg => ErrorReported?.Invoke(msg); + if (worker is not null) + worker.NotesUpdatedEvent += () => { if (IsNotesList) _ = Notes.LoadAsync(); }; + } _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand }; CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); if (_worker is not null) @@ -417,14 +429,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable HasOpen = false; HasCompleted = false; ShowOpenLabel = false; - ShowNotesRow = false; if (listChanged) SelectFrom(null, "list-change"); if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; } HeaderTitle = list.Name; HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); - ShowNotesRow = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day"; + IsNotesList = list.Id == ListsIslandViewModel.NotesListId; + if (IsNotesList && Notes is not null) _ = Notes.LoadAsync(); IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir); IsQuickClaudeVisible = IsLetClaudeVisible; IsMergeReviewsVisible = list.Id == "virtual:review"; diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 00073357..dceb7389 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -340,7 +340,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync(); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource); - Tasks.NotesRequested += () => Details.ShowNotes(); Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.ErrorReported += FlashFooterError; Lists.ErrorReported += FlashFooterError; diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 71a36289..24e16a0d 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -125,17 +125,12 @@ DragCompleted="OnSplitterDragCompleted"/> - + - - - - - diff --git a/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml b/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml index 49a206d6..5f14cce7 100644 --- a/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml @@ -1,38 +1,110 @@ - - - + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs index 9413cff9..6131d445 100644 --- a/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs @@ -1,12 +1,53 @@ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Views.Islands; public partial class NotesEditorView : UserControl { - public NotesEditorView() => InitializeComponent(); + private NotesEditorViewModel? _vm; + + public NotesEditorView() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (_vm is not null) _vm.FocusRequested -= FocusBullet; + _vm = DataContext as NotesEditorViewModel; + if (_vm is not null) _vm.FocusRequested += FocusBullet; + } + + /// Rows the VM just inserted have no container yet — wait for the layout pass that + /// creates it before reaching for the TextBox inside. + private void FocusBullet(NoteBulletViewModel bullet) => + Dispatcher.UIThread.Post( + () => RowsHost.ContainerFromItem(bullet)?.FindDescendantOfType()?.Focus(), + DispatcherPriority.Loaded); + + private void OnBulletKeyDown(object? sender, KeyEventArgs e) + { + if (sender is not TextBox { DataContext: NoteBulletViewModel bullet } box + || DataContext is not NotesEditorViewModel vm) return; + + // Shift+Enter falls through to the TextBox and breaks the line — a note can be several. + if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + e.Handled = true; + vm.SplitBulletCommand.Execute(bullet); + } + else if (e.Key == Key.Back && string.IsNullOrEmpty(box.Text)) + { + e.Handled = true; + vm.DeleteBulletCommand.Execute(bullet); + } + } private void OnBulletLostFocus(object? sender, RoutedEventArgs e) { @@ -15,4 +56,11 @@ public partial class NotesEditorView : UserControl && vm.CommitBulletCommand.CanExecute(bullet)) vm.CommitBulletCommand.Execute(bullet); } + + private void OnDeleteBulletClick(object? sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: NoteBulletViewModel bullet } + && DataContext is NotesEditorViewModel vm) + vm.DeleteBulletCommand.Execute(bullet); + } } diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml index 9e23d51b..d9a8fe58 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml @@ -68,7 +68,8 @@ - + - -