diff --git a/src/ClaudeDo.Ui/Design/Tokens.axaml b/src/ClaudeDo.Ui/Design/Tokens.axaml
index 85d85091..21502cf9 100644
--- a/src/ClaudeDo.Ui/Design/Tokens.axaml
+++ b/src/ClaudeDo.Ui/Design/Tokens.axaml
@@ -104,6 +104,16 @@
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
index abe55406..7ce7b88d 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
@@ -19,6 +19,25 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
+ /// Stable key behind . Defaults to the task id, so a
+ /// handoff — same task, new pane — keeps its colour for free; an ad-hoc pane gets a fresh one.
+ /// Settable so a handoff that mints a new key can inherit the outgoing pane's.
+ public string IdentityKey { get; init; }
+
+ /// Resource key of this tile's identity colour (via DotBrushConverter). Derived from
+ /// , never stored — the colour survives restarts without persistence.
+ public string IdentityColorKey => $"Identity{StableIndex(IdentityKey, 6) + 1}";
+
+ // FNV-1a, not string.GetHashCode: the latter is randomized per process, so the same task would
+ // change colour on every app start.
+ private static int StableIndex(string value, int buckets)
+ {
+ uint hash = 2166136261;
+ foreach (var c in value)
+ hash = (hash ^ c) * 16777619;
+ return (int)(hash % (uint)buckets);
+ }
+
/// Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
/// : the host sets it once right after construction, before the
@@ -70,6 +89,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
Func> descriptorFactory)
{
TaskId = taskId;
+ IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Subscribe(Terminal);
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
index 91ef739c..e720d79c 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
@@ -408,6 +408,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
OpenRequested?.Invoke();
+ // Keep the tile where it was: the incoming phase re-enters at the outgoing pane's slot
+ // instead of being appended, so a handoff doesn't reshuffle every other tile in the grid.
+ var slot = ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } first
+ ? ConPtySessions.IndexOf(first)
+ : (int?)null;
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
CloseConPtySession(stale);
@@ -428,7 +433,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
- { IsListHandler = true });
+ { IsListHandler = true }, slot);
}
// The 5h usage window is about to run out under this handler session: type the worker's warning
@@ -446,14 +451,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// 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)
+ private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
{
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += OnPaneCloseRequested;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun;
- ConPtySessions.Add(pane);
+ ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
pane.Start();
}
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
index abf6854e..0ad5f5c7 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
+++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
@@ -4,11 +4,16 @@
xmlns:views="using:ClaudeDo.Ui.Views"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:Avalonia.Data.Converters"
+ xmlns:cconv="using:ClaudeDo.Ui.Converters"
x:DataType="vm:ConPtyPaneViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
+
+
+
-
+
+
+
+
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
index 67be7570..24005a78 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
@@ -747,6 +747,30 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
}
+ // The handoff replaces a tile in place -- appending it would push it to the end of the grid and
+ // reshuffle every other tile on every phase change.
+ [Fact]
+ public async Task OpenMergeHelperHandoffConPtySessionAsync_KeepsPanePosition()
+ {
+ var worker = new FakeWorker();
+ using var vm = BuildVm(worker);
+ var dir = Path.Combine("C:", "Some", "Path");
+ await vm.OpenAdHocConPtySessionAsync(dir);
+ await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
+ await vm.OpenAdHocConPtySessionAsync(dir);
+ var taskId = vm.ConPtySessions[1].TaskId!;
+ var color = vm.ConPtySessions[1].IdentityColorKey;
+
+ await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
+
+ Assert.Equal(3, vm.ConPtySessions.Count);
+ Assert.Equal(taskId, vm.ConPtySessions[1].TaskId);
+ Assert.Equal(taskId, vm.Panes[1].TaskId);
+ // Same task, new pane -- the identity colour has to survive the handoff, or the tile the
+ // user was tracking changes colour mid-run.
+ Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
+ }
+
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
{