코드탭 통계 대시보드 병합 및 빈화면 동기화 보강
외부 작업 로그 기준으로 코드탭 병합 누락분을 검토하고 기존 컨텍스트 영속화 경로는 유지한 채 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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
191
src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs
Normal file
191
src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/AxCopilot/Models/CodeStatsSummary.cs
Normal file
63
src/AxCopilot/Models/CodeStatsSummary.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
270
src/AxCopilot/Services/CodeStatsAggregator.cs
Normal file
270
src/AxCopilot/Services/CodeStatsAggregator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
517
src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs
Normal file
517
src/AxCopilot/Views/ChatWindow.CodeStatsPresentation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3820,6 +3820,7 @@ public partial class ChatWindow
|
||||
}
|
||||
ClearTranscriptElements();
|
||||
EmptyState.Visibility = Visibility.Visible;
|
||||
UpdateCodeStatsVisibility();
|
||||
UpdateChatTitle();
|
||||
RefreshConversationList();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- ── 프롬프트 템플릿 팝업 ── -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user