Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs
lacvet 5ab04bc53e AX Agent 라이브 진행 표시 회귀 복구 및 본문 선택 유지 정리
상단 라이브 진행 카드를 이전 단계형 구조로 복구하고 스트리밍 중 현재 실행 이벤트가 본문 타임라인에 중복 표시되지 않도록 V2 렌더 컷오프를 다시 적용했습니다.

사용자 말풍선은 기존 마크다운 렌더로 되돌려 세로로 깨지던 표시를 해결하고, 어시스턴트 본문과 스트리밍 완료 본문은 계속 드래그 선택/복사가 가능하도록 유지했습니다. 또한 SkillRuntime, allowed_tools, 메인 루프 요청, 읽기 도구 조기 실행 준비, 스트리밍 도구 감지 같은 저신호 내부 문구를 추가 필터링해 화면 노이즈를 줄였습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_restore\\ -p:IntermediateOutputPath=obj\\verify_live_restore\\ 에서 경고 0 오류 0을 확인했습니다.

검증: AgentLoopCodeQualityTests, AgentStatusNarrativeCatalogTests, AgentProgressSummarySanitizerTests 필터로 dotnet test를 실행해 131개 테스트 통과를 확인했습니다.
2026-04-15 18:56:53 +09:00

420 lines
18 KiB
C#

using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class ChatWindow
{
private UIElement CreateMessageBubbleElement(string role, string content, bool animate = true, ChatMessage? message = null)
{
var beforeCount = GetTranscriptElementCount();
AddMessageBubble(role, content, animate, message);
var element = GetTranscriptElementAt(beforeCount)
?? throw new InvalidOperationException("메시지 버블을 생성하지 못했습니다.");
RemoveTranscriptElementAt(beforeCount);
return element;
}
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var userBubbleBg = hintBg;
var assistantBubbleBg = itemBg;
if (isUser)
{
var msgMaxWidth = GetMessageMaxWidth();
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = msgMaxWidth * 0.85,
Margin = new Thickness(60, 8, 12, 10), // 좌측 여백 넓게 → 우측에 붙음
};
var bubble = new Border
{
BorderBrush = borderBrush,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16, 12, 16, 12),
HorizontalAlignment = HorizontalAlignment.Right,
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
var userCodeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
MarkdownRenderer.EnableFilePathHighlight =
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
MarkdownRenderer.EnableCodeSymbolHighlight = true;
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush);
}
else
{
MarkdownRenderer.EnableCodeSymbolHighlight = false;
bubble.Child = new TextBlock
{
Text = content,
TextAlignment = TextAlignment.Left,
FontSize = 14,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 22,
};
}
wrapper.Children.Add(bubble);
var userActionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Opacity = 0,
Margin = new Thickness(0, 2, 0, 0),
};
var capturedUserContent = content;
var userBtnColor = secondaryText;
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
{
try { Clipboard.SetText(capturedUserContent); } catch { }
}));
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
() => EnterEditMode(wrapper, capturedUserContent)));
var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) };
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var timestamp = message?.Timestamp ?? DateTime.Now;
Grid.SetColumn(userActionBar, 1);
userBottomBar.Children.Add(userActionBar);
var timestampText = new TextBlock
{
Text = timestamp.ToString("HH:mm"),
FontSize = 10.5,
Opacity = 0,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 2, 1),
};
Grid.SetColumn(timestampText, 2);
userBottomBar.Children.Add(timestampText);
wrapper.Children.Add(userBottomBar);
wrapper.MouseEnter += (_, _) =>
{
userActionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(0.7, TimeSpan.FromMilliseconds(150)));
timestampText.BeginAnimation(OpacityProperty,
new DoubleAnimation(0.5, TimeSpan.FromMilliseconds(150)));
};
wrapper.MouseLeave += (_, _) =>
{
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1.0 : 0.0;
userActionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
timestampText.BeginAnimation(OpacityProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(200)));
};
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
var userContent = content;
wrapper.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(userContent, "user");
};
if (animate)
ApplyMessageEntryAnimation(wrapper);
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = wrapper;
AddTranscriptElement(wrapper);
return;
}
if (message != null && IsCompactionMetaMessage(message))
{
var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
if (animate)
ApplyMessageEntryAnimation(compactCard);
if (message.MsgId != null) _elementCache[$"m_{message.MsgId}"] = compactCard;
AddTranscriptElement(compactCard);
return;
}
var assistantMaxWidth = GetMessageMaxWidth();
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = assistantMaxWidth,
Margin = new Thickness(12, 10, 60, 10), // 우측 여백 넓게 → 좌측에 붙음
};
if (animate)
ApplyMessageEntryAnimation(container);
var (agentName, _, _) = GetAgentIdentity();
var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none");
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 4, 0, 0) };
header.Children.Add(iconHost);
header.Children.Add(new TextBlock
{
Text = agentName,
FontSize = 12,
FontWeight = FontWeights.Medium,
Foreground = secondaryText,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
// 아이콘 애니메이션 적용
var canvas = iconHost.Children.OfType<Canvas>().FirstOrDefault();
if (canvas != null)
{
var animState = new ChatIconAnimState
{
Host = iconHost, Canvas = canvas, Pixels = iconPixels,
Glows = iconGlows, Rotate = iconRotate, Scale = iconScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
}
var contentCard = new Border
{
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(2, 4, 2, 4),
};
var contentStack = new StackPanel();
var app = System.Windows.Application.Current as App;
MarkdownRenderer.EnableFilePathHighlight =
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
MarkdownRenderer.EnableCodeSymbolHighlight =
string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase);
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
if (IsBranchContextMessage(content))
{
var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun();
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
var branchCard = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 6),
};
var branchStack = new StackPanel();
branchStack.Children.Add(new TextBlock
{
Text = "분기 컨텍스트",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
branchStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush));
if (branchFiles.Count > 0)
{
var filesWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
foreach (var path in branchFiles)
{
var fileButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
ToolTip = path,
Content = new TextBlock
{
Text = System.IO.Path.GetFileName(path),
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
},
};
var capturedPath = path;
fileButton.Click += (_, _) => OpenRunFilePath(capturedPath);
filesWrap.Children.Add(fileButton);
}
branchStack.Children.Add(filesWrap);
}
if (branchRun != null)
{
var actionsWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
var followUpButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Content = new TextBlock
{
Text = "후속 작업 큐에 넣기",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
},
};
var capturedBranchRun = branchRun;
followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun);
actionsWrap.Children.Add(followUpButton);
var timelineButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Content = new TextBlock
{
Text = "관련 로그로 이동",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
},
};
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
actionsWrap.Children.Add(timelineButton);
branchStack.Children.Add(actionsWrap);
}
branchCard.Child = branchStack;
contentStack.Children.Add(branchCard);
}
else
{
contentStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush));
}
contentCard.Child = contentStack;
container.Children.Add(contentCard);
// 에이전트 이름 푸터 (메시지 본문 아래)
container.Children.Add(header);
// 어시스턴트 메시지에 파일 경로가 포함되어 있으면 프리뷰/열기 퀵 액션 추가
var outputFilePath = ExtractOutputFilePathFromContent(content);
if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath))
{
var quickActions = BuildFileQuickActions(outputFilePath);
quickActions.Margin = new Thickness(2, 4, 0, 2);
container.Children.Add(quickActions);
}
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
Opacity = 0,
};
var btnColor = secondaryText;
var capturedContent = content;
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
{
try { Clipboard.SetText(capturedContent); } catch { }
}));
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
AddLinkedFeedbackButtons(actionBar, btnColor, message);
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
actionBar.Children.Add(new TextBlock
{
Text = aiTimestamp.ToString("HH:mm"),
FontSize = 10.5,
Opacity = 0.52,
Foreground = btnColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 1),
});
container.Children.Add(actionBar);
var assistantMeta = CreateAssistantMessageMetaText(message);
if (assistantMeta != null)
container.Children.Add(assistantMeta);
container.MouseEnter += (_, _) =>
{
actionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(1, TimeSpan.FromMilliseconds(150)));
};
container.MouseLeave += (_, _) =>
{
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1.0 : 0.0;
actionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
};
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
var aiContent = content;
container.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(aiContent, "assistant");
};
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container;
AddTranscriptElement(container);
}
/// <summary>
/// 어시스턴트 메시지 텍스트에서 출력 파일 경로(절대 경로)를 추출합니다.
/// "완료: C:\...\file.ext" 패턴을 우선 찾고, 없으면 일반 절대 경로를 검색합니다.
/// </summary>
private static string? ExtractOutputFilePathFromContent(string content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
// 패턴 1: "완료: C:\path\file.ext" 또는 "완료: E:\path\file.ext"
var completionMatch = System.Text.RegularExpressions.Regex.Match(
content,
@"완료:\s*([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
if (completionMatch.Success)
return completionMatch.Groups[1].Value.TrimEnd('.');
// 패턴 2: 임의의 절대 경로 (알려진 문서 확장자)
var absMatch = System.Text.RegularExpressions.Regex.Match(
content,
@"([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
if (absMatch.Success)
return absMatch.Groups[1].Value.TrimEnd('.');
return null;
}
}