From b6d430105555209366e2e186a22ea40a6154656e Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 25 Aug 2026 13:12:44 +0200 Subject: [PATCH 01/24] AI-2199 spec: session rail and the tabless shell Co-Authored-By: Claude Fable 5 --- .../2026-08-25-ai2199-session-rail-design.md | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md diff --git a/docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md b/docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md new file mode 100644 index 0000000000..df10b6aa02 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md @@ -0,0 +1,288 @@ +# Session rail and the tabless shell (AI-2199) + +Slice 3 of the desktop shell (parent AI-2171; design canvas "Hosted Agents Shell", +Session artboard plus the Home artboard's structure). The navigation half: a 310px +left rail grouping **repository → worktree → session**, and the window restructure +it forces — the tabbed shell (Home | Agents | Activity) is replaced by two +top-level views, Home and Sessions. The workspace the rail selects into is +AI-2195's, already merged. + +Out of scope, by prior decomposition and by the canvas annotations: the Chat tab +and composer (AI-2196), structured turn frames and real attention events +(AI-2197), the work-context sidebar (AI-2198), Home's full 250px nav column with +the Needs-you view and per-repo counts, the work-item lanes, and real search. +Deferred within the rail itself: the per-repo "+" quick-launch (needs +repo-preselection plumbing into the launcher) and branch names on worktree rows +(needs a wire field; see Decisions 3). + +## Decisions + +Settled with the owner during brainstorming, 2026-08-25: + +1. **Two views, no tabs.** The UX artifact removes the `TabControl` entirely: the + window is either the **Home** view (daemon status block, launcher, session + cards) or the **Sessions** view (rail | workspace). An earlier + rail-beside-tabs shape was considered and superseded by the artifact. The + Agents tab is **deleted** — the rail plus the workspace header's Stop / + Open in web supersede it. The Activity feed **moves onto Home** as a + collapsed section: the consent surface survives, just not as a tab. +2. **Needs-you pip = `Status == "Failed"`, for now.** The wire carries no + attention signal yet; failure is the one real needs-attention state the + daemon already reports. The pip plumbing (per-session flag, worktree + roll-up, collapsed-row visibility) is built now; AI-2197's attention events + later become another input to the same flag. Rejected: a `needs_attention` + wire field now (reaches into AI-2197's territory with nothing real to feed + it), and no pip at all (the roll-up is the part that needs designing now). +3. **Worktree label = checkout leaf.** No daemon or wire change; the worktree + directory's own leaf (`federated-shimmying-key`) labels the row, full path in + the tooltip. The artifact shows branch names — that is the designated + upgrade, an additive `branch` field a later issue populates at registration. + Note `RepoLabel.Leaf` is the WRONG helper here: it deliberately maps + `.claude/worktrees/` to the repo name; the rail needs the actual leaf. +4. **Home's session cards stay.** Cards remain the richer at-a-glance surface; + the rail is the always-visible navigator. Consolidation can come with the + later Home issue if the redundancy bothers. +5. **Projection: nested DynamicData groups** (owner's pick over a + flat-rebuild-per-changeset). `Group` by repo root, nested `Group` by + checkout path, `SortAndBind` at each level, `DisposeMany` everywhere. The + disposal chain and group-recreation behavior are the known hazards and get + explicit tests. +6. **`AgentStatusDto` gains `Title` now** (owner's pick over vendor-only rows). + The daemon already holds the launch prompt per agent; the row's primary text + is the prompt's first line, truncated. Null-safe: older daemons and + prompt-less launches fall back to the vendor line. +7. **Minimal Home ⇄ Sessions switcher.** The rail's top carries the artifact's + two icon buttons (Home, Sessions); Home gets a matching "Sessions" entry + point. The full nav column stays with a later Home issue. + +## 1. Wire change (additive only) + +`AgentStatusDto` (`src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs`) gains one +field: + +- `Title` — a **trailing `string? Title = null`** member, so every existing + positional construction stays valid; serialized `title`, always emitted + (null, never omitted — the context's own doc pins that rule). The daemon + stamps it in `SnapshotAgentsForStatus` + (`AgentOrchestrator.LocalIpc.cs`): the agent's `Prompt`, normalized at the + wire boundary like `Model` already is — first non-blank line, trimmed, + truncated to 80 chars with a trailing ellipsis when cut; null when the + prompt is null or whitespace. `null` means "older daemon, or no goal text". + +The truncation is deliberate: the full prompt can be arbitrarily long and the +status payload is re-sent on every revision. The socket is the owner's 0600 +local channel, so this is a size decision, not a secrecy one. Nothing else on +the wire changes; `FrameType` is untouched. + +Serialization acceptance (extends the existing StatusIpc JSON tests): old JSON +without the member deserializes to `null`; a value and `null` both serialize +with `title` present in the declared trailing order. The daemon-side test +asserts the serialized payload for a prompted launch, a blank prompt, and a +multi-line prompt (first line only). + +## 2. Shell restructure + +### Views + +`MainWindowViewModel` gains a view state alongside the existing workspace slot: + +- `CurrentView` ∈ { Home, Sessions } (default Home), and the existing + `CurrentWorkspace` (null = no session selected) now scoped to the Sessions + view. `MainWindow.axaml`'s root becomes: Home surface visible when + `CurrentView == Home`; Sessions surface (rail | workspace-or-placeholder) + when `CurrentView == Sessions`. +- `OpenSession(agentId)` keeps its shutdown-latch and null-factory guards, + gains a **same-session no-op** (`CurrentWorkspace?.AgentId == agentId` + returns early — today's path would tear down and rebuild a live attach for + nothing), and now also sets `CurrentView = Sessions`. Every entry point + (rail row, Home card, launch auto-open with its captured generation) lands + in the Sessions view with the workspace open. +- `CloseWorkspaceCommand` survives for the coordinator's close/shutdown paths + and clears the workspace without leaving the Sessions view (the pane shows + the muted "Select a session" placeholder). The workspace's visible **Back + button goes away** — the rail is always beside it and the Home icon covers + the way out. `LatchShutdown` is unchanged. +- The **daemon status block** (name/version, connection line, Start/Retry, + StartMessage/Reason) stays at the top of the Home view, unchanged. The + Sessions view's equivalent is the rail footer (§3). +- Default window size 840×480 → **1200×760**: the workspace keeps a + terminal-usable width beside the 310px rail. + +### Activity moves to Home + +The Activity feed becomes a collapsed section on Home, below the session +cards. Its refresh-cadence contract is preserved with the same shape it has +today: `ActivityViewModel.OnTabVisibleChanged` now receives the AND of +window `IsVisible`, `CurrentView == Home`, and the section being expanded — a +background window, the Sessions view, and a collapsed section all stop the +polling, exactly as an unselected tab does today. The tab-selection wiring in +`MainWindow.axaml.cs` (`OnTabSelectionChanged`) is replaced by view-change and +expander wiring feeding the same method. + +### Agents tab deletion + +The Agents grid, its header, and the `Agents` projection in +`MainWindowViewModel` (the `_agentsSource` SortAndBind pipeline) are deleted, +along with `AgentRowViewModel` if nothing else consumes it (the tray has its +own models; `AgentActionService` stays — the workspace and tray use it). +Tests pinned to the grid (`AgentGridTests`, tab-related assertions in the +startup/smoke tests) are deleted or retargeted at the rail. + +## 3. The rail + +### Layout (matching the Session artboard) + +Top-to-bottom inside the 310px column: + +- Header row: spacer for the traffic lights, then two icon buttons — **Home** + and **Sessions** — the view switcher; Sessions renders active while this + view is up. +- **New session** row (⌘N hint): navigates to the Home view, where the + launcher is. +- The tree (scrollable): repo header rows, worktree rows, session rows, and + the No-repository group. +- Footer: status dot + connection word, bound to the window VM's existing + `StatusDotBrush`/`ConnectionDisplay` (the rail view lives inside + `MainWindow.axaml`, where that DataContext is at hand — the rail VM does not + re-derive them), plus "N hosted" from the rail VM's own session count. + +Empty rail (no sessions at all): a muted "No sessions" placeholder in the +tree area. + +### Rows + +- **Repository header**: caps leaf label of the resolved main root, "N + sessions" count. Not collapsible — only the worktree level collapses, so a + repo header can never hide an alert. Tooltip: full root path. +- **Worktree row**: chevron, branch icon, checkout-leaf label, session count, + amber `!` pip when any child session's `Status == "Failed"` (shown expanded + or collapsed — the pip is what makes a collapsed row safe). Click toggles + expansion. Tooltip: full checkout path. **The main checkout is its own + worktree-level row labeled `main checkout`, starting collapsed** (the + artboard annotation: "main starts collapsed — that is what keeps three + levels readable in 310px"); other worktrees start expanded. +- **Session row**: indented under a left guide-line; status dot (reuse + `SessionCardViewModel`'s immutable-brush status mapping); primary text = + `Title`, falling back to the vendor line when null; sub-line + "Vendor · Model · age" — with a null Title the vendor moves up to primary + and the sub-line drops it ("Model · age"). Model omitted when null; age is + a point-in-time snapshot like the Home cards — refreshed on dto revisions, + not ticked. Non-`agent` kinds append their kind to the vendor + ("codex · review"). `!` + pip when Failed. Selected row highlighted; the worktree row holding the + selection carries the artifact's holds-selected tint. Tooltip: id, status, + requester. +- **No repository group**: sorts last; header plus the inline muted note + ("Work with no checkout yet. It keeps a title and a session; parts and + blockers arrive once it lands in a repo."), sessions directly under it — + no worktree level. + +Sort orders: repos by leaf label (ordinal-ignore-case), No-repository last; +worktrees within a repo: main checkout first, then leaf label; sessions by +`CreatedAt` then `Id` ordinal — the existing convention everywhere. + +### Projection + +New `SessionRailViewModel`, ctor-scoped and disposable like `HomeViewModel`, +over the same `daemon.Agents` cache: + +``` +daemon.Agents.Connect() + .ObserveOn(RxSchedulers.MainThreadScheduler) // before anything bound mutates + .Group(dto => repoRootFor(dto)) // "" sentinel for null RepoPath + .Transform(g => new RailRepoViewModel(g, …)) + .DisposeMany() + .SortAndBind(_repos, RepoComparer) +``` + +Inside `RailRepoViewModel`: `group.Cache.Connect().Group(dto => dto.RepoPath)` +→ `Transform` to `RailWorktreeViewModel` → `DisposeMany` → `SortAndBind`. +Inside `RailWorktreeViewModel`: sessions `Transform`ed to +`RailSessionViewModel`, `SortAndBind`, plus the two aggregates (`SessionCount`, +`NeedsYou` = any child Failed) derived from the same shared connect. Every +level disposes its nested subscription when its VM is disposed; `DisposeMany` +covers per-item removal and teardown. + +The No-repository repo VM (the "" sentinel group) still nests one worktree +group internally — its sessions all carry the same null RepoPath — but the +view renders that single group headerless, sessions directly under the repo +header and note. + +`repoRootFor` = `GitRepository.ResolveMainRepoRoot(dto.RepoPath)`, memoized +per path inside the rail VM — it reads `.git` files, cheap once but not +per-changeset cheap, and a path's resolution never changes within a daemon's +lifetime. + +**Collapse state lives outside the group VMs**: the rail VM owns a +`Dictionary` of explicit user choices keyed by checkout path, +with the default rule "collapsed iff main checkout" applied when no entry +exists. A `RailWorktreeViewModel` reads it at construction and writes it on +toggle. DynamicData drops and re-forms a group whenever it empties or the +cache resets (reconnect) — state held on the VM itself would silently reset. +`OpenSession` clears the collapsed entry for the target session's worktree, +so a launch auto-open into a collapsed `main checkout` never highlights an +invisible row. + +Selection: the window pushes `CurrentWorkspace?.AgentId` into +`SessionRailViewModel.SelectedAgentId`; session rows derive `IsSelected`, +worktree rows derive holds-selected. A session that ends while open stays in +the workspace (frozen header, existing behavior) but leaves the rail; nothing +is then highlighted. + +Disconnects: rows persist (the service retains the `Agents` cache across +disconnects) and stay clickable — the rail is navigation, not a daemon +mutation; the footer's connection word is what says the daemon is gone. + +## 4. Testing + +`test/Capacitor.Cli.Core.Tests.Unit`: +- StatusIpc JSON: `title` round-trip, trailing order, null always emitted, + old-JSON-without-member → null. + +`test/Capacitor.Cli.Daemon.Tests.Unit`: +- `SnapshotAgentsForStatus` stamps Title: prompted launch (verbatim short + prompt), multi-line prompt (first line only), long prompt (80-char cut + + ellipsis), blank/null prompt (null on the wire). Asserts the serialized + payload, not just the in-memory DTO. + +`test/Capacitor.App.Tests.Unit` (over `FakeDaemonClientService`): +- Grouping: worktree paths resolve under their repo; main checkout and + worktrees split correctly; null RepoPath lands in No-repository, last. +- Sort orders at all three levels. +- Aggregates: session count, pip roll-up, pip clears when the failed session + recovers or leaves. +- Collapse: main checkout defaults collapsed, others expanded; a toggle + survives dto revisions AND group recreation (empty the group, refill it); + `OpenSession` expands the target's worktree. +- Row text: Title primary, vendor fallback, kind suffix, model-less sub-line. +- Selection: `SelectedAgentId` → `IsSelected`/holds-selected; ended-session + leaves nothing highlighted. +- Disposal: disposing the rail VM tears down every nested subscription (no + further mutations after dispose when the cache changes). +- `MainWindowViewModel`: same-session no-op; `OpenSession` sets Sessions + view; `CloseWorkspaceCommand` keeps the view; shutdown latch unchanged. +- Activity cadence: polling gated on window-visible AND Home view AND section + expanded — each of the three flips it off. +- Headless `AvaloniaSession` smoke: tabless window renders both views; rail + click opens the workspace; New session lands on Home. + +README: the desktop-app section is checked in the same PR — if it documents +the tabbed layout or the Agents tab, it moves to the two-view structure. + +## Risks + +- **Nested DynamicData pipelines** are the hardest DynamicData shape to hold + correct — double-subscription on reactivation, leaked inner subscriptions, + and group-recreation edge cases. Mitigated by ctor-scoping (no + WhenActivated re-runs), `DisposeMany` at every level, and the explicit + disposal/recreation tests above. +- **Tab removal has a wide test blast radius**: startup, smoke, and grid + tests reference tab items by name. The deletion is deliberate and the spec + treats retargeting those tests as part of the work, not collateral. +- **`Title` mirrors prompt text into every status payload.** Bounded to one + truncated line, and the payload already travels only the owner's 0600 + socket; still, the field must never grow past the truncation rule without + revisiting this. +- **Age on session rows is a point-in-time snapshot** (Home-card precedent): + a quiet session's age stales until its next dto revision. Accepted; a + shared ticker can upgrade both surfaces later. From 885dd69a5a9c9b1a1cd46f2a7eb7f8708d7c35e0 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 25 Aug 2026 15:00:45 +0200 Subject: [PATCH 02/24] AI-2199 plan: session rail and tabless shell Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-25-ai2199-session-rail.md | 1573 +++++++++++++++++ 1 file changed, 1573 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-ai2199-session-rail.md 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/SessionRailView.axaml.cs b/src/Capacitor.App/Views/SessionRailView.axaml.cs new file mode 100644 index 0000000000..5425e7fcfc --- /dev/null +++ b/src/Capacitor.App/Views/SessionRailView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +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(); +} diff --git a/src/Capacitor.App/Views/WorkspaceView.axaml b/src/Capacitor.App/Views/WorkspaceView.axaml index 9c0c1fa940..17113d9892 100644 --- a/src/Capacitor.App/Views/WorkspaceView.axaml +++ b/src/Capacitor.App/Views/WorkspaceView.axaml @@ -7,22 +7,17 @@ x:DataType="vm:WorkspaceViewModel" Background="{StaticResource KcapCanvasBrush}"> - - - @@ -61,9 +91,12 @@ Activity feed's refresh cadence tracks this section's expansion, the Home surface being the one on screen, and the window's own visibility. --> + Expanded="OnActivityExpandChanged" Collapsed="OnActivityExpandChanged" + Foreground="{StaticResource KcapTextBrush}" Background="{StaticResource KcapSurfaceBrush}" + BorderBrush="{StaticResource KcapBorderBrush}" CornerRadius="10" Margin="0,0,0,20"> diff --git a/src/Capacitor.App/Views/MainWindow.axaml.cs b/src/Capacitor.App/Views/MainWindow.axaml.cs index bd5a961e03..551d48921d 100644 --- a/src/Capacitor.App/Views/MainWindow.axaml.cs +++ b/src/Capacitor.App/Views/MainWindow.axaml.cs @@ -74,6 +74,13 @@ public MainWindow() { void ShowToast(string message) => _notifications?.Show(new Notification("Kurrent Capacitor", message, NotificationType.Warning, TimeSpan.FromSeconds(4))); + // The client area extends into the title bar, so the Home surface's header strip (and the + // bottom-most drag strip) must move the window the way the system bar used to. Buttons in the + // strip mark their presses handled, so this only fires on empty space. + void OnChromePointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) BeginMoveDrag(e); + } + // Wired from MainWindow.axaml's ActivityExpander (spec §7). void OnActivityExpandChanged(object? sender, RoutedEventArgs e) { _activityExpanded = ActivityExpander.IsExpanded; diff --git a/src/Capacitor.App/Views/SessionRailView.axaml b/src/Capacitor.App/Views/SessionRailView.axaml index 90a77e7a56..143fdafd64 100644 --- a/src/Capacitor.App/Views/SessionRailView.axaml +++ b/src/Capacitor.App/Views/SessionRailView.axaml @@ -15,51 +15,101 @@ declared AFTER this base to win the tie. --> + + + + - + - - - - - - - - + + + + + + + + + - + - - - - - + + + + @@ -67,45 +117,70 @@ - - - - - - - + + + + + + + + + + diff --git a/src/Capacitor.App/Views/SessionRailView.axaml.cs b/src/Capacitor.App/Views/SessionRailView.axaml.cs index 5425e7fcfc..9b35902c45 100644 --- a/src/Capacitor.App/Views/SessionRailView.axaml.cs +++ b/src/Capacitor.App/Views/SessionRailView.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Input; namespace Capacitor.App.Views; @@ -6,4 +7,12 @@ namespace Capacitor.App.Views; /// this view never builds or assigns one, same contract as HomeView. public partial class SessionRailView : UserControl { public SessionRailView() => InitializeComponent(); + + // The window extends its client area into the title bar, so the rail's 48px chrome row IS the + // title bar on this surface — empty space there must still move the window. Buttons in the row + // mark their presses handled, so this only fires on the blank stretch. + void OnChromePointerPressed(object? sender, PointerPressedEventArgs e) { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && TopLevel.GetTopLevel(this) is Window window) + window.BeginMoveDrag(e); + } } diff --git a/src/Capacitor.App/Views/WorkspaceView.axaml b/src/Capacitor.App/Views/WorkspaceView.axaml index 17113d9892..e4991ceabe 100644 --- a/src/Capacitor.App/Views/WorkspaceView.axaml +++ b/src/Capacitor.App/Views/WorkspaceView.axaml @@ -9,42 +9,49 @@ - - - + + - + - - - - - - + + + + + + + + - - - - - + + + - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/src/Capacitor.App/Views/MainWindow.axaml.cs b/src/Capacitor.App/Views/MainWindow.axaml.cs index 551d48921d..96ea6b4c73 100644 --- a/src/Capacitor.App/Views/MainWindow.axaml.cs +++ b/src/Capacitor.App/Views/MainWindow.axaml.cs @@ -2,7 +2,6 @@ using System.Reactive.Linq; using Avalonia; using Avalonia.Controls.Notifications; -using Avalonia.Interactivity; using Capacitor.App.Services; using Capacitor.App.ViewModels; using ReactiveUI; @@ -24,9 +23,9 @@ public partial class MainWindow : ReactiveWindow { IDisposable? _notifierSubscription; IAppNotifier? _notifier; - // Defaults to false — ActivityExpander starts collapsed (MainWindow.axaml), so Activity is off + // Defaults to false — the Activity flyout starts closed (MainWindow.axaml), so Activity is off // regardless of the window's own visibility. - bool _activityExpanded; + 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 @@ -60,9 +59,21 @@ public MainWindow() { 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.IsHomeView) - .Subscribe(_ => UpdateActivityVisibility()) + .Subscribe(isHome => { + // A popup can't meaningfully survive the surface swap under it — leaving Home + // closes the feed (its Closed handler then turns the gate off). + if (!isHome) ActivityButton.Flyout?.Hide(); + UpdateActivityVisibility(); + }) .DisposeWith(disposables); }); } @@ -81,12 +92,6 @@ void OnChromePointerPressed(object? sender, Avalonia.Input.PointerPressedEventAr if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) BeginMoveDrag(e); } - // Wired from MainWindow.axaml's ActivityExpander (spec §7). - void OnActivityExpandChanged(object? sender, RoutedEventArgs e) { - _activityExpanded = ActivityExpander.IsExpanded; - UpdateActivityVisibility(); - } - // IsVisible is decompile-verified to be exactly what Show()/Hide() toggle (see // App.ShowConfirmForceStopDialogAsync's owner check) — hide-to-tray never fires Closed/Opened // (MainWindowCoordinator's own doc comment: it "never detaches this window from the visual @@ -101,9 +106,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } // Activity polls only when it is ACTUALLY on screen: window visible AND Home surface AND the - // section expanded — the same contract the Activity tab's selection used to carry. + // flyout open — the same contract the Activity tab's selection used to carry. void UpdateActivityVisibility() { if (DataContext is MainWindowViewModel vm) - vm.Activity.OnTabVisibleChanged(_activityExpanded && IsVisible && vm.IsHomeView); + vm.Activity.OnTabVisibleChanged(_activityOpen && IsVisible && vm.IsHomeView); } } diff --git a/src/Capacitor.App/Views/SessionRailView.axaml b/src/Capacitor.App/Views/SessionRailView.axaml index 143fdafd64..12decd233e 100644 --- a/src/Capacitor.App/Views/SessionRailView.axaml +++ b/src/Capacitor.App/Views/SessionRailView.axaml @@ -68,12 +68,12 @@ Background="{StaticResource KcapSurfaceRaisedBrush}" BorderBrush="{StaticResource KcapBorderBrush}" BorderThickness="1" CornerRadius="7" Padding="11,7"> - - - @@ -83,10 +83,10 @@ + FontSize="11.5" Foreground="{StaticResource KcapFaintBrush}" VerticalAlignment="Center" /> - @@ -101,14 +101,14 @@ - @@ -122,7 +122,7 @@ Classes="railRow" Classes.holdsSelected="{Binding HoldsSelected}"> - @@ -138,7 +138,7 @@ - @@ -167,10 +167,10 @@ - - diff --git a/src/Capacitor.App/Views/WorkspaceView.axaml b/src/Capacitor.App/Views/WorkspaceView.axaml index e4991ceabe..b146ae4352 100644 --- a/src/Capacitor.App/Views/WorkspaceView.axaml +++ b/src/Capacitor.App/Views/WorkspaceView.axaml @@ -12,9 +12,9 @@ - - @@ -27,17 +27,17 @@ IsVisible="{Binding FamilyDot, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> - + + + - -