에이전트 선택적 탐색 구조 개선과 경고 정리 반영
Some checks failed
Release Gate / gate (push) Has been cancelled

- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
2026-04-09 14:27:59 +09:00
parent 7931566212
commit 33c1db4dae
119 changed files with 4453 additions and 6943 deletions

View File

@@ -42,10 +42,30 @@ public partial class ChatWindow
/// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다.
/// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지).
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
/// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{
// ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ──
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var result = _chatEngine.AppendExecutionEvent(
session, null!, _currentConversation, _activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}");
}
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
}

View File

@@ -181,9 +181,9 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth();
var stack = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0),
Margin = new Thickness(48, 1, 12, 1),
};
var liveWaitingStyle = evt.Type == AgentEventType.Thinking
@@ -400,13 +400,12 @@ public partial class ChatWindow
return new Border
{
Background = cardBackground,
BorderBrush = cardBorder,
BorderThickness = new Thickness(1),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(12, 6, 12, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0, 4, 0, 4),
Margin = new Thickness(0, 1, 12, 1),
HorizontalAlignment = HorizontalAlignment.Left,
Child = contentGrid,
};
}
@@ -1186,6 +1185,30 @@ public partial class ChatWindow
};
}
// HTML/대용량 파일 내용이 이벤트 요약에 포함된 경우 인라인 표시 대신 1줄 요약으로 축소
if (!string.IsNullOrWhiteSpace(eventSummaryText) && evt.Type == AgentEventType.ToolResult
&& eventSummaryText.Length > 500)
{
// HTML 내용 감지: <!DOCTYPE, <html, <head, <body, <div 등
var trimmed = eventSummaryText.TrimStart();
var firstLine = trimmed.Split('\n')[0];
if (trimmed.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
|| trimmed.Contains("<html", StringComparison.OrdinalIgnoreCase)
|| (evt.FilePath?.EndsWith(".html", StringComparison.OrdinalIgnoreCase) == true)
|| (evt.FilePath?.EndsWith(".htm", StringComparison.OrdinalIgnoreCase) == true))
{
var lineCount = eventSummaryText.Count(c => c == '\n') + 1;
eventSummaryText = $"{firstLine}\n({lineCount}줄 · HTML 문서 — 미리보기 패널에서 확인)";
}
else if (eventSummaryText.Length > 2000)
{
// 기타 대용량 텍스트: 첫 3줄만 표시
var lines = eventSummaryText.Split('\n');
var preview = string.Join("\n", lines.Take(3));
eventSummaryText = preview + $"\n... ({lines.Length}줄 전체)";
}
}
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
UpdateProgressBar(evt);
@@ -1204,13 +1227,12 @@ public partial class ChatWindow
var bannerMaxWidth = GetMessageMaxWidth();
var banner = new Border
{
Background = hintBg,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(9, 7, 9, 7),
Margin = new Thickness(12, 3, 12, 3),
HorizontalAlignment = HorizontalAlignment.Center,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 5, 14, 5),
Margin = new Thickness(48, 1, 12, 1),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = bannerMaxWidth,
};
if (!string.IsNullOrWhiteSpace(evt.RunId))

View File

@@ -33,12 +33,13 @@ public partial class ChatWindow
var triggerRatio = triggerPercent / 100.0;
// 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지)
// 스트리밍 중에는 매번 재계산 (도구 결과 메시지가 실시간으로 추가됨)
int messageTokens;
lock (_convLock)
{
var convId = _currentConversation?.Id;
var msgCount = _currentConversation?.Messages?.Count ?? 0;
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens)
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens || _isStreaming)
{
_cachedMessageTokens = msgCount > 0
? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages)

View File

@@ -16,6 +16,165 @@ public partial class ChatWindow
private const int ConversationPageSize = 50;
private List<ConversationMeta>? _pendingConversations;
// ── A-1: 이벤트 위임 필드 ──
/// <summary>현재 마우스가 올라가 있는 대화 항목 Border.</summary>
private Border? _lastHoveredConvBorder;
private bool _convPanelDelegationInitialized;
/// <summary>대화 항목 Border.Tag에 저장하는 메타 데이터.</summary>
private sealed class ConversationItemTag
{
public required string Id { get; init; }
public required bool IsSelected { get; init; }
public TextBlock? TitleBlock { get; init; }
public Brush? TitleColor { get; init; }
public Button? CatButton { get; init; }
public Brush? HoverBackground { get; init; }
}
/// <summary>ConversationPanel에 이벤트 위임 핸들러를 1회 등록합니다.</summary>
private void InitConversationPanelDelegation()
{
if (_convPanelDelegationInitialized || ConversationPanel == null)
return;
_convPanelDelegationInitialized = true;
ConversationPanel.MouseMove += ConversationPanel_DelegatedMouseMove;
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp;
}
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredConvBorder))
return;
// 이전 호버 해제
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
{
if (!prevTag.IsSelected)
_lastHoveredConvBorder.Background = Brushes.Transparent;
if (prevTag.CatButton != null)
prevTag.CatButton.Visibility = Visibility.Collapsed;
}
_lastHoveredConvBorder = border;
// 새 호버 적용
if (border?.Tag is ConversationItemTag tag)
{
if (!tag.IsSelected)
border.Background = tag.HoverBackground ?? Brushes.Transparent;
if (tag.CatButton != null)
tag.CatButton.Visibility = Visibility.Visible;
}
}
private void ConversationPanel_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
{
if (!prevTag.IsSelected)
_lastHoveredConvBorder.Background = Brushes.Transparent;
if (prevTag.CatButton != null)
prevTag.CatButton.Visibility = Visibility.Collapsed;
}
_lastHoveredConvBorder = null;
}
private void ConversationPanel_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// catBtn Click은 자체 이벤트로 처리 — Button 내부 클릭이면 무시
if (FindAncestor<Button>(e.OriginalSource as DependencyObject) is not null)
return;
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not ConversationItemTag tag)
return;
e.Handled = true;
HandleConversationItemClick(tag);
}
private void ConversationPanel_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not ConversationItemTag tag)
return;
e.Handled = true;
HandleConversationItemRightClick(tag);
}
private void HandleConversationItemClick(ConversationItemTag tag)
{
try
{
if (tag.IsSelected)
{
if (tag.TitleBlock != null && tag.TitleColor != null)
EnterTitleEditMode(tag.TitleBlock, tag.Id, tag.TitleColor);
return;
}
if (_streamingTabs.Contains(_activeTab))
{
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(tag.Id);
if (conv == null)
return;
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
}
private void HandleConversationItemRightClick(ConversationItemTag tag)
{
if (!tag.IsSelected)
{
var conv = _storage.Load(tag.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(tag.Id)), DispatcherPriority.Input);
}
public void RefreshConversationList()
{
var metas = _storage.LoadAllMeta();
@@ -66,20 +225,29 @@ public partial class ChatWindow
};
}).ToList();
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
items = items.Where(i =>
i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)
string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)
&& (i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase))
).ToList();
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
_runningConversationCount = items.Count(i => i.IsRunning);
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
// Count를 한 번의 루프로 계산 (3번 순회 → 1번)
int failedCount = 0, runningCount = 0, spotlightCount = 0;
foreach (var i in items)
{
if (i.FailedAgentRunCount > 0) failedCount++;
if (i.IsRunning) runningCount++;
if (i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) spotlightCount++;
}
_failedConversationCount = failedCount;
_runningConversationCount = runningCount;
_spotlightConversationCount = spotlightCount;
UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi();
UpdateConversationQuickStripUi();
@@ -137,6 +305,7 @@ public partial class ChatWindow
private void RenderConversationList(List<ConversationMeta> items)
{
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear();
_pendingConversations = null;
@@ -211,6 +380,8 @@ public partial class ChatWindow
Padding = new Thickness(8, 10, 8, 10),
Margin = new Thickness(6, 4, 6, 4),
HorizontalAlignment = HorizontalAlignment.Stretch,
// Tag를 설정하지 않아 이벤트 위임 대상에서 제외됨 (별도 핸들러)
Tag = new LoadMoreTag(),
};
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
stack.Children.Add(new TextBlock
@@ -238,6 +409,7 @@ public partial class ChatWindow
var all = _pendingConversations;
_pendingConversations = null;
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear();
string? lastGroup = null;
@@ -256,6 +428,9 @@ public partial class ChatWindow
ConversationPanel.Children.Add(btn);
}
/// <summary>"더 보기" 버튼의 Tag — ConversationItemTag와 구분하여 이벤트 위임에서 무시.</summary>
private sealed class LoadMoreTag;
private static string GetConversationDateGroup(DateTime updatedAt)
{
var today = DateTime.Today;
@@ -440,85 +615,15 @@ public partial class ChatWindow
border.Child = grid;
var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF));
border.MouseEnter += (_, _) =>
// A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장, 부모에서 처리
border.Tag = new ConversationItemTag
{
if (!isSelected)
border.Background = hoverBg;
catBtn.Visibility = Visibility.Visible;
};
border.MouseLeave += (_, _) =>
{
if (!isSelected)
border.Background = Brushes.Transparent;
catBtn.Visibility = Visibility.Collapsed;
};
border.MouseLeftButtonDown += (_, _) =>
{
try
{
if (isSelected)
{
EnterTitleEditMode(title, item.Id, titleColor);
return;
}
if (_streamingTabs.Contains(_activeTab))
{
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(item.Id);
if (conv == null)
return;
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
};
border.MouseRightButtonUp += (_, me) =>
{
me.Handled = true;
if (!isSelected)
{
var conv = _storage.Load(item.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
Id = item.Id,
IsSelected = isSelected,
TitleBlock = title,
TitleColor = titleColor,
CatButton = catBtn,
HoverBackground = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)),
};
ConversationPanel.Children.Add(border);

View File

@@ -62,9 +62,75 @@ public partial class ChatWindow
FileBrowserPanel.Visibility = Visibility.Collapsed;
}
// ── A-3: Clear 전 명시적 핸들러 해제 ──
/// <summary>TreeViewItem의 이벤트 핸들러를 재귀적으로 해제합니다.</summary>
private void DetachFileTreeHandlers(ItemCollection items)
{
foreach (var item in items.OfType<TreeViewItem>())
{
item.Expanded -= FileTreeItem_Expanded;
item.MouseDoubleClick -= FileTreeItem_DoubleClick;
item.MouseRightButtonUp -= FileTreeItem_RightClick;
DetachFileTreeHandlers(item.Items);
}
}
/// <summary>TreeViewItem의 깊이를 부모 탐색으로 계산합니다.</summary>
private static int GetTreeItemDepth(TreeViewItem item)
{
var depth = 0;
var parent = ItemsControl.ItemsControlFromItemContainer(item);
while (parent is TreeViewItem)
{
depth++;
parent = ItemsControl.ItemsControlFromItemContainer(parent);
}
return depth;
}
// ── 명명 핸들러 (람다 대체) ──
private void FileTreeItem_Expanded(object sender, RoutedEventArgs e)
{
if (sender is not TreeViewItem ti)
return;
if (ti.Items.Count != 1 || ti.Items[0] is not TreeViewItem placeholder || placeholder.Header?.ToString() != "로딩 중...")
return;
ti.Items.Clear();
if (ti.Tag is string dirPath && Directory.Exists(dirPath))
{
var depth = GetTreeItemDepth(ti);
var count = 0;
PopulateDirectory(new DirectoryInfo(dirPath), ti.Items, depth, ref count);
}
}
private void FileTreeItem_DoubleClick(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
if (sender is TreeViewItem ti && ti.Tag is string path)
TryShowPreview(path);
}
private void FileTreeItem_RightClick(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
if (sender is TreeViewItem ti)
{
ti.IsSelected = true;
if (ti.Tag is string path)
ShowFileTreeContextMenu(path);
}
}
private void BuildFileTree()
{
// A-3: Clear 전에 핸들러 명시적 해제 — 클로저 참조로 인한 GC 방해 방지
DetachFileTreeHandlers(FileTreeView.Items);
FileTreeView.Items.Clear();
var folder = GetCurrentWorkFolder();
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
{
@@ -102,17 +168,8 @@ public partial class ChatWindow
if (depth < 3)
{
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." });
var capturedDir = subDir;
var capturedDepth = depth;
dirItem.Expanded += (s, _) =>
{
if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
{
ti.Items.Clear();
var c = 0;
PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
}
};
// A-3: 명명 메서드 핸들러 — Tag에서 경로, GetTreeItemDepth()로 깊이 추출
dirItem.Expanded += FileTreeItem_Expanded;
}
else
{
@@ -144,20 +201,9 @@ public partial class ChatWindow
Tag = file.FullName,
};
var capturedPath = file.FullName;
fileItem.MouseDoubleClick += (_, e) =>
{
e.Handled = true;
TryShowPreview(capturedPath);
};
fileItem.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
if (s is TreeViewItem ti)
ti.IsSelected = true;
ShowFileTreeContextMenu(capturedPath);
};
// A-3: 명명 메서드 핸들러 — Tag에서 경로 추출, 해제 가능
fileItem.MouseDoubleClick += FileTreeItem_DoubleClick;
fileItem.MouseRightButtonUp += FileTreeItem_RightClick;
items.Add(fileItem);
}
@@ -310,12 +356,17 @@ public partial class ChatWindow
return;
_fileBrowserRefreshTimer?.Stop();
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_fileBrowserRefreshTimer.Tick += (_, _) =>
if (_fileBrowserRefreshTimer == null)
{
_fileBrowserRefreshTimer.Stop();
BuildFileTree();
};
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_fileBrowserRefreshTimer.Tick += FileBrowserRefreshTimer_Tick;
}
_fileBrowserRefreshTimer.Start();
}
private void FileBrowserRefreshTimer_Tick(object? sender, EventArgs e)
{
_fileBrowserRefreshTimer?.Stop();
BuildFileTree();
}
}

View File

@@ -12,7 +12,7 @@ public partial class ChatWindow
{
private void UpdateGitBranchUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility)
{
Dispatcher.Invoke(() =>
_ = Dispatcher.InvokeAsync(() =>
{
_currentGitBranchName = branchName;
_currentGitTooltip = tooltip;
@@ -152,9 +152,84 @@ public partial class ChatWindow
if (BtnGitBranch == null)
return;
await RefreshGitBranchStatusAsync();
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
try
{
await RefreshGitBranchStatusAsync();
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
}
catch (Exception ex)
{
Services.LogService.Error($"Git branch refresh failed: {ex.Message}");
}
}
// ── A-2: GitBranchItems 이벤트 위임 ──
private bool _gitBranchDelegationInitialized;
private Border? _lastHoveredGitBorder;
/// <summary>클릭 가능한 팝업 행의 Tag.</summary>
private sealed class PopupRowTag
{
public required Action OnClick { get; init; }
public required bool Selected { get; init; }
public Brush? HintBackground { get; init; }
public Brush? HoverBackground { get; init; }
}
private void InitGitBranchDelegation()
{
if (_gitBranchDelegationInitialized || GitBranchItems == null)
return;
_gitBranchDelegationInitialized = true;
GitBranchItems.MouseMove += GitBranch_DelegatedMouseMove;
GitBranchItems.MouseLeave += GitBranch_DelegatedMouseLeave;
GitBranchItems.PreviewMouseLeftButtonUp += GitBranch_DelegatedLeftButtonUp;
GitBranchItems.PreviewKeyDown += GitBranch_DelegatedKeyDown;
}
private void GitBranch_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredGitBorder))
return;
if (_lastHoveredGitBorder?.Tag is PopupRowTag prevTag)
_lastHoveredGitBorder.Background = prevTag.Selected ? (prevTag.HintBackground ?? Brushes.Transparent) : Brushes.Transparent;
_lastHoveredGitBorder = border;
if (border?.Tag is PopupRowTag tag)
border.Background = tag.Selected ? (tag.HintBackground ?? Brushes.Transparent) : (tag.HoverBackground ?? Brushes.Transparent);
}
private void GitBranch_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredGitBorder?.Tag is PopupRowTag prevTag)
_lastHoveredGitBorder.Background = prevTag.Selected ? (prevTag.HintBackground ?? Brushes.Transparent) : Brushes.Transparent;
_lastHoveredGitBorder = null;
}
private void GitBranch_DelegatedLeftButtonUp(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PopupRowTag tag)
{
e.Handled = true;
tag.OnClick();
}
}
private void GitBranch_DelegatedKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is not (Key.Enter or Key.Space))
return;
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PopupRowTag tag)
{
e.Handled = true;
tag.OnClick();
}
}
private void BuildGitBranchPopup()
@@ -162,6 +237,8 @@ public partial class ChatWindow
if (GitBranchItems == null)
return;
InitGitBranchDelegation();
_lastHoveredGitBorder = null;
GitBranchItems.Children.Clear();
var gitRoot = _currentGitRoot ?? ResolveGitRoot(GetCurrentWorkFolder());
@@ -462,18 +539,14 @@ public partial class ChatWindow
border.Child = grid;
// A-2: 이벤트 위임 — 개별 람다 대신 Tag에 액션 저장, 부모에서 처리
if (clickable && onClick != null)
{
border.MouseEnter += (_, _) => border.Background = hoverBrush;
border.MouseLeave += (_, _) => border.Background = Brushes.Transparent;
border.MouseLeftButtonUp += (_, _) => onClick();
border.KeyDown += (_, keyEvent) =>
border.Tag = new PopupRowTag
{
if (keyEvent.Key is Key.Enter or Key.Space)
{
keyEvent.Handled = true;
onClick();
}
OnClick = onClick,
Selected = false,
HoverBackground = hoverBrush,
};
}

View File

@@ -173,13 +173,11 @@ public partial class ChatWindow
var contentCard = new Border
{
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(11, 8, 11, 8),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(2, 4, 2, 4),
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
contentCard.SetResourceReference(Border.BackgroundProperty, "ItemBackground");
var contentStack = new StackPanel();
var app = System.Windows.Application.Current as App;

View File

@@ -14,9 +14,91 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
// ── A-2: PermissionItems 이벤트 위임 ──
private bool _permPanelDelegationInitialized;
private sealed class PermissionItemTag
{
public required string Level { get; init; }
public required bool IsActive { get; init; }
public required Brush SelectedBackground { get; init; }
public required Brush HoverBackground { get; init; }
}
private void InitPermissionPanelDelegation()
{
if (_permPanelDelegationInitialized || PermissionItems == null)
return;
_permPanelDelegationInitialized = true;
PermissionItems.MouseMove += PermissionItems_DelegatedMouseMove;
PermissionItems.MouseLeave += PermissionItems_DelegatedMouseLeave;
PermissionItems.PreviewMouseLeftButtonDown += PermissionItems_DelegatedLeftButtonDown;
PermissionItems.PreviewKeyDown += PermissionItems_DelegatedKeyDown;
}
private Border? _lastHoveredPermBorder;
private void PermissionItems_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredPermBorder))
return;
if (_lastHoveredPermBorder?.Tag is PermissionItemTag prevTag)
_lastHoveredPermBorder.Background = prevTag.IsActive ? prevTag.SelectedBackground : Brushes.Transparent;
_lastHoveredPermBorder = border;
if (border?.Tag is PermissionItemTag tag)
border.Background = tag.IsActive ? tag.SelectedBackground : tag.HoverBackground;
}
private void PermissionItems_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredPermBorder?.Tag is PermissionItemTag prevTag)
_lastHoveredPermBorder.Background = prevTag.IsActive ? prevTag.SelectedBackground : Brushes.Transparent;
_lastHoveredPermBorder = null;
}
private void PermissionItems_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PermissionItemTag tag)
{
e.Handled = true;
ApplyPermissionLevel(tag.Level);
}
}
private void PermissionItems_DelegatedKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is not (Key.Enter or Key.Space))
return;
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PermissionItemTag tag)
{
e.Handled = true;
ApplyPermissionLevel(tag.Level);
}
}
private void ApplyPermissionLevel(string level)
{
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(level);
try { _settings.Save(); } catch { }
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
RefreshOverlayModeButtons();
PermissionPopup.IsOpen = false;
}
private void BtnPermission_Click(object sender, RoutedEventArgs e)
{
if (PermissionPopup == null) return;
InitPermissionPanelDelegation();
_lastHoveredPermBorder = null;
PermissionItems.Children.Clear();
ChatConversation? currentConversation;
@@ -96,30 +178,14 @@ public partial class ChatWindow
row.Children.Add(check);
rowBorder.Child = row;
rowBorder.MouseEnter += (_, _) => rowBorder.Background = isActive ? selectedBackground : hoverBackground;
rowBorder.MouseLeave += (_, _) => rowBorder.Background = isActive ? selectedBackground : Brushes.Transparent;
var capturedLevel = level;
void ApplyPermission()
// A-2: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장
rowBorder.Tag = new PermissionItemTag
{
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel);
try { _settings.Save(); } catch { }
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
RefreshOverlayModeButtons();
PermissionPopup.IsOpen = false;
}
rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission();
rowBorder.KeyDown += (_, ke) =>
{
if (ke.Key is Key.Enter or Key.Space)
{
ke.Handled = true;
ApplyPermission();
}
Level = level,
IsActive = isActive,
SelectedBackground = selectedBackground,
HoverBackground = hoverBackground,
};
container.Children.Add(rowBorder);

View File

@@ -25,6 +25,12 @@ public partial class ChatWindow
_planViewerWindow?.LoadPlan(planSummary, steps, tcs);
ShowPlanButton(true);
AddDecisionButtons(tcs, options);
// 플랜 창 자동 표시 (사용자가 직접 BtnPlanViewer 클릭 안 해도 됨)
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
{
_planViewerWindow.Show();
_planViewerWindow.Activate();
}
});
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
@@ -88,61 +94,61 @@ public partial class ChatWindow
private void ShowPlanButton(bool show)
{
if (!show)
// 레거시: 이전 세션에서 동적 주입된 MoodIconPanel 칩 정리
try
{
for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--)
{
if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn")
{
if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep")
MoodIconPanel.Children.RemoveAt(i - 1);
if (i < MoodIconPanel.Children.Count)
MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1));
// separator 먼저 제거 (인덱스 시프트 전)
int sepIdx = i - 1;
MoodIconPanel.Children.RemoveAt(i); // PlanBtn 제거
if (sepIdx >= 0 && sepIdx < MoodIconPanel.Children.Count
&& MoodIconPanel.Children[sepIdx] is Border sep && sep.Tag?.ToString() == "PlanSep")
MoodIconPanel.Children.RemoveAt(sepIdx);
break;
}
}
}
catch
{
// 레거시 정리 실패 시에도 버튼 토글은 반드시 실행
}
// StatusBar의 XAML 선언 계획 버튼 토글
BtnPlanViewer.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
}
private void BtnPlanViewer_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
if (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0)
return;
}
foreach (var child in MoodIconPanel.Children)
EnsurePlanViewerWindow();
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
{
if (child is Border b && b.Tag?.ToString() == "PlanBtn")
return;
}
var separator = new Border
{
Width = 1,
Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
Tag = "PlanSep",
};
MoodIconPanel.Children.Add(separator);
var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981");
planBtn.Tag = "PlanBtn";
planBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
if (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0)
return;
EnsurePlanViewerWindow();
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|| !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps))
{
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|| !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps))
{
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
}
_planViewerWindow.Show();
_planViewerWindow.Activate();
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
}
};
MoodIconPanel.Children.Add(planBtn);
_planViewerWindow.Show();
_planViewerWindow.Activate();
}
}
/// <summary>계획 버튼 호버 효과 초기화 (Loaded에서 호출).</summary>
private void InitPlanButtonHover()
{
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var normalBg = TryFindResource("HintBackground") as Brush ?? Brushes.Transparent;
BtnPlanViewer.MouseEnter += (_, _) => BtnPlanViewer.Background = hoverBg;
BtnPlanViewer.MouseLeave += (_, _) => BtnPlanViewer.Background = normalBg;
}
private void UpdatePlanViewerStep(AgentEvent evt)

View File

@@ -12,6 +12,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Views;
@@ -21,6 +22,7 @@ public partial class ChatWindow
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
".docx", ".xlsx", ".pptx",
};
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
@@ -29,6 +31,124 @@ public partial class ChatWindow
private bool _webViewInitialized;
private Popup? _previewTabPopup;
// ── A-2: PreviewTabPanel 이벤트 위임 ──
private bool _previewTabDelegationInitialized;
private Border? _lastHoveredPreviewTab;
private sealed class PreviewTabTag
{
public required string FilePath { get; init; }
public required bool IsActive { get; init; }
public Border? CloseButton { get; init; }
}
private void InitPreviewTabDelegation()
{
if (_previewTabDelegationInitialized || PreviewTabPanel == null)
return;
_previewTabDelegationInitialized = true;
PreviewTabPanel.MouseMove += PreviewTab_DelegatedMouseMove;
PreviewTabPanel.MouseLeave += PreviewTab_DelegatedMouseLeave;
PreviewTabPanel.PreviewMouseLeftButtonUp += PreviewTab_DelegatedLeftButtonUp;
PreviewTabPanel.PreviewMouseLeftButtonDown += PreviewTab_DelegatedLeftButtonDown;
PreviewTabPanel.PreviewMouseRightButtonUp += PreviewTab_DelegatedRightButtonUp;
}
private void PreviewTab_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredPreviewTab))
return;
// 이전 호버 해제
if (_lastHoveredPreviewTab?.Tag is PreviewTabTag prevTag)
{
if (!prevTag.IsActive)
{
_lastHoveredPreviewTab.Background = Brushes.Transparent;
if (prevTag.CloseButton != null)
prevTag.CloseButton.Visibility = Visibility.Hidden;
}
}
_lastHoveredPreviewTab = border;
// 새 호버 적용
if (border?.Tag is PreviewTabTag tag && !tag.IsActive)
{
border.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
if (tag.CloseButton != null)
tag.CloseButton.Visibility = Visibility.Visible;
}
}
private void PreviewTab_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredPreviewTab?.Tag is PreviewTabTag prevTag && !prevTag.IsActive)
{
_lastHoveredPreviewTab.Background = Brushes.Transparent;
if (prevTag.CloseButton != null)
prevTag.CloseButton.Visibility = Visibility.Hidden;
}
_lastHoveredPreviewTab = null;
}
private void PreviewTab_DelegatedLeftButtonUp(object sender, MouseButtonEventArgs e)
{
// 닫기 버튼 클릭 여부 확인 (Tag == "close" 인 Border)
var closeAncestor = FindAncestor<Border>(e.OriginalSource as DependencyObject);
while (closeAncestor != null)
{
if (closeAncestor.Tag is string s && s == "close")
{
// 닫기 버튼 → 탭의 FilePath를 찾아 닫기
var tabBorder = FindAncestorWithTag<Border>(VisualTreeHelper.GetParent(closeAncestor));
if (tabBorder?.Tag is PreviewTabTag closeTag)
{
e.Handled = true;
ClosePreviewTab(closeTag.FilePath);
}
return;
}
if (closeAncestor.Tag is PreviewTabTag)
break;
var parent = VisualTreeHelper.GetParent(closeAncestor);
closeAncestor = parent as Border;
}
// 일반 탭 클릭
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PreviewTabTag tag)
{
e.Handled = true;
_activePreviewTab = tag.FilePath;
RebuildPreviewTabs();
LoadPreviewContent(tag.FilePath);
}
}
private void PreviewTab_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount != 2) return;
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PreviewTabTag tag)
{
e.Handled = true;
OpenPreviewPopupWindow(tag.FilePath);
}
}
private void PreviewTab_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is PreviewTabTag tag)
{
e.Handled = true;
ShowPreviewTabContextMenu(tag.FilePath);
}
}
private static readonly string WebView2DataFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "WebView2");
@@ -56,7 +176,9 @@ public partial class ChatWindow
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.LimeGreen;
if (PreviewIcon != null)
PreviewIcon.Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.LimeGreen;
UpdatePreviewChevronState();
RebuildPreviewTabs();
LoadPreviewContent(filePath);
@@ -64,6 +186,8 @@ public partial class ChatWindow
private void RebuildPreviewTabs()
{
InitPreviewTabDelegation();
_lastHoveredPreviewTab = null;
PreviewTabPanel.Children.Clear();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
@@ -102,14 +226,6 @@ public partial class ChatWindow
});
var closeFg = isActive ? primaryText : secondaryText;
var closeBtnText = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = closeFg,
VerticalAlignment = VerticalAlignment.Center,
};
var closeBtn = new Border
{
Background = Brushes.Transparent,
@@ -119,85 +235,30 @@ public partial class ChatWindow
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
Child = closeBtnText,
Child = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = closeFg,
VerticalAlignment = VerticalAlignment.Center,
},
Tag = "close",
};
var closePath = tabPath;
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b)
{
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
if (b.Child is TextBlock tb)
tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
}
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Background = Brushes.Transparent;
if (b.Child is TextBlock tb)
tb.Foreground = closeFg;
}
};
closeBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
ClosePreviewTab(closePath);
};
// closeBtn 호버 효과는 직접 이벤트 — 닫기 버튼은 수가 적고 탭에 종속
closeBtn.MouseEnter += CloseBtn_MouseEnter;
closeBtn.MouseLeave += CloseBtn_MouseLeave;
tabContent.Children.Add(closeBtn);
tabBorder.Child = tabContent;
var clickPath = tabPath;
tabBorder.MouseLeftButtonUp += (_, e) =>
// A-2: 이벤트 위임 — tabBorder의 클릭/호버는 PreviewTabPanel에서 처리
tabBorder.Tag = new PreviewTabTag
{
if (e.Handled)
return;
e.Handled = true;
_activePreviewTab = clickPath;
RebuildPreviewTabs();
LoadPreviewContent(clickPath);
};
var ctxPath = tabPath;
tabBorder.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ShowPreviewTabContextMenu(ctxPath);
};
var dblPath = tabPath;
tabBorder.MouseLeftButtonDown += (_, e) =>
{
if (e.Handled)
return;
if (e.ClickCount == 2)
{
e.Handled = true;
OpenPreviewPopupWindow(dblPath);
}
};
var capturedIsActive = isActive;
var capturedCloseBtn = closeBtn;
tabBorder.MouseEnter += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Visible;
};
tabBorder.MouseLeave += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = Brushes.Transparent;
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Hidden;
FilePath = tabPath,
IsActive = isActive,
CloseButton = closeBtn,
};
PreviewTabPanel.Children.Add(tabBorder);
@@ -216,6 +277,32 @@ public partial class ChatWindow
}
}
private static void CloseBtn_MouseEnter(object sender, MouseEventArgs e)
{
if (sender is Border b)
{
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
if (b.Child is TextBlock tb)
tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
}
}
private static void CloseBtn_MouseLeave(object sender, MouseEventArgs e)
{
if (sender is Border b)
{
b.Background = Brushes.Transparent;
// closeFg는 탭마다 다르지만 기본 색상 복원
if (b.Child is TextBlock tb)
tb.Foreground = (TryFindResource("SecondaryText", b) as Brush) ?? Brushes.Gray;
}
}
private static Brush? TryFindResource(string key, DependencyObject element)
{
return element is FrameworkElement fe ? fe.TryFindResource(key) as Brush : null;
}
private void ClosePreviewTab(string filePath)
{
_previewTabs.Remove(filePath);
@@ -279,6 +366,27 @@ public partial class ChatWindow
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".docx":
await EnsureWebViewInitializedAsync();
var docxHtml = Services.Agent.DocxToHtmlConverter.Convert(filePath);
PreviewWebView.NavigateToString(docxHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".xlsx":
await EnsureWebViewInitializedAsync();
var xlsxHtml = Services.Agent.XlsxToHtmlConverter.Convert(filePath);
PreviewWebView.NavigateToString(xlsxHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".pptx":
await EnsureWebViewInitializedAsync();
var pptxHtml = Services.Agent.PptxToHtmlConverter.Convert(filePath);
PreviewWebView.NavigateToString(pptxHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":
@@ -347,6 +455,12 @@ public partial class ChatWindow
catch (Exception ex)
{
LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
// 사용자에게 피드백 — HTML 미리보기가 동작하지 않는 이유 안내
if (PreviewEmpty != null)
{
PreviewEmpty.Text = "WebView2 초기화 실패 — HTML 미리보기를 사용할 수 없습니다.\n외부 프로그램으로 열기를 이용하세요.";
PreviewEmpty.Visibility = System.Windows.Visibility.Visible;
}
}
}
@@ -434,7 +548,9 @@ public partial class ChatWindow
{
_previewTabs.Clear();
_activePreviewTab = null;
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.Gray;
if (PreviewIcon != null)
PreviewIcon.Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
UpdatePreviewChevronState();
if (PreviewHeaderTitle != null) PreviewHeaderTitle.Text = "미리보기";
if (PreviewHeaderSubtitle != null) PreviewHeaderSubtitle.Text = "선택한 파일이 여기에 표시됩니다";
if (PreviewHeaderMeta != null) PreviewHeaderMeta.Text = "파일 메타";
@@ -470,15 +586,26 @@ public partial class ChatWindow
// BtnPreviewToggle은 항상 표시 — 패널만 닫힘
}
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
// ── 미리보기 Split Button 핸들러 ──
private void PreviewToggleArea_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
TogglePreviewPanel();
}
private void TogglePreviewPanel()
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.LimeGreen;
if (PreviewPanel.Visibility == Visibility.Visible)
{
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.Gray;
if (PreviewIcon != null) PreviewIcon.Foreground = secondaryText;
}
else if (_previewTabs.Count > 0)
{
@@ -486,11 +613,130 @@ public partial class ChatWindow
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.LimeGreen;
if (PreviewIcon != null) PreviewIcon.Foreground = accentBrush;
RebuildPreviewTabs();
if (_activePreviewTab != null)
LoadPreviewContent(_activePreviewTab);
}
else
{
// 탭이 없을 때: 현재 대화에서 최근 생성 파일을 자동으로 찾아 열기
var recentFile = FindRecentPreviewableFile();
if (recentFile != null)
ShowPreviewPanel(recentFile);
else
ShowToast("미리보기할 파일이 없습니다. 작업 완료 후 다시 시도하세요.", "\uE7BA", 2000);
}
}
/// <summary>현재 대화의 ExecutionEvents에서 최근 미리보기 가능한 파일을 찾습니다.</summary>
private string? FindRecentPreviewableFile()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
var events = conv?.ExecutionEvents;
if (events == null || events.Count == 0)
return null;
for (int i = events.Count - 1; i >= 0; i--)
{
var evt = events[i];
if (string.IsNullOrWhiteSpace(evt.FilePath))
continue;
var ext = System.IO.Path.GetExtension(evt.FilePath);
if (_previewableExtensions.Contains(ext))
return evt.FilePath;
}
return null;
}
private void PreviewChevron_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
if (_previewTabs.Count == 0) return;
ShowPreviewTabDropdown();
}
private Popup? _previewDropdownPopup;
private void ShowPreviewTabDropdown()
{
_previewDropdownPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var stack = new StackPanel { Margin = new Thickness(2) };
foreach (var tabPath in _previewTabs)
{
var fileName = Path.GetFileName(tabPath);
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
var ext = Path.GetExtension(tabPath).ToLowerInvariant();
var icon = ext switch
{
".html" or ".htm" => "\uE8A7",
".md" => "\uE8A5",
".csv" => "\uE80A",
".json" or ".xml" => "\uE943",
_ => "\uE8A5",
};
var capturedPath = tabPath;
var iconBrush = isActive ? accentBrush : secondaryText;
var textBrush = isActive ? primaryText : secondaryText;
var item = CreateSurfacePopupMenuItem(icon, iconBrush, fileName, () =>
{
_previewDropdownPopup!.IsOpen = false;
_activePreviewTab = capturedPath;
ShowPreviewPanel(capturedPath);
}, textBrush, 12.5);
stack.Children.Add(item);
}
var container = CreateSurfacePopupContainer(stack, 220, new Thickness(4, 6, 4, 6));
_previewDropdownPopup = new Popup
{
Child = container,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Placement = PlacementMode.Bottom,
PlacementTarget = PreviewChevronArea,
VerticalOffset = 4,
};
_previewDropdownPopup.IsOpen = true;
}
/// <summary>셰브론 영역의 활성/비활성 상태를 _previewTabs 수에 맞춰 동기화.</summary>
private void UpdatePreviewChevronState()
{
if (PreviewChevronArea == null) return;
var hasItems = _previewTabs.Count > 0;
PreviewChevronArea.IsHitTestVisible = hasItems;
PreviewChevronArea.Opacity = hasItems ? 1.0 : 0.35;
}
/// <summary>미리보기 Split Button 호버 효과 초기화 (Loaded에서 호출).</summary>
private void InitPreviewSplitButtonHover()
{
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
if (PreviewToggleArea != null)
{
PreviewToggleArea.MouseEnter += (_, _) => PreviewToggleArea.Background = hoverBg;
PreviewToggleArea.MouseLeave += (_, _) => PreviewToggleArea.Background = Brushes.Transparent;
}
if (PreviewChevronArea != null)
{
PreviewChevronArea.MouseEnter += (_, _) =>
{
if (PreviewChevronArea.IsHitTestVisible)
PreviewChevronArea.Background = hoverBg;
};
PreviewChevronArea.MouseLeave += (_, _) => PreviewChevronArea.Background = Brushes.Transparent;
}
}
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)

View File

@@ -176,9 +176,23 @@ public partial class ChatWindow
}
row.Child = grid;
// A-2: Tag 설정 — 위임이 있는 패널에서는 Tag 기반 처리
// 위임이 없는 패널에서는 아래 직접 핸들러가 동작
row.Tag = new PopupRowTag
{
OnClick = onClick ?? (() => { }),
Selected = selected,
HintBackground = hintBackground,
HoverBackground = hoverBackground,
};
// 직접 핸들러 — 위임 패널에서는 PreviewMouse*가 Handled=true로 설정하여 실행 안 됨
row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBackground;
row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent;
row.MouseLeftButtonUp += (_, _) => onClick?.Invoke();
if (onClick != null)
row.MouseLeftButtonUp += (_, _) => onClick();
return row;
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
@@ -12,9 +12,108 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
// ── A-1: TopicButtonPanel 이벤트 위임 ──
private Border? _lastHoveredTopicBorder;
private bool _topicPanelDelegationInitialized;
/// <summary>프리셋 카드 Border.Tag에 저장하는 메타 데이터.</summary>
private sealed class TopicCardTag
{
public required string Action { get; init; } // "preset" | "etc" | "add" | "custom_preset"
public TopicPreset? Preset { get; init; }
public required Brush NormalBackground { get; init; }
public required Brush HoverBackground { get; init; }
public Brush? NormalBorderBrush { get; init; }
}
/// <summary>TopicButtonPanel에 이벤트 위임 핸들러를 1회 등록합니다.</summary>
private void InitTopicPanelDelegation()
{
if (_topicPanelDelegationInitialized || TopicButtonPanel == null)
return;
_topicPanelDelegationInitialized = true;
TopicButtonPanel.Background = Brushes.Transparent; // 갭 영역도 히트테스트 대상으로 등록
TopicButtonPanel.MouseMove += TopicPanel_DelegatedMouseMove;
TopicButtonPanel.MouseLeave += TopicPanel_DelegatedMouseLeave;
TopicButtonPanel.PreviewMouseLeftButtonDown += TopicPanel_DelegatedLeftButtonDown;
TopicButtonPanel.PreviewMouseRightButtonUp += TopicPanel_DelegatedRightButtonUp;
}
private void TopicPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredTopicBorder))
return;
// 이전 호버 해제
if (_lastHoveredTopicBorder?.Tag is TopicCardTag prevTag)
{
_lastHoveredTopicBorder.Background = prevTag.NormalBackground;
if (prevTag.NormalBorderBrush != null)
_lastHoveredTopicBorder.BorderBrush = prevTag.NormalBorderBrush;
}
_lastHoveredTopicBorder = border;
// 새 호버 적용
if (border?.Tag is TopicCardTag tag)
{
border.Background = tag.HoverBackground;
border.BorderBrush = TryFindResource("AccentColor") as Brush ?? tag.NormalBorderBrush ?? border.BorderBrush;
}
}
private void TopicPanel_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredTopicBorder?.Tag is TopicCardTag prevTag)
{
_lastHoveredTopicBorder.Background = prevTag.NormalBackground;
if (prevTag.NormalBorderBrush != null)
_lastHoveredTopicBorder.BorderBrush = prevTag.NormalBorderBrush;
}
_lastHoveredTopicBorder = null;
}
private void TopicPanel_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not TopicCardTag tag)
return;
e.Handled = true;
switch (tag.Action)
{
case "preset" or "custom_preset" when tag.Preset != null:
SelectTopic(tag.Preset);
break;
case "etc":
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
break;
case "add":
ShowCustomPresetDialog();
break;
}
}
private void TopicPanel_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not TopicCardTag tag)
return;
if (tag.Action == "custom_preset" && tag.Preset != null)
{
e.Handled = true;
ShowCustomPresetContextMenu(border, tag.Preset);
}
}
/// <summary>프리셋 대화 주제 버튼을 동적으로 생성합니다.</summary>
private void BuildTopicButtons()
{
_lastHoveredTopicBorder = null;
TopicButtonPanel.Children.Clear();
TopicButtonPanel.Visibility = Visibility.Visible;
if (TopicPresetScrollViewer != null)
@@ -52,29 +151,8 @@ public partial class ChatWindow
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
void AttachTopicCardHover(Border card, Brush normalBackground, Brush hoverBackground)
{
card.MouseEnter += (sender, _) =>
{
if (sender is Border hovered)
{
hovered.Background = hoverBackground;
hovered.BorderBrush = TryFindResource("AccentColor") as Brush ?? cardBorder;
}
};
card.MouseLeave += (sender, _) =>
{
if (sender is Border hovered)
{
hovered.Background = normalBackground;
hovered.BorderBrush = cardBorder;
}
};
}
foreach (var preset in presets)
{
var capturedPreset = preset;
var buttonColor = BrushFromHex(preset.Color);
var border = new Border
@@ -133,7 +211,7 @@ public partial class ChatWindow
contentGrid.Children.Add(stack);
if (capturedPreset.IsCustom)
if (preset.IsCustom)
{
var badge = new Border
{
@@ -158,17 +236,16 @@ public partial class ChatWindow
}
border.Child = contentGrid;
AttachTopicCardHover(border, cardBackground, cardHoverBackground);
border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset);
if (capturedPreset.IsCustom)
// A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장
border.Tag = new TopicCardTag
{
border.MouseRightButtonUp += (sender, args) =>
{
args.Handled = true;
ShowCustomPresetContextMenu(sender as Border, capturedPreset);
};
}
Action = preset.IsCustom ? "custom_preset" : "preset",
Preset = preset,
NormalBackground = cardBackground,
HoverBackground = cardHoverBackground,
NormalBorderBrush = cardBorder,
};
TopicButtonPanel.Children.Add(border);
}
@@ -225,11 +302,12 @@ public partial class ChatWindow
});
etcGrid.Children.Add(etcStack);
etcBorder.Child = etcGrid;
AttachTopicCardHover(etcBorder, cardBackground, cardHoverBackground);
etcBorder.MouseLeftButtonDown += (_, _) =>
etcBorder.Tag = new TopicCardTag
{
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
Action = "etc",
NormalBackground = cardBackground,
HoverBackground = cardHoverBackground,
NormalBorderBrush = cardBorder,
};
TopicButtonPanel.Children.Add(etcBorder);
@@ -274,8 +352,13 @@ public partial class ChatWindow
addGrid.Children.Add(addStack);
addBorder.Child = addGrid;
AttachTopicCardHover(addBorder, Brushes.Transparent, cardHoverBackground);
addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog();
addBorder.Tag = new TopicCardTag
{
Action = "add",
NormalBackground = Brushes.Transparent,
HoverBackground = cardHoverBackground,
NormalBorderBrush = cardBorder,
};
TopicButtonPanel.Children.Add(addBorder);
UpdateTopicPresetScrollMode();

View File

@@ -1,7 +1,81 @@
using System.Collections.Generic;
namespace AxCopilot.Views;
public partial class ChatWindow
{
/// <summary>
/// B-3: 스트리밍 전용 append-only 렌더 경로.
/// prefix 비교를 완전히 우회하고, stable 키의 부분집합 관계만 확인하여
/// 새 항목만 추가하는 빠른 경로. B-1/B-2로 인크리멘탈이 대부분 성공하지만,
/// 스트리밍 중 키 순서가 변경되는 극단적 경우에도 전체 재빌드를 방지합니다.
/// </summary>
private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan)
{
if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0 || renderPlan.NewKeys.Count == 0)
return false;
// hiddenCount가 다르면 visible 범위 자체가 달라진 것 — append 불가
if (renderPlan.HiddenCount != _lastRenderedHiddenCount)
return false;
// 기존 stable 키가 새 키 집합의 부분집합인지 확인 (순서 무관)
var previousStable = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in _lastRenderedTimelineKeys)
{
if (!key.StartsWith("_live_", StringComparison.Ordinal))
previousStable.Add(key);
}
var newStableSet = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in renderPlan.NewKeys)
{
if (!key.StartsWith("_live_", StringComparison.Ordinal))
newStableSet.Add(key);
}
if (!previousStable.IsSubsetOf(newStableSet))
return false;
try
{
// 라이브 컨테이너 임시 분리
var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
if (hadLiveContainer)
RemoveTranscriptElement(_agentLiveContainer!);
// 기존 live 항목 제거 (끝에서부터)
for (var i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
{
if (!_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
break;
if (GetTranscriptElementCount() > 0)
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
}
// 새로 추가된 항목만 렌더 (기존에 없던 키)
foreach (var item in renderPlan.VisibleTimeline)
{
if (!previousStable.Contains(item.Key))
item.Render();
}
// 라이브 컨테이너 재삽입
if (hadLiveContainer && _agentLiveContainer != null)
AddTranscriptElement(_agentLiveContainer);
_lastRenderedTimelineKeys = renderPlan.NewKeys;
_lastRenderedHiddenCount = renderPlan.HiddenCount;
return true;
}
catch (Exception ex)
{
Services.LogService.Warn($"스트리밍 append 렌더 실패, 전체 렌더로 전환: {ex.Message}");
_lastRenderedTimelineKeys.Clear();
return false;
}
}
private bool TryApplyIncrementalTranscriptRender(TranscriptRenderPlan renderPlan)
{
if (!renderPlan.CanIncremental)
@@ -22,12 +96,21 @@ public partial class ChatWindow
try
{
// B-2: 라이브 컨테이너가 transcript 끝에 있으면 임시 분리
var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
if (hadLiveContainer)
RemoveTranscriptElement(_agentLiveContainer!);
for (var removeIndex = 0; removeIndex < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; removeIndex++)
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
for (var i = renderPlan.PreviousStableCount; i < renderPlan.VisibleTimeline.Count; i++)
renderPlan.VisibleTimeline[i].Render();
// B-2: 라이브 컨테이너 재삽입
if (hadLiveContainer && _agentLiveContainer != null)
AddTranscriptElement(_agentLiveContainer);
_lastRenderedTimelineKeys = renderPlan.NewKeys;
_lastRenderedHiddenCount = renderPlan.HiddenCount;
return true;

View File

@@ -23,6 +23,13 @@ public partial class ChatWindow
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
// B-1: 스트리밍 중 hiddenCount 안정화 — 감소만 차단하여 prefix 키 불일치 방지
// 스트리밍 시 GetActiveTimelineRenderLimit()가 더 작은 값을 반환하면
// hiddenCount가 변동하여 visible 범위가 밀리고 인크리멘탈 렌더가 항상 실패함
if (_isStreaming && _lastRenderedHiddenCount >= 0 && hiddenCount < _lastRenderedHiddenCount)
hiddenCount = _lastRenderedHiddenCount;
var visibleTimeline = hiddenCount > 0
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
: orderedTimeline;
@@ -31,10 +38,14 @@ public partial class ChatWindow
foreach (var item in visibleTimeline)
newKeys.Add(item.Key);
var hasExternalChildren = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
var canIncremental = !hasExternalChildren
&& _lastRenderedTimelineKeys.Count > 0
// B-2: _agentLiveContainer가 transcript에 있어도 인크리멘탈 허용
// 라이브 컨테이너는 인크리멘탈 렌더 시 임시 분리 후 재삽입하므로
// expectedChildCount에 포함하고 canIncremental 조건에서 제외
var liveContainerInTranscript = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
var expectedChildCount = _lastRenderedTimelineKeys.Count
+ (_lastRenderedHiddenCount > 0 ? 1 : 0)
+ (liveContainerInTranscript ? 1 : 0);
var canIncremental = _lastRenderedTimelineKeys.Count > 0
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
&& _lastRenderedHiddenCount == hiddenCount
&& GetTranscriptElementCount() == expectedChildCount;

View File

@@ -20,6 +20,10 @@ public partial class ChatWindow
private void RenderMessages(bool preserveViewport = false)
{
// B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지
if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible)
return;
var renderStopwatch = Stopwatch.StartNew();
var previousScrollableHeight = GetTranscriptScrollableHeight();
var previousVerticalOffset = GetTranscriptVerticalOffset();
@@ -63,7 +67,9 @@ public partial class ChatWindow
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents);
if (!TryApplyIncrementalTranscriptRender(renderPlan))
// B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → 전체 재빌드
if (!TryApplyStreamingAppendRender(renderPlan)
&& !TryApplyIncrementalTranscriptRender(renderPlan))
ApplyFullTranscriptRender(renderPlan);
PruneTranscriptElementCache(renderPlan.NewKeys);

View File

@@ -4,15 +4,19 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
private const int TranscriptCacheRetentionCount = 240;
/// <summary>최대 캐시 보유 수. 화면 표시 항목 + 여유분.</summary>
private const int TranscriptCacheRetentionCount = 120;
private void PruneTranscriptElementCache(IReadOnlyCollection<string> visibleKeys)
{
if (_elementCache.Count <= TranscriptCacheRetentionCount)
// 캐시가 보유 한도의 1.5배 이상일 때만 정리 (빈번한 정리 방지)
if (_elementCache.Count <= TranscriptCacheRetentionCount * 3 / 2)
return;
var keep = new HashSet<string>(visibleKeys, StringComparer.Ordinal);
foreach (var key in _lastRenderedTimelineKeys.TakeLast(Math.Min(TranscriptCacheRetentionCount, _lastRenderedTimelineKeys.Count)))
// 최근 렌더링된 키 중 보유 한도만큼만 유지
var retainFromRendered = Math.Min(TranscriptCacheRetentionCount, _lastRenderedTimelineKeys.Count);
foreach (var key in _lastRenderedTimelineKeys.TakeLast(retainFromRendered))
keep.Add(key);
var removable = _elementCache.Keys

View File

@@ -51,185 +51,236 @@ public partial class ChatWindow
?? new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x16, 0xFF, 0xFF, 0xFF));
var okBrush = BrushFromHex("#10B981");
var dangerBrush = BrushFromHex("#EF4444");
var selectedBg = new SolidColorBrush(Color.FromArgb(0x20, accentColor.R, accentColor.G, accentColor.B));
var container = new Border
{
Margin = new Thickness(40, 4, 90, 8),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = Math.Max(420, GetMessageMaxWidth() - 36),
Margin = new Thickness(48, 6, 48, 10),
HorizontalAlignment = HorizontalAlignment.Stretch,
MaxWidth = Math.Max(540, GetMessageMaxWidth()),
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 12, 14, 12),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(20, 18, 20, 18),
};
var outer = new StackPanel();
outer.Children.Add(new TextBlock
{
Text = "의견 요청",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
// ── 질문 텍스트 ──
outer.Children.Add(new TextBlock
{
Text = question,
Margin = new Thickness(0, 4, 0, 10),
FontSize = 12.5,
FontSize = 14,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
LineHeight = 22,
Margin = new Thickness(0, 0, 0, 14),
});
Border? selectedOption = null;
// ── 옵션 카드 목록 ──
Border? selectedCard = null;
TextBox? customInputBox = null;
string selectedResponse = defaultValue;
var cardPanel = new StackPanel { Margin = new Thickness(0, 0, 0, 4) };
if (options.Count > 0)
{
var optionPanel = new WrapPanel
for (int i = 0; i < options.Count; i++)
{
Margin = new Thickness(0, 0, 0, 10),
ItemWidth = double.NaN,
};
var optionLabel = options[i].Trim();
if (string.IsNullOrWhiteSpace(optionLabel)) continue;
var optionNumber = i + 1;
foreach (var option in options.Where(static option => !string.IsNullOrWhiteSpace(option)))
{
var optionLabel = option.Trim();
var optBorder = new Border
var card = new Border
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 8, 8),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 0, 0, 6),
Cursor = Cursors.Hand,
};
var cardGrid = new Grid();
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var textStack = new StackPanel();
textStack.Children.Add(new TextBlock
{
Text = optionLabel,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
});
Grid.SetColumn(textStack, 0);
cardGrid.Children.Add(textStack);
// 번호 배지
var badge = new Border
{
Width = 24, Height = 24,
CornerRadius = new CornerRadius(12),
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = optionLabel,
FontSize = 12,
Foreground = primaryText,
Text = optionNumber.ToString(),
FontSize = 11,
FontWeight = FontWeights.Bold,
Foreground = secondaryText,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
Grid.SetColumn(badge, 1);
cardGrid.Children.Add(badge);
optBorder.MouseEnter += (s, _) =>
card.Child = cardGrid;
var capturedLabel = optionLabel;
var capturedCard = card;
card.MouseEnter += (_, _) =>
{
if (!ReferenceEquals(selectedOption, s))
((Border)s).Background = hoverBg;
if (!ReferenceEquals(selectedCard, capturedCard))
capturedCard.Background = hoverBg;
};
optBorder.MouseLeave += (s, _) =>
card.MouseLeave += (_, _) =>
{
if (!ReferenceEquals(selectedOption, s))
((Border)s).Background = Brushes.Transparent;
if (!ReferenceEquals(selectedCard, capturedCard))
capturedCard.Background = Brushes.Transparent;
};
optBorder.MouseLeftButtonUp += (_, _) =>
card.MouseLeftButtonUp += (_, _) =>
{
if (selectedOption != null)
// 이전 선택 해제
if (selectedCard != null)
{
selectedOption.Background = Brushes.Transparent;
selectedOption.BorderBrush = borderBrush;
selectedCard.Background = Brushes.Transparent;
selectedCard.BorderBrush = borderBrush;
}
// 커스텀 입력 카드 선택 해제
if (customInputBox != null)
customInputBox.BorderBrush = borderBrush;
selectedOption = optBorder;
selectedOption.Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
selectedOption.BorderBrush = accentBrush;
selectedResponse = optionLabel;
selectedCard = capturedCard;
selectedCard.Background = selectedBg;
selectedCard.BorderBrush = accentBrush;
selectedResponse = capturedLabel;
};
optionPanel.Children.Add(optBorder);
cardPanel.Children.Add(card);
}
outer.Children.Add(optionPanel);
}
outer.Children.Add(new TextBlock
// ── 마지막 카드: 직접 입력 (Claude Desktop 스타일) ──
var customCard = new Border
{
Text = "직접 입력",
FontSize = 11.5,
Foreground = secondaryText,
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(2),
Margin = new Thickness(0, 0, 0, 6),
});
};
var inputBox = new TextBox
customInputBox = new TextBox
{
Text = defaultValue,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
MinHeight = 42,
MaxHeight = 100,
FontSize = 12.5,
Padding = new Thickness(10, 8, 10, 8),
MinHeight = 38,
MaxHeight = 120,
FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Background = Brushes.Transparent,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
BorderThickness = new Thickness(0),
};
inputBox.TextChanged += (_, _) =>
// 플레이스홀더
if (string.IsNullOrWhiteSpace(defaultValue))
{
if (!string.IsNullOrWhiteSpace(inputBox.Text))
customInputBox.Tag = "placeholder";
customInputBox.Text = "직접 입력...";
customInputBox.Foreground = secondaryText;
}
customInputBox.GotFocus += (_, _) =>
{
if (customInputBox.Tag?.ToString() == "placeholder")
{
selectedResponse = inputBox.Text.Trim();
if (selectedOption != null)
{
selectedOption.Background = Brushes.Transparent;
selectedOption.BorderBrush = borderBrush;
selectedOption = null;
}
customInputBox.Text = "";
customInputBox.Foreground = primaryText;
customInputBox.Tag = null;
}
// 옵션 카드 선택 해제 + 커스텀 카드 활성화
if (selectedCard != null)
{
selectedCard.Background = Brushes.Transparent;
selectedCard.BorderBrush = borderBrush;
selectedCard = null;
}
customCard.BorderBrush = accentBrush;
customCard.Background = selectedBg;
};
customInputBox.TextChanged += (_, _) =>
{
if (customInputBox.Tag?.ToString() != "placeholder" && !string.IsNullOrWhiteSpace(customInputBox.Text))
selectedResponse = customInputBox.Text.Trim();
};
outer.Children.Add(inputBox);
customCard.Child = customInputBox;
cardPanel.Children.Add(customCard);
outer.Children.Add(cardPanel);
// ── 하단 버튼 ──
var buttonRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 12, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 8, 0, 0),
};
Border BuildActionButton(string label, Brush bg, Brush fg)
var skipBtn = new Border
{
return new Border
{
Background = bg,
BorderBrush = bg,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(8, 0, 0, 0),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = label,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = fg,
},
};
}
var cancelBtn = BuildActionButton("취소", Brushes.Transparent, dangerBrush);
cancelBtn.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x44, 0x44));
cancelBtn.MouseLeftButtonUp += (_, _) =>
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(16, 7, 16, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
Child = new TextBlock { Text = "건너뛰기", FontSize = 12.5, Foreground = secondaryText },
};
skipBtn.MouseEnter += (_, _) => skipBtn.Background = hoverBg;
skipBtn.MouseLeave += (_, _) => skipBtn.Background = Brushes.Transparent;
skipBtn.MouseLeftButtonUp += (_, _) =>
{
RemoveUserAskCard();
tcs.TrySetResult(null);
};
buttonRow.Children.Add(cancelBtn);
buttonRow.Children.Add(skipBtn);
var submitBtn = BuildActionButton("전달", okBrush, Brushes.White);
var submitBtn = new Border
{
Background = accentBrush,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(16, 7, 16, 7),
Cursor = Cursors.Hand,
Child = new TextBlock { Text = "제출", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White },
};
submitBtn.MouseLeftButtonUp += (_, _) =>
{
var finalResponse = !string.IsNullOrWhiteSpace(inputBox.Text)
? inputBox.Text.Trim()
var finalResponse = (customInputBox.Tag?.ToString() != "placeholder" && !string.IsNullOrWhiteSpace(customInputBox.Text))
? customInputBox.Text.Trim()
: selectedResponse?.Trim();
if (string.IsNullOrWhiteSpace(finalResponse))
finalResponse = defaultValue?.Trim();
if (string.IsNullOrWhiteSpace(finalResponse))
return;
@@ -237,8 +288,8 @@ public partial class ChatWindow
tcs.TrySetResult(finalResponse);
};
buttonRow.Children.Add(submitBtn);
outer.Children.Add(buttonRow);
container.Child = outer;
_userAskCard = container;
@@ -246,7 +297,5 @@ public partial class ChatWindow
container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
AddTranscriptElement(container);
ForceScrollToEnd();
inputBox.Focus();
inputBox.CaretIndex = inputBox.Text.Length;
}
}

View File

@@ -240,6 +240,30 @@ public partial class ChatWindow
: null;
}
/// <summary>VisualTree를 부모 방향으로 탐색하여 Tag가 설정된 T 타입 요소를 찾습니다.</summary>
private static T? FindAncestorWithTag<T>(DependencyObject? source) where T : FrameworkElement
{
while (source != null)
{
if (source is T fe && fe.Tag != null)
return fe;
source = VisualTreeHelper.GetParent(source);
}
return null;
}
/// <summary>VisualTree를 부모 방향으로 탐색하여 지정 T 타입 요소를 찾습니다.</summary>
private static T? FindAncestor<T>(DependencyObject? source) where T : DependencyObject
{
while (source != null)
{
if (source is T found)
return found;
source = VisualTreeHelper.GetParent(source);
}
return null;
}
private static ControlTemplate BuildMinimalIconButtonTemplate()
{
var template = new ControlTemplate(typeof(Button));

View File

@@ -1053,19 +1053,37 @@
</Button>
</WrapPanel>
</StackPanel>
<!-- 우: 프리뷰 토글 버튼 -->
<!-- 우: 미리보기 Split Button (Claude Desktop 스타일) -->
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button x:Name="BtnPreviewToggle" Style="{StaticResource GhostBtn}"
Click="BtnPreviewToggle_Click" ToolTip="미리보기 패널"
Padding="5,2.5" MinWidth="0">
<Border CornerRadius="6" Background="Transparent">
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="PreviewDot" Width="5" Height="5" Fill="Gray"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="프리뷰" FontSize="10.5"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
<!-- 좌: 패널 토글 영역 -->
<Border x:Name="PreviewToggleArea" CornerRadius="6,0,0,6"
Background="Transparent" Padding="5,2.5,3,2.5"
Cursor="Hand" ToolTip="미리보기 패널 열기/닫기"
MouseLeftButtonUp="PreviewToggleArea_Click">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="PreviewIcon" Text="&#xE768;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="미리보기" FontSize="10.5"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- 우: 셰브론 드롭다운 영역 -->
<Border x:Name="PreviewChevronArea" CornerRadius="0,6,6,0"
Background="Transparent" Padding="3,2.5,5,2.5"
Cursor="Hand" ToolTip="열린 미리보기 목록"
IsHitTestVisible="False" Opacity="0.35"
MouseLeftButtonUp="PreviewChevron_Click">
<TextBlock Text="&#xE70D;" FontFamily="Segoe MDL2 Assets" FontSize="8"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Button>
</Border>
</StackPanel>
</Grid>
</Border>
@@ -1367,12 +1385,12 @@
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.CanContentScroll="True"
ScrollViewer.IsDeferredScrollingEnabled="True"
ScrollViewer.IsDeferredScrollingEnabled="False"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
VirtualizingPanel.CacheLength="2"
VirtualizingPanel.CacheLengthUnit="Item"
VirtualizingPanel.CacheLength="3"
VirtualizingPanel.CacheLengthUnit="Page"
UseLayoutRounding="True">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
@@ -1442,6 +1460,7 @@
<!-- 빈 상태 -->
<Grid x:Name="EmptyState" Grid.Row="3"
Background="Transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
MaxWidth="960"
@@ -2810,6 +2829,20 @@
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- 계획 버튼 (실행 계획 존재 시 표시) -->
<Border x:Name="BtnPlanViewer" Visibility="Collapsed"
CornerRadius="4" Padding="3.5,1" Margin="0,0,5,0"
Background="{DynamicResource HintBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand" ToolTip="실행 계획 보기"
MouseLeftButtonUp="BtnPlanViewer_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8FD;" FontFamily="Segoe MDL2 Assets" FontSize="9"
Foreground="#10B981" VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock x:Name="PlanViewerLabel" Text="계획" FontSize="9" FontWeight="SemiBold"
Foreground="#10B981" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<TextBlock x:Name="StatusElapsed" Text="" FontSize="8.25"
Visibility="Collapsed"
Foreground="{DynamicResource SecondaryText}"
@@ -3590,15 +3623,42 @@
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<TextBox x:Name="TxtOverlayPdfExportPath"
Grid.Column="1"
LostFocus="TxtOverlayPdfExportPath_LostFocus"
Padding="9,7"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
<Grid Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtOverlayPdfExportPath"
LostFocus="TxtOverlayPdfExportPath_LostFocus"
Padding="9,7"
IsReadOnly="True"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
<Border x:Name="BtnBrowsePdfExportPath"
Grid.Column="1"
Cursor="Hand"
Background="{DynamicResource ItemHoverBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="6"
Padding="8,6"
Margin="6,0,0,0"
VerticalAlignment="Center"
ToolTip="폴더 선택"
MouseLeftButtonUp="BtnBrowsePdfExportPath_Click"
MouseEnter="OverlayActionBtn_MouseEnter"
MouseLeave="OverlayActionBtn_MouseLeave"
MouseLeftButtonDown="OverlayActionBtn_MouseLeftButtonDown">
<TextBlock Text="&#xED25;"
FontFamily="Segoe MDL2 Assets"
FontSize="13"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"/>
</Border>
</Grid>
</Grid>
<Border Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
@@ -4497,7 +4557,8 @@
TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<Border Grid.Column="1"
<Border x:Name="BtnOverlayOpenAuditLog"
Grid.Column="1"
Cursor="Hand"
Background="{DynamicResource ItemHoverBackground}"
BorderBrush="{DynamicResource BorderColor}"
@@ -4505,7 +4566,10 @@
CornerRadius="8"
Padding="10,6"
Margin="12,0,0,0"
MouseLeftButtonUp="OverlayOpenAuditLogBtn_MouseLeftButtonUp">
MouseLeftButtonUp="OverlayOpenAuditLogBtn_MouseLeftButtonUp"
MouseEnter="OverlayActionBtn_MouseEnter"
MouseLeave="OverlayActionBtn_MouseLeave"
MouseLeftButtonDown="OverlayActionBtn_MouseLeftButtonDown">
<TextBlock Text="폴더 열기"
FontSize="12"
FontWeight="SemiBold"

View File

@@ -251,7 +251,7 @@ public partial class ChatWindow : Window
_gitRefreshTimer.Stop();
await RefreshGitBranchStatusAsync();
};
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_conversationSearchTimer.Tick += (_, _) =>
{
_conversationSearchTimer.Stop();
@@ -301,15 +301,15 @@ public partial class ChatWindow : Window
_conversationPersistTimer.Stop();
FlushPendingConversationPersists();
};
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
_agentUiEventTimer.Tick += (_, _) =>
{
_agentUiEventTimer.Stop();
_agentUiEventTimer.Interval = _isStreaming
? (IsLightweightLiveProgressMode()
? TimeSpan.FromMilliseconds(420)
: TimeSpan.FromMilliseconds(300))
: TimeSpan.FromMilliseconds(140);
? TimeSpan.FromMilliseconds(500)
: TimeSpan.FromMilliseconds(350))
: TimeSpan.FromMilliseconds(200);
FlushPendingAgentUiEvent();
};
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
@@ -320,7 +320,7 @@ public partial class ChatWindow : Window
_tokenUsagePopupCloseTimer.Stop();
CloseTokenUsagePopupIfIdle();
};
_responsiveLayoutTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
_responsiveLayoutTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_responsiveLayoutTimer.Tick += (_, _) =>
{
_responsiveLayoutTimer.Stop();
@@ -379,11 +379,17 @@ public partial class ChatWindow : Window
UpdateInputBoxHeight();
InputBox.Focus();
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
// A-1: 패널 이벤트 위임 1회 초기화 — 개별 람다 대신 부모 레벨에서 처리
InitConversationPanelDelegation();
InitTopicPanelDelegation();
InitPreviewSplitButtonHover();
InitPlanButtonHover();
Dispatcher.BeginInvoke(() =>
{
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildTopicButtons();
RestoreLastConversations();
BuildTopicButtons();
RefreshConversationList();
UpdateResponsiveChatLayout();
UpdateTaskSummaryIndicators();
@@ -471,13 +477,27 @@ public partial class ChatWindow : Window
_settings.SettingsChanged -= Settings_SettingsChanged;
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
// 모든 DispatcherTimer 명시적 Stop — auto-stop에만 의존하지 않음
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_conversationSearchTimer.Stop();
_inputUiRefreshTimer.Stop();
_responsiveLayoutTimer.Stop();
_gitRefreshTimer.Stop();
_executionHistoryRenderTimer.Stop();
_taskSummaryRefreshTimer.Stop();
_conversationPersistTimer.Stop();
_agentUiEventTimer.Stop();
_agentProgressHintTimer.Stop();
_tokenUsagePopupCloseTimer.Stop();
_smoothScrollTimer?.Stop();
_sidebarAnimTimer?.Stop();
_fileBrowserRefreshTimer?.Stop();
StopRainbowGlow();
StopAgentEventProcessor();
_llm.Dispose();
};
}
@@ -688,25 +708,18 @@ public partial class ChatWindow : Window
animation.Completed += (_, _) => ScrollTranscriptToVerticalOffset(targetOffset);
// ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간
// 기존 스크롤 애니메이션이 진행 중이면 즉시 종료
_smoothScrollTimer?.Stop();
var startTime = DateTime.UtcNow;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps
EventHandler tickHandler = null!;
tickHandler = (_, _) =>
_smoothScrollStartOffset = currentOffset;
_smoothScrollDiff = diff;
_smoothScrollStartTime = startTime;
if (_smoothScrollTimer == null)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
var progress = Math.Min(elapsed / 200.0, 1.0);
var eased = 1.0 - Math.Pow(1.0 - progress, 3);
var offset = currentOffset + diff * eased;
ScrollTranscriptToVerticalOffset(offset);
if (progress >= 1.0)
{
timer.Stop();
timer.Tick -= tickHandler;
}
};
timer.Tick += tickHandler;
timer.Start();
_smoothScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) }; // ~30fps (충분히 부드러움)
_smoothScrollTimer.Tick += SmoothScrollTimer_Tick;
}
_smoothScrollTimer.Start();
}
// ─── 대화 제목 인라인 편집 ──────────────────────────────────────────
@@ -1311,6 +1324,16 @@ public partial class ChatWindow : Window
? Visibility.Visible
: Visibility.Collapsed;
if (!isOwningTab) PauseIcon.Text = "\uE769";
// 스트리밍 중인 탭이 아니면 펄스 닷·상태 바 숨김 (다른 탭 작업 상태가 보이지 않도록)
// 스트리밍 탭으로 복귀 시 자동 복원
if (PulseDotBar != null)
{
if (!isOwningTab)
PulseDotBar.Visibility = Visibility.Collapsed;
else if (_streamingTabs.Count > 0)
PulseDotBar.Visibility = Visibility.Visible;
}
}
private void TabChat_Checked(object sender, RoutedEventArgs e)
@@ -2223,24 +2246,18 @@ public partial class ChatWindow : Window
{
var duration = 200.0;
var start = DateTime.UtcNow;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
EventHandler tickHandler = null!;
tickHandler = (_, _) =>
_sidebarAnimTimer?.Stop();
_sidebarAnimFrom = from;
_sidebarAnimTo = to;
_sidebarAnimStart = start;
_sidebarAnimDuration = duration;
_sidebarAnimComplete = onComplete;
if (_sidebarAnimTimer == null)
{
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
var t = Math.Min(elapsed / duration, 1.0);
t = 1 - (1 - t) * (1 - t);
SidebarColumn.Width = new GridLength(from + (to - from) * t);
if (elapsed >= duration)
{
timer.Stop();
timer.Tick -= tickHandler;
SidebarColumn.Width = new GridLength(to);
onComplete?.Invoke();
}
};
timer.Tick += tickHandler;
timer.Start();
_sidebarAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) };
_sidebarAnimTimer.Tick += SidebarAnimTimer_Tick;
}
_sidebarAnimTimer.Start();
}
// ─── 대화 목록 ────────────────────────────────────────────────────────
@@ -2254,6 +2271,27 @@ public partial class ChatWindow : Window
_conversationSearchTimer.Start();
}
// ─── 사이드바 애니메이션 (재사용 타이머) ──────────────────────────────
private DispatcherTimer? _sidebarAnimTimer;
private double _sidebarAnimFrom, _sidebarAnimTo, _sidebarAnimDuration;
private DateTime _sidebarAnimStart;
private Action? _sidebarAnimComplete;
private void SidebarAnimTimer_Tick(object? sender, EventArgs e)
{
var elapsed = (DateTime.UtcNow - _sidebarAnimStart).TotalMilliseconds;
var t = Math.Min(elapsed / _sidebarAnimDuration, 1.0);
t = 1 - (1 - t) * (1 - t);
SidebarColumn.Width = new GridLength(_sidebarAnimFrom + (_sidebarAnimTo - _sidebarAnimFrom) * t);
if (elapsed >= _sidebarAnimDuration)
{
_sidebarAnimTimer?.Stop();
SidebarColumn.Width = new GridLength(_sidebarAnimTo);
_sidebarAnimComplete?.Invoke();
}
}
private static string FormatDate(DateTime dt)
{
var diff = DateTime.Now - dt;
@@ -5805,6 +5843,12 @@ public partial class ChatWindow : Window
{
await ShowTypedAssistantPreviewAsync(assistantContent, streamToken);
}
else
{
// 에이전트 루프 완료 시 assistantContent가 비어 있으면
// ShowTypedAssistantPreviewAsync가 호출되지 않아 라이브 카드가 남음 → 명시적 제거
RemoveAgentLiveCard();
}
}
catch (OperationCanceledException)
{
@@ -6034,9 +6078,6 @@ public partial class ChatWindow : Window
PreviewWindow.RefreshIfOpen(evt.FilePath);
else
TryShowPreview(evt.FilePath);
if (!PreviewWindow.IsOpen)
TryShowPreview(evt.FilePath);
}
}
}
@@ -6197,11 +6238,15 @@ public partial class ChatWindow : Window
{
case "active":
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " +
"Then EVALUATE whether each document is RELEVANT to the user's current request topic. " +
"Only use document_read on files that are clearly related to the conversation subject. " +
"Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " +
"In your planning step, list which files you plan to read and explain WHY they are relevant.");
sb.AppendLine("Use selective exploration. Start with glob or grep when the request already has a clear topic.");
sb.AppendLine("Use folder_map only when the workspace structure is unclear or the request is repo-wide/open-ended.");
sb.AppendLine("[CRITICAL] FILE SELECTION STRATEGY — DO NOT READ ALL FILES:");
sb.AppendLine(" 1. Identify candidate files by filename or topic keywords first.");
sb.AppendLine(" 2. Read ONLY files that clearly match the user's topic. Skip unrelated topics.");
sb.AppendLine(" 3. Maximum 2-3 relevant files for the first pass. Expand only when evidence shows more files are needed.");
sb.AppendLine(" 4. Do NOT read every file 'just in case'. Broad reading without evidence is forbidden.");
sb.AppendLine(" 5. If no files match the topic, proceed WITHOUT reading any workspace files.");
sb.AppendLine("VIOLATION: Reading all files in the folder is FORBIDDEN. It wastes tokens and degrades quality.");
break;
case "passive":
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
@@ -6297,11 +6342,14 @@ public partial class ChatWindow : Window
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
sb.AppendLine("1. ORIENT: Choose the smallest exploration that matches the request.");
sb.AppendLine(" - If the request is file-specific, bug-specific, symbol-specific, or topic-specific: start with grep/glob and targeted file_read.");
sb.AppendLine(" - Only run folder_map (depth=2) when the repository structure is genuinely unclear or the request is repo-wide/open-ended.");
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + file_read to deeply understand the code you'll modify.");
sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + targeted file_read to deeply understand the code you'll modify.");
sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points.");
sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior.");
sb.AppendLine(" - Keep the first reading pass narrow (usually 1-5 files) unless evidence shows the scope is larger.");
sb.AppendLine("4. PLAN: Build an internal execution outline and impact assessment before editing.");
sb.AppendLine(" - Present the outline explicitly only when the user asks for a plan or the change is clearly high risk.");
sb.AppendLine(" - Explain WHY each change is needed and what could break.");
@@ -6378,8 +6426,10 @@ public partial class ChatWindow : Window
sb.Append(BuildSubAgentDelegationSection(true));
// 폴더 데이터 활용
sb.AppendLine("\nFolder Data Usage = ACTIVE. Use folder_map and file_read to understand the codebase.");
sb.AppendLine("Analyze project structure before making changes. Read relevant files to understand context.");
sb.AppendLine("\nFolder Data Usage = ACTIVE.");
sb.AppendLine("Prefer grep/glob + targeted file_read for narrow requests.");
sb.AppendLine("Use folder_map only when structure is unclear or the request is repo-wide.");
sb.AppendLine("Read only files that are relevant to the current question. Avoid broad codebase sweeps without evidence.");
// 프리셋 시스템 프롬프트
lock (_convLock)
@@ -8846,6 +8896,15 @@ public partial class ChatWindow : Window
else
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
// 대기열 정리: 실행 중 + 대기 중 항목 모두 제거 (중지는 "전부 멈춤"을 의미)
lock (_convLock)
{
_draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage);
_draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage);
_runningDraftId = null;
}
RefreshDraftQueueUi();
// 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음
StopLiveAgentProgressHints();
RemoveAgentLiveCard();
@@ -9491,6 +9550,24 @@ public partial class ChatWindow : Window
return "";
}
// ─── 부드러운 스크롤 애니메이션 (재사용 타이머) ──────────────────────
private DispatcherTimer? _smoothScrollTimer;
private double _smoothScrollStartOffset;
private double _smoothScrollDiff;
private DateTime _smoothScrollStartTime;
private void SmoothScrollTimer_Tick(object? sender, EventArgs e)
{
var elapsed = (DateTime.UtcNow - _smoothScrollStartTime).TotalMilliseconds;
var progress = Math.Min(elapsed / 200.0, 1.0);
var eased = 1.0 - Math.Pow(1.0 - progress, 3);
ScrollTranscriptToVerticalOffset(_smoothScrollStartOffset + _smoothScrollDiff * eased);
if (progress >= 1.0)
_smoothScrollTimer?.Stop();
}
// ─── 무지개 글로우 애니메이션 ─────────────────────────────────────────
private DispatcherTimer? _rainbowTimer;
@@ -11897,9 +11974,75 @@ public partial class ChatWindow : Window
private void OverlayOpenAuditLogBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
// 클릭 효과 리셋
if (sender is Border border)
{
border.Opacity = 1.0;
border.RenderTransform = null;
}
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
}
private void BtnBrowsePdfExportPath_Click(object sender, MouseButtonEventArgs e)
{
// 클릭 효과 리셋
if (sender is Border border)
{
border.Opacity = 1.0;
border.RenderTransform = null;
}
var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "PDF 내보내기 기본 폴더를 선택하세요",
ShowNewFolderButton = true,
UseDescriptionForTitle = true,
};
var current = _settings.Settings.Llm.PdfExportPath;
if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current))
dlg.SelectedPath = current;
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
_settings.Settings.Llm.PdfExportPath = dlg.SelectedPath;
if (TxtOverlayPdfExportPath != null)
TxtOverlayPdfExportPath.Text = dlg.SelectedPath;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
/// <summary>설정 오버레이 액션 버튼 공통 호버/클릭 효과.</summary>
private void OverlayActionBtn_MouseEnter(object sender, MouseEventArgs e)
{
if (sender is Border border)
{
border.Opacity = 0.85;
border.Background = TryFindResource("ItemActiveBackground") as Brush
?? TryFindResource("ItemHoverBackground") as Brush
?? border.Background;
}
}
private void OverlayActionBtn_MouseLeave(object sender, MouseEventArgs e)
{
if (sender is Border border)
{
border.Opacity = 1.0;
border.Background = TryFindResource("ItemHoverBackground") as Brush ?? border.Background;
}
}
private void OverlayActionBtn_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is Border border)
{
border.Opacity = 0.65;
border.RenderTransform = new ScaleTransform(0.96, 0.96);
border.RenderTransformOrigin = new Point(0.5, 0.5);
}
}
private void ChkOverlayEnableDragDropAiActions_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableDragDropAiActions == null)
@@ -14222,15 +14365,21 @@ public partial class ChatWindow : Window
private void BtnDeleteAll_Click(object sender, RoutedEventArgs e)
{
var tabLabel = _activeTab switch
{
"Cowork" => "코워크",
"Code" => "코드",
_ => "채팅"
};
var result = CustomMessageBox.Show(
"저장된 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
"대화 전체 삭제",
$"'{tabLabel}' 탭의 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
$"{tabLabel} 대화 전체 삭제",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
_storage.DeleteAll();
_storage.DeleteAllByTab(_activeTab);
lock (_convLock)
{
ChatSession?.ClearCurrentConversation(_activeTab);
@@ -14377,13 +14526,20 @@ public partial class ChatWindow : Window
return;
}
await RefreshGitBranchStatusAsync();
_gitBranchSearchText = "";
if (GitBranchSearchBox != null)
GitBranchSearchBox.Text = "";
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
GitBranchSearchBox?.Focus();
try
{
await RefreshGitBranchStatusAsync();
_gitBranchSearchText = "";
if (GitBranchSearchBox != null)
GitBranchSearchBox.Text = "";
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
GitBranchSearchBox?.Focus();
}
catch (Exception ex)
{
Services.LogService.Error($"Git branch refresh failed: {ex.Message}");
}
}
private void GitBranchSearchBox_TextChanged(object sender, TextChangedEventArgs e)
@@ -14463,9 +14619,17 @@ public partial class ChatWindow : Window
return value.ToString("N0");
}
private static void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
private double _lastArcRatio = -1;
private void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
{
ratio = Math.Clamp(ratio, 0, 0.9999);
// 비율 변화가 1% 미만이면 렌더링 생략 (250ms마다 호출되므로 불필요한 재생성 방지)
if (Math.Abs(ratio - _lastArcRatio) < 0.01)
return;
_lastArcRatio = ratio;
if (ratio <= 0)
{
path.Data = Geometry.Empty;

View File

@@ -38,6 +38,12 @@ public partial class LauncherWindow : Window
private System.Windows.Threading.DispatcherTimer? _indexStatusTimer;
private System.Windows.Threading.DispatcherTimer? _toastTimer;
// 이벤트 해제를 위해 핸들러 참조 보관
private EventHandler? _closeRequestedHandler;
private EventHandler<string>? _notificationRequestedHandler;
private System.ComponentModel.PropertyChangedEventHandler? _vmPropertyChangedHandler;
private EventHandler? _indexRebuiltHandler;
/// <summary>Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입)</summary>
public Action? OpenSettingsAction { get; set; }
@@ -52,36 +58,38 @@ public partial class LauncherWindow : Window
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) => Hide();
vm.NotificationRequested += (_, msg) => ShowNotification(msg);
// InputText가 코드에서 변경될 때 커서를 끝으로 이동
// (F1→"help", Ctrl+B→"fav", Ctrl+D→"cd Downloads" 등 자동 입력 후 위화감 해소)
vm.PropertyChanged += (_, e) =>
_closeRequestedHandler = (_, _) => Hide();
_notificationRequestedHandler = (_, msg) => ShowNotification(msg);
_vmPropertyChangedHandler = (_, e) =>
{
if (e.PropertyName == nameof(LauncherViewModel.InputText))
{
// DispatcherPriority.Input: 바인딩 업데이트가 완료된 직후 실행
Dispatcher.InvokeAsync(
() => { if (InputBox != null) InputBox.CaretIndex = InputBox.Text.Length; },
System.Windows.Threading.DispatcherPriority.Input);
}
};
vm.CloseRequested += _closeRequestedHandler;
vm.NotificationRequested += _notificationRequestedHandler;
vm.PropertyChanged += _vmPropertyChangedHandler;
// 인덱싱 완료 시 상태 표시
var app = (App)System.Windows.Application.Current;
if (app.IndexService != null)
{
app.IndexService.IndexRebuilt += (_, _) =>
_indexRebuiltHandler = (_, _) =>
{
Dispatcher.BeginInvoke(() =>
{
var svc = app.IndexService;
var svc = ((App)System.Windows.Application.Current).IndexService;
if (svc == null) return;
ShowIndexStatus(
$"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)",
TimeSpan.FromSeconds(5));
});
};
app.IndexService.IndexRebuilt += _indexRebuiltHandler;
}
if (CurrentApp?.SettingsService != null)
@@ -103,6 +111,7 @@ public partial class LauncherWindow : Window
private static readonly Random _iconRng = new();
private System.Windows.Media.Animation.Storyboard? _iconStoryboard;
private System.Windows.Threading.DispatcherTimer? _iconAnimationDelayTimer;
private System.Windows.Threading.DispatcherTimer? _rainbowTimer;
/// <summary>외부에서 입력 텍스트를 설정합니다 (AI 명령 전달용).</summary>
@@ -225,6 +234,8 @@ public partial class LauncherWindow : Window
/// <summary>아이콘 애니메이션을 중지하고 모든 픽셀을 완전 점등 상태로 복원합니다.</summary>
private void ResetIconAnimation()
{
_iconAnimationDelayTimer?.Stop();
_iconAnimationDelayTimer = null;
_iconStoryboard?.Stop();
_iconStoryboard = null;
var pixels = new[] { PixelBlue, PixelGreen1, PixelRed, PixelGreen2 };
@@ -583,8 +594,23 @@ public partial class LauncherWindow : Window
_iconStoryboard = sb;
sb.Completed += (_, _) =>
{
if (_vm.EnableIconAnimation && IsVisible)
ApplyRandomIconAnimation();
if (!_vm.EnableIconAnimation || !IsVisible) return;
// P3: 즉시 재귀 대신 8초 딜레이 — Storyboard+애니메이션 객체 할당 빈도 75% 감소
_iconAnimationDelayTimer?.Stop();
if (_iconAnimationDelayTimer == null)
{
_iconAnimationDelayTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(8)
};
_iconAnimationDelayTimer.Tick += (__, ___) =>
{
_iconAnimationDelayTimer?.Stop();
if (_vm.EnableIconAnimation && IsVisible)
ApplyRandomIconAnimation();
};
}
_iconAnimationDelayTimer.Start();
};
sb.Begin(this, true);
}
@@ -626,6 +652,7 @@ public partial class LauncherWindow : Window
private void DiamondIcon_Click(object sender, MouseButtonEventArgs e)
{
if (!_vm.EnableIconAnimation) return;
_iconAnimationDelayTimer?.Stop(); // 딜레이 대기 중이면 취소 후 즉시 전환
ApplyRandomIconAnimation();
}
@@ -1482,28 +1509,30 @@ public partial class LauncherWindow : Window
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
fadeIn.Begin(this);
_toastTimer?.Stop();
_toastTimer = new System.Windows.Threading.DispatcherTimer
// 타이머 재사용 — 매번 new 할당 방지
if (_toastTimer == null)
{
Interval = TimeSpan.FromSeconds(2)
};
_toastTimer.Tick += (_, _) =>
{
_toastTimer.Stop();
// 페이드아웃 후 Collapsed
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
onCompleted = (__, ___) =>
{
fadeOut.Completed -= onCompleted;
ToastOverlay.Visibility = Visibility.Collapsed;
};
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
};
_toastTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_toastTimer.Tick += ToastTimer_Tick;
}
_toastTimer.Stop();
_toastTimer.Start();
}
private void ToastTimer_Tick(object? sender, EventArgs e)
{
_toastTimer?.Stop();
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
onCompleted = (__, ___) =>
{
fadeOut.Completed -= onCompleted;
ToastOverlay.Visibility = Visibility.Collapsed;
};
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
}
private void ShowIndexStatus(string message, TimeSpan duration)
{
IndexStatusText.Text = message;
@@ -1983,10 +2012,24 @@ public partial class LauncherWindow : Window
protected override void OnClosed(EventArgs e)
{
// 이벤트 구독 해제 — ViewModel/서비스가 Window보다 오래 살면 GC 누수 방지
if (_closeRequestedHandler != null) _vm.CloseRequested -= _closeRequestedHandler;
if (_notificationRequestedHandler != null) _vm.NotificationRequested -= _notificationRequestedHandler;
if (_vmPropertyChangedHandler != null) _vm.PropertyChanged -= _vmPropertyChangedHandler;
if (_indexRebuiltHandler != null)
{
var app = System.Windows.Application.Current as App;
if (app?.IndexService != null)
app.IndexService.IndexRebuilt -= _indexRebuiltHandler;
}
if (CurrentApp?.SettingsService != null)
CurrentApp.SettingsService.SettingsChanged -= OnSettingsChanged;
StopRainbowGlow();
_toastTimer?.Stop();
_indexStatusTimer?.Stop();
_iconAnimationDelayTimer?.Stop();
_iconAnimationDelayTimer = null;
_iconStoryboard?.Stop();
_iconStoryboard = null;
@@ -2014,6 +2057,7 @@ public partial class LauncherWindow : Window
_quickLookWindow = null;
StopWidgetUpdates();
StopRainbowGlow(); // 숨김 상태에서 CPU 낭비 방지
_iconAnimationDelayTimer?.Stop(); // 딜레이 대기도 중지
_iconStoryboard?.Stop(); // 숨김 상태에서 애니메이션 중지
_iconStoryboard = null;
SaveRememberedPosition();

View File

@@ -31,6 +31,7 @@ public partial class PreviewWindow : Window
private static readonly HashSet<string> PreviewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".csv", ".txt", ".json", ".xml", ".log",
".docx", ".xlsx", ".pptx",
};
public PreviewWindow()
@@ -358,6 +359,24 @@ public partial class PreviewWindow : Window
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".docx":
if (!_webViewInitialized) return;
PreviewBrowser.NavigateToString(Services.Agent.DocxToHtmlConverter.Convert(filePath));
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".xlsx":
if (!_webViewInitialized) return;
PreviewBrowser.NavigateToString(Services.Agent.XlsxToHtmlConverter.Convert(filePath));
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".pptx":
if (!_webViewInitialized) return;
PreviewBrowser.NavigateToString(Services.Agent.PptxToHtmlConverter.Convert(filePath));
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":

View File

@@ -81,7 +81,7 @@ public partial class WorkflowAnalyzerWindow : Window
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.Invoke(() => OnAgentEvent(evt));
_ = Dispatcher.InvokeAsync(() => OnAgentEvent(evt));
return;
}