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.
This commit is contained in:
mika kuns
2026-09-28 08:56:50 +02:00
parent c9dd885fe0
commit e0a1448f44
6 changed files with 72 additions and 4 deletions
@@ -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;
/// <summary>Stable key behind <see cref="IdentityColorKey"/>. 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.</summary>
public string IdentityKey { get; init; }
/// <summary>Resource key of this tile's identity colour (via DotBrushConverter). Derived from
/// <see cref="IdentityKey"/>, never stored — the colour survives restarts without persistence.</summary>
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);
}
/// <summary>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
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
@@ -70,6 +89,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
{
TaskId = taskId;
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Subscribe(Terminal);
@@ -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();
}