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;