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 AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 대화 목록 ──────────────────────────────────────────────────────── public void RefreshConversationList() { var metas = _storage.LoadAllMeta(); // 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원) var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", Llm.CustomPresets) .Concat(Services.PresetService.GetByTabWithCustom("Code", Llm.CustomPresets)) .Concat(Services.PresetService.GetByTabWithCustom("Chat", Llm.CustomPresets)); var presetMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var p in allPresets) presetMap.TryAdd(p.Category, (p.Symbol, p.Color)); var items = metas.Select(c => { var symbol = ChatCategory.GetSymbol(c.Category); var color = ChatCategory.GetColor(c.Category); // ChatCategory 기본값이면 프리셋에서 검색 if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General) { if (presetMap.TryGetValue(c.Category, out var pm)) { symbol = pm.Symbol; color = pm.Color; } } return new ConversationMeta { Id = c.Id, Title = c.Title, Pinned = c.Pinned, Category = c.Category, Symbol = symbol, ColorHex = color, Tab = string.IsNullOrEmpty(c.Tab) ? "Chat" : c.Tab, UpdatedAtText = FormatDate(c.UpdatedAt), UpdatedAt = c.UpdatedAt, Preview = c.Preview ?? "", ParentId = c.ParentId, }; }).ToList(); // 탭 필터 — 현재 활성 탭의 대화만 표시 items = items.Where(i => i.Tab == _activeTab).ToList(); // 카테고리 필터 적용 if (_selectedCategory == "__custom__") { // 커스텀 프리셋으로 만든 대화만 표시 var customCats = Llm.CustomPresets .Select(c => $"custom_{c.Id}").ToHashSet(); items = items.Where(i => customCats.Contains(i.Category)).ToList(); } else if (!string.IsNullOrEmpty(_selectedCategory)) items = items.Where(i => i.Category == _selectedCategory).ToList(); // 검색 필터 (제목 + 내용 미리보기) var search = SearchBox?.Text?.Trim() ?? ""; if (!string.IsNullOrEmpty(search)) items = items.Where(i => i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) || i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) ).ToList(); RenderConversationList(items); } private const int ConversationPageSize = 50; private List? _pendingConversations; private void RenderConversationList(List items) { ConversationPanel.Children.Clear(); _pendingConversations = null; if (items.Count == 0) { var empty = new TextBlock { Text = "대화가 없습니다", FontSize = 12, Foreground = (ThemeResourceHelper.Secondary(this)), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; ConversationPanel.Children.Add(empty); return; } // 오늘 / 이전 그룹 분리 var today = DateTime.Today; var todayItems = items.Where(i => i.UpdatedAt.Date == today).ToList(); var olderItems = items.Where(i => i.UpdatedAt.Date < today).ToList(); var allOrdered = new List<(string Group, ConversationMeta Item)>(); foreach (var item in todayItems) allOrdered.Add(("오늘", item)); foreach (var item in olderItems) allOrdered.Add(("이전", item)); // 첫 페이지만 렌더링 var firstPage = allOrdered.Take(ConversationPageSize).ToList(); string? lastGroup = null; foreach (var (group, item) in firstPage) { if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; } AddConversationItem(item); } // 나머지가 있으면 "더 보기" 버튼 if (allOrdered.Count > ConversationPageSize) { _pendingConversations = items; AddLoadMoreButton(allOrdered.Count - ConversationPageSize); } } private void AddLoadMoreButton(int remaining) { var secondaryText = ThemeResourceHelper.Secondary(this); var accentBrush = ThemeResourceHelper.Accent(this); var btn = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, Padding = new Thickness(8, 10, 8, 10), Margin = new Thickness(6, 4, 6, 4), HorizontalAlignment = HorizontalAlignment.Stretch, }; var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; sp.Children.Add(new TextBlock { Text = $"더 보기 ({remaining}개 남음)", FontSize = 12, Foreground = accentBrush, HorizontalAlignment = HorizontalAlignment.Center, }); btn.Child = sp; btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); }; btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; btn.MouseLeftButtonUp += (_, _) => { // 전체 목록 렌더링 if (_pendingConversations != null) { var all = _pendingConversations; _pendingConversations = null; ConversationPanel.Children.Clear(); var today = DateTime.Today; var todayItems = all.Where(i => i.UpdatedAt.Date == today).ToList(); var olderItems = all.Where(i => i.UpdatedAt.Date < today).ToList(); if (todayItems.Count > 0) { AddGroupHeader("오늘"); foreach (var item in todayItems) AddConversationItem(item); } if (olderItems.Count > 0) { AddGroupHeader("이전"); foreach (var item in olderItems) AddConversationItem(item); } } }; ConversationPanel.Children.Add(btn); } private void AddGroupHeader(string text) { var header = new TextBlock { Text = text, FontSize = 11, FontWeight = FontWeights.SemiBold, Foreground = (ThemeResourceHelper.Secondary(this)), Margin = new Thickness(8, 12, 0, 4) }; ConversationPanel.Children.Add(header); } private void AddConversationItem(ConversationMeta item) { var isSelected = false; lock (_convLock) isSelected = _currentConversation?.Id == item.Id; var isBranch = !string.IsNullOrEmpty(item.ParentId); var border = new Border { Background = isSelected ? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)) : Brushes.Transparent, CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 8, 10, 8), Margin = isBranch ? new Thickness(16, 1, 0, 1) : new Thickness(0, 1, 0, 1), Cursor = Cursors.Hand }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상) Brush iconBrush; if (item.Pinned) iconBrush = Brushes.Orange; else { try { iconBrush = ThemeResourceHelper.HexBrush(item.ColorHex); } catch (Exception) { iconBrush = ThemeResourceHelper.Accent(this); } } var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol; if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색 var icon = new TextBlock { Text = iconText, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 13, Foreground = iconBrush, VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(icon, 0); grid.Children.Add(icon); // 제목 + 날짜 (선택 시 약간 밝게) var titleColor = ThemeResourceHelper.Primary(this); var dateColor = ThemeResourceHelper.HintFg(this); var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; var title = new TextBlock { Text = item.Title, FontSize = 12.5, Foreground = titleColor, TextTrimming = TextTrimming.CharacterEllipsis }; var date = new TextBlock { Text = item.UpdatedAtText, FontSize = 10, Foreground = dateColor, Margin = new Thickness(0, 2, 0, 0) }; stack.Children.Add(title); stack.Children.Add(date); Grid.SetColumn(stack, 1); grid.Children.Add(stack); // 카테고리 변경 버튼 (호버 시 표시) var catBtn = new Button { Content = new TextBlock { Text = "\uE70F", // Edit FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 10, Foreground = (ThemeResourceHelper.Secondary(this)) }, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed, Padding = new Thickness(4), ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경" }; var capturedId = item.Id; catBtn.Click += (_, _) => ShowConversationMenu(capturedId); Grid.SetColumn(catBtn, 2); grid.Children.Add(catBtn); // 선택 시 좌측 액센트 바 if (isSelected) { border.BorderBrush = ThemeResourceHelper.Accent(this); border.BorderThickness = new Thickness(2, 0, 0, 0); } border.Child = grid; // 호버 이벤트 — 배경 + 미세 확대 border.RenderTransformOrigin = new Point(0.5, 0.5); border.RenderTransform = new ScaleTransform(1, 1); var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)); border.MouseEnter += (_, _) => { if (!isSelected) border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); catBtn.Visibility = Visibility.Visible; var st = border.RenderTransform as ScaleTransform; st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120))); }; border.MouseLeave += (_, _) => { if (!isSelected) border.Background = Brushes.Transparent; catBtn.Visibility = Visibility.Collapsed; var st = border.RenderTransform as ScaleTransform; st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150))); }; // 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환 border.MouseLeftButtonDown += (_, _) => { try { if (isSelected) { // 이미 선택된 대화 → 제목 편집 모드 EnterTitleEditMode(title, item.Id, titleColor); return; } // 스트리밍 중이면 취소 if (_isStreaming) { _streamCts?.Cancel(); _cursorTimer.Stop(); _typingTimer.Stop(); _elapsedTimer.Stop(); _activeStreamText = null; _elapsedLabel = null; _isStreaming = false; } var conv = _storage.Load(item.Id); if (conv != null) { // Tab 보정 if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; lock (_convLock) _currentConversation = conv; _tabConversationId[_activeTab] = conv.Id; UpdateChatTitle(); RenderMessages(); RefreshConversationList(); } } catch (Exception ex) { LogService.Error($"대화 전환 오류: {ex.Message}"); } }; // 우클릭 → 대화 관리 메뉴 바로 표시 border.MouseRightButtonUp += (_, me) => { me.Handled = true; // 선택되지 않은 대화를 우클릭하면 먼저 선택 if (!isSelected) { var conv = _storage.Load(item.Id); if (conv != null) { if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab; lock (_convLock) _currentConversation = conv; _tabConversationId[_activeTab] = conv.Id; UpdateChatTitle(); RenderMessages(); } } // Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기 Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), System.Windows.Threading.DispatcherPriority.Input); }; ConversationPanel.Children.Add(border); } // ─── 대화 제목 인라인 편집 ──────────────────────────────────────────── private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor) { try { // titleTb가 이미 부모에서 분리된 경우(편집 중) 무시 var parent = titleTb.Parent as StackPanel; if (parent == null) return; var idx = parent.Children.IndexOf(titleTb); if (idx < 0) return; var editBox = new TextBox { Text = titleTb.Text, FontSize = 12.5, Foreground = titleColor, Background = Brushes.Transparent, BorderBrush = ThemeResourceHelper.Accent(this), BorderThickness = new Thickness(0, 0, 0, 1), CaretBrush = titleColor, Padding = new Thickness(0), Margin = new Thickness(0), }; // 안전하게 자식 교체: 먼저 제거 후 삽입 parent.Children.RemoveAt(idx); parent.Children.Insert(idx, editBox); var committed = false; void CommitEdit() { if (committed) return; committed = true; var newTitle = editBox.Text.Trim(); if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text; titleTb.Text = newTitle; // editBox가 아직 parent에 있는지 확인 후 교체 try { var currentIdx = parent.Children.IndexOf(editBox); if (currentIdx >= 0) { parent.Children.RemoveAt(currentIdx); parent.Children.Insert(currentIdx, titleTb); } } catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } var conv = _storage.Load(conversationId); if (conv != null) { conv.Title = newTitle; _storage.Save(conv); lock (_convLock) { if (_currentConversation?.Id == conversationId) _currentConversation.Title = newTitle; } UpdateChatTitle(); } } void CancelEdit() { if (committed) return; committed = true; try { var currentIdx = parent.Children.IndexOf(editBox); if (currentIdx >= 0) { parent.Children.RemoveAt(currentIdx); parent.Children.Insert(currentIdx, titleTb); } } catch (Exception) { /* 부모가 이미 해제된 경우 무시 */ } } editBox.KeyDown += (_, ke) => { if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); } if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); } }; editBox.LostFocus += (_, _) => CommitEdit(); editBox.Focus(); editBox.SelectAll(); } catch (Exception ex) { LogService.Error($"제목 편집 오류: {ex.Message}"); } } // ─── 카테고리 변경 팝업 ────────────────────────────────────────────── private void ShowConversationMenu(string conversationId) { var conv = _storage.Load(conversationId); var isPinned = conv?.Pinned ?? false; var primaryText = ThemeResourceHelper.Primary(this); var secondaryText = ThemeResourceHelper.Secondary(this); var hoverBg = ThemeResourceHelper.HoverBg(this); var (popup, stack) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200); // 메뉴 항목 헬퍼 — PopupMenuHelper.MenuItem 래핑 (아이콘 색상 개별 지정) Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick) => PopupMenuHelper.MenuItem(text, primaryText, hoverBg, () => { popup.IsOpen = false; onClick(); }, icon: icon, iconColor: iconColor, fontSize: 12.5); Border CreateSeparator() => PopupMenuHelper.Separator(); // 고정/해제 stack.Children.Add(CreateMenuItem( isPinned ? "\uE77A" : "\uE718", isPinned ? "고정 해제" : "상단 고정", ThemeResourceHelper.Accent(this), () => { var c = _storage.Load(conversationId); if (c != null) { c.Pinned = !c.Pinned; _storage.Save(c); lock (_convLock) { if (_currentConversation?.Id == conversationId) _currentConversation.Pinned = c.Pinned; } RefreshConversationList(); } })); // 이름 변경 stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () => { // 대화 목록에서 해당 항목 찾아서 편집 모드 진입 foreach (UIElement child in ConversationPanel.Children) { if (child is Border b && b.Child is Grid g) { foreach (UIElement gc in g.Children) { if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) { // title과 매칭 if (conv != null && tb.Text == conv.Title) { var titleColor = ThemeResourceHelper.Primary(this); EnterTitleEditMode(tb, conversationId, titleColor); return; } } } } } })); // Cowork/Code 탭: 작업 유형 읽기 전용 표시 if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null) { var catKey = conv.Category ?? ChatCategory.General; // ChatCategory 또는 프리셋에서 아이콘/라벨 검색 string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280"; var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey); if (chatCat != default && chatCat.Key != ChatCategory.General) { catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color; } else { var preset = Services.PresetService.GetByTabWithCustom(_activeTab, Llm.CustomPresets) .FirstOrDefault(p => p.Category == catKey); if (preset != null) { catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color; } } stack.Children.Add(CreateSeparator()); var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) }; try { var catBrush = ThemeResourceHelper.HexBrush(catColor); infoSp.Children.Add(new TextBlock { Text = catSymbol, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, Foreground = catBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }); infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, }); } catch (Exception) { infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText }); } stack.Children.Add(infoSp); } // Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요) var showCategorySection = _activeTab == "Chat"; if (showCategorySection) { stack.Children.Add(CreateSeparator()); // 분류 헤더 stack.Children.Add(new TextBlock { Text = "분류 변경", FontSize = 10.5, Foreground = secondaryText, Margin = new Thickness(10, 4, 0, 4), FontWeight = FontWeights.SemiBold, }); var currentCategory = conv?.Category ?? ChatCategory.General; var accentBrush = ThemeResourceHelper.Accent(this); foreach (var (key, label, symbol, color) in ChatCategory.All) { var capturedKey = key; var isCurrentCat = capturedKey == currentCategory; // 카테고리 항목 (체크 표시 포함) var catItem = 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 catGrid = new Grid(); catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); var catIcon = new TextBlock { Text = symbol, FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(catIcon, 0); catGrid.Children.Add(catIcon); var catText = new TextBlock { Text = label, FontSize = 12.5, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal, }; Grid.SetColumn(catText, 1); catGrid.Children.Add(catText); if (isCurrentCat) { var check = CreateSimpleCheck(accentBrush, 14); Grid.SetColumn(check, 2); catGrid.Children.Add(check); } catItem.Child = catGrid; catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; }; catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; catItem.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; var c = _storage.Load(conversationId); if (c != null) { c.Category = capturedKey; var preset = Services.PresetService.GetByCategory(capturedKey); if (preset != null) c.SystemCommand = preset.SystemPrompt; _storage.Save(c); lock (_convLock) { if (_currentConversation?.Id == conversationId) { _currentConversation.Category = capturedKey; if (preset != null) _currentConversation.SystemCommand = preset.SystemPrompt; } } // 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신 bool isCurrent; lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; } if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder)) { _promptCardPlaceholder = preset.Placeholder; UpdateWatermarkVisibility(); if (string.IsNullOrEmpty(InputBox.Text)) { InputWatermark.Text = preset.Placeholder; InputWatermark.Visibility = Visibility.Visible; } } else if (isCurrent) { ClearPromptCardPlaceholder(); } RefreshConversationList(); } }; stack.Children.Add(catItem); } } // end showCategorySection stack.Children.Add(CreateSeparator()); // 삭제 stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => { var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question); if (result != MessageBoxResult.Yes) return; _storage.Delete(conversationId); lock (_convLock) { if (_currentConversation?.Id == conversationId) { _currentConversation = null; MessagePanel.Children.Clear(); EmptyState.Visibility = Visibility.Visible; UpdateChatTitle(); } } RefreshConversationList(); })); popup.IsOpen = true; } // ─── 검색 ──────────────────────────────────────────────────────────── private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) { RefreshConversationList(); } private static string FormatDate(DateTime dt) { var diff = DateTime.Now - dt; if (diff.TotalMinutes < 1) return "방금 전"; if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전"; if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전"; if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전"; return dt.ToString("MM/dd"); } private void UpdateChatTitle() { lock (_convLock) { ChatTitle.Text = _currentConversation?.Title ?? ""; } } }