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 아이콘 펄스 ────────────────────────────