feat(ui): mission-control pane title shows usage limit reset time

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).
This commit is contained in:
mika kuns
2026-09-10 13:18:09 +02:00
parent 1177463a82
commit 5be972564a
9 changed files with 77 additions and 9 deletions
@@ -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}",
@@ -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}",
@@ -26,9 +26,10 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, string>? TaskQuestionResolvedEvent;
/// <summary>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").</summary>
event Action<string, IReadOnlyList<string>, 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.</summary>
event Action<string, IReadOnlyList<string>, string, DateTime?>? HandoffRequestedEvent;
/// <summary>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.</summary>
@@ -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.</summary>
event Action<string, string>? HandlerUsageWarningEvent;
/// <summary>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.</summary>
event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
+9 -3
View File
@@ -48,9 +48,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>, string, DateTime?>? HandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -156,9 +157,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string, IReadOnlyList<string>, string>("HandoffRequested", (taskId, survivingTaskIds, nextPhase) =>
_hub.On<string, IReadOnlyList<string>, 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<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
@@ -171,6 +172,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
});
_hub.On<string, DateTimeOffset>("HandlerHandoffDeferred", (taskId, dueAt) =>
{
Dispatcher.UIThread.Post(() => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -20,9 +20,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>, string, DateTime?> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
private readonly Action<string, string> _onHandlerUsageWarning;
private readonly Action<string, DateTimeOffset> _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<string> survivingTaskIds, string nextPhase)
string taskId, IReadOnlyList<string> 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())
+2
View File
@@ -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);
@@ -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);
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
public Task NotesUpdated() =>
_hub.Clients.All.SendAsync("NotesUpdated");
@@ -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<string>(), new Dictionary<string, string>())));
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);
}
}
@@ -28,6 +28,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
@@ -61,6 +62,7 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> 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<string> 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);