코드탭 통계 대시보드 병합 및 빈화면 동기화 보강

외부 작업 로그 기준으로 코드탭 병합 누락분을 검토하고 기존 컨텍스트 영속화 경로는 유지한 채 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개 통과
This commit is contained in:
2026-04-16 08:12:20 +09:00
parent e07b6dbed0
commit d5dbaa6e4a
12 changed files with 1284 additions and 79 deletions

View File

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

View File

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

View File

@@ -0,0 +1,191 @@
using AxCopilot.Models;
using AxCopilot.Services;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
/// <summary>
/// Tests for the pure helper logic inside CodeStatsAggregator.
/// </summary>
public class CodeStatsAggregatorTests
{
[Fact]
public void ComputeStreaks_ReturnsZeroWhenNoActivity()
{
var (current, longest) = CodeStatsAggregator.ComputeStreaks(
Array.Empty<DateTime>(),
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<ChatConversation> _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<ChatConversation?> LoadAsync(string id) => Task.FromResult<ChatConversation?>(Load(id));
public List<ChatConversation> 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;
}
}

View File

@@ -0,0 +1,63 @@
namespace AxCopilot.Models;
/// <summary>
/// Period filter for the Code tab usage dashboard.
/// Mirrors the "All / 30d / 7d" controls shown in the reference stats view.
/// </summary>
public enum CodeStatsPeriod
{
/// <summary>Aggregate over every record on disk.</summary>
All = 0,
/// <summary>Aggregate over the last 30 days.</summary>
Last30Days = 30,
/// <summary>Aggregate over the last 7 days.</summary>
Last7Days = 7,
}
/// <summary>
/// One day in the activity heatmap.
/// IntensityLevel is 0..4 where 0 means "no activity".
/// </summary>
public sealed record CodeStatsHeatmapCell(DateTime Date, long Tokens, int IntensityLevel);
/// <summary>One model bucket within a single day of the model chart.</summary>
public sealed record CodeStatsModelDailyTokens(string Model, long Tokens);
/// <summary>One day in the stacked model chart.</summary>
public sealed record CodeStatsDailyModelStack(DateTime Date, IReadOnlyList<CodeStatsModelDailyTokens> Models);
/// <summary>
/// Per-model aggregate row shown beneath the model stacked bar chart.
/// SharePercent is 0..100.
/// </summary>
public sealed record CodeStatsModelTotal(
string Model,
long InTokens,
long OutTokens,
double SharePercent);
/// <summary>
/// Summary model used by the Code tab usage dashboard.
/// </summary>
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<CodeStatsHeatmapCell> Heatmap { get; init; } = Array.Empty<CodeStatsHeatmapCell>();
public IReadOnlyList<CodeStatsDailyModelStack> ModelStack { get; init; } = Array.Empty<CodeStatsDailyModelStack>();
public IReadOnlyList<CodeStatsModelTotal> ModelTotals { get; init; } = Array.Empty<CodeStatsModelTotal>();
public CodeStatsPeriod Period { get; init; }
public DateTime ComputedAtUtc { get; init; }
public static CodeStatsSummary Empty(CodeStatsPeriod period) => new()
{
Period = period,
ComputedAtUtc = DateTime.UtcNow,
};
}

View File

@@ -0,0 +1,270 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// Aggregates Code tab usage statistics for the EmptyState dashboard.
/// Data comes from AgentStatsService session records and stored conversations.
/// </summary>
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<CodeStatsPeriod, (CodeStatsSummary Summary, DateTime ExpiresAtUtc)> 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<DateTime> activeDates, DateTime today)
{
var dateSet = new HashSet<DateTime>(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<CodeStatsHeatmapCell> BuildHeatmap(IEnumerable<AgentStatsService.AgentSessionRecord> 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<CodeStatsHeatmapCell>(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<CodeStatsHeatmapCell> BuildEmptyHeatmap()
{
var today = DateTime.Today;
var cells = new List<CodeStatsHeatmapCell>(HeatmapDays);
for (var offset = HeatmapDays - 1; offset >= 0; offset--)
{
cells.Add(new CodeStatsHeatmapCell(today.AddDays(-offset), 0L, 0));
}
return cells;
}
private static IReadOnlyList<CodeStatsDailyModelStack> BuildModelStack(IEnumerable<AgentStatsService.AgentSessionRecord> 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<CodeStatsModelTotal> BuildModelTotals(
IEnumerable<AgentStatsService.AgentSessionRecord> 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();
}
}

View File

@@ -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;
/// <summary>
/// Renders the Code tab usage dashboard inside the EmptyState surface when the
/// current Code conversation has no transcript yet.
/// </summary>
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<string, Color>(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;
}
}

View File

@@ -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;
@@ -430,6 +430,7 @@ public partial class ChatWindow
_currentConversation = null;
ClearTranscriptElements();
EmptyState.Visibility = Visibility.Visible;
UpdateCodeStatsVisibility();
UpdateChatTitle();
}
}

View File

@@ -3820,6 +3820,7 @@ public partial class ChatWindow
}
ClearTranscriptElements();
EmptyState.Visibility = Visibility.Visible;
UpdateCodeStatsVisibility();
UpdateChatTitle();
RefreshConversationList();
}

View File

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

View File

@@ -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)
// Throttle preserveViewport renders while streaming.
if (_isStreaming && preserveViewport && now - _lastRenderTicks < MinStreamingRenderIntervalMs)
return;
// Suppress rapid idle re-renders when the visible content is unchanged.
if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs)
{
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))
{
if (now - _lastRenderTicks < MinStreamingRenderIntervalMs)
return;
}
// B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인)
// 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시
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))
{
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}, " +
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)]}");
}
}
/// <summary>EmptyState를 표시합니다. 진행 중인 페이드 애니메이션을 취소하고 Opacity를 복원합니다.</summary>
/// <summary>
/// Shows EmptyState and cancels any in-flight fade animation.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
}

View File

@@ -1848,7 +1848,8 @@
</Image>
</Canvas>
<StackPanel HorizontalAlignment="Center"
<StackPanel x:Name="EmptyStateCenterStack"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,8,0,8">
<StackPanel HorizontalAlignment="Center"
@@ -1878,12 +1879,124 @@
MaxHeight="420"
Margin="0"
Padding="0,4,0,8">
<!-- 대화 주제 버튼 (프리셋에서 동적 생성) -->
<!-- Topic preset buttons (dynamically populated). -->
<WrapPanel x:Name="TopicButtonPanel" HorizontalAlignment="Center"
Margin="0,0,0,8"
Background="Transparent"/>
</ScrollViewer>
</StackPanel>
<!-- Code tab usage dashboard. -->
<ScrollViewer x:Name="CodeStatsDashboardScroll"
Visibility="Collapsed"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="Transparent">
<Grid x:Name="CodeStatsDashboard"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Margin="0,16,0,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,14">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Border x:Name="StatsTabOverview" Tag="overview"
Padding="12,6" Margin="0,0,6,0" CornerRadius="8"
Background="{DynamicResource ItemHoverBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand"
MouseLeftButtonUp="StatsInnerTab_MouseLeftButtonUp">
<TextBlock x:Name="StatsTabOverviewLabel" Text="개요"
Foreground="{DynamicResource PrimaryText}"
FontSize="12" FontWeight="SemiBold"/>
</Border>
<Border x:Name="StatsTabModel" Tag="model"
Padding="12,6" Margin="0,0,6,0" CornerRadius="8"
Background="Transparent"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand"
MouseLeftButtonUp="StatsInnerTab_MouseLeftButtonUp">
<TextBlock x:Name="StatsTabModelLabel" Text="모델"
Foreground="{DynamicResource SecondaryText}"
FontSize="12"/>
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Border x:Name="StatsRangeAll" Tag="all"
Padding="10,5" Margin="4,0,0,0" CornerRadius="6"
Background="Transparent"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand"
MouseLeftButtonUp="StatsRange_MouseLeftButtonUp">
<TextBlock x:Name="StatsRangeAllLabel" Text="전체"
Foreground="{DynamicResource SecondaryText}" FontSize="11"/>
</Border>
<Border x:Name="StatsRange30" Tag="30"
Padding="10,5" Margin="4,0,0,0" CornerRadius="6"
Background="{DynamicResource ItemHoverBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand"
MouseLeftButtonUp="StatsRange_MouseLeftButtonUp">
<TextBlock x:Name="StatsRange30Label" Text="30일"
Foreground="{DynamicResource PrimaryText}" FontSize="11"
FontWeight="SemiBold"/>
</Border>
<Border x:Name="StatsRange7" Tag="7"
Padding="10,5" Margin="4,0,0,0" CornerRadius="6"
Background="Transparent"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Cursor="Hand"
MouseLeftButtonUp="StatsRange_MouseLeftButtonUp">
<TextBlock x:Name="StatsRange7Label" Text="7일"
Foreground="{DynamicResource SecondaryText}" FontSize="11"/>
</Border>
</StackPanel>
</Grid>
<Grid x:Name="StatsOverviewPanel" Grid.Row="1">
<UniformGrid x:Name="StatsCardGrid" Columns="4" Rows="2"/>
</Grid>
<StackPanel x:Name="StatsHeatmapHost" Grid.Row="2"
Margin="0,18,0,0" HorizontalAlignment="Stretch">
<TextBlock Text="활동 히트맵 (최근 12주)"
FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}"
Margin="2,0,0,8"/>
<Border CornerRadius="8" Padding="12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Canvas x:Name="StatsHeatmapCanvas" Height="124" HorizontalAlignment="Left"/>
</Border>
<TextBlock x:Name="StatsFooterFunFact" Margin="2,10,0,0"
FontSize="11" Foreground="{DynamicResource SecondaryText}"
Opacity="0.85"
Visibility="Collapsed"/>
</StackPanel>
<StackPanel x:Name="StatsModelPanel" Grid.Row="3"
Visibility="Collapsed" Margin="0,18,0,0">
<TextBlock Text="모델별 일별 사용량"
FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}"
Margin="2,0,0,8"/>
<Border CornerRadius="8" Padding="12,12,12,8"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<StackPanel>
<Canvas x:Name="StatsModelStackCanvas" Height="200"/>
<Canvas x:Name="StatsModelStackXAxis" Height="14" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<ItemsControl x:Name="StatsModelLegend" Margin="2,12,0,0"/>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
<!-- ── 프롬프트 템플릿 팝업 ── -->

View File

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