diff --git a/README.md b/README.md index 9a5227e..4d1ce80 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-10 08:47 (KST) +- 계획 확인 UI를 AX Agent 테마에 맞춰 다시 정리했습니다. 채팅 안 승인 카드는 `LauncherBackground`/`ItemBackground`/`BorderColor`/`AccentColor`를 기준으로 표면, 버튼 모서리, 입력 패널 간격을 통일해 기본 컨트롤 느낌을 줄였습니다. +- 플랜 뷰어 창의 하단 승인 영역도 같은 시각 언어로 손봤습니다. 승인 전 안내 카드를 추가하고, 수정 입력 패널은 테마 배경과 테두리를 쓰도록 바꿨으며, 액션 버튼 호버도 `ItemHoverBackground` 중심으로 정리했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + - 업데이트: 2026-04-10 00:08 (KST) - `claude-code`와 비교했을 때 AX에 남아 있던 계획 선행과 후속 권유 톤을 더 줄였습니다. 기본 Cowork/Code 루프 안에 남아 있던 plan prelude/승인용 죽은 코드를 제거해, 별도 계획 생성 단계를 끼우지 않고 바로 모델+도구 실행으로 들어갑니다. - Code 최종 보고 재강제도 review 작업과 고영향 변경으로 좁혔습니다. 일반 수정은 변경 내용과 검증 근거가 충분하면 후속 계획이나 남은 리스크를 덧붙이도록 다시 유도하지 않고 마무리할 수 있습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9422244..659021f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,13 @@ # AX Copilot - 媛쒕컻 臾몄꽌 +## 계획 승인 UI 테마 정렬 + +- 업데이트: 2026-04-10 08:47 (KST) +- `ChatWindow.AgentStatusPresentation`의 계획 승인 카드를 AX Agent 테마 리소스 기반으로 다시 정리했습니다. 승인/수정/취소 버튼의 라운드, 간격, 입력 패널 배경을 `LauncherBackground`, `ItemBackground`, `BorderColor`, `AccentColor` 축으로 통일해 채팅 본문과 더 자연스럽게 이어지도록 조정했습니다. +- `PlanViewerWindow`의 승인 단계 UI도 같은 방향으로 손봤습니다. 승인 버튼 영역 앞에 검토 안내 카드를 추가하고, 수정 피드백 입력 패널은 테마 배경/테두리를 사용하며, 액션 버튼 호버는 단순 opacity 대신 `ItemHoverBackground` 계열로 반응하도록 바꿨습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + ## claude-code식 계획/후속 권유 최소화 - 업데이트: 2026-04-10 00:08 (KST) diff --git a/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs b/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs new file mode 100644 index 0000000..61452cd --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs @@ -0,0 +1,1586 @@ +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.Media.Animation; +using System.Windows.Threading; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── Claude 스타일 펄스 닷 + 단계 표시 (입력창 위) ────────────────────────── + + private System.Windows.Media.Animation.Storyboard? _pulseDotStoryboard; + private System.Windows.Media.Animation.Storyboard? _statusDiamondStoryboard; + private readonly System.Collections.Generic.List _activeSubItems = new(); + private string? _currentSubItemCategory; // 현재 세부 항목 카테고리 (변경 시 초기화) + private const int MaxStatusSubItems = 6; + + // ShowStreamingStatusBar → 펄스 닷 바로 위임 (플로팅 상태 바 표시 안 함) + private void ShowStreamingStatusBar(string message, string? iconCode = null) + => ShowPulseDots(message, iconCode); + + private void HideStreamingStatusBar() + => HidePulseDots(); + + private void UpdateStreamingStatusBar(string message, string? iconCode = null) + => UpdatePulseDotsText(message, iconCode); + + // ─── 입력창 위 펄스 닷 애니메이션 ────────────────────────────────────────── + + private void ShowPulseDots(string? message = null, string? iconCode = null, string? detail = null) + { + if (PulseDotBar == null) return; + if (PulseDotStatusText != null) + PulseDotStatusText.Text = message ?? "생각하는 중..."; + ClearStatusSubItems(); + PulseDotBar.Visibility = Visibility.Visible; + StartStatusDiamondAnimation(); + if (_pulseDotStoryboard != null) return; // 이미 실행 중 + + var sb = new System.Windows.Media.Animation.Storyboard(); + var dots = new System.Windows.Shapes.Ellipse[] { PulseDot1, PulseDot2, PulseDot3 }; + const double cycleSecs = 1.2; + + for (int i = 0; i < dots.Length; i++) + { + var animX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames + { + BeginTime = TimeSpan.FromSeconds(i * 0.2), + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + Duration = new System.Windows.Duration(TimeSpan.FromSeconds(cycleSecs)), + }; + animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( + 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.Zero))); + animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( + 1.0, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.4))) + { + EasingFunction = new System.Windows.Media.Animation.SineEase + { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } + }); + animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( + 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.8))) + { + EasingFunction = new System.Windows.Media.Animation.SineEase + { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } + }); + animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( + 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs)))); + + System.Windows.Media.Animation.Storyboard.SetTarget(animX, dots[i]); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(animX, + new PropertyPath(UIElement.OpacityProperty)); + sb.Children.Add(animX); + } + + _pulseDotStoryboard = sb; + _pulseDotStoryboard.Begin(); + } + + private void HidePulseDots() + { + if (PulseDotBar == null) return; + _pulseDotStoryboard?.Stop(); + _pulseDotStoryboard = null; + if (PulseDot1 != null) PulseDot1.Opacity = 0.3; + if (PulseDot2 != null) PulseDot2.Opacity = 0.3; + if (PulseDot3 != null) PulseDot3.Opacity = 0.3; + StopStatusDiamondAnimation(); + ClearStatusSubItems(); + _currentSubItemCategory = null; + PulseDotBar.Visibility = Visibility.Collapsed; + } + + // ─── 채팅창 내 에이전트 라이브 진행 카드 ───────────────────────────────────── + + // ─── 미니 다이아몬드 아이콘 애니메이션 ─────────────────────────────────────── + + private void StartStatusDiamondAnimation() + { + if (_statusDiamondStoryboard != null) return; + if (StatusIconScale == null || StatusPixelBlue == null) return; + + var sb = new System.Windows.Media.Animation.Storyboard(); + + // 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감) + var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames + { + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)), + }; + var beatTimes = new (double t, double v)[] + { + (0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00), + }; + foreach (var (t, v) in beatTimes) + { + scaleAnimX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(v, + System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(t))) + { + EasingFunction = new System.Windows.Media.Animation.QuadraticEase + { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } + }); + } + System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimX, StatusIconScale); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimX, new PropertyPath("ScaleX")); + sb.Children.Add(scaleAnimX); + + var scaleAnimY = scaleAnimX.Clone(); + System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimY, StatusIconScale); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY")); + sb.Children.Add(scaleAnimY); + + // 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게) + var pixels = new System.Windows.Shapes.Rectangle?[] + { StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed }; + for (int i = 0; i < pixels.Length; i++) + { + if (pixels[i] == null) continue; + var fade = new System.Windows.Media.Animation.DoubleAnimation( + fromValue: 1.0, toValue: 0.55, + duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2))) + { + AutoReverse = true, + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + BeginTime = TimeSpan.FromSeconds(i * 0.30), + EasingFunction = new System.Windows.Media.Animation.SineEase + { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }, + }; + System.Windows.Media.Animation.Storyboard.SetTarget(fade, pixels[i]); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(fade, + new PropertyPath(UIElement.OpacityProperty)); + sb.Children.Add(fade); + } + + _statusDiamondStoryboard = sb; + _statusDiamondStoryboard.Begin(); + } + + private void StopStatusDiamondAnimation() + { + _statusDiamondStoryboard?.Stop(); + _statusDiamondStoryboard = null; + if (StatusIconScale != null) { StatusIconScale.ScaleX = 1; StatusIconScale.ScaleY = 1; } + if (StatusPixelBlue != null) StatusPixelBlue.Opacity = 1; + if (StatusPixelGreen1 != null) StatusPixelGreen1.Opacity = 1; + if (StatusPixelGreen2 != null) StatusPixelGreen2.Opacity = 1; + if (StatusPixelRed != null) StatusPixelRed.Opacity = 1; + } + + // ─── 세부 항목 서브텍스트 관리 ────────────────────────────────────────────── + + private void AddStatusSubItem(string text, string? category = null) + { + if (PulseDotSubItems == null) return; + if (_activeSubItems.Contains(text)) return; // 중복 방지 + + // 카테고리 변경 시 기존 항목 초기화 + if (category != null && category != _currentSubItemCategory) + { + ClearStatusSubItems(); + _currentSubItemCategory = category; + } + + _activeSubItems.Add(text); + if (_activeSubItems.Count > MaxStatusSubItems) + { + _activeSubItems.RemoveAt(0); + if (PulseDotSubItems.Children.Count > 0) + PulseDotSubItems.Children.RemoveAt(0); + } + + var secondary = TryFindResource("SecondaryText") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.Gray; + var tb = new TextBlock + { + Text = $"› {text}", + FontSize = 10.5, + FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"), + Foreground = secondary, + Opacity = 0.60, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 380, + Margin = new Thickness(0, 0, 0, 1), + }; + PulseDotSubItems.Children.Add(tb); + } + + private void ClearStatusSubItems() + { + _activeSubItems.Clear(); + PulseDotSubItems?.Children.Clear(); + } + + private void UpdatePulseDotsText(string message, string? iconCode = null, string? detail = null, + bool clearSubItems = false, string? subItemCategory = null) + { + if (PulseDotBar?.Visibility != Visibility.Visible) return; + if (PulseDotStatusText != null) PulseDotStatusText.Text = message; + if (clearSubItems) ClearStatusSubItems(); + if (!string.IsNullOrEmpty(detail)) + AddStatusSubItem(detail, subItemCategory); + } + + // 레거시 호환 (단일 세부 텍스트 — 현재는 서브 아이템 목록으로 대체) + private void SetPulseDotDetail(string? detail) + { + if (!string.IsNullOrEmpty(detail)) + AddStatusSubItem(detail); + else + ClearStatusSubItems(); + } + + // 도구 이름 → (상태 메시지, MDL2 아이콘 코드, 카테고리) 변환 + private static (string message, string icon, string category) GetStatusInfoForTool(string toolName) + => toolName.ToLowerInvariant() switch + { + "document_read" => ("문서 읽는 중", "\uE8A5", "read"), + "file_read" => ("파일 읽는 중", "\uE8A5", "read"), + "folder_map" => ("폴더 구조 파악 중", "\uE8B7", "folder"), + "file_write" or "html_create" or "md_create" + or "docx_create" or "xlsx_create" + or "csv_create" or "script_create" + or "pptx_create" => ("파일 작성 중", "\uE8A3", "write"), + "file_edit" => ("파일 수정 중", "\uE70F", "edit"), + "terminal" => ("명령 실행 중", "\uE756", "terminal"), + "web_search" => ("웹 검색 중", "\uE721", "search"), + "document_plan" => ("작업 계획 수립 중", "\uE8F1", "plan"), + "diff_preview" => ("변경 사항 검토 중", "\uE8A9", "diff"), + "code_run" or "run_script" => ("코드 실행 중", "\uE756", "run"), + "git_commit" or "git_push" or "git_pull" + or "git_status" or "git_diff" => ("Git 작업 중", "\uE8A7", "git"), + _ => ("작업 실행 중", "\uE8A7", "misc"), + }; + + // 도구별 서브 아이템 접미사 ("> filename.txt 읽고 분석 중") + private static string GetToolSubItemSuffix(string toolName) + => toolName.ToLowerInvariant() switch + { + "document_read" or "file_read" => "읽고 분석 중", + "folder_map" => "구조 파악 중", + "file_write" or "html_create" or "md_create" + or "docx_create" or "xlsx_create" + or "csv_create" or "script_create" + or "pptx_create" => "작성 중", + "file_edit" => "수정 중", + "terminal" => "실행 중", + "web_search" => "검색 중", + "code_run" or "run_script" => "실행 중", + "diff_preview" => "비교 중", + "git_commit" => "커밋 중", + "git_push" => "푸시 중", + "git_pull" => "풀 중", + _ => "처리 중", + }; + + // 도구 결과 수신 후 표시할 메시지 + private static string GetToolResultMessage(string toolName) + => toolName.ToLowerInvariant() switch + { + "document_read" or "file_read" => "내용 분석 중", + "folder_map" => "구조 파악 중", + "web_search" => "검색 결과 검토 중", + "file_write" or "html_create" or "md_create" + or "docx_create" or "xlsx_create" + or "csv_create" or "script_create" + or "pptx_create" => "작성 완료, 검토 중", + "terminal" or "code_run" or "run_script" => "실행 결과 분석 중", + "git_commit" or "git_push" or "git_pull" => "Git 결과 확인 중", + _ => "결과 처리 중", + }; + + // 하위 호환 (단순 문자열만 필요한 경우) + private static string GetStatusMessageForTool(string toolName) + => GetStatusInfoForTool(toolName).message + "..."; + + private void TouchLiveAgentProgressHints() + { + _lastAgentProgressEventAt = DateTime.UtcNow; + } + + private void AgentProgressHintTimer_Tick(object? sender, EventArgs e) + { + if (!_streamingTabs.Contains(_activeTab) || !GetAgentLoop(_activeTab).IsRunning) + { + StopLiveAgentProgressHints(); + return; + } + + // _streamingTabs.Contains(_activeTab)가 위에서 이미 검증됨 — _streamRunTab은 다른 탭 시작 시 덮어써질 수 있으므로 _activeTab 사용 + var runTab = _activeTab; + if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + UpdateLiveAgentProgressHint(null); + return; + } + + var idle = DateTime.UtcNow - _lastAgentProgressEventAt; + TryGetStreamingElapsed(out var elapsed); + string? summary = null; + var toolName = "agent_wait"; + + if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2)) + { + toolName = "context_compaction"; + summary = idle >= TimeSpan.FromSeconds(12) + ? "컨텍스트를 압축한 뒤 응답을 정리하는 중입니다..." + : "컨텍스트를 압축하고 있습니다..."; + UpdateStreamingStatusBar("컨텍스트 정리 중...", "\uE72C"); + } + else if (idle >= TimeSpan.FromSeconds(90)) + { + summary = "대용량 컨텍스트 처리 중입니다... (최대 3분 소요될 수 있습니다)"; + UpdateStreamingStatusBar("대용량 컨텍스트 처리 중...", "\uE895"); + } + else if (idle >= TimeSpan.FromSeconds(30)) + { + summary = "모델이 응답을 준비하는 중입니다..."; + UpdateStreamingStatusBar("모델이 응답 준비 중...", "\uE895"); + } + else if (idle >= TimeSpan.FromSeconds(12)) + { + summary = "응답을 정리하는 중입니다..."; + } + else if (idle >= TimeSpan.FromSeconds(5)) + { + summary = "생각을 정리하는 중입니다..."; + } + else if (elapsed >= TimeSpan.FromSeconds(4)) + { + summary = "작업을 진행하는 중입니다..."; + } + + UpdateLiveAgentProgressHint(summary, toolName); + } + + private void UpdateLiveAgentProgressHint(string? summary, string toolName = "") + { + var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim(); + var currentSummary = _liveAgentProgressHint?.Summary; + var currentToolName = _liveAgentProgressHint?.ToolName ?? ""; + var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L; + var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab)); + var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab)); + var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000; + var nextElapsedBucket = elapsedMs / 3000; + var currentTokenBucket = ((_liveAgentProgressHint?.InputTokens ?? 0) + (_liveAgentProgressHint?.OutputTokens ?? 0)) / 500; + var nextTokenBucket = (inputTokens + outputTokens) / 500; + if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal) + && string.Equals(currentToolName, toolName, StringComparison.Ordinal) + && currentElapsedBucket == nextElapsedBucket + && currentTokenBucket == nextTokenBucket) + return; + + _liveAgentProgressHint = normalizedSummary == null + ? null + : new AgentEvent + { + Timestamp = DateTime.Now, + RunId = _appState.AgentRun.RunId, + Type = AgentEventType.Thinking, + ToolName = toolName, + Summary = normalizedSummary, + ElapsedMs = elapsedMs, + InputTokens = (int)inputTokens, + OutputTokens = (int)outputTokens, + }; + + // 스트리밍 중 프로그레스 힌트 변경으로 인한 ScheduleExecutionHistoryRender 호출 차단 + // 힌트 업데이트만으로 전체 트랜스크립트를 재렌더하면 UI 스레드 멈춤 발생 + // 타이머가 자연스럽게 다음 렌더 사이클에서 힌트를 반영함 + } + + private AgentEvent? GetLiveAgentProgressHint() + { + if (_liveAgentProgressHint == null) + return null; + + var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; + if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + return null; + + return new AgentEvent + { + Timestamp = _liveAgentProgressHint.Timestamp, + RunId = _liveAgentProgressHint.RunId, + Type = _liveAgentProgressHint.Type, + ToolName = _liveAgentProgressHint.ToolName, + Summary = _liveAgentProgressHint.Summary, + FilePath = _liveAgentProgressHint.FilePath, + Success = _liveAgentProgressHint.Success, + StepCurrent = _liveAgentProgressHint.StepCurrent, + StepTotal = _liveAgentProgressHint.StepTotal, + Steps = _liveAgentProgressHint.Steps, + ElapsedMs = _liveAgentProgressHint.ElapsedMs, + InputTokens = _liveAgentProgressHint.InputTokens, + OutputTokens = _liveAgentProgressHint.OutputTokens, + ToolInput = _liveAgentProgressHint.ToolInput, + Iteration = _liveAgentProgressHint.Iteration, + }; + } + + private static bool ShouldRenderProgressEventWhenHistoryCollapsed(AgentEvent evt) + { + if (evt.Type == AgentEventType.Complete || evt.Type == AgentEventType.Error) + return true; + + if (evt.Type == AgentEventType.Thinking) + { + if (string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + || string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(evt.Summary)) + return true; + } + + return IsProcessFeedEvent(evt); + } + + private void OnSubAgentStatusChanged(SubAgentStatusEvent evt) + { + Dispatcher.BeginInvoke(() => + { + try + { + _appState.ApplySubAgentStatus(evt); + ScheduleTaskSummaryRefresh(); + } + catch (Exception ex) + { + LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}"); + } + }); + } + + private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab) + { + lock (_convLock) + { + var session = _appState.ChatSession; + if (session == null) + return; + + var result = _chatEngine.AppendAgentRun( + session, + _storage, + _currentConversation, + _activeTab, + targetTab, + evt, + status, + summary); + _currentConversation = result.CurrentConversation; + ScheduleConversationPersist(result.UpdatedConversation); + } + } + + private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab) + { + lock (_convLock) + { + var session = _appState.ChatSession; + if (session == null) + return; + + var result = _chatEngine.AppendExecutionEvent( + session, + _storage, + _currentConversation, + _activeTab, + targetTab, + evt); + _currentConversation = result.CurrentConversation; + ScheduleConversationPersist(result.UpdatedConversation); + } + } + + private void SyncAppStateWithCurrentConversation() + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + + _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); + _appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory); + _appState.RestoreRecentTasks(conv?.ExecutionEvents); + ApplyConversationListPreferences(conv); + UpdateTaskSummaryIndicators(); + } + + private static string GetRunStatusLabel(string? status) + => status switch + { + "completed" => "완료", + "failed" => "실패", + "paused" => "일시중지", + _ => "진행 중", + }; + + private static string GetTaskStatusLabel(string? status) + => status switch + { + "completed" => "완료", + "failed" => "실패", + "blocked" => "재시도 대기", + "waiting" => "승인 대기", + "cancelled" => "중단", + _ => "진행 중", + }; + + private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false) + => GetTranscriptDisplayName(rawName, slashPrefix); + + private static bool IsTranscriptToolLikeEvent(AgentEvent evt) + => evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall + || (!string.IsNullOrWhiteSpace(evt.ToolName) + && evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied); + + private string BuildAgentEventSummaryText(AgentEvent evt, string displayName) + => GetTranscriptEventSummary(evt, displayName); + + private IEnumerable FilterTaskSummaryItems(IEnumerable tasks) + => _taskSummaryTaskFilter switch + { + "permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)), + "queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)), + "hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)), + "subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)), + "tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)), + _ => tasks, + }; + + private Border CreateTaskSummaryFilterChip(string key, string label) + { + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase); + var chip = new Border + { + Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"), + BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 5, 5), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = label, + FontSize = 10.5, + Foreground = active ? BrushFromHex("#4338CA") : secondaryText, + } + }; + chip.MouseLeftButtonUp += (_, _) => + { + _taskSummaryTaskFilter = key; + if (_taskSummaryTarget != null) + ShowTaskSummaryPopup(); + }; + return chip; + } + + private static Brush GetRunStatusBrush(string? status) + => status switch + { + "completed" => BrushFromHex("#166534"), + "failed" => BrushFromHex("#B91C1C"), + "paused" => BrushFromHex("#B45309"), + _ => BrushFromHex("#1D4ED8"), + }; + + private static string ShortRunId(string? runId) + { + if (string.IsNullOrWhiteSpace(runId)) + return "main"; + + return runId.Length <= 8 ? runId : runId[..8]; + } + + // ─── Task Decomposition UI ──────────────────────────────────────────── + + private Border? _planningCard; + private StackPanel? _planStepsPanel; + private ProgressBar? _planProgressBar; + private TextBlock? _planProgressText; + private TextBlock? _planToggleText; + + /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). + private void AddPlanningCard(AgentEvent evt) + { + var steps = evt.Steps!; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC"); + var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); + var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC"); + + var card = new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(8, 2, 248, 5), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = GetMessageMaxWidth(), + }; + + var sp = new StackPanel(); + + // 헤더 + var header = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; + headerLeft.Children.Add(new TextBlock + { + Text = "\uE9D5", // plan icon + FontFamily = s_segoeIconFont, + FontSize = 8, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center + }); + headerLeft.Children.Add(new TextBlock + { + Text = $"계획 {steps.Count}단계", + FontSize = 9, FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0), + }); + header.Children.Add(headerLeft); + + var toggleWrap = new Border + { + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(6, 2, 6, 2), + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + }; + _planToggleText = new TextBlock + { + Text = steps.Count > 0 ? "펼치기" : "", + FontSize = 8.25, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + }; + toggleWrap.Child = _planToggleText; + Grid.SetColumn(toggleWrap, 1); + header.Children.Add(toggleWrap); + sp.Children.Add(header); + + // 진행률 바 + var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) }; + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + _planProgressBar = new ProgressBar + { + Minimum = 0, + Maximum = steps.Count, + Value = 0, + Height = 3, + Foreground = accentBrush, + Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"), + VerticalAlignment = VerticalAlignment.Center, + }; + // Remove the default border on ProgressBar + _planProgressBar.BorderThickness = new Thickness(0); + Grid.SetColumn(_planProgressBar, 0); + progressGrid.Children.Add(_planProgressBar); + + _planProgressText = new TextBlock + { + Text = "0%", + FontSize = 8, FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + Margin = new Thickness(5, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(_planProgressText, 1); + progressGrid.Children.Add(_planProgressText); + sp.Children.Add(progressGrid); + + // 단계 목록 + _planStepsPanel = new StackPanel + { + Visibility = Visibility.Collapsed, + }; + for (int i = 0; i < steps.Count; i++) + { + var stepRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 1, 0, 0), + Tag = i, // 인덱스 저장 + }; + + stepRow.Children.Add(new TextBlock + { + Text = "○", // 빈 원 (미완료) + FontSize = 8.5, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0), + Tag = "status", + }); + stepRow.Children.Add(new TextBlock + { + Text = $"{i + 1}. {steps[i]}", + FontSize = 8.75, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68), + VerticalAlignment = VerticalAlignment.Center, + }); + + _planStepsPanel.Children.Add(stepRow); + } + sp.Children.Add(_planStepsPanel); + + toggleWrap.MouseLeftButtonUp += (_, _) => + { + if (_planStepsPanel == null || _planToggleText == null) + return; + + var expanded = _planStepsPanel.Visibility == Visibility.Visible; + _planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible; + _planToggleText.Text = expanded ? "펼치기" : "접기"; + toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC"); + }; + + card.Child = sp; + _planningCard = card; + + // 페이드인 + card.Opacity = 0; + card.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + AddTranscriptElement(card); + } + + /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. + private void AddDecisionButtons(TaskCompletionSource tcs, List options) + { + var expressionLevel = GetAgentUiExpressionLevel(); + var showDetailedCopy = expressionLevel != "simple"; + var showRichHint = expressionLevel == "rich"; + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var accentColor = accentBrush is SolidColorBrush solidAccent ? solidAccent.Color : Color.FromRgb(0x4B, 0x5E, 0xFC); + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var surfaceBrush = TryFindResource("LauncherBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x12, 0x14, 0x1F)); + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x20, accentColor.R, accentColor.G, accentColor.B)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var borderBrush = TryFindResource("BorderColor") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)); + var accentTintBrush = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); + var successBrush = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); + var dangerBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + + var container = new Border + { + Margin = expressionLevel == "simple" + ? new Thickness(40, 2, 120, 6) + : new Thickness(40, 2, 80, 6), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = expressionLevel == "simple" ? 460 : 560, + Background = surfaceBrush, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 12, 14, 12), + }; + + var outerStack = new StackPanel(); + + outerStack.Children.Add(new TextBlock + { + Text = "실행 계획 승인 요청", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + if (showDetailedCopy) + { + outerStack.Children.Add(new TextBlock + { + Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.", + FontSize = 11.5, + Foreground = secondaryText, + Margin = new Thickness(0, 2, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + + if (showDetailedCopy && options.Count > 0) + { + var optionCandidates = new List(); + foreach (var option in options) + { + if (string.IsNullOrWhiteSpace(option)) + continue; + + optionCandidates.Add(option.Trim()); + if (optionCandidates.Count >= 3) + break; + } + var optionHint = string.Join(" · ", optionCandidates); + if (!string.IsNullOrWhiteSpace(optionHint)) + { + outerStack.Children.Add(new TextBlock + { + Text = $"선택지: {optionHint}", + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + } + if (showRichHint) + { + outerStack.Children.Add(new TextBlock + { + Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.", + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + + // 버튼 행 + var btnRow = new WrapPanel + { + HorizontalAlignment = HorizontalAlignment.Left, + ItemHeight = 38, + }; + + // 승인 버튼 (강조) + var approveBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(18), + Padding = new Thickness(16, 9, 16, 9), + Margin = new Thickness(0, 0, 8, 8), + Cursor = Cursors.Hand, + }; + var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; + approveSp.Children.Add(new TextBlock + { + Text = "\uE73E", FontFamily = s_segoeIconFont, FontSize = 11, + Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + approveSp.Children.Add(new TextBlock + { + Text = "승인 후 실행", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + }); + approveBtn.Child = approveSp; + ApplyHoverScaleAnimation(approveBtn, 1.02); + approveBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); + tcs.TrySetResult(null); // null = 승인 + }; + btnRow.Children.Add(approveBtn); + + // 수정 요청 버튼 + var editBtn = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(18), + Padding = new Thickness(14, 9, 14, 9), + Margin = new Thickness(0, 0, 8, 8), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + }; + var editSp = new StackPanel { Orientation = Orientation.Horizontal }; + editSp.Children.Add(new TextBlock + { + Text = "\uE70F", FontFamily = s_segoeIconFont, FontSize = 11, + Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); + editBtn.Child = editSp; + ApplyMenuItemHover(editBtn); + + // 수정 요청용 텍스트 입력 패널 (초기 숨김) + var editInputPanel = new Border + { + Visibility = Visibility.Collapsed, + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(0, 4, 0, 0), + }; + var editInputStack = new StackPanel(); + editInputStack.Children.Add(new TextBlock + { + Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:", + FontSize = 11.5, Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + var editTextBox = new TextBox + { + MinHeight = 36, + MaxHeight = 100, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontSize = 12.5, + Background = surfaceBrush, + Foreground = primaryText, + CaretBrush = primaryText, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + Padding = new Thickness(8, 6, 8, 6), + }; + editInputStack.Children.Add(editTextBox); + + var submitEditBtn = new Border + { + Background = accentBrush, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 6, 0, 0), + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + }; + submitEditBtn.Child = new TextBlock + { + Text = "피드백 전송", + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + }; + ApplyHoverScaleAnimation(submitEditBtn, 1.05); + submitEditBtn.MouseLeftButtonUp += (_, _) => + { + var feedback = editTextBox.Text.Trim(); + if (string.IsNullOrEmpty(feedback)) return; + CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush); + tcs.TrySetResult(feedback); + }; + editInputStack.Children.Add(submitEditBtn); + editInputPanel.Child = editInputStack; + + editBtn.MouseLeftButtonUp += (_, _) => + { + editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible + ? Visibility.Collapsed : Visibility.Visible; + if (editInputPanel.Visibility == Visibility.Visible) + editTextBox.Focus(); + }; + btnRow.Children.Add(editBtn); + + // 취소 버튼 + var cancelBtn = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(18), + Padding = new Thickness(14, 9, 14, 9), + Margin = new Thickness(0, 0, 0, 8), + Cursor = Cursors.Hand, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), + BorderThickness = new Thickness(1), + }; + var cancelSp = new StackPanel { Orientation = Orientation.Horizontal }; + cancelSp.Children.Add(new TextBlock + { + Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), + }); + cancelSp.Children.Add(new TextBlock + { + Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + }); + cancelBtn.Child = cancelSp; + ApplyMenuItemHover(cancelBtn); + cancelBtn.MouseLeftButtonUp += (_, _) => + { + CollapseDecisionButtons(outerStack, "✕ 취소됨", + new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))); + tcs.TrySetResult("취소"); + }; + btnRow.Children.Add(cancelBtn); + + outerStack.Children.Add(btnRow); + outerStack.Children.Add(editInputPanel); + container.Child = outerStack; + + // 슬라이드 + 페이드 등장 애니메이션 + ApplyMessageEntryAnimation(container); + AddTranscriptElement(container); + ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 + + // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 + var capturedOuterStack = outerStack; + var capturedAccent = accentBrush; + _ = tcs.Task.ContinueWith(t => + { + if (t.IsFaulted || t.IsCanceled) return; + Dispatcher.BeginInvoke(() => + { + // 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우) + if (capturedOuterStack.Children.Count <= 1) return; + + var label = t.Result == null ? "✓ 승인됨" + : t.Result == "취소" ? "✕ 취소됨" + : "✎ 수정 요청됨"; + var fg = t.Result == "취소" + ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)) + : capturedAccent; + CollapseDecisionButtons(capturedOuterStack, label, fg); + }); + }, TaskScheduler.Default); + } + + /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. + private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) + { + outerStack.Children.Clear(); + var resultLabel = new TextBlock + { + Text = resultText, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = fg, + Opacity = 0.8, + Margin = new Thickness(0, 2, 0, 2), + }; + outerStack.Children.Add(resultLabel); + } + + // ════════════════════════════════════════════════════════════ + // 후속 작업 제안 칩 (suggest_actions) + // ════════════════════════════════════════════════════════════ + + /// suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다. + private void RenderSuggestActionChips(string jsonSummary) + { + List<(string label, string command)> actions = new(); + try + { + if (jsonSummary.Contains("\"label\"")) + { + using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in doc.RootElement.EnumerateArray()) + { + var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : ""; + var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label; + if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd)); + } + } + } + else + { + // 줄바꿈 형식: "1. label → command" + foreach (var line in jsonSummary.Split('\n')) + { + var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' '); + if (string.IsNullOrEmpty(trimmed)) continue; + var parts = trimmed.Split('→', ':', '—'); + if (parts.Length >= 2) + actions.Add((parts[0].Trim(), parts[1].Trim())); + else if (!string.IsNullOrEmpty(trimmed)) + actions.Add((trimmed, trimmed)); + } + } + } + catch { return; } + + if (actions.Count == 0) return; + var preview = string.Join(", ", actions.Take(3).Select(static action => action.label)); + var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : ""; + ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}"); + } + + // ════════════════════════════════════════════════════════════ + // 피드백 학습 반영 (J) + // ════════════════════════════════════════════════════════════ + + /// 피드백 컨텍스트 캐시 — 1분간 유효. + private string? _feedbackContextCache; + private DateTime _feedbackContextCacheExpiry; + + /// 최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다. + /// + /// 성능 최적화: LoadAllMeta() + Load() x20 은 모든 .axchat 파일을 복호화하므로 + /// 매 전송마다 호출하면 수 초~10초 이상 UI를 블로킹합니다. + /// 결과를 1분간 캐시하고, 현재 대화의 피드백만 즉시 반영합니다. + /// + private string BuildFeedbackContext() + { + // 캐시가 유효하면 즉시 반환 — 디스크 I/O 없음 + if (_feedbackContextCache != null && DateTime.UtcNow < _feedbackContextCacheExpiry) + return _feedbackContextCache; + + try + { + // 현재 대화에서만 피드백 수집 — 디스크 I/O 없이 메모리 내 처리 + var likedPatterns = new List(); + var dislikedPatterns = new List(); + + ChatConversation? currentConv; + lock (_convLock) currentConv = _currentConversation; + + if (currentConv != null) + { + foreach (var msg in currentConv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) + { + var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? ""; + if (msg.Feedback == "like") + likedPatterns.Add(preview); + else if (msg.Feedback == "dislike") + dislikedPatterns.Add(preview); + } + } + + // 현재 대화에 피드백이 없으면 백그라운드에서 전체 캐시를 갱신 예약 + if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0) + { + // 비동기 캐시 갱신 — UI 블로킹 없음 + _ = Task.Run(() => RefreshFeedbackCacheBackground()); + // 이전 캐시가 있으면 반환, 없으면 빈 문자열 + return _feedbackContextCache ?? ""; + } + + var result = FormatFeedbackPatterns(likedPatterns, dislikedPatterns); + _feedbackContextCache = result; + _feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1); + return result; + } + catch { return ""; } + } + + /// 백그라운드에서 전체 대화 피드백을 수집하여 캐시를 갱신합니다. + private void RefreshFeedbackCacheBackground() + { + try + { + var metaList = _storage.LoadAllMeta() + .OrderByDescending(m => m.UpdatedAt) + .Take(10) + .ToList(); + + var liked = new List(); + var disliked = new List(); + + foreach (var meta in metaList) + { + var conv = _storage.Load(meta.Id); + if (conv == null) continue; + + foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) + { + var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? ""; + if (msg.Feedback == "like") liked.Add(preview); + else if (msg.Feedback == "dislike") disliked.Add(preview); + } + + // 충분한 피드백을 모았으면 중단 + if (liked.Count + disliked.Count >= 10) break; + } + + var result = FormatFeedbackPatterns(liked, disliked); + _feedbackContextCache = result; + _feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1); + } + catch { /* 백그라운드 실패 무시 */ } + } + + private static string FormatFeedbackPatterns(List likedPatterns, List dislikedPatterns) + { + if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0) + return ""; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n[사용자 선호도 참고]"); + if (likedPatterns.Count > 0) + { + sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):"); + foreach (var p in likedPatterns.Take(5)) + sb.AppendLine($" - \"{p}...\""); + } + if (dislikedPatterns.Count > 0) + { + sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):"); + foreach (var p in dislikedPatterns.Take(5)) + sb.AppendLine($" - \"{p}...\""); + } + sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요."); + return sb.ToString(); + } + + /// 진행률 바와 단계 상태를 업데이트합니다. + private void UpdateProgressBar(AgentEvent evt) + { + if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null) + return; + + var stepIdx = evt.StepCurrent - 1; // 0-based + var total = evt.StepTotal; + + // 진행률 바 업데이트 + _planProgressBar.Value = evt.StepCurrent; + var pct = (int)((double)evt.StepCurrent / total * 100); + _planProgressText.Text = $"{pct}%"; + + // 이전 단계 완료 표시 + 현재 단계 강조 + for (int i = 0; i < _planStepsPanel.Children.Count; i++) + { + if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2) + { + var statusTb = row.Children[0] as TextBlock; + var textTb = row.Children[1] as TextBlock; + if (statusTb == null || textTb == null) continue; + + if (i < stepIdx) + { + // 완료 + statusTb.Text = "●"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")); + } + else if (i == stepIdx) + { + // 현재 진행 중 + statusTb.Text = "◉"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B")); + textTb.FontWeight = FontWeights.SemiBold; + } + else + { + // 대기 + statusTb.Text = "○"; + statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")); + textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")); + textTb.FontWeight = FontWeights.Normal; + } + } + } + } + + /// Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다. + private static UIElement BuildDiffView(string text) + { + var panel = new StackPanel + { + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")), + MaxWidth = 520, + }; + + var diffStarted = false; + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + + // diff 헤더 전의 일반 텍스트 + if (!diffStarted && !line.StartsWith("--- ")) + { + panel.Children.Add(new TextBlock + { + Text = line, + FontSize = 11, + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")), + FontFamily = new FontFamily("Consolas"), + Margin = new Thickness(0, 0, 0, 1), + }); + continue; + } + diffStarted = true; + + string bgHex, fgHex; + if (line.StartsWith("---") || line.StartsWith("+++")) + { + bgHex = "#F3F4F6"; fgHex = "#374151"; + } + else if (line.StartsWith("@@")) + { + bgHex = "#EFF6FF"; fgHex = "#3B82F6"; + } + else if (line.StartsWith("+")) + { + bgHex = "#ECFDF5"; fgHex = "#059669"; + } + else if (line.StartsWith("-")) + { + bgHex = "#FEF2F2"; fgHex = "#DC2626"; + } + else + { + bgHex = "Transparent"; fgHex = "#6B7280"; + } + + var tb = new TextBlock + { + Text = line, + FontSize = 10.5, + FontFamily = new FontFamily("Consolas"), + Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)), + Padding = new Thickness(4, 1, 4, 1), + }; + if (bgHex != "Transparent") + tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex)); + + panel.Children.Add(tb); + } + + return panel; + } + + private sealed class ReviewSignalSummary + { + public int P0 { get; init; } + public int P1 { get; init; } + public int P2 { get; init; } + public int P3 { get; init; } + public bool HasFixed { get; init; } + public bool HasUnfixed { get; init; } + public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed; + } + + private static ReviewSignalSummary ExtractReviewSignals(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new ReviewSignalSummary(); + + var source = text!; + var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈"); + var hasFixed = ContainsWholeWord(source, "fixed") + || ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료"); + + return new ReviewSignalSummary + { + P0 = CountToken(source, "P0"), + P1 = CountToken(source, "P1"), + P2 = CountToken(source, "P2"), + P3 = CountToken(source, "P3"), + HasUnfixed = hasUnfixed, + HasFixed = hasFixed, + }; + } + + private static int CountToken(string source, string token) + { + if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token)) + return 0; + + var count = 0; + var index = 0; + while (index < source.Length) + { + var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase); + if (hit < 0) + break; + + var before = hit == 0 ? ' ' : source[hit - 1]; + var afterIndex = hit + token.Length; + var after = afterIndex >= source.Length ? ' ' : source[afterIndex]; + if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after)) + count++; + + index = hit + token.Length; + } + + return count; + } + + private static bool ContainsAny(string source, params string[] needles) + { + foreach (var needle in needles) + { + if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + return false; + } + + private static bool ContainsWholeWord(string source, string token) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token)) + return false; + + var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b"; + return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary) + { + if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(toolName) && + (toolName.Contains("review", StringComparison.OrdinalIgnoreCase) || + toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase))) + return true; + + if (!string.IsNullOrWhiteSpace(title) && + title.Contains("review", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(summary) && + (summary.Contains("P0", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P1", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P2", StringComparison.OrdinalIgnoreCase) || + summary.Contains("P3", StringComparison.OrdinalIgnoreCase))) + return true; + + return false; + } + + private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex) + { + return new Border + { + Background = BrushFromHex(bgHex), + BorderBrush = BrushFromHex(borderHex), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Margin = new Thickness(0, 0, 6, 0), + Padding = new Thickness(8, 2, 8, 2), + Child = new TextBlock + { + Text = text, + FontSize = 10, + FontWeight = FontWeights.SemiBold, + Foreground = BrushFromHex(fgHex), + } + }; + } + + private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary) + { + if (!IsReviewContext(kind, toolName, title, summary)) + return null; + + var signals = ExtractReviewSignals(summary); + if (!signals.HasAny) + return null; + + var row = new WrapPanel + { + Margin = new Thickness(0, 6, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + if (signals.P0 > 0) + row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5")); + if (signals.P1 > 0) + row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74")); + if (signals.P2 > 0) + row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A")); + if (signals.P3 > 0) + row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD")); + + if (signals.HasFixed) + row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC")); + if (signals.HasUnfixed) + row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5")); + + return row.Children.Count == 0 ? null : row; + } + + /// 파일 빠른 작업 버튼 패널을 생성합니다. + private StackPanel BuildFileQuickActions(string filePath) + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + + var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); + var accentBrush = new SolidColorBrush(accentColor); + + Border MakeBtn(string mdlIcon, string tooltip, Action action) + { + var icon = new TextBlock + { + Text = mdlIcon, + FontFamily = s_segoeIconFont, + FontSize = 10, + Foreground = accentBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + var btn = new Border + { + Child = icon, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(4), + Width = 22, + Height = 22, + Margin = new Thickness(0, 0, 2, 0), + Cursor = Cursors.Hand, + ToolTip = tooltip, + }; + btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); }; + btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + btn.MouseLeftButtonUp += (_, _) => action(); + return btn; + } + + // 프리뷰 (지원 확장자만) + var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + if (_previewableExtensions.Contains(ext)) + { + var path1 = filePath; + panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1))); + } + + // 외부 열기 + var path2 = filePath; + panel.Children.Add(MakeBtn("\uE8A7", "열기", () => + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { } + })); + + // 폴더 열기 + var path3 = filePath; + panel.Children.Add(MakeBtn("\uED25", "폴더", () => + { + try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { } + })); + + // 경로 복사 + var path4 = filePath; + panel.Children.Add(MakeBtn("\uE8C8", "복사", () => + { + try { Clipboard.SetText(path4); } catch { } + })); + + return panel; + } + +} diff --git a/src/AxCopilot/Views/PlanViewerWindow.cs b/src/AxCopilot/Views/PlanViewerWindow.cs index 8df7ca7..bda9ea2 100644 --- a/src/AxCopilot/Views/PlanViewerWindow.cs +++ b/src/AxCopilot/Views/PlanViewerWindow.cs @@ -859,11 +859,30 @@ internal sealed class PlanViewerWindow : Window _btnPanel.Children.Clear(); var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accentColor = accentBrush is SolidColorBrush accentSolid ? accentSolid.Color : Color.FromRgb(0x4B, 0x5E, 0xFC); var approveLabel = _uiExpressionLevel == "simple" ? "승인" : "승인 후 실행"; var editLabel = _uiExpressionLevel == "simple" ? "수정" : "수정 피드백"; var rejectLabel = _uiExpressionLevel == "simple" ? "취소" : "거부"; + _btnPanel.Children.Add(new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x16, accentColor.R, accentColor.G, accentColor.B)), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(0, 0, 12, 0), + Child = new TextBlock + { + Text = "검토가 끝나면 바로 실행하거나 방향만 짧게 남길 수 있습니다.", + FontSize = 11.5, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + } + }); + var approveBtn = CreateActionButton("\uE73E", approveLabel, accentBrush, Brushes.White, true); approveBtn.MouseLeftButtonUp += (_, _) => { @@ -904,20 +923,31 @@ internal sealed class PlanViewerWindow : Window private void ShowEditInput() { + var itemBackground = Application.Current.TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var launcherBackground = Application.Current.TryFindResource("LauncherBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); + var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var editPanel = new Border { Margin = new Thickness(20, 0, 20, 12), - Padding = new Thickness(12, 8, 12, 8), - CornerRadius = new CornerRadius(10), - Background = Application.Current.TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)), + Padding = new Thickness(14, 12, 14, 12), + CornerRadius = new CornerRadius(12), + Background = itemBackground, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), }; var editStack = new StackPanel(); editStack.Children.Add(new TextBlock { - Text = "수정 사항을 입력하세요:", + Text = "어떤 방향으로 바꾸면 좋을지 짧게 남겨주세요.", FontSize = 11.5, - Foreground = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 6), }); var textBox = new TextBox @@ -927,29 +957,32 @@ internal sealed class PlanViewerWindow : Window AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, FontSize = 13, - Background = Application.Current.TryFindResource("LauncherBackground") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)), - Foreground = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, - CaretBrush = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, - BorderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + Background = launcherBackground, + Foreground = primaryText, + CaretBrush = primaryText, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(10, 8, 10, 8), }; editStack.Children.Add(textBox); - var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush - ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var actionRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 8, 0, 0), + }; + var sendBtn = new Border { Background = accentBrush, CornerRadius = new CornerRadius(8), Padding = new Thickness(14, 6, 14, 6), - Margin = new Thickness(0, 8, 0, 0), + Margin = new Thickness(0, 0, 8, 0), Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, Child = new TextBlock { - Text = "전송", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, + Text = "수정 요청 보내기", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, }, }; sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; @@ -960,7 +993,36 @@ internal sealed class PlanViewerWindow : Window if (string.IsNullOrEmpty(feedback)) return; _tcs?.TrySetResult(feedback); }; - editStack.Children.Add(sendBtn); + actionRow.Children.Add(sendBtn); + + var closeBtn = new Border + { + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(14, 6, 14, 6), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = "닫기", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + }, + }; + closeBtn.MouseEnter += (s, _) => ((Border)s).Background = itemBackground; + closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + closeBtn.MouseLeftButtonUp += (_, _) => + { + if (editPanel.Parent is Grid grid) + { + grid.Children.Remove(editPanel); + _btnPanel.Margin = new Thickness(20, 12, 20, 16); + } + }; + actionRow.Children.Add(closeBtn); + editStack.Children.Add(actionRow); editPanel.Child = editStack; if (_btnPanel.Parent is Grid parentGrid) @@ -1008,10 +1070,12 @@ internal sealed class PlanViewerWindow : Window Brush textColor, bool filled) { var color = ((SolidColorBrush)borderColor).Color; + var hoverBg = Application.Current?.TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var btn = new Border { - CornerRadius = new CornerRadius(12), - Padding = new Thickness(16, 8, 16, 8), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(16, 9, 16, 9), Margin = new Thickness(4, 0, 4, 0), Cursor = Cursors.Hand, Background = filled ? borderColor @@ -1033,8 +1097,20 @@ internal sealed class PlanViewerWindow : Window Foreground = filled ? Brushes.White : textColor, }); btn.Child = sp; - btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; - btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + btn.MouseEnter += (s, _) => + { + var border = (Border)s; + border.Opacity = 0.96; + if (!filled) + border.Background = hoverBg; + }; + btn.MouseLeave += (s, _) => + { + var border = (Border)s; + border.Opacity = 1.0; + if (!filled) + border.Background = new SolidColorBrush(Color.FromArgb(0x18, color.R, color.G, color.B)); + }; return btn; }