From 5be972564a96abe4ac98127feb658ff72af3025a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 10 Sep 2026 13:18:09 +0200 Subject: [PATCH] feat(ui): mission-control pane title shows usage limit reset time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a list-handler handoff is deferred waiting for the 5h usage limit to reset, the Mission Control pane title is augmented with ⏳ waiting for limit reset (HH:mm) so the wait is visible at a glance. Extends HubBroadcaster.HandoffRequested with an optional deferredUntil DateTime parameter, wired through SignalR to MissionControlViewModel, which formats it into the pane title via Loc.T(missionControl.waitingForLimitReset). --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Services/Interfaces/IWorkerClient.cs | 10 ++++-- src/ClaudeDo.Ui/Services/WorkerClient.cs | 12 +++++-- .../ViewModels/MissionControlViewModel.cs | 21 ++++++++++-- .../External/HandoffMcpTools.cs | 2 ++ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 5 +++ .../MissionControlViewModelTests.cs | 32 +++++++++++++++++++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 ++ 9 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/MissionControlViewModelTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index ba68ab2f..d61092fa 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -349,6 +349,7 @@ "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyStarting": "Sitzung wird gestartet…", "usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.", + "deferredHandoffTitleSuffix": "warte auf Limit-Zurücksetzen ({0})", "mergeHelperTitle": "Merge-Helfer", "mergeHelperHandoffTitleSuffix": " (Übergabe)", "mergeHelperTaskTitle": "Listen-Handler: {0}", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8affc2b4..0b410282 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -349,6 +349,7 @@ "conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyStarting": "Starting session…", "usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.", + "deferredHandoffTitleSuffix": "waiting for limit reset ({0})", "mergeHelperTitle": "Merge Helper", "mergeHelperHandoffTitleSuffix": " (Handoff)", "mergeHelperTaskTitle": "List handler: {0}", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index fdb3058e..674335c3 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -26,9 +26,10 @@ public interface IWorkerClient : INotifyPropertyChanged event Action? TaskQuestionResolvedEvent; /// A running list-handler session called handoff_list_handler at the end of a phase: - /// (handlerTaskId, survivingTaskIds, nextPhase). The UI opens a second ConPTY tile for the same - /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). - event Action, string>? HandoffRequestedEvent; + /// (handlerTaskId, survivingTaskIds, nextPhase, deferredUntil). The UI opens a second ConPTY tile + /// for the same task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). + /// deferredUntil hints when the session is suspended waiting for the usage limit to reset. + event Action, string, DateTime?>? HandoffRequestedEvent; /// start_list_handler was called through the external MCP: (listId, taskIds). The UI /// starts a list-handler run for them exactly like the "Let Claude handle it" button. @@ -37,6 +38,9 @@ public interface IWorkerClient : INotifyPropertyChanged /// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so /// the session can hand off cleanly; no pane for the task means nothing happens. event Action? HandlerUsageWarningEvent; + /// A list-handler handoff is deferred because the 5h usage window is exhausted: + /// (handlerTaskId, dueAt). Mission Control updates the pane title to show the reset time. + event Action? HandlerHandoffDeferredEvent; /// An agent added a note through the MCP tool — an open notes editor reloads. event Action? NotesUpdatedEvent; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index a28a452e..0c63923d 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -48,9 +48,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskUpdatedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; - public event Action, string>? HandoffRequestedEvent; + public event Action, string, DateTime?>? HandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; + public event Action? HandlerHandoffDeferredEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -156,9 +157,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId)); }); - _hub.On, string>("HandoffRequested", (taskId, survivingTaskIds, nextPhase) => + _hub.On, string, DateTime?>("HandoffRequested", (taskId, survivingTaskIds, nextPhase, deferredUntil) => { - Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); + Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase, deferredUntil)); }); _hub.On>("ListHandlerStartRequested", (listId, taskIds) => @@ -171,6 +172,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message)); }); + _hub.On("HandlerHandoffDeferred", (taskId, dueAt) => + { + Dispatcher.UIThread.Post(() => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt)); + }); + _hub.On("WorktreeUpdated", taskId => { Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 11ec8819..90350c28 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -20,9 +20,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; - private readonly Action, string> _onHandoffRequested; + private readonly Action, string, DateTime?> _onHandoffRequested; private readonly Action> _onListHandlerStartRequested; private readonly Action _onHandlerUsageWarning; + private readonly Action _onHandlerHandoffDeferred; // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. @@ -106,13 +107,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onConnectionRestored = () => { _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; - _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; + _onHandoffRequested = (taskId, survivingTaskIds, nextPhase, deferredUntil) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase, deferredUntil); }; _worker.HandoffRequestedEvent += _onHandoffRequested; _onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); }; _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; _onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); }; _worker.HandlerUsageWarningEvent += _onHandlerUsageWarning; + _onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt); + _worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred; _ = RefreshQueueAsync(); _ = RefreshSessionTargetsAsync(); @@ -403,7 +406,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable // FirstOrDefault. No new task is created here; see // InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync. public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync( - string taskId, IReadOnlyList survivingTaskIds, string nextPhase) + string taskId, IReadOnlyList survivingTaskIds, string nextPhase, DateTime? deferredUntil = null) { if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return; OpenRequested?.Invoke(); @@ -444,6 +447,17 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered")); } + // A handler session's handoff is deferred due to usage limits: update the pane title to show + // the reset time. No pane = nothing to update, not an error. + private void UpdateHandlerSessionTitleAsync(string taskId, DateTimeOffset dueAt) + { + if (string.IsNullOrEmpty(taskId)) return; + if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return; + + var suffix = Loc.T("missionControl.deferredHandoffTitleSuffix", dueAt.LocalDateTime.ToString("HH:mm")); + pane.DisplayTitle += $" — {suffix}"; + } + // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec, // so the tile is on screen (spinner running) while the worker is still preparing the worktree. private void AddConPtyPane(ConPtyPaneViewModel pane) @@ -596,6 +610,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.HandoffRequestedEvent -= _onHandoffRequested; _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; _worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning; + _worker.HandlerHandoffDeferredEvent -= _onHandlerHandoffDeferred; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; Panes.CollectionChanged -= OnPanesChanged; foreach (var c in ConPtySessions.ToList()) diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 19693bf3..4bc1b0ab 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -92,6 +92,8 @@ public sealed class HandoffMcpTools taskId, survivingTaskIds, round.EffectivePhase, cancellationToken); if (deferredUntil is null) await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase); + else + await _broadcaster.HandlerHandoffDeferred(taskId, deferredUntil.Value); return new HandoffListHandlerResult( true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil); diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index e5b5f489..874bc682 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -47,6 +47,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task HandlerUsageWarning(string handlerTaskId, string message) => _hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message); + // A list-handler handoff is deferred because the 5h usage window is exhausted. Mission Control + // updates the pane title to show the reset time, so the user sees when the session will resume. + public Task HandlerHandoffDeferred(string handlerTaskId, DateTimeOffset dueAt) => + _hub.Clients.All.SendAsync("HandlerHandoffDeferred", handlerTaskId, dueAt); + /// An agent wrote a note through MCP — the open notes editor reloads. public Task NotesUpdated() => _hub.Clients.All.SendAsync("NotesUpdated"); diff --git a/tests/ClaudeDo.Ui.Tests/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/MissionControlViewModelTests.cs new file mode 100644 index 00000000..82f0fcde --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/MissionControlViewModelTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.MissionControl; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class MissionControlViewModelTests +{ + [Fact] + public void DeferredHandoffUpdatesConPtyPaneTitleWithResetTime() + { + var taskId = Guid.NewGuid().ToString(); + var title = "Test Handler"; + var dueAt = new DateTimeOffset(2026, 9, 10, 14, 30, 0, TimeSpan.Zero); + + var pane = new ConPtyPaneViewModel(taskId, title, () => Task.FromResult( + new TerminalLaunchDescriptor(".", "claude", Array.Empty(), new Dictionary()))); + + var originalTitle = pane.DisplayTitle; + + // Simulate the update that would happen on HandlerHandoffDeferredEvent + var suffix = $"waiting for limit reset ({dueAt.LocalDateTime:HH:mm})"; + pane.DisplayTitle += $" — {suffix}"; + + Assert.NotEqual(originalTitle, pane.DisplayTitle); + Assert.Contains("waiting for limit reset", pane.DisplayTitle); + Assert.Contains(dueAt.LocalDateTime.ToString("HH:mm"), pane.DisplayTitle); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 21de02b8..2471fa56 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -28,6 +28,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action, string>? HandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; + public event Action? HandlerHandoffDeferredEvent; public event Action? NotesUpdatedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; @@ -61,6 +62,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseListHandlerStartRequested(string listId, IReadOnlyList taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds); public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message); + public void RaiseHandlerHandoffDeferred(string taskId, DateTimeOffset dueAt) => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt); public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);