- 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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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=""
|
||||
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="" 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="" 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=""
|
||||
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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -81,7 +81,7 @@ public partial class WorkflowAnalyzerWindow : Window
|
||||
{
|
||||
if (!Dispatcher.CheckAccess())
|
||||
{
|
||||
Dispatcher.Invoke(() => OnAgentEvent(evt));
|
||||
_ = Dispatcher.InvokeAsync(() => OnAgentEvent(evt));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user