diff --git a/README.md b/README.md index 554ef36..deb1c80 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # AX Commander +- 업데이트: 2026-04-15 20:41 (KST) +- AX Agent 좌측 대화 목록을 Codex 스타일에 가깝게 1줄형 카드로 단순화했습니다. `src/AxCopilot/Views/ChatWindow.xaml`의 `ConversationItemTemplate`는 제목과 시간을 한 줄에 배치하고, 선택된 항목은 전체 배경과 테두리가 현재 테마(`HintBackground`, `AccentColor`)를 따라 강조되도록 바뀌었습니다. +- `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`, `src/AxCopilot/ViewModels/ChatWindowViewModel.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs`를 통해 실행 중인 대화는 앞쪽 링 표시로, 백그라운드 완료 후 아직 열어보지 않은 대화는 테마색 완료 점으로 구분하도록 정리했습니다. 완료 점은 해당 대화를 열면 바로 사라집니다. +- 좌측 목록에서 선택된 대화를 다시 클릭했을 때 이름 편집으로 바로 들어가던 흐름은 제거했고, 우클릭 메뉴 기반 관리 흐름은 유지했습니다. +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_list_refresh\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_conversation_list_refresh_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh_tests\\` 통과 59 + - 업데이트: 2026-04-15 20:19 (KST) - AX Agent 내부 설정의 `최대 에이전트 패스` 상한을 100에서 500으로 확장했습니다. `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`를 함께 조정해 일반 설정창, Code 탭 오버레이, 별도 에이전트 설정창에서 모두 같은 1~500 범위를 사용하도록 맞췄습니다. - 검증: `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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 03e5dfb..264b9ab 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1548,3 +1548,11 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - AX Agent 입력창 위 시간·토큰 표시가 라이브 진행 텍스트 높이에 끌려 올라가던 배치를 수정했습니다. 원인은 `src/AxCopilot/Views/ChatWindow.xaml`에서 `StreamMetricsLabel`이 `PulseDotBar`와 같은 Grid를 공유하고 있어, 왼쪽 진행 상태가 여러 줄로 커질 때 라벨도 같은 행 중앙으로 끌려가던 점이었습니다. - `StreamMetricsLabel`를 진행 상태 행에서 분리해 입력 영역 바로 앞에 독립 배치했습니다. 이제 `PulseDotBar`의 높이가 바뀌어도 시간·토큰 라벨은 입력창 바로 위 오른쪽에 붙어 있게 됩니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_stream_metrics_anchor\\ -p:IntermediateOutputPath=obj\\verify_stream_metrics_anchor\\` 경고 0 / 오류 0 +업데이트: 2026-04-15 20:41 (KST) +- AX Agent 좌측 대화 목록을 Codex 스타일에 가깝게 1줄형 카드로 재구성했습니다. `src/AxCopilot/Views/ChatWindow.xaml`의 `ConversationItemTemplate`를 제목/시간 1줄 구조로 바꾸고, 선택 상태는 얇은 좌측 바 대신 전체 배경 + 테두리 강조로 바꿔 현재 테마(`HintBackground`, `AccentColor`, `ItemHoverBackground`)를 그대로 따르도록 정리했습니다. +- `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`에 실행 링/미열람 완료 점 정책을 추가했습니다. 현재 탭의 실제 스트리밍 대화만 실행 중 심볼을 표시하고, 백그라운드 완료 후 아직 열어보지 않은 대화는 완료 점을 붙였다가 해당 대화를 열면 바로 지워지도록 `MarkConversationCompletionSeen(...)`, `ShouldShowConversationRunningIndicator(...)`, `ShouldShowConversationCompletionMarker(...)` 헬퍼를 넣었습니다. +- 좌측 대화 목록에서 같은 항목을 다시 클릭했을 때 바로 이름 편집으로 들어가던 흐름은 제거했습니다. 이름 변경은 더 이상 목록 직접 클릭으로 진입하지 않고, 우클릭 메뉴 기반 관리 흐름만 유지합니다. +- `src/AxCopilot/ViewModels/ChatWindowViewModel.cs`에 `HasUnreadCompletion` 바인딩을 추가했고, `src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs`에 실행 링/완료 점 조건 회귀 테스트를 넣었습니다. +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_list_refresh\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_conversation_list_refresh_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh_tests\\` 통과 59 diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs index 5421ffb..7a823da 100644 --- a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -215,4 +215,41 @@ public class ChatWindowSlashPolicyTests center.Should().Be(expectedCenter); radius.Should().Be(expectedRadius); } + + [Theory] + [InlineData("conv-1", "conv-1", "run-1", "running", true)] + [InlineData("conv-1", "conv-2", "run-1", "running", false)] + [InlineData("conv-1", "conv-1", "", "running", false)] + [InlineData("conv-1", "conv-1", "run-1", "completed", false)] + [InlineData("conv-1", "conv-1", "run-1", "paused", false)] + public void ShouldShowConversationRunningIndicator_ShouldRequireActiveRun( + string conversationId, + string streamingConversationId, + string runId, + string status, + bool expected) + { + var method = typeof(ChatWindow).GetMethod( + "ShouldShowConversationRunningIndicator", + BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var result = method!.Invoke(null, new object?[] { conversationId, streamingConversationId, runId, status }); + result.Should().Be(expected); + } + + [Fact] + public void ShouldShowConversationCompletionMarker_ShouldHideWhenAlreadySeenOrSelected() + { + var method = typeof(ChatWindow).GetMethod( + "ShouldShowConversationCompletionMarker", + BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var completedAt = new DateTime(2026, 4, 15, 20, 30, 0, DateTimeKind.Local); + method!.Invoke(null, new object?[] { completedAt, null, false, false }).Should().Be(true); + method.Invoke(null, new object?[] { completedAt, completedAt, false, false }).Should().Be(false); + method.Invoke(null, new object?[] { completedAt, null, true, false }).Should().Be(false); + method.Invoke(null, new object?[] { completedAt, null, false, true }).Should().Be(false); + } } diff --git a/src/AxCopilot/ViewModels/ChatWindowViewModel.cs b/src/AxCopilot/ViewModels/ChatWindowViewModel.cs index 05fc622..a61dace 100644 --- a/src/AxCopilot/ViewModels/ChatWindowViewModel.cs +++ b/src/AxCopilot/ViewModels/ChatWindowViewModel.cs @@ -260,6 +260,7 @@ public class ConversationItemViewModel : ViewModelBase public int FailedAgentRunCount { get; init; } public string LastAgentRunSummary { get; init; } = ""; public string WorkFolder { get; init; } = ""; + public bool HasUnreadCompletion { get; init; } // ── 그룹 ── public string Group { get; init; } = "오늘"; diff --git a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs index 8cfc2c5..c0f612c 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs @@ -17,6 +17,7 @@ public partial class ChatWindow { private const int ConversationPageSize = 50; private List? _pendingConversations; + private readonly Dictionary _conversationCompletionSeenAt = new(StringComparer.OrdinalIgnoreCase); // ── A-1: 이벤트 위임 필드 ── /// 현재 마우스가 올라가 있는 대화 항목 Border. @@ -92,9 +93,11 @@ public partial class ChatWindow _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv; SyncTabConversationIdsFromSession(); } + MarkConversationCompletionSeen(conv); SaveLastConversations(); UpdateChatTitle(); RenderMessages(); + RefreshConversationList(); RefreshDraftQueueUi(); } } @@ -108,12 +111,6 @@ public partial class ChatWindow if (isSelected) { // 선택된 항목 클릭 → 이름 변경 모드 - var titleBlock = FindConversationTitleBlock(id); - if (titleBlock != null) - { - var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - EnterTitleEditMode(titleBlock, id, titleColor); - } return; } @@ -134,11 +131,13 @@ public partial class ChatWindow SyncTabConversationIdsFromSession(); } + MarkConversationCompletionSeen(conv); SaveLastConversations(); UpdateChatTitle(); ClearTranscriptElements(); // 이전 대화의 UI 요소 완전 제거 InvalidateTimelineCache(); // 타임라인 캐시 무효화 — 새 대화 데이터 반영 보장 RenderMessages(); + RefreshConversationList(); EnsureEmptyStateConsistency(); // EmptyState 일관성 강제 검사 RefreshConversationList(); RefreshStreamingControlsForActiveTab(); @@ -162,6 +161,64 @@ public partial class ChatWindow return null; } + private void MarkConversationCompletionSeen(ChatConversation? conversation) + { + if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id)) + return; + + var summary = _appState.GetConversationRunSummary(conversation.AgentRunHistory); + MarkConversationCompletionSeen(conversation.Id, summary.LastCompletedAt); + } + + private void MarkConversationCompletionSeen(string conversationId, DateTime? lastCompletedAt) + { + if (string.IsNullOrWhiteSpace(conversationId) || !lastCompletedAt.HasValue) + return; + + if (_conversationCompletionSeenAt.TryGetValue(conversationId, out var seenAt) + && seenAt >= lastCompletedAt.Value) + { + return; + } + + _conversationCompletionSeenAt[conversationId] = lastCompletedAt.Value; + } + + private static bool ShouldShowConversationRunningIndicator( + string conversationId, + string? streamingConversationId, + string? runId, + string? runStatus) + { + if (string.IsNullOrWhiteSpace(conversationId) + || string.IsNullOrWhiteSpace(streamingConversationId) + || string.IsNullOrWhiteSpace(runId)) + { + return false; + } + + if (!string.Equals(conversationId, streamingConversationId, StringComparison.Ordinal)) + return false; + + return !string.Equals(runStatus, "completed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runStatus, "failed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runStatus, "paused", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runStatus, "canceled", StringComparison.OrdinalIgnoreCase) + && !string.Equals(runStatus, "cancelled", StringComparison.OrdinalIgnoreCase); + } + + private static bool ShouldShowConversationCompletionMarker( + DateTime? lastCompletedAt, + DateTime? lastSeenCompletedAt, + bool isSelected, + bool isRunning) + { + if (isSelected || isRunning || !lastCompletedAt.HasValue) + return false; + + return !lastSeenCompletedAt.HasValue || lastCompletedAt.Value > lastSeenCompletedAt.Value; + } + private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e) { var border = FindAncestorWithTag(e.OriginalSource as DependencyObject); @@ -230,11 +287,7 @@ public partial class ChatWindow try { if (tag.IsSelected) - { - if (tag.TitleBlock != null && tag.TitleColor != null) - EnterTitleEditMode(tag.TitleBlock, tag.Id, tag.TitleColor); return; - } // 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋) var conv = _storage.Load(tag.Id); @@ -254,6 +307,7 @@ public partial class ChatWindow SyncTabConversationIdsFromSession(); } + MarkConversationCompletionSeen(conv); SaveLastConversations(); UpdateChatTitle(); RenderMessages(); @@ -281,9 +335,11 @@ public partial class ChatWindow _currentConversation.ShowExecutionHistory = true; SyncTabConversationIdsFromSession(); } + MarkConversationCompletionSeen(conv); SaveLastConversations(); UpdateChatTitle(); RenderMessages(); + RefreshConversationList(); RefreshDraftQueueUi(); } } @@ -301,6 +357,11 @@ public partial class ChatWindow foreach (var p in allPresets) presetMap.TryAdd(p.Category, (p.Symbol, p.Color)); + var currentConversationId = ""; + lock (_convLock) + currentConversationId = _currentConversation?.Id ?? ""; + var streamingConversationId = GetStreamingConversation(_activeTab)?.Id; + var items = metas.Select(c => { var symbol = ChatCategory.GetSymbol(c.Category); @@ -315,6 +376,17 @@ public partial class ChatWindow } var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory); + var isSelectedConversation = string.Equals(currentConversationId, c.Id, StringComparison.Ordinal); + if (isSelectedConversation) + MarkConversationCompletionSeen(c.Id, runSummary.LastCompletedAt); + + _conversationCompletionSeenAt.TryGetValue(c.Id, out var seenCompletedAt); + var isRunning = ShouldShowConversationRunningIndicator( + c.Id, + streamingConversationId, + _appState.AgentRun.RunId, + _appState.AgentRun.Status); + return new ConversationMeta { Id = c.Id, @@ -333,12 +405,14 @@ public partial class ChatWindow LastAgentRunSummary = runSummary.LastAgentRunSummary, LastFailedAt = runSummary.LastFailedAt, LastCompletedAt = runSummary.LastCompletedAt, + HasUnreadCompletion = ShouldShowConversationCompletionMarker( + runSummary.LastCompletedAt, + seenCompletedAt == default ? null : seenCompletedAt, + isSelectedConversation, + isRunning), WorkFolder = c.WorkFolder ?? "", Archived = c.Archived, - IsRunning = _currentConversation?.Id == c.Id - && !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId) - && !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase) - && !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase), + IsRunning = isRunning, }; }).ToList(); @@ -548,6 +622,7 @@ public partial class ChatWindow FailedAgentRunCount = item.FailedAgentRunCount, LastAgentRunSummary = item.LastAgentRunSummary, WorkFolder = item.WorkFolder, + HasUnreadCompletion = item.HasUnreadCompletion, Group = group, GroupOrder = groupOrder, }; diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 60fe122..45dbfae 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -163,120 +163,91 @@ + Background="Transparent" + BorderBrush="Transparent" + BorderThickness="1"> - + - + - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + Foreground="{DynamicResource SecondaryText}" + Margin="10,0,0,0" + VerticalAlignment="Center"/> - - - + + + + + + + + + diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index f160f2e..f26bab2 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -222,6 +222,7 @@ public partial class ChatWindow : Window public DateTime? LastFailedAt { get; init; } public DateTime? LastCompletedAt { get; init; } public bool IsRunning { get; init; } + public bool HasUnreadCompletion { get; init; } public string WorkFolder { get; init; } = ""; public bool Archived { get; init; } }