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:
@@ -104,6 +104,16 @@
|
|||||||
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
|
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
|
||||||
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
|
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
|
||||||
|
|
||||||
|
<!-- Mission Control tile identity colours. Cool hues only, on purpose: the warm/green half of
|
||||||
|
the wheel is taken by the status semantics above, and an identity strip must never read as
|
||||||
|
"running"/"review"/"failed". -->
|
||||||
|
<SolidColorBrush x:Key="Identity1Brush" Color="#5FA8C8" />
|
||||||
|
<SolidColorBrush x:Key="Identity2Brush" Color="#4FA396" />
|
||||||
|
<SolidColorBrush x:Key="Identity3Brush" Color="#7E88CC" />
|
||||||
|
<SolidColorBrush x:Key="Identity4Brush" Color="#A57BB5" />
|
||||||
|
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
|
||||||
|
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
|
||||||
|
|
||||||
<!-- Merge editor (3-pane conflict resolver) block tints -->
|
<!-- Merge editor (3-pane conflict resolver) block tints -->
|
||||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||||
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
||||||
|
|||||||
@@ -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).
|
// 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;
|
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
|
/// <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
|
/// 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
|
/// <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)
|
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||||
{
|
{
|
||||||
TaskId = taskId;
|
TaskId = taskId;
|
||||||
|
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
|
||||||
_displayTitle = displayTitle;
|
_displayTitle = displayTitle;
|
||||||
_descriptorFactory = descriptorFactory;
|
_descriptorFactory = descriptorFactory;
|
||||||
Subscribe(Terminal);
|
Subscribe(Terminal);
|
||||||
|
|||||||
@@ -408,6 +408,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
||||||
OpenRequested?.Invoke();
|
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())
|
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
|
||||||
CloseConPtySession(stale);
|
CloseConPtySession(stale);
|
||||||
|
|
||||||
@@ -428,7 +433,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
|
() => 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
|
// 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,
|
// 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.
|
// 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.ErrorReported += OnConPtyPaneError;
|
||||||
pane.CloseRequested += OnPaneCloseRequested;
|
pane.CloseRequested += OnPaneCloseRequested;
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||||
ConPtySessions.Add(pane);
|
ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
|
||||||
pane.Start();
|
pane.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
xmlns:conv="using:Avalonia.Data.Converters"
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
xmlns:cconv="using:ClaudeDo.Ui.Converters"
|
||||||
x:DataType="vm:ConPtyPaneViewModel"
|
x:DataType="vm:ConPtyPaneViewModel"
|
||||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Identity strip: same colour for the same session across handoffs and restarts. -->
|
||||||
|
<Border DockPanel.Dock="Left" Width="3"
|
||||||
|
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
|
||||||
|
|
||||||
<!-- Header: title + close -->
|
<!-- Header: title + close -->
|
||||||
<Border DockPanel.Dock="Top"
|
<Border DockPanel.Dock="Top"
|
||||||
Background="{DynamicResource Surface2Brush}"
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
|||||||
@@ -84,7 +84,11 @@
|
|||||||
IsVisible="{Binding IsFocusMode}">
|
IsVisible="{Binding IsFocusMode}">
|
||||||
<TabControl.ItemTemplate>
|
<TabControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vmm:ConPtyPaneViewModel">
|
<DataTemplate x:DataType="vmm:ConPtyPaneViewModel">
|
||||||
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
|
||||||
|
Fill="{Binding IdentityColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
|
||||||
|
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
|
||||||
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</TabControl.ItemTemplate>
|
</TabControl.ItemTemplate>
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|||||||
@@ -747,6 +747,30 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
|
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]
|
[Fact]
|
||||||
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
|
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user