using System; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { // ── A-1: TopicButtonPanel 이벤트 위임 ── private Border? _lastHoveredTopicBorder; private bool _topicPanelDelegationInitialized; /// 프리셋 카드 Border.Tag에 저장하는 메타 데이터. private sealed class TopicCardTag { public required string Action { get; init; } // "preset" | "etc" | "add" | "custom_preset" public TopicPreset? Preset { get; init; } public required Brush NormalBackground { get; init; } public required Brush HoverBackground { get; init; } public Brush? NormalBorderBrush { get; init; } } /// TopicButtonPanel에 이벤트 위임 핸들러를 1회 등록합니다. private void InitTopicPanelDelegation() { if (_topicPanelDelegationInitialized || TopicButtonPanel == null) return; _topicPanelDelegationInitialized = true; TopicButtonPanel.Background = Brushes.Transparent; // 갭 영역도 히트테스트 대상으로 등록 TopicButtonPanel.MouseMove += TopicPanel_DelegatedMouseMove; TopicButtonPanel.MouseLeave += TopicPanel_DelegatedMouseLeave; TopicButtonPanel.PreviewMouseLeftButtonDown += TopicPanel_DelegatedLeftButtonDown; TopicButtonPanel.PreviewMouseRightButtonUp += TopicPanel_DelegatedRightButtonUp; } private void TopicPanel_DelegatedMouseMove(object sender, MouseEventArgs e) { var border = FindAncestorWithTag(e.OriginalSource as DependencyObject); if (ReferenceEquals(border, _lastHoveredTopicBorder)) return; // 이전 호버 해제 if (_lastHoveredTopicBorder?.Tag is TopicCardTag prevTag) { _lastHoveredTopicBorder.Background = prevTag.NormalBackground; if (prevTag.NormalBorderBrush != null) _lastHoveredTopicBorder.BorderBrush = prevTag.NormalBorderBrush; } _lastHoveredTopicBorder = border; // 새 호버 적용 if (border?.Tag is TopicCardTag tag) { border.Background = tag.HoverBackground; border.BorderBrush = TryFindResource("AccentColor") as Brush ?? tag.NormalBorderBrush ?? border.BorderBrush; } } private void TopicPanel_DelegatedMouseLeave(object sender, MouseEventArgs e) { if (_lastHoveredTopicBorder?.Tag is TopicCardTag prevTag) { _lastHoveredTopicBorder.Background = prevTag.NormalBackground; if (prevTag.NormalBorderBrush != null) _lastHoveredTopicBorder.BorderBrush = prevTag.NormalBorderBrush; } _lastHoveredTopicBorder = null; } private void TopicPanel_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e) { var border = FindAncestorWithTag(e.OriginalSource as DependencyObject); if (border?.Tag is not TopicCardTag tag) return; e.Handled = true; switch (tag.Action) { case "preset" or "custom_preset" when tag.Preset != null: SelectTopic(tag.Preset); break; case "etc": EmptyState.Visibility = Visibility.Collapsed; InputBox.Focus(); break; case "add": ShowCustomPresetDialog(); break; } } private void TopicPanel_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e) { var border = FindAncestorWithTag(e.OriginalSource as DependencyObject); if (border?.Tag is not TopicCardTag tag) return; if (tag.Action == "custom_preset" && tag.Preset != null) { e.Handled = true; ShowCustomPresetContextMenu(border, tag.Preset); } } /// 프리셋 대화 주제 버튼을 동적으로 생성합니다. private void BuildTopicButtons() { _lastHoveredTopicBorder = null; TopicButtonPanel.Children.Clear(); TopicButtonPanel.Visibility = Visibility.Visible; if (TopicPresetScrollViewer != null) TopicPresetScrollViewer.Visibility = Visibility.Visible; if (_activeTab == "Cowork" || _activeTab == "Code") { if (EmptyStateTitle != null) EmptyStateTitle.Text = _activeTab == "Code" ? "코드 작업을 입력하세요" : "작업 유형을 선택하세요"; if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code" ? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다" : "에이전트가 상세한 데이터를 작성합니다"; } else { if (EmptyStateTitle != null) EmptyStateTitle.Text = "대화 주제를 선택하세요"; if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다"; } if (_activeTab == "Code") { TopicButtonPanel.Visibility = Visibility.Collapsed; if (TopicPresetScrollViewer != null) TopicPresetScrollViewer.Visibility = Visibility.Collapsed; return; } var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets); var cardBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; var cardHoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; var cardBorder = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB"); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; foreach (var preset in presets) { var buttonColor = BrushFromHex(preset.Color); var border = new Border { Background = cardBackground, BorderBrush = cardBorder, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(14, 14, 14, 12), Margin = new Thickness(6, 6, 6, 8), Cursor = Cursors.Hand, Width = 148, Height = 124, ClipToBounds = true, }; var contentGrid = new Grid(); var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; var iconCircle = new Border { Width = 34, Height = 34, CornerRadius = new CornerRadius(17), Background = new SolidColorBrush(((SolidColorBrush)buttonColor).Color) { Opacity = 0.15 }, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 9), }; var iconBlock = new TextBlock { Text = preset.Symbol, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 15, Foreground = buttonColor, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; iconCircle.Child = iconBlock; stack.Children.Add(iconCircle); stack.Children.Add(new TextBlock { Text = preset.Label, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = primaryText, HorizontalAlignment = HorizontalAlignment.Center, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap, MaxWidth = 112, }); contentGrid.Children.Add(stack); if (preset.IsCustom) { var badge = new Border { Width = 16, Height = 16, CornerRadius = new CornerRadius(4), Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)), HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(2, 2, 0, 0), }; badge.Child = new TextBlock { Text = "\uE710", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = buttonColor, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; contentGrid.Children.Add(badge); } border.Child = contentGrid; // A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장 border.Tag = new TopicCardTag { Action = preset.IsCustom ? "custom_preset" : "preset", Preset = preset, NormalBackground = cardBackground, HoverBackground = cardHoverBackground, NormalBorderBrush = cardBorder, }; TopicButtonPanel.Children.Add(border); } var etcColor = BrushFromHex("#6B7280"); var etcBorder = new Border { Background = cardBackground, BorderBrush = cardBorder, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(14, 14, 14, 12), Margin = new Thickness(6, 6, 6, 8), Cursor = Cursors.Hand, Width = 148, Height = 124, ClipToBounds = true, }; var etcGrid = new Grid(); var etcStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; var etcIconCircle = new Border { Width = 34, Height = 34, CornerRadius = new CornerRadius(17), Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 }, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 9), }; etcIconCircle.Child = new TextBlock { Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 15, Foreground = etcColor, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; etcStack.Children.Add(etcIconCircle); etcStack.Children.Add(new TextBlock { Text = "기타", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = primaryText, HorizontalAlignment = HorizontalAlignment.Center, TextAlignment = TextAlignment.Center, }); etcGrid.Children.Add(etcStack); etcBorder.Child = etcGrid; etcBorder.Tag = new TopicCardTag { Action = "etc", NormalBackground = cardBackground, HoverBackground = cardHoverBackground, NormalBorderBrush = cardBorder, }; TopicButtonPanel.Children.Add(etcBorder); var addBorder = new Border { Background = Brushes.Transparent, BorderBrush = cardBorder, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(14, 14, 14, 12), Margin = new Thickness(6, 6, 6, 8), Cursor = Cursors.Hand, Width = 148, Height = 124, ClipToBounds = true, }; var addGrid = new Grid(); var addStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; addStack.Children.Add(new TextBlock { Text = "\uE710", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 18, Foreground = secondaryText, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8, 0, 8), }); addStack.Children.Add(new TextBlock { Text = "프리셋 추가", FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, HorizontalAlignment = HorizontalAlignment.Center, }); addGrid.Children.Add(addStack); addBorder.Child = addGrid; addBorder.Tag = new TopicCardTag { Action = "add", NormalBackground = Brushes.Transparent, HoverBackground = cardHoverBackground, NormalBorderBrush = cardBorder, }; TopicButtonPanel.Children.Add(addBorder); UpdateTopicPresetScrollMode(); } private void UpdateTopicPresetScrollMode() { if (TopicPresetScrollViewer == null || TopicButtonPanel == null) return; Dispatcher.BeginInvoke(new Action(() => { if (TopicPresetScrollViewer == null || TopicButtonPanel == null) return; TopicPresetScrollViewer.UpdateLayout(); var shouldScroll = TopicPresetScrollViewer.ExtentHeight > TopicPresetScrollViewer.ViewportHeight + 1; TopicPresetScrollViewer.VerticalScrollBarVisibility = shouldScroll ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; TopicPresetScrollViewer.Padding = shouldScroll ? new Thickness(0, 2, 6, 0) : new Thickness(0, 2, 0, 0); }), System.Windows.Threading.DispatcherPriority.Loaded); } private void ShowCustomPresetDialog(CustomPresetEntry? existing = null) { var dialog = new CustomPresetDialog( existingName: existing?.Label ?? "", existingDesc: existing?.Description ?? "", existingPrompt: existing?.SystemPrompt ?? "", existingColor: existing?.Color ?? "#6366F1", existingSymbol: existing?.Symbol ?? "\uE713", existingTab: existing?.Tab ?? _activeTab) { Owner = this, }; if (dialog.ShowDialog() != true) return; if (existing != null) { existing.Label = dialog.PresetName; existing.Description = dialog.PresetDescription; existing.SystemPrompt = dialog.PresetSystemPrompt; existing.Color = dialog.PresetColor; existing.Symbol = dialog.PresetSymbol; existing.Tab = dialog.PresetTab; } else { _settings.Settings.Llm.CustomPresets.Add(new CustomPresetEntry { Label = dialog.PresetName, Description = dialog.PresetDescription, SystemPrompt = dialog.PresetSystemPrompt, Color = dialog.PresetColor, Symbol = dialog.PresetSymbol, Tab = dialog.PresetTab, }); } _settings.Save(); BuildTopicButtons(); } private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset) { if (anchor == null || preset.CustomId == null) return; var popup = new Popup { PlacementTarget = anchor, Placement = PlacementMode.Bottom, StaysOpen = false, AllowsTransparency = true, }; var menuBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var menuBorder = new Border { Background = menuBackground, CornerRadius = new CornerRadius(10), BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(4), MinWidth = 120, Effect = new System.Windows.Media.Effects.DropShadowEffect { BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black, }, }; var stack = new StackPanel(); var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText); editItem.MouseLeftButtonDown += (_, _) => { popup.IsOpen = false; var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(item => item.Id == preset.CustomId); if (entry != null) ShowCustomPresetDialog(entry); }; stack.Children.Add(editItem); var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44))); deleteItem.MouseLeftButtonDown += (_, _) => { popup.IsOpen = false; var result = CustomMessageBox.Show( $"'{preset.Label}' 프리셋을 삭제하시겠습니까?", "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); if (result != MessageBoxResult.Yes) return; _settings.Settings.Llm.CustomPresets.RemoveAll(item => item.Id == preset.CustomId); _settings.Save(); BuildTopicButtons(); }; stack.Children.Add(deleteItem); menuBorder.Child = stack; popup.Child = menuBorder; popup.IsOpen = true; } private Border CreateContextMenuItem(string icon, string label, Brush foreground) { var item = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(6), Padding = new Thickness(10, 6, 14, 6), Cursor = Cursors.Hand, }; var stack = new StackPanel { Orientation = Orientation.Horizontal }; stack.Children.Add(new TextBlock { Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 13, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), }); stack.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, }); item.Child = stack; var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; item.MouseEnter += (sender, _) => { if (sender is Border hovered) hovered.Background = hoverBackground; }; item.MouseLeave += (sender, _) => { if (sender is Border hovered) hovered.Background = Brushes.Transparent; }; return item; } private Border CreateContextMenuItem(string icon, string label, Brush foreground, Brush secondaryForeground) => CreateContextMenuItem(icon, label, foreground); private void SelectTopic(Services.TopicPreset preset) { bool hasConversation; bool hasMessages; lock (_convLock) { hasConversation = _currentConversation != null; hasMessages = _currentConversation?.Messages.Count > 0; } var hasInput = !string.IsNullOrEmpty(InputBox.Text); if (!hasConversation) StartNewConversation(); lock (_convLock) { if (_currentConversation == null) return; var session = ChatSession; if (session != null) { _currentConversation = session.UpdateConversationMetadata(_activeTab, conversation => { conversation.SystemCommand = preset.SystemPrompt; conversation.Category = preset.Category; }, _storage); } else { _currentConversation.SystemCommand = preset.SystemPrompt; _currentConversation.Category = preset.Category; } } UpdateCategoryLabel(); SaveConversationSettings(); RefreshConversationList(); UpdateSelectedPresetGuide(); if (EmptyState != null) EmptyState.Visibility = Visibility.Collapsed; InputBox.Focus(); if (!string.IsNullOrEmpty(preset.Placeholder)) { _promptCardPlaceholder = preset.Placeholder; if (!hasMessages && !hasInput) ShowPlaceholder(); } if (hasMessages || hasInput) ShowToast($"프리셋 변경 · {preset.Label}"); if (_activeTab == "Cowork") BuildBottomBar(); } }