From 35ec073eb944d646b3117d1f26063cc6b9602a45 Mon Sep 17 00:00:00 2001 From: lacvet Date: Mon, 6 Apr 2026 08:31:48 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=ED=98=B8=EC=9E=91=EC=9A=A9=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatWindow.MessageInteractions.cs를 추가해 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름을 메인 창 코드에서 분리함 - ChatWindow.xaml.cs는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리해 claw-code 기준 renderer 분리 작업을 이어가기 쉬운 구조로 개선함 - README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:27 (KST) 기준 변경 이력을 반영함 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인 --- README.md | 4 + docs/DEVELOPMENT.md | 2 + .../Views/ChatWindow.MessageInteractions.cs | 394 ++++++++++++++++++ src/AxCopilot/Views/ChatWindow.xaml.cs | 330 --------------- 4 files changed, 400 insertions(+), 330 deletions(-) create mode 100644 src/AxCopilot/Views/ChatWindow.MessageInteractions.cs diff --git a/README.md b/README.md index 5fc5935..96f306a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-06 08:27 (KST) +- AX Agent 메시지 액션/메타/편집 렌더를 `ChatWindow.MessageInteractions.cs`로 분리했습니다. 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름이 메인 창 코드 밖으로 이동했습니다. +- `ChatWindow.xaml.cs`는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리했고, claw-code 기준 메시지 타입 분리와 renderer 구조화를 계속 진행하기 쉬운 기반을 만들었습니다. + - 업데이트: 2026-04-06 08:28 (KST) - AX Agent 하단 composer와 대기열 UI 렌더를 `ChatWindow.ComposerQueuePresentation.cs`로 분리했습니다. 입력창 높이 계산, draft kind 해석, 후속 요청 큐 카드/요약 pill/배지/액션 버튼 생성 책임을 메인 창 코드에서 떼어냈습니다. - `ChatWindow.xaml.cs`는 대기열 실행 orchestration과 세션 변경 흐름만 더 선명하게 남겨, claw-code 기준 입력부/queued command UX 개선을 계속하기 쉬운 구조로 정리했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index be5a402..59c7fe3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4895,3 +4895,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - Document update: 2026-04-06 08:12 (KST) - This pass keeps the AX Agent transcript/runtime flow closer to the `claw-code` separation model by reducing UI assembly inside the main chat window file and isolating footer/prompt-adjacent presentation code for future parity work. - Document update: 2026-04-06 08:28 (KST) - Split composer/draft queue presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.ComposerQueuePresentation.cs`. Input-box height calculation, draft-kind inference, queue summary pills, compact queue cards, expanded queue sections, queue badges, and queue action buttons now live in a dedicated partial. - Document update: 2026-04-06 08:28 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused while preserving the same runtime behavior, and it aligns AX Agent more closely with the `claw-code` model of separating prompt/footer presentation from session execution logic. +- Document update: 2026-04-06 08:27 (KST) - Split message interaction presentation out of `ChatWindow.xaml.cs` into `ChatWindow.MessageInteractions.cs`. Feedback button creation, assistant response meta text, transcript entry animation, and inline user-message edit/regenerate flow are now grouped in a dedicated partial. +- Document update: 2026-04-06 08:27 (KST) - This pass further reduces renderer responsibility in the main chat window file and moves AX Agent closer to the `claw-code` structure where transcript orchestration and message interaction presentation are separated. diff --git a/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs b/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs new file mode 100644 index 0000000..083d661 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Models; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + /// 좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장) + private Button CreateFeedbackButton(string outline, string filled, string tooltip, + Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", + Action? resetSibling = null, Action? registerReset = null) + { + var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var isActive = message?.Feedback == feedbackType; + var icon = new TextBlock + { + Text = isActive ? filled : outline, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = isActive ? activeColor : normalColor, + VerticalAlignment = VerticalAlignment.Center, + RenderTransformOrigin = new Point(0.5, 0.5), + RenderTransform = new ScaleTransform(1, 1) + }; + var btn = new Button + { + Content = icon, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + ToolTip = tooltip + }; + + registerReset?.Invoke(() => + { + isActive = false; + icon.Text = outline; + icon.Foreground = normalColor; + }); + + btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; }; + btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; }; + btn.Click += (_, _) => + { + isActive = !isActive; + icon.Text = isActive ? filled : outline; + icon.Foreground = isActive ? activeColor : normalColor; + + if (isActive) + { + resetSibling?.Invoke(); + } + + if (message != null) + { + try + { + var feedback = isActive ? feedbackType : null; + var session = ChatSession; + if (session != null) + { + lock (_convLock) + { + session.UpdateMessageFeedback(_activeTab, message, feedback, _storage); + _currentConversation = session.CurrentConversation; + } + } + else + { + message.Feedback = feedback; + ChatConversation? conv; + lock (_convLock) + { + conv = _currentConversation; + } + + if (conv != null) + { + _storage.Save(conv); + } + } + } + catch + { + } + } + + var scale = (ScaleTransform)icon.RenderTransform; + var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250)) + { + EasingFunction = new ElasticEase + { + EasingMode = EasingMode.EaseOut, + Oscillations = 1, + Springiness = 5 + } + }; + scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce); + scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce); + }; + return btn; + } + + /// 좋아요/싫어요 버튼을 상호 배타로 연결하여 추가 + private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message) + { + Action? resetLikeAction = null; + Action? resetDislikeAction = null; + + var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor, + new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like", + resetSibling: () => resetDislikeAction?.Invoke(), + registerReset: reset => resetLikeAction = reset); + var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor, + new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike", + resetSibling: () => resetLikeAction?.Invoke(), + registerReset: reset => resetDislikeAction = reset); + + actionBar.Children.Add(likeBtn); + actionBar.Children.Add(dislikeBtn); + } + + private TextBlock? CreateAssistantMessageMetaText(ChatMessage? message) + { + if (message == null) + { + return null; + } + + var parts = new List(); + if (message.ResponseElapsedMs is > 0) + { + var elapsedMs = message.ResponseElapsedMs.Value; + parts.Add(elapsedMs < 1000 + ? $"{elapsedMs}ms" + : $"{(elapsedMs / 1000.0):0.0}s"); + } + + if (message.PromptTokens > 0 || message.CompletionTokens > 0) + { + var totalTokens = message.PromptTokens + message.CompletionTokens; + parts.Add($"{FormatTokenCount(totalTokens)} tokens"); + } + + if (parts.Count == 0) + { + return null; + } + + return new TextBlock + { + Text = string.Join(" · ", parts), + FontSize = 9.75, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(4, 2, 0, 0), + Opacity = 0.72, + }; + } + + private static void ApplyMessageEntryAnimation(FrameworkElement element) + { + element.Opacity = 0; + element.RenderTransform = new TranslateTransform(0, 16); + element.BeginAnimation(UIElement.OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }); + ((TranslateTransform)element.RenderTransform).BeginAnimation( + TranslateTransform.YProperty, + new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }); + } + + private bool _isEditing; + + private void EnterEditMode(StackPanel wrapper, string originalText) + { + if (_isStreaming || _isEditing) + { + return; + } + + _isEditing = true; + var idx = MessagePanel.Children.IndexOf(wrapper); + if (idx < 0) + { + _isEditing = false; + return; + } + + var editPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Right, + MaxWidth = 540, + Margin = wrapper.Margin, + }; + + var editBox = new TextBox + { + Text = originalText, + FontSize = 13.5, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray, + CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, + BorderThickness = new Thickness(1.5), + Padding = new Thickness(14, 10, 14, 10), + TextWrapping = TextWrapping.Wrap, + AcceptsReturn = false, + MaxHeight = 200, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + var editBorder = new Border + { + CornerRadius = new CornerRadius(14), + Child = editBox, + ClipToBounds = true, + }; + editPanel.Children.Add(editBorder); + + var btnBar = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 6, 0, 0), + }; + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + var cancelBtn = new Button + { + Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = Cursors.Hand, + Padding = new Thickness(12, 5, 12, 5), + Margin = new Thickness(0, 0, 6, 0), + }; + cancelBtn.Click += (_, _) => + { + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + { + MessagePanel.Children[idx] = wrapper; + } + }; + btnBar.Children.Add(cancelBtn); + + var sendBtn = new Button + { + Cursor = Cursors.Hand, + Padding = new Thickness(0), + }; + sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( + "" + + "" + + "" + + ""); + sendBtn.Click += (_, _) => + { + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + { + _ = SubmitEditAsync(idx, newText); + } + }; + btnBar.Children.Add(sendBtn); + + editPanel.Children.Add(btnBar); + MessagePanel.Children[idx] = editPanel; + + editBox.KeyDown += (_, ke) => + { + if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) + { + ke.Handled = true; + var newText = editBox.Text.Trim(); + if (!string.IsNullOrEmpty(newText)) + { + _ = SubmitEditAsync(idx, newText); + } + } + + if (ke.Key == Key.Escape) + { + ke.Handled = true; + _isEditing = false; + if (idx >= 0 && idx < MessagePanel.Children.Count) + { + MessagePanel.Children[idx] = wrapper; + } + } + }; + + editBox.Focus(); + editBox.SelectAll(); + } + + private async Task SubmitEditAsync(int bubbleIndex, string newText) + { + _isEditing = false; + if (_isStreaming) + { + return; + } + + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + { + return; + } + + conv = _currentConversation; + } + + var userMsgIdx = -1; + var uiIdx = 0; + lock (_convLock) + { + for (var i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Role == "system") + { + continue; + } + + if (uiIdx == bubbleIndex) + { + userMsgIdx = i; + break; + } + + uiIdx++; + } + } + + if (userMsgIdx < 0) + { + return; + } + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages[userMsgIdx].Content = newText; + while (conv.Messages.Count > userMsgIdx + 1) + { + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + } + } + + RenderMessages(preserveViewport: true); + AutoScrollIfNeeded(); + + await SendRegenerateAsync(conv); + try + { + if (ChatSession == null) + { + _storage.Save(conv); + } + } + catch (Exception ex) + { + Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); + } + + RefreshConversationList(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 6db6bee..88dc462 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -4249,336 +4249,6 @@ public partial class ChatWindow : Window return template; } - /// 좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장) - private Button CreateFeedbackButton(string outline, string filled, string tooltip, - Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", - Action? resetSibling = null, Action? registerReset = null) - { - var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var isActive = message?.Feedback == feedbackType; - var icon = new TextBlock - { - Text = isActive ? filled : outline, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12, - Foreground = isActive ? activeColor : normalColor, - VerticalAlignment = VerticalAlignment.Center, - RenderTransformOrigin = new Point(0.5, 0.5), - RenderTransform = new ScaleTransform(1, 1) - }; - var btn = new Button - { - Content = icon, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - Padding = new Thickness(6, 4, 6, 4), - Margin = new Thickness(0, 0, 4, 0), - ToolTip = tooltip - }; - // 상대 버튼이 리셋할 수 있도록 등록 - registerReset?.Invoke(() => - { - isActive = false; - icon.Text = outline; - icon.Foreground = normalColor; - }); - btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; }; - btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; }; - btn.Click += (_, _) => - { - isActive = !isActive; - icon.Text = isActive ? filled : outline; - icon.Foreground = isActive ? activeColor : normalColor; - - // 상호 배타: 활성화 시 반대쪽 리셋 - if (isActive) resetSibling?.Invoke(); - - // 피드백 상태 저장 - if (message != null) - { - try - { - var feedback = isActive ? feedbackType : null; - var session = ChatSession; - if (session != null) - { - lock (_convLock) - { - session.UpdateMessageFeedback(_activeTab, message, feedback, _storage); - _currentConversation = session.CurrentConversation; - } - } - else - { - message.Feedback = feedback; - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv != null) _storage.Save(conv); - } - } - catch { } - } - - // 바운스 애니메이션 - var scale = (ScaleTransform)icon.RenderTransform; - var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250)) - { EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } }; - scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce); - scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce); - }; - return btn; - } - - /// 좋아요/싫어요 버튼을 상호 배타로 연결하여 추가 - private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message) - { - // resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조 - Action? resetLikeAction = null; - Action? resetDislikeAction = null; - - var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor, - new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like", - resetSibling: () => resetDislikeAction?.Invoke(), - registerReset: reset => resetLikeAction = reset); - var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor, - new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike", - resetSibling: () => resetLikeAction?.Invoke(), - registerReset: reset => resetDislikeAction = reset); - - actionBar.Children.Add(likeBtn); - actionBar.Children.Add(dislikeBtn); - } - - private TextBlock? CreateAssistantMessageMetaText(ChatMessage? message) - { - if (message == null) - return null; - - var parts = new List(); - if (message.ResponseElapsedMs is > 0) - { - var elapsedMs = message.ResponseElapsedMs.Value; - parts.Add(elapsedMs < 1000 - ? $"{elapsedMs}ms" - : $"{(elapsedMs / 1000.0):0.0}s"); - } - - if (message.PromptTokens > 0 || message.CompletionTokens > 0) - { - var totalTokens = message.PromptTokens + message.CompletionTokens; - parts.Add($"{FormatTokenCount(totalTokens)} tokens"); - } - - if (parts.Count == 0) - return null; - - return new TextBlock - { - Text = string.Join(" · ", parts), - FontSize = 9.75, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(4, 2, 0, 0), - Opacity = 0.72, - }; - } - - // ─── 메시지 등장 애니메이션 ────────────────────────────────────────── - - private static void ApplyMessageEntryAnimation(FrameworkElement element) - { - element.Opacity = 0; - element.RenderTransform = new TranslateTransform(0, 16); - element.BeginAnimation(UIElement.OpacityProperty, - new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) - { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); - ((TranslateTransform)element.RenderTransform).BeginAnimation( - TranslateTransform.YProperty, - new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) - { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); - } - - // ─── 메시지 편집 ────────────────────────────────────────────────────── - - private bool _isEditing; // 편집 모드 중복 방지 - - private void EnterEditMode(StackPanel wrapper, string originalText) - { - if (_isStreaming || _isEditing) return; - _isEditing = true; - - // wrapper 위치(인덱스) 기억 - var idx = MessagePanel.Children.IndexOf(wrapper); - if (idx < 0) { _isEditing = false; return; } - - // 편집 UI 생성 - var editPanel = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Right, - MaxWidth = 540, - Margin = wrapper.Margin, - }; - - var editBox = new TextBox - { - Text = originalText, - FontSize = 13.5, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, - Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray, - CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, - BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, - BorderThickness = new Thickness(1.5), - Padding = new Thickness(14, 10, 14, 10), - TextWrapping = TextWrapping.Wrap, - AcceptsReturn = false, - MaxHeight = 200, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - }; - // 둥근 모서리 - var editBorder = new Border - { - CornerRadius = new CornerRadius(14), - Child = editBox, - ClipToBounds = true, - }; - editPanel.Children.Add(editBorder); - - // 버튼 바 - var btnBar = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 6, 0, 0), - }; - - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; - var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - - // 취소 버튼 - var cancelBtn = new Button - { - Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, - Padding = new Thickness(12, 5, 12, 5), - Margin = new Thickness(0, 0, 6, 0), - }; - cancelBtn.Click += (_, _) => - { - _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) - MessagePanel.Children[idx] = wrapper; // 원래 버블 복원 - }; - btnBar.Children.Add(cancelBtn); - - // 전송 버튼 - var sendBtn = new Button - { - Cursor = Cursors.Hand, - Padding = new Thickness(0), - }; - sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( - "" + - "" + - "" + - ""); - sendBtn.Click += (_, _) => - { - var newText = editBox.Text.Trim(); - if (!string.IsNullOrEmpty(newText)) - _ = SubmitEditAsync(idx, newText); - }; - btnBar.Children.Add(sendBtn); - - editPanel.Children.Add(btnBar); - - // 기존 wrapper → editPanel 교체 - MessagePanel.Children[idx] = editPanel; - - // Enter 키로도 전송 - editBox.KeyDown += (_, ke) => - { - if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) - { - ke.Handled = true; - var newText = editBox.Text.Trim(); - if (!string.IsNullOrEmpty(newText)) - _ = SubmitEditAsync(idx, newText); - } - if (ke.Key == Key.Escape) - { - ke.Handled = true; - _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) - MessagePanel.Children[idx] = wrapper; - } - }; - - editBox.Focus(); - editBox.SelectAll(); - } - - private async Task SubmitEditAsync(int bubbleIndex, string newText) - { - _isEditing = false; - if (_isStreaming) return; - - ChatConversation conv; - lock (_convLock) - { - if (_currentConversation == null) return; - conv = _currentConversation; - } - - // bubbleIndex에 해당하는 user 메시지 찾기 - // UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨 - // 데이터 모델에서 해당 위치의 user 메시지 찾기 - int userMsgIdx = -1; - int uiIdx = 0; - lock (_convLock) - { - for (int i = 0; i < conv.Messages.Count; i++) - { - if (conv.Messages[i].Role == "system") continue; - if (uiIdx == bubbleIndex) - { - userMsgIdx = i; - break; - } - uiIdx++; - } - } - - if (userMsgIdx < 0) return; - - // 데이터 모델에서 편집된 메시지 이후 모두 제거 - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else - { - conv.Messages[userMsgIdx].Content = newText; - while (conv.Messages.Count > userMsgIdx + 1) - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - } - - RenderMessages(preserveViewport: true); - AutoScrollIfNeeded(); - - // AI 재응답 - await SendRegenerateAsync(conv); - try { if (ChatSession == null) _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RefreshConversationList(); - } // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────