diff --git a/docs/explore-notes/conpty-sessions.md b/docs/explore-notes/conpty-sessions.md index a6370d06..3be3e726 100644 --- a/docs/explore-notes/conpty-sessions.md +++ b/docs/explore-notes/conpty-sessions.md @@ -94,6 +94,15 @@ the host wires handlers and then calls `Start()`. So the tile appears **immediat spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an inline error banner instead of the tile never appearing. +`SubmitForReviewCommand.CanExecute` also gates on `Terminal.IsStarting` / `StartError` / +`HasExited` (not just `IsTaskBased`) — a starting or dead pane can't offer a review it would only +have the worker reject, and `MissionControlViewModel.OnPaneSubmitForReview` sets the pane's +`IsSubmitPending` flag for the duration of the round trip so a rapid double-click can't race two +`SubmitTaskForReviewAsync` calls. A failed launch also offers `RetryCommand` (visible whenever +`HasExited && StartError != null`) — it swaps in a fresh `InteractiveTerminalViewModel` and calls +`Start()` again on the **same** pane/`TaskId` dedupe slot, since `PtyTerminalSession` throws on a +second `StartAsync` call and can't be restarted in place. + ### ⚠️ Gotcha: the terminal library kills its child on visual-tree detach `Iciclecreek.Avalonia.Terminal`'s `TerminalView.OnDetachedFromLogicalTree` calls diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 3c73a3da..db89f2cf 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -285,6 +285,8 @@ "submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}", "submitForReview": "Zum Review einreichen", "submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann", + "retry": "Erneut versuchen", + "retryTip": "Diese Sitzung erneut starten", "planningTitleSuffix": " (Planung)", "question": { "title": "Claude fragt nach", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index e2e0a6d2..f80c06e0 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -285,6 +285,8 @@ "submitForReviewFailed": "Couldn't submit for review: {0}", "submitForReview": "Submit for review", "submitForReviewTip": "Commit this worktree and move the task to review so it can be merged", + "retry": "Retry", + "retryTip": "Try launching this session again", "planningTitleSuffix": " (Planning)", "question": { "title": "Claude is asking", diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 6886dc27..06b0f55f 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -473,7 +473,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private void SyncInteractiveSessionChips() { - if (MissionControl is null) return; + if (MissionControl is null || Tasks is null) return; Tasks.SyncInteractiveSessions( MissionControl.ConPtySessions .Select(s => s.TaskId) diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs index 12d68113..1aa94df9 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -20,7 +20,11 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl [ObservableProperty] private string _displayTitle; - public InteractiveTerminalViewModel Terminal { get; } = new(); + [ObservableProperty] private InteractiveTerminalViewModel _terminal = new(); + + /// Set by the host (Mission Control) while a submit-for-review round trip is in + /// flight, so a rapid double-click can't race two submissions for the same task. + [ObservableProperty] private bool _isSubmitPending; /// Raised when the terminal failed to start — the host surfaces this via the footer error strip. public event Action? ErrorReported; @@ -76,12 +80,21 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl { if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error) ErrorReported?.Invoke(error); + + if (e.PropertyName is nameof(InteractiveTerminalViewModel.IsStarting) + or nameof(InteractiveTerminalViewModel.StartError) + or nameof(InteractiveTerminalViewModel.HasExited)) + { + SubmitForReviewCommand.NotifyCanExecuteChanged(); + RetryCommand.NotifyCanExecuteChanged(); + } } [RelayCommand] private void Close() => CloseRequested?.Invoke(this); - private bool CanSubmitForReview() => IsTaskBased; + private bool CanSubmitForReview() => + IsTaskBased && !IsSubmitPending && !Terminal.IsStarting && Terminal.StartError is null && !Terminal.HasExited; [RelayCommand(CanExecute = nameof(CanSubmitForReview))] private void SubmitForReview() @@ -89,6 +102,22 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id); } + partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged(); + + // A launch failure permanently occupies the TaskId dedupe slot unless the user can retry — + // re-opening the same task would otherwise just re-focus a dead tile. + private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null; + + [RelayCommand(CanExecute = nameof(CanRetry))] + private void Retry() + { + Terminal.PropertyChanged -= OnTerminalPropertyChanged; + Terminal.Dispose(); + Terminal = new InteractiveTerminalViewModel(); + Terminal.PropertyChanged += OnTerminalPropertyChanged; + Start(); + } + public void Dispose() { Terminal.PropertyChanged -= OnTerminalPropertyChanged; diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index c25d2e7a..68f5d273 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -302,16 +302,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable // Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive // session is finished). The worker commits the worktree and moves the task to WaitingForReview. + // Guarded by the pane's IsSubmitPending flag — a rapid double-click would otherwise race two + // SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error. private async void OnPaneSubmitForReview(string taskId) { + if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending) + return; + + pane.IsSubmitPending = true; try { await _worker.SubmitTaskForReviewAsync(taskId); - if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane) - CloseConPtySession(pane); + CloseConPtySession(pane); } catch (Exception ex) { + pane.IsSubmitPending = false; ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message)); } } @@ -321,6 +327,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable if (!ConPtySessions.Contains(pane)) return; pane.ErrorReported -= OnConPtyPaneError; pane.CloseRequested -= CloseConPtySession; + pane.SubmitForReviewRequested -= OnPaneSubmitForReview; ConPtySessions.Remove(pane); pane.Dispose(); } @@ -370,6 +377,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable { c.ErrorReported -= OnConPtyPaneError; c.CloseRequested -= CloseConPtySession; + c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.Dispose(); } ConPtySessions.Clear(); diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml index 32d6727f..649f5d19 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml @@ -39,9 +39,15 @@ Background="{DynamicResource ErrorTintBrush}" BorderBrush="{DynamicResource BloodBrush}" BorderThickness="0,0,0,1" Padding="12,6"> - + + +