From d5dbaa6e4ac2237149473d44975806d9ee14c7e6 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 08:12:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=83=AD=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20=EB=B9=88=ED=99=94=EB=A9=B4=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 작업 로그 기준으로 코드탭 병합 누락분을 검토하고 기존 컨텍스트 영속화 경로는 유지한 채 Code 사용량 대시보드만 선택적으로 병합했습니다. CodeStatsSummary, CodeStatsAggregator, ChatWindow.CodeStatsPresentation, CodeStatsAggregatorTests를 추가해 세션·메시지·토큰·연속일·모델 사용량 통계를 집계하고 EmptyState에서 개요/모델 히스토리 대시보드를 렌더링하도록 구현했습니다. ChatWindow.xaml, TranscriptRendering, TopicPresetPresentation, ConversationManagementPresentation, OverlaySettingsPresentation, xaml.cs에 UpdateCodeStatsVisibility 연결을 추가해 탭 전환, 첫 메시지 전송, 프리셋 선택, 대화 삭제 시 빈화면과 대시보드가 일관되게 전환되도록 수정했습니다. README.md와 docs/DEVELOPMENT.md에 2026-04-16 08:10 (KST) 기준 병합 이력과 검증 결과를 반영했습니다. 검증: verify_code_stats_merge 빌드 경고 0 / 오류 0, verify_code_stats_merge_tests 대상 테스트 56개 통과 --- README.md | 9 + docs/DEVELOPMENT.md | 18 +- .../Services/CodeStatsAggregatorTests.cs | 191 +++++++ src/AxCopilot/Models/CodeStatsSummary.cs | 63 +++ src/AxCopilot/Services/CodeStatsAggregator.cs | 270 +++++++++ .../Views/ChatWindow.CodeStatsPresentation.cs | 517 ++++++++++++++++++ ...ndow.ConversationManagementPresentation.cs | 11 +- .../ChatWindow.OverlaySettingsPresentation.cs | 1 + .../ChatWindow.TopicPresetPresentation.cs | 18 +- .../Views/ChatWindow.TranscriptRendering.cs | 142 ++--- src/AxCopilot/Views/ChatWindow.xaml | 117 +++- src/AxCopilot/Views/ChatWindow.xaml.cs | 6 + 12 files changed, 1284 insertions(+), 79 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs create mode 100644 src/AxCopilot/Models/CodeStatsSummary.cs create mode 100644 src/AxCopilot/Services/CodeStatsAggregator.cs create mode 100644 src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs diff --git a/README.md b/README.md index 307ba38..cfe3ed5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # AX Commander +- Update: 2026-04-16 08:10 (KST) +- Merged the external work-log changes recorded in `E:\AX Copilot - Claude\.claude\worktrees\vibrant-mendeleev\docs\WORK_LOG_2026-04-15_22h_to_2026-04-16.md` into the local Code tab implementation. +- The current branch already superseded the external context-persistence track in `AxAgentExecutionEngine` and `AgentQueryContextBuilder`, so the merge focused on the missing Code stats surface instead of overwriting the newer local context pipeline. +- Added `src/AxCopilot/Models/CodeStatsSummary.cs`, `src/AxCopilot/Services/CodeStatsAggregator.cs`, `src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs`, and `src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs` to render a `/stats`-style Code dashboard with session count, stored message count, total tokens, streaks, a 12-week activity heatmap, and per-model token usage history. +- `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs`, `src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs`, and `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs` now keep the dashboard synchronized with EmptyState visibility when the user switches tabs, clears conversations, picks presets, or starts the first Code message. +- Verification: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_stats_merge\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge\\` warning 0 / error 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeStatsAggregatorTests|AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests" -p:OutputPath=bin\\verify_code_stats_merge_tests\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge_tests\\` passed 56 + - Update: 2026-04-16 07:40 (KST) - Code tab context reliability was hardened to keep durable tool transcript history, auto-resolve the context budget when `MaxContextTokens = 0`, restore recent compacted tool snippets after boundary compaction, and deduplicate repeated system prompts before each loop request. - `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs` now preserves full `ChatMessage` metadata during turn cloning and syncs structured tool-use and tool-result transcript messages back into `conversation.Messages`, so the next turn and persisted history keep the same repair context. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a5c22a7..de87dfc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,12 @@ -업데이트: 2026-04-15 20:19 (KST) +업데이트: 2026-04-16 08:10 (KST) +- 외부 작업 로그 E:\AX Copilot - Claude\.claude\worktrees\vibrant-mendeleev\docs\WORK_LOG_2026-04-15_22h_to_2026-04-16.md 기준으로 Code 탭 병합 검토를 수행했습니다. AxAgentExecutionEngine, AgentQueryContextBuilder 쪽 컨텍스트 영속화는 현재 브랜치가 이미 더 앞선 구조를 갖고 있어 덮어쓰지 않았고, 누락되어 있던 Code 사용량 대시보드만 선택적으로 병합했습니다. +- src/AxCopilot/Models/CodeStatsSummary.cs, src/AxCopilot/Services/CodeStatsAggregator.cs를 추가해 AgentStatsService 기록과 저장된 Code 대화 메시지 수를 기간별(전체/30일/7일)로 집계하도록 했습니다. 최근 12주 히트맵, 현재/최장 연속 일수, 최다 사용 시간, 즐겨 쓰는 모델, 모델별 in/out 토큰 합계를 모두 집계합니다. +- src/AxCopilot/Views/ChatWindow.xaml, src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs를 추가/확장해 Code 탭 빈 화면에서만 보이는 /stats 스타일 대시보드를 렌더링하도록 했습니다. 대시보드는 8개 요약 카드, 히트맵, 모델 스택 차트, 범위 필터, 내부 탭(개요/모델)을 포함합니다. +- src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs, src/AxCopilot/Views/ChatWindow.xaml.cs, src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs, src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs, src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs는 UpdateCodeStatsVisibility()를 빈 상태 전환 지점에 연결해 탭 전환, 첫 메시지 전송, 프리셋 선택, 대화 삭제/전체 삭제 상황에서도 대시보드와 EmptyState가 엇갈리지 않도록 맞췄습니다. +- 테스트: src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs +- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_stats_merge\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge\\ 경고 0 / 오류 0 +- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeStatsAggregatorTests|AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests" -p:OutputPath=bin\\verify_code_stats_merge_tests\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge_tests\\ 통과 56 +업데이트: 2026-04-15 20:19 (KST) - AX Agent 반복 상한을 500으로 확장했습니다. `src/AxCopilot/ViewModels/SettingsViewModel.cs`의 `MaxAgentIterations` 클램프를 `1~500`으로 올리고, `src/AxCopilot/Views/SettingsWindow.xaml`의 슬라이더/힌트 문구도 같은 범위로 맞췄습니다. - `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`도 함께 수정해 Code 탭 오버레이와 별도 에이전트 설정창이 여전히 100 또는 200에 묶이지 않도록 범위를 통일했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_max_agent_iterations_500\\ -p:IntermediateOutputPath=obj\\verify_max_agent_iterations_500\\` 경고 0 / 오류 0 @@ -1892,3 +1900,11 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_followup\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests|AgentLoopDiagnosticsFormatterTests" -p:OutputPath=bin\\verify_context_reliability_followup_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests\\` 통과 50 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_context_reliability_followup_tests2\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests2\\` 통과 38 +업데이트: 2026-04-16 08:10 (KST) +- 외부 작업 로그 `E:\AX Copilot - Claude\.claude\worktrees\vibrant-mendeleev\docs\WORK_LOG_2026-04-15_22h_to_2026-04-16.md` 기준으로 Code 탭 병합 검토를 수행했습니다. `AxAgentExecutionEngine`, `AgentQueryContextBuilder` 쪽 컨텍스트 영속화는 현재 브랜치가 이미 더 앞선 구조를 갖고 있어 덮어쓰지 않았고, 누락되어 있던 Code 사용량 대시보드만 선택적으로 병합했습니다. +- `src/AxCopilot/Models/CodeStatsSummary.cs`, `src/AxCopilot/Services/CodeStatsAggregator.cs`를 추가해 `AgentStatsService` 기록과 저장된 Code 대화 메시지 수를 기간별(`전체/30일/7일`)로 집계하도록 했습니다. 최근 12주 히트맵, 현재/최장 연속 일수, 최다 사용 시간, 즐겨 쓰는 모델, 모델별 in/out 토큰 합계를 모두 집계합니다. +- `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs`를 추가/확장해 Code 탭 빈 화면에서만 보이는 `/stats` 스타일 대시보드를 렌더링하도록 했습니다. 대시보드는 8개 요약 카드, 히트맵, 모델 스택 차트, 범위 필터, 내부 탭(`개요/모델`)을 포함합니다. +- `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs`, `src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`는 `UpdateCodeStatsVisibility()`를 빈 상태 전환 지점에 연결해 탭 전환, 첫 메시지 전송, 프리셋 선택, 대화 삭제/전체 삭제 상황에서도 대시보드와 EmptyState가 엇갈리지 않도록 맞췄습니다. +- 테스트: `src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_stats_merge\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeStatsAggregatorTests|AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests" -p:OutputPath=bin\\verify_code_stats_merge_tests\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge_tests\\` 통과 56 diff --git a/src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs b/src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs new file mode 100644 index 0000000..027c673 --- /dev/null +++ b/src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs @@ -0,0 +1,191 @@ +using AxCopilot.Models; +using AxCopilot.Services; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +/// +/// Tests for the pure helper logic inside CodeStatsAggregator. +/// +public class CodeStatsAggregatorTests +{ + [Fact] + public void ComputeStreaks_ReturnsZeroWhenNoActivity() + { + var (current, longest) = CodeStatsAggregator.ComputeStreaks( + Array.Empty(), + new DateTime(2026, 4, 16)); + + current.Should().Be(0); + longest.Should().Be(0); + } + + [Fact] + public void ComputeStreaks_CurrentStreakWalksBackFromToday() + { + var today = new DateTime(2026, 4, 16); + var dates = new[] + { + today, + today.AddDays(-1), + today.AddDays(-2), + today.AddDays(-4), + }; + + var (current, longest) = CodeStatsAggregator.ComputeStreaks(dates, today); + + current.Should().Be(3); + longest.Should().Be(3); + } + + [Fact] + public void ComputeStreaks_CurrentStreakIsZeroWhenTodayMissing() + { + var today = new DateTime(2026, 4, 16); + var dates = new[] + { + today.AddDays(-1), + today.AddDays(-2), + today.AddDays(-3), + }; + + var (current, _) = CodeStatsAggregator.ComputeStreaks(dates, today); + + current.Should().Be(0); + } + + [Fact] + public void ComputeStreaks_LongestStreakFindsMaxConsecutiveRun() + { + var anchor = new DateTime(2026, 4, 16); + var dates = new[] + { + anchor.AddDays(-20), + anchor.AddDays(-19), + anchor.AddDays(-15), + anchor.AddDays(-14), + anchor.AddDays(-13), + anchor.AddDays(-12), + anchor.AddDays(-11), + anchor, + }; + + var (current, longest) = CodeStatsAggregator.ComputeStreaks(dates, anchor); + + current.Should().Be(1); + longest.Should().Be(5); + } + + [Fact] + public void ComputeStreaks_DuplicateDatesAreDeduplicated() + { + var today = new DateTime(2026, 4, 16); + var dates = new[] + { + today, + today.AddHours(2), + today.AddHours(8), + today.AddDays(-1), + }; + + var (current, longest) = CodeStatsAggregator.ComputeStreaks(dates, today); + + current.Should().Be(2); + longest.Should().Be(2); + } + + [Fact] + public void Aggregate_EmptyData_ReturnsEmptySummaryWithHeatmapPlaceholders() + { + CodeStatsAggregator.InvalidateCache(); + var summary = CodeStatsAggregator.Aggregate(CodeStatsPeriod.Last30Days, storage: null, bypassCache: true); + + summary.Should().NotBeNull(); + summary.Period.Should().Be(CodeStatsPeriod.Last30Days); + summary.Heatmap.Should().NotBeEmpty(); + summary.MessagesCount.Should().Be(0); + } + + [Fact] + public void CountStoredCodeMessages_ReturnsZeroWhenStorageIsNull() + { + var count = CodeStatsAggregator.CountStoredCodeMessages(storage: null); + count.Should().Be(0); + } + + [Fact] + public void CountStoredCodeMessages_FiltersToCodeTabAndSumsMessages() + { + var fake = new FakeChatStorage(); + fake.Add(tab: "Code", messages: 4); + fake.Add(tab: "Cowork", messages: 7); + fake.Add(tab: "Code", messages: 3); + fake.Add(tab: "Chat", messages: 11); + + var count = CodeStatsAggregator.CountStoredCodeMessages(fake); + + count.Should().Be(7); + } + + private sealed class FakeChatStorage : IChatStorageService + { + private readonly List _store = new(); + + public void Add(string tab, int messages) + { + var id = Guid.NewGuid().ToString("N"); + var conversation = new ChatConversation { Id = id, Tab = tab }; + for (var i = 0; i < messages; i++) + { + conversation.Messages.Add(new ChatMessage + { + Role = i % 2 == 0 ? "user" : "assistant", + Content = $"msg-{i}", + }); + } + + _store.Add(conversation); + } + + public void Save(ChatConversation conversation) { } + + public ChatConversation? Load(string id) => _store.FirstOrDefault(c => c.Id == id); + + public Task LoadAsync(string id) => Task.FromResult(Load(id)); + + public List LoadAllMeta() + { + return _store.Select(c => new ChatConversation + { + Id = c.Id, + Tab = c.Tab, + Title = c.Title, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + Messages = new(), + }).ToList(); + } + + public void InvalidateMetaCache() { } + + public void UpdateMetaCache(ChatConversation conv) { } + + public void RemoveFromMetaCache(string id) { } + + public void Delete(string id) => _store.RemoveAll(c => c.Id == id); + + public int DeleteAll() + { + var count = _store.Count; + _store.Clear(); + return count; + } + + public int DeleteAllByTab(string tab) => _store.RemoveAll(c => c.Tab == tab); + + public int PurgeExpired(int retentionDays) => 0; + + public int PurgeForDiskSpace(double threshold = 0.98) => 0; + } +} diff --git a/src/AxCopilot/Models/CodeStatsSummary.cs b/src/AxCopilot/Models/CodeStatsSummary.cs new file mode 100644 index 0000000..37ef07c --- /dev/null +++ b/src/AxCopilot/Models/CodeStatsSummary.cs @@ -0,0 +1,63 @@ +namespace AxCopilot.Models; + +/// +/// Period filter for the Code tab usage dashboard. +/// Mirrors the "All / 30d / 7d" controls shown in the reference stats view. +/// +public enum CodeStatsPeriod +{ + /// Aggregate over every record on disk. + All = 0, + /// Aggregate over the last 30 days. + Last30Days = 30, + /// Aggregate over the last 7 days. + Last7Days = 7, +} + +/// +/// One day in the activity heatmap. +/// IntensityLevel is 0..4 where 0 means "no activity". +/// +public sealed record CodeStatsHeatmapCell(DateTime Date, long Tokens, int IntensityLevel); + +/// One model bucket within a single day of the model chart. +public sealed record CodeStatsModelDailyTokens(string Model, long Tokens); + +/// One day in the stacked model chart. +public sealed record CodeStatsDailyModelStack(DateTime Date, IReadOnlyList Models); + +/// +/// Per-model aggregate row shown beneath the model stacked bar chart. +/// SharePercent is 0..100. +/// +public sealed record CodeStatsModelTotal( + string Model, + long InTokens, + long OutTokens, + double SharePercent); + +/// +/// Summary model used by the Code tab usage dashboard. +/// +public sealed record CodeStatsSummary +{ + public int SessionsCount { get; init; } + public int MessagesCount { get; init; } + public long TotalTokens { get; init; } + public int ActiveDays { get; init; } + public int CurrentStreak { get; init; } + public int LongestStreak { get; init; } + public int? MostActiveHour { get; init; } + public string? FavoriteModel { get; init; } + public IReadOnlyList Heatmap { get; init; } = Array.Empty(); + public IReadOnlyList ModelStack { get; init; } = Array.Empty(); + public IReadOnlyList ModelTotals { get; init; } = Array.Empty(); + public CodeStatsPeriod Period { get; init; } + public DateTime ComputedAtUtc { get; init; } + + public static CodeStatsSummary Empty(CodeStatsPeriod period) => new() + { + Period = period, + ComputedAtUtc = DateTime.UtcNow, + }; +} diff --git a/src/AxCopilot/Services/CodeStatsAggregator.cs b/src/AxCopilot/Services/CodeStatsAggregator.cs new file mode 100644 index 0000000..02e91da --- /dev/null +++ b/src/AxCopilot/Services/CodeStatsAggregator.cs @@ -0,0 +1,270 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services; + +/// +/// Aggregates Code tab usage statistics for the EmptyState dashboard. +/// Data comes from AgentStatsService session records and stored conversations. +/// +public static class CodeStatsAggregator +{ + private const string CodeTabKey = "Code"; + private const int HeatmapDays = 84; + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60); + private static readonly object CacheLock = new(); + private static readonly Dictionary Cache = new(); + + public static CodeStatsSummary Aggregate(CodeStatsPeriod period, IChatStorageService? storage = null, bool bypassCache = false) + { + if (!bypassCache) + { + lock (CacheLock) + { + if (Cache.TryGetValue(period, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow) + return cached.Summary; + } + } + + var summary = AggregateInternal(period, storage); + lock (CacheLock) + { + Cache[period] = (summary, DateTime.UtcNow.Add(CacheTtl)); + } + + return summary; + } + + public static void InvalidateCache() + { + lock (CacheLock) + { + Cache.Clear(); + } + } + + private static CodeStatsSummary AggregateInternal(CodeStatsPeriod period, IChatStorageService? storage) + { + var days = (int)period; + var records = AgentStatsService.LoadRecords(days) + .Where(record => string.Equals(record.Tab, CodeTabKey, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (records.Count == 0) + { + return CodeStatsSummary.Empty(period) with + { + MessagesCount = CountStoredCodeMessages(storage), + Heatmap = BuildEmptyHeatmap(), + }; + } + + var sessionsCount = records.Count; + var totalIn = records.Sum(record => (long)record.InputTokens); + var totalOut = records.Sum(record => (long)record.OutputTokens); + var totalTokens = totalIn + totalOut; + + var activeDates = records + .Select(record => record.Timestamp.Date) + .Distinct() + .OrderBy(date => date) + .ToList(); + + var (currentStreak, longestStreak) = ComputeStreaks(activeDates, DateTime.Today); + var mostActiveHour = records + .GroupBy(record => record.Timestamp.Hour) + .Select(group => new { Hour = group.Key, Count = group.Count() }) + .OrderByDescending(item => item.Count) + .ThenBy(item => item.Hour) + .FirstOrDefault() + ?.Hour; + + var favoriteModel = records + .Where(record => !string.IsNullOrWhiteSpace(record.Model)) + .GroupBy(record => record.Model) + .Select(group => new + { + Model = group.Key, + Tokens = group.Sum(record => (long)record.InputTokens + record.OutputTokens), + }) + .OrderByDescending(item => item.Tokens) + .ThenBy(item => item.Model, StringComparer.Ordinal) + .FirstOrDefault() + ?.Model; + + return new CodeStatsSummary + { + SessionsCount = sessionsCount, + MessagesCount = CountStoredCodeMessages(storage), + TotalTokens = totalTokens, + ActiveDays = activeDates.Count, + CurrentStreak = currentStreak, + LongestStreak = longestStreak, + MostActiveHour = mostActiveHour, + FavoriteModel = favoriteModel, + Heatmap = BuildHeatmap(records, DateTime.Today), + ModelStack = BuildModelStack(records), + ModelTotals = BuildModelTotals(records, totalTokens), + Period = period, + ComputedAtUtc = DateTime.UtcNow, + }; + } + + internal static int CountStoredCodeMessages(IChatStorageService? storage) + { + if (storage == null) + return 0; + + try + { + var metas = storage.LoadAllMeta(); + if (metas.Count == 0) + return 0; + + var total = 0; + foreach (var meta in metas) + { + if (!string.Equals(meta.Tab, CodeTabKey, StringComparison.OrdinalIgnoreCase)) + continue; + + var fullConversation = storage.Load(meta.Id); + total += fullConversation?.Messages?.Count ?? 0; + } + + return total; + } + catch + { + return 0; + } + } + + internal static (int Current, int Longest) ComputeStreaks(IEnumerable activeDates, DateTime today) + { + var dateSet = new HashSet(activeDates.Select(date => date.Date)); + if (dateSet.Count == 0) + return (0, 0); + + var current = 0; + var cursor = today.Date; + while (dateSet.Contains(cursor)) + { + current++; + cursor = cursor.AddDays(-1); + } + + var longest = 0; + var run = 0; + DateTime? previous = null; + foreach (var date in dateSet.OrderBy(date => date)) + { + if (previous.HasValue && date == previous.Value.AddDays(1)) + run++; + else + run = 1; + + if (run > longest) + longest = run; + + previous = date; + } + + return (current, longest); + } + + private static IReadOnlyList BuildHeatmap(IEnumerable records, DateTime today) + { + var perDate = records + .GroupBy(record => record.Timestamp.Date) + .ToDictionary(group => group.Key, group => group.Sum(record => (long)record.InputTokens + record.OutputTokens)); + + var cells = new List(HeatmapDays); + for (var offset = HeatmapDays - 1; offset >= 0; offset--) + { + var date = today.Date.AddDays(-offset); + var tokens = perDate.GetValueOrDefault(date, 0L); + cells.Add(new CodeStatsHeatmapCell(date, tokens, 0)); + } + + var nonZero = cells + .Where(cell => cell.Tokens > 0) + .Select(cell => cell.Tokens) + .OrderBy(value => value) + .ToList(); + + if (nonZero.Count == 0) + return cells; + + long Quartile(double pct) + { + if (nonZero.Count == 1) + return nonZero[0]; + + var index = (int)Math.Floor((nonZero.Count - 1) * pct); + return nonZero[Math.Clamp(index, 0, nonZero.Count - 1)]; + } + + var q1 = Quartile(0.25); + var q2 = Quartile(0.50); + var q3 = Quartile(0.75); + + return cells + .Select(cell => cell with + { + IntensityLevel = cell.Tokens <= 0 ? 0 + : cell.Tokens <= q1 ? 1 + : cell.Tokens <= q2 ? 2 + : cell.Tokens <= q3 ? 3 + : 4, + }) + .ToList(); + } + + private static IReadOnlyList BuildEmptyHeatmap() + { + var today = DateTime.Today; + var cells = new List(HeatmapDays); + for (var offset = HeatmapDays - 1; offset >= 0; offset--) + { + cells.Add(new CodeStatsHeatmapCell(today.AddDays(-offset), 0L, 0)); + } + + return cells; + } + + private static IReadOnlyList BuildModelStack(IEnumerable records) + { + return records + .Where(record => !string.IsNullOrWhiteSpace(record.Model)) + .GroupBy(record => record.Timestamp.Date) + .OrderBy(group => group.Key) + .Select(group => new CodeStatsDailyModelStack( + group.Key, + group.GroupBy(record => record.Model) + .Select(modelGroup => new CodeStatsModelDailyTokens( + modelGroup.Key, + modelGroup.Sum(record => (long)record.InputTokens + record.OutputTokens))) + .OrderByDescending(item => item.Tokens) + .ToList())) + .ToList(); + } + + private static IReadOnlyList BuildModelTotals( + IEnumerable records, + long totalTokens) + { + return records + .Where(record => !string.IsNullOrWhiteSpace(record.Model)) + .GroupBy(record => record.Model) + .Select(group => + { + var inTokens = group.Sum(record => (long)record.InputTokens); + var outTokens = group.Sum(record => (long)record.OutputTokens); + var combined = inTokens + outTokens; + var share = totalTokens > 0 ? combined * 100.0 / totalTokens : 0.0; + return new CodeStatsModelTotal(group.Key, inTokens, outTokens, Math.Round(share, 1)); + }) + .OrderByDescending(item => item.InTokens + item.OutTokens) + .ThenBy(item => item.Model, StringComparer.Ordinal) + .ToList(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs b/src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs new file mode 100644 index 0000000..28d550d --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs @@ -0,0 +1,517 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// Renders the Code tab usage dashboard inside the EmptyState surface when the +/// current Code conversation has no transcript yet. +/// +public partial class ChatWindow +{ + private static readonly string[] ModelPaletteHex = + { + "#A78BFA", + "#3B82F6", + "#10B981", + "#F59E0B", + "#EC4899", + "#06B6D4", + "#F97316", + "#8B5CF6", + }; + + private CodeStatsPeriod _codeStatsPeriod = CodeStatsPeriod.Last30Days; + private string _codeStatsInnerTab = "overview"; + private bool _codeStatsInitialized; + private bool _codeStatsRefreshInFlight; + + private void InitializeCodeStats() + { + if (_codeStatsInitialized) + return; + + _codeStatsInitialized = true; + SetCodeStatsInnerTabVisuals(); + SetCodeStatsRangeVisuals(); + } + + private void UpdateCodeStatsVisibility() + { + if (CodeStatsDashboardScroll == null || EmptyStateCenterStack == null) + return; + + var isCodeTab = string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase); + var hasMessages = (_currentConversation?.Messages?.Count ?? 0) > 0; + var emptyVisible = EmptyState != null && EmptyState.Visibility == Visibility.Visible; + var shouldShowDashboard = isCodeTab && !hasMessages && emptyVisible; + + CodeStatsDashboardScroll.Visibility = shouldShowDashboard ? Visibility.Visible : Visibility.Collapsed; + EmptyStateCenterStack.Visibility = shouldShowDashboard ? Visibility.Collapsed : Visibility.Visible; + ApplyMascotDashboardLayout(shouldShowDashboard); + + if (shouldShowDashboard) + { + InitializeCodeStats(); + _ = RefreshCodeStatsAsync(); + } + } + + private void ApplyMascotDashboardLayout(bool dashboardActive) + { + if (MascotImage == null || MascotScale == null) + return; + + if (dashboardActive) + { + MascotScale.ScaleX = 0.55; + MascotScale.ScaleY = 0.55; + MascotImage.Opacity = 0.65; + } + else + { + MascotScale.ScaleX = 1.0; + MascotScale.ScaleY = 1.0; + MascotImage.Opacity = 0.92; + } + } + + private async Task RefreshCodeStatsAsync() + { + if (_codeStatsRefreshInFlight) + return; + + _codeStatsRefreshInFlight = true; + try + { + var period = _codeStatsPeriod; + var storage = _storage; + var summary = await Task.Run(() => CodeStatsAggregator.Aggregate(period, storage)); + await Dispatcher.InvokeAsync(() => RenderCodeStats(summary)); + } + catch (Exception ex) + { + LogService.Debug($"[CodeStats] refresh failed: {ex.Message}"); + } + finally + { + _codeStatsRefreshInFlight = false; + } + } + + private void RenderCodeStats(CodeStatsSummary summary) + { + if (CodeStatsDashboard == null) + return; + + RenderStatsCards(summary); + RenderStatsHeatmap(summary); + RenderStatsFunFact(summary); + RenderStatsModelPanel(summary); + ApplyInnerTabVisibility(); + } + + private void RenderStatsCards(CodeStatsSummary summary) + { + if (StatsCardGrid == null) + return; + + StatsCardGrid.Children.Clear(); + StatsCardGrid.Children.Add(MakeStatsCard("\uE9D9", "세션", FormatNumber(summary.SessionsCount), "#A78BFA")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE8BD", "메시지", FormatNumber(summary.MessagesCount), "#3B82F6")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE7C8", "총 토큰", FormatTokens(summary.TotalTokens), "#10B981")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE787", "활성 일수", FormatNumber(summary.ActiveDays), "#F59E0B")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE945", "현재 연속 일수", $"{summary.CurrentStreak}일", "#EC4899")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE735", "최장 연속 일수", $"{summary.LongestStreak}일", "#F97316")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE823", "최다 사용 시간", FormatHour(summary.MostActiveHour), "#06B6D4")); + StatsCardGrid.Children.Add(MakeStatsCard("\uE790", "가장 많이 쓴 모델", FormatModelName(summary.FavoriteModel), "#8B5CF6")); + } + + private Border MakeStatsCard(string icon, string label, string value, string colorHex) + { + var background = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x1F, 0x1F, 0x2E)); + var foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.DimGray; + var color = (Color)ColorConverter.ConvertFromString(colorHex); + + var card = new Border + { + Background = background, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(14, 12, 14, 12), + Margin = new Thickness(0, 0, 8, 8), + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = label, + FontSize = 11, + Foreground = secondary, + Margin = new Thickness(0, 0, 0, 6), + }); + + var valueRow = new StackPanel { Orientation = Orientation.Horizontal }; + valueRow.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 14, + Foreground = new SolidColorBrush(color), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + valueRow.Children.Add(new TextBlock + { + Text = value, + FontSize = 18, + FontWeight = FontWeights.SemiBold, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + stack.Children.Add(valueRow); + card.Child = stack; + card.ToolTip = $"{label}: {value}"; + return card; + } + + private void RenderStatsHeatmap(CodeStatsSummary summary) + { + if (StatsHeatmapCanvas == null) + return; + + StatsHeatmapCanvas.Children.Clear(); + + const double cellSize = 14; + const double cellGap = 3; + const int rows = 7; + + var accent = TryFindResource("AccentColor") as SolidColorBrush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var emptyBackground = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80)); + + var cells = summary.Heatmap; + if (cells.Count == 0) + return; + + var firstWeekday = WeekdayIndex(cells[0].Date); + for (var i = 0; i < cells.Count; i++) + { + var cell = cells[i]; + var slot = i + firstWeekday; + var column = slot / rows; + var row = slot % rows; + + var fill = cell.IntensityLevel switch + { + 0 => emptyBackground, + 1 => MakeAlpha(accent.Color, 0.30), + 2 => MakeAlpha(accent.Color, 0.55), + 3 => MakeAlpha(accent.Color, 0.78), + _ => new SolidColorBrush(accent.Color) { Opacity = 1.0 }, + }; + + var rectangle = new Rectangle + { + Width = cellSize, + Height = cellSize, + Fill = fill, + RadiusX = 3, + RadiusY = 3, + ToolTip = $"{cell.Date:yyyy-MM-dd} · {FormatTokens(cell.Tokens)} tokens", + }; + + Canvas.SetLeft(rectangle, column * (cellSize + cellGap)); + Canvas.SetTop(rectangle, row * (cellSize + cellGap)); + StatsHeatmapCanvas.Children.Add(rectangle); + } + } + + private static SolidColorBrush MakeAlpha(Color baseColor, double alpha) + { + var channel = (byte)Math.Clamp((int)(alpha * 255), 0, 255); + return new SolidColorBrush(Color.FromArgb(channel, baseColor.R, baseColor.G, baseColor.B)); + } + + private static int WeekdayIndex(DateTime date) + => ((int)date.DayOfWeek + 6) % 7; + + private void RenderStatsFunFact(CodeStatsSummary summary) + { + if (StatsFooterFunFact == null) + return; + + const long ReferenceTokensPerBook = 60_000L; + const string ReferenceTitle = "어린 왕자"; + + if (summary.TotalTokens < ReferenceTokensPerBook) + { + StatsFooterFunFact.Visibility = Visibility.Collapsed; + return; + } + + var ratio = summary.TotalTokens / (double)ReferenceTokensPerBook; + StatsFooterFunFact.Text = $"『{ReferenceTitle}』보다 약 {ratio:0}배 많은 토큰을 사용했습니다."; + StatsFooterFunFact.Visibility = Visibility.Visible; + } + + private void RenderStatsModelPanel(CodeStatsSummary summary) + { + if (StatsModelStackCanvas == null || StatsModelStackXAxis == null || StatsModelLegend == null) + return; + + StatsModelStackCanvas.Children.Clear(); + StatsModelStackXAxis.Children.Clear(); + StatsModelLegend.Items.Clear(); + + var stack = summary.ModelStack; + var totals = summary.ModelTotals; + var modelColor = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < totals.Count; i++) + { + modelColor[totals[i].Model] = (Color)ColorConverter.ConvertFromString(ModelPaletteHex[i % ModelPaletteHex.Length]); + } + + if (stack.Count > 0) + { + var maxDayTotal = Math.Max(1, stack.Max(day => day.Models.Sum(model => model.Tokens))); + const double chartHeight = 196; + const double chartWidth = 720; + const double gap = 2; + var barWidth = Math.Max(4, (chartWidth - gap * stack.Count) / Math.Max(1, stack.Count)); + + for (var i = 0; i < stack.Count; i++) + { + var day = stack[i]; + var x = i * (barWidth + gap); + var dayTotal = day.Models.Sum(model => model.Tokens); + var dayHeight = (dayTotal / (double)maxDayTotal) * (chartHeight - 4); + var cursorY = chartHeight - dayHeight; + + foreach (var slice in day.Models) + { + if (slice.Tokens <= 0) + continue; + + var sliceHeight = (slice.Tokens / (double)dayTotal) * dayHeight; + var color = modelColor.TryGetValue(slice.Model, out var mapped) + ? mapped + : Color.FromRgb(0x88, 0x88, 0x88); + + var rect = new Rectangle + { + Width = barWidth, + Height = Math.Max(1, sliceHeight), + Fill = new SolidColorBrush(color), + ToolTip = $"{day.Date:yyyy-MM-dd}\n{ShortenModelName(slice.Model)}: {FormatTokens(slice.Tokens)}", + }; + + Canvas.SetLeft(rect, x); + Canvas.SetTop(rect, cursorY); + StatsModelStackCanvas.Children.Add(rect); + cursorY += sliceHeight; + } + + if (stack.Count <= 14 || i % 7 == 0) + { + var label = new TextBlock + { + Text = day.Date.ToString("M/d", CultureInfo.InvariantCulture), + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + }; + Canvas.SetLeft(label, x); + StatsModelStackXAxis.Children.Add(label); + } + } + } + + foreach (var total in totals) + { + var color = modelColor.TryGetValue(total.Model, out var mapped) + ? mapped + : Color.FromRgb(0x88, 0x88, 0x88); + + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 2, 0, 2), + }; + row.Children.Add(new Rectangle + { + Width = 12, + Height = 12, + Fill = new SolidColorBrush(color), + RadiusX = 2, + RadiusY = 2, + Margin = new Thickness(0, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + row.Children.Add(new TextBlock + { + Text = ShortenModelName(total.Model), + FontSize = 12, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + }); + row.Children.Add(new TextBlock + { + Text = $" {FormatTokens(total.InTokens)} in · {FormatTokens(total.OutTokens)} out", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }); + row.Children.Add(new TextBlock + { + Text = $" {total.SharePercent.ToString("0.0", CultureInfo.InvariantCulture)}%", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }); + StatsModelLegend.Items.Add(row); + } + } + + private void StatsInnerTab_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is not Border border || border.Tag is not string tag) + return; + + if (string.Equals(tag, _codeStatsInnerTab, StringComparison.OrdinalIgnoreCase)) + return; + + _codeStatsInnerTab = tag; + SetCodeStatsInnerTabVisuals(); + ApplyInnerTabVisibility(); + } + + private void ApplyInnerTabVisibility() + { + if (StatsOverviewPanel == null || StatsModelPanel == null || StatsHeatmapHost == null) + return; + + var isOverview = string.Equals(_codeStatsInnerTab, "overview", StringComparison.OrdinalIgnoreCase); + StatsOverviewPanel.Visibility = isOverview ? Visibility.Visible : Visibility.Collapsed; + StatsHeatmapHost.Visibility = isOverview ? Visibility.Visible : Visibility.Collapsed; + StatsModelPanel.Visibility = isOverview ? Visibility.Collapsed : Visibility.Visible; + } + + private void SetCodeStatsInnerTabVisuals() + { + var activeBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var activeForeground = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var inactiveForeground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var isOverview = string.Equals(_codeStatsInnerTab, "overview", StringComparison.OrdinalIgnoreCase); + + if (StatsTabOverview != null) + StatsTabOverview.Background = isOverview ? activeBackground : Brushes.Transparent; + if (StatsTabOverviewLabel != null) + { + StatsTabOverviewLabel.Foreground = isOverview ? activeForeground : inactiveForeground; + StatsTabOverviewLabel.FontWeight = isOverview ? FontWeights.SemiBold : FontWeights.Normal; + } + + if (StatsTabModel != null) + StatsTabModel.Background = isOverview ? Brushes.Transparent : activeBackground; + if (StatsTabModelLabel != null) + { + StatsTabModelLabel.Foreground = isOverview ? inactiveForeground : activeForeground; + StatsTabModelLabel.FontWeight = isOverview ? FontWeights.Normal : FontWeights.SemiBold; + } + } + + private void StatsRange_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is not Border border || border.Tag is not string tag) + return; + + var newPeriod = tag switch + { + "all" => CodeStatsPeriod.All, + "7" => CodeStatsPeriod.Last7Days, + _ => CodeStatsPeriod.Last30Days, + }; + + if (newPeriod == _codeStatsPeriod) + return; + + _codeStatsPeriod = newPeriod; + SetCodeStatsRangeVisuals(); + _ = RefreshCodeStatsAsync(); + } + + private void SetCodeStatsRangeVisuals() + { + var activeBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var activeForeground = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var inactiveForeground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + void Apply(Border? border, TextBlock? label, bool active) + { + if (border != null) + border.Background = active ? activeBackground : Brushes.Transparent; + + if (label == null) + return; + + label.Foreground = active ? activeForeground : inactiveForeground; + label.FontWeight = active ? FontWeights.SemiBold : FontWeights.Normal; + } + + Apply(StatsRangeAll, StatsRangeAllLabel, _codeStatsPeriod == CodeStatsPeriod.All); + Apply(StatsRange30, StatsRange30Label, _codeStatsPeriod == CodeStatsPeriod.Last30Days); + Apply(StatsRange7, StatsRange7Label, _codeStatsPeriod == CodeStatsPeriod.Last7Days); + } + + private static string FormatTokens(long count) => count switch + { + >= 1_000_000_000 => $"{count / 1_000_000_000.0:0.#}B", + >= 1_000_000 => $"{count / 1_000_000.0:0.#}M", + >= 1_000 => $"{count / 1_000.0:0.#}K", + _ => count.ToString("N0"), + }; + + private static string FormatNumber(int count) => count.ToString("N0"); + + private static string FormatHour(int? hour) + { + if (!hour.HasValue) + return "없음"; + + var value = hour.Value; + if (value == 0) + return "오전 12시"; + if (value < 12) + return $"오전 {value}시"; + if (value == 12) + return "오후 12시"; + return $"오후 {value - 12}시"; + } + + private static string FormatModelName(string? raw) + => string.IsNullOrWhiteSpace(raw) ? "없음" : ShortenModelName(raw); + + private static string ShortenModelName(string raw) + { + var separatorIndex = raw.IndexOf(':'); + return separatorIndex > 0 && separatorIndex < raw.Length - 1 + ? raw[(separatorIndex + 1)..] + : raw; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs index 0bce89b..8e050e7 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs @@ -397,11 +397,11 @@ public partial class ChatWindow stack.Children.Add(CreateSeparator()); - // 아카이브 토글 + // Archive toggle. var isArchived = _storage.Load(conversationId)?.Archived ?? false; stack.Children.Add(CreateMenuItem( isArchived ? "\uE7B8" : "\uE7B7", - isArchived ? "아카이브 해제" : "아카이브 보관", + isArchived ? "Unarchive" : "Archive", TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, () => { var convToArchive = _storage.Load(conversationId); @@ -416,9 +416,9 @@ public partial class ChatWindow RefreshConversationList(); })); - stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => + stack.Children.Add(CreateMenuItem("\uE74D", "Delete conversation", Brushes.IndianRed, () => { - var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", + var result = CustomMessageBox.Show("Delete this conversation?", "Delete conversation", MessageBoxButton.YesNo, MessageBoxImage.Question); if (result != MessageBoxResult.Yes) return; @@ -428,8 +428,9 @@ public partial class ChatWindow if (_currentConversation?.Id == conversationId) { _currentConversation = null; - ClearTranscriptElements(); + ClearTranscriptElements(); EmptyState.Visibility = Visibility.Visible; + UpdateCodeStatsVisibility(); UpdateChatTitle(); } } diff --git a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs index 85340e8..7e8e3b2 100644 --- a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs @@ -3820,6 +3820,7 @@ public partial class ChatWindow } ClearTranscriptElements(); EmptyState.Visibility = Visibility.Visible; + UpdateCodeStatsVisibility(); UpdateChatTitle(); RefreshConversationList(); } diff --git a/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs b/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs index 3b922ee..a2eaa03 100644 --- a/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs @@ -88,8 +88,9 @@ public partial class ChatWindow SelectTopic(tag.Preset); break; case "etc": - Services.LogService.Info($"[EmptyState] HIDE ← TopicButton_etc, tab={_activeTab}"); + Services.LogService.Info($"[EmptyState] HIDE caller=TopicButton_etc, tab={_activeTab}"); EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); InputBox.Focus(); break; case "add": @@ -123,18 +124,18 @@ public partial class ChatWindow if (_activeTab == "Cowork" || _activeTab == "Code") { if (EmptyStateTitle != null) - EmptyStateTitle.Text = _activeTab == "Code" ? "코드 작업을 입력하세요" : "작업 유형을 선택하세요"; + EmptyStateTitle.Text = _activeTab == "Code" ? "Describe the coding task" : "Choose the work style"; if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code" - ? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다" - : "에이전트가 상세한 데이터를 작성합니다"; + ? "The coding agent can inspect, modify, build, and test the workspace." + : "The agent can draft a detailed deliverable for the selected work style."; } else { if (EmptyStateTitle != null) - EmptyStateTitle.Text = "대화 주제를 선택하세요"; + EmptyStateTitle.Text = "Choose a topic"; if (EmptyStateDesc != null) - EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다"; + EmptyStateDesc.Text = "A matching prompt preset is applied automatically for the topic you choose."; } if (_activeTab == "Code") @@ -603,13 +604,14 @@ public partial class ChatWindow _ = System.Threading.Tasks.Task.Run(() => { try { _storage.Save(convCopy); } - catch (Exception ex) { Services.LogService.Debug($"프리셋 저장 실패: {ex.Message}"); } + catch (Exception ex) { Services.LogService.Debug($"[Preset] save failed: {ex.Message}"); } }); } if (EmptyState != null) { - Services.LogService.Info($"[EmptyState] HIDE ← SelectTopic, tab={_activeTab}"); + Services.LogService.Info($"[EmptyState] HIDE caller=SelectTopic, tab={_activeTab}"); EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); } InputBox.Focus(); diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs index 62dc7f2..5204083 100644 --- a/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs @@ -8,10 +8,10 @@ namespace AxCopilot.Views; public partial class ChatWindow { - // ─── 렌더링 쓰로틀: 스트리밍 중 최소 간격 보장 ─────────────────────── + // Render throttling keeps the transcript responsive during heavy streaming. private long _lastRenderTicks; - private const long MinStreamingRenderIntervalMs = 1500; // 스트리밍 중 최소 1.5초 간격 - private const long MinIdleRenderIntervalMs = 300; // 비스트리밍(유휴) 시 최소 300ms 간격 + private const long MinStreamingRenderIntervalMs = 1500; + private const long MinIdleRenderIntervalMs = 300; private int GetActiveTimelineRenderLimit() { @@ -26,42 +26,38 @@ public partial class ChatWindow private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null) { - // B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지 - if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible) + // Skip background rendering when the window is not actually visible. + if (WindowState == System.Windows.WindowState.Minimized || !IsVisible) return; var now = Environment.TickCount64; - // B-5: 스트리밍 중 쓰로틀 — preserveViewport=true (타이머 기반) 호출만 제한 - // preserveViewport=false는 사용자 메시지 전송 등 중요 렌더이므로 항상 허용 - if (_isStreaming && preserveViewport) - { - if (now - _lastRenderTicks < MinStreamingRenderIntervalMs) - return; - } + // Throttle preserveViewport renders while streaming. + if (_isStreaming && preserveViewport && now - _lastRenderTicks < MinStreamingRenderIntervalMs) + return; - // B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인) - // 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시 + // Suppress rapid idle re-renders when the visible content is unchanged. if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs) { - ChatConversation? quickConv; - lock (_convLock) quickConv = _currentConversation; - var quickMsgCount = quickConv?.Messages?.Count ?? 0; - var quickEvtCount = quickConv?.ExecutionEvents?.Count ?? 0; - // 대화 내용이 마지막 렌더와 같으면 스킵 (빈 대화 반복 렌더 차단) - if (quickMsgCount == _lastRenderedMessageCount - && quickEvtCount == _lastRenderedEventCount - && string.Equals(_lastRenderedConversationId, quickConv?.Id, StringComparison.OrdinalIgnoreCase)) + ChatConversation? quickConversation; + lock (_convLock) + quickConversation = _currentConversation; + + var quickMessageCount = quickConversation?.Messages?.Count ?? 0; + var quickEventCount = quickConversation?.ExecutionEvents?.Count ?? 0; + + if (quickMessageCount == _lastRenderedMessageCount + && quickEventCount == _lastRenderedEventCount + && string.Equals(_lastRenderedConversationId, quickConversation?.Id, StringComparison.OrdinalIgnoreCase)) { return; } - // B-7b: 빈 대화 간 convId 플래핑 방지 — 둘 다 메시지 0개면 - // convId가 달라도 빈 화면 렌더를 반복할 이유 없음 (SwitchToTabConversation 스팸 차단) - // 단, preserveViewport=false(탭 전환 등 명시적 렌더)는 차단하지 않음 - // — 탭 전환 시 EmptyState/마스코트 표시에 필요 + if (preserveViewport - && quickMsgCount == 0 && quickEvtCount == 0 - && _lastRenderedMessageCount == 0 && _lastRenderedEventCount == 0) + && quickMessageCount == 0 + && quickEventCount == 0 + && _lastRenderedMessageCount == 0 + && _lastRenderedEventCount == 0) { return; } @@ -71,32 +67,33 @@ public partial class ChatWindow var previousScrollableHeight = GetTranscriptScrollableHeight(); var previousVerticalOffset = GetTranscriptVerticalOffset(); - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); + ChatConversation? conversation; + lock (_convLock) + conversation = _currentConversation; - var visibleMessages = GetVisibleTimelineMessages(conv); - var visibleEvents = GetVisibleTimelineEvents(conv); + _appState.RestoreAgentRunHistory(conversation?.AgentRunHistory); - // 진단 로그: 렌더링 호출 시점의 상태 추적 - Services.LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " + - $"convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}, " + - $"rawMsgCount={conv?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " + - $"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " + - $"transcriptElements={GetTranscriptElementCount()}"); + var visibleMessages = GetVisibleTimelineMessages(conversation); + var visibleEvents = GetVisibleTimelineEvents(conversation); + + LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " + + $"convId={conversation?.Id?[..Math.Min(8, conversation?.Id?.Length ?? 0)]}, " + + $"rawMsgCount={conversation?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " + + $"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " + + $"transcriptElements={GetTranscriptElementCount()}"); if (_isStreaming && preserveViewport && visibleMessages.Count == _lastRenderedMessageCount && visibleEvents.Count == _lastRenderedEventCount - && (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory - && string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase)) - return; - - if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0)) + && (conversation?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory + && string.Equals(_lastRenderedConversationId, conversation?.Id, StringComparison.OrdinalIgnoreCase)) { - // 스트리밍 중이거나 대화에 원본 메시지가 있으면 EmptyState를 표시하지 않음 - // (GetVisibleTimelineMessages의 필터링으로 visibleMessages가 0이 되어도 원본은 존재) - bool hasRawMessages = (conv?.Messages?.Count ?? 0) > 0; + return; + } + + if (conversation == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0)) + { + var hasRawMessages = (conversation?.Messages?.Count ?? 0) > 0; if (!_isStreaming && !hasRawMessages) { ClearTranscriptElements(); @@ -108,15 +105,15 @@ public partial class ChatWindow } else { - // 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김 HideEmptyState(animate: preserveViewport); } + return; } - if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(_lastRenderedConversationId, conversation.Id, StringComparison.OrdinalIgnoreCase)) { - _lastRenderedConversationId = conv.Id; + _lastRenderedConversationId = conversation.Id; _timelineRenderLimit = TimelineRenderPageSize; _elementCache.Clear(); _lastRenderedTimelineKeys.Clear(); @@ -127,41 +124,54 @@ public partial class ChatWindow HideEmptyState(animate: preserveViewport); - // V2 렌더링 (Claude Code 스타일 상세 이력 UI) - RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport, - previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller); + RenderMessagesV2( + conversation, + visibleMessages, + visibleEvents, + preserveViewport, + previousScrollableHeight, + previousVerticalOffset, + renderStopwatch, + caller); - // 렌더 완료 후 진단 — transcript에 실제로 요소가 추가되었는지 확인 var postRenderCount = GetTranscriptElementCount(); if (postRenderCount == 0 && visibleMessages.Count > 0) - Services.LogService.Warn($"[Render] POST-RENDER WARNING: transcript has 0 elements after rendering {visibleMessages.Count} messages! caller={caller}, convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}"); + { + LogService.Warn($"[Render] POST-RENDER WARNING: transcript has 0 elements after rendering {visibleMessages.Count} messages! " + + $"caller={caller}, convId={conversation?.Id?[..Math.Min(8, conversation?.Id?.Length ?? 0)]}"); + } } - /// EmptyState를 표시합니다. 진행 중인 페이드 애니메이션을 취소하고 Opacity를 복원합니다. + /// + /// Shows EmptyState and cancels any in-flight fade animation. + /// private void ShowEmptyState([System.Runtime.CompilerServices.CallerMemberName] string? caller = null) { - var prev = EmptyState.Visibility; + var previousVisibility = EmptyState.Visibility; ++_emptyStateAnimationToken; EmptyState.BeginAnimation(OpacityProperty, null); EmptyState.Opacity = 1; EmptyState.Visibility = System.Windows.Visibility.Visible; - Services.LogService.Info($"[EmptyState] SHOW ← {caller}, prev={prev}, opacity={EmptyState.Opacity}, tab={_activeTab}"); + UpdateCodeStatsVisibility(); + LogService.Info($"[EmptyState] SHOW caller={caller}, prev={previousVisibility}, opacity={EmptyState.Opacity}, tab={_activeTab}"); StartMascotAnimation(); } /// - /// EmptyState를 숨깁니다. animate=true이면 150ms 페이드아웃, false이면 즉시 Collapsed. - /// 토큰 기반 무효화: 탭 전환이나 ShowEmptyState가 호출되면 진행 중인 Completed 콜백이 무시됩니다. + /// Hides EmptyState. When animate is true, a 150 ms fade-out is used. + /// Token-based invalidation prevents stale callbacks from collapsing a newly + /// shown EmptyState. /// private void HideEmptyState(bool animate, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null) { if (EmptyState.Visibility != System.Windows.Visibility.Visible) { StopMascotAnimation(); + UpdateCodeStatsVisibility(); return; } - Services.LogService.Info($"[EmptyState] HIDE ← {caller}, animate={animate}, tab={_activeTab}"); + LogService.Info($"[EmptyState] HIDE caller={caller}, animate={animate}, tab={_activeTab}"); if (animate) { @@ -170,17 +180,21 @@ public partial class ChatWindow { EasingFunction = new System.Windows.Media.Animation.QuadraticEase() }; + fadeOut.Completed += (_, _) => { if (token != _emptyStateAnimationToken) { - Services.LogService.Info($"[EmptyState] HIDE animation STALE (token {token} vs {_emptyStateAnimationToken}), tab={_activeTab}"); + LogService.Info($"[EmptyState] HIDE animation stale (token {token} vs {_emptyStateAnimationToken}), tab={_activeTab}"); return; } + EmptyState.Visibility = System.Windows.Visibility.Collapsed; EmptyState.Opacity = 1; - Services.LogService.Info($"[EmptyState] HIDE animation COMPLETED, tab={_activeTab}"); + UpdateCodeStatsVisibility(); + LogService.Info($"[EmptyState] HIDE animation completed, tab={_activeTab}"); }; + EmptyState.BeginAnimation(OpacityProperty, fadeOut); } else @@ -189,7 +203,9 @@ public partial class ChatWindow EmptyState.BeginAnimation(OpacityProperty, null); EmptyState.Opacity = 1; EmptyState.Visibility = System.Windows.Visibility.Collapsed; + UpdateCodeStatsVisibility(); } + StopMascotAnimation(); } } diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 2a1021d..44b901c 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -1848,7 +1848,8 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 4e7b52f..ab7c552 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1461,6 +1461,7 @@ public partial class ChatWindow : Window ? Visibility.Collapsed : Visibility.Visible; } + UpdateCodeStatsVisibility(); if (TopicButtonPanel != null) TopicButtonPanel.Visibility = Visibility.Visible; } @@ -2047,6 +2048,7 @@ public partial class ChatWindow : Window // 현재 대화를 해당 탭 대화로 전환 SwitchToTabConversation(); + UpdateCodeStatsVisibility(); // Cowork/Code 탭 전환 시 팁 표시 ShowRandomTip(); @@ -4479,6 +4481,7 @@ public partial class ChatWindow : Window UpdateChatTitle(); InputBox.Text = ""; EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); InvalidateTimelineCache(); RenderMessages(preserveViewport: false); @@ -4563,6 +4566,7 @@ public partial class ChatWindow : Window UpdateChatTitle(); InputBox.Text = ""; EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); InvalidateTimelineCache(); RenderMessages(preserveViewport: false); } @@ -6010,6 +6014,7 @@ public partial class ChatWindow : Window UpdateInputBoxHeight(); } EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); StopMascotAnimation(); InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장 try @@ -6022,6 +6027,7 @@ public partial class ChatWindow : Window } // RenderMessages가 EmptyState를 Visible로 되돌릴 수 있으므로 강제 재설정 EmptyState.Visibility = Visibility.Collapsed; + UpdateCodeStatsVisibility(); // ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ── var convForPersist = conv;