From 39e07dd947889ad6556d1556839d6a79e739ec69 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 21:12:38 +0900 Subject: [PATCH] =?UTF-8?q?[Phase48]=20=EB=8C=80=ED=98=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=ED=95=A0=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=204=EC=B0=A8=20=E2=80=94=204=EA=B0=9C=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=ED=8C=8C=EC=85=9C=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 분할 대상 및 결과 ### ChatWindow.Controls.cs (595줄 → 372줄) - ChatWindow.TabSwitching.cs (232줄, 신규): _activeTab 필드, _tabConversationId 필드 TabChat/Cowork/Code_Checked, UpdateTabUI, BtnPlanMode_Click, UpdatePlanModeUI, SwitchToTabConversation, SaveCurrentTabConversationId, StopStreamingIfActive ### ChatWindow.SlashCommands.cs (579줄 → 406줄) - ChatWindow.DropActions.cs (160줄, 신규): DropActions 딕셔너리, CodeExtensions, DataExtensions, _dropActionPopup 필드, ShowDropActionMenu() 메서드 ### WorkflowAnalyzerWindow.Charts.cs (667줄 → 397줄) - WorkflowAnalyzerWindow.Timeline.cs (281줄, 신규): CreateTimelineNode, GetEventVisual, CreateBadge, ShowDetail, UpdateSummaryCards, FormatMs, Truncate, 윈도우 이벤트 핸들러, WndProc ### SkillGalleryWindow.xaml.cs (631줄 → ~430줄) - SkillGalleryWindow.SkillDetail.cs (197줄, 신규): ShowSkillDetail() 메서드 전체 (스킬 상세 보기 팝업 — 메타정보·프롬프트 미리보기·Action 버튼) ## 빌드 결과: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- src/AxCopilot/Views/ChatWindow.Controls.cs | 223 -------------- src/AxCopilot/Views/ChatWindow.DropActions.cs | 183 ++++++++++++ .../Views/ChatWindow.SlashCommands.cs | 173 ----------- .../Views/ChatWindow.TabSwitching.cs | 232 +++++++++++++++ .../Views/SkillGalleryWindow.SkillDetail.cs | 210 +++++++++++++ .../Views/SkillGalleryWindow.xaml.cs | 200 ------------- .../Views/WorkflowAnalyzerWindow.Charts.cs | 270 ----------------- .../Views/WorkflowAnalyzerWindow.Timeline.cs | 281 ++++++++++++++++++ 8 files changed, 906 insertions(+), 866 deletions(-) create mode 100644 src/AxCopilot/Views/ChatWindow.DropActions.cs create mode 100644 src/AxCopilot/Views/ChatWindow.TabSwitching.cs create mode 100644 src/AxCopilot/Views/SkillGalleryWindow.SkillDetail.cs create mode 100644 src/AxCopilot/Views/WorkflowAnalyzerWindow.Timeline.cs diff --git a/src/AxCopilot/Views/ChatWindow.Controls.cs b/src/AxCopilot/Views/ChatWindow.Controls.cs index 9423c89..9ce104b 100644 --- a/src/AxCopilot/Views/ChatWindow.Controls.cs +++ b/src/AxCopilot/Views/ChatWindow.Controls.cs @@ -369,227 +369,4 @@ public partial class ChatWindow MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘 } private void BtnClose_Click(object sender, RoutedEventArgs e) => Close(); - - // ─── 탭 전환 ────────────────────────────────────────────────────────── - - private string _activeTab = "Chat"; - - private void SaveCurrentTabConversationId() - { - lock (_convLock) - { - if (_currentConversation != null && _currentConversation.Messages.Count > 0) - { - _tabConversationId[_activeTab] = _currentConversation.Id; - // 탭 전환 시 현재 대화를 즉시 저장 (스트리밍 중이어도 진행 중인 내용 보존) - try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - } - } - // 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용) - SaveLastConversations(); - } - - /// 탭 전환 전 스트리밍 중이면 즉시 중단합니다. - private void StopStreamingIfActive() - { - if (!_isStreaming) return; - // 스트리밍 중단 - _streamCts?.Cancel(); - _cursorTimer.Stop(); - _elapsedTimer.Stop(); - _typingTimer.Stop(); - StopRainbowGlow(); - HideStickyProgress(); - _activeStreamText = null; - _elapsedLabel = null; - _cachedStreamContent = ""; - _isStreaming = false; - BtnSend.IsEnabled = true; - BtnStop.Visibility = Visibility.Collapsed; - BtnPause.Visibility = Visibility.Collapsed; - PauseIcon.Text = "\uE769"; // 리셋 - BtnSend.Visibility = Visibility.Visible; - _streamCts?.Dispose(); - _streamCts = null; - SetStatusIdle(); - } - - private void TabChat_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Chat") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Chat"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - private void TabCowork_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Cowork") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Cowork"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - private void TabCode_Checked(object sender, RoutedEventArgs e) - { - if (_activeTab == "Code") return; - StopStreamingIfActive(); - SaveCurrentTabConversationId(); - _activeTab = "Code"; - _selectedCategory = ""; UpdateCategoryLabel(); - UpdateTabUI(); - UpdatePlanModeUI(); - } - - /// 탭별로 마지막으로 활성화된 대화 ID를 기억. - private readonly Dictionary _tabConversationId = new() - { - ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, - }; - - private void UpdateTabUI() - { - // 폴더 바는 Cowork/Code 탭에서만 표시 - if (FolderBar != null) - FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; - - // 탭별 입력 안내 문구 - if (InputWatermark != null) - { - InputWatermark.Text = _activeTab switch - { - "Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)", - "Code" => "코드 관련 작업을 요청하세요...", - _ => _promptCardPlaceholder, - }; - } - - // 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용) - ApplyTabDefaultPermission(); - - // 포맷/디자인 드롭다운은 Cowork 탭에서만 표시 - if (_activeTab == "Cowork") - { - BuildBottomBar(); - if (Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } - } - else if (_activeTab == "Code") - { - // Code 탭: 언어 선택기 + 파일 탐색기 - BuildCodeBottomBar(); - if (Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } - } - else - { - MoodIconPanel.Children.Clear(); - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; - if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; - } - - // 탭별 프리셋 버튼 재구성 - BuildTopicButtons(); - - // 현재 대화를 해당 탭 대화로 전환 - SwitchToTabConversation(); - - // Cowork/Code 탭 전환 시 팁 표시 - ShowRandomTip(); - } - - private void BtnPlanMode_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - // 3단 순환: off → auto → always → off - Llm.PlanMode = Llm.PlanMode switch - { - "auto" => "always", - "always" => "off", - _ => "auto" - }; - _settings.Save(); - UpdatePlanModeUI(); - } - - private void UpdatePlanModeUI() - { - var planMode = Llm.PlanMode ?? "off"; - if (PlanModeValue == null) return; - - PlanModeValue.Text = planMode switch - { - "auto" => "Auto", - "always" => "Always", - _ => "Off" - }; - var isActive = planMode != "off"; - var activeBrush = ThemeResourceHelper.Accent(this); - var secondaryBrush = ThemeResourceHelper.Secondary(this); - if (PlanModeIcon != null) PlanModeIcon.Foreground = isActive ? activeBrush : secondaryBrush; - if (PlanModeLabel != null) PlanModeLabel.Foreground = isActive ? activeBrush : secondaryBrush; - if (BtnPlanMode != null) - BtnPlanMode.Background = isActive - ? new SolidColorBrush(Color.FromArgb(0x1A, 0x4B, 0x5E, 0xFC)) - : Brushes.Transparent; - } - - private void SwitchToTabConversation() - { - // 이전 탭의 대화 저장 - lock (_convLock) - { - if (_currentConversation != null && _currentConversation.Messages.Count > 0) - { - try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } - } - } - - // 현재 탭에 기억된 대화가 있으면 복원 - var savedId = _tabConversationId.GetValueOrDefault(_activeTab); - if (!string.IsNullOrEmpty(savedId)) - { - var conv = _storage.Load(savedId); - if (conv != null) - { - if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; - lock (_convLock) _currentConversation = conv; - MessagePanel.Children.Clear(); - foreach (var msg in conv.Messages) - AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); - EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; - UpdateChatTitle(); - RefreshConversationList(); - UpdateFolderBar(); - return; - } - } - - // 기억된 대화가 없으면 새 대화 - lock (_convLock) - { - _currentConversation = new ChatConversation { Tab = _activeTab }; - var workFolder = Llm.WorkFolder; - if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") - _currentConversation.WorkFolder = workFolder; - } - MessagePanel.Children.Clear(); - EmptyState.Visibility = Visibility.Visible; - _attachedFiles.Clear(); - RefreshAttachedFilesUI(); - UpdateChatTitle(); - RefreshConversationList(); - UpdateFolderBar(); - } } diff --git a/src/AxCopilot/Views/ChatWindow.DropActions.cs b/src/AxCopilot/Views/ChatWindow.DropActions.cs new file mode 100644 index 0000000..ed8e567 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.DropActions.cs @@ -0,0 +1,183 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 드래그 앤 드롭 AI 액션 팝업 ───────────────────────────────────── + + private static readonly Dictionary> DropActions = new(StringComparer.OrdinalIgnoreCase) + { + ["code"] = + [ + ("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."), + ("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."), + ("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."), + ("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."), + ], + ["document"] = + [ + ("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."), + ("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."), + ("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."), + ], + ["data"] = + [ + ("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."), + ("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."), + ("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"), + ], + ["image"] = + [ + ("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."), + ("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."), + ], + }; + + private static readonly HashSet CodeExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" }; + private static readonly HashSet DataExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" }; + // ImageExtensions는 이미지 첨부 영역에서 정의됨 — 재사용 + + private Popup? _dropActionPopup; + + private void ShowDropActionMenu(string[] files) + { + // 파일 유형 판별 + var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant(); + string category; + if (CodeExtensions.Contains(ext)) category = "code"; + else if (DataExtensions.Contains(ext)) category = "data"; + else if (ImageExtensions.Contains(ext)) category = "image"; + else category = "document"; + + var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"]; + + // 팝업 생성 + _dropActionPopup?.SetValue(Popup.IsOpenProperty, false); + + var panel = new StackPanel(); + // 헤더 + var header = new TextBlock + { + Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" 외 {files.Length - 1}개" : "")}", + FontSize = 11, + Foreground = ThemeResourceHelper.Secondary(this), + Margin = new Thickness(12, 8, 12, 6), + }; + panel.Children.Add(header); + + // 액션 항목 + var hoverBrush = ThemeResourceHelper.HoverBg(this); + var accentBrush = ThemeResourceHelper.Accent(this); + var textBrush = ThemeResourceHelper.Primary(this); + + foreach (var (label, icon, prompt) in actions) + { + var capturedPrompt = prompt; + var row = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(12, 7, 12, 7), + Margin = new Thickness(4, 1, 4, 1), + Cursor = Cursors.Hand, + }; + var stack = new StackPanel { Orientation = Orientation.Horizontal }; + stack.Children.Add(new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + stack.Children.Add(new TextBlock + { + Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center, + }); + row.Child = stack; + + row.MouseEnter += (_, _) => row.Background = hoverBrush; + row.MouseLeave += (_, _) => row.Background = Brushes.Transparent; + row.MouseLeftButtonUp += (_, _) => + { + if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; + foreach (var f in files) AddAttachedFile(f); + InputBox.Text = capturedPrompt; + InputBox.CaretIndex = InputBox.Text.Length; + InputBox.Focus(); + if (Llm.DragDropAutoSend) + _ = SendMessageAsync(); + }; + panel.Children.Add(row); + } + + // "첨부만" 항목 + var attachOnly = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(6), + Padding = new Thickness(12, 7, 12, 7), + Margin = new Thickness(4, 1, 4, 1), + Cursor = Cursors.Hand, + }; + var attachStack = new StackPanel { Orientation = Orientation.Horizontal }; + attachStack.Children.Add(new TextBlock + { + Text = "\uE723", + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 13, Foreground = ThemeResourceHelper.Secondary(this), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + attachStack.Children.Add(new TextBlock + { + Text = "첨부만", FontSize = 13, + Foreground = ThemeResourceHelper.Secondary(this), + VerticalAlignment = VerticalAlignment.Center, + }); + attachOnly.Child = attachStack; + attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush; + attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent; + attachOnly.MouseLeftButtonUp += (_, _) => + { + if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; + foreach (var f in files) AddAttachedFile(f); + InputBox.Focus(); + }; + panel.Children.Add(attachOnly); + + var container = new Border + { + Background = ThemeResourceHelper.Background(this), + CornerRadius = new CornerRadius(12), + BorderBrush = ThemeResourceHelper.Border(this), + BorderThickness = new Thickness(1), + Padding = new Thickness(4, 4, 4, 6), + Child = panel, + MinWidth = 200, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, + }, + }; + + _dropActionPopup = new Popup + { + PlacementTarget = InputBorder, + Placement = PlacementMode.Top, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + Child = container, + }; + _dropActionPopup.IsOpen = true; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs index 3e37a74..e1b0f62 100644 --- a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs +++ b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs @@ -403,177 +403,4 @@ public partial class ChatWindow return (null, input); } - - // ─── 드래그 앤 드롭 AI 액션 팝업 ───────────────────────────────────── - - private static readonly Dictionary> DropActions = new(StringComparer.OrdinalIgnoreCase) - { - ["code"] = - [ - ("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."), - ("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."), - ("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."), - ("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."), - ], - ["document"] = - [ - ("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."), - ("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."), - ("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."), - ], - ["data"] = - [ - ("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."), - ("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."), - ("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"), - ], - ["image"] = - [ - ("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."), - ("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."), - ], - }; - - private static readonly HashSet CodeExtensions = new(StringComparer.OrdinalIgnoreCase) - { ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" }; - private static readonly HashSet DataExtensions = new(StringComparer.OrdinalIgnoreCase) - { ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" }; - // ImageExtensions는 이미지 첨부 영역에서 정의됨 — 재사용 - - private Popup? _dropActionPopup; - - private void ShowDropActionMenu(string[] files) - { - // 파일 유형 판별 - var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant(); - string category; - if (CodeExtensions.Contains(ext)) category = "code"; - else if (DataExtensions.Contains(ext)) category = "data"; - else if (ImageExtensions.Contains(ext)) category = "image"; - else category = "document"; - - var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"]; - - // 팝업 생성 - _dropActionPopup?.SetValue(Popup.IsOpenProperty, false); - - var panel = new StackPanel(); - // 헤더 - var header = new TextBlock - { - Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" 외 {files.Length - 1}개" : "")}", - FontSize = 11, - Foreground = ThemeResourceHelper.Secondary(this), - Margin = new Thickness(12, 8, 12, 6), - }; - panel.Children.Add(header); - - // 액션 항목 - var hoverBrush = ThemeResourceHelper.HoverBg(this); - var accentBrush = ThemeResourceHelper.Accent(this); - var textBrush = ThemeResourceHelper.Primary(this); - - foreach (var (label, icon, prompt) in actions) - { - var capturedPrompt = prompt; - var row = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(12, 7, 12, 7), - Margin = new Thickness(4, 1, 4, 1), - Cursor = Cursors.Hand, - }; - var stack = new StackPanel { Orientation = Orientation.Horizontal }; - stack.Children.Add(new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, Foreground = accentBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - stack.Children.Add(new TextBlock - { - Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold, - Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center, - }); - row.Child = stack; - - row.MouseEnter += (_, _) => row.Background = hoverBrush; - row.MouseLeave += (_, _) => row.Background = Brushes.Transparent; - row.MouseLeftButtonUp += (_, _) => - { - if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; - foreach (var f in files) AddAttachedFile(f); - InputBox.Text = capturedPrompt; - InputBox.CaretIndex = InputBox.Text.Length; - InputBox.Focus(); - if (Llm.DragDropAutoSend) - _ = SendMessageAsync(); - }; - panel.Children.Add(row); - } - - // "첨부만" 항목 - var attachOnly = new Border - { - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(6), - Padding = new Thickness(12, 7, 12, 7), - Margin = new Thickness(4, 1, 4, 1), - Cursor = Cursors.Hand, - }; - var attachStack = new StackPanel { Orientation = Orientation.Horizontal }; - attachStack.Children.Add(new TextBlock - { - Text = "\uE723", - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 13, Foreground = ThemeResourceHelper.Secondary(this), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }); - attachStack.Children.Add(new TextBlock - { - Text = "첨부만", FontSize = 13, - Foreground = ThemeResourceHelper.Secondary(this), - VerticalAlignment = VerticalAlignment.Center, - }); - attachOnly.Child = attachStack; - attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush; - attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent; - attachOnly.MouseLeftButtonUp += (_, _) => - { - if (_dropActionPopup != null) _dropActionPopup.IsOpen = false; - foreach (var f in files) AddAttachedFile(f); - InputBox.Focus(); - }; - panel.Children.Add(attachOnly); - - var container = new Border - { - Background = ThemeResourceHelper.Background(this), - CornerRadius = new CornerRadius(12), - BorderBrush = ThemeResourceHelper.Border(this), - BorderThickness = new Thickness(1), - Padding = new Thickness(4, 4, 4, 6), - Child = panel, - MinWidth = 200, - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, - }, - }; - - _dropActionPopup = new Popup - { - PlacementTarget = InputBorder, - Placement = PlacementMode.Top, - StaysOpen = false, - AllowsTransparency = true, - PopupAnimation = PopupAnimation.Fade, - Child = container, - }; - _dropActionPopup.IsOpen = true; - } } diff --git a/src/AxCopilot/Views/ChatWindow.TabSwitching.cs b/src/AxCopilot/Views/ChatWindow.TabSwitching.cs new file mode 100644 index 0000000..e2a4e07 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.TabSwitching.cs @@ -0,0 +1,232 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 탭 전환 ────────────────────────────────────────────────────────── + + private string _activeTab = "Chat"; + + /// 탭별로 마지막으로 활성화된 대화 ID를 기억. + private readonly Dictionary _tabConversationId = new() + { + ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, + }; + + private void SaveCurrentTabConversationId() + { + lock (_convLock) + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + { + _tabConversationId[_activeTab] = _currentConversation.Id; + // 탭 전환 시 현재 대화를 즉시 저장 (스트리밍 중이어도 진행 중인 내용 보존) + try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + } + } + // 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용) + SaveLastConversations(); + } + + /// 탭 전환 전 스트리밍 중이면 즉시 중단합니다. + private void StopStreamingIfActive() + { + if (!_isStreaming) return; + // 스트리밍 중단 + _streamCts?.Cancel(); + _cursorTimer.Stop(); + _elapsedTimer.Stop(); + _typingTimer.Stop(); + StopRainbowGlow(); + HideStickyProgress(); + _activeStreamText = null; + _elapsedLabel = null; + _cachedStreamContent = ""; + _isStreaming = false; + BtnSend.IsEnabled = true; + BtnStop.Visibility = Visibility.Collapsed; + BtnPause.Visibility = Visibility.Collapsed; + PauseIcon.Text = "\uE769"; // 리셋 + BtnSend.Visibility = Visibility.Visible; + _streamCts?.Dispose(); + _streamCts = null; + SetStatusIdle(); + } + + private void TabChat_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Chat") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Chat"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + private void TabCowork_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Cowork") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Cowork"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + private void TabCode_Checked(object sender, RoutedEventArgs e) + { + if (_activeTab == "Code") return; + StopStreamingIfActive(); + SaveCurrentTabConversationId(); + _activeTab = "Code"; + _selectedCategory = ""; UpdateCategoryLabel(); + UpdateTabUI(); + UpdatePlanModeUI(); + } + + private void UpdateTabUI() + { + // 폴더 바는 Cowork/Code 탭에서만 표시 + if (FolderBar != null) + FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; + + // 탭별 입력 안내 문구 + if (InputWatermark != null) + { + InputWatermark.Text = _activeTab switch + { + "Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)", + "Code" => "코드 관련 작업을 요청하세요...", + _ => _promptCardPlaceholder, + }; + } + + // 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용) + ApplyTabDefaultPermission(); + + // 포맷/디자인 드롭다운은 Cowork 탭에서만 표시 + if (_activeTab == "Cowork") + { + BuildBottomBar(); + if (Llm.ShowFileBrowser && FileBrowserPanel != null) + { + FileBrowserPanel.Visibility = Visibility.Visible; + BuildFileTree(); + } + } + else if (_activeTab == "Code") + { + // Code 탭: 언어 선택기 + 파일 탐색기 + BuildCodeBottomBar(); + if (Llm.ShowFileBrowser && FileBrowserPanel != null) + { + FileBrowserPanel.Visibility = Visibility.Visible; + BuildFileTree(); + } + } + else + { + MoodIconPanel.Children.Clear(); + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; + } + + // 탭별 프리셋 버튼 재구성 + BuildTopicButtons(); + + // 현재 대화를 해당 탭 대화로 전환 + SwitchToTabConversation(); + + // Cowork/Code 탭 전환 시 팁 표시 + ShowRandomTip(); + } + + private void BtnPlanMode_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // 3단 순환: off → auto → always → off + Llm.PlanMode = Llm.PlanMode switch + { + "auto" => "always", + "always" => "off", + _ => "auto" + }; + _settings.Save(); + UpdatePlanModeUI(); + } + + private void UpdatePlanModeUI() + { + var planMode = Llm.PlanMode ?? "off"; + if (PlanModeValue == null) return; + + PlanModeValue.Text = planMode switch + { + "auto" => "Auto", + "always" => "Always", + _ => "Off" + }; + var isActive = planMode != "off"; + var activeBrush = ThemeResourceHelper.Accent(this); + var secondaryBrush = ThemeResourceHelper.Secondary(this); + if (PlanModeIcon != null) PlanModeIcon.Foreground = isActive ? activeBrush : secondaryBrush; + if (PlanModeLabel != null) PlanModeLabel.Foreground = isActive ? activeBrush : secondaryBrush; + if (BtnPlanMode != null) + BtnPlanMode.Background = isActive + ? new SolidColorBrush(Color.FromArgb(0x1A, 0x4B, 0x5E, 0xFC)) + : Brushes.Transparent; + } + + private void SwitchToTabConversation() + { + // 이전 탭의 대화 저장 + lock (_convLock) + { + if (_currentConversation != null && _currentConversation.Messages.Count > 0) + { + try { _storage.Save(_currentConversation); } catch (Exception) { /* 대화 저장 실패 — UI 차단 방지 */ } + } + } + + // 현재 탭에 기억된 대화가 있으면 복원 + var savedId = _tabConversationId.GetValueOrDefault(_activeTab); + if (!string.IsNullOrEmpty(savedId)) + { + var conv = _storage.Load(savedId); + if (conv != null) + { + if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; + lock (_convLock) _currentConversation = conv; + MessagePanel.Children.Clear(); + foreach (var msg in conv.Messages) + AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg); + EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + return; + } + } + + // 기억된 대화가 없으면 새 대화 + lock (_convLock) + { + _currentConversation = new Models.ChatConversation { Tab = _activeTab }; + var workFolder = Llm.WorkFolder; + if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat") + _currentConversation.WorkFolder = workFolder; + } + MessagePanel.Children.Clear(); + EmptyState.Visibility = Visibility.Visible; + _attachedFiles.Clear(); + RefreshAttachedFilesUI(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + } +} diff --git a/src/AxCopilot/Views/SkillGalleryWindow.SkillDetail.cs b/src/AxCopilot/Views/SkillGalleryWindow.SkillDetail.cs new file mode 100644 index 0000000..78aff17 --- /dev/null +++ b/src/AxCopilot/Views/SkillGalleryWindow.SkillDetail.cs @@ -0,0 +1,210 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class SkillGalleryWindow +{ + // ─── 스킬 상세 보기 팝업 ─────────────────────────────────────────────── + + private void ShowSkillDetail(SkillDefinition skill) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + var borderBr = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var popup = new Window + { + Title = $"/{skill.Name}", + Width = 580, + Height = 480, + WindowStyle = WindowStyle.None, + AllowsTransparency = true, + Background = Brushes.Transparent, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, + }; + + var outerBorder = new Border + { + Background = bgBrush, + CornerRadius = new CornerRadius(12), + BorderBrush = borderBr, + BorderThickness = new Thickness(1, 1, 1, 1), + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 20, + ShadowDepth = 4, + Opacity = 0.3, + Color = Colors.Black, + }, + }; + + var mainGrid = new Grid(); + mainGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) }); + mainGrid.RowDefinitions.Add(new RowDefinition()); + + // ── 타이틀바 ── + var titleBar = new Border + { + CornerRadius = new CornerRadius(12, 12, 0, 0), + Background = itemBg, + }; + titleBar.MouseLeftButtonDown += (_, e) => + { + var pos = e.GetPosition(popup); + if (pos.X > popup.ActualWidth - 50) return; + popup.DragMove(); + }; + + var titleGrid = new Grid { Margin = new Thickness(16, 0, 12, 0) }; + var titleLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + titleLeft.Children.Add(new TextBlock + { + Text = skill.Icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 14, + Foreground = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 1, 8, 0), + }); + titleLeft.Children.Add(new TextBlock + { + Text = $"/{skill.Name} {skill.Label}", + FontSize = 14, + FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + titleGrid.Children.Add(titleLeft); + + var closeBtn = new Border + { + Width = 28, Height = 28, + CornerRadius = new CornerRadius(6), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + closeBtn.Child = new TextBlock + { + Text = "\uE8BB", + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = subBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + closeBtn.MouseEnter += (s, _) => ((Border)s).Background = + new SolidColorBrush(Color.FromArgb(0x33, 0xFF, 0x44, 0x44)); + closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + closeBtn.MouseLeftButtonUp += (_, _) => popup.Close(); + titleGrid.Children.Add(closeBtn); + + titleBar.Child = titleGrid; + Grid.SetRow(titleBar, 0); + + // ── 본문: 스킬 정보 + 프롬프트 미리보기 ── + var body = new ScrollViewer + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(20, 16, 20, 16), + }; + + var bodyPanel = new StackPanel(); + + // 메타 정보 + var metaGrid = new Grid { Margin = new Thickness(0, 0, 0, 14) }; + metaGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); + metaGrid.ColumnDefinitions.Add(new ColumnDefinition()); + + void AddMetaRow(string label, string value, int row) + { + if (string.IsNullOrEmpty(value)) return; + metaGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var lb = new TextBlock + { + Text = label, FontSize = 11.5, Foreground = subBrush, + Margin = new Thickness(0, 2, 0, 2), + }; + Grid.SetRow(lb, row); Grid.SetColumn(lb, 0); + metaGrid.Children.Add(lb); + + var vb = new TextBlock + { + Text = value, FontSize = 11.5, Foreground = fgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 2), + }; + Grid.SetRow(vb, row); Grid.SetColumn(vb, 1); + metaGrid.Children.Add(vb); + } + + var metaRow = 0; + AddMetaRow("명령어", $"/{skill.Name}", metaRow++); + AddMetaRow("라벨", skill.Label, metaRow++); + AddMetaRow("설명", skill.Description, metaRow++); + if (!string.IsNullOrEmpty(skill.Requires)) + AddMetaRow("런타임", skill.Requires, metaRow++); + if (!string.IsNullOrEmpty(skill.AllowedTools)) + AddMetaRow("허용 도구", skill.AllowedTools, metaRow++); + AddMetaRow("상태", skill.IsAvailable ? "✓ 사용 가능" : $"✗ {skill.UnavailableHint}", metaRow++); + AddMetaRow("경로", skill.FilePath, metaRow++); + + bodyPanel.Children.Add(metaGrid); + + // 구분선 + bodyPanel.Children.Add(new Border + { + Height = 1, + Background = borderBr, + Margin = new Thickness(0, 4, 0, 12), + }); + + // 프롬프트 내용 미리보기 + bodyPanel.Children.Add(new TextBlock + { + Text = "시스템 프롬프트 (미리보기)", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = subBrush, + Margin = new Thickness(0, 0, 0, 6), + }); + + var promptBorder = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 10, 12, 10), + }; + var promptText = skill.SystemPrompt; + if (promptText.Length > 2000) + promptText = promptText[..2000] + "\n\n... (이하 생략)"; + + promptBorder.Child = new TextBlock + { + Text = promptText, + FontSize = 11.5, + FontFamily = ThemeResourceHelper.ConsolasCode, + Foreground = fgBrush, + TextWrapping = TextWrapping.Wrap, + Opacity = 0.85, + }; + bodyPanel.Children.Add(promptBorder); + + body.Content = bodyPanel; + Grid.SetRow(body, 1); + + mainGrid.Children.Add(titleBar); + mainGrid.Children.Add(body); + outerBorder.Child = mainGrid; + popup.Content = outerBorder; + + popup.ShowDialog(); + } +} diff --git a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs index 0fbda14..f61ce85 100644 --- a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs @@ -428,204 +428,4 @@ public partial class SkillGalleryWindow : Window btn.MouseLeftButtonUp += (_, _) => action(); return btn; } - - // ─── 스킬 상세 보기 팝업 ─────────────────────────────────────────────── - - private void ShowSkillDetail(SkillDefinition skill) - { - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; - var borderBr = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - - var popup = new Window - { - Title = $"/{skill.Name}", - Width = 580, - Height = 480, - WindowStyle = WindowStyle.None, - AllowsTransparency = true, - Background = Brushes.Transparent, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, - }; - - var outerBorder = new Border - { - Background = bgBrush, - CornerRadius = new CornerRadius(12), - BorderBrush = borderBr, - BorderThickness = new Thickness(1, 1, 1, 1), - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 20, - ShadowDepth = 4, - Opacity = 0.3, - Color = Colors.Black, - }, - }; - - var mainGrid = new Grid(); - mainGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) }); - mainGrid.RowDefinitions.Add(new RowDefinition()); - - // ── 타이틀바 ── - var titleBar = new Border - { - CornerRadius = new CornerRadius(12, 12, 0, 0), - Background = itemBg, - }; - titleBar.MouseLeftButtonDown += (_, e) => - { - var pos = e.GetPosition(popup); - if (pos.X > popup.ActualWidth - 50) return; - popup.DragMove(); - }; - - var titleGrid = new Grid { Margin = new Thickness(16, 0, 12, 0) }; - var titleLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; - titleLeft.Children.Add(new TextBlock - { - Text = skill.Icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 14, - Foreground = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 1, 8, 0), - }); - titleLeft.Children.Add(new TextBlock - { - Text = $"/{skill.Name} {skill.Label}", - FontSize = 14, - FontWeight = FontWeights.SemiBold, - Foreground = fgBrush, - VerticalAlignment = VerticalAlignment.Center, - }); - titleGrid.Children.Add(titleLeft); - - var closeBtn = new Border - { - Width = 28, Height = 28, - CornerRadius = new CornerRadius(6), - Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - }; - closeBtn.Child = new TextBlock - { - Text = "\uE8BB", - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, - Foreground = subBrush, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }; - closeBtn.MouseEnter += (s, _) => ((Border)s).Background = - new SolidColorBrush(Color.FromArgb(0x33, 0xFF, 0x44, 0x44)); - closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; - closeBtn.MouseLeftButtonUp += (_, _) => popup.Close(); - titleGrid.Children.Add(closeBtn); - - titleBar.Child = titleGrid; - Grid.SetRow(titleBar, 0); - - // ── 본문: 스킬 정보 + 프롬프트 미리보기 ── - var body = new ScrollViewer - { - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(20, 16, 20, 16), - }; - - var bodyPanel = new StackPanel(); - - // 메타 정보 - var metaGrid = new Grid { Margin = new Thickness(0, 0, 0, 14) }; - metaGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); - metaGrid.ColumnDefinitions.Add(new ColumnDefinition()); - - void AddMetaRow(string label, string value, int row) - { - if (string.IsNullOrEmpty(value)) return; - metaGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - var lb = new TextBlock - { - Text = label, FontSize = 11.5, Foreground = subBrush, - Margin = new Thickness(0, 2, 0, 2), - }; - Grid.SetRow(lb, row); Grid.SetColumn(lb, 0); - metaGrid.Children.Add(lb); - - var vb = new TextBlock - { - Text = value, FontSize = 11.5, Foreground = fgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 2, 0, 2), - }; - Grid.SetRow(vb, row); Grid.SetColumn(vb, 1); - metaGrid.Children.Add(vb); - } - - var metaRow = 0; - AddMetaRow("명령어", $"/{skill.Name}", metaRow++); - AddMetaRow("라벨", skill.Label, metaRow++); - AddMetaRow("설명", skill.Description, metaRow++); - if (!string.IsNullOrEmpty(skill.Requires)) - AddMetaRow("런타임", skill.Requires, metaRow++); - if (!string.IsNullOrEmpty(skill.AllowedTools)) - AddMetaRow("허용 도구", skill.AllowedTools, metaRow++); - AddMetaRow("상태", skill.IsAvailable ? "✓ 사용 가능" : $"✗ {skill.UnavailableHint}", metaRow++); - AddMetaRow("경로", skill.FilePath, metaRow++); - - bodyPanel.Children.Add(metaGrid); - - // 구분선 - bodyPanel.Children.Add(new Border - { - Height = 1, - Background = borderBr, - Margin = new Thickness(0, 4, 0, 12), - }); - - // 프롬프트 내용 미리보기 - bodyPanel.Children.Add(new TextBlock - { - Text = "시스템 프롬프트 (미리보기)", - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = subBrush, - Margin = new Thickness(0, 0, 0, 6), - }); - - var promptBorder = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 10, 12, 10), - }; - var promptText = skill.SystemPrompt; - if (promptText.Length > 2000) - promptText = promptText[..2000] + "\n\n... (이하 생략)"; - - promptBorder.Child = new TextBlock - { - Text = promptText, - FontSize = 11.5, - FontFamily = ThemeResourceHelper.ConsolasCode, - Foreground = fgBrush, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.85, - }; - bodyPanel.Children.Add(promptBorder); - - body.Content = bodyPanel; - Grid.SetRow(body, 1); - - mainGrid.Children.Add(titleBar); - mainGrid.Children.Add(body); - outerBorder.Child = mainGrid; - popup.Content = outerBorder; - - popup.ShowDialog(); - } } diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs index 8b93ab0..aa8cdb3 100644 --- a/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs +++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs @@ -394,274 +394,4 @@ public partial class WorkflowAnalyzerWindow }); return sp; } - - // ─── 타임라인 노드 생성 ──────────────────────────────────────── - - private Border CreateTimelineNode(AgentEvent evt) - { - var (icon, iconColor, label) = GetEventVisual(evt); - - var node = new Border - { - Background = Brushes.Transparent, - Margin = new Thickness(0, 1, 0, 1), - Padding = new Thickness(8, 6, 8, 6), - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Tag = evt, - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - // 아이콘 원 - var iconBorder = new Border - { - Width = 22, Height = 22, - CornerRadius = new CornerRadius(11), - Background = new SolidColorBrush(iconColor), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Top, - Margin = new Thickness(0, 2, 0, 0), - }; - iconBorder.Child = new TextBlock - { - Text = icon, - FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 10, - Foreground = Brushes.White, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(iconBorder, 0); - grid.Children.Add(iconBorder); - - // 타임라인 세로 선 - var line = new Rectangle - { - Width = 2, - Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Stretch, - Opacity = 0.4, - }; - Grid.SetColumn(line, 1); - grid.Children.Add(line); - - // 내용 - var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) }; - - var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; - headerPanel.Children.Add(new TextBlock - { - Text = label, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, - VerticalAlignment = VerticalAlignment.Center, - }); - - if (evt.ElapsedMs > 0) - { - headerPanel.Children.Add(new TextBlock - { - Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms", - FontSize = 10, - Foreground = evt.ElapsedMs > 3000 - ? Brushes.OrangeRed - : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - VerticalAlignment = VerticalAlignment.Center, - }); - } - - headerPanel.Children.Add(new TextBlock - { - Text = $" {evt.Timestamp:HH:mm:ss}", - FontSize = 9, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.6, - }); - content.Children.Add(headerPanel); - - if (!string.IsNullOrEmpty(evt.Summary)) - { - content.Children.Add(new TextBlock - { - Text = Truncate(evt.Summary, 120), - FontSize = 11, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 2, 0, 0), - LineHeight = 16, - }); - } - - if (evt.InputTokens > 0 || evt.OutputTokens > 0) - { - var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; - tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6")); - tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981")); - content.Children.Add(tokenPanel); - } - - Grid.SetColumn(content, 2); - grid.Children.Add(content); - node.Child = grid; - - var hoverBrush = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - node.MouseEnter += (_, _) => node.Background = hoverBrush; - node.MouseLeave += (_, _) => node.Background = Brushes.Transparent; - node.MouseLeftButtonUp += (_, _) => ShowDetail(evt); - - return node; - } - - private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt) - { - return evt.Type switch - { - AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"), - AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"), - AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"), - AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"), - AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName), - AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"), - AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"), - AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"), - AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"), - AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"), - _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"), - }; - } - - private static Border CreateBadge(string text, string colorHex) - { - var color = (Color)ColorConverter.ConvertFromString(colorHex); - return new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(5, 1, 5, 1), - Margin = new Thickness(0, 0, 4, 0), - Child = new TextBlock - { - Text = text, - FontSize = 9, - Foreground = new SolidColorBrush(color), - FontWeight = FontWeights.SemiBold, - }, - }; - } - - // ─── 상세 패널 ───────────────────────────────────────────── - - private void ShowDetail(AgentEvent evt) - { - DetailPanel.Visibility = Visibility.Visible; - DetailPanel.Tag = evt; - var (_, color, label) = GetEventVisual(evt); - - DetailTitle.Text = label; - DetailBadge.Text = evt.Success ? "성공" : "실패"; - DetailBadge.Background = new SolidColorBrush(evt.Success - ? Color.FromRgb(0x34, 0xD3, 0x99) - : Color.FromRgb(0xF8, 0x71, 0x71)); - - var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}"; - if (evt.ElapsedMs > 0) - meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}"; - if (evt.InputTokens > 0 || evt.OutputTokens > 0) - meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}"; - if (evt.Iteration > 0) - meta += $" | 반복 #{evt.Iteration}"; - DetailMeta.Text = meta; - - var contentText = evt.Summary ?? ""; - if (!string.IsNullOrEmpty(evt.ToolInput)) - contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}"; - if (!string.IsNullOrEmpty(evt.FilePath)) - contentText += $"\n파일: {evt.FilePath}"; - DetailContent.Text = contentText; - } - - // ─── 요약 카드 업데이트 ────────────────────────────────────── - - private void UpdateSummaryCards() - { - var elapsed = (DateTime.Now - _startTime).TotalSeconds; - CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s"; - CardIterations.Text = _maxIteration.ToString(); - var totalTokens = _totalInputTokens + _totalOutputTokens; - CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString(); - CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString(); - CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString(); - CardToolCalls.Text = _totalToolCalls.ToString(); - } - - // ─── 유틸리티 ──────────────────────────────────────────────── - - private static string FormatMs(long ms) - => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms"; - - // ─── 윈도우 이벤트 ──────────────────────────────────────────── - - private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지) - var src = e.OriginalSource as DependencyObject; - while (src != null && src != sender) - { - if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear") - return; - src = VisualTreeHelper.GetParent(src); - } - if (e.ClickCount == 1) DragMove(); - } - - private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide(); - private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized; - private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset(); - - private void TitleBtn_MouseEnter(object sender, MouseEventArgs e) - { - if (sender is Border b) - b.Background = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - } - - private void TitleBtn_MouseLeave(object sender, MouseEventArgs e) - { - if (sender is Border b) - b.Background = Brushes.Transparent; - } - - private static string Truncate(string text, int maxLen) - => text.Length <= maxLen ? text : text[..maxLen] + "…"; - - private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - if (msg == WM_NCHITTEST) - { - var pt = PointFromScreen(new Point( - (short)(lParam.ToInt32() & 0xFFFF), - (short)((lParam.ToInt32() >> 16) & 0xFFFF))); - const double grip = 8; - var w = ActualWidth; - var h = ActualHeight; - - if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } - if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } - if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } - if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } - if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } - if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } - if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } - if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } - } - return IntPtr.Zero; - } } diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.Timeline.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Timeline.cs new file mode 100644 index 0000000..51cdbbe --- /dev/null +++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Timeline.cs @@ -0,0 +1,281 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class WorkflowAnalyzerWindow +{ + // ─── 타임라인 노드 생성 ──────────────────────────────────────── + + private Border CreateTimelineNode(AgentEvent evt) + { + var (icon, iconColor, label) = GetEventVisual(evt); + + var node = new Border + { + Background = Brushes.Transparent, + Margin = new Thickness(0, 1, 0, 1), + Padding = new Thickness(8, 6, 8, 6), + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Tag = evt, + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + // 아이콘 원 + var iconBorder = new Border + { + Width = 22, Height = 22, + CornerRadius = new CornerRadius(11), + Background = new SolidColorBrush(iconColor), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 2, 0, 0), + }; + iconBorder.Child = new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconBorder, 0); + grid.Children.Add(iconBorder); + + // 타임라인 세로 선 + var line = new Rectangle + { + Width = 2, + Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Stretch, + Opacity = 0.4, + }; + Grid.SetColumn(line, 1); + grid.Children.Add(line); + + // 내용 + var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) }; + + var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; + headerPanel.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + }); + + if (evt.ElapsedMs > 0) + { + headerPanel.Children.Add(new TextBlock + { + Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms", + FontSize = 10, + Foreground = evt.ElapsedMs > 3000 + ? Brushes.OrangeRed + : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }); + } + + headerPanel.Children.Add(new TextBlock + { + Text = $" {evt.Timestamp:HH:mm:ss}", + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.6, + }); + content.Children.Add(headerPanel); + + if (!string.IsNullOrEmpty(evt.Summary)) + { + content.Children.Add(new TextBlock + { + Text = Truncate(evt.Summary, 120), + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 0, 0), + LineHeight = 16, + }); + } + + if (evt.InputTokens > 0 || evt.OutputTokens > 0) + { + var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; + tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6")); + tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981")); + content.Children.Add(tokenPanel); + } + + Grid.SetColumn(content, 2); + grid.Children.Add(content); + node.Child = grid; + + var hoverBrush = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + node.MouseEnter += (_, _) => node.Background = hoverBrush; + node.MouseLeave += (_, _) => node.Background = Brushes.Transparent; + node.MouseLeftButtonUp += (_, _) => ShowDetail(evt); + + return node; + } + + private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt) + { + return evt.Type switch + { + AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"), + AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"), + AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"), + AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"), + AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName), + AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"), + AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"), + AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"), + AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"), + AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"), + _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"), + }; + } + + private static Border CreateBadge(string text, string colorHex) + { + var color = (Color)ColorConverter.ConvertFromString(colorHex); + return new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(5, 1, 5, 1), + Margin = new Thickness(0, 0, 4, 0), + Child = new TextBlock + { + Text = text, + FontSize = 9, + Foreground = new SolidColorBrush(color), + FontWeight = FontWeights.SemiBold, + }, + }; + } + + // ─── 상세 패널 ───────────────────────────────────────────── + + private void ShowDetail(AgentEvent evt) + { + DetailPanel.Visibility = Visibility.Visible; + DetailPanel.Tag = evt; + var (_, color, label) = GetEventVisual(evt); + + DetailTitle.Text = label; + DetailBadge.Text = evt.Success ? "성공" : "실패"; + DetailBadge.Background = new SolidColorBrush(evt.Success + ? Color.FromRgb(0x34, 0xD3, 0x99) + : Color.FromRgb(0xF8, 0x71, 0x71)); + + var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}"; + if (evt.ElapsedMs > 0) + meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}"; + if (evt.InputTokens > 0 || evt.OutputTokens > 0) + meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}"; + if (evt.Iteration > 0) + meta += $" | 반복 #{evt.Iteration}"; + DetailMeta.Text = meta; + + var contentText = evt.Summary ?? ""; + if (!string.IsNullOrEmpty(evt.ToolInput)) + contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}"; + if (!string.IsNullOrEmpty(evt.FilePath)) + contentText += $"\n파일: {evt.FilePath}"; + DetailContent.Text = contentText; + } + + // ─── 요약 카드 업데이트 ────────────────────────────────────── + + private void UpdateSummaryCards() + { + var elapsed = (DateTime.Now - _startTime).TotalSeconds; + CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s"; + CardIterations.Text = _maxIteration.ToString(); + var totalTokens = _totalInputTokens + _totalOutputTokens; + CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString(); + CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString(); + CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString(); + CardToolCalls.Text = _totalToolCalls.ToString(); + } + + // ─── 유틸리티 ──────────────────────────────────────────────── + + private static string FormatMs(long ms) + => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms"; + + private static string Truncate(string text, int maxLen) + => text.Length <= maxLen ? text : text[..maxLen] + "…"; + + // ─── 윈도우 이벤트 ──────────────────────────────────────────── + + private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지) + var src = e.OriginalSource as DependencyObject; + while (src != null && src != sender) + { + if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear") + return; + src = VisualTreeHelper.GetParent(src); + } + if (e.ClickCount == 1) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide(); + private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized; + private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset(); + + private void TitleBtn_MouseEnter(object sender, MouseEventArgs e) + { + if (sender is Border b) + b.Background = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + } + + private void TitleBtn_MouseLeave(object sender, MouseEventArgs e) + { + if (sender is Border b) + b.Background = Brushes.Transparent; + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_NCHITTEST) + { + var pt = PointFromScreen(new Point( + (short)(lParam.ToInt32() & 0xFFFF), + (short)((lParam.ToInt32() >> 16) & 0xFFFF))); + const double grip = 8; + var w = ActualWidth; + var h = ActualHeight; + + if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } + if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } + if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } + if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } + if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } + if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } + if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } + if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } + } + return IntPtr.Zero; + } +}