diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 3c01c959..195d61e7 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -160,7 +160,8 @@
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen",
- "refineTip": "Aufgabe mit Claude verfeinern"
+ "refineTip": "Aufgabe mit Claude verfeinern",
+ "refiningTip": "Claude verfeinert diese Aufgabe…"
},
"lists": {
"heading": "Listen",
@@ -267,6 +268,7 @@
"overviewMode": "Übersicht",
"closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
+ "conptyStarting": "Sitzung wird gestartet…",
"mergeHelperTitle": "Merge-Helfer",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
"submitForReview": "Zum Review einreichen",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 5979f9e2..251147da 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -160,7 +160,8 @@
"feedbackLabel": "FEEDBACK FOR THE AGENT",
"feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run",
- "refineTip": "Refine this task with Claude"
+ "refineTip": "Refine this task with Claude",
+ "refiningTip": "Claude is refining this task…"
},
"lists": {
"heading": "Lists",
@@ -267,6 +268,7 @@
"overviewMode": "Overview",
"closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
+ "conptyStarting": "Starting session…",
"mergeHelperTitle": "Merge Helper",
"submitForReviewFailed": "Couldn't submit for review: {0}",
"submitForReview": "Submit for review",
diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
index 8310bce7..3169b4e9 100644
--- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml
+++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
@@ -454,6 +454,25 @@
+
+
+
+
diff --git a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
index 6b335800..60813126 100644
--- a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
@@ -22,6 +22,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
[ObservableProperty] private int? _exitCode;
[ObservableProperty] private string? _startError;
+ /// True from construction until the child process is actually launched (or the launch
+ /// failed) — covers both the caller's launch-spec roundtrip and the ConPTY spawn, so the host
+ /// can show a spinner instead of an empty black pane.
+ public bool IsStarting => !IsRunning && !HasExited && StartError is null;
+
+ partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
+ partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
+ partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
+
public InteractiveTerminalViewModel()
{
_session.ProcessExited += OnSessionProcessExited;
@@ -74,6 +83,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
ExitCode = exitCode;
}
+ /// Reports a failure that happened before could be called (e.g. the
+ /// launch-spec roundtrip threw), so it surfaces through the same banner as a spawn failure.
+ public void ReportStartFailure(string message)
+ {
+ IsRunning = false;
+ HasExited = true;
+ StartError = message;
+ }
+
public void Kill() => _session.Kill();
public void Dispose()
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
index 40125658..23257b72 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
@@ -35,18 +35,44 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
public event Action? SubmitForReviewRequested;
/// Task-based pane — dedup'd by . Pass null for an ad-hoc pane
- /// (no task, never deduped); prefer at ad-hoc call sites.
- public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
+ /// (no task, never deduped); prefer at ad-hoc call sites.
+ /// is resolved by the pane itself (in )
+ /// so the tile — and its starting spinner — is visible while the launch spec is still being
+ /// fetched. The host must wire its handlers and then call .
+ public ConPtyPaneViewModel(
+ string? taskId,
+ string displayTitle,
+ Func> descriptorFactory)
{
TaskId = taskId;
_displayTitle = displayTitle;
+ _descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged;
- Terminal.Start(descriptor);
+ }
+
+ private readonly Func> _descriptorFactory;
+
+ /// Resolves the launch spec and spawns the session. Call after wiring
+ /// so a failed launch is not swallowed.
+ public void Start() => _ = StartAsync();
+
+ private async System.Threading.Tasks.Task StartAsync()
+ {
+ try
+ {
+ Terminal.Start(await _descriptorFactory());
+ }
+ catch (Exception ex)
+ {
+ Terminal.ReportStartFailure(ex.Message);
+ }
}
/// Ad-hoc pane — no task, no dedup.
- public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
- => new(null, displayTitle, descriptor);
+ public static ConPtyPaneViewModel CreateAdHoc(
+ string displayTitle,
+ Func> descriptorFactory)
+ => new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
index 5792365b..650bf90b 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
@@ -241,20 +241,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
catch { /* best-effort title lookup */ }
- try
- {
- var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
- var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
- var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
- pane.ErrorReported += OnConPtyPaneError;
- pane.CloseRequested += CloseConPtySession;
- pane.SubmitForReviewRequested += OnPaneSubmitForReview;
- ConPtySessions.Add(pane);
- }
- catch (Exception ex)
- {
- ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
- }
+ AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
+ () => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
}
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
@@ -279,47 +267,23 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort title lookup */ }
title += Loc.T("missionControl.planningTitleSuffix");
- try
- {
- var spec = resume
- ? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
- : await _worker.GetPlanningStartLaunchSpecAsync(taskId);
- var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
- var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
- pane.ErrorReported += OnConPtyPaneError;
- pane.CloseRequested += CloseConPtySession;
- pane.SubmitForReviewRequested += OnPaneSubmitForReview;
- ConPtySessions.Add(pane);
- }
- catch (Exception ex)
- {
- ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
- }
+ AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
+ ? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
+ : _worker.GetPlanningStartLaunchSpecAsync(taskId))));
}
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
- public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
+ public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
{
- if (string.IsNullOrEmpty(directory)) return;
+ if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(title)) title = directory;
- try
- {
- var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
- var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
- var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
- pane.ErrorReported += OnConPtyPaneError;
- pane.CloseRequested += CloseConPtySession;
- pane.SubmitForReviewRequested += OnPaneSubmitForReview;
- ConPtySessions.Add(pane);
- }
- catch (Exception ex)
- {
- ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
- }
+ AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
+ () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
+ return System.Threading.Tasks.Task.CompletedTask;
}
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
@@ -337,23 +301,30 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
catch { /* best-effort title lookup */ }
- try
- {
- var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId);
- var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
- var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
- pane.ErrorReported += OnConPtyPaneError;
- pane.CloseRequested += CloseConPtySession;
- pane.SubmitForReviewRequested += OnPaneSubmitForReview;
- ConPtySessions.Add(pane);
- }
- catch (Exception ex)
- {
- ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
- }
+ AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
+ () => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
}
- private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
+ // 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)
+ {
+ pane.ErrorReported += OnConPtyPaneError;
+ pane.CloseRequested += CloseConPtySession;
+ pane.SubmitForReviewRequested += OnPaneSubmitForReview;
+ ConPtySessions.Add(pane);
+ pane.Start();
+ }
+
+ private static async System.Threading.Tasks.Task DescribeAsync(
+ Func> fetch)
+ {
+ var spec = await fetch();
+ return new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
+ }
+
+ private void OnConPtyPaneError(string message)
+ => ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", message));
// 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.
diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
index db880640..4329eef1 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
@@ -207,17 +207,23 @@
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
index 26cedbd5..e82a42d3 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
@@ -298,7 +298,7 @@ public class MissionControlViewModelTests : IDisposable
}
[Fact]
- public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
+ public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{
var worker = new ThrowingLaunchSpecWorker();
using var vm = BuildVm(worker);
@@ -307,8 +307,10 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenConPtySessionAsync("t1");
- Assert.Empty(vm.ConPtySessions);
- Assert.Empty(vm.Panes);
+ // The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
+ var pane = Assert.Single(vm.ConPtySessions);
+ Assert.NotNull(pane.Terminal.StartError);
+ Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error);
}
@@ -378,7 +380,7 @@ public class MissionControlViewModelTests : IDisposable
}
[Fact]
- public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
+ public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{
var worker = new ThrowingAdHocLaunchSpecWorker();
using var vm = BuildVm(worker);
@@ -387,8 +389,9 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
- Assert.Empty(vm.ConPtySessions);
- Assert.Empty(vm.Panes);
+ var pane = Assert.Single(vm.ConPtySessions);
+ Assert.NotNull(pane.Terminal.StartError);
+ Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error);
}
@@ -451,7 +454,7 @@ public class MissionControlViewModelTests : IDisposable
}
[Fact]
- public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
+ public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{
var worker = new ThrowingMergeHelperLaunchSpecWorker();
using var vm = BuildVm(worker);
@@ -460,8 +463,9 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
- Assert.Empty(vm.ConPtySessions);
- Assert.Empty(vm.Panes);
+ var pane = Assert.Single(vm.ConPtySessions);
+ Assert.NotNull(pane.Terminal.StartError);
+ Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error);
}