Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.StatusPresentation.cs로 UpdateStatusBar, StartStatusAnimation, StopStatusAnimation을 이동해 runtime 상태 이벤트와 상태선 표현 책임을 메인 창 코드에서 분리함 - ChatWindow.xaml.cs는 transcript 오케스트레이션 중심으로 더 정리했고, claw-code 기준 status/footer 품질 개선을 이어가기 쉬운 구조로 개선함 - README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:39 (KST) 기준 변경 이력을 반영함 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
285 lines
11 KiB
C#
285 lines
11 KiB
C#
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Animation;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
private Storyboard? _statusSpinStoryboard;
|
|
|
|
private AppStateService.OperationalStatusPresentationState BuildOperationalStatusPresentation()
|
|
{
|
|
var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|
|
&& (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0);
|
|
return _appState.GetOperationalStatusPresentation(
|
|
_activeTab,
|
|
hasLiveRuntimeActivity,
|
|
_runningConversationCount,
|
|
_spotlightConversationCount,
|
|
_runningOnlyFilter,
|
|
_sortConversationsByRecent);
|
|
}
|
|
|
|
private void UpdateTaskSummaryIndicators()
|
|
{
|
|
var status = BuildOperationalStatusPresentation();
|
|
|
|
if (RuntimeActivityBadge != null)
|
|
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
|
|
? Visibility.Visible
|
|
: Visibility.Collapsed;
|
|
|
|
if (RuntimeActivityLabel != null)
|
|
RuntimeActivityLabel.Text = status.RuntimeLabel;
|
|
|
|
if (LastCompletedLabel != null)
|
|
{
|
|
LastCompletedLabel.Text = status.LastCompletedText;
|
|
LastCompletedLabel.Visibility = status.ShowLastCompleted ? Visibility.Visible : Visibility.Collapsed;
|
|
}
|
|
|
|
if (ConversationStatusStrip != null && ConversationStatusStripLabel != null)
|
|
{
|
|
ConversationStatusStrip.Visibility = status.ShowCompactStrip
|
|
? Visibility.Visible
|
|
: Visibility.Collapsed;
|
|
ConversationStatusStripLabel.Text = status.ShowCompactStrip ? status.StripText : "";
|
|
|
|
if (status.ShowCompactStrip)
|
|
{
|
|
ConversationStatusStrip.Background = BrushFromHex(status.StripBackgroundHex);
|
|
ConversationStatusStrip.BorderBrush = BrushFromHex(status.StripBorderHex);
|
|
ConversationStatusStripLabel.Foreground = BrushFromHex(status.StripForegroundHex);
|
|
}
|
|
}
|
|
|
|
UpdateConversationQuickStripUi(status);
|
|
}
|
|
|
|
private void UpdateConversationQuickStripUi()
|
|
{
|
|
UpdateConversationQuickStripUi(BuildOperationalStatusPresentation());
|
|
}
|
|
|
|
private void UpdateConversationQuickStripUi(AppStateService.OperationalStatusPresentationState status)
|
|
{
|
|
if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null
|
|
|| BtnQuickRunningFilter == null || BtnQuickHotSort == null)
|
|
return;
|
|
|
|
ConversationQuickStrip.Visibility = status.ShowQuickStrip
|
|
? Visibility.Visible
|
|
: Visibility.Collapsed;
|
|
|
|
QuickRunningLabel.Text = status.QuickRunningText;
|
|
QuickHotLabel.Text = status.QuickHotText;
|
|
|
|
BtnQuickRunningFilter.Background = BrushFromHex(status.QuickRunningBackgroundHex);
|
|
BtnQuickRunningFilter.BorderBrush = BrushFromHex(status.QuickRunningBorderHex);
|
|
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
|
|
QuickRunningLabel.Foreground = BrushFromHex(status.QuickRunningForegroundHex);
|
|
|
|
BtnQuickHotSort.Background = BrushFromHex(status.QuickHotBackgroundHex);
|
|
BtnQuickHotSort.BorderBrush = BrushFromHex(status.QuickHotBorderHex);
|
|
BtnQuickHotSort.BorderThickness = new Thickness(1);
|
|
QuickHotLabel.Foreground = BrushFromHex(status.QuickHotForegroundHex);
|
|
}
|
|
|
|
private void SetStatus(string text, bool spinning)
|
|
{
|
|
if (StatusLabel != null)
|
|
StatusLabel.Text = text;
|
|
|
|
if (spinning)
|
|
StartStatusAnimation();
|
|
else
|
|
StopStatusAnimation();
|
|
}
|
|
|
|
private static bool IsDecisionPending(string? summary)
|
|
{
|
|
var text = summary?.Trim() ?? "";
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return true;
|
|
|
|
return text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsDecisionApproved(string? summary)
|
|
{
|
|
var text = summary?.Trim() ?? "";
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return false;
|
|
|
|
return text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsDecisionRejected(string? summary)
|
|
{
|
|
var text = summary?.Trim() ?? "";
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return false;
|
|
|
|
return text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("취소", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string GetDecisionStatusText(string? summary)
|
|
{
|
|
if (IsDecisionPending(summary))
|
|
return "계획 승인 대기 중";
|
|
if (IsDecisionApproved(summary))
|
|
return "계획 승인됨 · 실행 시작";
|
|
if (IsDecisionRejected(summary))
|
|
return "계획 반려됨 · 계획 재작성";
|
|
return string.IsNullOrWhiteSpace(summary) ? "사용자 의사결정 대기 중" : TruncateForStatus(summary);
|
|
}
|
|
|
|
private void SetStatusIdle()
|
|
{
|
|
StopStatusAnimation();
|
|
if (StatusLabel != null)
|
|
StatusLabel.Text = "대기 중";
|
|
if (StatusElapsed != null)
|
|
{
|
|
StatusElapsed.Text = "";
|
|
StatusElapsed.Visibility = Visibility.Collapsed;
|
|
}
|
|
if (StatusTokens != null)
|
|
{
|
|
StatusTokens.Text = "";
|
|
StatusTokens.Visibility = Visibility.Collapsed;
|
|
}
|
|
RefreshContextUsageVisual();
|
|
ScheduleGitBranchRefresh(250);
|
|
}
|
|
|
|
private void UpdateStatusTokens(int inputTokens, int outputTokens)
|
|
{
|
|
if (StatusTokens == null)
|
|
return;
|
|
|
|
var llm = _settings.Settings.Llm;
|
|
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
|
|
inputTokens, outputTokens, llm.Service, llm.Model);
|
|
var totalCost = inCost + outCost;
|
|
var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : "";
|
|
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
|
|
StatusTokens.Visibility = Visibility.Visible;
|
|
RefreshContextUsageVisual();
|
|
}
|
|
private void UpdateStatusBar(AgentEvent evt)
|
|
{
|
|
var toolLabel = evt.ToolName switch
|
|
{
|
|
"file_read" or "document_read" => "파일 읽기",
|
|
"file_write" => "파일 쓰기",
|
|
"file_edit" => "파일 수정",
|
|
"html_create" => "HTML 생성",
|
|
"xlsx_create" => "Excel 생성",
|
|
"docx_create" => "Word 생성",
|
|
"csv_create" => "CSV 생성",
|
|
"md_create" => "Markdown 생성",
|
|
"folder_map" => "폴더 탐색",
|
|
"glob" => "파일 검색",
|
|
"grep" => "내용 검색",
|
|
"process" => "명령 실행",
|
|
_ => evt.ToolName,
|
|
};
|
|
|
|
var isDebugLogLevel = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase);
|
|
|
|
switch (evt.Type)
|
|
{
|
|
case AgentEventType.Thinking:
|
|
SetStatus("생각 중...", spinning: true);
|
|
break;
|
|
case AgentEventType.Planning:
|
|
SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true);
|
|
break;
|
|
case AgentEventType.PermissionRequest:
|
|
SetStatus($"권한 확인 중: {toolLabel}", spinning: false);
|
|
break;
|
|
case AgentEventType.PermissionGranted:
|
|
SetStatus($"권한 승인됨: {toolLabel}", spinning: false);
|
|
break;
|
|
case AgentEventType.PermissionDenied:
|
|
SetStatus($"권한 거부됨: {toolLabel}", spinning: false);
|
|
StopStatusAnimation();
|
|
break;
|
|
case AgentEventType.Decision:
|
|
SetStatus(GetDecisionStatusText(evt.Summary), spinning: IsDecisionPending(evt.Summary));
|
|
break;
|
|
case AgentEventType.ToolCall:
|
|
if (!isDebugLogLevel)
|
|
break;
|
|
SetStatus($"{toolLabel} 실행 중...", spinning: true);
|
|
break;
|
|
case AgentEventType.ToolResult:
|
|
SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false);
|
|
break;
|
|
case AgentEventType.StepStart:
|
|
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true);
|
|
break;
|
|
case AgentEventType.StepDone:
|
|
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true);
|
|
break;
|
|
case AgentEventType.SkillCall:
|
|
if (!isDebugLogLevel)
|
|
break;
|
|
SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true);
|
|
break;
|
|
case AgentEventType.Complete:
|
|
SetStatus("작업 완료", spinning: false);
|
|
StopStatusAnimation();
|
|
break;
|
|
case AgentEventType.Error:
|
|
SetStatus("오류 발생", spinning: false);
|
|
StopStatusAnimation();
|
|
break;
|
|
case AgentEventType.Paused:
|
|
if (!isDebugLogLevel)
|
|
break;
|
|
SetStatus("⏸ 일시정지", spinning: false);
|
|
break;
|
|
case AgentEventType.Resumed:
|
|
if (!isDebugLogLevel)
|
|
break;
|
|
SetStatus("▶ 재개됨", spinning: true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void StartStatusAnimation()
|
|
{
|
|
if (_statusSpinStoryboard != null)
|
|
return;
|
|
|
|
var anim = new DoubleAnimation
|
|
{
|
|
From = 0,
|
|
To = 360,
|
|
Duration = TimeSpan.FromSeconds(2),
|
|
RepeatBehavior = RepeatBehavior.Forever,
|
|
};
|
|
|
|
_statusSpinStoryboard = new Storyboard();
|
|
Storyboard.SetTarget(anim, StatusDiamond);
|
|
Storyboard.SetTargetProperty(anim,
|
|
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
|
|
_statusSpinStoryboard.Children.Add(anim);
|
|
_statusSpinStoryboard.Begin();
|
|
}
|
|
|
|
private void StopStatusAnimation()
|
|
{
|
|
_statusSpinStoryboard?.Stop();
|
|
_statusSpinStoryboard = null;
|
|
}
|
|
}
|