코드탭 통계 대시보드 병합 및 빈화면 동기화 보강
외부 작업 로그 기준으로 코드탭 병합 누락분을 검토하고 기존 컨텍스트 영속화 경로는 유지한 채 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
|
# 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)
|
- 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.
|
- 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.
|
- `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`의 슬라이더/힌트 문구도 같은 범위로 맞췄습니다.
|
- 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에 묶이지 않도록 범위를 통일했습니다.
|
- `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
|
- 검증: `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 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 "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
|
- `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());
|
stack.Children.Add(CreateSeparator());
|
||||||
|
|
||||||
// 아카이브 토글
|
// Archive toggle.
|
||||||
var isArchived = _storage.Load(conversationId)?.Archived ?? false;
|
var isArchived = _storage.Load(conversationId)?.Archived ?? false;
|
||||||
stack.Children.Add(CreateMenuItem(
|
stack.Children.Add(CreateMenuItem(
|
||||||
isArchived ? "\uE7B8" : "\uE7B7",
|
isArchived ? "\uE7B8" : "\uE7B7",
|
||||||
isArchived ? "아카이브 해제" : "아카이브 보관",
|
isArchived ? "Unarchive" : "Archive",
|
||||||
TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, () =>
|
TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, () =>
|
||||||
{
|
{
|
||||||
var convToArchive = _storage.Load(conversationId);
|
var convToArchive = _storage.Load(conversationId);
|
||||||
@@ -416,9 +416,9 @@ public partial class ChatWindow
|
|||||||
RefreshConversationList();
|
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);
|
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||||
if (result != MessageBoxResult.Yes) return;
|
if (result != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
@@ -428,8 +428,9 @@ public partial class ChatWindow
|
|||||||
if (_currentConversation?.Id == conversationId)
|
if (_currentConversation?.Id == conversationId)
|
||||||
{
|
{
|
||||||
_currentConversation = null;
|
_currentConversation = null;
|
||||||
ClearTranscriptElements();
|
ClearTranscriptElements();
|
||||||
EmptyState.Visibility = Visibility.Visible;
|
EmptyState.Visibility = Visibility.Visible;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
UpdateChatTitle();
|
UpdateChatTitle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3820,6 +3820,7 @@ public partial class ChatWindow
|
|||||||
}
|
}
|
||||||
ClearTranscriptElements();
|
ClearTranscriptElements();
|
||||||
EmptyState.Visibility = Visibility.Visible;
|
EmptyState.Visibility = Visibility.Visible;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
UpdateChatTitle();
|
UpdateChatTitle();
|
||||||
RefreshConversationList();
|
RefreshConversationList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ public partial class ChatWindow
|
|||||||
SelectTopic(tag.Preset);
|
SelectTopic(tag.Preset);
|
||||||
break;
|
break;
|
||||||
case "etc":
|
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;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
InputBox.Focus();
|
InputBox.Focus();
|
||||||
break;
|
break;
|
||||||
case "add":
|
case "add":
|
||||||
@@ -123,18 +124,18 @@ public partial class ChatWindow
|
|||||||
if (_activeTab == "Cowork" || _activeTab == "Code")
|
if (_activeTab == "Cowork" || _activeTab == "Code")
|
||||||
{
|
{
|
||||||
if (EmptyStateTitle != null)
|
if (EmptyStateTitle != null)
|
||||||
EmptyStateTitle.Text = _activeTab == "Code" ? "코드 작업을 입력하세요" : "작업 유형을 선택하세요";
|
EmptyStateTitle.Text = _activeTab == "Code" ? "Describe the coding task" : "Choose the work style";
|
||||||
if (EmptyStateDesc != null)
|
if (EmptyStateDesc != null)
|
||||||
EmptyStateDesc.Text = _activeTab == "Code"
|
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
|
else
|
||||||
{
|
{
|
||||||
if (EmptyStateTitle != null)
|
if (EmptyStateTitle != null)
|
||||||
EmptyStateTitle.Text = "대화 주제를 선택하세요";
|
EmptyStateTitle.Text = "Choose a topic";
|
||||||
if (EmptyStateDesc != null)
|
if (EmptyStateDesc != null)
|
||||||
EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다";
|
EmptyStateDesc.Text = "A matching prompt preset is applied automatically for the topic you choose.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_activeTab == "Code")
|
if (_activeTab == "Code")
|
||||||
@@ -603,13 +604,14 @@ public partial class ChatWindow
|
|||||||
_ = System.Threading.Tasks.Task.Run(() =>
|
_ = System.Threading.Tasks.Task.Run(() =>
|
||||||
{
|
{
|
||||||
try { _storage.Save(convCopy); }
|
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)
|
if (EmptyState != null)
|
||||||
{
|
{
|
||||||
Services.LogService.Info($"[EmptyState] HIDE ← SelectTopic, tab={_activeTab}");
|
Services.LogService.Info($"[EmptyState] HIDE caller=SelectTopic, tab={_activeTab}");
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
InputBox.Focus();
|
InputBox.Focus();
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ namespace AxCopilot.Views;
|
|||||||
|
|
||||||
public partial class ChatWindow
|
public partial class ChatWindow
|
||||||
{
|
{
|
||||||
// ─── 렌더링 쓰로틀: 스트리밍 중 최소 간격 보장 ───────────────────────
|
// Render throttling keeps the transcript responsive during heavy streaming.
|
||||||
private long _lastRenderTicks;
|
private long _lastRenderTicks;
|
||||||
private const long MinStreamingRenderIntervalMs = 1500; // 스트리밍 중 최소 1.5초 간격
|
private const long MinStreamingRenderIntervalMs = 1500;
|
||||||
private const long MinIdleRenderIntervalMs = 300; // 비스트리밍(유휴) 시 최소 300ms 간격
|
private const long MinIdleRenderIntervalMs = 300;
|
||||||
|
|
||||||
private int GetActiveTimelineRenderLimit()
|
private int GetActiveTimelineRenderLimit()
|
||||||
{
|
{
|
||||||
@@ -26,42 +26,38 @@ public partial class ChatWindow
|
|||||||
|
|
||||||
private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
||||||
{
|
{
|
||||||
// B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지
|
// Skip background rendering when the window is not actually visible.
|
||||||
if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible)
|
if (WindowState == System.Windows.WindowState.Minimized || !IsVisible)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var now = Environment.TickCount64;
|
var now = Environment.TickCount64;
|
||||||
|
|
||||||
// B-5: 스트리밍 중 쓰로틀 — preserveViewport=true (타이머 기반) 호출만 제한
|
// Throttle preserveViewport renders while streaming.
|
||||||
// preserveViewport=false는 사용자 메시지 전송 등 중요 렌더이므로 항상 허용
|
if (_isStreaming && preserveViewport && now - _lastRenderTicks < MinStreamingRenderIntervalMs)
|
||||||
if (_isStreaming && preserveViewport)
|
return;
|
||||||
{
|
|
||||||
if (now - _lastRenderTicks < MinStreamingRenderIntervalMs)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인)
|
// Suppress rapid idle re-renders when the visible content is unchanged.
|
||||||
// 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시
|
|
||||||
if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs)
|
if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs)
|
||||||
{
|
{
|
||||||
ChatConversation? quickConv;
|
ChatConversation? quickConversation;
|
||||||
lock (_convLock) quickConv = _currentConversation;
|
lock (_convLock)
|
||||||
var quickMsgCount = quickConv?.Messages?.Count ?? 0;
|
quickConversation = _currentConversation;
|
||||||
var quickEvtCount = quickConv?.ExecutionEvents?.Count ?? 0;
|
|
||||||
// 대화 내용이 마지막 렌더와 같으면 스킵 (빈 대화 반복 렌더 차단)
|
var quickMessageCount = quickConversation?.Messages?.Count ?? 0;
|
||||||
if (quickMsgCount == _lastRenderedMessageCount
|
var quickEventCount = quickConversation?.ExecutionEvents?.Count ?? 0;
|
||||||
&& quickEvtCount == _lastRenderedEventCount
|
|
||||||
&& string.Equals(_lastRenderedConversationId, quickConv?.Id, StringComparison.OrdinalIgnoreCase))
|
if (quickMessageCount == _lastRenderedMessageCount
|
||||||
|
&& quickEventCount == _lastRenderedEventCount
|
||||||
|
&& string.Equals(_lastRenderedConversationId, quickConversation?.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// B-7b: 빈 대화 간 convId 플래핑 방지 — 둘 다 메시지 0개면
|
|
||||||
// convId가 달라도 빈 화면 렌더를 반복할 이유 없음 (SwitchToTabConversation 스팸 차단)
|
|
||||||
// 단, preserveViewport=false(탭 전환 등 명시적 렌더)는 차단하지 않음
|
|
||||||
// — 탭 전환 시 EmptyState/마스코트 표시에 필요
|
|
||||||
if (preserveViewport
|
if (preserveViewport
|
||||||
&& quickMsgCount == 0 && quickEvtCount == 0
|
&& quickMessageCount == 0
|
||||||
&& _lastRenderedMessageCount == 0 && _lastRenderedEventCount == 0)
|
&& quickEventCount == 0
|
||||||
|
&& _lastRenderedMessageCount == 0
|
||||||
|
&& _lastRenderedEventCount == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,32 +67,33 @@ public partial class ChatWindow
|
|||||||
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
||||||
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
||||||
|
|
||||||
ChatConversation? conv;
|
ChatConversation? conversation;
|
||||||
lock (_convLock) conv = _currentConversation;
|
lock (_convLock)
|
||||||
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
conversation = _currentConversation;
|
||||||
|
|
||||||
var visibleMessages = GetVisibleTimelineMessages(conv);
|
_appState.RestoreAgentRunHistory(conversation?.AgentRunHistory);
|
||||||
var visibleEvents = GetVisibleTimelineEvents(conv);
|
|
||||||
|
|
||||||
// 진단 로그: 렌더링 호출 시점의 상태 추적
|
var visibleMessages = GetVisibleTimelineMessages(conversation);
|
||||||
Services.LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " +
|
var visibleEvents = GetVisibleTimelineEvents(conversation);
|
||||||
$"convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}, " +
|
|
||||||
$"rawMsgCount={conv?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " +
|
LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " +
|
||||||
$"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " +
|
$"convId={conversation?.Id?[..Math.Min(8, conversation?.Id?.Length ?? 0)]}, " +
|
||||||
$"transcriptElements={GetTranscriptElementCount()}");
|
$"rawMsgCount={conversation?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " +
|
||||||
|
$"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " +
|
||||||
|
$"transcriptElements={GetTranscriptElementCount()}");
|
||||||
|
|
||||||
if (_isStreaming && preserveViewport
|
if (_isStreaming && preserveViewport
|
||||||
&& visibleMessages.Count == _lastRenderedMessageCount
|
&& visibleMessages.Count == _lastRenderedMessageCount
|
||||||
&& visibleEvents.Count == _lastRenderedEventCount
|
&& visibleEvents.Count == _lastRenderedEventCount
|
||||||
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
&& (conversation?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
||||||
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
|
&& string.Equals(_lastRenderedConversationId, conversation?.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
return;
|
|
||||||
|
|
||||||
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
|
||||||
{
|
{
|
||||||
// 스트리밍 중이거나 대화에 원본 메시지가 있으면 EmptyState를 표시하지 않음
|
return;
|
||||||
// (GetVisibleTimelineMessages의 필터링으로 visibleMessages가 0이 되어도 원본은 존재)
|
}
|
||||||
bool hasRawMessages = (conv?.Messages?.Count ?? 0) > 0;
|
|
||||||
|
if (conversation == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
||||||
|
{
|
||||||
|
var hasRawMessages = (conversation?.Messages?.Count ?? 0) > 0;
|
||||||
if (!_isStreaming && !hasRawMessages)
|
if (!_isStreaming && !hasRawMessages)
|
||||||
{
|
{
|
||||||
ClearTranscriptElements();
|
ClearTranscriptElements();
|
||||||
@@ -108,15 +105,15 @@ public partial class ChatWindow
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김
|
|
||||||
HideEmptyState(animate: preserveViewport);
|
HideEmptyState(animate: preserveViewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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;
|
_timelineRenderLimit = TimelineRenderPageSize;
|
||||||
_elementCache.Clear();
|
_elementCache.Clear();
|
||||||
_lastRenderedTimelineKeys.Clear();
|
_lastRenderedTimelineKeys.Clear();
|
||||||
@@ -127,41 +124,54 @@ public partial class ChatWindow
|
|||||||
|
|
||||||
HideEmptyState(animate: preserveViewport);
|
HideEmptyState(animate: preserveViewport);
|
||||||
|
|
||||||
// V2 렌더링 (Claude Code 스타일 상세 이력 UI)
|
RenderMessagesV2(
|
||||||
RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport,
|
conversation,
|
||||||
previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller);
|
visibleMessages,
|
||||||
|
visibleEvents,
|
||||||
|
preserveViewport,
|
||||||
|
previousScrollableHeight,
|
||||||
|
previousVerticalOffset,
|
||||||
|
renderStopwatch,
|
||||||
|
caller);
|
||||||
|
|
||||||
// 렌더 완료 후 진단 — transcript에 실제로 요소가 추가되었는지 확인
|
|
||||||
var postRenderCount = GetTranscriptElementCount();
|
var postRenderCount = GetTranscriptElementCount();
|
||||||
if (postRenderCount == 0 && visibleMessages.Count > 0)
|
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)
|
private void ShowEmptyState([System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
||||||
{
|
{
|
||||||
var prev = EmptyState.Visibility;
|
var previousVisibility = EmptyState.Visibility;
|
||||||
++_emptyStateAnimationToken;
|
++_emptyStateAnimationToken;
|
||||||
EmptyState.BeginAnimation(OpacityProperty, null);
|
EmptyState.BeginAnimation(OpacityProperty, null);
|
||||||
EmptyState.Opacity = 1;
|
EmptyState.Opacity = 1;
|
||||||
EmptyState.Visibility = System.Windows.Visibility.Visible;
|
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();
|
StartMascotAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// EmptyState를 숨깁니다. animate=true이면 150ms 페이드아웃, false이면 즉시 Collapsed.
|
/// Hides EmptyState. When animate is true, a 150 ms fade-out is used.
|
||||||
/// 토큰 기반 무효화: 탭 전환이나 ShowEmptyState가 호출되면 진행 중인 Completed 콜백이 무시됩니다.
|
/// Token-based invalidation prevents stale callbacks from collapsing a newly
|
||||||
|
/// shown EmptyState.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HideEmptyState(bool animate, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
private void HideEmptyState(bool animate, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
||||||
{
|
{
|
||||||
if (EmptyState.Visibility != System.Windows.Visibility.Visible)
|
if (EmptyState.Visibility != System.Windows.Visibility.Visible)
|
||||||
{
|
{
|
||||||
StopMascotAnimation();
|
StopMascotAnimation();
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Services.LogService.Info($"[EmptyState] HIDE ← {caller}, animate={animate}, tab={_activeTab}");
|
LogService.Info($"[EmptyState] HIDE caller={caller}, animate={animate}, tab={_activeTab}");
|
||||||
|
|
||||||
if (animate)
|
if (animate)
|
||||||
{
|
{
|
||||||
@@ -170,17 +180,21 @@ public partial class ChatWindow
|
|||||||
{
|
{
|
||||||
EasingFunction = new System.Windows.Media.Animation.QuadraticEase()
|
EasingFunction = new System.Windows.Media.Animation.QuadraticEase()
|
||||||
};
|
};
|
||||||
|
|
||||||
fadeOut.Completed += (_, _) =>
|
fadeOut.Completed += (_, _) =>
|
||||||
{
|
{
|
||||||
if (token != _emptyStateAnimationToken)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
||||||
EmptyState.Opacity = 1;
|
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);
|
EmptyState.BeginAnimation(OpacityProperty, fadeOut);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -189,7 +203,9 @@ public partial class ChatWindow
|
|||||||
EmptyState.BeginAnimation(OpacityProperty, null);
|
EmptyState.BeginAnimation(OpacityProperty, null);
|
||||||
EmptyState.Opacity = 1;
|
EmptyState.Opacity = 1;
|
||||||
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
StopMascotAnimation();
|
StopMascotAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1848,7 +1848,8 @@
|
|||||||
</Image>
|
</Image>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<StackPanel x:Name="EmptyStateCenterStack"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,8,0,8">
|
Margin="0,8,0,8">
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<StackPanel HorizontalAlignment="Center"
|
||||||
@@ -1878,12 +1879,124 @@
|
|||||||
MaxHeight="420"
|
MaxHeight="420"
|
||||||
Margin="0"
|
Margin="0"
|
||||||
Padding="0,4,0,8">
|
Padding="0,4,0,8">
|
||||||
<!-- 대화 주제 버튼 (프리셋에서 동적 생성) -->
|
<!-- Topic preset buttons (dynamically populated). -->
|
||||||
<WrapPanel x:Name="TopicButtonPanel" HorizontalAlignment="Center"
|
<WrapPanel x:Name="TopicButtonPanel" HorizontalAlignment="Center"
|
||||||
Margin="0,0,0,8"
|
Margin="0,0,0,8"
|
||||||
Background="Transparent"/>
|
Background="Transparent"/>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</StackPanel>
|
</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>
|
</Grid>
|
||||||
|
|
||||||
<!-- ── 프롬프트 템플릿 팝업 ── -->
|
<!-- ── 프롬프트 템플릿 팝업 ── -->
|
||||||
|
|||||||
@@ -1461,6 +1461,7 @@ public partial class ChatWindow : Window
|
|||||||
? Visibility.Collapsed
|
? Visibility.Collapsed
|
||||||
: Visibility.Visible;
|
: Visibility.Visible;
|
||||||
}
|
}
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
if (TopicButtonPanel != null)
|
if (TopicButtonPanel != null)
|
||||||
TopicButtonPanel.Visibility = Visibility.Visible;
|
TopicButtonPanel.Visibility = Visibility.Visible;
|
||||||
}
|
}
|
||||||
@@ -2047,6 +2048,7 @@ public partial class ChatWindow : Window
|
|||||||
|
|
||||||
// 현재 대화를 해당 탭 대화로 전환
|
// 현재 대화를 해당 탭 대화로 전환
|
||||||
SwitchToTabConversation();
|
SwitchToTabConversation();
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
|
|
||||||
// Cowork/Code 탭 전환 시 팁 표시
|
// Cowork/Code 탭 전환 시 팁 표시
|
||||||
ShowRandomTip();
|
ShowRandomTip();
|
||||||
@@ -4479,6 +4481,7 @@ public partial class ChatWindow : Window
|
|||||||
UpdateChatTitle();
|
UpdateChatTitle();
|
||||||
InputBox.Text = "";
|
InputBox.Text = "";
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
InvalidateTimelineCache();
|
InvalidateTimelineCache();
|
||||||
RenderMessages(preserveViewport: false);
|
RenderMessages(preserveViewport: false);
|
||||||
|
|
||||||
@@ -4563,6 +4566,7 @@ public partial class ChatWindow : Window
|
|||||||
UpdateChatTitle();
|
UpdateChatTitle();
|
||||||
InputBox.Text = "";
|
InputBox.Text = "";
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
InvalidateTimelineCache();
|
InvalidateTimelineCache();
|
||||||
RenderMessages(preserveViewport: false);
|
RenderMessages(preserveViewport: false);
|
||||||
}
|
}
|
||||||
@@ -6010,6 +6014,7 @@ public partial class ChatWindow : Window
|
|||||||
UpdateInputBoxHeight();
|
UpdateInputBoxHeight();
|
||||||
}
|
}
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
StopMascotAnimation();
|
StopMascotAnimation();
|
||||||
InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장
|
InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장
|
||||||
try
|
try
|
||||||
@@ -6022,6 +6027,7 @@ public partial class ChatWindow : Window
|
|||||||
}
|
}
|
||||||
// RenderMessages가 EmptyState를 Visible로 되돌릴 수 있으므로 강제 재설정
|
// RenderMessages가 EmptyState를 Visible로 되돌릴 수 있으므로 강제 재설정
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateCodeStatsVisibility();
|
||||||
|
|
||||||
// ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ──
|
// ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ──
|
||||||
var convForPersist = conv;
|
var convForPersist = conv;
|
||||||
|
|||||||
Reference in New Issue
Block a user