From e0a1448f44aa006008e227c92ca868a9ccf2c912 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 28 Sep 2026 08:56:50 +0200 Subject: [PATCH] feat(ui): Mission-Control-Kacheln behalten Position und Identitaetsfarbe ueber Handoffs Ein Handoff schloss die alte Pane und haengte die neue hinten an -- die Kachel sprang ans Ende und das ganze Grid ordnete sich neu. Die neue Pane wird jetzt am Index der alten eingesetzt. Dazu bekommt jede Kachel eine aus IdentityKey (Default: TaskId) abgeleitete Farbe: 3px-Streifen links am Tile, Punkt im Focus-Mode-Tab. Kein State, keine Persistenz -- derselbe Task faerbt nach App-Neustart gleich, und ein Handoff erbt die Farbe, weil er dieselbe TaskId behaelt. Nur kuehle Hues, damit nichts als Status gelesen wird. --- src/ClaudeDo.Ui/Design/Tokens.axaml | 10 ++++++++ .../MissionControl/ConPtyPaneViewModel.cs | 20 ++++++++++++++++ .../ViewModels/MissionControlViewModel.cs | 11 ++++++--- .../Views/MissionControl/ConPtyPaneView.axaml | 5 ++++ .../MissionControl/MissionControlView.axaml | 6 ++++- .../MissionControlViewModelTests.cs | 24 +++++++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) 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() {