diff --git a/src/Capacitor.App/Views/ChatTabView.axaml.cs b/src/Capacitor.App/Views/ChatTabView.axaml.cs index 0909f8220a..4f7dbfd7f4 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml.cs +++ b/src/Capacitor.App/Views/ChatTabView.axaml.cs @@ -87,6 +87,18 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) () => (DataContext as ChatTabViewModel)?.Attachments, TimeProvider.System); } + /// The window reuses this view for every session it opens, so the reading position belongs to + /// the session it was showing: the next one starts at the tail. + protected override void OnDataContextChanged(EventArgs e) { + DropToggleAnchor(); + CancelSmoothScroll(); + _thumbDragging = false; + _thumbFraction = null; + _readerGesture = false; + _followTail = true; + base.OnDataContextChanged(e); + } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { DropToggleAnchor(); CancelSmoothScroll(); diff --git a/src/Capacitor.App/Views/MainWindow.axaml b/src/Capacitor.App/Views/MainWindow.axaml index 029f070a1f..c399e011e3 100644 --- a/src/Capacitor.App/Views/MainWindow.axaml +++ b/src/Capacitor.App/Views/MainWindow.axaml @@ -142,12 +142,12 @@ - + - - + + - + diff --git a/src/Capacitor.App/Views/RetainedViewTemplate.cs b/src/Capacitor.App/Views/RetainedViewTemplate.cs new file mode 100644 index 0000000000..eefb57a845 --- /dev/null +++ b/src/Capacitor.App/Views/RetainedViewTemplate.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Markup.Xaml.Templates; + +namespace Capacitor.App.Views; + +/// A data template that builds its view once and hands the same instance back on every later +/// build, including after the presenter has dropped it for null or another template's content. +/// On macOS a detached control stays reachable from any accessibility peer a client created for +/// it, so a template that builds afresh leaks the whole previous view on each rebuild. +public sealed class RetainedViewTemplate : DataTemplate, IRecyclingDataTemplate { + Control? _view; + + Control? ITemplate.Build(object? param) => Build(param, null); + + Control? IRecyclingDataTemplate.Build(object? data, Control? existing) => Build(data, existing); + + new Control? Build(object? data, Control? existing) { + // Another presenter matching this template must not steal the view from the one showing it. + if (_view is { Parent: not null } && !ReferenceEquals(_view, existing)) return base.Build(data, null); + return _view ??= base.Build(data, null); + } +} diff --git a/src/Capacitor.App/Views/TerminalClipboard.cs b/src/Capacitor.App/Views/TerminalClipboard.cs index a55742ac16..7ce2accb9c 100644 --- a/src/Capacitor.App/Views/TerminalClipboard.cs +++ b/src/Capacitor.App/Views/TerminalClipboard.cs @@ -25,9 +25,10 @@ static bool IsPaste(KeyEventArgs e) => Application.Current?.PlatformSettings?.HotkeyConfiguration.Paste.Any(g => g.Matches(e)) == true; internal static async Task PasteAsync(TerminalControl terminal) { - if (TopLevel.GetTopLevel(terminal)?.Clipboard is not { } clipboard) return; + if (TopLevel.GetTopLevel(terminal)?.Clipboard is not { } clipboard || terminal.Model is not { } model) return; var text = await clipboard.TryGetTextAsync(); - if (string.IsNullOrEmpty(text) || terminal.Model is not { } model) return; + // The control is reused across sessions: a paste outlived by its session goes nowhere. + if (string.IsNullOrEmpty(text) || !ReferenceEquals(terminal.Model, model)) return; model.Send(TerminalInputEncoder.Paste(text)); } } diff --git a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs index dc9d04e233..7e4d4619f0 100644 --- a/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs @@ -473,6 +473,34 @@ await RunOnUiAsync(async () => { }); } + /// The window reuses one view for every session it opens: a reader who scrolled up in one + /// session must not open the next one scrolled up and off the tail. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_new_session_in_the_same_view_opens_at_the_bottom_and_follows() { + await RunOnUiAsync(async () => { + var host = new Host(); + await host.LoadAsync(Tmp.CreateFile("first.jsonl", Enumerable.Repeat(UserLine, 60).ToArray())); + WheelUp(host); + host.Settle(); + await Assert.That(host.Scroll.Offset.Y).IsEqualTo(0); + + var next = new Host(show: false); + var nextPath = Tmp.CreateFile("second.jsonl", Enumerable.Repeat(UserLine, 60).ToArray()); + await next.LoadAsync(nextPath); + host.View.DataContext = next.Chat; + host.Settle(); + await Assert.That(host.AtBottom()).IsTrue(); + + await next.AppendAndTickAsync(nextPath, 20); + host.Settle(); + await Assert.That(host.AtBottom()).IsTrue(); + + await host.CloseAsync(); + await next.CloseAsync(); + }); + } + /// Pins that the initial load lands the reader at the bottom even when it completes before the /// view's first layout pass — an unshown window, and a tab collapsed from the start — so there /// is no ScrollViewer to read "was at end" from when the rows arrive. diff --git a/test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs b/test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs index d4ce7548cd..646b5e3d20 100644 --- a/test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs +++ b/test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs @@ -492,6 +492,104 @@ Control Surface(string name) => }); } + /// Reopening after a close shows the view the window built first, bound to the new session: + /// a fresh view per open is what a macOS accessibility peer keeps alive. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Reopening_a_session_after_a_close_reuses_the_workspace_view() { + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var reuse = await AvaloniaSession.DispatchAsync(() => { + var service = new FakeDaemonClientService(); + var (actions, _) = NewActions(service); + var vm = new MainWindowViewModel( + service, CancellationToken.None, TestActivity.New(), TimeProvider.System, + workspaceFactory: agentId => NewWorkspace(service, actions, agentId)); + var window = new MainWindow { DataContext = vm }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + vm.OpenSession("0123456789abcdef0123456789abcdef"); + Dispatcher.UIThread.RunJobs(); + var first = window.GetVisualDescendants().OfType().Single(); + + vm.CloseWorkspace(); + Dispatcher.UIThread.RunJobs(); + var heldWhileClosed = first.DataContext; + + vm.OpenSession("fedcba9876543210fedcba9876543210"); + Dispatcher.UIThread.RunJobs(); + var second = window.GetVisualDescendants().OfType().Single(); + var boundTo = (second.DataContext as WorkspaceViewModel)?.AgentId; + + vm.CloseWorkspace(); + window.Close(); + Dispatcher.UIThread.RunJobs(); + return (Same: ReferenceEquals(first, second), boundTo, heldWhileClosed); + }); + + await Assert.That(reuse.Same).IsTrue(); + await Assert.That(reuse.boundTo).IsEqualTo("fedcba9876543210fedcba9876543210"); + await Assert.That(reuse.heldWhileClosed).IsNull(); // the retained view lets the closed session go + }); + } + + /// A local session in between hands the slot to the other template; the next remote session + /// still gets the remote view the window built first. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_remote_session_after_a_local_one_reuses_the_remote_view() { + using var host = new RemoteHost(); + await AvaloniaSession.WithImmediateRxScheduler(async () => { + var reuse = await AvaloniaSession.DispatchAsync(() => { + var service = new FakeDaemonClientService(); + var (actions, _) = NewActions(service); + var vm = new MainWindowViewModel( + service, CancellationToken.None, TestActivity.New(), TimeProvider.System, + workspaceFactory: agentId => NewWorkspace(service, actions, agentId), + remoteWorkspaceFactory: host.New); + var window = new MainWindow { DataContext = vm }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + vm.OpenSession("remote-1", AgentOrigin.Remote); + Dispatcher.UIThread.RunJobs(); + var first = window.GetVisualDescendants().OfType().Single(); + + vm.OpenSession("local-1", AgentOrigin.Local); + Dispatcher.UIThread.RunJobs(); + var localShown = window.GetVisualDescendants().OfType().Count(); + + vm.OpenSession("remote-2", AgentOrigin.Remote); + Dispatcher.UIThread.RunJobs(); + var second = window.GetVisualDescendants().OfType().Single(); + var boundTo = (second.DataContext as RemoteSessionViewModel)?.AgentId; + + vm.CloseWorkspace(); + window.Close(); + Dispatcher.UIThread.RunJobs(); + return (Same: ReferenceEquals(first, second), localShown, boundTo); + }); + + await Assert.That(reuse.localShown).IsEqualTo(1); + await Assert.That(reuse.Same).IsTrue(); + await Assert.That(reuse.boundTo).IsEqualTo("remote-2"); + }); + } + + /// x:DataType on a DataTemplate subclass must still narrow it, or the local template would + /// claim remote sessions too. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Each_workspace_template_matches_only_its_own_model() { + var types = await AvaloniaSession.DispatchAsync(() => { + var window = new MainWindow(); + var host = window.FindControl("WorkspaceHost")!; + return host.DataTemplates.OfType().Select(t => t.DataType!).ToList(); + }); + + await Assert.That(types).IsEquivalentTo([typeof(WorkspaceViewModel), typeof(RemoteSessionViewModel)]); + } + /// A shown MainWindow whose rail holds two rows, "Fix the flaky test" /// and "Leave this one alone", with an optional sibling checkout. static (MainWindowViewModel Vm, MainWindow Window) RailWindow( diff --git a/test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs b/test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs index 62c4f464e2..98de1fb83c 100644 --- a/test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs @@ -48,43 +48,6 @@ static MainWindowViewModel NewVm( openFeedback: openFeedback, opener: opener); } - /// The remote host's dependencies, held together so a test disposes them once. The lane is - /// never connected, so even a row that carries a session id dials nothing and the routing - /// assertions stay deterministic. - sealed class RemoteHost : IDisposable { - readonly FakeServerLane _lane = new(); - readonly SessionAccessService _access; - readonly FakePermissionService _permissions = new(); - - public RemoteHost() => _access = new SessionAccessService(_lane, new FakeTimeProvider()); - - public FakeAgentDirectory Directory { get; } = new(); - public FakeWorkContextSource WorkSource { get; } = new(); - - // A separate overload rather than an optional parameter: the factory parameter takes this - // as a method group, and that conversion needs an exact one-argument signature. - public RemoteSessionViewModel New(string agentId) => New(agentId, null); - - /// A session id only where a test needs one: it is what a local row is compared against - /// before the removal counts as an origin change rather than an ended session. - public RemoteSessionViewModel New(string agentId, string? sessionId, IPullRequestSource? pullRequests = null, ComposerDrafts? drafts = null) { - var row = AgentRow.FromRemote(new AgentInstanceDto { - AgentId = agentId, SessionId = sessionId, Status = "Running", DaemonName = "work-mac", OwnerUserId = "u1", - Vendor = "claude", RegisteredAt = DateTime.UtcNow, - }); - Directory.Rows.AddOrUpdate(row); - return new RemoteSessionViewModel(row, Directory, _access, _permissions, WorkspaceFixtures.NewActions(), _lane, - (_, _) => Task.FromResult(new SessionDetailFetch(null)), new RecordingOpener(), new FakeTimeProvider(), WorkSource, - () => new FakeTerminalSurface(), pullRequests: pullRequests, drafts: drafts); - } - - public void Dispose() { - _access.Dispose(); - _permissions.Dispose(); - Directory.Dispose(); - } - } - /// A real WorkspaceViewModel over the fake service and scripted attach/surface fakes — same /// pieces WorkspaceNavigationTests.NewNav wires, just without its Nav bookkeeping. /// A window wired to a launcher pane: the Start/Reconnect buttons live on the pane, so their diff --git a/test/Capacitor.App.Tests.Unit/RemoteHost.cs b/test/Capacitor.App.Tests.Unit/RemoteHost.cs new file mode 100644 index 0000000000..4caf3c2171 --- /dev/null +++ b/test/Capacitor.App.Tests.Unit/RemoteHost.cs @@ -0,0 +1,45 @@ +using Capacitor.App.Services; +using Capacitor.App.ViewModels; +using Capacitor.Cli.Core.PullRequests; +using Capacitor.Remote.Models; +using DynamicData; +using Microsoft.Extensions.Time.Testing; + +namespace Capacitor.App.Tests.Unit; + +/// The remote host's dependencies, held together so a test disposes them once. The lane is +/// never connected, so even a row that carries a session id dials nothing and the routing +/// assertions stay deterministic. +sealed class RemoteHost : IDisposable { + readonly FakeServerLane _lane = new(); + readonly SessionAccessService _access; + readonly FakePermissionService _permissions = new(); + + public RemoteHost() => _access = new SessionAccessService(_lane, new FakeTimeProvider()); + + public FakeAgentDirectory Directory { get; } = new(); + public FakeWorkContextSource WorkSource { get; } = new(); + + // A separate overload rather than an optional parameter: the factory parameter takes this + // as a method group, and that conversion needs an exact one-argument signature. + public RemoteSessionViewModel New(string agentId) => New(agentId, null); + + /// A session id only where a test needs one: it is what a local row is compared against + /// before the removal counts as an origin change rather than an ended session. + public RemoteSessionViewModel New(string agentId, string? sessionId, IPullRequestSource? pullRequests = null, ComposerDrafts? drafts = null) { + var row = AgentRow.FromRemote(new AgentInstanceDto { + AgentId = agentId, SessionId = sessionId, Status = "Running", DaemonName = "work-mac", OwnerUserId = "u1", + Vendor = "claude", RegisteredAt = DateTime.UtcNow, + }); + Directory.Rows.AddOrUpdate(row); + return new RemoteSessionViewModel(row, Directory, _access, _permissions, WorkspaceFixtures.NewActions(), _lane, + (_, _) => Task.FromResult(new SessionDetailFetch(null)), new RecordingOpener(), new FakeTimeProvider(), WorkSource, + () => new FakeTerminalSurface(), pullRequests: pullRequests, drafts: drafts); + } + + public void Dispose() { + _access.Dispose(); + _permissions.Dispose(); + Directory.Dispose(); + } +}