using AxCopilot.Models; namespace AxCopilot.Services; /// /// 채팅 창 외부에 유지되는 세션 상태. /// 활성 탭과 탭별 마지막 대화 ID를 관리해 창 재생성/재로딩에도 상태를 복원합니다. /// public sealed class ChatSessionStateService { private const int MaxExecutionEventHistory = 400; private const int MaxAgentRunHistory = 12; private readonly DraftQueueService _draftQueue = new(); public sealed class RuntimeActivity { public string Key { get; init; } = ""; public string Kind { get; init; } = ""; public string Title { get; set; } = ""; public string Summary { get; set; } = ""; public DateTime StartedAt { get; set; } = DateTime.Now; } public string ActiveTab { get; set; } = "Chat"; public ChatConversation? CurrentConversation { get; set; } public bool IsStreaming { get; set; } public string StatusText { get; set; } = "대기 중"; public bool IsStatusSpinning { get; set; } public string LastCompletedSummary { get; set; } = ""; public List ActiveRuntimeActivities { get; } = new(); public Dictionary TabConversationIds { get; } = new(StringComparer.OrdinalIgnoreCase) { ["Chat"] = null, ["Cowork"] = null, ["Code"] = null, }; public ChatConversation EnsureCurrentConversation(string tab) { var normalizedTab = NormalizeTab(tab); if (CurrentConversation == null || !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)) { CurrentConversation = new ChatConversation { Tab = normalizedTab }; } if (string.IsNullOrWhiteSpace(CurrentConversation.Tab) || !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)) CurrentConversation.Tab = normalizedTab; return CurrentConversation; } public void Load(SettingsService settings) { var llm = settings.Settings.Llm; ActiveTab = NormalizeTab(llm.LastActiveTab); foreach (var key in TabConversationIds.Keys.ToList()) TabConversationIds[key] = null; foreach (var kv in llm.LastConversationIds) { var normalized = NormalizeTab(kv.Key); if (TabConversationIds.ContainsKey(normalized) && !string.IsNullOrWhiteSpace(kv.Value)) TabConversationIds[normalized] = kv.Value; } } public void Save(SettingsService settings) { var llm = settings.Settings.Llm; llm.LastActiveTab = NormalizeTab(ActiveTab); var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in TabConversationIds) { if (!string.IsNullOrWhiteSpace(kv.Value)) snapshot[kv.Key] = kv.Value!; } llm.LastConversationIds = snapshot; settings.Save(); } public void RememberConversation(string tab, string? conversationId) { TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId; } public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings) { var normalizedTab = NormalizeTab(tab); var rememberedId = GetConversationId(normalizedTab); var hadRememberedConversation = !string.IsNullOrWhiteSpace(rememberedId); if (!string.IsNullOrWhiteSpace(rememberedId)) { var loaded = storage.Load(rememberedId); if (loaded != null) { if (string.IsNullOrWhiteSpace(loaded.Tab)) loaded.Tab = normalizedTab; if (string.Equals(NormalizeTab(loaded.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)) { var normalized = NormalizeLoadedConversation(loaded); CurrentConversation = loaded; if (normalized) try { storage.Save(loaded); } catch { } return loaded; } // 잘못 매핑된 탭 ID 방어: 교차 탭 대화 누적 방지 RememberConversation(normalizedTab, null); } else RememberConversation(normalizedTab, null); } // 새 대화를 시작한 직후처럼 아직 저장되지 않은 현재 대화가 있으면, // 최신 저장 대화로 되돌아가지 말고 그 임시 세션을 그대로 유지합니다. if (CurrentConversation != null && string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase) && !HasPersistableContent(CurrentConversation)) { return CurrentConversation; } if (!hadRememberedConversation) { var latestMeta = storage.LoadAllMeta() .Where(c => string.Equals(NormalizeTab(c.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(c => c.Pinned) .ThenByDescending(c => c.UpdatedAt) .FirstOrDefault(); if (latestMeta != null) { var loaded = storage.Load(latestMeta.Id); if (loaded != null) { loaded.Tab = normalizedTab; var normalized = NormalizeLoadedConversation(loaded); CurrentConversation = loaded; RememberConversation(normalizedTab, loaded.Id); if (normalized) try { storage.Save(loaded); } catch { } return loaded; } } } RememberConversation(normalizedTab, null); return CreateFreshConversation(normalizedTab, settings); } public ChatConversation CreateFreshConversation(string tab, SettingsService settings) { var normalizedTab = NormalizeTab(tab); var created = new ChatConversation { Tab = normalizedTab }; var workFolder = settings.Settings.Llm.WorkFolder; if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat") created.WorkFolder = workFolder; CurrentConversation = created; return created; } public ChatConversation CreateBranchConversation( ChatConversation source, int atIndex, int branchCount, string? branchHint = null, string? branchContextMessage = null, string? branchContextRunId = null) { var branchLabel = string.IsNullOrWhiteSpace(branchHint) ? $"분기 {branchCount}" : $"분기 {branchCount} · {Truncate(branchHint, 18)}"; var fork = new ChatConversation { Title = $"{source.Title} ({branchLabel})", Tab = source.Tab, Category = source.Category, WorkFolder = source.WorkFolder, SystemCommand = source.SystemCommand, ParentId = source.Id, BranchLabel = branchLabel, BranchAtIndex = atIndex, ConversationFailedOnlyFilter = source.ConversationFailedOnlyFilter, ConversationRunningOnlyFilter = source.ConversationRunningOnlyFilter, ConversationSortMode = source.ConversationSortMode, AgentRunHistory = source.AgentRunHistory .Select(x => new ChatAgentRunRecord { RunId = x.RunId, Status = x.Status, Summary = x.Summary, LastIteration = x.LastIteration, StartedAt = x.StartedAt, UpdatedAt = x.UpdatedAt, }) .ToList(), }; for (var i = 0; i <= atIndex && i < source.Messages.Count; i++) { var m = source.Messages[i]; fork.Messages.Add(new ChatMessage { Role = m.Role, Content = m.Content, Timestamp = m.Timestamp, MetaKind = m.MetaKind, MetaRunId = m.MetaRunId, Feedback = m.Feedback, AttachedFiles = m.AttachedFiles?.ToList(), Images = m.Images?.Select(img => new ImageAttachment { FileName = img.FileName, MimeType = img.MimeType, Base64 = img.Base64, }).ToList(), }); } if (!string.IsNullOrWhiteSpace(branchContextMessage)) { fork.Messages.Add(new ChatMessage { Role = "assistant", Content = branchContextMessage.Trim(), Timestamp = DateTime.Now, MetaKind = "branch_context", MetaRunId = branchContextRunId, }); } return fork; } public void SaveCurrentConversation(ChatStorageService storage, string tab) { var conv = CurrentConversation; if (conv == null) return; var conversationTab = NormalizeTab(conv.Tab); if (!HasPersistableContent(conv)) { RememberConversation(conversationTab, null); return; } try { storage.Save(conv); } catch { } RememberConversation(conversationTab, conv.Id); } public void ClearCurrentConversation(string tab) { CurrentConversation = null; RememberConversation(tab, null); } public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true) { var normalizedTab = NormalizeTab(tab); conversation.Tab = normalizedTab; NormalizeLoadedConversation(conversation); CurrentConversation = conversation; if (remember && !string.IsNullOrWhiteSpace(conversation.Id)) RememberConversation(normalizedTab, conversation.Id); try { storage?.Save(conversation); } catch { } return conversation; } public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false) { var conv = EnsureCurrentConversation(tab); conv.Messages.Add(message); if (useForTitle && string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)) { var userMessageCount = conv.Messages.Count(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); if (userMessageCount == 1 && !string.IsNullOrWhiteSpace(message.Content)) conv.Title = message.Content.Length > 30 ? message.Content[..30] + "…" : message.Content; } TouchConversation(storage, tab); return message; } public ChatConversation UpdateConversationMetadata( string tab, Action apply, ChatStorageService? storage = null, bool ensureConversation = true) { var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) }); apply(conv); if (CurrentConversation == null || ensureConversation) CurrentConversation = conv; TouchConversation(storage, tab); return conv; } public ChatConversation SaveConversationSettings( string tab, string? permission, string? dataUsage, string? outputFormat, string? mood, ChatStorageService? storage = null) { return UpdateConversationMetadata(tab, conv => { conv.Permission = permission; conv.DataUsage = dataUsage; conv.OutputFormat = outputFormat; conv.Mood = mood; }, storage); } public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null) { var conv = CurrentConversation; if (conv == null || conv.Messages.Count == 0) return false; if (!string.Equals(conv.Messages[^1].Role, "assistant", StringComparison.OrdinalIgnoreCase)) return false; conv.Messages.RemoveAt(conv.Messages.Count - 1); TouchConversation(storage, tab); return true; } public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null) { var conv = CurrentConversation; if (conv == null) return false; if (userMessageIndex < 0 || userMessageIndex >= conv.Messages.Count) return false; conv.Messages[userMessageIndex].Content = newText; while (conv.Messages.Count > userMessageIndex + 1) conv.Messages.RemoveAt(conv.Messages.Count - 1); TouchConversation(storage, tab); return true; } public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null) { var conv = CurrentConversation; if (conv == null) return false; var index = conv.Messages.IndexOf(message); if (index < 0) return false; conv.Messages[index].Feedback = string.IsNullOrWhiteSpace(feedback) ? null : feedback; TouchConversation(storage, tab); return true; } public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null) { var conv = EnsureCurrentConversation(tab); conv.ExecutionEvents ??= new List(); var nextEvent = new ChatExecutionEvent { Timestamp = evt.Timestamp, RunId = evt.RunId, Type = evt.Type.ToString(), ToolName = evt.ToolName, Summary = evt.Summary, FilePath = evt.FilePath, Success = evt.Success, StepCurrent = evt.StepCurrent, StepTotal = evt.StepTotal, Steps = evt.Steps?.ToList(), ElapsedMs = evt.ElapsedMs, InputTokens = evt.InputTokens, OutputTokens = evt.OutputTokens, }; if (conv.ExecutionEvents.Count > 0 && IsNearDuplicateExecutionEvent(conv.ExecutionEvents[^1], nextEvent)) { var existing = conv.ExecutionEvents[^1]; existing.Timestamp = nextEvent.Timestamp; existing.ElapsedMs = Math.Max(existing.ElapsedMs, nextEvent.ElapsedMs); existing.InputTokens = Math.Max(existing.InputTokens, nextEvent.InputTokens); existing.OutputTokens = Math.Max(existing.OutputTokens, nextEvent.OutputTokens); existing.StepCurrent = Math.Max(existing.StepCurrent, nextEvent.StepCurrent); existing.StepTotal = Math.Max(existing.StepTotal, nextEvent.StepTotal); existing.Steps = nextEvent.Steps ?? existing.Steps; existing.Success = existing.Success && nextEvent.Success; if (string.IsNullOrWhiteSpace(existing.FilePath)) existing.FilePath = nextEvent.FilePath; if (!string.IsNullOrWhiteSpace(nextEvent.Summary) && nextEvent.Summary.Length >= existing.Summary.Length) existing.Summary = nextEvent.Summary; } else { conv.ExecutionEvents.Add(nextEvent); } if (conv.ExecutionEvents.Count > MaxExecutionEventHistory) conv.ExecutionEvents.RemoveRange(0, conv.ExecutionEvents.Count - MaxExecutionEventHistory); TouchConversation(storage, tab); return conv; } public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null) { var conv = EnsureCurrentConversation(tab); conv.AgentRunHistory ??= new List(); var newRecord = new ChatAgentRunRecord { RunId = evt.RunId, Status = status, Summary = summary, LastIteration = evt.Iteration, StartedAt = evt.Timestamp, UpdatedAt = DateTime.Now, }; var existingIndex = conv.AgentRunHistory.FindIndex(x => !string.IsNullOrWhiteSpace(x.RunId) && string.Equals(x.RunId, newRecord.RunId, StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) { conv.AgentRunHistory.RemoveAt(existingIndex); conv.AgentRunHistory.Insert(0, newRecord); } else conv.AgentRunHistory.Insert(0, newRecord); if (conv.AgentRunHistory.Count > MaxAgentRunHistory) conv.AgentRunHistory.RemoveRange(MaxAgentRunHistory, conv.AgentRunHistory.Count - MaxAgentRunHistory); TouchConversation(storage, tab); return conv; } public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null, string kind = "message") { var trimmed = text?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(trimmed)) return null; var conv = EnsureCurrentConversation(tab); conv.DraftQueueItems ??= new List(); var item = _draftQueue.CreateItem(trimmed, priority, kind); conv.DraftQueueItems.Add(item); TouchConversation(storage, tab); return item; } public DraftQueueItem? GetNextQueuedDraft(string tab) { var conv = EnsureCurrentConversation(tab); return _draftQueue.GetNextQueuedItem(conv.DraftQueueItems); } public DraftQueueService.DraftQueueSummary GetDraftQueueSummary(string tab) { var conv = EnsureCurrentConversation(tab); return _draftQueue.GetSummary(conv.DraftQueueItems); } public IReadOnlyList GetDraftQueueItems(string tab) { var conv = EnsureCurrentConversation(tab); return (conv.DraftQueueItems ?? []).ToList(); } public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null) => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage); public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null) => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage); public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null) => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage); public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null) { return UpdateDraftItem(tab, draftId, item => { if (item == null) return false; if (item.AttemptCount >= maxAutoRetries) return _draftQueue.MarkFailed(item, error); return _draftQueue.ScheduleRetry(item, error); }, storage); } public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null) => UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage); public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null) { if (string.IsNullOrWhiteSpace(draftId)) return false; var conv = EnsureCurrentConversation(tab); var removed = conv.DraftQueueItems?.RemoveAll(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)) ?? 0; if (removed <= 0) return false; TouchConversation(storage, tab); return true; } public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null) { var conv = EnsureCurrentConversation(tab); conv.ShowExecutionHistory = !conv.ShowExecutionHistory; TouchConversation(storage, tab); return conv.ShowExecutionHistory; } public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null) { var conv = EnsureCurrentConversation(tab); conv.ConversationFailedOnlyFilter = failedOnly; conv.ConversationRunningOnlyFilter = runningOnly; conv.ConversationSortMode = sortByRecent ? "recent" : "activity"; TouchConversation(storage, tab); } public void SetRuntimeState(bool isStreaming, string statusText, bool isStatusSpinning) { IsStreaming = isStreaming; StatusText = string.IsNullOrWhiteSpace(statusText) ? "대기 중" : statusText; IsStatusSpinning = isStatusSpinning; } public void UpsertRuntimeActivity(string key, string kind, string title, string summary) { var existing = ActiveRuntimeActivities.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); if (existing == null) { ActiveRuntimeActivities.Add(new RuntimeActivity { Key = key, Kind = kind, Title = title, Summary = summary, StartedAt = DateTime.Now, }); return; } existing.Title = title; existing.Summary = summary; } public void RemoveRuntimeActivity(string key, string? completionSummary = null) { ActiveRuntimeActivities.RemoveAll(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(completionSummary)) LastCompletedSummary = completionSummary; } public void ClearRuntimeActivities(string? completionSummary = null) { ActiveRuntimeActivities.Clear(); if (!string.IsNullOrWhiteSpace(completionSummary)) LastCompletedSummary = completionSummary; } public string? GetConversationId(string tab) { TabConversationIds.TryGetValue(NormalizeTab(tab), out var value); return value; } private static string NormalizeTab(string? tab) { var normalized = (tab ?? "").Trim(); if (string.IsNullOrEmpty(normalized)) return "Chat"; if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase)) return "Cowork"; var canonical = new string(normalized .Where(char.IsLetterOrDigit) .ToArray()) .ToLowerInvariant(); if (canonical is "cowork" or "coworkcode" or "coworkcodetab") return "Cowork"; if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase) || canonical is "code" or "codetab") return "Code"; return "Chat"; } private static bool HasPersistableContent(ChatConversation conv) { if ((conv.Messages?.Count ?? 0) > 0) return true; if ((conv.ExecutionEvents?.Count ?? 0) > 0) return true; if ((conv.AgentRunHistory?.Count ?? 0) > 0) return true; if ((conv.DraftQueueItems?.Count ?? 0) > 0) return true; if (conv.Pinned) return true; if (!string.IsNullOrWhiteSpace(conv.WorkFolder)) return true; if (!string.IsNullOrWhiteSpace(conv.SystemCommand)) return true; if (!string.IsNullOrWhiteSpace(conv.ParentId)) return true; if (!string.IsNullOrWhiteSpace(conv.Permission)) return true; if (!string.IsNullOrWhiteSpace(conv.DataUsage)) return true; if (!string.IsNullOrWhiteSpace(conv.OutputFormat)) return true; if (!string.IsNullOrWhiteSpace(conv.Mood)) return true; if (!string.IsNullOrWhiteSpace(conv.Preview)) return true; if (!string.Equals(conv.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)) return true; if (!string.Equals((conv.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)) return true; return false; } private static bool NormalizeLoadedConversation(ChatConversation conversation) { var changed = false; conversation.Messages ??= new List(); conversation.ExecutionEvents ??= new List(); conversation.AgentRunHistory ??= new List(); conversation.DraftQueueItems ??= new List(); var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents); if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents)) { conversation.ExecutionEvents = normalizedEvents; changed = true; } if (conversation.ExecutionEvents.Count > MaxExecutionEventHistory) { conversation.ExecutionEvents = conversation.ExecutionEvents .Skip(conversation.ExecutionEvents.Count - MaxExecutionEventHistory) .ToList(); changed = true; } var orderedRuns = conversation.AgentRunHistory .GroupBy(run => string.IsNullOrWhiteSpace(run.RunId) ? Guid.NewGuid().ToString("N") : run.RunId, StringComparer.OrdinalIgnoreCase) .Select(group => group .OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt) .First()) .OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt) .ToList(); if (!conversation.AgentRunHistory.SequenceEqual(orderedRuns)) { conversation.AgentRunHistory = orderedRuns; changed = true; } if (conversation.AgentRunHistory.Count > MaxAgentRunHistory) { conversation.AgentRunHistory = conversation.AgentRunHistory .Take(MaxAgentRunHistory) .ToList(); changed = true; } return changed; } private static bool IsNearDuplicateExecutionEvent(ChatExecutionEvent left, ChatExecutionEvent right) { if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal)) return false; if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase)) return false; if (left.Success != right.Success) return false; var delta = (right.Timestamp - left.Timestamp).Duration(); return delta <= TimeSpan.FromSeconds(2); } private static List NormalizeExecutionEventsForResume(IEnumerable? source) { if (source == null) return new List(); var ordered = source .Where(evt => evt != null) .OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp) .ThenBy(evt => evt.Iteration) .ThenBy(evt => evt.ElapsedMs) .ToList(); var result = new List(ordered.Count); foreach (var evt in ordered) { if (result.Count == 0) { result.Add(evt); continue; } var last = result[^1]; if (!IsNearDuplicateExecutionEvent(last, evt)) { result.Add(evt); continue; } last.Timestamp = evt.Timestamp; last.ElapsedMs = Math.Max(last.ElapsedMs, evt.ElapsedMs); last.InputTokens = Math.Max(last.InputTokens, evt.InputTokens); last.OutputTokens = Math.Max(last.OutputTokens, evt.OutputTokens); last.StepCurrent = Math.Max(last.StepCurrent, evt.StepCurrent); last.StepTotal = Math.Max(last.StepTotal, evt.StepTotal); last.Iteration = Math.Max(last.Iteration, evt.Iteration); if (string.IsNullOrWhiteSpace(last.FilePath)) last.FilePath = evt.FilePath; if (!string.IsNullOrWhiteSpace(evt.Summary) && evt.Summary.Length >= (last.Summary?.Length ?? 0)) last.Summary = evt.Summary; if (evt.Steps is { Count: > 0 }) last.Steps = evt.Steps.ToList(); if (!string.IsNullOrWhiteSpace(evt.ToolInput)) last.ToolInput = evt.ToolInput; last.Success = last.Success && evt.Success; } return result; } private void TouchConversation(ChatStorageService? storage, string tab) { var conv = EnsureCurrentConversation(tab); conv.UpdatedAt = DateTime.Now; var conversationTab = NormalizeTab(conv.Tab); if (!string.IsNullOrWhiteSpace(conv.Id)) RememberConversation(conversationTab, conv.Id); try { storage?.Save(conv); } catch { } } private bool UpdateDraftItem(string tab, string draftId, Func update, ChatStorageService? storage) { if (string.IsNullOrWhiteSpace(draftId)) return false; var conv = EnsureCurrentConversation(tab); var item = conv.DraftQueueItems?.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)); if (item == null) return false; if (!update(item)) return false; TouchConversation(storage, tab); return true; } private static string Truncate(string? text, int max) { var value = text?.Trim() ?? ""; if (string.IsNullOrEmpty(value) || value.Length <= max) return value; return value[..max] + "…"; } }