| 1 | #nullable enable |
| 2 | using System.Text; |
| 3 | using CascadeIDE.Services; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Chat; |
| 6 | |
| 7 | /// <summary>Общая презентация тем (текстовые отчёты, picker, autocomplete).</summary> |
| 8 | public static class ChatThreadPresentation |
| 9 | { |
| 10 | public const string EmptyTopicsHint = |
| 11 | "Тем пока нет. Отправь сообщение или /intercom topic create <название>."; |
| 12 | |
| 13 | public sealed record PickerRow(Guid ThreadId, string Title, string Meta, int Depth); |
| 14 | |
| 15 | public static Dictionary<Guid, int> MessageCountsByThread(ChatSurfaceSnapshot snapshot) |
| 16 | { |
| 17 | var counts = snapshot.State.Messages |
| 18 | .GroupBy(m => m.ThreadId) |
| 19 | .ToDictionary(g => g.Key, g => g.Count()); |
| 20 | foreach (var lane in snapshot.Layout.Lanes) |
| 21 | { |
| 22 | var n = lane.Entries.Count(e => e.Kind == ChatSurfaceEntryKind.Message); |
| 23 | if (!counts.TryGetValue(lane.Thread.ThreadId, out var existing) || n > existing) |
| 24 | counts[lane.Thread.ThreadId] = n; |
| 25 | } |
| 26 | |
| 27 | foreach (var thread in snapshot.State.Threads) |
| 28 | counts.TryAdd(thread.ThreadId, 0); |
| 29 | |
| 30 | return counts; |
| 31 | } |
| 32 | |
| 33 | public static string FormatFlags(ChatThreadNode thread) |
| 34 | { |
| 35 | var flags = new List<string>(); |
| 36 | if (thread.IsMainThread) |
| 37 | flags.Add("main"); |
| 38 | if (thread.IsActive) |
| 39 | flags.Add("active"); |
| 40 | return flags.Count == 0 ? "" : string.Join(", ", flags); |
| 41 | } |
| 42 | |
| 43 | public static string FormatMeta(ChatThreadNode thread, IReadOnlyDictionary<Guid, int> messageCounts) |
| 44 | { |
| 45 | messageCounts.TryGetValue(thread.ThreadId, out var count); |
| 46 | var flags = FormatFlags(thread); |
| 47 | var shortId = thread.ThreadId.ToString("N")[..8]; |
| 48 | return string.IsNullOrEmpty(flags) |
| 49 | ? $"{count} · {shortId}" |
| 50 | : $"{flags} · {count} · {shortId}"; |
| 51 | } |
| 52 | |
| 53 | public static string FormatListLine(ChatThreadNode thread, int messageCount, int depth) |
| 54 | { |
| 55 | var indent = new string(' ', depth * 2); |
| 56 | var flags = FormatFlags(thread); |
| 57 | var flagText = flags.Length == 0 ? "" : " [" + flags + "]"; |
| 58 | var shortId = thread.ThreadId.ToString("N")[..8]; |
| 59 | return $"{indent}• {thread.Title}{flagText} — {messageCount} сообщ. — {shortId}"; |
| 60 | } |
| 61 | |
| 62 | public static string FormatTopicList(ChatSurfaceSnapshot snapshot) |
| 63 | { |
| 64 | var threads = snapshot.State.Threads; |
| 65 | if (threads.Count == 0) |
| 66 | return EmptyTopicsHint; |
| 67 | |
| 68 | var msgCounts = MessageCountsByThread(snapshot); |
| 69 | var lines = new List<string> { $"Темы сессии ({threads.Count}):" }; |
| 70 | foreach (var thread in threads.OrderBy(t => t.Order)) |
| 71 | { |
| 72 | msgCounts.TryGetValue(thread.ThreadId, out var count); |
| 73 | lines.Add(FormatListLine(thread, count, thread.Depth)); |
| 74 | } |
| 75 | |
| 76 | return string.Join(Environment.NewLine, lines); |
| 77 | } |
| 78 | |
| 79 | public static string FormatTopicTree(ChatSurfaceSnapshot snapshot) |
| 80 | { |
| 81 | var threads = snapshot.State.Threads; |
| 82 | if (threads.Count == 0) |
| 83 | return EmptyTopicsHint; |
| 84 | |
| 85 | var byId = threads.ToDictionary(t => t.ThreadId); |
| 86 | var msgCounts = MessageCountsByThread(snapshot); |
| 87 | var roots = threads |
| 88 | .Where(t => t.ParentThreadId is null || !byId.ContainsKey(t.ParentThreadId.Value)) |
| 89 | .OrderBy(t => t.Order) |
| 90 | .ToList(); |
| 91 | |
| 92 | var sb = new StringBuilder(); |
| 93 | sb.AppendLine($"Дерево тем ({threads.Count}):"); |
| 94 | for (var i = 0; i < roots.Count; i++) |
| 95 | AppendTreeNode(sb, roots[i], byId, msgCounts, prefix: "", isLast: i == roots.Count - 1); |
| 96 | |
| 97 | return sb.ToString().TrimEnd(); |
| 98 | } |
| 99 | |
| 100 | /// <summary>Строки Topic Navigator (дерево + опциональный фильтр по заголовку, ADR 0127-E).</summary> |
| 101 | public static IReadOnlyList<PickerRow> BuildNavigatorRows( |
| 102 | IReadOnlyList<ChatThreadNode> threads, |
| 103 | IReadOnlyDictionary<Guid, int> messageCounts, |
| 104 | string? searchQuery = null) |
| 105 | { |
| 106 | var rows = BuildPickerRows(TopicPickerPresentation.Tree, threads, messageCounts); |
| 107 | return FilterNavigatorRows(rows, searchQuery); |
| 108 | } |
| 109 | |
| 110 | public static IReadOnlyList<PickerRow> FilterNavigatorRows( |
| 111 | IReadOnlyList<PickerRow> rows, |
| 112 | string? searchQuery) |
| 113 | { |
| 114 | var q = searchQuery?.Trim() ?? ""; |
| 115 | if (q.Length == 0) |
| 116 | return rows; |
| 117 | |
| 118 | return rows |
| 119 | .Where(r => r.Title.Contains(q, StringComparison.OrdinalIgnoreCase) |
| 120 | || r.Meta.Contains(q, StringComparison.OrdinalIgnoreCase)) |
| 121 | .ToList(); |
| 122 | } |
| 123 | |
| 124 | public static IReadOnlyList<PickerRow> BuildPickerRows( |
| 125 | TopicPickerPresentation mode, |
| 126 | IReadOnlyList<ChatThreadNode> threads, |
| 127 | IReadOnlyDictionary<Guid, int> messageCounts) |
| 128 | { |
| 129 | if (threads.Count == 0) |
| 130 | return []; |
| 131 | |
| 132 | if (mode == TopicPickerPresentation.List) |
| 133 | { |
| 134 | return threads |
| 135 | .OrderBy(t => t.Order) |
| 136 | .Select(t => new PickerRow(t.ThreadId, t.Title, FormatMeta(t, messageCounts), t.Depth)) |
| 137 | .ToList(); |
| 138 | } |
| 139 | |
| 140 | var byId = threads.ToDictionary(t => t.ThreadId); |
| 141 | var roots = threads |
| 142 | .Where(t => t.ParentThreadId is null || !byId.ContainsKey(t.ParentThreadId.Value)) |
| 143 | .OrderBy(t => t.Order) |
| 144 | .ToList(); |
| 145 | |
| 146 | var rows = new List<PickerRow>(); |
| 147 | foreach (var root in roots) |
| 148 | AppendPickerTreeNode(rows, root, byId, messageCounts, depth: 0, isLast: true, prefix: ""); |
| 149 | return rows; |
| 150 | } |
| 151 | |
| 152 | public static IEnumerable<ChatThreadNode> RankThreadsForCompletion( |
| 153 | IReadOnlyList<ChatThreadNode> threads, |
| 154 | string prefix) |
| 155 | { |
| 156 | if (threads.Count == 0) |
| 157 | return []; |
| 158 | |
| 159 | if (prefix.Length == 0) |
| 160 | { |
| 161 | return threads |
| 162 | .OrderBy(t => t.IsActive ? 0 : 1) |
| 163 | .ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase); |
| 164 | } |
| 165 | |
| 166 | return threads |
| 167 | .Select(t => (t, RankPrefix(prefix, t))) |
| 168 | .Where(x => x.Item2 < int.MaxValue) |
| 169 | .OrderBy(x => x.Item2) |
| 170 | .ThenBy(x => x.t.Title, StringComparer.OrdinalIgnoreCase) |
| 171 | .Select(x => x.t); |
| 172 | } |
| 173 | |
| 174 | private static int RankPrefix(string prefix, ChatThreadNode thread) |
| 175 | { |
| 176 | var id = thread.ThreadId.ToString("N"); |
| 177 | if (id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) |
| 178 | return 0; |
| 179 | if (thread.Title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) |
| 180 | return 1; |
| 181 | if (thread.Title.Contains(prefix, StringComparison.OrdinalIgnoreCase)) |
| 182 | return 2; |
| 183 | return int.MaxValue; |
| 184 | } |
| 185 | |
| 186 | private static void AppendTreeNode( |
| 187 | StringBuilder sb, |
| 188 | ChatThreadNode node, |
| 189 | IReadOnlyDictionary<Guid, ChatThreadNode> byId, |
| 190 | IReadOnlyDictionary<Guid, int> msgCounts, |
| 191 | string prefix, |
| 192 | bool isLast) |
| 193 | { |
| 194 | msgCounts.TryGetValue(node.ThreadId, out var count); |
| 195 | var branch = isLast ? "└─ " : "├─ "; |
| 196 | var flags = FormatFlags(node); |
| 197 | var flagText = flags.Length == 0 ? "" : " [" + flags + "]"; |
| 198 | sb.AppendLine($"{prefix}{branch}{node.Title}{flagText} ({count} сообщ.)"); |
| 199 | |
| 200 | var childPrefix = prefix + (isLast ? " " : "│ "); |
| 201 | var children = byId.Values |
| 202 | .Where(t => t.ParentThreadId == node.ThreadId) |
| 203 | .OrderBy(t => t.Order) |
| 204 | .ToList(); |
| 205 | for (var i = 0; i < children.Count; i++) |
| 206 | AppendTreeNode(sb, children[i], byId, msgCounts, childPrefix, i == children.Count - 1); |
| 207 | } |
| 208 | |
| 209 | private static void AppendPickerTreeNode( |
| 210 | List<PickerRow> rows, |
| 211 | ChatThreadNode node, |
| 212 | IReadOnlyDictionary<Guid, ChatThreadNode> byId, |
| 213 | IReadOnlyDictionary<Guid, int> messageCounts, |
| 214 | int depth, |
| 215 | bool isLast, |
| 216 | string prefix) |
| 217 | { |
| 218 | rows.Add(new PickerRow(node.ThreadId, prefix + node.Title, FormatMeta(node, messageCounts), depth)); |
| 219 | var children = byId.Values |
| 220 | .Where(t => t.ParentThreadId == node.ThreadId) |
| 221 | .OrderBy(t => t.Order) |
| 222 | .ToList(); |
| 223 | for (var i = 0; i < children.Count; i++) |
| 224 | { |
| 225 | var childPrefix = prefix + (isLast ? " " : "│ "); |
| 226 | AppendPickerTreeNode(rows, children[i], byId, messageCounts, depth + 1, i == children.Count - 1, childPrefix); |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |