diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 1a5b941d..31281061 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -393,6 +393,7 @@ "conptyExited": "Sitzung beendet (Code {0}).", "usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.", "terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet 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 e0bc986a..fa7467cb 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -393,6 +393,7 @@ "conptyExited": "Session ended (code {0}).", "usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.", "terminalSendFailed": "Couldn't send that to the terminal session.", + "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 6286b29b..fb95bfa3 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -41,6 +41,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 ea14431a..3b499133 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -52,6 +52,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? SessionHandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; + public event Action? HandlerHandoffDeferredEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -176,6 +177,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 9387e6a6..42899d82 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -25,6 +25,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action, string> _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. @@ -122,6 +123,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; _onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); }; _worker.HandlerUsageWarningEvent += _onHandlerUsageWarning; + _onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt); + _worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred; _ = RefreshQueueAsync(); _ = RefreshSessionTargetsAsync(); @@ -556,6 +559,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, int? index = null) @@ -725,6 +739,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.SessionHandoffRequestedEvent -= OpenSessionHandoff; _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 d6e3657f..e17ed39e 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -52,6 +52,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 11c874b4..f71d03e7 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -29,6 +29,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? SessionHandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; + public event Action? HandlerHandoffDeferredEvent; public event Action? NotesUpdatedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; @@ -62,6 +63,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath); 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);