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

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

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

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

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

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

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

View File

@@ -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;