using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { // 레이아웃 재계산 억제용 캐시: 동일한 높이면 WPF measure/arrange 생략 private double _cachedInputBoxHeight = -1; private int _cachedInputBoxMaxLines = -1; private void UpdateInputBoxHeight() { if (InputBox == null) return; var text = InputBox.Text ?? string.Empty; var explicitLineCount = 1 + text.Count(ch => ch == '\n'); var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); if (displayMode is not ("rich" or "balanced" or "simple")) displayMode = "balanced"; var maxLines = displayMode switch { "rich" => 6, "simple" => 4, _ => 5, }; const double baseHeight = 42; const double lineStep = 22; var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines); var targetHeight = baseHeight + ((visibleLines - 1) * lineStep); var needsScroll = explicitLineCount > maxLines; // 값이 바뀐 경우에만 WPF 속성 쓰기 (매 키입력마다 레이아웃 통과 방지) if (Math.Abs(targetHeight - _cachedInputBoxHeight) < 0.5 && maxLines == _cachedInputBoxMaxLines) return; _cachedInputBoxHeight = targetHeight; _cachedInputBoxMaxLines = maxLines; InputBox.MinLines = 1; InputBox.MaxLines = maxLines; InputBox.Height = targetHeight; InputBox.VerticalScrollBarVisibility = needsScroll ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; } private string BuildComposerDraftText() { var rawText = InputBox?.Text?.Trim() ?? ""; return _slashPalette.ActiveCommand != null ? (_slashPalette.ActiveCommand + " " + rawText).Trim() : rawText; } private static string InferDraftKind(string text, string? explicitKind = null) { var trimmed = text?.Trim() ?? ""; var requestedKind = explicitKind?.Trim().ToLowerInvariant(); if (requestedKind is "followup" or "steering") return requestedKind; if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase)) return "command"; if (requestedKind is "direct" or "message") return requestedKind; if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase)) return "steering"; return "message"; } private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true) { if (InputBox == null) return; var text = BuildComposerDraftText(); if (string.IsNullOrWhiteSpace(text)) return; // 현재 탭이 스트리밍 중일 때만 우선순위를 "next"로 낮춤 — 다른 탭 스트리밍은 무관 if (_streamingTabs.Contains(_activeTab) && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase)) priority = "next"; HideSlashChip(restoreText: false); ClearPromptCardPlaceholder(); var queuedItem = EnqueueDraftRequest(text, priority, explicitKind); InputBox.Clear(); InputBox.Focus(); UpdateInputBoxHeight(); RefreshDraftQueueUi(); if (queuedItem == null) return; if (!_streamingTabs.Contains(_activeTab) && startImmediatelyWhenIdle) { StartNextQueuedDraftIfAny(queuedItem.Id); return; } var runningTab = _streamRunTab; var runningLabel = runningTab switch { "Cowork" => "코워크", "Code" => "코드", "Chat" => "채팅", _ => runningTab ?? "다른 탭", }; var suffix = !string.IsNullOrEmpty(runningTab) && !string.Equals(runningTab, _activeTab, StringComparison.OrdinalIgnoreCase) ? $" ({runningLabel} 실행 완료 후 자동 실행)" : " (실행 완료 후 자동 실행)"; var toast = queuedItem.Kind switch { "command" => "명령이 대기열에 추가되었습니다." + suffix, "direct" => "직접 실행 요청이 대기열에 추가되었습니다." + suffix, "steering" => "조정 요청이 대기열에 추가되었습니다." + suffix, "followup" => "후속 작업이 대기열에 추가되었습니다." + suffix, _ => "메시지가 대기열에 추가되었습니다." + suffix, }; ShowToast(toast); } private void RefreshDraftQueueUi() { if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null) return; lock (_convLock) { var session = ChatSession; if (session != null) _draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage); } var items = _appState.GetDraftQueueItems(_activeTab); DraftPreviewCard.Visibility = Visibility.Collapsed; BtnDraftEnqueue.IsEnabled = false; DraftPreviewText.Text = string.Empty; RebuildDraftQueuePanel(items); } // --- Queue panel (Codex style) --- private void RebuildDraftQueuePanel(IReadOnlyList items) { if (DraftQueuePanel == null) return; DraftQueuePanel.Children.Clear(); var visibleItems = items .Where(x => !string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase) // 완료 항목 제거 && !string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)) // 수행 중인 항목은 채팅창에서 이미 표시됨 .OrderBy(GetDraftStateRank) .ThenBy(GetDraftPriorityRank) .ThenBy(x => x.CreatedAt) .ToList(); if (visibleItems.Count == 0) { DraftQueuePanel.Visibility = Visibility.Collapsed; return; } DraftQueuePanel.Visibility = Visibility.Visible; // 단일 통합 컨테이너 var containerBorder = new Border { Background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#1E1E2A"), BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Margin = new Thickness(0, 0, 0, 4), }; var innerStack = new StackPanel(); containerBorder.Child = innerStack; for (int i = 0; i < visibleItems.Count; i++) { innerStack.Children.Add(CreateDraftQueueRow(visibleItems[i])); // 구분선 (마지막 항목 제외) if (i < visibleItems.Count - 1) { innerStack.Children.Add(new Border { Height = 1, Background = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#2E2E3E"), Margin = new Thickness(10, 0, 10, 0), }); } } DraftQueuePanel.Children.Add(containerBorder); // 실패 항목 정리 버튼 (실패만 — 완료는 자동 제거됨) var summary = _appState.GetDraftQueueSummary(_activeTab); if (summary.FailedCount > 0) { var failBtn = CreateQueueFooterButton($"실패 정리 ({summary.FailedCount})", ClearFailedDrafts); DraftQueuePanel.Children.Add(failBtn); } } // --- Codex-style row --- private Border CreateDraftQueueRow(DraftQueueItem item) { var isRunning = string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase); var isFailed = string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase); var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#E5E5EA"); var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"); var row = new Border { Padding = new Thickness(10, 7, 8, 7), }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // state icon grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // actions row.Child = grid; // State icon var stateIcon = new TextBlock { Text = GetDraftStateIcon(item), FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = GetDraftStateIconBrush(item), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), Width = 14, }; Grid.SetColumn(stateIcon, 0); grid.Children.Add(stateIcon); // Message text var msgText = new TextBlock { Text = item.Text, FontSize = 12, Foreground = isFailed ? (TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A")) : primaryText, TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), }; Grid.SetColumn(msgText, 1); grid.Children.Add(msgText); // Right actions var actions = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(actions, 2); grid.Children.Add(actions); if (!isRunning) { // Kind chip (↪ 조정 style) actions.Children.Add(CreateKindChip(item, secondaryText)); } if (!isRunning && !isFailed) { // Run now button actions.Children.Add(CreateRowIconButton("\uE768", "지금 실행", () => QueueDraftForImmediateRun(item.Id))); } if (!isRunning) { // Edit button actions.Children.Add(CreateRowIconButton("\uE70F", "편집", () => PopDraftToEditor(item.Id))); } // Delete actions.Children.Add(CreateRowIconButton("\uE74D", isRunning ? "취소" : "삭제", () => RemoveDraftFromQueue(item.Id))); return row; } // Kind chip on the right — "↪ 조정" style private Border CreateKindChip(DraftQueueItem item, Brush defaultForeground) { var (kindIcon, kindLabel) = GetDraftKindChipContent(item); var foreground = GetDraftKindChipColor(item); return new Border { BorderBrush = foreground, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(5), Padding = new Thickness(5, 2, 6, 2), Margin = new Thickness(0, 0, 4, 0), VerticalAlignment = VerticalAlignment.Center, Child = new StackPanel { Orientation = Orientation.Horizontal, Children = { new TextBlock { Text = kindIcon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0), }, new TextBlock { Text = kindLabel, FontSize = 10.5, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, }, } } }; } // Row icon button — flat, no border private Button CreateRowIconButton(string icon, string tooltip, Action onClick) { var btn = new Button { Content = new TextBlock { Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center, }, Width = 24, Height = 24, Padding = new Thickness(0), Background = Brushes.Transparent, BorderThickness = new Thickness(0), Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"), Cursor = Cursors.Hand, ToolTip = tooltip, }; btn.Click += (_, _) => onClick(); return btn; } // Footer button (failed clear) private Button CreateQueueFooterButton(string label, Action onClick) { var btn = new Button { Content = label, Margin = new Thickness(0, 2, 0, 0), Padding = new Thickness(10, 4, 10, 4), FontSize = 11, Background = Brushes.Transparent, BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"), BorderThickness = new Thickness(1), Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"), HorizontalAlignment = HorizontalAlignment.Left, Cursor = Cursors.Hand, }; btn.Click += (_, _) => onClick(); return btn; } // Pop queued draft back into the InputBox for editing private void PopDraftToEditor(string draftId) { string? text = null; lock (_convLock) { var session = ChatSession; if (session != null) { var item = session.GetDraftQueueItems(_activeTab) .FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)); if (item != null) { text = item.Text; if (session.RemoveDraft(_activeTab, draftId, _storage)) _currentConversation = session.CurrentConversation ?? _currentConversation; } } } if (InputBox != null && text != null) { InputBox.Text = text; InputBox.CaretIndex = text.Length; InputBox.Focus(); UpdateInputBoxHeight(); } RefreshDraftQueueUi(); } // --- Icon-only button (kept for compatibility) --- private Button CreateIconButton(string icon, string tooltip, Action onClick) => CreateRowIconButton(icon, tooltip, onClick); // --- Ranking helpers --- private static int GetDraftStateRank(DraftQueueItem item) => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0 : IsDraftBlocked(item) ? 1 : string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2 : string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3 : 4; private static int GetDraftPriorityRank(DraftQueueItem item) => item.Priority?.ToLowerInvariant() switch { "now" => 0, "next" => 1, _ => 2, }; // --- State icon --- private static string GetDraftStateIcon(DraftQueueItem item) { if (IsDraftBlocked(item)) return "\uE9F5"; // 시계 return item.State?.ToLowerInvariant() switch { "running" => "\uE895", // 회전 (재생 아이콘) "failed" => "\uE783", // 경고 "completed" => "\uE73E", // 체크 _ => "\uE76C", // 대기 점 }; } private Brush GetDraftStateIconBrush(DraftQueueItem item) { if (IsDraftBlocked(item)) return BrushFromHex("#C2410C"); return item.State?.ToLowerInvariant() switch { "running" => TryFindResource("AccentColor") as Brush ?? BrushFromHex("#5B8AF5"), "failed" => BrushFromHex("#DC2626"), "completed" => BrushFromHex("#16A34A"), _ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"), }; } // --- Kind chip --- private static (string Icon, string Label) GetDraftKindChipContent(DraftQueueItem item) => item.Kind?.ToLowerInvariant() switch { "followup" => ("\uE8A5", "후속"), "steering" => ("\uE7C3", "조정"), "command" => ("\uE756", "명령"), "direct" => ("\uE8A7", "직접"), _ => ("\uE8BD", "메시지"), }; private Brush GetDraftKindChipColor(DraftQueueItem item) => item.Kind?.ToLowerInvariant() switch { "followup" => BrushFromHex("#0F766E"), "steering" => BrushFromHex("#B45309"), "command" => BrushFromHex("#7C3AED"), "direct" => BrushFromHex("#2563EB"), _ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280"), }; // --- Legacy helpers (used by other partial classes) --- private static string GetDraftKindLabel(DraftQueueItem item) => item.Kind?.ToLowerInvariant() switch { "followup" => "후속", "steering" => "조정", "command" => "명령", "direct" => "직접", _ => "메시지", }; private static string GetDraftStateLabel(DraftQueueItem item) => IsDraftBlocked(item) ? "재시도 대기" : item.State?.ToLowerInvariant() switch { "running" => "실행 중", "failed" => "실패", "completed" => "완료", _ => "대기", }; private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item) => IsDraftBlocked(item) ? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")) : item.State?.ToLowerInvariant() switch { "running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")), "failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")), "completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")), _ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")), }; private static bool IsDraftBlocked(DraftQueueItem item) => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now; private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null) => CreateQueueFooterButton(label, onClick); }