AX Agent 메시지 상호작용 렌더 구조 분리 및 문서 갱신
Some checks failed
Release Gate / gate (push) Has been cancelled

- 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 확인
This commit is contained in:
2026-04-06 08:31:48 +09:00
parent 68524c1c94
commit 35ec073eb9
4 changed files with 400 additions and 330 deletions

View File

@@ -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 개선을 계속하기 쉬운 구조로 정리했습니다.

View File

@@ -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.

View File

@@ -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
{
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<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;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
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<string>();
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(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
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();
}
}

View File

@@ -4249,336 +4249,6 @@ public partial class ChatWindow : Window
return template;
}
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<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;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
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<string>();
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(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
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 아이콘 펄스 ────────────────────────────