변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
1599 lines
66 KiB
C#
1599 lines
66 KiB
C#
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<string> _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 = 12.5,
|
||
FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"),
|
||
Foreground = secondary,
|
||
Opacity = 0.60,
|
||
LineHeight = 18,
|
||
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<TaskRunStore.TaskRun> FilterTaskSummaryItems(IEnumerable<TaskRunStore.TaskRun> 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;
|
||
|
||
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
|
||
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> 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<string>();
|
||
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.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = Brushes.White,
|
||
LineHeight = 18,
|
||
};
|
||
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);
|
||
}
|
||
|
||
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
|
||
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
|
||
{
|
||
outerStack.Children.Clear();
|
||
|
||
// 부모 컨테이너(Border)의 테두리·배경 제거 — 승인 후 깔끔하게
|
||
if (outerStack.Parent is Border containerBorder)
|
||
{
|
||
containerBorder.BorderThickness = new Thickness(0);
|
||
containerBorder.Background = Brushes.Transparent;
|
||
containerBorder.Padding = new Thickness(0, 2, 0, 2);
|
||
}
|
||
|
||
var resultLabel = new TextBlock
|
||
{
|
||
Text = resultText,
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = fg,
|
||
Opacity = 0.8,
|
||
LineHeight = 18,
|
||
Margin = new Thickness(40, 2, 0, 2),
|
||
};
|
||
outerStack.Children.Add(resultLabel);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 후속 작업 제안 칩 (suggest_actions)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
|
||
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)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>피드백 컨텍스트 캐시 — 1분간 유효.</summary>
|
||
private string? _feedbackContextCache;
|
||
private DateTime _feedbackContextCacheExpiry;
|
||
|
||
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
|
||
/// <remarks>
|
||
/// 성능 최적화: LoadAllMeta() + Load() x20 은 모든 .axchat 파일을 복호화하므로
|
||
/// 매 전송마다 호출하면 수 초~10초 이상 UI를 블로킹합니다.
|
||
/// 결과를 1분간 캐시하고, 현재 대화의 피드백만 즉시 반영합니다.
|
||
/// </remarks>
|
||
private string BuildFeedbackContext()
|
||
{
|
||
// 캐시가 유효하면 즉시 반환 — 디스크 I/O 없음
|
||
if (_feedbackContextCache != null && DateTime.UtcNow < _feedbackContextCacheExpiry)
|
||
return _feedbackContextCache;
|
||
|
||
try
|
||
{
|
||
// 현재 대화에서만 피드백 수집 — 디스크 I/O 없이 메모리 내 처리
|
||
var likedPatterns = new List<string>();
|
||
var dislikedPatterns = new List<string>();
|
||
|
||
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 ""; }
|
||
}
|
||
|
||
/// <summary>백그라운드에서 전체 대화 피드백을 수집하여 캐시를 갱신합니다.</summary>
|
||
private void RefreshFeedbackCacheBackground()
|
||
{
|
||
try
|
||
{
|
||
var metaList = _storage.LoadAllMeta()
|
||
.OrderByDescending(m => m.UpdatedAt)
|
||
.Take(10)
|
||
.ToList();
|
||
|
||
var liked = new List<string>();
|
||
var disliked = new List<string>();
|
||
|
||
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<string> likedPatterns, List<string> 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();
|
||
}
|
||
|
||
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
|
||
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;
|
||
}
|
||
|
||
}
|