diff --git a/README.md b/README.md index 1bde8fa..63c27a0 100644 --- a/README.md +++ b/README.md @@ -1178,3 +1178,7 @@ MIT License - 업데이트: 2026-04-06 09:58 (KST) - footer/composer 구조 개선의 다음 단계로 Git 브랜치 팝업과 footer 요약 helper를 [ChatWindow.GitBranchPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.GitBranchPresentation.cs) 로 분리했다. [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)는 이제 폴더 바 상태와 선택된 프리셋 안내처럼 footer의 현재 상태 동기화 책임만 남긴다. - [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)를 개발 루틴 문서로 강화했다. Chat/Cowork/Code 공통 프롬프트 세트에 `blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise` 실패 분류를 붙여, runtime/transcript 변경 뒤 어떤 묶음을 확인해야 하는지 바로 쓸 수 있게 정리했다. +- 업데이트: 2026-04-06 10:07 (KST) + - 프리셋 카드와 주제 선택 흐름을 [ChatWindow.TopicPresetPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs) 로 분리했다. `BuildTopicButtons`, `ShowCustomPresetDialog`, `ShowCustomPresetContextMenu`, `SelectTopic`이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 프리셋 UI와 대화 orchestration의 책임 경계가 더 분명해졌다. +- 업데이트: 2026-04-06 10:18 (KST) + - 좌측 대화 목록 렌더를 [ChatWindow.ConversationListPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs) 로 분리했다. `RefreshConversationList`, `RenderConversationList`, `AddLoadMoreButton`, `BuildConversationSpotlightItems`, `AddGroupHeader`, `AddConversationItem`이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 메인 창은 transcript/runtime orchestration에 더 집중하고 목록 UI는 별도 presentation surface에서 관리되게 정리했다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index dbd5232..a0f181c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4921,3 +4921,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - Document update: 2026-04-06 09:58 (KST) - Rewrote `docs/AX_AGENT_REGRESSION_PROMPTS.md` into a repeatable regression ritual with explicit failure classes (`blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise`) and required prompt bundles per change area so runtime/transcript work can be validated consistently. - Document update: 2026-04-06 10:07 (KST) - Split topic preset rendering and selection flow out of `ChatWindow.xaml.cs` into `ChatWindow.TopicPresetPresentation.cs`. Preset card creation, custom preset dialogs/context menus, and `SelectTopic(...)` metadata application now live in a dedicated partial. - Document update: 2026-04-06 10:07 (KST) - This keeps the main chat window more orchestration-focused and narrows the remaining maintainability work to small follow-up polish rather than any large structural split. +- Document update: 2026-04-06 10:18 (KST) - Split conversation list rendering out of `ChatWindow.xaml.cs` into `ChatWindow.ConversationListPresentation.cs`. Conversation meta filtering, spotlight calculation, grouped list rendering, load-more pagination, and sidebar conversation-row assembly now live in a dedicated partial. +- Document update: 2026-04-06 10:18 (KST) - This keeps the main chat window more focused on transcript/runtime orchestration while isolating sidebar list presentation for future UX tuning and parity work. diff --git a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs new file mode 100644 index 0000000..f7dc5dc --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private const int ConversationPageSize = 50; + private List? _pendingConversations; + + public void RefreshConversationList() + { + var metas = _storage.LoadAllMeta(); + var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets) + .Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets)) + .Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.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); + if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General) + { + if (presetMap.TryGetValue(c.Category, out var pm)) + { + symbol = pm.Symbol; + color = pm.Color; + } + } + + var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory); + return new ConversationMeta + { + Id = c.Id, + Title = c.Title, + Pinned = c.Pinned, + Category = c.Category, + Symbol = symbol, + ColorHex = color, + Tab = NormalizeTabName(c.Tab), + UpdatedAtText = FormatDate(c.UpdatedAt), + UpdatedAt = c.UpdatedAt, + Preview = c.Preview ?? "", + ParentId = c.ParentId, + AgentRunCount = runSummary.AgentRunCount, + FailedAgentRunCount = runSummary.FailedAgentRunCount, + LastAgentRunSummary = runSummary.LastAgentRunSummary, + LastFailedAt = runSummary.LastFailedAt, + LastCompletedAt = runSummary.LastCompletedAt, + WorkFolder = c.WorkFolder ?? "", + IsRunning = _currentConversation?.Id == c.Id + && !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId) + && !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase), + }; + }).ToList(); + + items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList(); + items = items.Where(i => + i.Pinned + || !string.IsNullOrWhiteSpace(i.ParentId) + || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(i.Preview) + || i.AgentRunCount > 0 + || i.FailedAgentRunCount > 0 + || !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + _failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0); + _runningConversationCount = items.Count(i => i.IsRunning); + _spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3); + UpdateConversationFailureFilterUi(); + UpdateConversationRunningFilterUi(); + UpdateConversationQuickStripUi(); + + if (_activeTab == "Cowork") + { + if (!string.IsNullOrEmpty(_selectedCategory)) + items = items.Where(i => string.Equals(i.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + else if (_activeTab == "Code") + { + if (!string.IsNullOrEmpty(_selectedCategory)) + items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + else + { + if (_selectedCategory == "__custom__") + { + var customCats = _settings.Settings.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) || + i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase) + ).ToList(); + } + + if (_runningOnlyFilter) + items = items.Where(i => i.IsRunning).ToList(); + + items = (_sortConversationsByRecent + ? items.OrderByDescending(i => i.Pinned) + .ThenByDescending(i => i.UpdatedAt) + .ThenByDescending(i => i.FailedAgentRunCount > 0) + .ThenByDescending(i => i.AgentRunCount) + : items.OrderByDescending(i => i.Pinned) + .ThenByDescending(i => i.FailedAgentRunCount > 0) + .ThenByDescending(i => i.AgentRunCount) + .ThenByDescending(i => i.UpdatedAt)) + .ToList(); + + RenderConversationList(items); + } + + private void RenderConversationList(List items) + { + ConversationPanel.Children.Clear(); + _pendingConversations = null; + + if (items.Count == 0) + { + var emptyText = _activeTab switch + { + "Cowork" => "Cowork 탭 대화가 없습니다", + "Code" => "Code 탭 대화가 없습니다", + _ => "Chat 탭 대화가 없습니다", + }; + var empty = new TextBlock + { + Text = emptyText, + FontSize = 12, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 20, 0, 0), + }; + ConversationPanel.Children.Add(empty); + return; + } + + var spotlightItems = BuildConversationSpotlightItems(items); + if (spotlightItems.Count > 0) + { + AddGroupHeader("집중 필요"); + foreach (var item in spotlightItems) + AddConversationItem(item); + + ConversationPanel.Children.Add(new Border + { + Height = 1, + Margin = new Thickness(10, 8, 10, 4), + Background = BrushFromHex("#E5E7EB"), + Opacity = 0.7, + }); + } + + var allOrdered = new List<(string Group, ConversationMeta Item)>(); + foreach (var item in items) + allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), 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 accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + 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 stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; + stack.Children.Add(new TextBlock + { + Text = $"더 보기 ({remaining}개 남음)", + FontSize = 12, + Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, + }); + btn.Child = stack; + 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) + return; + + var all = _pendingConversations; + _pendingConversations = null; + ConversationPanel.Children.Clear(); + + string? lastGroup = null; + foreach (var item in all) + { + var group = GetConversationDateGroup(item.UpdatedAt); + if (!string.Equals(lastGroup, group, StringComparison.Ordinal)) + { + AddGroupHeader(group); + lastGroup = group; + } + + AddConversationItem(item); + } + }; + ConversationPanel.Children.Add(btn); + } + + private static string GetConversationDateGroup(DateTime updatedAt) + { + var today = DateTime.Today; + var date = updatedAt.Date; + if (date == today) + return "오늘"; + if (date == today.AddDays(-1)) + return "어제"; + return "이전"; + } + + private List BuildConversationSpotlightItems(List items) + { + if (_failedOnlyFilter || _runningOnlyFilter) + return new List(); + + var search = SearchBox?.Text?.Trim() ?? ""; + if (!string.IsNullOrEmpty(search)) + return new List(); + + return items + .Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) + .OrderByDescending(i => i.FailedAgentRunCount) + .ThenByDescending(i => i.AgentRunCount) + .ThenByDescending(i => i.UpdatedAt) + .Take(3) + .ToList(); + } + + private void AddGroupHeader(string text) + { + var header = new TextBlock + { + Text = text, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(8, 10, 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(0x10, 0x4B, 0x5E, 0xFC)) + : Brushes.Transparent, + CornerRadius = new CornerRadius(5), + Padding = new Thickness(7, 4.5, 7, 4.5), + Margin = isBranch ? new Thickness(10, 1, 0, 1) : new Thickness(0, 1, 0, 1), + Cursor = Cursors.Hand, + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16) }); + 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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); } + catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; } + } + + 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 = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10.5, + Foreground = iconBrush, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(icon, 0); + grid.Children.Add(icon); + + var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray; + + var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var title = new TextBlock + { + Text = item.Title, + FontSize = 11.75, + FontWeight = isSelected ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = titleColor, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + var date = new TextBlock + { + Text = item.UpdatedAtText, + FontSize = 9, + Foreground = dateColor, + Margin = new Thickness(0, 1.5, 0, 0), + }; + stack.Children.Add(title); + stack.Children.Add(date); + + if (item.IsRunning) + { + stack.Children.Add(new TextBlock + { + Text = _appState.ActiveTasks.Count > 0 ? $"진행 중 {_appState.ActiveTasks.Count}" : "진행 중", + FontSize = 8.8, + FontWeight = FontWeights.Medium, + Foreground = BrushFromHex("#4F46E5"), + Margin = new Thickness(0, 1.5, 0, 0), + }); + } + + if (item.AgentRunCount > 0) + { + var runSummaryText = new TextBlock + { + Text = item.FailedAgentRunCount > 0 + ? $"실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 26)}" + : $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}", + FontSize = 8.9, + Foreground = item.FailedAgentRunCount > 0 + ? BrushFromHex("#B91C1C") + : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), + Margin = new Thickness(0, 1.5, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis, + }; + if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary)) + { + runSummaryText.ToolTip = item.FailedAgentRunCount > 0 + ? $"최근 실패 포함\n{item.LastAgentRunSummary}" + : item.LastAgentRunSummary; + } + stack.Children.Add(runSummaryText); + } + + Grid.SetColumn(stack, 1); + grid.Children.Add(stack); + + var catBtn = new Button + { + Content = new TextBlock + { + Text = "\uE70F", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Visibility = Visibility.Collapsed, + Width = 20, + Height = 20, + Padding = new Thickness(0), + Opacity = 0.72, + ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경", + }; + var capturedId = item.Id; + catBtn.Click += (_, _) => ShowConversationMenu(capturedId); + Grid.SetColumn(catBtn, 2); + grid.Children.Add(catBtn); + + if (isSelected) + { + border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + border.BorderThickness = new Thickness(1.25, 0, 0, 0); + } + + border.Child = grid; + + var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)); + border.MouseEnter += (_, _) => + { + if (!isSelected) + border.Background = hoverBg; + catBtn.Visibility = Visibility.Visible; + }; + border.MouseLeave += (_, _) => + { + if (!isSelected) + border.Background = Brushes.Transparent; + catBtn.Visibility = Visibility.Collapsed; + }; + + 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) + return; + + lock (_convLock) + { + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; + SyncTabConversationIdsFromSession(); + } + + SaveLastConversations(); + UpdateChatTitle(); + RenderMessages(); + RefreshConversationList(); + RefreshDraftQueueUi(); + } + catch (Exception ex) + { + LogService.Error($"대화 전환 오류: {ex.Message}"); + } + }; + + border.MouseRightButtonUp += (_, me) => + { + me.Handled = true; + if (!isSelected) + { + var conv = _storage.Load(item.Id); + if (conv != null) + { + lock (_convLock) + { + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; + SyncTabConversationIdsFromSession(); + } + SaveLastConversations(); + UpdateChatTitle(); + RenderMessages(); + RefreshDraftQueueUi(); + } + } + + Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input); + }; + + ConversationPanel.Children.Add(border); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 79024ff..77cf239 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -2084,517 +2084,6 @@ public partial class ChatWindow : Window // ─── 대화 목록 ──────────────────────────────────────────────────────── - public void RefreshConversationList() - { - var metas = _storage.LoadAllMeta(); - // 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원) - var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets) - .Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets)) - .Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.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; - } - } - var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory); - return new ConversationMeta - { - Id = c.Id, - Title = c.Title, - Pinned = c.Pinned, - Category = c.Category, - Symbol = symbol, - ColorHex = color, - Tab = NormalizeTabName(c.Tab), - UpdatedAtText = FormatDate(c.UpdatedAt), - UpdatedAt = c.UpdatedAt, - Preview = c.Preview ?? "", - ParentId = c.ParentId, - AgentRunCount = runSummary.AgentRunCount, - FailedAgentRunCount = runSummary.FailedAgentRunCount, - LastAgentRunSummary = runSummary.LastAgentRunSummary, - LastFailedAt = runSummary.LastFailedAt, - LastCompletedAt = runSummary.LastCompletedAt, - WorkFolder = c.WorkFolder ?? "", - IsRunning = _currentConversation?.Id == c.Id - && !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId) - && !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase) - && !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase), - }; - }).ToList(); - - // 탭 필터 — 현재 활성 탭의 대화만 표시 - items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList(); - // 탭 전환 과정에서 저장된 "빈 새 대화" 노이즈 항목은 목록에서 숨김 - items = items.Where(i => - i.Pinned - || !string.IsNullOrWhiteSpace(i.ParentId) - || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) - || !string.IsNullOrWhiteSpace(i.Preview) - || i.AgentRunCount > 0 - || i.FailedAgentRunCount > 0 - || !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase) - ).ToList(); - _failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0); - _runningConversationCount = items.Count(i => i.IsRunning); - _spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3); - UpdateConversationFailureFilterUi(); - UpdateConversationRunningFilterUi(); - UpdateConversationQuickStripUi(); - - // 상단 필터 적용 - if (_activeTab == "Cowork") - { - if (!string.IsNullOrEmpty(_selectedCategory)) - { - items = items.Where(i => string.Equals(i.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); - } - } - else if (_activeTab == "Code") - { - if (!string.IsNullOrEmpty(_selectedCategory)) - { - items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); - } - } - else - { - if (_selectedCategory == "__custom__") - { - // 커스텀 프리셋으로 만든 대화만 표시 - var customCats = _settings.Settings.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) || - i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase) - ).ToList(); - - if (_runningOnlyFilter) - items = items.Where(i => i.IsRunning).ToList(); - - items = (_sortConversationsByRecent - ? items.OrderByDescending(i => i.Pinned) - .ThenByDescending(i => i.UpdatedAt) - .ThenByDescending(i => i.FailedAgentRunCount > 0) - .ThenByDescending(i => i.AgentRunCount) - : items.OrderByDescending(i => i.Pinned) - .ThenByDescending(i => i.FailedAgentRunCount > 0) - .ThenByDescending(i => i.AgentRunCount) - .ThenByDescending(i => i.UpdatedAt)) - .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 emptyText = _activeTab switch - { - "Cowork" => "Cowork 탭 대화가 없습니다", - "Code" => "Code 탭 대화가 없습니다", - _ => "Chat 탭 대화가 없습니다", - }; - var empty = new TextBlock - { - Text = emptyText, - FontSize = 12, - Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 20, 0, 0) - }; - ConversationPanel.Children.Add(empty); - return; - } - - var spotlightItems = BuildConversationSpotlightItems(items); - if (spotlightItems.Count > 0) - { - AddGroupHeader("집중 필요"); - foreach (var item in spotlightItems) - AddConversationItem(item); - - ConversationPanel.Children.Add(new Border - { - Height = 1, - Margin = new Thickness(10, 8, 10, 4), - Background = BrushFromHex("#E5E7EB"), - Opacity = 0.7, - }); - } - - var allOrdered = new List<(string Group, ConversationMeta Item)>(); - foreach (var item in items) - allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), 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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - 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(); - - string? lastGroup = null; - foreach (var item in all) - { - var group = GetConversationDateGroup(item.UpdatedAt); - if (!string.Equals(lastGroup, group, StringComparison.Ordinal)) - { - AddGroupHeader(group); - lastGroup = group; - } - - AddConversationItem(item); - } - } - }; - ConversationPanel.Children.Add(btn); - } - - private static string GetConversationDateGroup(DateTime updatedAt) - { - var today = DateTime.Today; - var date = updatedAt.Date; - if (date == today) - return "오늘"; - if (date == today.AddDays(-1)) - return "어제"; - return "이전"; - } - - private List BuildConversationSpotlightItems(List items) - { - if (_failedOnlyFilter || _runningOnlyFilter) - return new List(); - - var search = SearchBox?.Text?.Trim() ?? ""; - if (!string.IsNullOrEmpty(search)) - return new List(); - - return items - .Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) - .OrderByDescending(i => i.FailedAgentRunCount) - .ThenByDescending(i => i.AgentRunCount) - .ThenByDescending(i => i.UpdatedAt) - .Take(3) - .ToList(); - } - - private void AddGroupHeader(string text) - { - var header = new TextBlock - { - Text = text, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), - Margin = new Thickness(8, 10, 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(0x10, 0x4B, 0x5E, 0xFC)) - : Brushes.Transparent, - CornerRadius = new CornerRadius(5), - Padding = new Thickness(7, 4.5, 7, 4.5), - Margin = isBranch ? new Thickness(10, 1, 0, 1) : new Thickness(0, 1, 0, 1), - Cursor = Cursors.Hand - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16) }); - 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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); } - catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; } - } - 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 = new FontFamily("Segoe MDL2 Assets"), - FontSize = 10.5, - Foreground = iconBrush, - VerticalAlignment = VerticalAlignment.Center - }; - Grid.SetColumn(icon, 0); - grid.Children.Add(icon); - - // 제목 + 날짜 (선택 시 약간 밝게) - var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray; - - var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - var title = new TextBlock - { - Text = item.Title, - FontSize = 11.75, - FontWeight = isSelected ? FontWeights.SemiBold : FontWeights.Normal, - Foreground = titleColor, - TextTrimming = TextTrimming.CharacterEllipsis - }; - var date = new TextBlock - { - Text = item.UpdatedAtText, - FontSize = 9, - Foreground = dateColor, - Margin = new Thickness(0, 1.5, 0, 0) - }; - stack.Children.Add(title); - stack.Children.Add(date); - if (item.IsRunning) - { - stack.Children.Add(new TextBlock - { - Text = _appState.ActiveTasks.Count > 0 - ? $"진행 중 {_appState.ActiveTasks.Count}" - : "진행 중", - FontSize = 8.8, - FontWeight = FontWeights.Medium, - Foreground = BrushFromHex("#4F46E5"), - Margin = new Thickness(0, 1.5, 0, 0), - }); - } - if (item.AgentRunCount > 0) - { - var runSummaryText = new TextBlock - { - Text = item.FailedAgentRunCount > 0 - ? $"실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 26)}" - : $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}", - FontSize = 8.9, - Foreground = item.FailedAgentRunCount > 0 - ? BrushFromHex("#B91C1C") - : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray), - Margin = new Thickness(0, 1.5, 0, 0), - TextTrimming = TextTrimming.CharacterEllipsis - }; - if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary)) - { - runSummaryText.ToolTip = item.FailedAgentRunCount > 0 - ? $"최근 실패 포함\n{item.LastAgentRunSummary}" - : item.LastAgentRunSummary; - } - stack.Children.Add(runSummaryText); - } - Grid.SetColumn(stack, 1); - grid.Children.Add(stack); - - // 카테고리 변경 버튼 (호버 시 표시) - var catBtn = new Button - { - Content = new TextBlock - { - Text = "\uE70F", // Edit - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 9, - Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray) - }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - VerticalAlignment = VerticalAlignment.Center, - Visibility = Visibility.Collapsed, - Width = 20, - Height = 20, - Padding = new Thickness(0), - Opacity = 0.72, - ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경" - }; - var capturedId = item.Id; - catBtn.Click += (_, _) => ShowConversationMenu(capturedId); - Grid.SetColumn(catBtn, 2); - grid.Children.Add(catBtn); - - // 선택 시 좌측 액센트 바 - if (isSelected) - { - border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - border.BorderThickness = new Thickness(1.25, 0, 0, 0); - } - - border.Child = grid; - - // 호버 이벤트 — 배경만 얇게 강조 - border.RenderTransformOrigin = new Point(0.5, 0.5); - border.RenderTransform = new ScaleTransform(1, 1); - var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)); - border.MouseEnter += (_, _) => - { - if (!isSelected) - border.Background = hoverBg; - catBtn.Visibility = Visibility.Visible; - }; - border.MouseLeave += (_, _) => - { - if (!isSelected) - border.Background = Brushes.Transparent; - catBtn.Visibility = Visibility.Collapsed; - }; - - // 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환 - 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) - { - lock (_convLock) - { - _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; - SyncTabConversationIdsFromSession(); - } - SaveLastConversations(); - UpdateChatTitle(); - RenderMessages(); - RefreshConversationList(); - RefreshDraftQueueUi(); - } - } - catch (Exception ex) - { - LogService.Error($"대화 전환 오류: {ex.Message}"); - } - }; - - // 우클릭 → 대화 관리 메뉴 바로 표시 - border.MouseRightButtonUp += (_, me) => - { - me.Handled = true; - // 선택되지 않은 대화를 우클릭하면 먼저 선택 - if (!isSelected) - { - var conv = _storage.Load(item.Id); - if (conv != null) - { - lock (_convLock) - { - _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; - SyncTabConversationIdsFromSession(); - } - SaveLastConversations(); - UpdateChatTitle(); - RenderMessages(); - RefreshDraftQueueUi(); - } - } - // Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기 - Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input); - }; - - ConversationPanel.Children.Add(border); - } - // ─── 대화 제목 인라인 편집 ──────────────────────────────────────────── private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor)