diff --git a/docs/superpowers/plans/2026-08-25-ai2199-session-rail.md b/docs/superpowers/plans/2026-08-25-ai2199-session-rail.md new file mode 100644 index 0000000000..459dd72f5e --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-ai2199-session-rail.md @@ -0,0 +1,1573 @@ +# Session Rail and Tabless Shell (AI-2199) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A 310px session rail (repository → worktree → session) in a restructured, tabless desktop shell: two top-level views (Home, Sessions), an additive `title` wire field, and the Agents tab deleted. + +**Architecture:** `SessionRailViewModel` projects the daemon's `Agents` SourceCache through nested DynamicData groups (repo root → checkout path → session rows). `MainWindowViewModel` gains a `CurrentView` (Home/Sessions) alongside `CurrentWorkspace`; `MainWindow.axaml` swaps two surfaces instead of hosting a TabControl. The daemon stamps a truncated launch-prompt line as `title` on `AgentStatusDto`. + +**Tech Stack:** .NET 10 / NativeAOT, Avalonia + ReactiveUI, DynamicData, TUnit (Microsoft Testing Platform), headless Avalonia tests via `AvaloniaSession`. + +**Spec:** `docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md` — read it first; every decision below argues from it. + +## Global Constraints + +- Branch: `alexeyzimarev/ai-2199-desktop-shell-session-rail` (already created; spec committed on it). +- Namespaces follow directories (compiler-enforced): `Capacitor.App.ViewModels`, `Capacitor.App.Views`, etc. +- All UI-touching tests: `[NotInParallel("AvaloniaSession")]` and go through `AvaloniaSession.WithImmediateRxScheduler` / `DispatchAsync` (see `test/Capacitor.App.Tests.Unit/AvaloniaSession.cs`). +- Every `daemon.Agents`/`Snapshots` consumer must `ObserveOn(RxSchedulers.MainThreadScheduler)` BEFORE any operator that touches bound state. +- Wire rule (`StatusIpc.cs` doc): every field ALWAYS emitted, absent = JSON null, never omitted; additive fields are trailing with defaults; never add a `DefaultIgnoreCondition`. +- `dotnet build` does NOT surface AOT warnings — the final task runs `dotnet publish -c Release` and greps `IL[23][01][0-9]{2}`. +- Comments only where they add value; never narrate the change. +- Test suites run directly, e.g. `dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj` — filter with `--treenode-filter '/*/*/ClassName/*'` (NOT `--filter`). +- Commit after every task (small, green commits). + +--- + +### Task 1: Wire field `AgentStatusDto.Title` + +**Files:** +- Modify: `src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs:38-47` +- Test: `test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `AgentStatusDto(..., bool? HasTerminal = null, string? Title = null)` — trailing member, serialized `title`. Tasks 2, 3 rely on `Title`. + +- [ ] **Step 1: Write the failing tests** + +In `StatusIpcJsonTests.cs`: + +1. In `DaemonStatus_serializes_exactly_with_nulls_present_and_pinned_field_order`, give the first agent a title by appending `Title: "Fix the flaky test"` to its constructor call (after `"Ada Lovelace"` — use the named argument), leave the second agent without one, and extend the pinned JSON string: first agent's tail becomes `"requester_display":"Ada Lovelace","has_terminal":null,"title":"Fix the flaky test"}` and the second's `"requester_display":null,"has_terminal":null,"title":null}`. +2. Add a new test mirroring `Old_agent_json_without_has_terminal_deserializes_to_null`: + +```csharp +[Test] +public async Task Old_agent_json_without_title_deserializes_to_null() { + var dto = new AgentStatusDto( + "a1", "agent", "claude", "/repo", "Running", + null, null, null, DateTime.UtcNow, null, null); + var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.AgentStatusDto); + var stripped = System.Text.RegularExpressions.Regex.Replace(json, ",\"title\":[^,}]+", ""); + + var back = JsonSerializer.Deserialize(stripped, StatusIpcJsonContext.Default.AgentStatusDto); + + await Assert.That(back!.Title).IsNull(); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.Cli.Core.Tests.Unit/Capacitor.Cli.Core.Tests.Unit.csproj -- --treenode-filter '/*/*/StatusIpcJsonTests/*'` +Expected: compile error (`Title` not defined) — that counts as the failing state. + +- [ ] **Step 3: Add the field** + +In `StatusIpc.cs`, extend the `AgentStatusDto` record with a trailing member after `HasTerminal`: + +```csharp + bool? HasTerminal = null, + // The launch prompt's first non-blank line, truncated by the daemon (TitleFromPrompt) — + // display text for session rows. Trailing + nullable: null = older daemon or no goal text. + string? Title = null); +``` + +- [ ] **Step 4: Run to verify pass** + +Same command as Step 2. Expected: PASS (all StatusIpcJsonTests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs +git commit -m "AI-2199: additive title field on AgentStatusDto" +``` + +--- + +### Task 2: Daemon stamps `title` from the launch prompt + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs` (SnapshotAgentsForStatus, ~line 44; add `TitleFromPrompt`) +- Modify: `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs:1513` (`SeedAgentForTest` gains `string? prompt = null`) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs` + +**Interfaces:** +- Consumes: `AgentStatusDto.Title` (Task 1); `AgentInstance.Prompt` (exists, `AgentOrchestrator.cs:24`). +- Produces: `internal static string? AgentOrchestrator.TitleFromPrompt(string? prompt)`; snapshots carry `Title`. + +- [ ] **Step 1: Write the failing tests** + +In `AgentStatusSnapshotTests.cs` (reuse the file's existing `Build()` fixture and `SeedAgentForTest` pattern — see its existing tests around line 190 for the seed/snapshot/cleanup shape). Serialize the snapshot the way the file's existing serialization assertions do (via `StatusIpcJsonContext.Default`): + +```csharp +[Test] +public async Task Snapshot_title_is_first_line_truncated_or_null() { + var fx = Build(); + try { + fx.Orchestrator.SeedAgentForTest("t-short", prompt: "Fix the flaky test"); + fx.Orchestrator.SeedAgentForTest("t-multi", prompt: "\n First real line \nsecond line"); + fx.Orchestrator.SeedAgentForTest("t-long", prompt: new string('x', 200)); + fx.Orchestrator.SeedAgentForTest("t-blank", prompt: " \n "); + fx.Orchestrator.SeedAgentForTest("t-none"); + + var byId = fx.Orchestrator.SnapshotAgentsForStatus().ToDictionary(a => a.Id); + + await Assert.That(byId["t-short"].Title).IsEqualTo("Fix the flaky test"); + await Assert.That(byId["t-multi"].Title).IsEqualTo("First real line"); + await Assert.That(byId["t-long"].Title!.Length).IsEqualTo(80); + await Assert.That(byId["t-long"].Title).EndsWith("…"); + await Assert.That(byId["t-blank"].Title).IsNull(); + await Assert.That(byId["t-none"].Title).IsNull(); + + // The wire boundary, not just the in-memory DTO (spec §1). + var json = JsonSerializer.Serialize(byId["t-short"], StatusIpcJsonContext.Default.AgentStatusDto); + await Assert.That(json).Contains("\"title\":\"Fix the flaky test\""); + var jsonNone = JsonSerializer.Serialize(byId["t-none"], StatusIpcJsonContext.Default.AgentStatusDto); + await Assert.That(jsonNone).Contains("\"title\":null"); + } finally { await fx.CleanupAsync(); } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.Cli.Daemon.Tests.Unit/Capacitor.Cli.Daemon.Tests.Unit.csproj -- --treenode-filter '/*/*/AgentStatusSnapshotTests/*'` +Expected: compile error — `SeedAgentForTest` has no `prompt` parameter. + +- [ ] **Step 3: Implement** + +1. `SeedAgentForTest` (`AgentOrchestrator.cs:1513`): add `string? prompt = null` to the parameter list and pass it as the second positional argument of `new AgentInstance(id, prompt, model, ...)` (currently hardcoded `null`). +2. In `AgentOrchestrator.LocalIpc.cs`, add below `KindText`: + +```csharp + /// First non-blank line of the launch prompt, trimmed, capped at 80 chars (ellipsis when + /// cut) — the status payload is re-sent on every revision, so the full prompt never rides it. + internal static string? TitleFromPrompt(string? prompt) { + if (prompt is null) return null; + foreach (var raw in prompt.Split('\n')) { + var line = raw.Trim(); + if (line.Length == 0) continue; + return line.Length <= 80 ? line : line[..79] + "…"; + } + return null; + } +``` + +3. In `SnapshotAgentsForStatus`, add `Title: TitleFromPrompt(a.Prompt)` after `HasTerminal: a.Runtime.EmitsTerminalOutput`. + +- [ ] **Step 4: Run to verify pass** + +Same command. Expected: PASS (whole class — the exact-payload tests in this file may need their pinned strings extended with `"title":null`; if any fail on the pinned JSON, extend those strings, that is the contract updating, not a regression). + +- [ ] **Step 5: Commit** + +```bash +git add src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs +git commit -m "AI-2199: daemon stamps title from the launch prompt" +``` + +--- + +### Task 3: `RailSessionViewModel` + shared status dots + +**Files:** +- Create: `src/Capacitor.App/ViewModels/SessionStatusDots.cs` +- Create: `src/Capacitor.App/ViewModels/RailSessionViewModel.cs` +- Modify: `src/Capacitor.App/ViewModels/SessionCardViewModel.cs` (use the shared dots) +- Test: `test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs` + +**Interfaces:** +- Consumes: `AgentStatusDto` (incl. `Title` from Task 1), `UptimeFormat.Format(TimeSpan)`, `StatusColors`. +- Produces: + - `static class SessionStatusDots { public static IBrush For(string status); }` + - `sealed class RailSessionViewModel : ReactiveObject, IDisposable` with ctor `(AgentStatusDto dto, IObservable selectedAgentId, Action open)` and members: `string Id`, `string Primary`, `string Sub`, `IBrush StatusDot`, `bool NeedsYou`, `string Tooltip`, `bool IsSelected` (OAPH), `ReactiveCommand OpenCommand`, `DateTime CreatedAt` (internal sort key). + +- [ ] **Step 1: Extract the shared dots (no behavior change)** + +Create `SessionStatusDots.cs` by moving `SessionCardViewModel`'s four `ImmutableSolidColorBrush` statics and its `StatusDotFor` switch (keep its comments — the thread-affinity reasoning is the point of the type): + +```csharp +using Avalonia.Media; +using Avalonia.Media.Immutable; + +namespace Capacitor.App.ViewModels; + +/// Status → dot brush for session surfaces (Home cards, the rail). ImmutableSolidColorBrush, +/// not SolidColorBrush: these are built on the daemon client's pump thread and an immutable +/// brush has no thread affinity, which is also what makes the four instances shareable. +public static class SessionStatusDots { + static readonly ImmutableSolidColorBrush RunningDot = new(Color.Parse(StatusColors.Connected)); + static readonly ImmutableSolidColorBrush StartingDot = new(Color.Parse(StatusColors.InProgress)); + static readonly ImmutableSolidColorBrush FailedDot = new(Color.Parse(StatusColors.Disrupted)); + static readonly ImmutableSolidColorBrush NeutralDot = new(Color.Parse(StatusColors.Unavailable)); + + // Running/Starting/Failed are the daemon's own open vocabulary (AgentOrchestrator); anything + // else (Completed, or a value this build has never heard of) reads as neutral. + public static IBrush For(string status) => status switch { + "Running" => RunningDot, + "Starting" => StartingDot, + "Failed" => FailedDot, + _ => NeutralDot, + }; +} +``` + +In `SessionCardViewModel`, delete the four statics + `StatusDotFor` and call `SessionStatusDots.For(dto.Status)`. + +- [ ] **Step 2: Write the failing tests** + +`test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs`. Helper DTO builder mirrors `FakeDaemonClientService.Snap`'s agent shape: + +```csharp +using System.Reactive.Subjects; +using Capacitor.App.ViewModels; +using Capacitor.Cli.Core.LocalIpc; + +namespace Capacitor.App.Tests.Unit; + +public class RailSessionViewModelTests { + static AgentStatusDto Dto( + string id = "a1", string kind = "agent", string vendor = "claude", string status = "Running", + string? model = "Opus 5", string? title = "Fix the flaky test") => + new(id, kind, vendor, "/repo", status, null, null, null, DateTime.UtcNow, model, null, Title: title); + + [Test] + public async Task Title_is_primary_with_vendor_model_age_sub() { + using var row = new RailSessionViewModel(Dto(), new BehaviorSubject(null), _ => { }); + await Assert.That(row.Primary).IsEqualTo("Fix the flaky test"); + await Assert.That(row.Sub).StartsWith("claude · Opus 5 · "); + } + + [Test] + public async Task Null_title_promotes_vendor_and_drops_it_from_sub() { + using var row = new RailSessionViewModel(Dto(title: null), new BehaviorSubject(null), _ => { }); + await Assert.That(row.Primary).IsEqualTo("claude"); + await Assert.That(row.Sub).StartsWith("Opus 5 · "); + } + + [Test] + public async Task Review_kind_is_appended_to_the_vendor() { + using var row = new RailSessionViewModel(Dto(kind: "review", title: null), new BehaviorSubject(null), _ => { }); + await Assert.That(row.Primary).IsEqualTo("claude · review"); + } + + [Test] + public async Task Null_model_is_omitted_from_sub() { + using var row = new RailSessionViewModel(Dto(model: null), new BehaviorSubject(null), _ => { }); + await Assert.That(row.Sub).DoesNotContain("· ·"); + await Assert.That(row.Sub).DoesNotStartWith("·"); + } + + [Test] + public async Task Failed_status_sets_the_pip() { + using var ok = new RailSessionViewModel(Dto(), new BehaviorSubject(null), _ => { }); + using var bad = new RailSessionViewModel(Dto(status: "Failed"), new BehaviorSubject(null), _ => { }); + await Assert.That(ok.NeedsYou).IsFalse(); + await Assert.That(bad.NeedsYou).IsTrue(); + } + + [Test] + public async Task IsSelected_tracks_the_selection_observable() { + var selected = new BehaviorSubject(null); + using var row = new RailSessionViewModel(Dto(id: "a1"), selected, _ => { }); + await Assert.That(row.IsSelected).IsFalse(); + selected.OnNext("a1"); + await Assert.That(row.IsSelected).IsTrue(); + selected.OnNext("other"); + await Assert.That(row.IsSelected).IsFalse(); + } + + [Test] + public async Task OpenCommand_invokes_the_callback_with_the_id() { + string? opened = null; + using var row = new RailSessionViewModel(Dto(id: "a9"), new BehaviorSubject(null), id => opened = id); + row.OpenCommand.Execute().Subscribe(); + await Assert.That(opened).IsEqualTo("a9"); + } +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/RailSessionViewModelTests/*'` +Expected: compile error — `RailSessionViewModel` does not exist. + +- [ ] **Step 4: Implement** + +`RailSessionViewModel.cs`: + +```csharp +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Avalonia.Media; +using Capacitor.Cli.Core.LocalIpc; +using ReactiveUI; + +namespace Capacitor.App.ViewModels; + +/// One session row of the rail. Recreated per dto revision (DynamicData Transform), so every +/// static field is computed once from the ctor dto; only IsSelected is live — selection changes +/// are not dto revisions. Age is a point-in-time snapshot (SessionCardViewModel precedent). +public sealed class RailSessionViewModel : ReactiveObject, IDisposable { + public string Id { get; } + public string Primary { get; } + public string Sub { get; } + public IBrush StatusDot { get; } + public bool NeedsYou { get; } + public string Tooltip { get; } + public ReactiveCommand OpenCommand { get; } + + internal DateTime CreatedAt { get; } + + readonly ObservableAsPropertyHelper _isSelected; + public bool IsSelected => _isSelected.Value; + + readonly CompositeDisposable _disposables = new(); + + public RailSessionViewModel(AgentStatusDto dto, IObservable selectedAgentId, Action open) { + Id = dto.Id; + CreatedAt = dto.CreatedAt; + var vendorLine = dto.Kind == "agent" ? dto.Vendor : $"{dto.Vendor} · {dto.Kind}"; + var age = UptimeFormat.Format(DateTime.UtcNow - DateTime.SpecifyKind(dto.CreatedAt, DateTimeKind.Utc)); + + Primary = dto.Title ?? vendorLine; + Sub = dto.Title is null + ? Join(dto.Model, age) + : Join(vendorLine, dto.Model, age); + StatusDot = SessionStatusDots.For(dto.Status); + NeedsYou = dto.Status == "Failed"; + Tooltip = Join(dto.Id, dto.Status, dto.RequesterDisplay); + + _isSelected = selectedAgentId.Select(sel => sel == dto.Id) + .ToProperty(this, x => x.IsSelected, initialValue: false) + .DisposeWith(_disposables); + OpenCommand = ReactiveCommand.Create(() => open(dto.Id)); + _disposables.Add(OpenCommand); + } + + static string Join(params string?[] parts) => + string.Join(" · ", parts.Where(p => !string.IsNullOrEmpty(p))); + + public void Dispose() => _disposables.Dispose(); +} +``` + +- [ ] **Step 5: Run to verify pass** + +Same command. Also run the Home card tests to prove the extraction changed nothing: `--treenode-filter '/*/*/SessionCard*/*'` (if a card test class exists; otherwise run the whole App suite). +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/Capacitor.App/ViewModels/SessionStatusDots.cs src/Capacitor.App/ViewModels/RailSessionViewModel.cs src/Capacitor.App/ViewModels/SessionCardViewModel.cs test/Capacitor.App.Tests.Unit/RailSessionViewModelTests.cs +git commit -m "AI-2199: rail session row VM over shared status dots" +``` + +--- + +### Task 4: `RailCollapseState` + `RailWorktreeViewModel` + +**Files:** +- Create: `src/Capacitor.App/ViewModels/RailCollapseState.cs` +- Create: `src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs` +- Test: `test/Capacitor.App.Tests.Unit/RailWorktreeViewModelTests.cs` + +**Interfaces:** +- Consumes: `RailSessionViewModel` (Task 3); DynamicData `IObservableCache`. +- Produces: + - `sealed class RailCollapseState` — `bool IsCollapsed(string path, bool isMainCheckout)`, `void Set(string path, bool collapsed)`, `IObservable Changes`. Default rule: collapsed iff main checkout, until an explicit `Set`. + - `sealed class RailWorktreeViewModel : ReactiveObject, IDisposable` with ctor `(string path, string repoRoot, bool showHeader, IObservableCache sessionsCache, RailCollapseState collapse, IObservable selectedAgentId, Action open)` and members: `string Path`, `string Label`, `bool IsMainCheckout`, `bool ShowHeader`, `bool IsExpanded` (OAPH), `int SessionCount` (OAPH), `string CountText` (OAPH), `bool NeedsYou` (OAPH), `bool HoldsSelected` (OAPH), `bool SessionsVisible` (OAPH: `IsExpanded || !ShowHeader`), `ReadOnlyObservableCollection Sessions`, `ReactiveCommand ToggleCommand`. + - `internal static string RailWorktreeViewModel.LabelFor(string path, bool isMainCheckout)` → `"main checkout"` or the path leaf. + +- [ ] **Step 1: Write the failing tests** + +`RailWorktreeViewModelTests.cs`. Build a private `SourceCache(a => a.Id)` per test and hand `cache.AsObservableCache()` to the VM. Tests are pure VM tests but OAPHs subscribe immediately — no scheduler involved (no ObserveOn inside this VM; marshaling is the outer pipeline's job, see Task 5), so no Avalonia session is needed: + +```csharp +using Capacitor.App.ViewModels; +using Capacitor.Cli.Core.LocalIpc; +using DynamicData; +using System.Reactive.Subjects; + +namespace Capacitor.App.Tests.Unit; + +public class RailWorktreeViewModelTests { + static AgentStatusDto Dto(string id, string status = "Running", DateTime? created = null) => + new(id, "agent", "claude", "/repo/.claude/worktrees/wt-a", status, + null, null, null, created ?? DateTime.UtcNow, null, null); + + static RailWorktreeViewModel Build( + SourceCache cache, RailCollapseState? collapse = null, + string path = "/repo/.claude/worktrees/wt-a", string root = "/repo", bool showHeader = true, + IObservable? selected = null) => + new(path, root, showHeader, cache.AsObservableCache(), + collapse ?? new RailCollapseState(), selected ?? new BehaviorSubject(null), _ => { }); + + [Test] + public async Task Label_is_the_checkout_leaf_and_main_checkout_for_the_root() { + await Assert.That(RailWorktreeViewModel.LabelFor("/repo/.claude/worktrees/wt-a", false)).IsEqualTo("wt-a"); + await Assert.That(RailWorktreeViewModel.LabelFor("/repo/", false)).IsEqualTo("repo"); + await Assert.That(RailWorktreeViewModel.LabelFor("/repo", true)).IsEqualTo("main checkout"); + } + + [Test] + public async Task Count_and_pip_follow_the_cache() { + var cache = new SourceCache(a => a.Id); + using var wt = Build(cache); + cache.AddOrUpdate(Dto("a1")); + cache.AddOrUpdate(Dto("a2", status: "Failed")); + await Assert.That(wt.SessionCount).IsEqualTo(2); + await Assert.That(wt.CountText).IsEqualTo("2"); + await Assert.That(wt.NeedsYou).IsTrue(); + + cache.AddOrUpdate(Dto("a2", status: "Running")); // recovery clears the pip + await Assert.That(wt.NeedsYou).IsFalse(); + } + + [Test] + public async Task Main_checkout_defaults_collapsed_and_others_expanded() { + var cache = new SourceCache(a => a.Id); + using var main = Build(cache, path: "/repo", root: "/repo"); + using var wt = Build(cache); + await Assert.That(main.IsExpanded).IsFalse(); + await Assert.That(wt.IsExpanded).IsTrue(); + } + + [Test] + public async Task Toggle_persists_in_the_shared_state_across_VM_recreation() { + var collapse = new RailCollapseState(); + var cache = new SourceCache(a => a.Id); + using (var first = Build(cache, collapse, path: "/repo", root: "/repo")) { + first.ToggleCommand.Execute().Subscribe(); + await Assert.That(first.IsExpanded).IsTrue(); + } + using var recreated = Build(cache, collapse, path: "/repo", root: "/repo"); + await Assert.That(recreated.IsExpanded).IsTrue(); // survived the group's death + } + + [Test] + public async Task Sessions_sort_by_created_then_id() { + var cache = new SourceCache(a => a.Id); + var t = DateTime.UtcNow; + using var wt = Build(cache); + cache.AddOrUpdate(Dto("b", created: t)); + cache.AddOrUpdate(Dto("a", created: t)); + cache.AddOrUpdate(Dto("c", created: t.AddMinutes(-1))); + await Assert.That(wt.Sessions.Select(s => s.Id)).IsEquivalentTo(["c", "a", "b"], CollectionOrdering.Matching); + } + + [Test] + public async Task Headerless_group_always_shows_sessions() { + var cache = new SourceCache(a => a.Id); + using var wt = Build(cache, showHeader: false, path: "", root: ""); + await Assert.That(wt.SessionsVisible).IsTrue(); + } + + [Test] + public async Task Dispose_stops_tracking_the_cache() { + var cache = new SourceCache(a => a.Id); + var wt = Build(cache); + cache.AddOrUpdate(Dto("a1")); + wt.Dispose(); + cache.AddOrUpdate(Dto("a2")); + await Assert.That(wt.SessionCount).IsEqualTo(1); + await Assert.That(wt.Sessions.Count).IsEqualTo(1); + } +} +``` + +(`CollectionOrdering` comes from `TUnit.Assertions.Enums`, already used by daemon tests.) + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/RailWorktreeViewModelTests/*'` +Expected: compile errors — types do not exist. + +- [ ] **Step 3: Implement** + +`RailCollapseState.cs`: + +```csharp +using System.Reactive.Subjects; + +namespace Capacitor.App.ViewModels; + +/// Collapse choices for worktree rows, held OUTSIDE the group VMs: DynamicData drops and +/// re-forms a group whenever it empties or the cache resets, so state on the VM itself would +/// silently reset (spec §3). Default rule: collapsed iff main checkout. UI-thread only. +public sealed class RailCollapseState { + readonly Dictionary _explicit = new(StringComparer.Ordinal); + readonly Subject _changes = new(); + + /// Fires the path whose state changed — worktree VMs re-read IsCollapsed on it. + public IObservable Changes => _changes; + + public bool IsCollapsed(string path, bool isMainCheckout) => + _explicit.TryGetValue(path, out var collapsed) ? collapsed : isMainCheckout; + + public void Set(string path, bool collapsed) { + _explicit[path] = collapsed; + _changes.OnNext(path); + } +} +``` + +`RailWorktreeViewModel.cs`: + +```csharp +using System.Collections.ObjectModel; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Capacitor.Cli.Core.LocalIpc; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; + +namespace Capacitor.App.ViewModels; + +/// One worktree level of the rail: the collapsible row plus its session rows. ShowHeader=false +/// is the No-repository group's single nested group — rendered headerless, sessions always +/// visible (spec §3). No ObserveOn here: the OUTER pipeline (SessionRailViewModel) marshals to +/// the UI thread before any group cache is mutated, so everything below already runs there. +public sealed class RailWorktreeViewModel : ReactiveObject, IDisposable { + public string Path { get; } + public string Label { get; } + public bool IsMainCheckout { get; } + public bool ShowHeader { get; } + public ReactiveCommand ToggleCommand { get; } + + readonly ObservableAsPropertyHelper _isExpanded; + public bool IsExpanded => _isExpanded.Value; + + readonly ObservableAsPropertyHelper _sessionsVisible; + public bool SessionsVisible => _sessionsVisible.Value; + + readonly ObservableAsPropertyHelper _sessionCount; + public int SessionCount => _sessionCount.Value; + + readonly ObservableAsPropertyHelper _countText; + public string CountText => _countText.Value; + + readonly ObservableAsPropertyHelper _needsYou; + public bool NeedsYou => _needsYou.Value; + + readonly ObservableAsPropertyHelper _holdsSelected; + public bool HoldsSelected => _holdsSelected.Value; + + readonly ObservableCollectionExtended _sessionsSource = new(); + public ReadOnlyObservableCollection Sessions { get; } + + static readonly IComparer SessionComparer = + Comparer.Create((a, b) => { + var byCreated = a.CreatedAt.CompareTo(b.CreatedAt); + return byCreated != 0 ? byCreated : string.CompareOrdinal(a.Id, b.Id); + }); + + readonly CompositeDisposable _disposables = new(); + + public RailWorktreeViewModel( + string path, string repoRoot, bool showHeader, + IObservableCache sessionsCache, RailCollapseState collapse, + IObservable selectedAgentId, Action open) { + Path = path; + IsMainCheckout = PathEquals(path, repoRoot); + Label = LabelFor(path, IsMainCheckout); + ShowHeader = showHeader; + + _isExpanded = collapse.Changes.Where(p => p == path).Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Select(_ => !collapse.IsCollapsed(path, IsMainCheckout)) + .ToProperty(this, x => x.IsExpanded) + .DisposeWith(_disposables); + _sessionsVisible = this.WhenAnyValue(x => x.IsExpanded, expanded => expanded || !showHeader) + .ToProperty(this, x => x.SessionsVisible) + .DisposeWith(_disposables); + ToggleCommand = ReactiveCommand.Create(() => collapse.Set(path, IsExpanded)); + _disposables.Add(ToggleCommand); + + _sessionCount = sessionsCache.CountChanged + .ToProperty(this, x => x.SessionCount, initialValue: sessionsCache.Count) + .DisposeWith(_disposables); + _countText = this.WhenAnyValue(x => x.SessionCount, c => c.ToString()) + .ToProperty(this, x => x.CountText) + .DisposeWith(_disposables); + + _needsYou = sessionsCache.Connect() + .QueryWhenChanged(q => q.Items.Any(d => d.Status == "Failed")) + .ToProperty(this, x => x.NeedsYou, initialValue: false) + .DisposeWith(_disposables); + + _holdsSelected = sessionsCache.Connect().QueryWhenChanged(q => q.Keys.ToHashSet()) + .CombineLatest(selectedAgentId, (ids, sel) => sel is not null && ids.Contains(sel)) + .ToProperty(this, x => x.HoldsSelected, initialValue: false) + .DisposeWith(_disposables); + + Sessions = new ReadOnlyObservableCollection(_sessionsSource); + sessionsCache.Connect() + .Transform(dto => new RailSessionViewModel(dto, selectedAgentId, open)) + .DisposeMany() + .SortAndBind(_sessionsSource, SessionComparer) + .Subscribe() + .DisposeWith(_disposables); + } + + internal static string LabelFor(string path, bool isMainCheckout) => + isMainCheckout ? "main checkout" + : System.IO.Path.GetFileName(System.IO.Path.TrimEndingDirectorySeparator(path)); + + // Same platform rule HomeViewModel.PathComparer documents: case-insensitive except Linux. + internal static bool PathEquals(string a, string b) => + string.Equals( + System.IO.Path.TrimEndingDirectorySeparator(a), System.IO.Path.TrimEndingDirectorySeparator(b), + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); + + public void Dispose() => _disposables.Dispose(); +} +``` + +Note `LabelFor("/repo/", false)` must yield `repo` — `TrimEndingDirectorySeparator` before `GetFileName` handles it. + +- [ ] **Step 4: Run to verify pass** + +Same command. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Capacitor.App/ViewModels/RailCollapseState.cs src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs test/Capacitor.App.Tests.Unit/RailWorktreeViewModelTests.cs +git commit -m "AI-2199: worktree level VM with shared collapse state" +``` + +--- + +### Task 5: `RailRepoViewModel` + `SessionRailViewModel` + +**Files:** +- Create: `src/Capacitor.App/ViewModels/RailRepoViewModel.cs` +- Create: `src/Capacitor.App/ViewModels/SessionRailViewModel.cs` +- Test: `test/Capacitor.App.Tests.Unit/SessionRailViewModelTests.cs` + +**Interfaces:** +- Consumes: `RailWorktreeViewModel`, `RailCollapseState` (Task 4); `IDaemonClientService.Agents`; `GitRepository.ResolveMainRepoRoot(string)` (Cli.Core). +- Produces: + - `sealed class RailRepoViewModel : ReactiveObject, IDisposable` — `string RootPath`, `string Label`, `bool IsNoRepository`, `string CountText` (OAPH, "N session(s)"), `ReadOnlyObservableCollection Worktrees`. + - `sealed class SessionRailViewModel : ReactiveObject, IDisposable` — ctor `(IDaemonClientService daemon, Action openSession, Func? resolveRepoRoot = null)`; members `ReadOnlyObservableCollection Repos`, `bool IsEmpty` (OAPH), `string HostedText` (OAPH, "N hosted"), `string? SelectedAgentId` (reactive property), `void NotifySessionOpened(string agentId)`. + - `resolveRepoRoot` defaults to `GitRepository.ResolveMainRepoRoot`; injectable so tests never do real `.git` I/O. + +- [ ] **Step 1: Write the failing tests** + +`SessionRailViewModelTests.cs`. The pipeline `ObserveOn`s the main-thread scheduler, so every test wraps in `AvaloniaSession.WithImmediateRxScheduler` and carries `[NotInParallel("AvaloniaSession")]` (MainWindowViewModelTests conventions). Use a fake resolver: worktree paths map under their repo, plain paths map to themselves. + +```csharp +using Capacitor.App.ViewModels; +using Capacitor.Cli.Core.LocalIpc; +using DynamicData; +using TUnit.Assertions.Enums; + +namespace Capacitor.App.Tests.Unit; + +public class SessionRailViewModelTests { + // "/x/wt/" resolves to "/x" — a stand-in for GitRepository.ResolveMainRepoRoot so no + // test touches real .git files. + static string Resolve(string path) { + var marker = path.IndexOf("/wt/", StringComparison.Ordinal); + return marker < 0 ? path : path[..marker]; + } + + static AgentStatusDto Dto(string id, string? repoPath, string status = "Running", DateTime? created = null) => + new(id, "agent", "claude", repoPath, status, null, null, null, created ?? DateTime.UtcNow, null, null); + + static (FakeDaemonClientService Service, SessionRailViewModel Rail) Build(Action? open = null) { + var service = new FakeDaemonClientService(); + return (service, new SessionRailViewModel(service, open ?? (_ => { }), Resolve)); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Groups_repo_worktree_session_with_no_repository_last() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + using (rail) { + service.Agents.AddOrUpdate(Dto("a1", "/dev/zeta")); + service.Agents.AddOrUpdate(Dto("a2", "/dev/alpha/wt/feature-x")); + service.Agents.AddOrUpdate(Dto("a3", "/dev/alpha")); + service.Agents.AddOrUpdate(Dto("a4", null)); + + await Assert.That(rail.Repos.Select(r => r.Label)) + .IsEquivalentTo(["alpha", "zeta", "No repository"], CollectionOrdering.Matching); + + var alpha = rail.Repos[0]; + await Assert.That(alpha.Worktrees.Select(w => w.Label)) + .IsEquivalentTo(["main checkout", "feature-x"], CollectionOrdering.Matching); + + var noRepo = rail.Repos[2]; + await Assert.That(noRepo.IsNoRepository).IsTrue(); + await Assert.That(noRepo.Worktrees).HasCount().EqualTo(1); + await Assert.That(noRepo.Worktrees[0].ShowHeader).IsFalse(); + } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Counts_and_hosted_text_track_the_cache() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + using (rail) { + await Assert.That(rail.IsEmpty).IsTrue(); + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha")); + service.Agents.AddOrUpdate(Dto("a2", "/dev/alpha/wt/feature-x")); + await Assert.That(rail.IsEmpty).IsFalse(); + await Assert.That(rail.HostedText).IsEqualTo("2 hosted"); + await Assert.That(rail.Repos[0].CountText).IsEqualTo("2 sessions"); + + service.Agents.RemoveKey("a2"); + await Assert.That(rail.Repos[0].CountText).IsEqualTo("1 session"); + } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Empty_repo_group_disappears_and_collapse_survives_recreation() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + using (rail) { + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha")); + rail.Repos[0].Worktrees[0].ToggleCommand.Execute().Subscribe(); // expand main checkout + + service.Agents.RemoveKey("a1"); // the whole repo group dies + await Assert.That(rail.Repos).HasCount().EqualTo(0); + + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha")); // and re-forms + await Assert.That(rail.Repos[0].Worktrees[0].IsExpanded).IsTrue(); + } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task NotifySessionOpened_expands_the_collapsed_worktree_and_selects() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + using (rail) { + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha")); // main checkout: collapsed + await Assert.That(rail.Repos[0].Worktrees[0].IsExpanded).IsFalse(); + + rail.NotifySessionOpened("a1"); + rail.SelectedAgentId = "a1"; + + var wt = rail.Repos[0].Worktrees[0]; + await Assert.That(wt.IsExpanded).IsTrue(); + await Assert.That(wt.HoldsSelected).IsTrue(); + await Assert.That(wt.Sessions[0].IsSelected).IsTrue(); + } + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Dispose_stops_tracking() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha")); + rail.Dispose(); + service.Agents.AddOrUpdate(Dto("a2", "/dev/zeta")); + await Assert.That(rail.Repos).HasCount().EqualTo(1); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Repo_pip_reaches_the_worktree_row_when_a_session_fails() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var (service, rail) = Build(); + using (rail) { + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha/wt/feature-x")); + await Assert.That(rail.Repos[0].Worktrees[0].NeedsYou).IsFalse(); + service.Agents.AddOrUpdate(Dto("a1", "/dev/alpha/wt/feature-x", status: "Failed")); + await Assert.That(rail.Repos[0].Worktrees[0].NeedsYou).IsTrue(); + } + }); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/SessionRailViewModelTests/*'` +Expected: compile errors. + +- [ ] **Step 3: Implement** + +`RailRepoViewModel.cs`: + +```csharp +using System.Collections.ObjectModel; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Capacitor.Cli.Core.LocalIpc; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; + +namespace Capacitor.App.ViewModels; + +/// One repository level of the rail. IsNoRepository is the "" sentinel group — its single +/// nested worktree group renders headerless (spec §3). No ObserveOn: the outer pipeline +/// already marshaled (RailWorktreeViewModel's identical note). +public sealed class RailRepoViewModel : ReactiveObject, IDisposable { + public string RootPath { get; } + public string Label { get; } + public bool IsNoRepository { get; } + + readonly ObservableAsPropertyHelper _countText; + public string CountText => _countText.Value; + + readonly ObservableCollectionExtended _worktreesSource = new(); + public ReadOnlyObservableCollection Worktrees { get; } + + // Main checkout first, then leaf label; path tiebreak keeps the order total. + static readonly IComparer WorktreeComparer = + Comparer.Create((a, b) => { + var byMain = b.IsMainCheckout.CompareTo(a.IsMainCheckout); + if (byMain != 0) return byMain; + var byLabel = StringComparer.OrdinalIgnoreCase.Compare(a.Label, b.Label); + return byLabel != 0 ? byLabel : string.CompareOrdinal(a.Path, b.Path); + }); + + readonly CompositeDisposable _disposables = new(); + + public RailRepoViewModel( + IGroup group, RailCollapseState collapse, + IObservable selectedAgentId, Action open) { + RootPath = group.Key; + IsNoRepository = group.Key.Length == 0; + Label = IsNoRepository ? "No repository" + : System.IO.Path.GetFileName(System.IO.Path.TrimEndingDirectorySeparator(group.Key)); + + _countText = group.Cache.CountChanged + .Select(c => c == 1 ? "1 session" : $"{c} sessions") + .ToProperty(this, x => x.CountText, initialValue: "") + .DisposeWith(_disposables); + + Worktrees = new ReadOnlyObservableCollection(_worktreesSource); + group.Cache.Connect() + .Group(dto => dto.RepoPath ?? "") + .Transform(wt => new RailWorktreeViewModel( + wt.Key, RootPath, showHeader: !IsNoRepository, wt.Cache, collapse, selectedAgentId, open)) + .DisposeMany() + .SortAndBind(_worktreesSource, WorktreeComparer) + .Subscribe() + .DisposeWith(_disposables); + } + + public void Dispose() => _disposables.Dispose(); +} +``` + +`SessionRailViewModel.cs`: + +```csharp +using System.Collections.ObjectModel; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Capacitor.App.Services; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.LocalIpc; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; + +namespace Capacitor.App.ViewModels; + +/// The session rail's root: repository → worktree → session over daemon.Agents (spec §3). +/// Ctor-scoped and disposable like HomeViewModel — the tree must be live from construction. +/// ONE ObserveOn at the top of the pipeline: nested group caches are mutated by this outer +/// pipeline, so every inner Connect() below already fires on the UI thread. +public sealed class SessionRailViewModel : ReactiveObject, IDisposable { + readonly IDaemonClientService _daemon; + readonly RailCollapseState _collapse = new(); + readonly Dictionary _rootByPath = new(StringComparer.Ordinal); + readonly Func _resolveRepoRoot; + readonly CompositeDisposable _disposables = new(); + + string? _selectedAgentId; + public string? SelectedAgentId { + get => _selectedAgentId; + set => this.RaiseAndSetIfChanged(ref _selectedAgentId, value); + } + + readonly ObservableAsPropertyHelper _isEmpty; + public bool IsEmpty => _isEmpty.Value; + + readonly ObservableAsPropertyHelper _hostedText; + public string HostedText => _hostedText.Value; + + readonly ObservableCollectionExtended _reposSource = new(); + public ReadOnlyObservableCollection Repos { get; } + + // No-repository last, then leaf label; root path tiebreak. + static readonly IComparer RepoComparer = + Comparer.Create((a, b) => { + var byNoRepo = a.IsNoRepository.CompareTo(b.IsNoRepository); + if (byNoRepo != 0) return byNoRepo; + var byLabel = StringComparer.OrdinalIgnoreCase.Compare(a.Label, b.Label); + return byLabel != 0 ? byLabel : string.CompareOrdinal(a.RootPath, b.RootPath); + }); + + /// resolveRepoRoot defaults to the real .git-reading heuristic; tests inject a pure one. + public SessionRailViewModel( + IDaemonClientService daemon, Action openSession, + Func? resolveRepoRoot = null) { + _daemon = daemon; + _resolveRepoRoot = resolveRepoRoot ?? GitRepository.ResolveMainRepoRoot; + var selected = this.WhenAnyValue(x => x.SelectedAgentId); + + _isEmpty = daemon.Agents.CountChanged + .Select(c => c == 0) + .ObserveOn(RxSchedulers.MainThreadScheduler) + .ToProperty(this, x => x.IsEmpty, initialValue: daemon.Agents.Count == 0) + .DisposeWith(_disposables); + _hostedText = daemon.Agents.CountChanged + .Select(c => $"{c} hosted") + .ObserveOn(RxSchedulers.MainThreadScheduler) + .ToProperty(this, x => x.HostedText, initialValue: $"{daemon.Agents.Count} hosted") + .DisposeWith(_disposables); + + Repos = new ReadOnlyObservableCollection(_reposSource); + daemon.Agents.Connect() + .ObserveOn(RxSchedulers.MainThreadScheduler) + .Group(dto => RepoRootFor(dto)) + .Transform(g => new RailRepoViewModel(g, _collapse, selected, openSession)) + .DisposeMany() + .SortAndBind(_reposSource, RepoComparer) + .Subscribe() + .DisposeWith(_disposables); + } + + /// The launch auto-open's counterpart: a session opened into a collapsed worktree (main + /// checkout by default) must never highlight an invisible row (spec §3). + public void NotifySessionOpened(string agentId) { + var dto = _daemon.Agents.Lookup(agentId); + if (!dto.HasValue || dto.Value.RepoPath is not { Length: > 0 } path) return; + _collapse.Set(path, collapsed: false); + } + + // Memoized: ResolveMainRepoRoot reads .git files — cheap once, not per-changeset cheap; a + // path's resolution never changes within a daemon's lifetime. + string RepoRootFor(AgentStatusDto dto) { + if (dto.RepoPath is not { Length: > 0 } path) return ""; + if (_rootByPath.TryGetValue(path, out var root)) return root; + root = _resolveRepoRoot(path); + _rootByPath[path] = root; + return root; + } + + public void Dispose() => _disposables.Dispose(); +} +``` + +Note: `_rootByPath` is only touched from the pipeline's `Group` selector, which runs post-`ObserveOn` on the UI thread — no lock needed. + +- [ ] **Step 4: Run to verify pass** + +Same command; then the two prior rail test classes too. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Capacitor.App/ViewModels/RailRepoViewModel.cs src/Capacitor.App/ViewModels/SessionRailViewModel.cs test/Capacitor.App.Tests.Unit/SessionRailViewModelTests.cs +git commit -m "AI-2199: session rail projection over nested DynamicData groups" +``` + +--- + +### Task 6: `MainWindowViewModel` view state + rail wiring + +**Files:** +- Modify: `src/Capacitor.App/ViewModels/MainWindowViewModel.cs` +- Test: `test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs` (append a new region/section) + +**Interfaces:** +- Consumes: `SessionRailViewModel` (Task 5). +- Produces on `MainWindowViewModel`: + - `public enum ShellView { Home, Sessions }` (top of the file, same namespace). + - `ShellView CurrentView` (private set, default `Home`), `bool IsHomeView`, `bool IsSessionsView` (both raised when CurrentView changes). + - `ReactiveCommand ShowHomeCommand`, `ShowSessionsCommand`. + - `SessionRailViewModel? Rail { get; }` — new optional ctor param `SessionRailViewModel? rail = null` (after `workspaceFactory`). + - `OpenSession`: same-session no-op; sets `CurrentView = Sessions`; calls `Rail?.NotifySessionOpened(agentId)`. + - `SwapTo`/`LatchShutdown`: push `CurrentWorkspace?.AgentId` into `Rail.SelectedAgentId`. + +- [ ] **Step 1: Write the failing tests** + +Append to `MainWindowViewModelTests.cs`, following that file's existing construction helpers (find its `NewVm`-style helper and mirror it; pass `rail:` where needed). New tests: + +```csharp +[Test] +[NotInParallel("AvaloniaSession")] +public async Task OpenSession_switches_to_sessions_view_and_reopening_the_same_id_is_a_noop() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var service = new FakeDaemonClientService(); + var built = 0; + var vm = /* file's standard construction */ NewVm(service, + workspaceFactory: id => { built++; return NewWorkspace(service, id); }); + + await Assert.That(vm.IsHomeView).IsTrue(); + vm.OpenSession("a1"); + await Assert.That(vm.IsSessionsView).IsTrue(); + await Assert.That(built).IsEqualTo(1); + + vm.OpenSession("a1"); // same id: no teardown/rebuild of a live attach + await Assert.That(built).IsEqualTo(1); + + vm.OpenSession("a2"); // different id still swaps + await Assert.That(built).IsEqualTo(2); + }); +} + +[Test] +[NotInParallel("AvaloniaSession")] +public async Task View_commands_swap_surfaces_and_close_keeps_sessions_view() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var service = new FakeDaemonClientService(); + var vm = NewVm(service, workspaceFactory: id => NewWorkspace(service, id)); + vm.OpenSession("a1"); + vm.CloseWorkspace(); + await Assert.That(vm.CurrentWorkspace).IsNull(); + await Assert.That(vm.IsSessionsView).IsTrue(); // placeholder pane, not Home + + vm.ShowHomeCommand.Execute().Subscribe(); + await Assert.That(vm.IsHomeView).IsTrue(); + vm.ShowSessionsCommand.Execute().Subscribe(); + await Assert.That(vm.IsSessionsView).IsTrue(); + }); +} + +[Test] +[NotInParallel("AvaloniaSession")] +public async Task Rail_selection_follows_the_workspace() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var service = new FakeDaemonClientService(); + service.Agents.AddOrUpdate(new AgentStatusDto( + "a1", "agent", "claude", "/dev/alpha", "Running", null, null, null, DateTime.UtcNow, null, null)); + var rail = new SessionRailViewModel(service, _ => { }, p => p); + var vm = NewVm(service, workspaceFactory: id => NewWorkspace(service, id), rail: rail); + + vm.OpenSession("a1"); + await Assert.That(rail.SelectedAgentId).IsEqualTo("a1"); + await Assert.That(rail.Repos[0].Worktrees[0].IsExpanded).IsTrue(); // NotifySessionOpened ran + + vm.CloseWorkspace(); + await Assert.That(rail.SelectedAgentId).IsNull(); + }); +} +``` + +(`NewVm`/`NewWorkspace` stand for whatever helpers the file already uses to build a `MainWindowViewModel`/`WorkspaceViewModel` against the fake service — reuse them verbatim; only add the optional `rail` pass-through to the helper.) + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet run --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/MainWindowViewModelTests/*'` +Expected: compile errors (`IsHomeView` etc. missing). + +- [ ] **Step 3: Implement** + +In `MainWindowViewModel.cs`: + +```csharp +/// Which surface owns the window: Home (status block + launcher + cards + Activity) or +/// Sessions (rail | workspace). Orthogonal to CurrentWorkspace, which only means anything in +/// Sessions view. +public enum ShellView { Home, Sessions } +``` + +Members (ctor-scoped, near CurrentWorkspace): + +```csharp + ShellView _currentView = ShellView.Home; + public ShellView CurrentView { + get => _currentView; + private set { + this.RaiseAndSetIfChanged(ref _currentView, value); + this.RaisePropertyChanged(nameof(IsHomeView)); + this.RaisePropertyChanged(nameof(IsSessionsView)); + } + } + public bool IsHomeView => CurrentView == ShellView.Home; + public bool IsSessionsView => CurrentView == ShellView.Sessions; + + public ReactiveCommand ShowHomeCommand { get; } + public ReactiveCommand ShowSessionsCommand { get; } + + public SessionRailViewModel? Rail { get; } +``` + +Ctor: add `SessionRailViewModel? rail = null` parameter (last), `Rail = rail;`, and: + +```csharp + ShowHomeCommand = ReactiveCommand.Create(() => { CurrentView = ShellView.Home; }); + ShowSessionsCommand = ReactiveCommand.Create(() => { CurrentView = ShellView.Sessions; }); +``` + +`OpenSession` becomes: + +```csharp + public void OpenSession(string agentId) { + if (_navigation.ShutdownLatched || _workspaceFactory is null) return; + CurrentView = ShellView.Sessions; + // Re-clicking the open session must not tear down and rebuild a live attach. + if (CurrentWorkspace?.AgentId == agentId) return; + + var workspace = _workspaceFactory(agentId); + SwapTo(workspace); + Rail?.NotifySessionOpened(agentId); + } +``` + +(The `workspace.BackCommand = CloseWorkspaceCommand;` line is removed in Task 7 together with the property — leave it in place for now so the build stays green.) + +`SwapTo` gains one line after `CurrentWorkspace = next;`: + +```csharp + if (Rail is not null) Rail.SelectedAgentId = next?.AgentId; +``` + +`LatchShutdown` similarly sets `if (Rail is not null) Rail.SelectedAgentId = null;` after clearing `CurrentWorkspace`. + +- [ ] **Step 4: Run to verify pass** + +Same command; the whole MainWindowViewModelTests class must stay green (pre-existing tests untouched). +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Capacitor.App/ViewModels/MainWindowViewModel.cs test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs +git commit -m "AI-2199: shell view state and rail wiring on the window VM" +``` + +--- + +### Task 7: XAML restructure — tabless window, rail view, Back removal + +**Files:** +- Create: `src/Capacitor.App/Views/SessionRailView.axaml` + `src/Capacitor.App/Views/SessionRailView.axaml.cs` +- Modify: `src/Capacitor.App/Views/MainWindow.axaml` (full restructure), `src/Capacitor.App/Views/MainWindow.axaml.cs` (Activity gating), `src/Capacitor.App/Views/WorkspaceView.axaml` (delete BackButton), `src/Capacitor.App/ViewModels/WorkspaceViewModel.cs` (delete `BackCommand`), `src/Capacitor.App/ViewModels/MainWindowViewModel.cs` (drop the `workspace.BackCommand` assignment) +- Test: `test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs` (rewrite tab-dependent tests), plus `grep`-found BackCommand tests + +**Interfaces:** +- Consumes: `IsHomeView`/`IsSessionsView`/`Rail`/`ShowHomeCommand`/`ShowSessionsCommand` (Task 6), `SessionRailViewModel` tree (Task 5). +- Produces: named controls the smoke tests use — `HomeSurface`, `SessionsSurface`, `SessionRail`, `RailNewSessionButton`, `RailHomeButton`, `RailSessionsButton`, `HomeSessionsButton`, `ActivityExpander`, `WorkspacePlaceholder`. Existing names preserved: `DaemonIdentityText`, `DaemonVersionText`, `ServerUrlText`, `ConnectionText`, `AgentCountText`, `StartButton`, `RetryButton`, `StartMessageText`, `ReasonText`, `ActivityEmptyText`, `ActivityHeader`, `ActivityItems`, `WorkspaceHost`. + +- [ ] **Step 1: Inventory the blast radius** + +Run: `grep -rn 'BackCommand\|BackButton\|MainTabs\|HomeTabItem\|ActivityTabItem\|AgentsGrid\|AgentsItems\|EmptyStateText\|AgentsGridHeader' src/Capacitor.App test/Capacitor.App.Tests.Unit --include='*.cs' --include='*.axaml'` +Every hit must be accounted for by the end of this task or Task 8 (Agents grid names die in Task 8's file deletions; tab and Back names die here). + +- [ ] **Step 2: Rewrite `MainWindow.axaml`** + +Keep the `rxui:ReactiveWindow` root attributes; change `Width="1200" Height="760"`. New body (the status block, Home content, and Activity block are the EXISTING XAML moved, not rewritten — control names preserved): + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Add the `xmlns:views` namespace, a `Styles` block giving `Button.selected` / `Button.holdsSelected` a raised background (`{DynamicResource SystemControlBackgroundListLowBrush}`), and a tiny `ExpanderChevronConverter` in `Views/Converters.cs` (pattern-match the existing converters there): `true → "▾"`, `false → "▸"`. Code-behind `SessionRailView.axaml.cs` is the standard empty `InitializeComponent` partial (copy `HomeView.axaml.cs`'s shape). + +- [ ] **Step 4: Back button removal** + +- `WorkspaceView.axaml`: delete the `BackButton` element; the title block takes its grid column. +- `WorkspaceViewModel.cs`: delete the `BackCommand` property (`_backCommand` field included). +- `MainWindowViewModel.OpenSession`: delete the `workspace.BackCommand = CloseWorkspaceCommand;` line. +- Fix every test the Step-1 grep found referencing `BackCommand`/`BackButton` — delete the assertions (the behavior they pinned no longer exists; `CloseWorkspaceCommand` tests stay). + +- [ ] **Step 5: Activity gating in `MainWindow.axaml.cs`** + +Replace `OnTabSelectionChanged` and `_activityTabSelected` with: + +```csharp + // Activity polls only when it is ACTUALLY on screen: window visible AND Home view AND the + // section expanded — the same contract the Activity tab's selection used to carry. + bool _activityExpanded; + + void OnActivityExpandChanged(object? sender, RoutedEventArgs e) { + _activityExpanded = ActivityExpander.IsExpanded; + UpdateActivityVisibility(); + } + + void UpdateActivityVisibility() { + if (DataContext is MainWindowViewModel vm) + vm.Activity.OnTabVisibleChanged(_activityExpanded && IsVisible && vm.IsHomeView); + } +``` + +Keep the existing `OnPropertyChanged` override (IsVisible/DataContext triggers). Add view-change tracking in the constructor: + +```csharp + this.WhenActivated(disposables => { + ViewModel?.WhenAnyValue(x => x.IsHomeView) + .Subscribe(_ => UpdateActivityVisibility()) + .DisposeWith(disposables); + }); +``` + +(`using System.Reactive.Disposables.Fluent;` and `ReactiveUI` are already the codebase's idiom.) + +- [ ] **Step 6: Update the smoke tests** + +In `MainWindowSmokeTests.cs`: +- Delete `SelectAgentsTab` and every test that clicks through the Agents tab (their grid subject dies in Task 8; if a test asserts VM-level stop/open-in-web behavior rather than grid rendering, move the assertion to `AgentActionServiceTests` style or drop it if already covered there). +- The identity test stays as-is (the status block still renders on the default Home view). +- Add two new tests in the same file's style: + +```csharp +[Test] +[NotInParallel("AvaloniaSession")] +public async Task Rail_click_opens_the_workspace_in_sessions_view() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var opened = await AvaloniaSession.DispatchAsync(() => { + var service = new FakeDaemonClientService(); + service.SnapshotsSubject.OnNext(Snap()); + service.StatusSubject.OnNext(new AttachStatus(AttachState.Connected, null, null)); + service.Agents.AddOrUpdate(new AgentStatusDto( + "a1", "agent", "claude", "/dev/alpha/wt/feature-x", "Running", + null, null, null, DateTime.UtcNow, null, null, Title: "Fix the flaky test")); + + var (actions, _) = NewActions(service); + MainWindowViewModel? vm = null; + var rail = new SessionRailViewModel(service, id => vm!.OpenSession(id), p => p.Contains("/wt/") ? p[..p.IndexOf("/wt/", StringComparison.Ordinal)] : p); + vm = new MainWindowViewModel(service, actions, new FakeTicker(), CancellationToken.None, TestActivity.New(), + workspaceFactory: id => WorkspaceFixtures.NewWorkspace(service, actions, id), rail: rail); + var window = new MainWindow { DataContext = vm }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + vm.ShowSessionsCommand.Execute().Subscribe(); + Dispatcher.UIThread.RunJobs(); + + var row = window.GetVisualDescendants().OfType - - - - + + - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - diff --git a/src/Capacitor.App/Views/MainWindow.axaml.cs b/src/Capacitor.App/Views/MainWindow.axaml.cs index d956a3e324..f1897d2d4d 100644 --- a/src/Capacitor.App/Views/MainWindow.axaml.cs +++ b/src/Capacitor.App/Views/MainWindow.axaml.cs @@ -1,6 +1,6 @@ +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using Avalonia; -using Avalonia.Controls; using Avalonia.Controls.Notifications; using Capacitor.App.Services; using Capacitor.App.ViewModels; @@ -23,9 +23,9 @@ public partial class MainWindow : ReactiveWindow { IDisposable? _notifierSubscription; IAppNotifier? _notifier; - // Defaults to false — the Home TabItem is selected first (MainWindow.axaml), so Activity - // starts unselected regardless of the window's own visibility. - bool _activityTabSelected; + // Defaults to false — the Activity flyout starts closed (MainWindow.axaml), so Activity is off + // regardless of the window's own visibility. + bool _activityOpen; /// Assigned by App.BuildAndShowMainWindow (spec §11) — the SAME IAppNotifier instance /// AgentActionService pushes into, so the toast overlay and stderr mirroring are always in @@ -58,6 +58,25 @@ public MainWindow() { Loaded += (_, _) => _notifications ??= new WindowNotificationManager(this) { Position = NotificationPosition.TopRight, }; + + // The Activity feed rides a flyout off its chip: open/closed IS the section's + // expanded/collapsed state for the polling gate below. + if (ActivityButton.Flyout is { } activityFlyout) { + activityFlyout.Opened += (_, _) => { _activityOpen = true; UpdateActivityVisibility(); }; + activityFlyout.Closed += (_, _) => { _activityOpen = false; UpdateActivityVisibility(); }; + } + + this.WhenActivated(disposables => { + ViewModel?.WhenAnyValue(x => x.IsSessionsView, x => x.CurrentWorkspace) + .Subscribe(state => { + // A popup can't meaningfully survive the pane swapping under it — opening a + // workspace (or leaving the Sessions surface) closes the feed; its Closed + // handler then turns the gate off. + if (!state.Item1 || state.Item2 is not null) ActivityButton.Flyout?.Hide(); + UpdateActivityVisibility(); + }) + .DisposeWith(disposables); + }); } // A toast fired before Loaded, or while the window is hidden (Hide() suspends rendering @@ -67,13 +86,9 @@ public MainWindow() { void ShowToast(string message) => _notifications?.Show(new Notification("Kurrent Capacitor", message, NotificationType.Warning, TimeSpan.FromSeconds(4))); - // Wired from MainWindow.axaml's TabControl (spec §7): the Activity tab's refresh cadence - // needs to know it is ACTUALLY on screen, which is the AND of tab selection and the window's - // own visibility below — a background window with Activity selected must not poll. - void OnTabSelectionChanged(object? sender, SelectionChangedEventArgs e) { - _activityTabSelected = e.AddedItems.Count > 0 && ReferenceEquals(e.AddedItems[0], ActivityTabItem); - UpdateActivityVisibility(); - } + // The launcher header strip and the bottom-most drag strip — see WindowChrome. + void OnChromePointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) => + WindowChrome.BeginDrag(this, e); // IsVisible is decompile-verified to be exactly what Show()/Hide() toggle (see // App.ShowConfirmForceStopDialogAsync's owner check) — hide-to-tray never fires Closed/Opened @@ -88,7 +103,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == IsVisibleProperty || change.Property == DataContextProperty) UpdateActivityVisibility(); } + // Activity polls only when it is ACTUALLY on screen: window visible AND the launcher pane + // showing (Sessions surface, no workspace open) AND the flyout open — the same contract the + // Activity tab's selection used to carry. void UpdateActivityVisibility() { - if (DataContext is MainWindowViewModel vm) vm.Activity.OnTabVisibleChanged(_activityTabSelected && IsVisible); + if (DataContext is MainWindowViewModel vm) + vm.Activity.OnTabVisibleChanged(_activityOpen && IsVisible && vm.IsSessionsView && vm.CurrentWorkspace is null); } } diff --git a/src/Capacitor.App/Views/SessionRailView.axaml b/src/Capacitor.App/Views/SessionRailView.axaml new file mode 100644 index 0000000000..f41321912c --- /dev/null +++ b/src/Capacitor.App/Views/SessionRailView.axaml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Capacitor.App/Views/SessionRailView.axaml.cs b/src/Capacitor.App/Views/SessionRailView.axaml.cs new file mode 100644 index 0000000000..0d90b55708 --- /dev/null +++ b/src/Capacitor.App/Views/SessionRailView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace Capacitor.App.Views; + +/// The Sessions surface's rail. DataContext is the window's own MainWindowViewModel, inherited — +/// this view never builds or assigns one, same contract as HomeView. +public partial class SessionRailView : UserControl { + public SessionRailView() => InitializeComponent(); + + // The rail's 44px chrome row IS the title bar on this surface — see WindowChrome. + void OnChromePointerPressed(object? sender, PointerPressedEventArgs e) => WindowChrome.BeginDrag(this, e); +} diff --git a/src/Capacitor.App/Views/VendorIcons.cs b/src/Capacitor.App/Views/VendorIcons.cs new file mode 100644 index 0000000000..5791cb9a3c --- /dev/null +++ b/src/Capacitor.App/Views/VendorIcons.cs @@ -0,0 +1,27 @@ +using Avalonia.Media; + +namespace Capacitor.App.Views; + +/// Brand marks for the harness picker and agent chip: Simple Icons path data (CC0, 24x24 +/// viewBox), one filled path per vendor. Vendors without an entry fall back to the monogram +/// tile. Parsed per call, never cached statically -- Geometry is an AvaloniaObject with +/// thread affinity, the same trap MainWindowViewModel.DotBrush documents for brushes. +public static class VendorIcons { + static readonly Dictionary PathData = new(StringComparer.OrdinalIgnoreCase) { + ["claude"] = + "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z", + ["codex"] = + "M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z", + ["cursor"] = + "M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23", + ["copilot"] = + "M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z", + ["gemini"] = + "M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81", + ["opencode"] = + "M22 24H2V0h20zM17 4.8H7v14.4h10z", + }; + + public static Geometry? For(string vendor) => + PathData.TryGetValue(vendor, out var data) ? Geometry.Parse(data) : null; +} diff --git a/src/Capacitor.App/Views/WindowChrome.cs b/src/Capacitor.App/Views/WindowChrome.cs new file mode 100644 index 0000000000..bc7f118b25 --- /dev/null +++ b/src/Capacitor.App/Views/WindowChrome.cs @@ -0,0 +1,15 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace Capacitor.App.Views; + +/// The one "empty chrome space drags the window" rule: the client area extends into the title +/// bar, so each surface's top strip must move the window the way the system bar used to. Buttons +/// in a strip mark their presses handled, so a wired handler only fires on the blank stretch. +public static class WindowChrome { + public static void BeginDrag(Visual host, PointerPressedEventArgs e) { + if (e.GetCurrentPoint(host).Properties.IsLeftButtonPressed && TopLevel.GetTopLevel(host) is Window window) + window.BeginMoveDrag(e); + } +} diff --git a/src/Capacitor.App/Views/WorkspaceView.axaml b/src/Capacitor.App/Views/WorkspaceView.axaml index 9c0c1fa940..b146ae4352 100644 --- a/src/Capacitor.App/Views/WorkspaceView.axaml +++ b/src/Capacitor.App/Views/WorkspaceView.axaml @@ -7,49 +7,51 @@ x:DataType="vm:WorkspaceViewModel" Background="{StaticResource KcapCanvasBrush}"> - - -