Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs
lacvet 82e58bde57 동시 Cowork·Code 실행 시 메인 루프 상태 혼선을 탭별로 분리
Cowork와 Code를 동시에 실행할 때 메인 루프 번호와 라이브 진행 힌트가 서로 섞이던 문제를 수정했다. ChatWindow가 전역 단일 진행 상태를 공유하던 구조를 탭별 현재 run 상태, 진행 스텝, 라이브 힌트, 대기 UI 이벤트로 분리해 현재 탭 기준으로만 렌더링하도록 정리했다.

AppStateService에 탭별 최신 run 상태 추적을 추가하고 ConversationList, TaskSummary, Timeline, V2 라이브 카드가 활성 탭의 run 메타를 읽도록 변경했다. AppStateServiceTests에 탭별 run iteration 분리 회귀 테스트를 추가했고 README와 DEVELOPMENT 문서에도 2026-04-15 22:25 (KST) 기준 이력을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tab_loop_isolation\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation\\ (경고 0 / 오류 0)
검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AppStateServiceTests" -p:OutputPath=bin\\verify_tab_loop_isolation_tests\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation_tests\\ (통과 45)
2026-04-15 22:26:10 +09:00

447 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// 스트리밍 중 LINQ 재실행 방지용 캐시
private List<ChatMessage>? _cachedVisibleMessages;
private int _cachedVisibleMessagesSourceCount = -1;
private List<ChatExecutionEvent>? _cachedVisibleEvents;
private int _cachedVisibleEventsSourceCount = -1;
private bool _cachedVisibleEventsShowHistory;
private void InvalidateTimelineCache()
{
_cachedVisibleMessages = null;
_cachedVisibleMessagesSourceCount = -1;
_cachedVisibleEvents = null;
_cachedVisibleEventsSourceCount = -1;
}
private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation)
{
var sourceCount = conversation?.Messages?.Count ?? 0;
if (_cachedVisibleMessages != null && sourceCount == _cachedVisibleMessagesSourceCount)
return _cachedVisibleMessages;
var result = conversation?.Messages?.Where(msg =>
{
if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase))
return false;
if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(msg.Content))
return false;
// 에이전트 루프 내부 메시지 (도구 호출 블록 / 도구 결과) — 채팅 타임라인에 표시하지 않음
var content = msg.Content;
if (content != null)
{
if (msg.Role == "assistant" && content.StartsWith("{\"_tool_use_blocks\""))
return false;
if (msg.Role == "user" && content.StartsWith("{\"type\":\"tool_result\""))
return false;
}
return true;
}).ToList() ?? new List<ChatMessage>();
_cachedVisibleMessages = result;
_cachedVisibleMessagesSourceCount = sourceCount;
return result;
}
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
{
var sourceCount = conversation?.ExecutionEvents?.Count ?? 0;
var showHistory = conversation?.ShowExecutionHistory ?? true;
if (_cachedVisibleEvents != null
&& sourceCount == _cachedVisibleEventsSourceCount
&& showHistory == _cachedVisibleEventsShowHistory)
return _cachedVisibleEvents;
List<ChatExecutionEvent> result;
if (showHistory)
{
result = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
}
else
{
result = (conversation?.ExecutionEvents ?? Enumerable.Empty<ChatExecutionEvent>())
.Where(ShouldShowCollapsedProgressEvent).ToList();
}
_cachedVisibleEvents = result;
_cachedVisibleEventsSourceCount = sourceCount;
_cachedVisibleEventsShowHistory = showHistory;
return result;
}
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
{
var restoredEvent = ToAgentEvent(executionEvent);
// Claude 스타일: SessionStart / UserPromptSubmit 운영 이벤트는 항상 숨김
if (restoredEvent.Type == AgentEventType.SessionStart || restoredEvent.Type == AgentEventType.UserPromptSubmit)
return false;
if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error)
return true;
if (restoredEvent.Type == AgentEventType.Thinking)
{
if (string.Equals(restoredEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|| string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(restoredEvent.Summary)
&& !AgentProgressSummarySanitizer.IsLowSignalStatusSummary(
restoredEvent.Summary,
restoredEvent.ToolName))
return true;
}
// 문서 생성 ToolResult 성공 시 항상 표시 (미리보기 카드용)
if (restoredEvent.Type == AgentEventType.ToolResult && restoredEvent.Success
&& IsDocumentCreationTool(restoredEvent.ToolName))
return true;
return IsProcessFeedEvent(restoredEvent);
}
private static bool IsDocumentCreationTool(string? toolName) =>
toolName is "html_create" or "docx_create" or "excel_create" or "xlsx_create"
or "csv_create" or "markdown_create" or "md_create" or "script_create"
or "pptx_create";
private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
IReadOnlyCollection<ChatMessage> visibleMessages,
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
{
var timeline = new List<(string Key, DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
foreach (var msg in visibleMessages)
{
var capturedMsg = msg;
var cacheKey = $"m_{msg.MsgId}";
timeline.Add((cacheKey, msg.Timestamp, 0, () =>
{
AddDeferredTranscriptElement(
cacheKey,
() =>
{
if (_elementCache.TryGetValue(cacheKey, out var cached))
return cached;
var element = CreateMessageBubbleElement(
capturedMsg.Role,
capturedMsg.Content,
animate: false,
message: capturedMsg);
_elementCache[cacheKey] = element;
return element;
});
}));
}
// 현재 실행 중인 run의 process feed 이벤트를 통합 카드로 대체 (히스토리 접힘 모드)
var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true;
var activeRunId = _isStreaming
? (GetCurrentAgentRunState(_activeTab)?.RunId ?? "")
: "";
var eventIndex = 0;
string? prevToolCallName = null;
int consecutiveToolCallCount = 0;
// 과거 대화(비-스트리밍) 히스토리: 연속 process feed 이벤트를 접힌 요약 그룹으로 묶기
var pendingProcessFeedGroup = new List<AgentEvent>();
DateTime pendingGroupTimestamp = default;
void FlushProcessFeedGroup()
{
if (pendingProcessFeedGroup.Count == 0) return;
var capturedGroup = pendingProcessFeedGroup.ToList();
var ts = pendingGroupTimestamp;
var groupKey = $"eg_{ts.Ticks}_{eventIndex++}";
timeline.Add((groupKey, ts, 1, () => AddCollapsedProcessFeedGroup(capturedGroup)));
pendingProcessFeedGroup.Clear();
}
foreach (var executionEvent in visibleEvents)
{
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
if (!showFullHistory && _isStreaming
&& !string.IsNullOrEmpty(activeRunId)
&& string.Equals(executionEvent.RunId, activeRunId, StringComparison.Ordinal))
{
var restoredCheck = ToAgentEvent(executionEvent);
if (IsProcessFeedEvent(restoredCheck))
continue; // 통합 카드로 대체 — 개별 pill 스킵
}
var restoredEvent = ToAgentEvent(executionEvent);
// 접힌 모드: 연속 동일 ToolCall 병합 (예: document_read 3회 → 1개 pill)
if (!showFullHistory && restoredEvent.Type == AgentEventType.ToolCall
&& !string.IsNullOrWhiteSpace(restoredEvent.ToolName))
{
if (string.Equals(prevToolCallName, restoredEvent.ToolName, StringComparison.OrdinalIgnoreCase))
{
consecutiveToolCallCount++;
continue; // 연속 중복 스킵
}
prevToolCallName = restoredEvent.ToolName;
consecutiveToolCallCount = 1;
}
else
{
prevToolCallName = null;
consecutiveToolCallCount = 0;
}
// 과거 대화(비-스트리밍)에서 process feed 이벤트를 그룹으로 묶기
if (!_isStreaming && IsProcessFeedEvent(restoredEvent))
{
pendingProcessFeedGroup.Add(restoredEvent);
pendingGroupTimestamp = executionEvent.Timestamp;
continue;
}
// 비-process feed 이벤트가 오면 이전 그룹 플러시
FlushProcessFeedGroup();
var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
}
// 마지막 그룹 플러시
FlushProcessFeedGroup();
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
var currentRunProgressSteps = GetRunProgressSteps(_activeTab);
if (!showFullHistory && _isStreaming && currentRunProgressSteps.Count > 0)
{
var capturedSteps = currentRunProgressSteps.ToList();
var cardTimestamp = capturedSteps[^1].Timestamp;
// 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
}
// 라이브 프로그레스 힌트는 트랜스크립트에 표시하지 않음
// 입력창 위의 PulseDotBar로만 상태를 표시 (Claude Desktop 스타일)
// 이전에는 "처리 중..." / "작업을 준비하는 중입니다..." 등이
// 트랜스크립트에 불필요하게 표시되어 사용자 경험을 저해함
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피
var needsSort = false;
for (int i = 1; i < timeline.Count; i++)
{
var cmp = timeline[i].Timestamp.CompareTo(timeline[i - 1].Timestamp);
if (cmp < 0 || (cmp == 0 && timeline[i].Order < timeline[i - 1].Order))
{
needsSort = true;
break;
}
}
if (needsSort)
timeline.Sort((a, b) =>
{
var cmp = a.Timestamp.CompareTo(b.Timestamp);
return cmp != 0 ? cmp : a.Order.CompareTo(b.Order);
});
return timeline;
}
private Border CreateTimelineLoadMoreCard(int hiddenCount)
{
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B");
var loadMoreBtn = new Button
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(7, 3, 7, 3),
Cursor = System.Windows.Input.Cursors.Hand,
Foreground = primaryText,
HorizontalAlignment = HorizontalAlignment.Center,
};
loadMoreBtn.Template = BuildMinimalIconButtonTemplate();
loadMoreBtn.Content = new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = "\uE70D",
FontFamily = s_segoeIconFont,
FontSize = 8,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"이전 대화 {hiddenCount:N0}개",
FontSize = 9.25,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
}
}
};
loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg;
loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent;
loadMoreBtn.Click += (_, _) =>
{
_timelineRenderLimit += TimelineRenderPageSize;
RenderMessages(preserveViewport: true);
};
return new Border
{
CornerRadius = new CornerRadius(10),
Margin = new Thickness(0, 2, 0, 8),
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Center,
Child = loadMoreBtn,
};
}
private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent)
{
var parsedType = Enum.TryParse<AgentEventType>(executionEvent.Type, out var eventType)
? eventType
: AgentEventType.Thinking;
return new AgentEvent
{
Timestamp = executionEvent.Timestamp,
RunId = executionEvent.RunId,
Type = parsedType,
ToolName = executionEvent.ToolName,
Summary = executionEvent.Summary,
FilePath = executionEvent.FilePath,
Success = executionEvent.Success,
StepCurrent = executionEvent.StepCurrent,
StepTotal = executionEvent.StepTotal,
Steps = executionEvent.Steps,
ElapsedMs = executionEvent.ElapsedMs,
InputTokens = executionEvent.InputTokens,
OutputTokens = executionEvent.OutputTokens,
};
}
private static bool IsCompactionMetaMessage(ChatMessage? message)
{
var kind = message?.MetaKind ?? "";
return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase);
}
private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
{
var expressionLevel = GetAgentUiExpressionLevel();
var icon = "\uE9CE";
var title = message.MetaKind switch
{
"session_memory_compaction" => "세션 메모리 압축",
"collapsed_boundary" => "압축 경계 병합",
_ => "컨텍스트 정리",
};
var wrapper = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(10, 4, 150, 4),
MaxWidth = GetMessageMaxWidth(),
HorizontalAlignment = HorizontalAlignment.Left,
};
var stack = new StackPanel();
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
header.Children.Add(new TextBlock
{
Text = icon,
FontFamily = s_segoeIconFont,
FontSize = 11,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
});
header.Children.Add(new TextBlock
{
Text = title,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(header);
var summary = message.MetaKind switch
{
"session_memory_compaction" => "이전 기록을 세션 메모로 정리했습니다.",
"collapsed_boundary" => "이전 압축 경계를 합쳤습니다.",
_ => "오래된 실행 기록을 줄였습니다.",
};
var detailBits = new List<string>();
if (message.AttachedFiles?.Count > 0 && expressionLevel != "simple")
detailBits.Add($"파일 {message.AttachedFiles.Count}개");
var compactDetail = expressionLevel switch
{
"simple" => summary,
_ when detailBits.Count > 0 => $"{summary} · {string.Join(", ", detailBits)}",
_ => summary
};
stack.Children.Add(new TextBlock
{
Text = compactDetail,
FontSize = expressionLevel == "simple" ? 10 : 10.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
});
if (expressionLevel == "rich" && !string.IsNullOrWhiteSpace(message.Content))
{
var preview = message.Content.Trim();
if (preview.Length > 120)
preview = preview[..120] + "...";
stack.Children.Add(new TextBlock
{
Text = preview,
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.88,
Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
wrapper.Child = stack;
return wrapper;
}
}