From 5a21d673c1535801fb79b722ac52eb331bb120d6 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Thu, 25 Jun 2026 14:48:40 +0200 Subject: [PATCH] feat(ui): reveal a task by id from anywhere --- src/ClaudeDo.App/Program.cs | 2 + .../Islands/TaskMonitorViewModel.cs | 11 +++ .../Islands/TasksIslandViewModel.cs | 18 +++- .../ViewModels/IslandsShellViewModel.cs | 30 ++++++ .../ViewModels/MissionControlViewModel.cs | 12 +++ .../MissionControlViewModelTests.cs | 16 ++++ .../ViewModels/TasksIslandSelectByIdTests.cs | 91 +++++++++++++++++++ 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandSelectByIdTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 2575faa..7951f69 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -168,6 +168,8 @@ sealed class Program shell.ConflictResolverFactory = sp.GetRequiredService>(); sp.GetRequiredService().Handler = shell.RequestConflictResolutionAsync; + var missionControl = sp.GetRequiredService(); + missionControl.OpenInApp = id => _ = shell.RevealTaskAsync(id); return shell; }); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index d4a8486..d06f69a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -1,6 +1,7 @@ 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; @@ -131,6 +132,16 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable Roadblocks = null; } + // Set by the host (e.g. Mission Control) to navigate the main app to this task. + public Action? OpenInAppRequested { get; set; } + + [RelayCommand] + private void OpenInApp() + { + if (!string.IsNullOrEmpty(_subscribedTaskId)) + OpenInAppRequested?.Invoke(_subscribedTaskId); + } + public void SetTaskId(string id) => _subscribedTaskId = id; public void ApplyState(ClaudeDo.Data.Models.TaskStatus status) => diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index fa40b2d..dc98b1d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -70,6 +70,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _isMyDayList; + internal Task? LoadTask { get; private set; } + public Func? ShowUnfinishedPlanningModal { get; set; } private readonly EventHandler _langChangedHandler; @@ -220,14 +222,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable HasCompleted = false; ShowOpenLabel = false; ShowNotesRow = false; - if (list is null) return; + if (list is null) { 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"; - _ = LoadForListAsync(list, ct); + LoadTask = LoadForListAsync(list, ct); } private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct) @@ -778,6 +780,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [RelayCommand] private void Select(TaskRowViewModel row) => SelectedTask = row; + public async System.Threading.Tasks.Task SelectByIdAsync(string taskId) + { + if (LoadTask is { } lt) + { + try { await lt; } catch { /* load cancelled/failed — fall through */ } + } + var row = Items.FirstOrDefault(r => r.Id == taskId); + if (row is null) return false; + SelectedTask = row; + return true; + } + [RelayCommand] private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted; diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 7a5d70d..bd6342c 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -43,6 +43,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable // Layer C seam: composition root sets the factory; the dialog service shows the resolver. public Func? ConflictResolverFactory { get; set; } + // Set by MainWindow so a reveal can bring the main window to the foreground. + public Action? BringToFront { get; set; } + // Single dialog seam (set by MainWindow); propagated to the lists island. private IDialogService? _dialogs; public IDialogService? Dialogs @@ -55,6 +58,33 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable } } + public async Task RevealTaskAsync(string taskId) + { + if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; } + + string? listId = null; + if (_dbFactory is not null) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); + listId = entity?.ListId; + } + catch { /* best-effort list resolution */ } + } + + if (listId is not null) + { + var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}"); + if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem)) + Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList + } + + await Tasks.SelectByIdAsync(taskId); + BringToFront?.Invoke(); + } + public async Task RequestConflictResolutionAsync(string taskId, string targetBranch) { if (ConflictResolverFactory is null || Dialogs is null) return; diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 67c3c43..570fd9e 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -22,6 +22,17 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable [ObservableProperty] private int _columnCount = 1; + private Action? _openInApp; + public Action? OpenInApp + { + get => _openInApp; + set + { + _openInApp = value; + foreach (var m in Monitors) m.OpenInAppRequested = value; + } + } + public bool HasMonitors => Monitors.Count > 0; public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker) @@ -53,6 +64,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable var monitor = new TaskMonitorViewModel(_dbFactory, _worker); monitor.SetTaskId(taskId); + monitor.OpenInAppRequested = _openInApp; Monitors.Add(monitor); _ = HydrateAsync(monitor, taskId); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index e5b8440..01fb797 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -114,4 +114,20 @@ public class MissionControlViewModelTests : IDisposable public override IReadOnlyList GetActiveTasks() => new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) }; } + + [Fact] + public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + string? revealed = null; + vm.OpenInApp = id => revealed = id; + + worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + + vm.Monitors[0].OpenInAppCommand.Execute(null); + + Assert.Equal("t1", revealed); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandSelectByIdTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandSelectByIdTests.cs new file mode 100644 index 0000000..db3021f --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandSelectByIdTests.cs @@ -0,0 +1,91 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class TasksIslandSelectByIdTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandSelectByIdTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_selectbyid_{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 async Task SeedAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity + { + Id = "L1", + Name = "Work", + CreatedAt = DateTime.UtcNow, + }); + db.Tasks.Add(new TaskEntity + { + Id = "T1", + ListId = "L1", + Title = "My task", + Status = TaskStatus.Idle, + ParentTaskId = null, + CreatedAt = DateTime.UtcNow, + SortOrder = 0, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task SelectByIdAsync_ReturnsTrue_AndSetsSelectedTask() + { + await SeedAsync(); + var factory = new TestDbFactory(NewContext); + var vm = new TasksIslandViewModel(factory, worker: null); + + vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User }); + var found = await vm.SelectByIdAsync("T1"); + + Assert.True(found); + Assert.Equal("T1", vm.SelectedTask?.Id); + } + + [Fact] + public async Task SelectByIdAsync_MissingId_ReturnsFalse_AndLeavesSelectedTaskNull() + { + await SeedAsync(); + var factory = new TestDbFactory(NewContext); + var vm = new TasksIslandViewModel(factory, worker: null); + + vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User }); + var found = await vm.SelectByIdAsync("missing"); + + Assert.False(found); + Assert.Null(vm.SelectedTask); + } +}