diff --git a/src/AxCopilot/Views/ChatWindow.MessageEdit.cs b/src/AxCopilot/Views/ChatWindow.MessageEdit.cs
new file mode 100644
index 0000000..80e69f9
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.MessageEdit.cs
@@ -0,0 +1,201 @@
+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 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 = ThemeResourceHelper.Primary(this),
+ Background = ThemeResourceHelper.ItemBg(this),
+ CaretBrush = ThemeResourceHelper.Accent(this),
+ BorderBrush = ThemeResourceHelper.Accent(this),
+ 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 = ThemeResourceHelper.Accent(this);
+ var secondaryBrush = ThemeResourceHelper.Secondary(this);
+
+ // 취소 버튼
+ 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 메시지 찾기
+ 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)
+ {
+ conv.Messages[userMsgIdx].Content = newText;
+ while (conv.Messages.Count > userMsgIdx + 1)
+ conv.Messages.RemoveAt(conv.Messages.Count - 1);
+ }
+
+ // UI에서 편집된 버블 이후 모두 제거
+ while (MessagePanel.Children.Count > bubbleIndex + 1)
+ MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
+
+ // 편집된 메시지를 새 버블로 교체
+ MessagePanel.Children.RemoveAt(bubbleIndex);
+ AddMessageBubble("user", newText, animate: false);
+
+ // AI 재응답
+ await SendRegenerateAsync(conv);
+ try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
+ RefreshConversationList();
+ }
+}
diff --git a/src/AxCopilot/Views/ChatWindow.Sending.cs b/src/AxCopilot/Views/ChatWindow.Sending.cs
index 16f5450..408ab80 100644
--- a/src/AxCopilot/Views/ChatWindow.Sending.cs
+++ b/src/AxCopilot/Views/ChatWindow.Sending.cs
@@ -1,8 +1,6 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Animation;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
@@ -11,255 +9,6 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
- // ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
-
- 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 = ThemeResourceHelper.Primary(this),
- Background = ThemeResourceHelper.ItemBg(this),
- CaretBrush = ThemeResourceHelper.Accent(this),
- BorderBrush = ThemeResourceHelper.Accent(this),
- 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 = ThemeResourceHelper.Accent(this);
- var secondaryBrush = ThemeResourceHelper.Secondary(this);
-
- // 취소 버튼
- 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 메시지 찾기
- 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)
- {
- conv.Messages[userMsgIdx].Content = newText;
- while (conv.Messages.Count > userMsgIdx + 1)
- conv.Messages.RemoveAt(conv.Messages.Count - 1);
- }
-
- // UI에서 편집된 버블 이후 모두 제거
- while (MessagePanel.Children.Count > bubbleIndex + 1)
- MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
-
- // 편집된 메시지를 새 버블로 교체
- MessagePanel.Children.RemoveAt(bubbleIndex);
- AddMessageBubble("user", newText, animate: false);
-
- // AI 재응답
- await SendRegenerateAsync(conv);
- try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
- RefreshConversationList();
- }
-
- // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
-
- private void StopAiIconPulse()
- {
- if (_aiIconPulseStopped || _activeAiIcon == null) return;
- _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
- _activeAiIcon.Opacity = 1.0;
- _activeAiIcon = null;
- _aiIconPulseStopped = true;
- }
-
- private void CursorTimer_Tick(object? sender, EventArgs e)
- {
- _cursorVisible = !_cursorVisible;
- // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
- if (_activeStreamText != null && _displayedLength > 0)
- {
- var displayed = _cachedStreamContent.Length > 0
- ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
- : "";
- _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
- }
- }
-
- private void ElapsedTimer_Tick(object? sender, EventArgs e)
- {
- var elapsed = DateTime.UtcNow - _streamStartTime;
- var sec = (int)elapsed.TotalSeconds;
- if (_elapsedLabel != null)
- _elapsedLabel.Text = $"{sec}s";
-
- // 하단 상태바 시간 갱신
- if (StatusElapsed != null)
- StatusElapsed.Text = $"{sec}초";
- }
-
- private void TypingTimer_Tick(object? sender, EventArgs e)
- {
- if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
-
- var targetLen = _cachedStreamContent.Length;
- if (_displayedLength >= targetLen) return;
-
- // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
- var pending = targetLen - _displayedLength;
- int step;
- if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
- else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
- else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
-
- _displayedLength += step;
-
- var displayed = _cachedStreamContent[.._displayedLength];
- _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
-
- // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
- if (!_userScrolled)
- MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
- }
-
// ─── 전송 ──────────────────────────────────────────────────────────────
public void SendInitialMessage(string message)
@@ -456,7 +205,7 @@ public partial class ChatWindow
{
Source = thumbnail,
MaxHeight = 48, MaxWidth = 64,
- Stretch = Stretch.Uniform,
+ Stretch = System.Windows.Media.Stretch.Uniform,
Margin = new Thickness(0, 0, 4, 0),
});
}
@@ -483,8 +232,8 @@ public partial class ChatWindow
var removeBtn = new Button
{
Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush },
- Background = Brushes.Transparent, BorderThickness = new Thickness(0),
- Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
+ Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
+ Cursor = System.Windows.Input.Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
};
removeBtn.Click += (_, _) =>
{
diff --git a/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs b/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
new file mode 100644
index 0000000..32845ae
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
@@ -0,0 +1,66 @@
+using System.Windows;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
+
+ private void StopAiIconPulse()
+ {
+ if (_aiIconPulseStopped || _activeAiIcon == null) return;
+ _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
+ _activeAiIcon.Opacity = 1.0;
+ _activeAiIcon = null;
+ _aiIconPulseStopped = true;
+ }
+
+ private void CursorTimer_Tick(object? sender, EventArgs e)
+ {
+ _cursorVisible = !_cursorVisible;
+ // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
+ if (_activeStreamText != null && _displayedLength > 0)
+ {
+ var displayed = _cachedStreamContent.Length > 0
+ ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
+ : "";
+ _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
+ }
+ }
+
+ private void ElapsedTimer_Tick(object? sender, EventArgs e)
+ {
+ var elapsed = DateTime.UtcNow - _streamStartTime;
+ var sec = (int)elapsed.TotalSeconds;
+ if (_elapsedLabel != null)
+ _elapsedLabel.Text = $"{sec}s";
+
+ // 하단 상태바 시간 갱신
+ if (StatusElapsed != null)
+ StatusElapsed.Text = $"{sec}초";
+ }
+
+ private void TypingTimer_Tick(object? sender, EventArgs e)
+ {
+ if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
+
+ var targetLen = _cachedStreamContent.Length;
+ if (_displayedLength >= targetLen) return;
+
+ // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
+ var pending = targetLen - _displayedLength;
+ int step;
+ if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
+ else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
+ else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
+
+ _displayedLength += step;
+
+ var displayed = _cachedStreamContent[.._displayedLength];
+ _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
+
+ // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
+ if (!_userScrolled)
+ MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
+ }
+}