diff --git a/SalmonEgg/SalmonEgg/App.xaml.cs b/SalmonEgg/SalmonEgg/App.xaml.cs index 94cd687b..6ab39202 100644 --- a/SalmonEgg/SalmonEgg/App.xaml.cs +++ b/SalmonEgg/SalmonEgg/App.xaml.cs @@ -1,9 +1,9 @@ using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; +using SalmonEgg.Infrastructure.Storage; using SalmonEgg.Presentation.Models; using SalmonEgg.Presentation.Services; -using SalmonEgg.Infrastructure.Storage; namespace SalmonEgg; diff --git a/SalmonEgg/SalmonEgg/Controls/ChatInputArea.xaml.cs b/SalmonEgg/SalmonEgg/Controls/ChatInputArea.xaml.cs index fade5c95..422f9c98 100644 --- a/SalmonEgg/SalmonEgg/Controls/ChatInputArea.xaml.cs +++ b/SalmonEgg/SalmonEgg/Controls/ChatInputArea.xaml.cs @@ -10,11 +10,11 @@ using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using SalmonEgg.Presentation.Core.Services.Input; -using SalmonEgg.Presentation.Core.ViewModels.Composer; using SalmonEgg.Presentation.Core.ViewModels.Chat.Selectors; +using SalmonEgg.Presentation.Core.ViewModels.Composer; using SalmonEgg.Presentation.ViewModels.Chat; -using XamlFocusManager = Microsoft.UI.Xaml.Input.FocusManager; using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs; +using XamlFocusManager = Microsoft.UI.Xaml.Input.FocusManager; namespace SalmonEgg.Controls; diff --git a/SalmonEgg/SalmonEgg/Controls/ChatSkeleton.xaml.cs b/SalmonEgg/SalmonEgg/Controls/ChatSkeleton.xaml.cs index 9844ea26..80e2798d 100644 --- a/SalmonEgg/SalmonEgg/Controls/ChatSkeleton.xaml.cs +++ b/SalmonEgg/SalmonEgg/Controls/ChatSkeleton.xaml.cs @@ -1,12 +1,11 @@ using Microsoft.UI.Xaml.Controls; -namespace SalmonEgg.Controls +namespace SalmonEgg.Controls; + +public sealed partial class ChatSkeleton : UserControl { - public sealed partial class ChatSkeleton : UserControl + public ChatSkeleton() { - public ChatSkeleton() - { - InitializeComponent(); - } + InitializeComponent(); } } diff --git a/SalmonEgg/SalmonEgg/Controls/ResizeGrip.cs b/SalmonEgg/SalmonEgg/Controls/ResizeGrip.cs index e19e1bfd..6bd755c6 100644 --- a/SalmonEgg/SalmonEgg/Controls/ResizeGrip.cs +++ b/SalmonEgg/SalmonEgg/Controls/ResizeGrip.cs @@ -1,6 +1,6 @@ using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; namespace SalmonEgg.Controls; diff --git a/SalmonEgg/SalmonEgg/Controls/SettingsBreadcrumbBar.xaml.cs b/SalmonEgg/SalmonEgg/Controls/SettingsBreadcrumbBar.xaml.cs index 425d5845..c8ff14e4 100644 --- a/SalmonEgg/SalmonEgg/Controls/SettingsBreadcrumbBar.xaml.cs +++ b/SalmonEgg/SalmonEgg/Controls/SettingsBreadcrumbBar.xaml.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using SalmonEgg.Presentation.Models.Navigation; using SalmonEgg.Presentation.Core.Services; +using SalmonEgg.Presentation.Models.Navigation; namespace SalmonEgg.Controls; diff --git a/SalmonEgg/SalmonEgg/Controls/ShortcutRecorder.xaml.cs b/SalmonEgg/SalmonEgg/Controls/ShortcutRecorder.xaml.cs index 5d69ef3f..ea588b23 100644 --- a/SalmonEgg/SalmonEgg/Controls/ShortcutRecorder.xaml.cs +++ b/SalmonEgg/SalmonEgg/Controls/ShortcutRecorder.xaml.cs @@ -1,12 +1,12 @@ using System; -using System.Globalization; using System.ComponentModel; +using System.Globalization; using System.Runtime.CompilerServices; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; -using Windows.System; using SalmonEgg.Presentation.Core.Services.Shortcuts; +using Windows.System; namespace SalmonEgg.Controls; diff --git a/SalmonEgg/SalmonEgg/Platforms/Desktop/Program.cs b/SalmonEgg/SalmonEgg/Platforms/Desktop/Program.cs index 74aac6b2..948a6c76 100644 --- a/SalmonEgg/SalmonEgg/Platforms/Desktop/Program.cs +++ b/SalmonEgg/SalmonEgg/Platforms/Desktop/Program.cs @@ -7,21 +7,21 @@ internal class Program { [STAThread] static void Main(string[] args) - { - App.InitializeLogging(); + { + App.InitializeLogging(); - var host = UnoPlatformHostBuilder.Create() - .App(() => new App()) - .UseX11() - .UseLinuxFrameBuffer() - .UseMacOS() - .UseWin32() - .Build(); + var host = UnoPlatformHostBuilder.Create() + .App(() => new App()) + .UseX11() + .UseLinuxFrameBuffer() + .UseMacOS() + .UseWin32() + .Build(); - // Some hosting configurations may reset the ambient logger factory during Build(). - // Re-apply our filters before running to suppress known noisy categories (e.g., RevealBrush setters on Skia). - App.InitializeLogging(); + // Some hosting configurations may reset the ambient logger factory during Build(). + // Re-apply our filters before running to suppress known noisy categories (e.g., RevealBrush setters on Skia). + App.InitializeLogging(); - host.RunAsync().GetAwaiter().GetResult(); - } + host.RunAsync().GetAwaiter().GetResult(); + } } diff --git a/SalmonEgg/SalmonEgg/Platforms/WebAssembly/Program.cs b/SalmonEgg/SalmonEgg/Platforms/WebAssembly/Program.cs index ce419f5c..aeb53774 100644 --- a/SalmonEgg/SalmonEgg/Platforms/WebAssembly/Program.cs +++ b/SalmonEgg/SalmonEgg/Platforms/WebAssembly/Program.cs @@ -15,4 +15,4 @@ public static async Task Main(string[] args) await host.RunAsync(); } -} \ No newline at end of file +} diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/BoolToVisibilityConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/BoolToVisibilityConverter.cs index 3a39b8d1..f08a706f 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/BoolToVisibilityConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/BoolToVisibilityConverter.cs @@ -2,41 +2,40 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将布尔值转换为 Visibility +/// Requirements: 4.2 +/// +public class BoolToVisibilityConverter : IValueConverter { - /// - /// 将布尔值转换为 Visibility - /// Requirements: 4.2 - /// - public class BoolToVisibilityConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool boolValue) { - if (value is bool boolValue) - { - bool invert = parameter?.ToString() == "Invert"; - if (invert) boolValue = !boolValue; - return boolValue ? Visibility.Visible : Visibility.Collapsed; - } - - if (value is string stringValue) - { - return string.IsNullOrWhiteSpace(stringValue) ? Visibility.Collapsed : Visibility.Visible; - } - - return Visibility.Collapsed; + bool invert = parameter?.ToString() == "Invert"; + if (invert) boolValue = !boolValue; + return boolValue ? Visibility.Visible : Visibility.Collapsed; } - public object ConvertBack(object value, Type targetType, object parameter, string language) + if (value is string stringValue) { - if (value is Visibility visibility) - { - var result = visibility == Visibility.Visible; - var invert = parameter?.ToString() == "Invert"; - return invert ? !result : result; - } + return string.IsNullOrWhiteSpace(stringValue) ? Visibility.Collapsed : Visibility.Visible; + } - return false; + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is Visibility visibility) + { + var result = visibility == Visibility.Visible; + var invert = parameter?.ToString() == "Invert"; + return invert ? !result : result; } + + return false; } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/ConnectionStatusToColorConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/ConnectionStatusToColorConverter.cs index 8220ef31..c46135a1 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/ConnectionStatusToColorConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/ConnectionStatusToColorConverter.cs @@ -2,34 +2,33 @@ using Microsoft.UI.Xaml.Data; using SalmonEgg.Domain.Models; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将连接状态转换为颜色 +/// Requirements: 4.2 +/// +public class ConnectionStatusToColorConverter : IValueConverter { - /// - /// 将连接状态转换为颜色 - /// Requirements: 4.2 - /// - public class ConnectionStatusToColorConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is ConnectionStatus status) { - if (value is ConnectionStatus status) + return status switch { - return status switch - { - ConnectionStatus.Connected => ThemeBrushConverter.Resolve("SystemFillColorSuccessBrush"), - ConnectionStatus.Connecting => ThemeBrushConverter.Resolve("AccentBrush"), - ConnectionStatus.Reconnecting => ThemeBrushConverter.Resolve("SystemFillColorCautionBrush", "AccentBrush"), - ConnectionStatus.Error => ThemeBrushConverter.Resolve("SystemFillColorCriticalBrush"), - _ => ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush") - }; - } - - return ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush"); + ConnectionStatus.Connected => ThemeBrushConverter.Resolve("SystemFillColorSuccessBrush"), + ConnectionStatus.Connecting => ThemeBrushConverter.Resolve("AccentBrush"), + ConnectionStatus.Reconnecting => ThemeBrushConverter.Resolve("SystemFillColorCautionBrush", "AccentBrush"), + ConnectionStatus.Error => ThemeBrushConverter.Resolve("SystemFillColorCriticalBrush"), + _ => ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush") + }; } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + return ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush"); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBoolConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBoolConverter.cs index f4ea2723..fb3f605d 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBoolConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBoolConverter.cs @@ -2,29 +2,28 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将布尔值取反后转换为 Visibility +/// +public class InverseBoolConverter : IValueConverter { - /// - /// 将布尔值取反后转换为 Visibility - /// - public class InverseBoolConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool boolValue) { - if (value is bool boolValue) - { - return boolValue ? Visibility.Collapsed : Visibility.Visible; - } - return Visibility.Visible; + return boolValue ? Visibility.Collapsed : Visibility.Visible; } + return Visibility.Visible; + } - public object ConvertBack(object value, Type targetType, object parameter, string language) + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is Visibility visibility) { - if (value is Visibility visibility) - { - return visibility == Visibility.Collapsed; - } - return true; + return visibility == Visibility.Collapsed; } + return true; } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBooleanConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBooleanConverter.cs index a1970cd3..bcae5098 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBooleanConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/InverseBooleanConverter.cs @@ -1,30 +1,29 @@ using System; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将布尔值取反(返回 bool,不是 Visibility) +/// 用于 IsEnabled 等需要 bool 的属性 +/// +public class InverseBooleanConverter : IValueConverter { - /// - /// 将布尔值取反(返回 bool,不是 Visibility) - /// 用于 IsEnabled 等需要 bool 的属性 - /// - public class InverseBooleanConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool boolValue) { - if (value is bool boolValue) - { - return !boolValue; - } - return true; + return !boolValue; } + return true; + } - public object ConvertBack(object value, Type targetType, object parameter, string language) + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool boolValue) { - if (value is bool boolValue) - { - return !boolValue; - } - return false; + return !boolValue; } + return false; } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToAlignmentConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToAlignmentConverter.cs index 0aef72f5..0add733d 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToAlignmentConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToAlignmentConverter.cs @@ -2,26 +2,25 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为对齐方式 +/// Requirements: 4.2 +/// +public class MessageDirectionToAlignmentConverter : IValueConverter { - /// - /// 将消息方向转换为对齐方式 - /// Requirements: 4.2 - /// - public class MessageDirectionToAlignmentConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool isOutgoing) { - if (value is bool isOutgoing) - { - return isOutgoing ? HorizontalAlignment.Right : HorizontalAlignment.Left; - } - return HorizontalAlignment.Left; + return isOutgoing ? HorizontalAlignment.Right : HorizontalAlignment.Left; } + return HorizontalAlignment.Left; + } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBackgroundConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBackgroundConverter.cs index c63efb26..8236c6c1 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBackgroundConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBackgroundConverter.cs @@ -3,6 +3,8 @@ using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; + +<<<<<<< TODO: Unmerged change from project 'SalmonEgg(net10.0-browserwasm)', Before: namespace SalmonEgg.Presentation.Converters { /// @@ -40,5 +42,79 @@ public object ConvertBack(object value, Type targetType, object parameter, strin { throw new NotImplementedException(); } +======= +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为背景色画刷 +/// 用户发出(True)返回 AccentFillColorDefaultBrush +/// AI接收(False)返回 CardBackgroundFillColorDefaultBrush +/// +public class MessageDirectionToBackgroundConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool isOutgoing) + { + if (isOutgoing) + { + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("AccentFillColorDefaultBrush", out var accentBrush)) + { + return accentBrush; + } + } + else + { + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("ControlFillColorSecondaryBrush", out var cardBrush)) + { + return cardBrush; + } + } + } + + // Fallback + return Microsoft.UI.Xaml.Application.Current.Resources["LayerOnAcrylicFillColorDefaultBrush"]; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); +>>>>>>> After +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为背景色画刷 +/// 用户发出(True)返回 AccentFillColorDefaultBrush +/// AI接收(False)返回 CardBackgroundFillColorDefaultBrush +/// +public class MessageDirectionToBackgroundConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool isOutgoing) + { + if (isOutgoing) + { + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("AccentFillColorDefaultBrush", out var accentBrush)) + { + return accentBrush; + } + } + else + { + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("ControlFillColorSecondaryBrush", out var cardBrush)) + { + return cardBrush; + } + } + } + + // Fallback + return Microsoft.UI.Xaml.Application.Current.Resources["LayerOnAcrylicFillColorDefaultBrush"]; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBorderThicknessConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBorderThicknessConverter.cs index 519db77d..2240c57a 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBorderThicknessConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToBorderThicknessConverter.cs @@ -2,28 +2,27 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为边框粗细 +/// 用户发出(True)返回 0 (无边框,颜色深不需要) +/// AI接收(False)返回 1 (浅色模式下需要边框建立层级感) +/// +public class MessageDirectionToBorderThicknessConverter : IValueConverter { - /// - /// 将消息方向转换为边框粗细 - /// 用户发出(True)返回 0 (无边框,颜色深不需要) - /// AI接收(False)返回 1 (浅色模式下需要边框建立层级感) - /// - public class MessageDirectionToBorderThicknessConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool isOutgoing) { - if (value is bool isOutgoing) - { - // AI 消息返回 1,用户消息返回 0 - return isOutgoing ? new Thickness(0) : new Thickness(1); - } - return new Thickness(0); + // AI 消息返回 1,用户消息返回 0 + return isOutgoing ? new Thickness(0) : new Thickness(1); } + return new Thickness(0); + } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToCornerRadiusConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToCornerRadiusConverter.cs index c6f61a63..d1841a9f 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToCornerRadiusConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToCornerRadiusConverter.cs @@ -2,28 +2,27 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为气泡的非对称圆角 +/// 用户发出(True)为右下直角,左下圆角 +/// AI接收(False)为左下直角,右下圆角 +/// +public class MessageDirectionToCornerRadiusConverter : IValueConverter { - /// - /// 将消息方向转换为气泡的非对称圆角 - /// 用户发出(True)为右下直角,左下圆角 - /// AI接收(False)为左下直角,右下圆角 - /// - public class MessageDirectionToCornerRadiusConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool isOutgoing) { - if (value is bool isOutgoing) - { - // 左上, 右上, 右下, 左下 - return isOutgoing ? new CornerRadius(12, 12, 0, 12) : new CornerRadius(12, 12, 12, 0); - } - return new CornerRadius(12); + // 左上, 右上, 右下, 左下 + return isOutgoing ? new CornerRadius(12, 12, 0, 12) : new CornerRadius(12, 12, 12, 0); } + return new CornerRadius(12); + } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToForegroundConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToForegroundConverter.cs index 5a7f6ed8..bdedd40d 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToForegroundConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageDirectionToForegroundConverter.cs @@ -3,42 +3,41 @@ using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将消息方向转换为前景色(文本色)画刷 +/// 用户发出(True)返回 TextOnAccentFillColorPrimaryBrush +/// AI接收(False)返回 TextFillColorPrimaryBrush +/// +public class MessageDirectionToForegroundConverter : IValueConverter { - /// - /// 将消息方向转换为前景色(文本色)画刷 - /// 用户发出(True)返回 TextOnAccentFillColorPrimaryBrush - /// AI接收(False)返回 TextFillColorPrimaryBrush - /// - public class MessageDirectionToForegroundConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is bool isOutgoing) { - if (value is bool isOutgoing) + if (isOutgoing) { - if (isOutgoing) + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("TextOnAccentFillColorPrimaryBrush", out var textOnAccent)) { - if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("TextOnAccentFillColorPrimaryBrush", out var textOnAccent)) - { - return textOnAccent; - } + return textOnAccent; } - else + } + else + { + if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("TextFillColorPrimaryBrush", out var textPrimary)) { - if (Microsoft.UI.Xaml.Application.Current.Resources.TryGetValue("TextFillColorPrimaryBrush", out var textPrimary)) - { - return textPrimary; - } + return textPrimary; } } - - // Fallback - return Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"]; } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + // Fallback + return Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"]; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageTemplateSelector.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageTemplateSelector.cs index 28ff6982..796a8ae5 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/MessageTemplateSelector.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/MessageTemplateSelector.cs @@ -1,6 +1,6 @@ +using System; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using System; using SalmonEgg.Presentation.ViewModels.Chat; namespace SalmonEgg.Presentation.Converters; diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/PlanStatusToColorConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/PlanStatusToColorConverter.cs index 5c19800f..027cfe8a 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/PlanStatusToColorConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/PlanStatusToColorConverter.cs @@ -2,30 +2,29 @@ using Microsoft.UI.Xaml.Data; using SalmonEgg.Domain.Models.Plan; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将计划条目状态转换为对应的颜色 +/// +public class PlanStatusToColorConverter : IValueConverter { - /// - /// 将计划条目状态转换为对应的颜色 - /// - public class PlanStatusToColorConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is PlanEntryStatus status) { - if (value is PlanEntryStatus status) + return status switch { - return status switch - { - PlanEntryStatus.InProgress => ThemeBrushConverter.Resolve("AccentBrush"), - PlanEntryStatus.Completed => ThemeBrushConverter.Resolve("SystemFillColorSuccessBrush"), - _ => ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush") - }; - } - return ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush"); + PlanEntryStatus.InProgress => ThemeBrushConverter.Resolve("AccentBrush"), + PlanEntryStatus.Completed => ThemeBrushConverter.Resolve("SystemFillColorSuccessBrush"), + _ => ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush") + }; } + return ThemeBrushConverter.Resolve("TextFillColorSecondaryBrush"); + } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Converters/TimeFormatConverter.cs b/SalmonEgg/SalmonEgg/Presentation/Converters/TimeFormatConverter.cs index 71f7f198..950767ed 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Converters/TimeFormatConverter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Converters/TimeFormatConverter.cs @@ -1,25 +1,24 @@ using System; using Microsoft.UI.Xaml.Data; -namespace SalmonEgg.Presentation.Converters +namespace SalmonEgg.Presentation.Converters; + +/// +/// 将 DateTime 转换为时间字符串格式 (HH:mm:ss) +/// +public class TimeFormatConverter : IValueConverter { - /// - /// 将 DateTime 转换为时间字符串格式 (HH:mm:ss) - /// - public class TimeFormatConverter : IValueConverter + public object Convert(object value, Type targetType, object parameter, string language) { - public object Convert(object value, Type targetType, object parameter, string language) + if (value is DateTime dateTime) { - if (value is DateTime dateTime) - { - return dateTime.ToString("HH:mm:ss"); - } - return string.Empty; + return dateTime.ToString("HH:mm:ss"); } + return string.Empty; + } - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); } } diff --git a/SalmonEgg/SalmonEgg/Presentation/Navigation/ContentFrameNavigationAdapter.cs b/SalmonEgg/SalmonEgg/Presentation/Navigation/ContentFrameNavigationAdapter.cs index d9abf07e..1652196a 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Navigation/ContentFrameNavigationAdapter.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Navigation/ContentFrameNavigationAdapter.cs @@ -3,8 +3,8 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media.Animation; using Microsoft.UI.Xaml.Navigation; -using SalmonEgg.Presentation.Models; using SalmonEgg.Presentation.Core.Services.Navigation; +using SalmonEgg.Presentation.Models; using SalmonEgg.Presentation.Services; namespace SalmonEgg.Presentation.Navigation; diff --git a/SalmonEgg/SalmonEgg/Presentation/Services/UiRuntimeService.cs b/SalmonEgg/SalmonEgg/Presentation/Services/UiRuntimeService.cs index 11065a84..2c6352cf 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Services/UiRuntimeService.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Services/UiRuntimeService.cs @@ -1,5 +1,5 @@ -using SalmonEgg.Presentation.Models; using SalmonEgg.Presentation.Core.Services; +using SalmonEgg.Presentation.Models; #if WINDOWS using Microsoft.UI.Xaml.Media.Animation; using Windows.UI.ViewManagement; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Chat/ChatView.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Chat/ChatView.xaml.cs index 2daf3eff..3d2db8e4 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Chat/ChatView.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Chat/ChatView.xaml.cs @@ -1,17 +1,17 @@ using System; using System.Collections.Specialized; using System.ComponentModel; -using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using SalmonEgg.Controls; +using SalmonEgg.Presentation.Core.Services.Input; +using SalmonEgg.Presentation.Core.ViewModels.ShellLayout; using SalmonEgg.Presentation.Models; using SalmonEgg.Presentation.Transcript; using SalmonEgg.Presentation.Utilities; using SalmonEgg.Presentation.ViewModels.Chat; -using SalmonEgg.Presentation.Core.ViewModels.ShellLayout; -using SalmonEgg.Presentation.Core.Services.Input; using Windows.Foundation; using XamlFocusManager = Microsoft.UI.Xaml.Input.FocusManager; @@ -19,185 +19,185 @@ namespace SalmonEgg.Presentation.Views.Chat; public sealed partial class ChatView : Page, INavigationIntentConsumer, IPrimaryContentFocusTarget { - public ChatShellViewModel ShellViewModel { get; } - public ChatViewModel ViewModel => ShellViewModel.Chat; - public ListViewTranscriptItemsSource MessagesItemsSource { get; } = new(); - public ShellLayoutViewModel LayoutVM => ShellViewModel.ShellLayout; - private bool _isViewLoaded; - private bool _isTrackingMessages; - private readonly TranscriptViewportController _viewportController = new(); - private const double BottomThreshold = 10; - private const double BottomGeometryTolerance = 2; - private const int MaxRestoreAttempts = 32; - private bool _wasOverlayVisible; - private bool _restoreDetachedViewportAfterOverlay; - private string? _restoreDetachedViewportConversationId; - private bool _resumeViewportCoordinatorAfterOverlayPending; - private bool _isTranscriptViewportLayerActive; - private object? _activeTranscriptMessageAnchorItem; - private bool _isTranscriptChildControlLayerActive; - private int? _pendingTranscriptMessageFocusIndex; - private readonly TranscriptProjectionRestoreController _projectionRestoreController = new(MaxRestoreAttempts); - private string _transcriptViewportAutomationState = "inactive"; - private INotifyCollectionChanged? _trackedMessageHistory; - private readonly Microsoft.UI.Xaml.Input.KeyEventHandler _messagesListHandledKeyDownHandler; - private readonly PointerEventHandler _messagesListHandledPointerPressedHandler; - private readonly PointerEventHandler _messagesListHandledPointerWheelChangedHandler; - private readonly TypedEventHandler _messagesListContainerContentChangingHandler; - private readonly RoutedEventHandler _messagesListItemGotFocusHandler; - private ITranscriptViewportHost? _transcriptViewportHost; - public ChatView() - { - ShellViewModel = App.ServiceProvider.GetRequiredService(); - NavigationCacheMode = NavigationCacheMode.Required; - _messagesListHandledKeyDownHandler = OnMessagesListKeyDown; - _messagesListHandledPointerPressedHandler = OnMessagesListPointerPressed; - _messagesListHandledPointerWheelChangedHandler = OnMessagesListPointerWheelChanged; - _messagesListContainerContentChangingHandler = OnMessagesListContainerContentChanging; - _messagesListItemGotFocusHandler = OnMessagesListItemGotFocus; - - this.InitializeComponent(); - - Loaded += OnLoaded; - Unloaded += OnUnloaded; - } - - private void OnConversationInputAreaLoaded(object sender, RoutedEventArgs e) - { - if (sender is ChatInputArea inputArea) - { - inputArea.MoveUpEscapeHandler = TryFocusTranscriptViewport; - } - } - - private void OnConversationInputAreaUnloaded(object sender, RoutedEventArgs e) - { - if (sender is ChatInputArea inputArea) - { - inputArea.MoveUpEscapeHandler = null; - } - } - - private async void OnLoaded(object sender, RoutedEventArgs e) - { - _isViewLoaded = true; - _restoreDetachedViewportAfterOverlay = false; - _restoreDetachedViewportConversationId = null; - _resumeViewportCoordinatorAfterOverlayPending = false; - ClearPendingProjectionRestore(); - ClearTranscriptMessageLayerState(); - _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; - _viewportController.Load( - CurrentViewportConversationId, - ViewModel.IsSessionActive, - _wasOverlayVisible, - ViewModel.MessageHistory.Count > 0); - if (_wasOverlayVisible) - { - _resumeViewportCoordinatorAfterOverlayPending = true; - ApplyViewportActions(_viewportController.SuspendForOverlay()); - } - else - { - RestoreViewportForWarmResume(); - } - EnsureMessageTracking(); - BeginLayoutLoadingIfPendingMessages(); - TryIssueTranscriptScrollRequestIfAttached(); - UpdateTranscriptViewportAutomationState(); - try - { - await ViewModel.EnsureAcpProfilesLoadedAsync(); - } - catch - { - } - } - - private void OnUnloaded(object sender, RoutedEventArgs e) + public ChatShellViewModel ShellViewModel { get; } + public ChatViewModel ViewModel => ShellViewModel.Chat; + public ListViewTranscriptItemsSource MessagesItemsSource { get; } = new(); + public ShellLayoutViewModel LayoutVM => ShellViewModel.ShellLayout; + private bool _isViewLoaded; + private bool _isTrackingMessages; + private readonly TranscriptViewportController _viewportController = new(); + private const double BottomThreshold = 10; + private const double BottomGeometryTolerance = 2; + private const int MaxRestoreAttempts = 32; + private bool _wasOverlayVisible; + private bool _restoreDetachedViewportAfterOverlay; + private string? _restoreDetachedViewportConversationId; + private bool _resumeViewportCoordinatorAfterOverlayPending; + private bool _isTranscriptViewportLayerActive; + private object? _activeTranscriptMessageAnchorItem; + private bool _isTranscriptChildControlLayerActive; + private int? _pendingTranscriptMessageFocusIndex; + private readonly TranscriptProjectionRestoreController _projectionRestoreController = new(MaxRestoreAttempts); + private string _transcriptViewportAutomationState = "inactive"; + private INotifyCollectionChanged? _trackedMessageHistory; + private readonly Microsoft.UI.Xaml.Input.KeyEventHandler _messagesListHandledKeyDownHandler; + private readonly PointerEventHandler _messagesListHandledPointerPressedHandler; + private readonly PointerEventHandler _messagesListHandledPointerWheelChangedHandler; + private readonly TypedEventHandler _messagesListContainerContentChangingHandler; + private readonly RoutedEventHandler _messagesListItemGotFocusHandler; + private ITranscriptViewportHost? _transcriptViewportHost; + public ChatView() + { + ShellViewModel = App.ServiceProvider.GetRequiredService(); + NavigationCacheMode = NavigationCacheMode.Required; + _messagesListHandledKeyDownHandler = OnMessagesListKeyDown; + _messagesListHandledPointerPressedHandler = OnMessagesListPointerPressed; + _messagesListHandledPointerWheelChangedHandler = OnMessagesListPointerWheelChanged; + _messagesListContainerContentChangingHandler = OnMessagesListContainerContentChanging; + _messagesListItemGotFocusHandler = OnMessagesListItemGotFocus; + + this.InitializeComponent(); + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnConversationInputAreaLoaded(object sender, RoutedEventArgs e) + { + if (sender is ChatInputArea inputArea) + { + inputArea.MoveUpEscapeHandler = TryFocusTranscriptViewport; + } + } + + private void OnConversationInputAreaUnloaded(object sender, RoutedEventArgs e) + { + if (sender is ChatInputArea inputArea) + { + inputArea.MoveUpEscapeHandler = null; + } + } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + _isViewLoaded = true; + _restoreDetachedViewportAfterOverlay = false; + _restoreDetachedViewportConversationId = null; + _resumeViewportCoordinatorAfterOverlayPending = false; + ClearPendingProjectionRestore(); + ClearTranscriptMessageLayerState(); + _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; + _viewportController.Load( + CurrentViewportConversationId, + ViewModel.IsSessionActive, + _wasOverlayVisible, + ViewModel.MessageHistory.Count > 0); + if (_wasOverlayVisible) + { + _resumeViewportCoordinatorAfterOverlayPending = true; + ApplyViewportActions(_viewportController.SuspendForOverlay()); + } + else + { + RestoreViewportForWarmResume(); + } + EnsureMessageTracking(); + BeginLayoutLoadingIfPendingMessages(); + TryIssueTranscriptScrollRequestIfAttached(); + UpdateTranscriptViewportAutomationState(); + try + { + await ViewModel.EnsureAcpProfilesLoadedAsync(); + } + catch + { + } + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + AbandonPendingProjectionRestore("ViewUnloaded"); + ApplyViewportActions(_viewportController.Unload()); + _isViewLoaded = false; + _restoreDetachedViewportAfterOverlay = false; + _restoreDetachedViewportConversationId = null; + _resumeViewportCoordinatorAfterOverlayPending = false; + DisposeTranscriptViewportHost(); + ClearPendingProjectionRestore(); + ClearTranscriptMessageLayerState(); + UpdateTranscriptViewportAutomationState(); + if (_isTrackingMessages) + { + if (_trackedMessageHistory != null) + { + _trackedMessageHistory.CollectionChanged -= OnMessageHistoryChanged; + _trackedMessageHistory = null; + } + ViewModel.PropertyChanged -= OnViewModelPropertyChanged; + ViewModel.ProjectionRestoreReady -= OnProjectionRestoreReady; + MessagesItemsSource.Detach(); + _isTrackingMessages = false; + } + } + + private void EnsureMessageTracking() + { + if (_isTrackingMessages) { - AbandonPendingProjectionRestore("ViewUnloaded"); - ApplyViewportActions(_viewportController.Unload()); - _isViewLoaded = false; - _restoreDetachedViewportAfterOverlay = false; - _restoreDetachedViewportConversationId = null; - _resumeViewportCoordinatorAfterOverlayPending = false; - DisposeTranscriptViewportHost(); - ClearPendingProjectionRestore(); - ClearTranscriptMessageLayerState(); - UpdateTranscriptViewportAutomationState(); - if (_isTrackingMessages) + if (!ReferenceEquals(_trackedMessageHistory, ViewModel.MessageHistory)) { if (_trackedMessageHistory != null) { _trackedMessageHistory.CollectionChanged -= OnMessageHistoryChanged; - _trackedMessageHistory = null; - } - ViewModel.PropertyChanged -= OnViewModelPropertyChanged; - ViewModel.ProjectionRestoreReady -= OnProjectionRestoreReady; - MessagesItemsSource.Detach(); - _isTrackingMessages = false; - } - } - - private void EnsureMessageTracking() - { - if (_isTrackingMessages) - { - if (!ReferenceEquals(_trackedMessageHistory, ViewModel.MessageHistory)) - { - if (_trackedMessageHistory != null) - { - _trackedMessageHistory.CollectionChanged -= OnMessageHistoryChanged; - } - - _trackedMessageHistory = ViewModel.MessageHistory; - MessagesItemsSource.Attach(ViewModel.MessageHistory); - _trackedMessageHistory.CollectionChanged += OnMessageHistoryChanged; } - return; + _trackedMessageHistory = ViewModel.MessageHistory; + MessagesItemsSource.Attach(ViewModel.MessageHistory); + _trackedMessageHistory.CollectionChanged += OnMessageHistoryChanged; } - _trackedMessageHistory = ViewModel.MessageHistory; - MessagesItemsSource.Attach(ViewModel.MessageHistory); - _trackedMessageHistory.CollectionChanged += OnMessageHistoryChanged; - ViewModel.PropertyChanged += OnViewModelPropertyChanged; - ViewModel.ProjectionRestoreReady += OnProjectionRestoreReady; - _isTrackingMessages = true; + return; } - private void OnMessageHistoryChanged(object? sender, NotifyCollectionChangedEventArgs e) + _trackedMessageHistory = ViewModel.MessageHistory; + MessagesItemsSource.Attach(ViewModel.MessageHistory); + _trackedMessageHistory.CollectionChanged += OnMessageHistoryChanged; + ViewModel.PropertyChanged += OnViewModelPropertyChanged; + ViewModel.ProjectionRestoreReady += OnProjectionRestoreReady; + _isTrackingMessages = true; + } + + private void OnMessageHistoryChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (!_isViewLoaded) { - if (!_isViewLoaded) - { - UpdateTranscriptViewportAutomationState(); - return; - } + UpdateTranscriptViewportAutomationState(); + return; + } - ResumeViewportCoordinatorAfterOverlayIfNeeded(); + ResumeViewportCoordinatorAfterOverlayIfNeeded(); - BeginLayoutLoadingIfPendingMessages(); - TryApplyPendingProjectionRestore(); - ApplyViewportActions(_viewportController.OnMessagesAppended( - e.NewItems?.Count ?? 0, - CreateViewportViewState())); - RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); + BeginLayoutLoadingIfPendingMessages(); + TryApplyPendingProjectionRestore(); + ApplyViewportActions(_viewportController.OnMessagesAppended( + e.NewItems?.Count ?? 0, + CreateViewportViewState())); + RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); - UpdateTranscriptViewportAutomationState(); - } + UpdateTranscriptViewportAutomationState(); + } - private void OnMessagesListLoaded(object sender, RoutedEventArgs e) + private void OnMessagesListLoaded(object sender, RoutedEventArgs e) + { + DisposeTranscriptViewportHost(); + var messagesList = MessagesList; + _transcriptViewportHost = messagesList is null + ? null + : new ListViewTranscriptViewportHost(messagesList); + if (_transcriptViewportHost is not null) { - DisposeTranscriptViewportHost(); - var messagesList = MessagesList; - _transcriptViewportHost = messagesList is null - ? null - : new ListViewTranscriptViewportHost(messagesList); - if (_transcriptViewportHost is not null) - { - _transcriptViewportHost.ViewportChanged += OnMessagesListViewportChanged; - } + _transcriptViewportHost.ViewportChanged += OnMessagesListViewportChanged; + } #if WINDOWS if (messagesList is not null) @@ -206,1056 +206,1066 @@ private void OnMessagesListLoaded(object sender, RoutedEventArgs e) } #endif - if (messagesList is not null) - { - messagesList.ContainerContentChanging -= _messagesListContainerContentChangingHandler; - messagesList.ContainerContentChanging += _messagesListContainerContentChangingHandler; - messagesList.AddHandler(UIElement.KeyDownEvent, _messagesListHandledKeyDownHandler, true); - messagesList.AddHandler(UIElement.PointerPressedEvent, _messagesListHandledPointerPressedHandler, true); - messagesList.AddHandler(UIElement.PointerWheelChangedEvent, _messagesListHandledPointerWheelChangedHandler, true); - } - - ResumeViewportCoordinatorAfterOverlayIfNeeded(); - BeginLayoutLoadingIfPendingMessages(); - TryApplyPendingTranscriptMessageFocus(); - TryApplyPendingProjectionRestore(); - TryIssueTranscriptScrollRequest(); - TryRefreshViewportCoordinatorFromView(); - UpdateTranscriptViewportAutomationState(); - } - - private void OnMessagesListUnloaded(object sender, RoutedEventArgs e) + if (messagesList is not null) { - DisposeTranscriptViewportHost(); - if (MessagesList is not null) - { - MessagesList.ContainerContentChanging -= _messagesListContainerContentChangingHandler; - } - MessagesList?.RemoveHandler(UIElement.KeyDownEvent, _messagesListHandledKeyDownHandler); - MessagesList?.RemoveHandler(UIElement.PointerPressedEvent, _messagesListHandledPointerPressedHandler); - MessagesList?.RemoveHandler(UIElement.PointerWheelChangedEvent, _messagesListHandledPointerWheelChangedHandler); - UpdateTranscriptViewportAutomationState(); + messagesList.ContainerContentChanging -= _messagesListContainerContentChangingHandler; + messagesList.ContainerContentChanging += _messagesListContainerContentChangingHandler; + messagesList.AddHandler(UIElement.KeyDownEvent, _messagesListHandledKeyDownHandler, true); + messagesList.AddHandler(UIElement.PointerPressedEvent, _messagesListHandledPointerPressedHandler, true); + messagesList.AddHandler(UIElement.PointerWheelChangedEvent, _messagesListHandledPointerWheelChangedHandler, true); } - private void DisposeTranscriptViewportHost() - { - if (_transcriptViewportHost is null) - { - return; - } + ResumeViewportCoordinatorAfterOverlayIfNeeded(); + BeginLayoutLoadingIfPendingMessages(); + TryApplyPendingTranscriptMessageFocus(); + TryApplyPendingProjectionRestore(); + TryIssueTranscriptScrollRequest(); + TryRefreshViewportCoordinatorFromView(); + UpdateTranscriptViewportAutomationState(); + } - _transcriptViewportHost.ViewportChanged -= OnMessagesListViewportChanged; - _transcriptViewportHost.Dispose(); - _transcriptViewportHost = null; + private void OnMessagesListUnloaded(object sender, RoutedEventArgs e) + { + DisposeTranscriptViewportHost(); + if (MessagesList is not null) + { + MessagesList.ContainerContentChanging -= _messagesListContainerContentChangingHandler; } + MessagesList?.RemoveHandler(UIElement.KeyDownEvent, _messagesListHandledKeyDownHandler); + MessagesList?.RemoveHandler(UIElement.PointerPressedEvent, _messagesListHandledPointerPressedHandler); + MessagesList?.RemoveHandler(UIElement.PointerWheelChangedEvent, _messagesListHandledPointerWheelChangedHandler); + UpdateTranscriptViewportAutomationState(); + } - private void OnMessagesListViewportChanged(object? sender, EventArgs e) + private void DisposeTranscriptViewportHost() + { + if (_transcriptViewportHost is null) { - var lastItemContainerGenerated = HasLastItemContainerGenerated(ViewModel.MessageHistory.Count); - RefreshLayoutLoadingState(lastItemContainerGenerated); - - TryApplyPendingTranscriptMessageFocus(); - TryApplyPendingProjectionRestore(); - ApplyViewportActions(_viewportController.OnViewportChanged( - CreateViewportViewState(lastItemContainerGenerated), - TryCaptureProjectionRestoreToken())); - RefreshLayoutLoadingState(lastItemContainerGenerated); - UpdateTranscriptViewportAutomationState(); + return; } - private void OnMessagesListPointerPressed(object sender, PointerRoutedEventArgs e) - { - if (MessagesList is not null - && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) - { - return; - } + _transcriptViewportHost.ViewportChanged -= OnMessagesListViewportChanged; + _transcriptViewportHost.Dispose(); + _transcriptViewportHost = null; + } - if (IsViewportDetachedByUser()) - { - _viewportController.MarkDetachedViewportInteractionStarted(); - FocusTranscriptScroller(); - return; - } + private void OnMessagesListViewportChanged(object? sender, EventArgs e) + { + var lastItemContainerGenerated = HasLastItemContainerGenerated(ViewModel.MessageHistory.Count); + RefreshLayoutLoadingState(lastItemContainerGenerated); - if (_projectionRestoreController.HasPending) - { - AbandonPendingProjectionRestore("UserInterrupted"); - } + TryApplyPendingTranscriptMessageFocus(); + TryApplyPendingProjectionRestore(); + ApplyViewportActions(_viewportController.OnViewportChanged( + CreateViewportViewState(lastItemContainerGenerated), + TryCaptureProjectionRestoreToken())); + RefreshLayoutLoadingState(lastItemContainerGenerated); + UpdateTranscriptViewportAutomationState(); + } - _viewportController.MarkUserScrollIntentStarted(); - FocusTranscriptScroller(); + private void OnMessagesListPointerPressed(object sender, PointerRoutedEventArgs e) + { + if (MessagesList is not null + && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) + { + return; } - private void OnMessagesListPointerReleased(object sender, PointerRoutedEventArgs e) + if (IsViewportDetachedByUser()) { - if (MessagesList is not null - && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) - { - return; - } - - _viewportController.MarkUserScrollIntentCompleted(); - var releaseGeneration = _viewportController.Generation; - _ = DispatcherQueue.TryEnqueue(() => - { - if (releaseGeneration != _viewportController.Generation - || ViewModel.IsActivationOverlayVisible) - { - return; - } - - TryRefreshViewportCoordinatorFromView(); - UpdateTranscriptViewportAutomationState(); - }); + _viewportController.MarkDetachedViewportInteractionStarted(); + FocusTranscriptScroller(); + return; } - private void OnMessagesListPointerWheelChanged(object sender, PointerRoutedEventArgs e) + if (_projectionRestoreController.HasPending) { - if (MessagesList is not null - && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) - { - return; - } - - RegisterUserViewportIntent(); + AbandonPendingProjectionRestore("UserInterrupted"); } - private void OnMessagesListKeyDown(object sender, KeyRoutedEventArgs e) + _viewportController.MarkUserScrollIntentStarted(); + FocusTranscriptScroller(); + } + + private void OnMessagesListPointerReleased(object sender, PointerRoutedEventArgs e) + { + if (MessagesList is not null + && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) { - if (e.Key is Windows.System.VirtualKey.Up - or Windows.System.VirtualKey.Down - or Windows.System.VirtualKey.PageUp - or Windows.System.VirtualKey.PageDown - or Windows.System.VirtualKey.Home - or Windows.System.VirtualKey.End) - { - RegisterUserViewportIntent(); - } + return; } - private void OnMessagesListContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args) + _viewportController.MarkUserScrollIntentCompleted(); + var releaseGeneration = _viewportController.Generation; + _ = DispatcherQueue.TryEnqueue(() => { - if (args.ItemContainer is not ListViewItem container) + if (releaseGeneration != _viewportController.Generation + || ViewModel.IsActivationOverlayVisible) { return; } - container.GotFocus -= _messagesListItemGotFocusHandler; - container.GotFocus += _messagesListItemGotFocusHandler; - container.ClearValue(Control.XYFocusRightProperty); - - if (FindFirstInteractiveTranscriptChild(container) is not Control firstInteractiveChild) - { - return; - } + TryRefreshViewportCoordinatorFromView(); + UpdateTranscriptViewportAutomationState(); + }); + } - container.XYFocusRight = firstInteractiveChild; - firstInteractiveChild.XYFocusLeft = container; - TryApplyPendingTranscriptMessageFocus(); + private void OnMessagesListPointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + if (MessagesList is not null + && !TranscriptPointerIntentFilter.ShouldTrackViewportIntent(e.OriginalSource, MessagesList)) + { + return; } - private void OnMessagesListItemGotFocus(object sender, RoutedEventArgs e) - { - if (sender is not ListViewItem container || MessagesList is null) - { - return; - } + RegisterUserViewportIntent(); + } - _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(container); - _isTranscriptViewportLayerActive = false; - _isTranscriptChildControlLayerActive = false; + private void OnMessagesListKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key is Windows.System.VirtualKey.Up + or Windows.System.VirtualKey.Down + or Windows.System.VirtualKey.PageUp + or Windows.System.VirtualKey.PageDown + or Windows.System.VirtualKey.Home + or Windows.System.VirtualKey.End) + { + RegisterUserViewportIntent(); } + } - public bool TryConsumeNavigationIntent(GamepadNavigationIntent intent) + private void OnMessagesListContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args) + { + if (args.ItemContainer is not ListViewItem container) { - if (_isTranscriptViewportLayerActive && IsTranscriptMessageLayerFocusWithin()) - { - _isTranscriptViewportLayerActive = false; - } + return; + } - if (!_isTranscriptChildControlLayerActive - && IsTranscriptMessageLayerFocusWithin()) - { - if (intent == GamepadNavigationIntent.MoveUp) - { - return TryMoveFocusBetweenTranscriptMessages(-1); - } + container.GotFocus -= _messagesListItemGotFocusHandler; + container.GotFocus += _messagesListItemGotFocusHandler; + container.ClearValue(Control.XYFocusRightProperty); - if (intent == GamepadNavigationIntent.MoveDown) - { - return TryMoveFocusBetweenTranscriptMessages(1); - } - } + if (FindFirstInteractiveTranscriptChild(container) is not Control firstInteractiveChild) + { + return; + } - if (intent == GamepadNavigationIntent.MoveRight) - { - return TryMoveFocusFromTranscriptMessageToChildControl(); - } + container.XYFocusRight = firstInteractiveChild; + firstInteractiveChild.XYFocusLeft = container; + TryApplyPendingTranscriptMessageFocus(); + } - if (intent == GamepadNavigationIntent.MoveLeft) - { - return TryMoveFocusFromTranscriptChildControlToMessage(); - } - - if (_transcriptViewportHost is null) - { - return false; - } + private void OnMessagesListItemGotFocus(object sender, RoutedEventArgs e) + { + if (sender is not ListViewItem container || MessagesList is null) + { + return; + } - var consumed = ChatTranscriptNavigationIntentHandler.TryConsume( - intent, - _isTranscriptViewportLayerActive || IsTranscriptViewportSurfaceFocusWithin(), - ViewModel.MessageHistory.Count, - _transcriptViewportHost.TryScrollByItems, - RegisterUserViewportIntent); - if (consumed) - { - _isTranscriptViewportLayerActive = true; - if (!IsTranscriptViewportSurfaceFocusWithin()) - { - _ = TryFocusTranscriptViewportSurface(FocusState.Keyboard); - } - } + _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(container); + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = false; + } - return consumed; + public bool TryConsumeNavigationIntent(GamepadNavigationIntent intent) + { + if (_isTranscriptViewportLayerActive && IsTranscriptMessageLayerFocusWithin()) + { + _isTranscriptViewportLayerActive = false; } - public bool TryFocusPrimaryContentTarget() + if (!_isTranscriptChildControlLayerActive + && IsTranscriptMessageLayerFocusWithin()) { - if (ViewModel.ShouldShowConversationInputSurface - && ConversationInputArea is not null - && ConversationInputArea.TryFocusInputBox()) - { - _isTranscriptViewportLayerActive = false; - ClearTranscriptMessageLayerState(); - return true; - } - - if (MessagesList is not null - && ViewModel.ShouldShowTranscriptSurface - && ViewModel.MessageHistory.Count > 0) + if (intent == GamepadNavigationIntent.MoveUp) { - return TryFocusTranscriptViewportSurface(FocusState.Keyboard); + return TryMoveFocusBetweenTranscriptMessages(-1); } - if (ViewModel.ShouldShowSessionHeader - && CurrentSessionTitleBlock is not null) + if (intent == GamepadNavigationIntent.MoveDown) { - _isTranscriptViewportLayerActive = false; - return CurrentSessionTitleBlock.Focus(FocusState.Programmatic); + return TryMoveFocusBetweenTranscriptMessages(1); } - - return false; } - private bool TryFocusTranscriptViewport() + if (intent == GamepadNavigationIntent.MoveRight) { - if (MessagesList is not null - && ViewModel.ShouldShowTranscriptSurface - && ViewModel.MessageHistory.Count > 0) - { - return TryFocusTranscriptViewportSurface(FocusState.Keyboard); - } - - return false; + return TryMoveFocusFromTranscriptMessageToChildControl(); } - private void FocusTranscriptScroller() + if (intent == GamepadNavigationIntent.MoveLeft) { - _ = TryFocusTranscriptViewportSurface(FocusState.Keyboard); + return TryMoveFocusFromTranscriptChildControlToMessage(); } - private bool TryFocusTranscriptViewportSurface(FocusState focusState) + if (_transcriptViewportHost is null) { - _pendingTranscriptMessageFocusIndex = null; - if (_transcriptViewportHost?.TryFocusViewport(focusState) == true) - { - _isTranscriptViewportLayerActive = true; - _isTranscriptChildControlLayerActive = false; - return true; - } - - if (MessagesList?.Focus(focusState) == true) - { - _isTranscriptViewportLayerActive = true; - _isTranscriptChildControlLayerActive = false; - return true; - } - return false; } - private bool IsTranscriptViewportSurfaceFocusWithin() + var consumed = ChatTranscriptNavigationIntentHandler.TryConsume( + intent, + _isTranscriptViewportLayerActive || IsTranscriptViewportSurfaceFocusWithin(), + ViewModel.MessageHistory.Count, + _transcriptViewportHost.TryScrollByItems, + RegisterUserViewportIntent); + if (consumed) { - if (MessagesList is null) - { - return false; - } - - if (MessagesList.FocusState is FocusState.Keyboard or FocusState.Programmatic) - { - return true; - } - - if (MessagesList.XamlRoot is null) + _isTranscriptViewportLayerActive = true; + if (!IsTranscriptViewportSurfaceFocusWithin()) { - return false; - } - - var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; - if (FindAncestorOrSelf(current) is ListViewItem itemContainer - && IsDescendantOf(itemContainer, MessagesList)) - { - return false; - } - - while (current is not null) - { - if (ReferenceEquals(current, MessagesList)) - { - return true; - } - - current = VisualTreeHelper.GetParent(current); + _ = TryFocusTranscriptViewportSurface(FocusState.Keyboard); } - - return false; } - private bool IsTranscriptMessageLayerFocusWithin() - { - if (MessagesList?.XamlRoot is null) - { - return false; - } - - var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; - return FindAncestorOrSelf(current) is ListViewItem itemContainer - && IsDescendantOf(itemContainer, MessagesList); - } + return consumed; + } - private static Control? FindFirstInteractiveTranscriptChild(DependencyObject root) + public bool TryFocusPrimaryContentTarget() + { + if (ViewModel.ShouldShowConversationInputSurface + && ConversationInputArea is not null + && ConversationInputArea.TryFocusInputBox()) { - var count = VisualTreeHelper.GetChildrenCount(root); - for (var index = 0; index < count; index++) - { - var child = VisualTreeHelper.GetChild(root, index); - if (child is Control control - && control is not ListViewItem - && control.Visibility == Visibility.Visible - && control.IsEnabled - && control.IsTabStop) - { - return control; - } - - var nested = FindFirstInteractiveTranscriptChild(child); - if (nested is not null) - { - return nested; - } - } - - return null; + _isTranscriptViewportLayerActive = false; + ClearTranscriptMessageLayerState(); + return true; } - private bool TryMoveFocusFromTranscriptMessageToChildControl() + if (MessagesList is not null + && ViewModel.ShouldShowTranscriptSurface + && ViewModel.MessageHistory.Count > 0) { - if (MessagesList?.XamlRoot is null) - { - return false; - } - - var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; - var focusedItemContainer = FindAncestorOrSelf(current); - if (focusedItemContainer is null || !IsDescendantOf(focusedItemContainer, MessagesList)) - { - return false; - } - - if (!ReferenceEquals(current, focusedItemContainer)) - { - return false; - } - - if (FindFirstInteractiveTranscriptChild(focusedItemContainer) is not Control firstInteractiveChild) - { - return false; - } - - _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(focusedItemContainer); - var focused = TryFocusTranscriptNavigationTarget(firstInteractiveChild); - _isTranscriptViewportLayerActive = false; - _isTranscriptChildControlLayerActive = focused; - return focused; + return TryFocusTranscriptViewportSurface(FocusState.Keyboard); } - private bool TryMoveFocusBetweenTranscriptMessages(int itemDelta) + if (ViewModel.ShouldShowSessionHeader + && CurrentSessionTitleBlock is not null) { - if (itemDelta == 0 || MessagesList?.XamlRoot is null || MessagesList.Items.Count <= 0) - { - return false; - } - - var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; - var focusedItemContainer = FindAncestorOrSelf(current); - var currentIndex = -1; - if (focusedItemContainer is not null && IsDescendantOf(focusedItemContainer, MessagesList)) - { - currentIndex = MessagesList.IndexFromContainer(focusedItemContainer); - } - - if (currentIndex < 0 && _activeTranscriptMessageAnchorItem is not null) - { - currentIndex = MessagesList.Items.IndexOf(_activeTranscriptMessageAnchorItem); - } - - if (currentIndex < 0) - { - return false; - } - - var targetIndex = Math.Clamp(currentIndex + itemDelta, 0, MessagesList.Items.Count - 1); - if (targetIndex == currentIndex) - { - _isTranscriptViewportLayerActive = false; - _isTranscriptChildControlLayerActive = false; - return true; - } - - if (MessagesList.ContainerFromIndex(targetIndex) is not ListViewItem targetContainer) - { - if (MessagesList.Items[targetIndex] is not object item) - { - return false; - } - - _pendingTranscriptMessageFocusIndex = targetIndex; - MessagesList.ScrollIntoView(item, ScrollIntoViewAlignment.Leading); - _activeTranscriptMessageAnchorItem = item; - _isTranscriptViewportLayerActive = false; - _isTranscriptChildControlLayerActive = false; - _ = DispatcherQueue.TryEnqueue(TryApplyPendingTranscriptMessageFocus); - return true; - } - - _pendingTranscriptMessageFocusIndex = null; - _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(targetContainer); _isTranscriptViewportLayerActive = false; - _isTranscriptChildControlLayerActive = false; - return TryFocusTranscriptNavigationTarget(targetContainer); + return CurrentSessionTitleBlock.Focus(FocusState.Programmatic); } - private void TryApplyPendingTranscriptMessageFocus() - { - if (_pendingTranscriptMessageFocusIndex is not int pendingIndex - || MessagesList?.ContainerFromIndex(pendingIndex) is not ListViewItem pendingContainer) - { - return; - } - - if (TryFocusTranscriptNavigationTarget(pendingContainer)) - { - _pendingTranscriptMessageFocusIndex = null; - } - } + return false; + } - private bool TryMoveFocusFromTranscriptChildControlToMessage() + private bool TryFocusTranscriptViewport() + { + if (MessagesList is not null + && ViewModel.ShouldShowTranscriptSurface + && ViewModel.MessageHistory.Count > 0) { - if (MessagesList?.XamlRoot is null) - { - return false; - } + return TryFocusTranscriptViewportSurface(FocusState.Keyboard); + } - var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; - var focusedItemContainer = FindAncestorOrSelf(current); - if (focusedItemContainer is null || !IsDescendantOf(focusedItemContainer, MessagesList)) - { - if (_isTranscriptChildControlLayerActive && TryFocusStoredTranscriptMessageAnchor()) - { - _isTranscriptChildControlLayerActive = false; - return true; - } - return false; - } + return false; + } - if (ReferenceEquals(current, focusedItemContainer)) - { - return false; - } + private void FocusTranscriptScroller() + { + _ = TryFocusTranscriptViewportSurface(FocusState.Keyboard); + } - var focused = TryFocusTranscriptNavigationTarget(focusedItemContainer); - _isTranscriptViewportLayerActive = false; + private bool TryFocusTranscriptViewportSurface(FocusState focusState) + { + _pendingTranscriptMessageFocusIndex = null; + if (_transcriptViewportHost?.TryFocusViewport(focusState) == true) + { + _isTranscriptViewportLayerActive = true; _isTranscriptChildControlLayerActive = false; - return focused; + return true; } - private bool TryFocusStoredTranscriptMessageAnchor() + if (MessagesList?.Focus(focusState) == true) { - if (MessagesList is null || _activeTranscriptMessageAnchorItem is null) - { - return false; - } - - if (MessagesList.ContainerFromItem(_activeTranscriptMessageAnchorItem) is not ListViewItem container) - { - return false; - } - - return TryFocusTranscriptNavigationTarget(container); + _isTranscriptViewportLayerActive = true; + _isTranscriptChildControlLayerActive = false; + return true; } - private void ClearTranscriptMessageLayerState() + return false; + } + + private bool IsTranscriptViewportSurfaceFocusWithin() + { + if (MessagesList is null) { - _activeTranscriptMessageAnchorItem = null; - _isTranscriptChildControlLayerActive = false; - _pendingTranscriptMessageFocusIndex = null; + return false; } - private static bool TryFocusTranscriptNavigationTarget(Control target) + if (MessagesList.FocusState is FocusState.Keyboard or FocusState.Programmatic) { - return target.Focus(FocusState.Keyboard) - || target.Focus(FocusState.Programmatic); + return true; } - private static T? FindAncestorOrSelf(DependencyObject? start) - where T : class + if (MessagesList.XamlRoot is null) { - var current = start; - while (current is not null) - { - if (current is T match) - { - return match; - } - - current = VisualTreeHelper.GetParent(current); - } - - return default; + return false; } - private static bool IsDescendantOf(DependencyObject? current, DependencyObject ancestor) + var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; + if (FindAncestorOrSelf(current) is ListViewItem itemContainer + && IsDescendantOf(itemContainer, MessagesList)) { - var node = current; - while (node is not null) - { - if (ReferenceEquals(node, ancestor)) - { - return true; - } - - node = VisualTreeHelper.GetParent(node); - } - return false; } - private void RegisterUserViewportIntent() + while (current is not null) { - if (_projectionRestoreController.HasPending) + if (ReferenceEquals(current, MessagesList)) { - AbandonPendingProjectionRestore("UserInterrupted"); - } - - if (IsViewportDetachedByUser()) - { - FocusTranscriptScroller(); - ApplyViewportActions(_viewportController.OnUserViewportIntent(CreateViewportViewState())); - UpdateTranscriptViewportAutomationState(); - return; + return true; } - FocusTranscriptScroller(); + current = VisualTreeHelper.GetParent(current); + } - if (IsListViewportAtBottom()) - { - ApplyViewportActions(_viewportController.OnUserViewportDetachIntent( - CreateViewportViewState(), - TryCaptureProjectionRestoreToken())); - UpdateTranscriptViewportAutomationState(); - return; - } + return false; + } - ApplyViewportActions(_viewportController.OnUserViewportDetachIntent( - CreateViewportViewState(), - TryCaptureProjectionRestoreToken())); - RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); - UpdateTranscriptViewportAutomationState(); + private bool IsTranscriptMessageLayerFocusWithin() + { + if (MessagesList?.XamlRoot is null) + { + return false; } - private TranscriptProjectionRestoreToken? TryCaptureProjectionRestoreToken() + var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; + return FindAncestorOrSelf(current) is ListViewItem itemContainer + && IsDescendantOf(itemContainer, MessagesList); + } + + private static Control? FindFirstInteractiveTranscriptChild(DependencyObject root) + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) { - if (_transcriptViewportHost is null || ViewModel.MessageHistory.Count <= 0) + var child = VisualTreeHelper.GetChild(root, index); + if (child is Control control + && control is not ListViewItem + && control.Visibility == Visibility.Visible + && control.IsEnabled + && control.IsTabStop) { - return null; + return control; } - if (!_transcriptViewportHost.TryGetFirstVisibleIndex(ViewModel.MessageHistory.Count, out var firstVisibleIndex) - || firstVisibleIndex < 0 - || firstVisibleIndex >= ViewModel.MessageHistory.Count) + var nested = FindFirstInteractiveTranscriptChild(child); + if (nested is not null) { - return null; + return nested; } - - return ViewModel.CreateViewportProjectionRestoreToken(ViewModel.MessageHistory[firstVisibleIndex]); } - private int ResolveProjectionRestoreIndex(TranscriptProjectionRestoreToken token) - => ViewModel.MessageHistory.IndexOfProjectionItemKey(token.ProjectionItemKey); + return null; + } - private void OnProjectionRestoreReady(object? sender, ProjectionRestoreReadyEventArgs e) + private bool TryMoveFocusFromTranscriptMessageToChildControl() + { + if (MessagesList?.XamlRoot is null) { - ApplyViewportActions(_viewportController.OnProjectionReady(e.ConversationId, e.ProjectionEpoch)); - TryApplyPendingProjectionRestore(); - UpdateTranscriptViewportAutomationState(); + return false; } - private void TryRefreshViewportCoordinatorFromView(bool? lastItemContainerGenerated = null) + var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; + var focusedItemContainer = FindAncestorOrSelf(current); + if (focusedItemContainer is null || !IsDescendantOf(focusedItemContainer, MessagesList)) { - if (!_isViewLoaded - || _transcriptViewportHost is null - || ViewModel.IsActivationOverlayVisible - || !ViewModel.IsSessionActive - || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) - { - return; - } - - ApplyViewportActions(_viewportController.OnViewportChanged( - CreateViewportViewState(lastItemContainerGenerated), - TryCaptureProjectionRestoreToken())); + return false; } - private TranscriptViewportViewState CreateViewportViewState(bool? lastItemContainerGenerated = null) + if (!ReferenceEquals(current, focusedItemContainer)) { - var messageCount = ViewModel.MessageHistory.Count; - var hasMessages = messageCount > 0; - return new TranscriptViewportViewState( - IsViewReady: _isViewLoaded - && _transcriptViewportHost is not null - && !ViewModel.IsActivationOverlayVisible - && ViewModel.IsSessionActive - && !string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId), - IsViewportReady: hasMessages && (lastItemContainerGenerated ?? HasLastItemContainerGenerated(messageCount)), - HasMessages: hasMessages, - IsAtBottom: IsListViewportAtBottom(), - IsLastItemVisibleAtBottom: hasMessages && IsLastItemVisiblyAtBottom(messageCount)); + return false; } - private void ApplyViewportActions(IReadOnlyList actions) + if (FindFirstInteractiveTranscriptChild(focusedItemContainer) is not Control firstInteractiveChild) { - foreach (var action in actions) - { - ApplyViewportAction(action); - } + return false; } - private void ApplyViewportAction(TranscriptViewportControllerAction action) + _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(focusedItemContainer); + var focused = TryFocusTranscriptNavigationTarget(firstInteractiveChild); + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = focused; + return focused; + } + + private bool TryMoveFocusBetweenTranscriptMessages(int itemDelta) + { + if (itemDelta == 0 || MessagesList?.XamlRoot is null || MessagesList.Items.Count <= 0) { - switch (action.Kind) - { - case TranscriptViewportControllerActionKind.ScrollLastMessageIntoView: - if (action.ScrollRequestToken.Generation >= 0) - { - IssueNativeTranscriptScrollRequest(action.ScrollRequestToken); - } - else - { - RequestScrollToBottom(); - } - break; - - case TranscriptViewportControllerActionKind.RequestRestore: - if (action.RestoreToken is { } restoreToken) - { - QueueProjectionOwnedRestore(restoreToken, action.Generation); - } - break; - - case TranscriptViewportControllerActionKind.StopProgrammaticScroll: - ClearPendingProjectionRestore(); - break; - - case TranscriptViewportControllerActionKind.AutoFollowDetached: - ClearPendingProjectionRestore(); - break; - - case TranscriptViewportControllerActionKind.AutoFollowAttached: - ClearPendingProjectionRestore(); - break; - } + return false; } - private void ActivateViewportForCurrentSession(TranscriptViewportActivationKind activationKind) + var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; + var focusedItemContainer = FindAncestorOrSelf(current); + var currentIndex = -1; + if (focusedItemContainer is not null && IsDescendantOf(focusedItemContainer, MessagesList)) { - ApplyViewportActions(_viewportController.ActivateCurrentConversation( - CurrentViewportConversationId, - ViewModel.IsSessionActive, - ViewModel.IsActivationOverlayVisible, - ViewModel.MessageHistory.Count > 0, - activationKind)); + currentIndex = MessagesList.IndexFromContainer(focusedItemContainer); } - private bool IsViewportDetachedByUser() + if (currentIndex < 0 && _activeTranscriptMessageAnchorItem is not null) { - return _viewportController.State is TranscriptViewportState.DetachedByUser - or TranscriptViewportState.DetachedPendingRestore - or TranscriptViewportState.DetachedRestoring; + currentIndex = MessagesList.Items.IndexOf(_activeTranscriptMessageAnchorItem); } - private string CurrentViewportConversationId => ViewModel.CurrentSessionId ?? string.Empty; + if (currentIndex < 0) + { + return false; + } - private void QueueProjectionOwnedRestore(TranscriptProjectionRestoreToken token, int generation) + var targetIndex = Math.Clamp(currentIndex + itemDelta, 0, MessagesList.Items.Count - 1); + if (targetIndex == currentIndex) { - _projectionRestoreController.Queue(token, generation); - _viewportController.MarkProjectionRestoreQueued(); - TryApplyPendingProjectionRestore(); + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = false; + return true; } - private void TryApplyPendingProjectionRestore() + if (MessagesList.ContainerFromIndex(targetIndex) is not ListViewItem targetContainer) { - if (_transcriptViewportHost is null || !_isViewLoaded) + if (MessagesList.Items[targetIndex] is not object item) { - return; + return false; } - ApplyProjectionRestoreResult(_projectionRestoreController.TryApply( - _transcriptViewportHost, - ViewModel.MessageHistory.Count, - CurrentViewportConversationId, - _viewportController.Generation, - ResolveProjectionRestoreIndex)); + _pendingTranscriptMessageFocusIndex = targetIndex; + MessagesList.ScrollIntoView(item, ScrollIntoViewAlignment.Leading); + _activeTranscriptMessageAnchorItem = item; + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = false; + _ = DispatcherQueue.TryEnqueue(TryApplyPendingTranscriptMessageFocus); + return true; } - private void AbandonPendingProjectionRestore(string reason) + _pendingTranscriptMessageFocusIndex = null; + _activeTranscriptMessageAnchorItem = MessagesList.ItemFromContainer(targetContainer); + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = false; + return TryFocusTranscriptNavigationTarget(targetContainer); + } + + private void TryApplyPendingTranscriptMessageFocus() + { + if (_pendingTranscriptMessageFocusIndex is not int pendingIndex + || MessagesList?.ContainerFromIndex(pendingIndex) is not ListViewItem pendingContainer) { - ApplyProjectionRestoreResult(_projectionRestoreController.Abandon(CurrentViewportConversationId, reason)); + return; } - private void ClearPendingProjectionRestore() + if (TryFocusTranscriptNavigationTarget(pendingContainer)) { - _projectionRestoreController.Clear(); + _pendingTranscriptMessageFocusIndex = null; } + } - private void ApplyProjectionRestoreResult(TranscriptProjectionRestoreResult result) + private bool TryMoveFocusFromTranscriptChildControlToMessage() + { + if (MessagesList?.XamlRoot is null) { - switch (result.Kind) - { - case TranscriptProjectionRestoreResultKind.Retry: - _projectionRestoreController.TryScheduleRetry(DispatcherQueue, TryApplyPendingProjectionRestore); - break; - - case TranscriptProjectionRestoreResultKind.Confirmed: - if (result.Token is { } token) - { - ApplyViewportActions(_viewportController.OnRestoreConfirmed(token, result.Generation)); - } - break; - - case TranscriptProjectionRestoreResultKind.Unavailable: - ApplyViewportActions(_viewportController.OnRestoreUnavailable( - result.ConversationId ?? CurrentViewportConversationId, - result.Generation, - result.Reason ?? "RestoreUnavailable")); - break; - - case TranscriptProjectionRestoreResultKind.Abandoned: - ApplyViewportActions(_viewportController.OnRestoreAbandoned( - result.ConversationId ?? CurrentViewportConversationId, - result.Generation, - result.Reason ?? "RestoreAbandoned")); - break; - } + return false; } - private void RequestScrollToBottom() + var current = XamlFocusManager.GetFocusedElement(MessagesList.XamlRoot) as DependencyObject; + var focusedItemContainer = FindAncestorOrSelf(current); + if (focusedItemContainer is null || !IsDescendantOf(focusedItemContainer, MessagesList)) { - if (_transcriptViewportHost is not null && ViewModel.MessageHistory.Count > 0) + if (_isTranscriptChildControlLayerActive && TryFocusStoredTranscriptMessageAnchor()) { - _transcriptViewportHost.ScrollItemIntoView( - ViewModel.MessageHistory.Count - 1, - TranscriptItemScrollAlignment.Leading); + _isTranscriptChildControlLayerActive = false; + return true; } + return false; } - private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + if (ReferenceEquals(current, focusedItemContainer)) { - if (e.PropertyName == nameof(ChatViewModel.CurrentSessionId)) - { - ResetAutoScrollStateForConversationChange(); - _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; - TryIssueTranscriptScrollRequestIfAttached(); - BeginLayoutLoadingIfPendingMessages(); - UpdateTranscriptViewportAutomationState(); - return; - } + return false; + } - if (e.PropertyName == nameof(ChatViewModel.IsSessionActive)) - { - if (_viewportController.State is TranscriptViewportState.DetachedPendingRestore - or TranscriptViewportState.DetachedRestoring) - { - BeginLayoutLoadingIfPendingMessages(); - TryApplyPendingProjectionRestore(); - TryRefreshViewportCoordinatorFromView(); - UpdateTranscriptViewportAutomationState(); - return; - } + var focused = TryFocusTranscriptNavigationTarget(focusedItemContainer); + _isTranscriptViewportLayerActive = false; + _isTranscriptChildControlLayerActive = false; + return focused; + } - ResetAutoScrollStateForConversationChange(); - _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; - TryIssueTranscriptScrollRequestIfAttached(); - BeginLayoutLoadingIfPendingMessages(); - UpdateTranscriptViewportAutomationState(); - return; - } + private bool TryFocusStoredTranscriptMessageAnchor() + { + if (MessagesList is null || _activeTranscriptMessageAnchorItem is null) + { + return false; + } - if (e.PropertyName == nameof(ChatViewModel.MessageHistory)) - { - EnsureMessageTracking(); - if (_viewportController.State is TranscriptViewportState.DetachedPendingRestore - or TranscriptViewportState.DetachedRestoring) - { - BeginLayoutLoadingIfPendingMessages(); - TryApplyPendingProjectionRestore(); - TryRefreshViewportCoordinatorFromView(); - UpdateTranscriptViewportAutomationState(); - return; - } + if (MessagesList.ContainerFromItem(_activeTranscriptMessageAnchorItem) is not ListViewItem container) + { + return false; + } - ResetAutoScrollStateForConversationChange(); - TryIssueTranscriptScrollRequestIfAttached(); - BeginLayoutLoadingIfPendingMessages(); - UpdateTranscriptViewportAutomationState(); - return; - } + return TryFocusTranscriptNavigationTarget(container); + } + + private void ClearTranscriptMessageLayerState() + { + _activeTranscriptMessageAnchorItem = null; + _isTranscriptChildControlLayerActive = false; + _pendingTranscriptMessageFocusIndex = null; + } - if (e.PropertyName == nameof(ChatViewModel.IsActivationOverlayVisible)) + private static bool TryFocusTranscriptNavigationTarget(Control target) + { + return target.Focus(FocusState.Keyboard) + || target.Focus(FocusState.Programmatic); + } + + private static T? FindAncestorOrSelf(DependencyObject? start) + where T : class + { + var current = start; + while (current is not null) + { + if (current is T match) { - HandleOverlayVisibilityChanged(); - UpdateTranscriptViewportAutomationState(); - return; + return match; } - if (e.PropertyName == nameof(ChatViewModel.PresentedSessionHeaderDisplayName)) + current = VisualTreeHelper.GetParent(current); + } + + return default; + } + + private static bool IsDescendantOf(DependencyObject? current, DependencyObject ancestor) + { + var node = current; + while (node is not null) + { + if (ReferenceEquals(node, ancestor)) { - Bindings.Update(); - return; + return true; } + + node = VisualTreeHelper.GetParent(node); } - private void BeginLayoutLoadingIfPendingMessages() + return false; + } + + private void RegisterUserViewportIntent() + { + if (_projectionRestoreController.HasPending) { - RefreshLayoutLoadingState(); + AbandonPendingProjectionRestore("UserInterrupted"); } - private void RefreshLayoutLoadingState(bool lastItemContainerGenerated = false) + if (IsViewportDetachedByUser()) { - ViewModel.IsLayoutLoading = InitialLayoutLoadingPolicy.ShouldKeepLoading( - isSessionActive: ViewModel.IsSessionActive, - messageCount: ViewModel.MessageHistory.Count, - hasPendingInitialScroll: _viewportController.HasPendingSettle, - lastItemContainerGenerated: lastItemContainerGenerated, - isHydrating: ViewModel.IsHydrating, - isRemoteHydrationPending: ViewModel.IsRemoteHydrationPending); + FocusTranscriptScroller(); + ApplyViewportActions(_viewportController.OnUserViewportIntent(CreateViewportViewState())); UpdateTranscriptViewportAutomationState(); + return; } - private bool TryIssueTranscriptScrollRequest() + FocusTranscriptScroller(); + + if (IsListViewportAtBottom()) { - var actions = _viewportController.OnViewportChanged(CreateViewportViewState()); - ApplyViewportActions(actions); - RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); + ApplyViewportActions(_viewportController.OnUserViewportDetachIntent( + CreateViewportViewState(), + TryCaptureProjectionRestoreToken())); UpdateTranscriptViewportAutomationState(); - return actions.Count > 0; + return; } - private void TryIssueTranscriptScrollRequestIfAttached() + ApplyViewportActions(_viewportController.OnUserViewportDetachIntent( + CreateViewportViewState(), + TryCaptureProjectionRestoreToken())); + RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); + UpdateTranscriptViewportAutomationState(); + } + + private TranscriptProjectionRestoreToken? TryCaptureProjectionRestoreToken() + { + if (_transcriptViewportHost is null || ViewModel.MessageHistory.Count <= 0) { - if (!IsViewportDetachedByUser()) - { - TryIssueTranscriptScrollRequest(); - } + return null; } - private void IssueNativeTranscriptScrollRequest(TranscriptScrollRequestToken requestToken) + if (!_transcriptViewportHost.TryGetFirstVisibleIndex(ViewModel.MessageHistory.Count, out var firstVisibleIndex) + || firstVisibleIndex < 0 + || firstVisibleIndex >= ViewModel.MessageHistory.Count) { - if (_transcriptViewportHost is null - || ViewModel.MessageHistory.Count <= 0 - || !_viewportController.MatchesActiveScrollRequest(requestToken)) - { - return; - } + return null; + } - RequestScrollToBottom(); + return ViewModel.CreateViewportProjectionRestoreToken(ViewModel.MessageHistory[firstVisibleIndex]); + } + + private int ResolveProjectionRestoreIndex(TranscriptProjectionRestoreToken token) + => ViewModel.MessageHistory.IndexOfProjectionItemKey(token.ProjectionItemKey); + + private void OnProjectionRestoreReady(object? sender, ProjectionRestoreReadyEventArgs e) + { + ApplyViewportActions(_viewportController.OnProjectionReady(e.ConversationId, e.ProjectionEpoch)); + TryApplyPendingProjectionRestore(); + UpdateTranscriptViewportAutomationState(); + } + + private void TryRefreshViewportCoordinatorFromView(bool? lastItemContainerGenerated = null) + { + if (!_isViewLoaded + || _transcriptViewportHost is null + || ViewModel.IsActivationOverlayVisible + || !ViewModel.IsSessionActive + || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) + { + return; + } + + ApplyViewportActions(_viewportController.OnViewportChanged( + CreateViewportViewState(lastItemContainerGenerated), + TryCaptureProjectionRestoreToken())); + } + + private TranscriptViewportViewState CreateViewportViewState(bool? lastItemContainerGenerated = null) + { + var messageCount = ViewModel.MessageHistory.Count; + var hasMessages = messageCount > 0; + return new TranscriptViewportViewState( + IsViewReady: _isViewLoaded + && _transcriptViewportHost is not null + && !ViewModel.IsActivationOverlayVisible + && ViewModel.IsSessionActive + && !string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId), + IsViewportReady: hasMessages && (lastItemContainerGenerated ?? HasLastItemContainerGenerated(messageCount)), + HasMessages: hasMessages, + IsAtBottom: IsListViewportAtBottom(), + IsLastItemVisibleAtBottom: hasMessages && IsLastItemVisiblyAtBottom(messageCount)); + } + + private void ApplyViewportActions(IReadOnlyList actions) + { + foreach (var action in actions) + { + ApplyViewportAction(action); + } + } + + private void ApplyViewportAction(TranscriptViewportControllerAction action) + { + switch (action.Kind) + { + case TranscriptViewportControllerActionKind.ScrollLastMessageIntoView: + if (action.ScrollRequestToken.Generation >= 0) + { + IssueNativeTranscriptScrollRequest(action.ScrollRequestToken); + } + else + { + RequestScrollToBottom(); + } + break; - _ = DispatcherQueue.TryEnqueue(() => - { - if (!_isViewLoaded - || _transcriptViewportHost is null - || ViewModel.MessageHistory.Count <= 0 - || !_viewportController.MatchesActiveScrollRequest(requestToken)) + case TranscriptViewportControllerActionKind.RequestRestore: + if (action.RestoreToken is { } restoreToken) + { + QueueProjectionOwnedRestore(restoreToken, action.Generation); + } + break; + + case TranscriptViewportControllerActionKind.StopProgrammaticScroll: + ClearPendingProjectionRestore(); + break; + + case TranscriptViewportControllerActionKind.AutoFollowDetached: + ClearPendingProjectionRestore(); + break; + + case TranscriptViewportControllerActionKind.AutoFollowAttached: + ClearPendingProjectionRestore(); + break; + } + } + + private void ActivateViewportForCurrentSession(TranscriptViewportActivationKind activationKind) + { + ApplyViewportActions(_viewportController.ActivateCurrentConversation( + CurrentViewportConversationId, + ViewModel.IsSessionActive, + ViewModel.IsActivationOverlayVisible, + ViewModel.MessageHistory.Count > 0, + activationKind)); + } + + private bool IsViewportDetachedByUser() + { + return _viewportController.State is TranscriptViewportState.DetachedByUser + or TranscriptViewportState.DetachedPendingRestore + or TranscriptViewportState.DetachedRestoring; + } + + private string CurrentViewportConversationId => ViewModel.CurrentSessionId ?? string.Empty; + + private void QueueProjectionOwnedRestore(TranscriptProjectionRestoreToken token, int generation) + { + _projectionRestoreController.Queue(token, generation); + _viewportController.MarkProjectionRestoreQueued(); + TryApplyPendingProjectionRestore(); + } + + private void TryApplyPendingProjectionRestore() + { + if (_transcriptViewportHost is null || !_isViewLoaded) + { + return; + } + + ApplyProjectionRestoreResult(_projectionRestoreController.TryApply( + _transcriptViewportHost, + ViewModel.MessageHistory.Count, + CurrentViewportConversationId, + _viewportController.Generation, + ResolveProjectionRestoreIndex)); + } + + private void AbandonPendingProjectionRestore(string reason) + { + ApplyProjectionRestoreResult(_projectionRestoreController.Abandon(CurrentViewportConversationId, reason)); + } + + private void ClearPendingProjectionRestore() + { + _projectionRestoreController.Clear(); + } + + private void ApplyProjectionRestoreResult(TranscriptProjectionRestoreResult result) + { + switch (result.Kind) + { + case TranscriptProjectionRestoreResultKind.Retry: + _projectionRestoreController.TryScheduleRetry(DispatcherQueue, TryApplyPendingProjectionRestore); + break; + + case TranscriptProjectionRestoreResultKind.Confirmed: + if (result.Token is { } token) { - return; + ApplyViewportActions(_viewportController.OnRestoreConfirmed(token, result.Generation)); } + break; - RequestScrollToBottom(); - ScheduleTranscriptScrollRequestObservation(requestToken); - }); + case TranscriptProjectionRestoreResultKind.Unavailable: + ApplyViewportActions(_viewportController.OnRestoreUnavailable( + result.ConversationId ?? CurrentViewportConversationId, + result.Generation, + result.Reason ?? "RestoreUnavailable")); + break; + + case TranscriptProjectionRestoreResultKind.Abandoned: + ApplyViewportActions(_viewportController.OnRestoreAbandoned( + result.ConversationId ?? CurrentViewportConversationId, + result.Generation, + result.Reason ?? "RestoreAbandoned")); + break; } + } - private void ScheduleTranscriptScrollRequestObservation(TranscriptScrollRequestToken requestToken) + private void RequestScrollToBottom() + { + if (_transcriptViewportHost is not null && ViewModel.MessageHistory.Count > 0) { - _ = DispatcherQueue.TryEnqueue(() => - { - if (!_isViewLoaded - || _transcriptViewportHost is null - || ViewModel.MessageHistory.Count <= 0 - || !_viewportController.MatchesActiveScrollRequest(requestToken)) - { - return; - } + _transcriptViewportHost.ScrollItemIntoView( + ViewModel.MessageHistory.Count - 1, + TranscriptItemScrollAlignment.Leading); + } + } - var lastItemContainerGenerated = HasLastItemContainerGenerated(ViewModel.MessageHistory.Count); - ObserveActiveTranscriptScrollFromLayout(lastItemContainerGenerated); - TryIssueTranscriptScrollRequest(); - TryRefreshViewportCoordinatorFromView(lastItemContainerGenerated); - UpdateTranscriptViewportAutomationState(); - }); + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ChatViewModel.CurrentSessionId)) + { + ResetAutoScrollStateForConversationChange(); + _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; + TryIssueTranscriptScrollRequestIfAttached(); + BeginLayoutLoadingIfPendingMessages(); + UpdateTranscriptViewportAutomationState(); + return; } - private bool HasLastItemContainerGenerated(int itemCount) + if (e.PropertyName == nameof(ChatViewModel.IsSessionActive)) { - if (_transcriptViewportHost is null || itemCount <= 0) + if (_viewportController.State is TranscriptViewportState.DetachedPendingRestore + or TranscriptViewportState.DetachedRestoring) { - return false; + BeginLayoutLoadingIfPendingMessages(); + TryApplyPendingProjectionRestore(); + TryRefreshViewportCoordinatorFromView(); + UpdateTranscriptViewportAutomationState(); + return; } - return _transcriptViewportHost.HasRealizedItem(itemCount - 1); + ResetAutoScrollStateForConversationChange(); + _wasOverlayVisible = ViewModel.IsActivationOverlayVisible; + TryIssueTranscriptScrollRequestIfAttached(); + BeginLayoutLoadingIfPendingMessages(); + UpdateTranscriptViewportAutomationState(); + return; } - private bool IsListViewportAtBottom() + if (e.PropertyName == nameof(ChatViewModel.MessageHistory)) { - if (_transcriptViewportHost is null) - { - return false; - } - - var itemCount = ViewModel.MessageHistory.Count; - if (itemCount <= 0) + EnsureMessageTracking(); + if (_viewportController.State is TranscriptViewportState.DetachedPendingRestore + or TranscriptViewportState.DetachedRestoring) { - return true; + BeginLayoutLoadingIfPendingMessages(); + TryApplyPendingProjectionRestore(); + TryRefreshViewportCoordinatorFromView(); + UpdateTranscriptViewportAutomationState(); + return; } - return _transcriptViewportHost.IsAtBottom(itemCount, BottomThreshold, BottomGeometryTolerance); + ResetAutoScrollStateForConversationChange(); + TryIssueTranscriptScrollRequestIfAttached(); + BeginLayoutLoadingIfPendingMessages(); + UpdateTranscriptViewportAutomationState(); + return; } - private bool ObserveActiveTranscriptScrollFromLayout(bool? lastItemContainerGenerated = null) + if (e.PropertyName == nameof(ChatViewModel.IsActivationOverlayVisible)) + { + HandleOverlayVisibilityChanged(); + UpdateTranscriptViewportAutomationState(); + return; + } + + if (e.PropertyName == nameof(ChatViewModel.PresentedSessionHeaderDisplayName)) + { + Bindings.Update(); + return; + } + } + + private void BeginLayoutLoadingIfPendingMessages() + { + RefreshLayoutLoadingState(); + } + + private void RefreshLayoutLoadingState(bool lastItemContainerGenerated = false) + { + ViewModel.IsLayoutLoading = InitialLayoutLoadingPolicy.ShouldKeepLoading( + isSessionActive: ViewModel.IsSessionActive, + messageCount: ViewModel.MessageHistory.Count, + hasPendingInitialScroll: _viewportController.HasPendingSettle, + lastItemContainerGenerated: lastItemContainerGenerated, + isHydrating: ViewModel.IsHydrating, + isRemoteHydrationPending: ViewModel.IsRemoteHydrationPending); + UpdateTranscriptViewportAutomationState(); + } + + private bool TryIssueTranscriptScrollRequest() + { + var actions = _viewportController.OnViewportChanged(CreateViewportViewState()); + ApplyViewportActions(actions); + RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); + UpdateTranscriptViewportAutomationState(); + return actions.Count > 0; + } + + private void TryIssueTranscriptScrollRequestIfAttached() + { + if (!IsViewportDetachedByUser()) { - var actions = _viewportController.OnActiveScrollObservation(CreateViewportViewState(lastItemContainerGenerated)); - ApplyViewportActions(actions); - return actions.Count > 0; + TryIssueTranscriptScrollRequest(); } + } - private bool IsLastItemVisiblyAtBottom(int itemCount) + private void IssueNativeTranscriptScrollRequest(TranscriptScrollRequestToken requestToken) + { + if (_transcriptViewportHost is null + || ViewModel.MessageHistory.Count <= 0 + || !_viewportController.MatchesActiveScrollRequest(requestToken)) { - if (_transcriptViewportHost is null || itemCount <= 0) - { - return false; - } - - return _transcriptViewportHost.IsLastItemVisiblyAtBottom(itemCount, BottomThreshold, BottomGeometryTolerance); + return; } - private void ResetAutoScrollStateForConversationChange() + RequestScrollToBottom(); + + _ = DispatcherQueue.TryEnqueue(() => { - AbandonPendingProjectionRestore("ConversationChanged"); - ClearPendingProjectionRestore(); - if (ViewModel.IsActivationOverlayVisible) + if (!_isViewLoaded + || _transcriptViewportHost is null + || ViewModel.MessageHistory.Count <= 0 + || !_viewportController.MatchesActiveScrollRequest(requestToken)) { - _resumeViewportCoordinatorAfterOverlayPending = true; - UpdateTranscriptViewportAutomationState(); return; } - if (_resumeViewportCoordinatorAfterOverlayPending) + RequestScrollToBottom(); + ScheduleTranscriptScrollRequestObservation(requestToken); + }); + } + + private void ScheduleTranscriptScrollRequestObservation(TranscriptScrollRequestToken requestToken) + { + _ = DispatcherQueue.TryEnqueue(() => + { + if (!_isViewLoaded + || _transcriptViewportHost is null + || ViewModel.MessageHistory.Count <= 0 + || !_viewportController.MatchesActiveScrollRequest(requestToken)) { - ResumeViewportCoordinatorAfterOverlayIfNeeded(); - UpdateTranscriptViewportAutomationState(); return; } - _restoreDetachedViewportAfterOverlay = false; - _restoreDetachedViewportConversationId = null; - _resumeViewportCoordinatorAfterOverlayPending = false; - ApplyViewportActions(_viewportController.OnConversationChanged( - CurrentViewportConversationId, - ViewModel.IsSessionActive, - ViewModel.IsActivationOverlayVisible, - ViewModel.MessageHistory.Count > 0)); + var lastItemContainerGenerated = HasLastItemContainerGenerated(ViewModel.MessageHistory.Count); + ObserveActiveTranscriptScrollFromLayout(lastItemContainerGenerated); + TryIssueTranscriptScrollRequest(); + TryRefreshViewportCoordinatorFromView(lastItemContainerGenerated); UpdateTranscriptViewportAutomationState(); + }); + } + + private bool HasLastItemContainerGenerated(int itemCount) + { + if (_transcriptViewportHost is null || itemCount <= 0) + { + return false; } - private void HandleOverlayVisibilityChanged() + return _transcriptViewportHost.HasRealizedItem(itemCount - 1); + } + + private bool IsListViewportAtBottom() + { + if (_transcriptViewportHost is null) { - var isOverlayVisible = ViewModel.IsActivationOverlayVisible; - var overlayJustDismissed = _wasOverlayVisible && !isOverlayVisible; - _wasOverlayVisible = isOverlayVisible; + return false; + } - if (isOverlayVisible) - { - if (IsViewportDetachedByUser()) - { - _restoreDetachedViewportAfterOverlay = true; - _restoreDetachedViewportConversationId = CurrentViewportConversationId; - } - _resumeViewportCoordinatorAfterOverlayPending = true; - ApplyViewportActions(_viewportController.SuspendForOverlay()); - UpdateTranscriptViewportAutomationState(); - return; - } + var itemCount = ViewModel.MessageHistory.Count; + if (itemCount <= 0) + { + return true; + } - if (!overlayJustDismissed) - { - return; - } + return _transcriptViewportHost.IsAtBottom(itemCount, BottomThreshold, BottomGeometryTolerance); + } + + private bool ObserveActiveTranscriptScrollFromLayout(bool? lastItemContainerGenerated = null) + { + var actions = _viewportController.OnActiveScrollObservation(CreateViewportViewState(lastItemContainerGenerated)); + ApplyViewportActions(actions); + return actions.Count > 0; + } + + private bool IsLastItemVisiblyAtBottom(int itemCount) + { + if (_transcriptViewportHost is null || itemCount <= 0) + { + return false; + } + + return _transcriptViewportHost.IsLastItemVisiblyAtBottom(itemCount, BottomThreshold, BottomGeometryTolerance); + } + + private void ResetAutoScrollStateForConversationChange() + { + AbandonPendingProjectionRestore("ConversationChanged"); + ClearPendingProjectionRestore(); + if (ViewModel.IsActivationOverlayVisible) + { + _resumeViewportCoordinatorAfterOverlayPending = true; + UpdateTranscriptViewportAutomationState(); + return; + } + if (_resumeViewportCoordinatorAfterOverlayPending) + { ResumeViewportCoordinatorAfterOverlayIfNeeded(); + UpdateTranscriptViewportAutomationState(); + return; } - private void ResumeViewportCoordinatorAfterOverlayIfNeeded() + _restoreDetachedViewportAfterOverlay = false; + _restoreDetachedViewportConversationId = null; + _resumeViewportCoordinatorAfterOverlayPending = false; + ApplyViewportActions(_viewportController.OnConversationChanged( + CurrentViewportConversationId, + ViewModel.IsSessionActive, + ViewModel.IsActivationOverlayVisible, + ViewModel.MessageHistory.Count > 0)); + UpdateTranscriptViewportAutomationState(); + } + + private void HandleOverlayVisibilityChanged() + { + var isOverlayVisible = ViewModel.IsActivationOverlayVisible; + var overlayJustDismissed = _wasOverlayVisible && !isOverlayVisible; + _wasOverlayVisible = isOverlayVisible; + + if (isOverlayVisible) { - if (!_resumeViewportCoordinatorAfterOverlayPending - || ViewModel.IsActivationOverlayVisible - || !_isViewLoaded - || !ViewModel.IsSessionActive - || _transcriptViewportHost is null - || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) + if (IsViewportDetachedByUser()) { - return; + _restoreDetachedViewportAfterOverlay = true; + _restoreDetachedViewportConversationId = CurrentViewportConversationId; } + _resumeViewportCoordinatorAfterOverlayPending = true; + ApplyViewportActions(_viewportController.SuspendForOverlay()); + UpdateTranscriptViewportAutomationState(); + return; + } - _resumeViewportCoordinatorAfterOverlayPending = false; - if (_restoreDetachedViewportAfterOverlay - && !string.Equals(_restoreDetachedViewportConversationId, CurrentViewportConversationId, StringComparison.Ordinal)) - { - _restoreDetachedViewportAfterOverlay = false; - _restoreDetachedViewportConversationId = null; - } + if (!overlayJustDismissed) + { + return; + } + + ResumeViewportCoordinatorAfterOverlayIfNeeded(); + } + private void ResumeViewportCoordinatorAfterOverlayIfNeeded() + { + if (!_resumeViewportCoordinatorAfterOverlayPending + || ViewModel.IsActivationOverlayVisible + || !_isViewLoaded + || !ViewModel.IsSessionActive + || _transcriptViewportHost is null + || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) + { + return; + } + + _resumeViewportCoordinatorAfterOverlayPending = false; + if (_restoreDetachedViewportAfterOverlay + && !string.Equals(_restoreDetachedViewportConversationId, CurrentViewportConversationId, StringComparison.Ordinal)) + { _restoreDetachedViewportAfterOverlay = false; _restoreDetachedViewportConversationId = null; - ActivateViewportForCurrentSession(TranscriptViewportActivationKind.OverlayResume); - TryApplyPendingProjectionRestore(); - TryIssueTranscriptScrollRequestIfAttached(); - TryRefreshViewportCoordinatorFromView(); - RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); - UpdateTranscriptViewportAutomationState(); } - private void RestoreViewportForWarmResume() + _restoreDetachedViewportAfterOverlay = false; + _restoreDetachedViewportConversationId = null; + ActivateViewportForCurrentSession(TranscriptViewportActivationKind.OverlayResume); + TryApplyPendingProjectionRestore(); + TryIssueTranscriptScrollRequestIfAttached(); + TryRefreshViewportCoordinatorFromView(); + RefreshLayoutLoadingState(HasLastItemContainerGenerated(ViewModel.MessageHistory.Count)); + UpdateTranscriptViewportAutomationState(); + } + + private void RestoreViewportForWarmResume() + { + if (!_isViewLoaded + || !ViewModel.IsSessionActive + || ViewModel.IsActivationOverlayVisible + || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) + { + return; + } + + _ = DispatcherQueue.TryEnqueue(() => { if (!_isViewLoaded || !ViewModel.IsSessionActive @@ -1265,86 +1275,76 @@ private void RestoreViewportForWarmResume() return; } - _ = DispatcherQueue.TryEnqueue(() => - { - if (!_isViewLoaded - || !ViewModel.IsSessionActive - || ViewModel.IsActivationOverlayVisible - || string.IsNullOrWhiteSpace(ViewModel.CurrentSessionId)) - { - return; - } + ActivateViewportForCurrentSession(TranscriptViewportActivationKind.WarmReturn); + TryApplyPendingProjectionRestore(); + TryIssueTranscriptScrollRequestIfAttached(); + TryRefreshViewportCoordinatorFromView(); + UpdateTranscriptViewportAutomationState(); + }); + } - ActivateViewportForCurrentSession(TranscriptViewportActivationKind.WarmReturn); - TryApplyPendingProjectionRestore(); - TryIssueTranscriptScrollRequestIfAttached(); - TryRefreshViewportCoordinatorFromView(); - UpdateTranscriptViewportAutomationState(); - }); + private void UpdateTranscriptViewportAutomationState() + { + var state = ResolveTranscriptViewportAutomationState(); + UpdateTranscriptViewportDebugProbe(state); + if (TranscriptViewportStateProbe is not null) + { + TranscriptViewportStateProbe.Text = state; + AutomationProperties.SetName(TranscriptViewportStateProbe, state); } - private void UpdateTranscriptViewportAutomationState() + if (string.Equals(_transcriptViewportAutomationState, state, StringComparison.Ordinal)) { - var state = ResolveTranscriptViewportAutomationState(); - UpdateTranscriptViewportDebugProbe(state); - if (TranscriptViewportStateProbe is not null) - { - TranscriptViewportStateProbe.Text = state; - AutomationProperties.SetName(TranscriptViewportStateProbe, state); - } + return; + } - if (string.Equals(_transcriptViewportAutomationState, state, StringComparison.Ordinal)) - { - return; - } + _transcriptViewportAutomationState = state; + } - _transcriptViewportAutomationState = state; + private void UpdateTranscriptViewportDebugProbe(string state) + { + if (TranscriptViewportDebugProbe is null) + { + return; } - private void UpdateTranscriptViewportDebugProbe(string state) - { - if (TranscriptViewportDebugProbe is null) - { - return; - } + var transition = _viewportController.LastTransition; + var conversationState = string.IsNullOrWhiteSpace(CurrentViewportConversationId) + ? null + : _viewportController.GetConversationState(CurrentViewportConversationId); + var debug = $"state={state};coord={_viewportController.State};attached={_viewportController.IsAutoFollowAttached};current={CurrentViewportConversationId};generation={_viewportController.Generation};transition={(transition?.Reason ?? "")};attachPending={_viewportController.AttachToBottomIntentPending};scrollIntentPending={_viewportController.UserScrollIntentPending};scrollIntentCompleted={_viewportController.UserScrollIntentCompleted};restoreConversation={_projectionRestoreController.PendingConversationId ?? ""};restoreGeneration={_projectionRestoreController.PendingGeneration};restoreToken={(conversationState?.RestoreToken?.ProjectionItemKey ?? "")}"; + TranscriptViewportDebugProbe.Text = debug; + AutomationProperties.SetName(TranscriptViewportDebugProbe, debug); + } - var transition = _viewportController.LastTransition; - var conversationState = string.IsNullOrWhiteSpace(CurrentViewportConversationId) - ? null - : _viewportController.GetConversationState(CurrentViewportConversationId); - var debug = $"state={state};coord={_viewportController.State};attached={_viewportController.IsAutoFollowAttached};current={CurrentViewportConversationId};generation={_viewportController.Generation};transition={(transition?.Reason ?? "")};attachPending={_viewportController.AttachToBottomIntentPending};scrollIntentPending={_viewportController.UserScrollIntentPending};scrollIntentCompleted={_viewportController.UserScrollIntentCompleted};restoreConversation={_projectionRestoreController.PendingConversationId ?? ""};restoreGeneration={_projectionRestoreController.PendingGeneration};restoreToken={(conversationState?.RestoreToken?.ProjectionItemKey ?? "")}"; - TranscriptViewportDebugProbe.Text = debug; - AutomationProperties.SetName(TranscriptViewportDebugProbe, debug); + private string ResolveTranscriptViewportAutomationState() + { + if (!_isViewLoaded || !ViewModel.IsSessionActive) + { + return "inactive"; } - private string ResolveTranscriptViewportAutomationState() + if (ViewModel.IsActivationOverlayVisible) { - if (!_isViewLoaded || !ViewModel.IsSessionActive) - { - return "inactive"; - } - - if (ViewModel.IsActivationOverlayVisible) - { - return "loading"; - } - - if (ViewModel.MessageHistory.Count == 0) - { - return "empty"; - } + return "loading"; + } - return _viewportController.State switch - { - TranscriptViewportState.Idle => "untracked", - TranscriptViewportState.Settling => "pending", - TranscriptViewportState.Following => "bottom", - TranscriptViewportState.DetachedByUser => "not_bottom", - TranscriptViewportState.DetachedPendingRestore => "pending", - TranscriptViewportState.DetachedRestoring => "pending", - TranscriptViewportState.Suspended => "loading", - _ => "untracked", - }; + if (ViewModel.MessageHistory.Count == 0) + { + return "empty"; } + return _viewportController.State switch + { + TranscriptViewportState.Idle => "untracked", + TranscriptViewportState.Settling => "pending", + TranscriptViewportState.Following => "bottom", + TranscriptViewportState.DetachedByUser => "not_bottom", + TranscriptViewportState.DetachedPendingRestore => "pending", + TranscriptViewportState.DetachedRestoring => "pending", + TranscriptViewportState.Suspended => "loading", + _ => "untracked", + }; + } + } diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml.cs index 490e7043..289e0bd0 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml.cs @@ -18,7 +18,7 @@ private async void OnSaveClick(ContentDialog sender, ContentDialogButtonClickEve { args.Cancel = true; await ViewModel.SaveConfigurationAsync(); - + if (!ViewModel.HasError) { args.Cancel = false; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Discover/DiscoverSessionsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Discover/DiscoverSessionsPage.xaml.cs index e71c0f84..4cc8df52 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Discover/DiscoverSessionsPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Discover/DiscoverSessionsPage.xaml.cs @@ -1,10 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; -using SalmonEgg.Presentation.Core.Services.Input; using SalmonEgg.Domain.Models; +using SalmonEgg.Presentation.Core.Services.Input; using SalmonEgg.Presentation.ViewModels.Discover; namespace SalmonEgg.Presentation.Views.Discover; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/GeneralSettingsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/GeneralSettingsPage.xaml.cs index b9b85e8b..d5a804c3 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/GeneralSettingsPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/GeneralSettingsPage.xaml.cs @@ -2,6 +2,8 @@ using SalmonEgg.Presentation.Models.Settings; using SalmonEgg.Presentation.ViewModels.Settings; + +<<<<<<< TODO: Unmerged change from project 'SalmonEgg(net10.0-browserwasm)', Before: namespace SalmonEgg.Presentation.Views { public sealed partial class GeneralSettingsPage : SettingsPageBase @@ -18,4 +20,36 @@ public GeneralSettingsPage() protected override Control? GetSectionEntryFocusTarget() => GeneralAutoStartToggle; } +======= +namespace SalmonEgg.Presentation.Views; + +public sealed partial class GeneralSettingsPage : SettingsPageBase +{ +public GeneralSettingsViewModel ViewModel { get; } + +public GeneralSettingsPage() +{ + ViewModel = App.ServiceProvider.GetRequiredService(); + this.InitializeComponent(); + SetSettingsBreadcrumbForSection(SettingsSectionCatalog.GeneralKey); +} + +protected override Control? GetSectionEntryFocusTarget() + => GeneralAutoStartToggle; +>>>>>>> After +namespace SalmonEgg.Presentation.Views; + +public sealed partial class GeneralSettingsPage : SettingsPageBase +{ + public GeneralSettingsViewModel ViewModel { get; } + + public GeneralSettingsPage() + { + ViewModel = App.ServiceProvider.GetRequiredService(); + this.InitializeComponent(); + SetSettingsBreadcrumbForSection(SettingsSectionCatalog.GeneralKey); + } + + protected override Control? GetSectionEntryFocusTarget() + => GeneralAutoStartToggle; } diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/MiniWindow/MiniChatView.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/MiniWindow/MiniChatView.xaml.cs index 8866f877..b0b7e0f3 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/MiniWindow/MiniChatView.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/MiniWindow/MiniChatView.xaml.cs @@ -495,14 +495,14 @@ private void ApplyProjectionRestoreResult(TranscriptProjectionRestoreResult resu _projectionRestoreController.TryScheduleRetry(DispatcherQueue, TryApplyPendingProjectionRestore); break; - case TranscriptProjectionRestoreResultKind.Confirmed: - if (result.Token is { } token) - { - ApplyViewportActions(_viewportController.OnRestoreConfirmed(token, result.Generation)); - } - break; - - case TranscriptProjectionRestoreResultKind.Unavailable: + case TranscriptProjectionRestoreResultKind.Confirmed: + if (result.Token is { } token) + { + ApplyViewportActions(_viewportController.OnRestoreConfirmed(token, result.Generation)); + } + break; + + case TranscriptProjectionRestoreResultKind.Unavailable: ApplyViewportActions(_viewportController.OnRestoreUnavailable( result.ConversationId ?? CurrentViewportConversationId, result.Generation, diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AcpConnectionSettingsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AcpConnectionSettingsPage.xaml.cs index 1e5c8b63..a1212af4 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AcpConnectionSettingsPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AcpConnectionSettingsPage.xaml.cs @@ -49,7 +49,7 @@ private void OnEditProfileMenuClick(object sender, RoutedEventArgs e) UiMotionController.Current.CreateNavigationTransitionInfo()); } - + private async void OnDeleteProfileMenuClick(object sender, RoutedEventArgs e) { if (sender is not MenuFlyoutItem item || item.Tag is not string profileId) diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AgentProfileEditorPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AgentProfileEditorPage.xaml.cs index 68f3d8b1..70a0454c 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AgentProfileEditorPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/AgentProfileEditorPage.xaml.cs @@ -4,9 +4,9 @@ using Microsoft.UI.Xaml.Navigation; using SalmonEgg.Domain.Services; using SalmonEgg.Presentation.Models; -using SalmonEgg.Presentation.ViewModels; using SalmonEgg.Presentation.Models.Navigation; using SalmonEgg.Presentation.Models.Settings; +using SalmonEgg.Presentation.ViewModels; using SalmonEgg.Presentation.ViewModels.Settings; using SalmonEgg.Presentation.Views; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/DiagnosticsSettingsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/DiagnosticsSettingsPage.xaml.cs index e7649da5..28549143 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/DiagnosticsSettingsPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/DiagnosticsSettingsPage.xaml.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; -using Microsoft.Extensions.Logging; using SalmonEgg.Presentation.Models.Settings; using SalmonEgg.Presentation.ViewModels.Settings; using SalmonEgg.Presentation.Views; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/McpSettingsPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/McpSettingsPage.xaml.cs index 773bda81..4319f4a6 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Settings/McpSettingsPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Settings/McpSettingsPage.xaml.cs @@ -1,9 +1,9 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Collections.Generic; using SalmonEgg.Presentation.Models.Settings; using SalmonEgg.Presentation.ViewModels.Settings; using SalmonEgg.Presentation.Views; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs index 5ee620b3..fa554e8c 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/SettingsShellPage.xaml.cs @@ -1,6 +1,6 @@ using System; -using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; using SalmonEgg.Presentation.Core.Services.Input; diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/Start/StartView.xaml.cs b/SalmonEgg/SalmonEgg/Presentation/Views/Start/StartView.xaml.cs index dbbc064c..17ef2d32 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/Start/StartView.xaml.cs +++ b/SalmonEgg/SalmonEgg/Presentation/Views/Start/StartView.xaml.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using SalmonEgg.Presentation.Core.Services.Input; using SalmonEgg.Presentation.ViewModels.Start; diff --git a/global.json b/global.json index 9d942998..bdd9fefe 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "Uno.Sdk": "6.5.33" }, "sdk": { - "version": "10.0.202", + "version": "10.0.103", "rollForward": "latestPatch", "allowPrerelease": false } diff --git a/src/SalmonEgg.Application/Common/Result.cs b/src/SalmonEgg.Application/Common/Result.cs index b52b0edb..59dccd51 100644 --- a/src/SalmonEgg.Application/Common/Result.cs +++ b/src/SalmonEgg.Application/Common/Result.cs @@ -5,77 +5,77 @@ namespace SalmonEgg.Application.Common /// This pattern avoids using exceptions for control flow. /// public class Result -{ - /// - /// Gets a value indicating whether the operation was successful. - /// - public bool IsSuccess { get; } - - /// - /// Gets the error message if the operation failed. - /// - public string? Error { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Whether the operation was successful. - /// The error message if the operation failed. - protected Result(bool isSuccess, string? error) { - IsSuccess = isSuccess; - Error = error; - } + /// + /// Gets a value indicating whether the operation was successful. + /// + public bool IsSuccess { get; } - /// - /// Creates a successful result. - /// - /// A successful result. - public static Result Success() => new Result(true, null); + /// + /// Gets the error message if the operation failed. + /// + public string? Error { get; } - /// - /// Creates a failed result with an error message. - /// - /// The error message. - /// A failed result. - public static Result Failure(string error) => new Result(false, error); -} + /// + /// Initializes a new instance of the class. + /// + /// Whether the operation was successful. + /// The error message if the operation failed. + protected Result(bool isSuccess, string? error) + { + IsSuccess = isSuccess; + Error = error; + } -/// -/// Represents the result of an operation that can succeed with a value or fail. -/// -/// The type of the value returned on success. -public class Result : Result -{ - /// - /// Gets the value if the operation was successful. - /// - public T Value { get; } + /// + /// Creates a successful result. + /// + /// A successful result. + public static Result Success() => new Result(true, null); - /// - /// Initializes a new instance of the class. - /// - /// Whether the operation was successful. - /// The value if the operation was successful. - /// The error message if the operation failed. - private Result(bool isSuccess, T value, string? error) - : base(isSuccess, error) - { - Value = value; + /// + /// Creates a failed result with an error message. + /// + /// The error message. + /// A failed result. + public static Result Failure(string error) => new Result(false, error); } /// - /// Creates a successful result with a value. + /// Represents the result of an operation that can succeed with a value or fail. /// - /// The value to return. - /// A successful result with the specified value. - public static Result Success(T value) => new Result(true, value, null); + /// The type of the value returned on success. + public class Result : Result + { + /// + /// Gets the value if the operation was successful. + /// + public T Value { get; } - /// - /// Creates a failed result with an error message. - /// - /// The error message. - /// A failed result. - public static new Result Failure(string error) => new Result(false, default!, error); -} + /// + /// Initializes a new instance of the class. + /// + /// Whether the operation was successful. + /// The value if the operation was successful. + /// The error message if the operation failed. + private Result(bool isSuccess, T value, string? error) + : base(isSuccess, error) + { + Value = value; + } + + /// + /// Creates a successful result with a value. + /// + /// The value to return. + /// A successful result with the specified value. + public static Result Success(T value) => new Result(true, value, null); + + /// + /// Creates a failed result with an error message. + /// + /// The error message. + /// A failed result. + public static new Result Failure(string error) => new Result(false, default!, error); + } } diff --git a/src/SalmonEgg.Application/Services/Chat/ChatService.cs b/src/SalmonEgg.Application/Services/Chat/ChatService.cs index 68c7de85..fef6437c 100644 --- a/src/SalmonEgg.Application/Services/Chat/ChatService.cs +++ b/src/SalmonEgg.Application/Services/Chat/ChatService.cs @@ -294,7 +294,7 @@ public async Task LoadSessionAsync(SessionLoadParams @param hadPreviousHistory = true; previousHistory = session.History.ToList(); } - + // Clear history before loading to ensure we don't have duplicate entries // if the server replays the history during the load process. _sessionManager.UpdateSession(@params.SessionId, s => s.History.Clear()); diff --git a/src/SalmonEgg.Application/Services/Chat/ErrorRecoveryService.cs b/src/SalmonEgg.Application/Services/Chat/ErrorRecoveryService.cs index 9916b978..7166fb79 100644 --- a/src/SalmonEgg.Application/Services/Chat/ErrorRecoveryService.cs +++ b/src/SalmonEgg.Application/Services/Chat/ErrorRecoveryService.cs @@ -19,7 +19,7 @@ public class ErrorRecoveryService : IErrorRecoveryService private readonly IErrorLogger _errorLogger; private readonly ErrorRecoveryConfig _config; private int _retryCount; - + // CRITICAL: Prevent concurrent recovery attempts which could cause inconsistent state // or flood the server with reconnect requests. private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); @@ -62,7 +62,7 @@ public async Task RecoverFromConnectionErrorAsync(string error, int maxR while (_retryCount < retryCount) { _retryCount++; - + // RECOVERY STRATEGY: Exponential backoff to avoid hammering the server during transient failures. var delay = (int)Math.Min(initialDelay * Math.Pow(multiplier, _retryCount - 1), maxDelay); @@ -238,7 +238,7 @@ public async Task RecoverFromProtocolVersionErrorAsync(int expectedVersi try { var message = $"Protocol version mismatch: Expected {expectedVersion}, actual {actualVersion}"; - + if (_config.ShowProtocolVersionWarning) { // USER EXPERIENCE: Version mismatch usually requires external action (updating the app/agent). diff --git a/src/SalmonEgg.Application/Services/MessageService.cs b/src/SalmonEgg.Application/Services/MessageService.cs index 4e3ee6fe..db9e313c 100644 --- a/src/SalmonEgg.Application/Services/MessageService.cs +++ b/src/SalmonEgg.Application/Services/MessageService.cs @@ -54,20 +54,20 @@ public MessageService( public async Task> SendRequestAsync(string? method, JsonElement? parameters) { _logger.Debug("MessageService: 调用 SendMessageUseCase,方法: {Method}", method); - + // 委托给 SendMessageUseCase (Requirement 2.5) var result = await _sendMessageUseCase.ExecuteAsync(method, parameters); - + if (result.IsSuccess) { _logger.Debug("MessageService: 消息发送成功,方法: {Method}", method); } else { - _logger.Warning("MessageService: 消息发送失败,方法: {Method}, 错误: {Error}", + _logger.Warning("MessageService: 消息发送失败,方法: {Method}, 错误: {Error}", method, result.Error); } - + return result; } diff --git a/src/SalmonEgg.Application/UseCases/ConnectToServerUseCase.cs b/src/SalmonEgg.Application/UseCases/ConnectToServerUseCase.cs index f7d65349..044a5a2f 100644 --- a/src/SalmonEgg.Application/UseCases/ConnectToServerUseCase.cs +++ b/src/SalmonEgg.Application/UseCases/ConnectToServerUseCase.cs @@ -82,7 +82,7 @@ public async Task ExecuteAsync(string? configId) // 3. 建立连接 (Requirement 3.1) var connectionResult = await _connectionManager.ConnectAsync(config, CancellationToken.None); - + if (!connectionResult.IsSuccess) { // 连接失败时返回具体的错误原因 (Requirement 3.2) diff --git a/src/SalmonEgg.Application/UseCases/SendMessageUseCase.cs b/src/SalmonEgg.Application/UseCases/SendMessageUseCase.cs index 479e1109..b9b09315 100644 --- a/src/SalmonEgg.Application/UseCases/SendMessageUseCase.cs +++ b/src/SalmonEgg.Application/UseCases/SendMessageUseCase.cs @@ -79,7 +79,7 @@ public async Task> ExecuteAsync(string? method, JsonElement? // 4. 发送消息 (Requirement 4.3) var sendResult = await _connectionManager.SendMessageAsync(message, CancellationToken.None); - + if (!sendResult.IsSuccess) { _logger.Error("发送消息失败: {Error}", sendResult.Error); diff --git a/src/SalmonEgg.Domain/Exceptions/AcpErrorCode.cs b/src/SalmonEgg.Domain/Exceptions/AcpErrorCode.cs index 3f6795fe..e6314ca4 100644 --- a/src/SalmonEgg.Domain/Exceptions/AcpErrorCode.cs +++ b/src/SalmonEgg.Domain/Exceptions/AcpErrorCode.cs @@ -9,22 +9,22 @@ public enum AcpErrorCode /// 无效的消息格式 /// InvalidMessage = 1000, - + /// /// 不支持的协议版本 /// UnsupportedVersion = 1001, - + /// /// 缺少必需字段 /// MissingRequiredField = 1002, - + /// /// 无效的消息类型 /// InvalidMessageType = 1003, - + /// /// 序列化错误 /// diff --git a/src/SalmonEgg.Domain/Exceptions/ConnectionErrorType.cs b/src/SalmonEgg.Domain/Exceptions/ConnectionErrorType.cs index d032e10b..2f57034b 100644 --- a/src/SalmonEgg.Domain/Exceptions/ConnectionErrorType.cs +++ b/src/SalmonEgg.Domain/Exceptions/ConnectionErrorType.cs @@ -9,27 +9,27 @@ public enum ConnectionErrorType /// 无效的 URL /// InvalidUrl, - + /// /// 网络不可达 /// NetworkUnreachable, - + /// /// 连接超时 /// Timeout, - + /// /// 认证失败 /// AuthenticationFailed, - + /// /// 不支持的传输协议 /// UnsupportedTransport, - + /// /// 服务器错误 /// diff --git a/src/SalmonEgg.Domain/Interfaces/IMessageParser.cs b/src/SalmonEgg.Domain/Interfaces/IMessageParser.cs index 53ba0f68..826680a1 100644 --- a/src/SalmonEgg.Domain/Interfaces/IMessageParser.cs +++ b/src/SalmonEgg.Domain/Interfaces/IMessageParser.cs @@ -49,9 +49,9 @@ public interface IMessageParser /// JSON 字符串 string SerializeMessage(JsonRpcMessage message); - /// - /// 获取 JsonSerializerOptions 实例供外部使用。 - /// - JsonSerializerOptions Options { get; } - } - } + /// + /// 获取 JsonSerializerOptions 实例供外部使用。 + /// + JsonSerializerOptions Options { get; } + } +} diff --git a/src/SalmonEgg.Domain/Models/AcpMessageType.cs b/src/SalmonEgg.Domain/Models/AcpMessageType.cs index 2e3c6b15..d40fab3d 100644 --- a/src/SalmonEgg.Domain/Models/AcpMessageType.cs +++ b/src/SalmonEgg.Domain/Models/AcpMessageType.cs @@ -35,9 +35,9 @@ public static class AcpMessageType /// True if the type is valid, false otherwise. public static bool IsValid(string type) { - return type == Request - || type == Response - || type == Notification + return type == Request + || type == Response + || type == Notification || type == Initialize; } } diff --git a/src/SalmonEgg.Domain/Models/ConversationPreview/ConversationPreviewSnapshot.cs b/src/SalmonEgg.Domain/Models/ConversationPreview/ConversationPreviewSnapshot.cs index c254a307..4479def1 100644 --- a/src/SalmonEgg.Domain/Models/ConversationPreview/ConversationPreviewSnapshot.cs +++ b/src/SalmonEgg.Domain/Models/ConversationPreview/ConversationPreviewSnapshot.cs @@ -9,6 +9,6 @@ public record ConversationPreviewSnapshot( DateTimeOffset GeneratedAt); public record PreviewEntry( - string Sender, - string Text, + string Sender, + string Text, DateTimeOffset Timestamp); diff --git a/src/SalmonEgg.Domain/Models/JsonRpc/JsonRpcNotification.cs b/src/SalmonEgg.Domain/Models/JsonRpc/JsonRpcNotification.cs index 877c9665..9c35c521 100644 --- a/src/SalmonEgg.Domain/Models/JsonRpc/JsonRpcNotification.cs +++ b/src/SalmonEgg.Domain/Models/JsonRpc/JsonRpcNotification.cs @@ -11,35 +11,35 @@ namespace SalmonEgg.Domain.Models.JsonRpc public class JsonRpcNotification : JsonRpcMessage { /// - /// 要调用的方法名。 - /// - [JsonPropertyName("method")] - public string Method { get; set; } = string.Empty; + /// 要调用的方法名。 + /// + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; /// /// 方法的参数。 - /// 可以是对象、数组、原始值或省略。 - /// - [JsonPropertyName("params")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Params { get; set; } + /// 可以是对象、数组、原始值或省略。 + /// + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } /// - /// 创建一个新的 JsonRpcNotification 实例。 - /// - public JsonRpcNotification() - { - } + /// 创建一个新的 JsonRpcNotification 实例。 + /// + public JsonRpcNotification() + { + } - /// - /// 创建一个新的 JsonRpcNotification 实例。 - /// - /// 方法名 - /// 方法参数 - public JsonRpcNotification(string method, JsonElement? @params = null) - { - Method = method; - Params = @params; - } + /// + /// 创建一个新的 JsonRpcNotification 实例。 + /// + /// 方法名 + /// 方法参数 + public JsonRpcNotification(string method, JsonElement? @params = null) + { + Method = method; + Params = @params; + } } } diff --git a/src/SalmonEgg.Domain/Models/Protocol/SessionNewTypes.cs b/src/SalmonEgg.Domain/Models/Protocol/SessionNewTypes.cs index 0082d774..658a515d 100644 --- a/src/SalmonEgg.Domain/Models/Protocol/SessionNewTypes.cs +++ b/src/SalmonEgg.Domain/Models/Protocol/SessionNewTypes.cs @@ -62,7 +62,7 @@ public class SessionNewResponse [JsonConverter(typeof(SessionModesStateJsonConverter))] public SessionModesState? Modes { get; set; } - + /// /// 可用的配置选项列表(可选)。 /// diff --git a/src/SalmonEgg.Domain/Models/Protocol/SessionPromptTypes.cs b/src/SalmonEgg.Domain/Models/Protocol/SessionPromptTypes.cs index 00426473..5b53b41a 100644 --- a/src/SalmonEgg.Domain/Models/Protocol/SessionPromptTypes.cs +++ b/src/SalmonEgg.Domain/Models/Protocol/SessionPromptTypes.cs @@ -55,7 +55,7 @@ public SessionPromptParams() /// /// 创建新的 SessionPromptParams 实例。 /// - + /// 会话 ID /// 提示内容块数组 /// 最大令牌数 diff --git a/src/SalmonEgg.Domain/Models/Protocol/SessionUpdateTypes.cs b/src/SalmonEgg.Domain/Models/Protocol/SessionUpdateTypes.cs index 18282c89..5160271a 100644 --- a/src/SalmonEgg.Domain/Models/Protocol/SessionUpdateTypes.cs +++ b/src/SalmonEgg.Domain/Models/Protocol/SessionUpdateTypes.cs @@ -334,7 +334,7 @@ public CurrentModeUpdate(string currentModeId, string? title = null) /// /// 配置更新。 /// 用于通知客户端会话配置选项的变化。 - /// + /// public class ConfigUpdateUpdate : SessionUpdate { /// diff --git a/src/SalmonEgg.Infrastructure/Client/AcpClient.cs b/src/SalmonEgg.Infrastructure/Client/AcpClient.cs index 21a6b1f3..fb1f1851 100644 --- a/src/SalmonEgg.Infrastructure/Client/AcpClient.cs +++ b/src/SalmonEgg.Infrastructure/Client/AcpClient.cs @@ -40,7 +40,7 @@ private sealed record PendingInboundRequest( private readonly ITerminalSessionManager _terminalSessionManager; private readonly IErrorLogger _errorLogger; - + private readonly ConcurrentDictionary> _pendingRequests = new(); // Inbound tool requests (agent -> client) are correlated by request id so we can format responses correctly. private readonly ConcurrentDictionary _pendingInboundRequests = new(); @@ -149,29 +149,29 @@ public AcpClient( /// /// 初始化与 Agent 的连接。 /// - public async Task InitializeAsync(InitializeParams @params, CancellationToken cancellationToken = default) - { - if (_isInitialized) - { - throw new InvalidOperationException("客户端已初始化"); - } - - // 确保传输层已连接 - if (!_transport.IsConnected) - { - var connected = await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); - if (!connected) - { - throw new InvalidOperationException("无法连接到传输层"); - } - } - - // 发送 initialize 请求 - var request = new JsonRpcRequest( - Interlocked.Increment(ref _nextMessageId), - "initialize", - ToElement(@params, AcpJsonContext.Default.InitializeParams)); - var response = await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + public async Task InitializeAsync(InitializeParams @params, CancellationToken cancellationToken = default) + { + if (_isInitialized) + { + throw new InvalidOperationException("客户端已初始化"); + } + + // 确保传输层已连接 + if (!_transport.IsConnected) + { + var connected = await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + if (!connected) + { + throw new InvalidOperationException("无法连接到传输层"); + } + } + + // 发送 initialize 请求 + var request = new JsonRpcRequest( + Interlocked.Increment(ref _nextMessageId), + "initialize", + ToElement(@params, AcpJsonContext.Default.InitializeParams)); + var response = await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); // 验证响应 var validationResult = _validator.ValidateResponse(response); @@ -736,7 +736,7 @@ public async Task DisconnectAsync() /// /// 发送请求并等待响应。 /// - + private async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) { var requestIdStr = request.Id?.ToString() ?? string.Empty; @@ -791,7 +791,7 @@ private void OnMessageReceived(object? sender, MessageReceivedEventArgs e) { var message = _parser.ParseMessage(e.Message); - + if (message is JsonRpcResponse response) { var responseIdStr = response.Id?.ToString() ?? string.Empty; diff --git a/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs b/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs index b01f1468..39956e2f 100644 --- a/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs +++ b/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs @@ -18,178 +18,178 @@ namespace SalmonEgg.Infrastructure.Client; /// public class TransportFactory : ITransportFactory { - private readonly ILogger _logger; - private readonly ITransportSupportPolicy _transportSupportPolicy; - private readonly IStdioTransportFactory _stdioTransportFactory; - - /// - /// 创建 的新实例。 - /// - /// 日志记录器实例 - public TransportFactory( - ILogger logger, - ITransportSupportPolicy transportSupportPolicy, - IStdioTransportFactory stdioTransportFactory) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _transportSupportPolicy = transportSupportPolicy ?? throw new ArgumentNullException(nameof(transportSupportPolicy)); - _stdioTransportFactory = stdioTransportFactory ?? throw new ArgumentNullException(nameof(stdioTransportFactory)); - } - - /// - /// 根据指定的传输类型创建新的传输实例。 - /// - /// 传输类型(Stdio, WebSocket, HttpSse) - /// 命令(仅用于 Stdio 传输) - /// 命令行参数(仅用于 Stdio 传输) - /// 连接 URL(用于 WebSocket 和 HttpSse 传输) - /// 新创建的 实例 - /// 当传输类型不支持或必要参数缺失时抛出 - /// 当指定的传输类型未实现时抛出 - public SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransport( - TransportType transportType, - string? command = null, - string? args = null, - string? url = null) - { - _logger.Information("正在创建传输实例:{TransportType}", transportType); - - return transportType switch - { - TransportType.Stdio => CreateStdioTransport(command, args), - TransportType.WebSocket => CreateWebSocketTransport(url), - TransportType.HttpSse => CreateHttpSseTransport(url), - _ => throw new NotSupportedException($"不支持的传输类型:{transportType}") - }; - } - - /// - /// 创建 Stdio 传输实例。 - /// - /// 命令 - /// 命令行参数 - /// Stdio 传输实例 - /// 当命令为空时抛出 - private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateStdioTransport(string? command, string? args) - { - if (!_transportSupportPolicy.IsSupported(TransportType.Stdio)) - { - throw new NotSupportedException( - _transportSupportPolicy.GetUnsupportedReason(TransportType.Stdio) - ?? "Stdio transport is not supported on this platform."); - } - - if (string.IsNullOrWhiteSpace(command)) - { - throw new ArgumentException("Stdio 传输必须指定命令", nameof(command)); - } - - _logger.Information("创建 Stdio 传输:Command={Command}, Args={Args}", command, args); - - // Stdio remains a subprocess transport even when the subprocess is a bridge such as ssh. - var argsArray = ParseCommandLineArguments(args); - - return _stdioTransportFactory.Create(command.Trim(), argsArray, Encoding.UTF8); - } - - private static string[] ParseCommandLineArguments(string? args) - { - if (string.IsNullOrWhiteSpace(args)) - { - return Array.Empty(); - } - - var results = new List(); - var current = new StringBuilder(); - char? activeQuote = null; - - foreach (var character in args) - { - if ((character == '"' || character == '\'')) - { - if (activeQuote == character) - { - activeQuote = null; - continue; - } - - if (activeQuote == null) - { - activeQuote = character; - continue; - } - } - - if (char.IsWhiteSpace(character) && activeQuote == null) - { - if (current.Length == 0) - { - continue; - } - - results.Add(current.ToString()); - current.Clear(); - continue; - } - - current.Append(character); - } - - if (current.Length > 0) - { - results.Add(current.ToString()); - } - - return results.ToArray(); - } - - /// - /// 创建 WebSocket 传输实例。 - /// - /// WebSocket URL - /// WebSocket 传输实例 - /// 当 URL 为空或无效时抛出 - private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTransport(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - throw new ArgumentException("WebSocket 传输必须指定 URL", nameof(url)); - } - - if (!Uri.TryCreate(url, UriKind.Absolute, out _)) - { - throw new ArgumentException($"无效的 WebSocket URL: {url}", nameof(url)); - } - - _logger.Information("创建 WebSocket 传输:Url={Url}", url); - - var logger = _logger; - var inner = new SalmonEgg.Infrastructure.Network.WebSocketTransport(logger); - return new NetworkTransportAdapter(inner, url.Trim()); - } - - /// - /// 创建 HTTP SSE 传输实例。 - /// - /// HTTP SSE URL - /// HTTP SSE 传输实例 - /// 当 URL 为空或无效时抛出 - private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateHttpSseTransport(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - throw new ArgumentException("HTTP SSE 传输必须指定 URL", nameof(url)); - } - - if (!Uri.TryCreate(url, UriKind.Absolute, out _)) - { - throw new ArgumentException($"无效的 HTTP SSE URL: {url}", nameof(url)); - } - - _logger.Information("创建 HTTP SSE 传输:Url={Url}", url); - - var logger = _logger; - var inner = new SalmonEgg.Infrastructure.Network.HttpSseTransport(logger); - return new NetworkTransportAdapter(inner, url.Trim()); - } + private readonly ILogger _logger; + private readonly ITransportSupportPolicy _transportSupportPolicy; + private readonly IStdioTransportFactory _stdioTransportFactory; + + /// + /// 创建 的新实例。 + /// + /// 日志记录器实例 + public TransportFactory( + ILogger logger, + ITransportSupportPolicy transportSupportPolicy, + IStdioTransportFactory stdioTransportFactory) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _transportSupportPolicy = transportSupportPolicy ?? throw new ArgumentNullException(nameof(transportSupportPolicy)); + _stdioTransportFactory = stdioTransportFactory ?? throw new ArgumentNullException(nameof(stdioTransportFactory)); + } + + /// + /// 根据指定的传输类型创建新的传输实例。 + /// + /// 传输类型(Stdio, WebSocket, HttpSse) + /// 命令(仅用于 Stdio 传输) + /// 命令行参数(仅用于 Stdio 传输) + /// 连接 URL(用于 WebSocket 和 HttpSse 传输) + /// 新创建的 实例 + /// 当传输类型不支持或必要参数缺失时抛出 + /// 当指定的传输类型未实现时抛出 + public SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransport( + TransportType transportType, + string? command = null, + string? args = null, + string? url = null) + { + _logger.Information("正在创建传输实例:{TransportType}", transportType); + + return transportType switch + { + TransportType.Stdio => CreateStdioTransport(command, args), + TransportType.WebSocket => CreateWebSocketTransport(url), + TransportType.HttpSse => CreateHttpSseTransport(url), + _ => throw new NotSupportedException($"不支持的传输类型:{transportType}") + }; + } + + /// + /// 创建 Stdio 传输实例。 + /// + /// 命令 + /// 命令行参数 + /// Stdio 传输实例 + /// 当命令为空时抛出 + private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateStdioTransport(string? command, string? args) + { + if (!_transportSupportPolicy.IsSupported(TransportType.Stdio)) + { + throw new NotSupportedException( + _transportSupportPolicy.GetUnsupportedReason(TransportType.Stdio) + ?? "Stdio transport is not supported on this platform."); + } + + if (string.IsNullOrWhiteSpace(command)) + { + throw new ArgumentException("Stdio 传输必须指定命令", nameof(command)); + } + + _logger.Information("创建 Stdio 传输:Command={Command}, Args={Args}", command, args); + + // Stdio remains a subprocess transport even when the subprocess is a bridge such as ssh. + var argsArray = ParseCommandLineArguments(args); + + return _stdioTransportFactory.Create(command.Trim(), argsArray, Encoding.UTF8); + } + + private static string[] ParseCommandLineArguments(string? args) + { + if (string.IsNullOrWhiteSpace(args)) + { + return Array.Empty(); + } + + var results = new List(); + var current = new StringBuilder(); + char? activeQuote = null; + + foreach (var character in args) + { + if ((character == '"' || character == '\'')) + { + if (activeQuote == character) + { + activeQuote = null; + continue; + } + + if (activeQuote == null) + { + activeQuote = character; + continue; + } + } + + if (char.IsWhiteSpace(character) && activeQuote == null) + { + if (current.Length == 0) + { + continue; + } + + results.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(character); + } + + if (current.Length > 0) + { + results.Add(current.ToString()); + } + + return results.ToArray(); + } + + /// + /// 创建 WebSocket 传输实例。 + /// + /// WebSocket URL + /// WebSocket 传输实例 + /// 当 URL 为空或无效时抛出 + private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTransport(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("WebSocket 传输必须指定 URL", nameof(url)); + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException($"无效的 WebSocket URL: {url}", nameof(url)); + } + + _logger.Information("创建 WebSocket 传输:Url={Url}", url); + + var logger = _logger; + var inner = new SalmonEgg.Infrastructure.Network.WebSocketTransport(logger); + return new NetworkTransportAdapter(inner, url.Trim()); + } + + /// + /// 创建 HTTP SSE 传输实例。 + /// + /// HTTP SSE URL + /// HTTP SSE 传输实例 + /// 当 URL 为空或无效时抛出 + private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateHttpSseTransport(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("HTTP SSE 传输必须指定 URL", nameof(url)); + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException($"无效的 HTTP SSE URL: {url}", nameof(url)); + } + + _logger.Information("创建 HTTP SSE 传输:Url={Url}", url); + + var logger = _logger; + var inner = new SalmonEgg.Infrastructure.Network.HttpSseTransport(logger); + return new NetworkTransportAdapter(inner, url.Trim()); + } } diff --git a/src/SalmonEgg.Infrastructure/Network/ConnectionManager.cs b/src/SalmonEgg.Infrastructure/Network/ConnectionManager.cs index 15a347c4..5191be39 100644 --- a/src/SalmonEgg.Infrastructure/Network/ConnectionManager.cs +++ b/src/SalmonEgg.Infrastructure/Network/ConnectionManager.cs @@ -22,7 +22,7 @@ public class ConnectionManager : IConnectionManager, IDisposable private readonly ILogger _logger; private readonly Func _transportFactory; private readonly AsyncRetryPolicy _retryPolicy; - + private ITransport? _transport; private readonly Subject _incomingMessages; private readonly BehaviorSubject _connectionStateChanges; @@ -58,14 +58,14 @@ public ConnectionManager( _protocolService = protocolService ?? throw new ArgumentNullException(nameof(protocolService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)); - + _incomingMessages = new Subject(); _connectionStateChanges = new BehaviorSubject(new ConnectionState { Status = ConnectionStatus.Disconnected }); _pendingRequests = new Dictionary>(); - + // 配置重试策略:最多重试 3 次,使用指数退避 _retryPolicy = Policy .Handle() @@ -77,10 +77,10 @@ public ConnectionManager( _logger.Warning( "连接尝试 {RetryCount}/3 失败: {Error}. 将在 {Delay} 秒后重试", retryCount, exception.Message, timeSpan.TotalSeconds); - + // 更新连接状态为重连中 UpdateConnectionState( - ConnectionStatus.Reconnecting, + ConnectionStatus.Reconnecting, _currentConfig?.ServerUrl ?? string.Empty, $"重试 {retryCount}/3", retryCount); @@ -99,7 +99,7 @@ public async Task ConnectAsync(ServerConfiguration config, Can try { - _logger.Information("开始连接到服务器: {ServerUrl}, 传输类型: {Transport}", + _logger.Information("开始连接到服务器: {ServerUrl}, 传输类型: {Transport}", config.ServerUrl, config.Transport); // 验证配置 @@ -134,7 +134,7 @@ await _retryPolicy.ExecuteAsync(async () => UpdateConnectionState(ConnectionStatus.Connected, config.ServerUrl ?? string.Empty); _logger.Information("成功连接到服务器: {ServerUrl}", config.ServerUrl); - + // 获取当前连接状态并返回 var connectedState = _connectionStateChanges.Value; return ConnectionResult.Success(connectedState); @@ -167,7 +167,7 @@ public async Task DisconnectAsync() try { _logger.Information("Disconnecting from server"); - + // 设置手动断开标志,防止自动重连 _isManualDisconnect = true; @@ -186,7 +186,7 @@ public async Task DisconnectAsync() // 更新状态 UpdateConnectionState(ConnectionStatus.Disconnected, _currentConfig?.ServerUrl ?? string.Empty); - + // 清除配置信息 _currentConfig = null; @@ -454,7 +454,7 @@ private async Task AutoReconnectAsync() { _isReconnecting = true; _logger.Information("Reconnecting to server automatically: {ServerUrl}", _currentConfig.ServerUrl); - + // 更新状态为重连中 UpdateConnectionState(ConnectionStatus.Reconnecting, _currentConfig.ServerUrl ?? string.Empty, "正在自动重连"); @@ -474,7 +474,7 @@ private async Task AutoReconnectAsync() // 创建新的传输层实例 _transport = _transportFactory(_currentConfig.Transport); - + // 订阅传输层的消息和状态变化 SubscribeToTransport(); @@ -496,8 +496,8 @@ await _retryPolicy.ExecuteAsync(async () => { _logger.Error(ex, "Reconnection failed"); UpdateConnectionState( - ConnectionStatus.Error, - _currentConfig.ServerUrl ?? string.Empty, + ConnectionStatus.Error, + _currentConfig.ServerUrl ?? string.Empty, "Reconnection failed"); } finally diff --git a/src/SalmonEgg.Infrastructure/Network/HttpSseTransport.cs b/src/SalmonEgg.Infrastructure/Network/HttpSseTransport.cs index 4323d680..78bfde84 100644 --- a/src/SalmonEgg.Infrastructure/Network/HttpSseTransport.cs +++ b/src/SalmonEgg.Infrastructure/Network/HttpSseTransport.cs @@ -78,12 +78,12 @@ public async Task ConnectAsync(string url, CancellationToken ct) { _stateSubject.OnNext(TransportState.Error); _logger.Error(ex, "Failed to connect to HTTP SSE server at {Url}", url); - + // Clean up on failure _connectionCts?.Cancel(); _connectionCts?.Dispose(); _connectionCts = null; - + throw; } } diff --git a/src/SalmonEgg.Infrastructure/RecordShims.cs b/src/SalmonEgg.Infrastructure/RecordShims.cs index 1f454008..c114c40f 100644 --- a/src/SalmonEgg.Infrastructure/RecordShims.cs +++ b/src/SalmonEgg.Infrastructure/RecordShims.cs @@ -1,6 +1,6 @@ #if !NET5_0_OR_GREATER namespace System.Runtime.CompilerServices { - internal static class IsExternalInit {} + internal static class IsExternalInit { } } #endif diff --git a/src/SalmonEgg.Infrastructure/Serialization/AcpMessageParser.cs b/src/SalmonEgg.Infrastructure/Serialization/AcpMessageParser.cs index 6c32dc97..d47d6662 100644 --- a/src/SalmonEgg.Infrastructure/Serialization/AcpMessageParser.cs +++ b/src/SalmonEgg.Infrastructure/Serialization/AcpMessageParser.cs @@ -12,7 +12,7 @@ namespace SalmonEgg.Infrastructure.Serialization public class AcpMessageParser : IAcpProtocolService { private readonly JsonSerializerOptions _options; - + /// /// 有效的消息类型 /// @@ -44,7 +44,7 @@ public AcpMessage ParseMessage(string json) try { var message = JsonSerializer.Deserialize(json, AcpJsonContext.Default.AcpMessage); - + if (message == null) { throw new AcpProtocolException( @@ -54,10 +54,10 @@ public AcpMessage ParseMessage(string json) // 验证必需字段 ValidateRequiredFields(message); - + // 验证消息类型 ValidateMessageType(message); - + // 验证消息类型特定的字段 ValidateMessageTypeSpecificFields(message); diff --git a/src/SalmonEgg.Infrastructure/Storage/SecureStorage.cs b/src/SalmonEgg.Infrastructure/Storage/SecureStorage.cs index a8ff677b..3a4c5a4f 100644 --- a/src/SalmonEgg.Infrastructure/Storage/SecureStorage.cs +++ b/src/SalmonEgg.Infrastructure/Storage/SecureStorage.cs @@ -43,7 +43,7 @@ await Task.Run(() => var filePath = GetFilePath(key); Directory.CreateDirectory(_storageDirectory); File.WriteAllText(filePath, encoded); - + // 同时缓存到内存 _memoryCache[key] = value; } @@ -73,10 +73,10 @@ await Task.Run(() => var encoded = File.ReadAllText(filePath); var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); - + // 缓存到内存 _memoryCache[key] = decoded; - + return decoded; } catch (Exception ex) @@ -98,7 +98,7 @@ await Task.Run(() => var filePath = GetFilePath(key); if (File.Exists(filePath)) File.Delete(filePath); - + // 从内存缓存中移除 _memoryCache.Remove(key); } diff --git a/src/SalmonEgg.Presentation.Core/Mvux/Chat/ChatReducer.cs b/src/SalmonEgg.Presentation.Core/Mvux/Chat/ChatReducer.cs index 5150381f..cf7f165c 100644 --- a/src/SalmonEgg.Presentation.Core/Mvux/Chat/ChatReducer.cs +++ b/src/SalmonEgg.Presentation.Core/Mvux/Chat/ChatReducer.cs @@ -55,24 +55,24 @@ ClearConversationRuntimeStateAction clearRuntimeState when string.IsNullOrWhiteS }), AdvanceTurnPhaseAction advance when MatchesActiveTurn(current.ActiveTurn, advance.ConversationId, advance.TurnId) && !IsTerminalPhase(current.ActiveTurn!.Phase) => Mutate(current, current with - { - ActiveTurn = current.ActiveTurn with { Phase = advance.NewPhase, ToolCallId = advance.ToolCallId, ToolTitle = advance.ToolTitle, LastUpdatedAtUtc = DateTime.UtcNow } - }), + { + ActiveTurn = current.ActiveTurn with { Phase = advance.NewPhase, ToolCallId = advance.ToolCallId, ToolTitle = advance.ToolTitle, LastUpdatedAtUtc = DateTime.UtcNow } + }), CompleteTurnAction complete when MatchesActiveTurn(current.ActiveTurn, complete.ConversationId, complete.TurnId) && !IsTerminalPhase(current.ActiveTurn!.Phase) => Mutate(current, current with - { - ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Completed, LastUpdatedAtUtc = DateTime.UtcNow } - }), + { + ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Completed, LastUpdatedAtUtc = DateTime.UtcNow } + }), FailTurnAction fail when MatchesActiveTurn(current.ActiveTurn, fail.ConversationId, fail.TurnId) && !IsTerminalPhase(current.ActiveTurn!.Phase) => Mutate(current, current with - { - ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Failed, FailureMessage = fail.ErrorMessage, LastUpdatedAtUtc = DateTime.UtcNow } - }), + { + ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Failed, FailureMessage = fail.ErrorMessage, LastUpdatedAtUtc = DateTime.UtcNow } + }), CancelTurnAction cancel when MatchesActiveTurn(current.ActiveTurn, cancel.ConversationId, cancel.TurnId) && !IsTerminalPhase(current.ActiveTurn!.Phase) => Mutate(current, current with - { - ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Cancelled, LastUpdatedAtUtc = DateTime.UtcNow } - }), + { + ActiveTurn = current.ActiveTurn with { Phase = ChatTurnPhase.Cancelled, LastUpdatedAtUtc = DateTime.UtcNow } + }), ClearTurnAction clear when current.ActiveTurn?.ConversationId == clear.ConversationId => Mutate(current, current with { ActiveTurn = null @@ -628,7 +628,7 @@ private static ConversationMessageSnapshot CloneMessage( Priority = source.PlanEntry.Priority }, ModeId = source.ModeId - }; + }; } } diff --git a/src/SalmonEgg.Presentation.Core/Mvux/Chat/ConversationAttentionReducer.cs b/src/SalmonEgg.Presentation.Core/Mvux/Chat/ConversationAttentionReducer.cs index 645b6218..c651c617 100644 --- a/src/SalmonEgg.Presentation.Core/Mvux/Chat/ConversationAttentionReducer.cs +++ b/src/SalmonEgg.Presentation.Core/Mvux/Chat/ConversationAttentionReducer.cs @@ -14,18 +14,18 @@ public static ConversationAttentionState Reduce( switch (action) { case MarkConversationUnreadAction mark when !string.IsNullOrWhiteSpace(mark.ConversationId): - { - var next = current.Conversations; - next.TryGetValue(mark.ConversationId, out var existing); - return new ConversationAttentionState(next.SetItem( - mark.ConversationId, - new ConversationAttentionSlice( + { + var next = current.Conversations; + next.TryGetValue(mark.ConversationId, out var existing); + return new ConversationAttentionState(next.SetItem( mark.ConversationId, - HasUnread: true, - UnreadVersion: checked((existing?.UnreadVersion ?? 0) + 1), - LastAttentionAtUtc: mark.TimestampUtc, - LastAttentionSource: mark.Source))); - } + new ConversationAttentionSlice( + mark.ConversationId, + HasUnread: true, + UnreadVersion: checked((existing?.UnreadVersion ?? 0) + 1), + LastAttentionAtUtc: mark.TimestampUtc, + LastAttentionSource: mark.Source))); + } case ClearConversationUnreadAction clear when !string.IsNullOrWhiteSpace(clear.ConversationId) diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatMessageViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatMessageViewModel.cs index be38d042..756028b3 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatMessageViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatMessageViewModel.cs @@ -112,15 +112,15 @@ public partial class ChatMessageViewModel : ObservableObject, IRenderFailureSink // 计划条目 - [ObservableProperty] - private PlanEntryViewModel? _planEntry; + [ObservableProperty] + private PlanEntryViewModel? _planEntry; - // 模式切换 + // 模式切换 [ObservableProperty] private string? _modeId; - // 资源内容 - [ObservableProperty] + // 资源内容 + [ObservableProperty] private ResourceViewModel? _resourceViewModel; private ChatMarkdownPresentationState _markdownPresentation = ChatMarkdownPresentationState.PlainStreaming; @@ -198,7 +198,7 @@ public static ChatMessageViewModel CreateFromAudioContent(string id, ContentBloc public static ChatMessageViewModel CreateFromToolCall(string id, string? toolCallId, string? rawInput, string? rawOutput, ToolCallKind? kind, ToolCallStatus? status, string? title, bool isOutgoing = false) { var toolCallJson = !string.IsNullOrEmpty(rawInput) ? rawInput : (!string.IsNullOrEmpty(rawOutput) ? rawOutput : string.Empty); - + var viewModel = new ChatMessageViewModel { Id = id, @@ -238,43 +238,43 @@ public static ChatMessageViewModel CreateFromPlanEntry(string id, PlanEntry entr } public static ChatMessageViewModel CreateFromModeChange(string id, string? modeId, string? title, bool isOutgoing = false) - { - return new ChatMessageViewModel - { - Id = id, - IsOutgoing = isOutgoing, - ContentType = "mode_change", - ModeId = modeId, - Title = title ?? "Mode Changed", - Timestamp = DateTime.Now - }; - } - - public static ChatMessageViewModel CreateFromResourceContent(string id, ResourceContentBlock block, bool isOutgoing = false) - { - return new ChatMessageViewModel - { - Id = id, - IsOutgoing = isOutgoing, - ContentType = "resource_content", - Title = "Resource Content", - Timestamp = DateTime.Now, - ResourceViewModel = ResourceViewModel.CreateFromContent(block) - }; - } - - public static ChatMessageViewModel CreateFromResourceLink(string id, ResourceLinkContentBlock block, bool isOutgoing = false) - { - return new ChatMessageViewModel - { - Id = id, - IsOutgoing = isOutgoing, - ContentType = "resource_link", - Title = block.Title ?? block.Name ?? "Resource Link", - Timestamp = DateTime.Now, - ResourceViewModel = ResourceViewModel.CreateFromLink(block) - }; - } + { + return new ChatMessageViewModel + { + Id = id, + IsOutgoing = isOutgoing, + ContentType = "mode_change", + ModeId = modeId, + Title = title ?? "Mode Changed", + Timestamp = DateTime.Now + }; + } + + public static ChatMessageViewModel CreateFromResourceContent(string id, ResourceContentBlock block, bool isOutgoing = false) + { + return new ChatMessageViewModel + { + Id = id, + IsOutgoing = isOutgoing, + ContentType = "resource_content", + Title = "Resource Content", + Timestamp = DateTime.Now, + ResourceViewModel = ResourceViewModel.CreateFromContent(block) + }; + } + + public static ChatMessageViewModel CreateFromResourceLink(string id, ResourceLinkContentBlock block, bool isOutgoing = false) + { + return new ChatMessageViewModel + { + Id = id, + IsOutgoing = isOutgoing, + ContentType = "resource_link", + Title = block.Title ?? block.Name ?? "Resource Link", + Timestamp = DateTime.Now, + ResourceViewModel = ResourceViewModel.CreateFromLink(block) + }; + } public bool HasTitle => !string.IsNullOrEmpty(Title); @@ -301,94 +301,94 @@ private set public string CopyableMarkdownCodeBlockText => MarkdownPresentation.CopyableCodeBlockText; public bool HasCopyableMarkdownCodeBlock => MarkdownPresentation.HasCopyableCodeBlock; public bool HasImageContent => !string.IsNullOrEmpty(ImageData); - public bool HasAudioContent => !string.IsNullOrEmpty(AudioData); - public bool HasToolCall => !string.IsNullOrEmpty(ToolCallId); - public bool HasPlanEntry => PlanEntry != null; - public bool HasModeChange => !string.IsNullOrEmpty(ModeId); - public bool HasResourceContent => ResourceViewModel?.IsResourceContent == true; - public bool HasResourceLink => ResourceViewModel?.IsResourceLink == true; - public bool IsToolCallCancelled - { - get => _isToolCallCancelled; - set => SetProperty(ref _isToolCallCancelled, value); - } - public bool HasToolCallLocations => ToolCallLocations?.Count > 0; - public bool HasPendingPermissionRequest => PendingPermissionRequest != null; - public bool ShouldShowToolCallPill => - string.Equals(ContentType, "tool_call", StringComparison.Ordinal) - && (HasToolCall - || HasToolCallJson - || ToolCallKind is not null - || ToolCallStatus is not null - || HasToolCallDetails - || HasTitle); - - - public bool HasToolCallJson => !string.IsNullOrWhiteSpace(ToolCallJson); - public bool HasToolCallRawInput => !string.IsNullOrWhiteSpace(ToolCallRawInputJson); - public bool HasToolCallRawOutput => !string.IsNullOrWhiteSpace(ToolCallRawOutputJson); - public bool HasToolCallDetails => ToolCallDetailItems.Count > 0; - - public void ConfigureShellActions( - Func> copyTextAsync, - Func> openUriAsync) - { + public bool HasAudioContent => !string.IsNullOrEmpty(AudioData); + public bool HasToolCall => !string.IsNullOrEmpty(ToolCallId); + public bool HasPlanEntry => PlanEntry != null; + public bool HasModeChange => !string.IsNullOrEmpty(ModeId); + public bool HasResourceContent => ResourceViewModel?.IsResourceContent == true; + public bool HasResourceLink => ResourceViewModel?.IsResourceLink == true; + public bool IsToolCallCancelled + { + get => _isToolCallCancelled; + set => SetProperty(ref _isToolCallCancelled, value); + } + public bool HasToolCallLocations => ToolCallLocations?.Count > 0; + public bool HasPendingPermissionRequest => PendingPermissionRequest != null; + public bool ShouldShowToolCallPill => + string.Equals(ContentType, "tool_call", StringComparison.Ordinal) + && (HasToolCall + || HasToolCallJson + || ToolCallKind is not null + || ToolCallStatus is not null + || HasToolCallDetails + || HasTitle); + + + public bool HasToolCallJson => !string.IsNullOrWhiteSpace(ToolCallJson); + public bool HasToolCallRawInput => !string.IsNullOrWhiteSpace(ToolCallRawInputJson); + public bool HasToolCallRawOutput => !string.IsNullOrWhiteSpace(ToolCallRawOutputJson); + public bool HasToolCallDetails => ToolCallDetailItems.Count > 0; + + public void ConfigureShellActions( + Func> copyTextAsync, + Func> openUriAsync) + { _copyTextAsync = copyTextAsync ?? throw new ArgumentNullException(nameof(copyTextAsync)); _openUriAsync = openUriAsync ?? throw new ArgumentNullException(nameof(openUriAsync)); CopyTextCommand.NotifyCanExecuteChanged(); OpenMarkdownLinkCommand.NotifyCanExecuteChanged(); - } + } - public void MarkMarkdownRenderFailed() - { + public void MarkMarkdownRenderFailed() + { IsMarkdownFallbackSticky = true; RefreshMarkdownPresentation(); - } + } - public void MarkRenderFailed() => MarkMarkdownRenderFailed(); + public void MarkRenderFailed() => MarkMarkdownRenderFailed(); - partial void OnIsOutgoingChanged(bool value) => RefreshMarkdownPresentation(); + partial void OnIsOutgoingChanged(bool value) => RefreshMarkdownPresentation(); - partial void OnContentTypeChanged(string value) => RefreshMarkdownPresentation(); + partial void OnContentTypeChanged(string value) => RefreshMarkdownPresentation(); - partial void OnTextContentChanged(string value) - { + partial void OnTextContentChanged(string value) + { OnPropertyChanged(nameof(HasTextContent)); CopyTextCommand.NotifyCanExecuteChanged(); RefreshMarkdownPresentation(); - } + } - partial void OnIsMarkdownFallbackStickyChanged(bool value) => RefreshMarkdownPresentation(); + partial void OnIsMarkdownFallbackStickyChanged(bool value) => RefreshMarkdownPresentation(); - private void RefreshMarkdownPresentation() - { + private void RefreshMarkdownPresentation() + { var renderMode = ChatMarkdownRenderPolicy.Resolve( ContentType, IsOutgoing, TextContent, IsMarkdownFallbackSticky); MarkdownPresentation = ChatMarkdownPresentationState.Create(renderMode, TextContent); - } + } - private bool CanCopyText(string? text) - => _copyTextAsync is not null && !string.IsNullOrWhiteSpace(text); + private bool CanCopyText(string? text) + => _copyTextAsync is not null && !string.IsNullOrWhiteSpace(text); - private async Task CopyTextAsync(string? text) - { + private async Task CopyTextAsync(string? text) + { if (!CanCopyText(text)) { return; } _ = await _copyTextAsync!(text!).ConfigureAwait(true); - } + } - private bool CanOpenMarkdownLink(string? rawLink) - => _openUriAsync is not null - && ChatMarkdownLinkPolicy.TryResolveLaunchUri(rawLink, out _); + private bool CanOpenMarkdownLink(string? rawLink) + => _openUriAsync is not null + && ChatMarkdownLinkPolicy.TryResolveLaunchUri(rawLink, out _); - private async Task OpenMarkdownLinkAsync(string? rawLink) - { + private async Task OpenMarkdownLinkAsync(string? rawLink) + { if (!CanOpenMarkdownLink(rawLink)) { return; @@ -396,7 +396,7 @@ private async Task OpenMarkdownLinkAsync(string? rawLink) _ = ChatMarkdownLinkPolicy.TryResolveLaunchUri(rawLink, out var uri); _ = await _openUriAsync!(uri!).ConfigureAwait(true); - } + } private void UpdateToolCallState() { diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.AcpSessionLifecycle.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.AcpSessionLifecycle.cs index 24e40da0..401c6c5c 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.AcpSessionLifecycle.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.AcpSessionLifecycle.cs @@ -1472,7 +1472,8 @@ internal void PrimeSessionSwitchPreview(string conversationId) return; } - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { ApplySessionSwitchPreview(conversationId); }); } @@ -1490,7 +1491,8 @@ internal void ClearSessionSwitchPreview(string conversationId) return; } - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { ApplySessionSwitchPreviewClear(conversationId); }); } @@ -1541,7 +1543,8 @@ private void RemovePendingAskUserRequestState(string conversationId) private void OnPermissionRequestReceived(object? sender, PermissionRequestEventArgs e) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { try { PermissionRequestViewModel? permissionRequest = null; @@ -1657,7 +1660,8 @@ private void ClearInlinePermissionRequest(PermissionRequestViewModel? permission private void OnFileSystemRequestReceived(object? sender, FileSystemRequestEventArgs e) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { try { PendingFileSystemRequest = _interactionEventBridge.CreateFileSystemRequestViewModel( @@ -1685,7 +1689,8 @@ private void OnFileSystemRequestReceived(object? sender, FileSystemRequestEventA private void OnTerminalRequestReceived(object? sender, TerminalRequestEventArgs e) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { try { Logger.LogInformation("Terminal request received: Method={Method}, TerminalId={TerminalId}", e.Method, e.TerminalId); @@ -1700,7 +1705,8 @@ private void OnTerminalRequestReceived(object? sender, TerminalRequestEventArgs private void OnTerminalStateChangedReceived(object? sender, TerminalStateChangedEventArgs e) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { try { _ = ProcessTerminalStateChangedAsync(e); @@ -1767,7 +1773,8 @@ private void ApplyTerminalSelection(string conversationId, ChatConversationPanel private void OnErrorOccurred(object? sender, string error) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { SetError(error); Logger.LogError(error); }); diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.CommandWorkflow.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.CommandWorkflow.cs index 3583bd58..18f4e37c 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.CommandWorkflow.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.CommandWorkflow.cs @@ -891,7 +891,8 @@ private void ShowTransientNotificationToast(string message, int durationMs = 300 _transientNotificationCts = new CancellationTokenSource(); var token = _transientNotificationCts.Token; - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { TransientNotificationMessage = message.Trim(); ShowTransientNotification = true; }); diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.RemoteConversationLifecycle.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.RemoteConversationLifecycle.cs index b2f61efe..d0f8b914 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.RemoteConversationLifecycle.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.RemoteConversationLifecycle.cs @@ -2822,7 +2822,8 @@ private void SetConversationOverlayOwners( private void ScheduleSessionSwitchOverlayDismissal(long activationVersion, string conversationId) { - _uiDispatcher.Enqueue(() => { + _uiDispatcher.Enqueue(() => + { DismissSessionSwitchOverlay(activationVersion, conversationId); }); } diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.cs index 1cb6c905..991c6692 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ChatViewModel.cs @@ -2720,45 +2720,45 @@ await PostToUiAsync(() => } [RelayCommand] - private async Task ApplyTransportConfigAsync() - { - await ApplyTransportConfigCoreAsync(preserveConversation: false); - } + private async Task ApplyTransportConfigAsync() + { + await ApplyTransportConfigCoreAsync(preserveConversation: false); + } - /// - /// Core logic for applying a new transport configuration. - /// This method tears down the existing connection and establishes a new one, - /// optionally preserving the active conversation's local state. - /// - private async Task ApplyTransportConfigCoreAsync(bool preserveConversation) + /// + /// Core logic for applying a new transport configuration. + /// This method tears down the existing connection and establishes a new one, + /// optionally preserving the active conversation's local state. + /// + private async Task ApplyTransportConfigCoreAsync(bool preserveConversation) + { + try { - try - { - var result = preserveConversation - ? await _acpConnectionCommands - .ApplyTransportConfigurationAsync( - TransportConfig, - this, - new AcpConnectionContext(CurrentSessionId, PreserveConversation: true), - CancellationToken.None) - .ConfigureAwait(false) - : await _acpConnectionCommands - .ApplyTransportConfigurationAsync(TransportConfig, this, preserveConversation) - .ConfigureAwait(false); + var result = preserveConversation + ? await _acpConnectionCommands + .ApplyTransportConfigurationAsync( + TransportConfig, + this, + new AcpConnectionContext(CurrentSessionId, PreserveConversation: true), + CancellationToken.None) + .ConfigureAwait(false) + : await _acpConnectionCommands + .ApplyTransportConfigurationAsync(TransportConfig, this, preserveConversation) + .ConfigureAwait(false); _authenticationCoordinator.CacheAuthMethods(result.InitializeResponse); _authenticationCoordinator.ClearAuthenticationRequirement(_acpConnectionCoordinator); _ = _authenticationCoordinator.UpdateAgentInfoAsync(_chatService, _chatStore, SelectedProfileId); - if (string.IsNullOrWhiteSpace(CurrentSessionId)) - { - var sessionId = Guid.NewGuid().ToString("N"); - await _sessionManager.CreateSessionAsync(sessionId, GetActiveSessionCwdOrDefault()).ConfigureAwait(false); - await ActivateConversationAsync(sessionId).ConfigureAwait(false); - } - - ShowTransportConfigPanel = false; + if (string.IsNullOrWhiteSpace(CurrentSessionId)) + { + var sessionId = Guid.NewGuid().ToString("N"); + await _sessionManager.CreateSessionAsync(sessionId, GetActiveSessionCwdOrDefault()).ConfigureAwait(false); + await ActivateConversationAsync(sessionId).ConfigureAwait(false); } + + ShowTransportConfigPanel = false; + } catch (OperationCanceledException) { Logger.LogDebug("Transport apply was superseded by a newer request."); @@ -2767,7 +2767,7 @@ private async Task ApplyTransportConfigCoreAsync(bool preserveConversation) { Logger.LogError(ex, "Error during connection"); } - } + } private async Task ApplySessionNewResponseAsync(string conversationId, SessionNewResponse response) { @@ -2787,7 +2787,7 @@ private async Task ApplySessionLoadResponseAsync(string conversationId, SessionL var delta = _acpSessionUpdateProjector.ProjectSessionLoad(response); await ApplySessionUpdateDeltaAsync(conversationId, delta).ConfigureAwait(true); - + Logger.LogInformation( "Session load state projected. ConversationId={ConversationId} ModeCount={Count} LatestActivationVersion={ActivationVersion}", conversationId, @@ -2814,29 +2814,29 @@ private void SubscribeToChatService(IChatService chatService) _ = _authenticationCoordinator.UpdateAgentInfoAsync(_chatService, _chatStore, SelectedProfileId); } - private void UnsubscribeFromChatService(IChatService chatService) - { - chatService.SessionUpdateReceived -= OnSessionUpdateReceived; - chatService.PermissionRequestReceived -= OnPermissionRequestReceived; - chatService.FileSystemRequestReceived -= OnFileSystemRequestReceived; - chatService.TerminalRequestReceived -= OnTerminalRequestReceived; - chatService.TerminalStateChangedReceived -= OnTerminalStateChangedReceived; - chatService.AskUserRequestReceived -= OnAskUserRequestReceived; - chatService.ErrorOccurred -= OnErrorOccurred; - } + private void UnsubscribeFromChatService(IChatService chatService) + { + chatService.SessionUpdateReceived -= OnSessionUpdateReceived; + chatService.PermissionRequestReceived -= OnPermissionRequestReceived; + chatService.FileSystemRequestReceived -= OnFileSystemRequestReceived; + chatService.TerminalRequestReceived -= OnTerminalRequestReceived; + chatService.TerminalStateChangedReceived -= OnTerminalStateChangedReceived; + chatService.AskUserRequestReceived -= OnAskUserRequestReceived; + chatService.ErrorOccurred -= OnErrorOccurred; + } - private void SubscribeToEvents() - { - // Only subscribe if _chatService is not null. - // In constructor, _chatService might be null; it will be created in ApplyTransportConfigAsync. - if (_chatService != null) - { - SubscribeToChatService(_chatService); + private void SubscribeToEvents() + { + // Only subscribe if _chatService is not null. + // In constructor, _chatService might be null; it will be created in ApplyTransportConfigAsync. + if (_chatService != null) + { + SubscribeToChatService(_chatService); - _ = _authenticationCoordinator.UpdateAgentInfoAsync(_chatService, _chatStore, SelectedProfileId); + _ = _authenticationCoordinator.UpdateAgentInfoAsync(_chatService, _chatStore, SelectedProfileId); - } - } + } + } private sealed class ScopedAcpChatCoordinatorSink : IAcpChatCoordinatorSink { @@ -3069,79 +3069,79 @@ private sealed class NoopAcpConnectionCoordinator : IAcpConnectionCoordinator public Task ResyncAsync(IAcpChatCoordinatorSink sink, CancellationToken cancellationToken = default) => Task.CompletedTask; } - public void Dispose() + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - _disposed = true; - - if (!disposing) - { - return; - } - - if (_chatService != null) - { - UnsubscribeFromChatService(_chatService); - } - - _acpProfiles.PropertyChanged -= OnAcpProfilesPropertyChanged; - _acpProfiles.Profiles.CollectionChanged -= OnAcpProfilesCollectionChanged; - _preferences.PropertyChanged -= OnPreferencesPropertyChanged; - _preferences.Projects.CollectionChanged -= OnProjectAffinityPreferencesCollectionChanged; - _preferences.ProjectPathMappings.CollectionChanged -= OnProjectAffinityPreferencesCollectionChanged; - _conversationWorkspace.PropertyChanged -= OnConversationWorkspacePropertyChanged; - _voiceInputService.PartialResultReceived -= OnVoiceInputPartialResultReceived; - _voiceInputService.FinalResultReceived -= OnVoiceInputFinalResultReceived; - _voiceInputService.SessionEnded -= OnVoiceInputSessionEnded; - _voiceInputService.ErrorOccurred -= OnVoiceInputErrorOccurred; - if (_shellNavigationRuntimeState is not null) - { - _shellNavigationRuntimeState.PropertyChanged -= OnShellNavigationRuntimeStatePropertyChanged; - } - _planEntriesProjectionCoordinator.Observe(null, null); - if (_observedPendingAskUserRequest != null) - { - _observedPendingAskUserRequest.PropertyChanged -= OnPendingAskUserRequestPropertyChanged; - _observedPendingAskUserRequest = null; - } - - _sendPromptCts?.Cancel(); - _voiceInputCts?.Cancel(); - _transientNotificationCts?.Cancel(); - _newSessionDraftModeSelectionCts?.Cancel(); - _disposeCts.Cancel(); - CancelAndClearRemoteSessionRecoveryRequests("Dispose"); - try { _ = _voiceInputService.StopAsync(); } catch { } - StopStoreProjection(); - - try { _sendPromptCts?.Dispose(); } catch { } - try { _voiceInputCts?.Dispose(); } catch { } - try { _transientNotificationCts?.Dispose(); } catch { } - try { _newSessionDraftModeSelectionCts?.Dispose(); } catch { } - try { _disposeCts.Dispose(); } catch { } - try { _conversationActivationOrchestrator.Dispose(); } catch { } - try { _ = _localTerminalPanelCoordinator?.DisposeAsync().AsTask(); } catch { } - try { _remoteConversationActivationGate.Dispose(); } catch { } - try { _newSessionDraftGate.Dispose(); } catch { } - - _selectedProfileConnectTask = null; - _pendingSelectedProfileConnect = null; - _sendPromptCts = null; - _voiceInputCts = null; - _transientNotificationCts = null; - _newSessionDraftModeSelectionCts = null; + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (!disposing) + { + return; } + if (_chatService != null) + { + UnsubscribeFromChatService(_chatService); + } + + _acpProfiles.PropertyChanged -= OnAcpProfilesPropertyChanged; + _acpProfiles.Profiles.CollectionChanged -= OnAcpProfilesCollectionChanged; + _preferences.PropertyChanged -= OnPreferencesPropertyChanged; + _preferences.Projects.CollectionChanged -= OnProjectAffinityPreferencesCollectionChanged; + _preferences.ProjectPathMappings.CollectionChanged -= OnProjectAffinityPreferencesCollectionChanged; + _conversationWorkspace.PropertyChanged -= OnConversationWorkspacePropertyChanged; + _voiceInputService.PartialResultReceived -= OnVoiceInputPartialResultReceived; + _voiceInputService.FinalResultReceived -= OnVoiceInputFinalResultReceived; + _voiceInputService.SessionEnded -= OnVoiceInputSessionEnded; + _voiceInputService.ErrorOccurred -= OnVoiceInputErrorOccurred; + if (_shellNavigationRuntimeState is not null) + { + _shellNavigationRuntimeState.PropertyChanged -= OnShellNavigationRuntimeStatePropertyChanged; + } + _planEntriesProjectionCoordinator.Observe(null, null); + if (_observedPendingAskUserRequest != null) + { + _observedPendingAskUserRequest.PropertyChanged -= OnPendingAskUserRequestPropertyChanged; + _observedPendingAskUserRequest = null; + } + + _sendPromptCts?.Cancel(); + _voiceInputCts?.Cancel(); + _transientNotificationCts?.Cancel(); + _newSessionDraftModeSelectionCts?.Cancel(); + _disposeCts.Cancel(); + CancelAndClearRemoteSessionRecoveryRequests("Dispose"); + try { _ = _voiceInputService.StopAsync(); } catch { } + StopStoreProjection(); + + try { _sendPromptCts?.Dispose(); } catch { } + try { _voiceInputCts?.Dispose(); } catch { } + try { _transientNotificationCts?.Dispose(); } catch { } + try { _newSessionDraftModeSelectionCts?.Dispose(); } catch { } + try { _disposeCts.Dispose(); } catch { } + try { _conversationActivationOrchestrator.Dispose(); } catch { } + try { _ = _localTerminalPanelCoordinator?.DisposeAsync().AsTask(); } catch { } + try { _remoteConversationActivationGate.Dispose(); } catch { } + try { _newSessionDraftGate.Dispose(); } catch { } + + _selectedProfileConnectTask = null; + _pendingSelectedProfileConnect = null; + _sendPromptCts = null; + _voiceInputCts = null; + _transientNotificationCts = null; + _newSessionDraftModeSelectionCts = null; + } + private void RaisePlanEntryDerivedPropertyNotifications() { OnPropertyChanged(nameof(HasPlanEntries)); diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/Overlay/ChatConversationSurfaceProjectionCoordinator.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/Overlay/ChatConversationSurfaceProjectionCoordinator.cs index fcbdfafd..287317cd 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/Overlay/ChatConversationSurfaceProjectionCoordinator.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/Overlay/ChatConversationSurfaceProjectionCoordinator.cs @@ -4,18 +4,24 @@ namespace SalmonEgg.Presentation.Core.ViewModels.Chat.Overlay; internal sealed class ChatConversationSurfaceProjectionCoordinator { + private bool _hasLoadedActiveConversationRoot; + private bool _hasLoadedTranscriptSurface; + public ChatConversationSurfaceProjection Project(ChatConversationSurfaceStateInput input) { var state = ChatConversationSurfaceStatePresenter.Resolve(input); + _hasLoadedActiveConversationRoot |= state.ShouldShowActiveConversationRoot; + _hasLoadedTranscriptSurface |= state.ShouldShowTranscriptSurface; + return new ChatConversationSurfaceProjection( state.IsActivationOverlayVisible, state.IsOverlayVisible, state.ShouldShowActiveConversationRoot, - state.ShouldShowActiveConversationRoot, + _hasLoadedActiveConversationRoot, state.ShouldShowSessionHeader, state.ShouldShowTranscriptSurface, - state.ShouldShowTranscriptSurface, + _hasLoadedTranscriptSurface, state.ShouldShowConversationInputSurface, state.ShouldShowBlockingLoadingMask, state.ShouldShowLoadingOverlayStatusPill, diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ResourceViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ResourceViewModel.cs index 312928e8..fc5601ca 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ResourceViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Chat/ResourceViewModel.cs @@ -124,67 +124,67 @@ private static string FormatSize(long bytes) return $"{size:F2} {suffixes[i]}"; } - /// - /// 从 ResourceContentBlock 创建 ResourceViewModel - /// - public static ResourceViewModel CreateFromContent(ResourceContentBlock block) - { - var resource = block.Resource; - var isText = resource.IsText; - var content = resource.Text ?? resource.Blob ?? string.Empty; - - return new ResourceViewModel - { - Uri = resource.Uri, - Name = ExtractNameFromUri(resource.Uri), - MimeType = resource.MimeType ?? "text/plain", - Content = content, - Title = "资源内容", - Description = null, - IsTextResource = isText, - IsBinaryResource = resource.IsBinary, - Size = content.Length - }; - } - - /// - /// 从 URI 中提取名称 - /// - private static string ExtractNameFromUri(string uri) - { - if (string.IsNullOrEmpty(uri)) return "Unknown Resource"; - - try - { - var lastSlash = uri.LastIndexOf('/'); - if (lastSlash >= 0 && lastSlash < uri.Length - 1) - { - return uri.Substring(lastSlash + 1); - } - return uri; - } - catch - { - return "Unknown Resource"; - } - } - - /// - /// 从 ResourceLinkContentBlock 创建 ResourceViewModel - /// - public static ResourceViewModel CreateFromLink(ResourceLinkContentBlock block) - { - return new ResourceViewModel - { - Uri = block.Uri, - Name = block.Name ?? ExtractNameFromUri(block.Uri), - MimeType = block.MimeType ?? string.Empty, - LinkText = block.Name ?? block.Uri, - Title = block.Title ?? "资源链接", - Description = block.Description, - Size = block.Size, - IsTextResource = false, - IsBinaryResource = false - }; - } + /// + /// 从 ResourceContentBlock 创建 ResourceViewModel + /// + public static ResourceViewModel CreateFromContent(ResourceContentBlock block) + { + var resource = block.Resource; + var isText = resource.IsText; + var content = resource.Text ?? resource.Blob ?? string.Empty; + + return new ResourceViewModel + { + Uri = resource.Uri, + Name = ExtractNameFromUri(resource.Uri), + MimeType = resource.MimeType ?? "text/plain", + Content = content, + Title = "资源内容", + Description = null, + IsTextResource = isText, + IsBinaryResource = resource.IsBinary, + Size = content.Length + }; + } + + /// + /// 从 URI 中提取名称 + /// + private static string ExtractNameFromUri(string uri) + { + if (string.IsNullOrEmpty(uri)) return "Unknown Resource"; + + try + { + var lastSlash = uri.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < uri.Length - 1) + { + return uri.Substring(lastSlash + 1); + } + return uri; + } + catch + { + return "Unknown Resource"; + } + } + + /// + /// 从 ResourceLinkContentBlock 创建 ResourceViewModel + /// + public static ResourceViewModel CreateFromLink(ResourceLinkContentBlock block) + { + return new ResourceViewModel + { + Uri = block.Uri, + Name = block.Name ?? ExtractNameFromUri(block.Uri), + MimeType = block.MimeType ?? string.Empty, + LinkText = block.Name ?? block.Uri, + Title = block.Title ?? "资源链接", + Description = block.Description, + Size = block.Size, + IsTextResource = false, + IsBinaryResource = false + }; + } } diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Discover/DiscoverSessionsViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Discover/DiscoverSessionsViewModel.cs index 58942d39..afcd2b07 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Discover/DiscoverSessionsViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Discover/DiscoverSessionsViewModel.cs @@ -295,7 +295,7 @@ await PostToUiAsync(() => var cancellationToken = _refreshSessionsCts.Token; var generation = Interlocked.Increment(ref _refreshGeneration); - + await PostToUiAsync(() => { AgentSessions.Clear(); diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/MainNavItemViewModels.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/MainNavItemViewModels.cs index 3c044bf7..60ce233a 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/MainNavItemViewModels.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/MainNavItemViewModels.cs @@ -70,8 +70,8 @@ public bool IsActiveDescendant } public ProjectNavItemViewModel( - ProjectDefinition project, - bool isSystemProject, + ProjectDefinition project, + bool isSystemProject, Func createSessionAsync, INavigationPaneState navigationState, IUiDispatcher uiDispatcher) diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Settings/AcpProfilesViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Settings/AcpProfilesViewModel.cs index 1f315173..1c323f7a 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Settings/AcpProfilesViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Settings/AcpProfilesViewModel.cs @@ -156,7 +156,7 @@ public async Task RefreshAsync() try { await SetIsLoadingAsync(true).ConfigureAwait(false); - + var configs = await _configurationService.ListConfigurationsAsync().ConfigureAwait(false); var ordered = configs.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToArray(); diff --git a/tests/SalmonEgg.Domain.Tests/Models/Content/ContentBlockProperties.cs b/tests/SalmonEgg.Domain.Tests/Models/Content/ContentBlockProperties.cs index 1e7375a1..b79a6fef 100644 --- a/tests/SalmonEgg.Domain.Tests/Models/Content/ContentBlockProperties.cs +++ b/tests/SalmonEgg.Domain.Tests/Models/Content/ContentBlockProperties.cs @@ -13,21 +13,21 @@ namespace SalmonEgg.Domain.Tests.Models.Content [TestFixture] public class ContentBlockProperties { - private readonly JsonSerializerOptions _jsonOptions; - - public ContentBlockProperties() - { - // 配置序列化选项:使用小写命名策略,以匹配 "type" 字段 - _jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - WriteIndented = false - }; - - // 添加 JsonPolymorphic 转换器支持 - _jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); - } + private readonly JsonSerializerOptions _jsonOptions; + + public ContentBlockProperties() + { + // 配置序列化选项:使用小写命名策略,以匹配 "type" 字段 + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + // 添加 JsonPolymorphic 转换器支持 + _jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + } [FsCheck.NUnit.Property(QuietOnSuccess = true)] public void TextContentBlock_PropertyRoundTrip_PreservesFidelity( diff --git a/tests/SalmonEgg.Domain.Tests/Models/JsonRpc/JsonRpcMessageProperties.cs b/tests/SalmonEgg.Domain.Tests/Models/JsonRpc/JsonRpcMessageProperties.cs index 7fde90e9..bac7742b 100644 --- a/tests/SalmonEgg.Domain.Tests/Models/JsonRpc/JsonRpcMessageProperties.cs +++ b/tests/SalmonEgg.Domain.Tests/Models/JsonRpc/JsonRpcMessageProperties.cs @@ -267,7 +267,7 @@ public void JsonRpcResponse_ExactlyOneOfResultOrError_Success() Assert.That(hasResult, Is.True, "Response should have 'result' property"); Assert.That(errorIsNull, Is.True, "Response should have 'error' property set to null"); - } + } /// /// 属性 4:响应消息互斥字段验证(错误情况) @@ -293,7 +293,7 @@ public void JsonRpcResponse_ExactlyOneOfResultOrError_Error() Assert.That(resultIsNull, Is.True, "Response should have 'result' property set to null"); Assert.That(errorIsNotNull, Is.True, "Response should have a non-null 'error' property"); - } + } /// /// 属性 5:错误码标准化 diff --git a/tests/SalmonEgg.Infrastructure.Tests/Client/AcpClientTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Client/AcpClientTests.cs index e0309c71..b1d29ef9 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Client/AcpClientTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Client/AcpClientTests.cs @@ -77,7 +77,7 @@ public async Task CreateSessionAsync_SlowButValidResponse_CompletesWhenResponseE var parser = new MessageParser(); var client = await CreateInitializedClientAsync( new AgentCapabilities(loadSession: true)); - + SetupJsonRpcResponse( "session/new", JsonSerializer.SerializeToElement(new SessionNewResponse("session-123"), parser.Options), diff --git a/tests/SalmonEgg.Infrastructure.Tests/Logging/LoggingConfigurationTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Logging/LoggingConfigurationTests.cs index 1fb9238b..29e84de1 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Logging/LoggingConfigurationTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Logging/LoggingConfigurationTests.cs @@ -26,7 +26,7 @@ public void ConfigureLogging_ShouldCreateLogger() // Assert Assert.NotNull(logger); - + // 关闭 logger (logger as IDisposable)?.Dispose(); } @@ -55,7 +55,7 @@ public void ConfigureLogging_ShouldCreateLogDirectory() // Assert var logDirectory = Path.Combine(tempPath, "logs"); Assert.True(Directory.Exists(logDirectory), "日志目录应该被创建"); - + // 关闭 logger (logger as IDisposable)?.Dispose(); } @@ -86,7 +86,7 @@ public void ConfigureLogging_WithDebugMode_ShouldEnableDebugLevel() Assert.NotNull(logger); // 验证 Debug 级别的日志可以被记录 logger.Debug("Test debug message"); - + // 关闭 logger 以释放文件句柄 (logger as IDisposable)?.Dispose(); } @@ -158,7 +158,7 @@ public void ConfigureLogging_ShouldWriteToLogFile() // Act var logger = LoggingConfiguration.ConfigureLogging(tempPath); logger.Information("Test log message"); - + // 关闭 logger 以确保日志被写入 (logger as IDisposable)?.Dispose(); @@ -190,7 +190,7 @@ public void ConfigureLogging_LogFileShouldContainTimestampAndLevel() var logger = LoggingConfiguration.ConfigureLogging(tempPath); var testMessage = $"Test message {Guid.NewGuid()}"; logger.Information(testMessage); - + // 关闭 logger 以确保日志被写入 (logger as IDisposable)?.Dispose(); @@ -227,7 +227,7 @@ public void ConfigureLogging_ShouldIncludeThreadIdInLogFile() // Act var logger = LoggingConfiguration.ConfigureLogging(tempPath); logger.Information("Test message with thread ID"); - + // 关闭 logger 以确保日志被写入 (logger as IDisposable)?.Dispose(); diff --git a/tests/SalmonEgg.Infrastructure.Tests/Network/ConnectionManagerTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Network/ConnectionManagerTests.cs index 2dd42e95..66be80ac 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Network/ConnectionManagerTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Network/ConnectionManagerTests.cs @@ -1,4 +1,13 @@ -using System;using System.Reactive.Subjects;using System.Threading;using System.Threading.Tasks;using Moq;using Serilog;using SalmonEgg.Domain.Models;using SalmonEgg.Domain.Services;using SalmonEgg.Infrastructure.Network;using Xunit; +using System; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Serilog; +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; +using SalmonEgg.Infrastructure.Network; +using Xunit; namespace SalmonEgg.Infrastructure.Tests.Network { diff --git a/tests/SalmonEgg.Infrastructure.Tests/Network/TransportTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Network/TransportTests.cs index 225bceb5..b68b9ac1 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Network/TransportTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Network/TransportTests.cs @@ -1,4 +1,13 @@ -using System;using System.Collections.Generic;using System.Net.Http;using System.Reactive.Subjects;using System.Threading;using System.Threading.Tasks;using Moq;using Serilog;using SalmonEgg.Infrastructure.Network;using Xunit; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Serilog; +using SalmonEgg.Infrastructure.Network; +using Xunit; namespace SalmonEgg.Infrastructure.Tests.Network { diff --git a/tests/SalmonEgg.Infrastructure.Tests/Serialization/AcpMessageParserTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Serialization/AcpMessageParserTests.cs index ab6d5d92..b64200de 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Serialization/AcpMessageParserTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Serialization/AcpMessageParserTests.cs @@ -35,7 +35,7 @@ public void SerializeThenParseShouldReturnEquivalentMessage() // Act var json = _parser.SerializeMessage(originalMessage); var parsedMessage = _parser.ParseMessage(json); - + // Assert AssertEquivalentMessages(originalMessage, parsedMessage); } @@ -57,7 +57,7 @@ public void SerializeThenParseShouldReturnEquivalentMessageForDifferentTypes(str // Act var json = _parser.SerializeMessage(originalMessage); var parsedMessage = _parser.ParseMessage(json); - + // Assert AssertEquivalentMessages(originalMessage, parsedMessage); } @@ -113,10 +113,10 @@ public void ParseRequestMessageShouldSucceed() { // Arrange var json = "{\"id\":\"1\",\"type\":\"request\",\"method\":\"test\",\"params\":{\"key\":\"value\"}}"; - + // Act var message = _parser.ParseMessage(json); - + // Assert Assert.Equal("1", message.Id); Assert.Equal("request", message.Type); @@ -128,10 +128,10 @@ public void ParseResponseMessageShouldSucceed() { // Arrange var json = "{\"id\":\"1\",\"type\":\"response\",\"result\":\"success\"}"; - + // Act var message = _parser.ParseMessage(json); - + // Assert Assert.Equal("1", message.Id); Assert.Equal("response", message.Type); @@ -142,10 +142,10 @@ public void ParseNotificationMessageShouldSucceed() { // Arrange var json = "{\"id\":\"1\",\"type\":\"notification\",\"method\":\"test\",\"params\":{\"key\":\"value\"}}"; - + // Act var message = _parser.ParseMessage(json); - + // Assert Assert.Equal("1", message.Id); Assert.Equal("notification", message.Type); @@ -157,10 +157,10 @@ public void ParseInitializeMessageShouldSucceed() { // Arrange var json = "{\"id\":\"1\",\"type\":\"initialize\",\"params\":{\"version\":\"1.0\"}}"; - + // Act var message = _parser.ParseMessage(json); - + // Assert Assert.Equal("1", message.Id); Assert.Equal("initialize", message.Type); @@ -229,7 +229,7 @@ private void AssertEquivalentMessages(AcpMessage expected, AcpMessage actual) Assert.Equal(expected.Id, actual.Id); Assert.Equal(expected.Type, actual.Type); Assert.Equal(expected.Method, actual.Method); - + // Compare Params if (expected.Params.HasValue && actual.Params.HasValue) { @@ -241,7 +241,7 @@ private void AssertEquivalentMessages(AcpMessage expected, AcpMessage actual) { Assert.Equal(expected.Params.HasValue, actual.Params.HasValue); } - + // Compare Result if (expected.Result.HasValue && actual.Result.HasValue) { @@ -253,7 +253,7 @@ private void AssertEquivalentMessages(AcpMessage expected, AcpMessage actual) { Assert.Equal(expected.Result.HasValue, actual.Result.HasValue); } - + // Compare Error if (expected.Error != null && actual.Error != null) { @@ -264,7 +264,7 @@ private void AssertEquivalentMessages(AcpMessage expected, AcpMessage actual) { Assert.Equal(expected.Error == null, actual.Error == null); } - + Assert.Equal(expected.ProtocolVersion, actual.ProtocolVersion); } } diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Chat/ChatViewModelTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Chat/ChatViewModelTests.cs index 4c323afc..ce4cd9ce 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Chat/ChatViewModelTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Chat/ChatViewModelTests.cs @@ -54,7 +54,7 @@ namespace SalmonEgg.Presentation.Core.Tests.Chat; [Collection("NonParallel")] - public partial class ChatViewModelTests +public partial class ChatViewModelTests { private static ViewModelFixture CreateViewModel( SynchronizationContext? syncContext = null, @@ -10155,10 +10155,10 @@ await fixture.UpdateStateAsync(state => state with runtimeState.IsSessionActivationInProgress = true; Assert.False(fixture.ViewModel.ShouldShowActiveConversationRoot); - Assert.False(fixture.ViewModel.ShouldLoadActiveConversationRoot); + Assert.True(fixture.ViewModel.ShouldLoadActiveConversationRoot); Assert.False(fixture.ViewModel.ShouldShowSessionHeader); Assert.False(fixture.ViewModel.ShouldShowTranscriptSurface); - Assert.False(fixture.ViewModel.ShouldLoadTranscriptSurface); + Assert.True(fixture.ViewModel.ShouldLoadTranscriptSurface); Assert.False(fixture.ViewModel.ShouldShowConversationInputSurface); Assert.Equal(SessionNamePolicy.CreateDefault("conv-1"), fixture.ViewModel.CurrentSessionDisplayName); Assert.Equal(string.Empty, fixture.ViewModel.PresentedSessionHeaderDisplayName); @@ -11215,10 +11215,10 @@ await fixture.DispatchConnectionAsync(new SetNewSessionDraftAction(new NewSessio syncContext.RunAll(); Assert.False(fixture.ViewModel.ShouldShowActiveConversationRoot); - Assert.False(fixture.ViewModel.ShouldLoadActiveConversationRoot); + Assert.True(fixture.ViewModel.ShouldLoadActiveConversationRoot); Assert.False(fixture.ViewModel.ShouldShowSessionHeader); Assert.False(fixture.ViewModel.ShouldShowTranscriptSurface); - Assert.False(fixture.ViewModel.ShouldLoadTranscriptSurface); + Assert.True(fixture.ViewModel.ShouldLoadTranscriptSurface); Assert.False(fixture.ViewModel.ShouldShowConversationInputSurface); var switchTask = fixture.ViewModel.SwitchConversationAsync("conv-2"); @@ -11235,10 +11235,10 @@ await fixture.DispatchConnectionAsync(new SetNewSessionDraftAction(new NewSessio syncContext.RunAll(); Assert.False(fixture.ViewModel.ShouldShowActiveConversationRoot); - Assert.False(fixture.ViewModel.ShouldLoadActiveConversationRoot); + Assert.True(fixture.ViewModel.ShouldLoadActiveConversationRoot); Assert.False(fixture.ViewModel.ShouldShowSessionHeader); Assert.False(fixture.ViewModel.ShouldShowTranscriptSurface); - Assert.False(fixture.ViewModel.ShouldLoadTranscriptSurface); + Assert.True(fixture.ViewModel.ShouldLoadTranscriptSurface); Assert.False(fixture.ViewModel.ShouldShowConversationInputSurface); Assert.Equal("conv-2", runtimeState.DesiredSessionId); Assert.True(runtimeState.IsSessionActivationInProgress); diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Chat/Overlay/ChatConversationSurfaceProjectionCoordinatorTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Chat/Overlay/ChatConversationSurfaceProjectionCoordinatorTests.cs index 6dca3376..b1bc7652 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Chat/Overlay/ChatConversationSurfaceProjectionCoordinatorTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Chat/Overlay/ChatConversationSurfaceProjectionCoordinatorTests.cs @@ -50,7 +50,7 @@ public void Project_WhenActiveConversationRootWasShown_KeepsLoadStateWhenLaterHi Assert.True(visible.ShouldShowActiveConversationRoot); Assert.True(visible.ShouldLoadActiveConversationRoot); Assert.False(hidden.ShouldShowActiveConversationRoot); - Assert.False(hidden.ShouldLoadActiveConversationRoot); + Assert.True(hidden.ShouldLoadActiveConversationRoot); } [Fact] @@ -97,6 +97,6 @@ public void Project_WhenTranscriptSurfaceWasShown_KeepsLoadStateWhenLaterHidden( Assert.True(visible.ShouldShowTranscriptSurface); Assert.True(visible.ShouldLoadTranscriptSurface); Assert.False(hidden.ShouldShowTranscriptSurface); - Assert.False(hidden.ShouldLoadTranscriptSurface); + Assert.True(hidden.ShouldLoadTranscriptSurface); } } diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Discover/DiscoverSessionsViewModelTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Discover/DiscoverSessionsViewModelTests.cs index 20cde5ac..851a1793 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Discover/DiscoverSessionsViewModelTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Discover/DiscoverSessionsViewModelTests.cs @@ -73,7 +73,7 @@ public async Task RefreshSessionsAsync_ProjectsNeedsMappingAndUnclassifiedAffini }; using var viewModel = CreateViewModel( profilesViewModel, - connectionFacade, + connectionFacade, new StubNavigationCoordinator()); await viewModel.RefreshSessionsCommand.ExecuteAsync(null); @@ -119,7 +119,7 @@ public async Task RefreshSessionsAsync_WhenRemoteListIsEmpty_UsesEmptyPhaseInste }; using var viewModel = CreateViewModel( profilesViewModel, - connectionFacade, + connectionFacade, new StubNavigationCoordinator()); await viewModel.RefreshSessionsCommand.ExecuteAsync(null); @@ -209,7 +209,7 @@ public async Task RefreshSessionsAsync_WhenAgentDoesNotSupportSessionList_DoesNo }; using var viewModel = CreateViewModel( profilesViewModel, - connectionFacade, + connectionFacade, new StubNavigationCoordinator()); await viewModel.RefreshSessionsCommand.ExecuteAsync(null); @@ -713,7 +713,7 @@ public void SetLayoutMode_Narrow_UpdatesShowProperties() var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); @@ -732,7 +732,7 @@ public void OpenProfileDetails_InNarrowMode_MovesToDetailPane() var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); @@ -751,7 +751,7 @@ public void BackToProfiles_InNarrowMode_ReturnsToListPane() var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); @@ -770,7 +770,7 @@ public void SetLayoutMode_WhenModeIsUnchanged_DoesNotRaiseDerivedPaneNotificatio var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); var changedProperties = new List(); viewModel.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName ?? string.Empty); @@ -789,7 +789,7 @@ public void OpenProfileDetails_WhenAlreadyInDetailPane_DoesNotRaiseDerivedPaneNo var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); viewModel.OpenProfileDetailsCommand.Execute(null); @@ -810,10 +810,10 @@ public void SelectingProfile_InNarrowMode_MovesToDetailPane() var profile2 = new ServerConfiguration { Id = "profile-2", Name = "Profile 2" }; var profilesViewModel = CreateProfilesViewModel(profile1); profilesViewModel.Profiles.Add(profile2); - + using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); @@ -831,7 +831,7 @@ public void ClearingProfileSelection_CoercesPaneToListMode() var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); @@ -853,7 +853,7 @@ public void DiscoverSelection_IsLocallyOwned_AndNotOverwrittenBySharedProfileSel using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SelectedProfile = profile2; @@ -873,12 +873,12 @@ public void SwitchingToWideMode_ExposesBothPanes_PreservingSelection() var profilesViewModel = CreateProfilesViewModel(profile); using var viewModel = CreateViewModel( profilesViewModel, - new FakeDiscoverSessionsConnectionFacade(), + new FakeDiscoverSessionsConnectionFacade(), new StubNavigationCoordinator()); viewModel.SetLayoutMode(DiscoverLayoutMode.Narrow); viewModel.OpenProfileDetailsCommand.Execute(null); - + // Act: switch back to wide viewModel.SetLayoutMode(DiscoverLayoutMode.Wide); @@ -954,7 +954,7 @@ public async Task RefreshSessionsAsync_WhenProfileChangesDuringRefresh_DropsStal using var vm = CreateViewModel( profilesViewModel, - connectionFacade, + connectionFacade, new StubNavigationCoordinator()); viewModel = vm; @@ -1002,7 +1002,7 @@ public async Task OnConnectionFacadePropertyChanged_WhenProfileChanges_DropsStal }; using var viewModel = CreateViewModel( profilesViewModel, - connectionFacade, + connectionFacade, new StubNavigationCoordinator()); // Start refresh for profile 1 with a valid chat service so the test isolates stale facade @@ -1013,7 +1013,7 @@ public async Task OnConnectionFacadePropertyChanged_WhenProfileChanges_DropsStal // Switch to profile 2 viewModel.SelectedProfile = profile2; - + // At this point, the connection gating generation has been incremented. // Simulate profile 1 connection error arriving late. // Real property changes from the facade happen on a background thread.