using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 사용자 정보 ──────────────────────────────────────────────────── private void SetupUserInfo() { var userName = Environment.UserName; // AD\, AD/, AD: 접두사 제거 var cleanName = userName; foreach (var sep in new[] { '\\', '/', ':' }) { var idx = cleanName.LastIndexOf(sep); if (idx >= 0) cleanName = cleanName[(idx + 1)..]; } var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U"; var pcName = Environment.MachineName; UserInitialSidebar.Text = initial; UserInitialIconBar.Text = initial; UserNameText.Text = cleanName; UserPcText.Text = pcName; BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})"; } // ─── 스크롤 동작 ────────────────────────────────────────────────── private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e) { // 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥 if (MessageScroll.ScrollableHeight <= 1) { _userScrolled = false; return; } // 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지 if (Math.Abs(e.ExtentHeightChange) > 0.5) return; var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40; _userScrolled = !atBottom; } private void AutoScrollIfNeeded() { if (!_userScrolled) SmoothScrollToEnd(); } /// 새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋). private void ForceScrollToEnd() { _userScrolled = false; Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background); } /// 부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다. private void SmoothScrollToEnd() { var targetOffset = MessageScroll.ScrollableHeight; var currentOffset = MessageScroll.VerticalOffset; var diff = targetOffset - currentOffset; // 차이가 작으면 즉시 이동 (깜빡임 방지) if (diff <= 60) { MessageScroll.ScrollToEnd(); return; } // 부드럽게 스크롤 (DoubleAnimation) var animation = new DoubleAnimation { From = currentOffset, To = targetOffset, Duration = TimeSpan.FromMilliseconds(200), EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, }; animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset); // ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간 var startTime = DateTime.UtcNow; var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps EventHandler tickHandler = null!; tickHandler = (_, _) => { var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; var progress = Math.Min(elapsed / 200.0, 1.0); var eased = 1.0 - Math.Pow(1.0 - progress, 3); var offset = currentOffset + diff * eased; MessageScroll.ScrollToVerticalOffset(offset); if (progress >= 1.0) { timer.Stop(); timer.Tick -= tickHandler; } }; timer.Tick += tickHandler; timer.Start(); } // ─── 대화 제목 인라인 편집 ────────────────────────────────────────── private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e) { lock (_convLock) { if (_currentConversation == null) return; } ChatTitle.Visibility = Visibility.Collapsed; ChatTitleEdit.Text = ChatTitle.Text; ChatTitleEdit.Visibility = Visibility.Visible; ChatTitleEdit.Focus(); ChatTitleEdit.SelectAll(); } private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit(); private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; } if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; } } private void CommitTitleEdit() { var newTitle = ChatTitleEdit.Text.Trim(); ChatTitleEdit.Visibility = Visibility.Collapsed; ChatTitle.Visibility = Visibility.Visible; if (string.IsNullOrEmpty(newTitle)) return; lock (_convLock) { if (_currentConversation == null) return; _currentConversation.Title = newTitle; } ChatTitle.Text = newTitle; try { ChatConversation conv; lock (_convLock) conv = _currentConversation!; _storage.Save(conv); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } RefreshConversationList(); } private void CancelTitleEdit() { ChatTitleEdit.Visibility = Visibility.Collapsed; ChatTitle.Visibility = Visibility.Visible; } // ─── 카테고리 드롭다운 ────────────────────────────────────────────── private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e) { var borderBrush = ThemeResourceHelper.Border(this); var primaryText = ThemeResourceHelper.Primary(this); var secondaryText = ThemeResourceHelper.Secondary(this); var hoverBg = ThemeResourceHelper.HoverBg(this); var accentBrush = ThemeResourceHelper.Accent(this); var (popup, stack) = PopupMenuHelper.Create(BtnCategoryDrop, this, PlacementMode.Bottom, minWidth: 180); popup.VerticalOffset = 4; Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick) { var item = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 7, 10, 7), Margin = new Thickness(0, 1, 0, 1), Cursor = Cursors.Hand, }; var g = new Grid(); g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); var iconTb = new TextBlock { Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(iconTb, 0); g.Children.Add(iconTb); var textTb = new TextBlock { Text = text, FontSize = 12.5, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(textTb, 1); g.Children.Add(textTb); if (isSelected) { var check = CreateSimpleCheck(accentBrush, 14); Grid.SetColumn(check, 2); g.Children.Add(check); } item.Child = g; item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); }; return item; } Border CreateSep() => new() { Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4), }; // 전체 보기 var allLabel = _activeTab switch { "Cowork" => "모든 작업", "Code" => "모든 작업", _ => "모든 주제", }; stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText, string.IsNullOrEmpty(_selectedCategory), () => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); })); stack.Children.Add(CreateSep()); if (_activeTab == "Cowork" || _activeTab == "Code") { // 코워크/코드: 프리셋 카테고리 기반 필터 var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); var seen = new HashSet(); foreach (var p in presets) { if (p.IsCustom) continue; // 커스텀은 별도 그룹 if (!seen.Add(p.Category)) continue; var capturedCat = p.Category; stack.Children.Add(CreateCatItem(p.Symbol, p.Label, BrushFromHex(p.Color), _selectedCategory == capturedCat, () => { _selectedCategory = capturedCat; UpdateCategoryLabel(); RefreshConversationList(); })); } // 커스텀 프리셋 통합 필터 if (presets.Any(p => p.IsCustom)) { stack.Children.Add(CreateSep()); stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, _selectedCategory == "__custom__", () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); } } else { // Chat: 기존 ChatCategory 기반 foreach (var (key, label, symbol, color) in ChatCategory.All) { var capturedKey = key; stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color), _selectedCategory == capturedKey, () => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); })); } // 커스텀 프리셋 통합 필터 (Chat) var chatCustom = Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList(); if (chatCustom.Count > 0) { stack.Children.Add(CreateSep()); stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText, _selectedCategory == "__custom__", () => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); })); } } popup.IsOpen = true; } private void UpdateCategoryLabel() { if (string.IsNullOrEmpty(_selectedCategory)) { CategoryLabel.Text = _activeTab switch { "Cowork" or "Code" => "모든 작업", _ => "모든 주제" }; CategoryIcon.Text = "\uE8BD"; } else if (_selectedCategory == "__custom__") { CategoryLabel.Text = "커스텀 프리셋"; CategoryIcon.Text = "\uE710"; } else { // ChatCategory에서 찾기 foreach (var (key, label, symbol, _) in ChatCategory.All) { if (key == _selectedCategory) { CategoryLabel.Text = label; CategoryIcon.Text = symbol; return; } } // 프리셋 카테고리에서 찾기 (Cowork/Code) var presets = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets); var match = presets.FirstOrDefault(p => p.Category == _selectedCategory); if (match != null) { CategoryLabel.Text = match.Label; CategoryIcon.Text = match.Symbol; } else { CategoryLabel.Text = _selectedCategory; CategoryIcon.Text = "\uE8BD"; } } } // ─── 창 컨트롤 ────────────────────────────────────────────────────── // WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요 protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); var source = System.Windows.Interop.HwndSource.FromHwnd( new System.Windows.Interop.WindowInteropHelper(this).Handle); source?.AddHook(WndProc); } private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { // WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보 if (msg == 0x0024) { var screen = System.Windows.Forms.Screen.FromHandle(hwnd); var workArea = screen.WorkingArea; var monitor = screen.Bounds; var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd); // MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36) System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y handled = true; } return IntPtr.Zero; } private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; private void BtnMaximize_Click(object sender, RoutedEventArgs e) { WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘 } private void BtnClose_Click(object sender, RoutedEventArgs e) => Close(); }