Forge
csharp4405de34
1#nullable enable
2using CascadeIDE.Models.AgentChat;
3using CascadeIDE.Models.Intercom;
4
5namespace CascadeIDE.Features.Chat;
6
7public interface IChatSurfaceIntentStage
8{
9 ChatSurfaceState Resolve(in ChatSurfaceIntent intent);
10}
11
12public interface IChatSurfaceDeclutterStage
13{
14 ChatSurfaceState Apply(in ChatSurfaceState state);
15}
16
17public interface IChatSurfaceLayoutStage
18{
19 ChatSurfaceLayout Layout(in ChatSurfaceState state, IReadOnlyList<ChatSedmTimelineEntry>? sedmTimelineEntries = null);
20}
21
22/// <summary>Intent stage: строит first-class thread/message/confirmation graph из канонического chat intent.</summary>
23public sealed partial class ChatSurfaceIntentStage : IChatSurfaceIntentStage
24{
25 public ChatSurfaceState Resolve(in ChatSurfaceIntent intent)
26 {
27 var currentIntent = intent;
28 var messages = currentIntent.Messages
29 .Select(message => new ChatMessageNode(
30 message.MessageId,
31 NodeIdForMessage(message.MessageId),
32 message.ThreadId,
33 message.ParentMessageId,
34 message.MessageIndex,
35 NormalizeRole(message.Role),
36 message.Content ?? "",
37 message.MessageIndex == currentIntent.SelectedMessageIndex,
38 StartsBranch: false,
39 message.SlashCommandPath,
40 message.SlashCommandArgs,
41 message.SlashCommandStatus,
42 message.Attachments,
43 message.Audience))
44 .OrderBy(message => message.MessageIndex)
45 .ToList();
46
47 if (messages.Count == 0)
48 return BuildStateWithoutMessages(currentIntent);
49
50 var messageById = messages.ToDictionary(message => message.MessageId, message => message);
51 var parentThreadByThread = new Dictionary<Guid, Guid?>();
52 var parentMessageByThread = new Dictionary<Guid, Guid?>();
53 foreach (var message in messages)
54 {
55 if (message.ParentMessageId is not { } parentMessageId)
56 continue;
57 if (!messageById.TryGetValue(parentMessageId, out var parent))
58 continue;
59 if (parent.ThreadId == message.ThreadId)
60 continue;
61 parentThreadByThread[message.ThreadId] = parent.ThreadId;
62 parentMessageByThread[message.ThreadId] = parent.MessageId;
63 }
64
65 ApplyForkHints(currentIntent, parentThreadByThread);
66
67 var firstMessageByThread = messages
68 .GroupBy(message => message.ThreadId)
69 .ToDictionary(group => group.Key, group => group.OrderBy(message => message.MessageIndex).First());
70
71 var threads = firstMessageByThread
72 .OrderBy(pair => pair.Value.MessageIndex)
73 .Select(pair =>
74 {
75 var threadId = pair.Key;
76 parentThreadByThread.TryGetValue(threadId, out var parentThreadId);
77 parentMessageByThread.TryGetValue(threadId, out var parentMessageId);
78 return new ChatThreadNode(
79 threadId,
80 NodeIdForThread(threadId),
81 BuildThreadTitle(
82 threadId,
83 pair.Value,
84 currentIntent.MainThreadId,
85 currentIntent.ThreadBranchHint,
86 currentIntent.ThreadDisplayTitles),
87 IsMainThread: threadId == currentIntent.MainThreadId,
88 IsActive: threadId == currentIntent.ActiveThreadId,
89 ParentThreadId: parentThreadId,
90 ForkedFromMessageId: parentMessageId,
91 Depth: 0,
92 Order: pair.Value.MessageIndex);
93 })
94 .ToDictionary(thread => thread.ThreadId);
95
96 var orderBase = messages.Count > 0 ? messages.Max(m => m.MessageIndex) + 1 : 0;
97 MergeSyntheticThreads(threads, currentIntent, parentThreadByThread, orderBase);
98
99 foreach (var threadId in threads.Keys.ToList())
100 {
101 threads[threadId] = threads[threadId] with
102 {
103 Depth = ComputeDepth(threadId, threads)
104 };
105 }
106
107 messages = messages
108 .Select(message => message with
109 {
110 StartsBranch = message.ParentMessageId is { } parentId
111 && messageById.TryGetValue(parentId, out var parent)
112 && parent.ThreadId != message.ThreadId
113 })
114 .ToList();
115
116 var confirmations = new List<ChatConfirmationNode>();
117 var edges = new List<ChatDecisionEdge>();
118
119 foreach (var message in messages)
120 {
121 if (message.ParentMessageId is { } parentMessageId && messageById.ContainsKey(parentMessageId))
122 {
123 edges.Add(new ChatDecisionEdge(
124 NodeIdForMessage(parentMessageId),
125 message.NodeId,
126 message.StartsBranch ? "fork" : "reply"));
127 }
128 }
129
130 if (currentIntent.ActiveClarificationBatch is { } clarification)
131 {
132 var activeThreadId = currentIntent.ActiveThreadId != Guid.Empty ? currentIntent.ActiveThreadId : currentIntent.MainThreadId;
133 var confirmation = new ChatConfirmationNode(
134 NodeIdForClarification(clarification.Id),
135 activeThreadId,
136 clarification.Id,
137 string.IsNullOrWhiteSpace(clarification.Title) ? "Уточнения к текущему шагу" : clarification.Title.Trim(),
138 BuildClarificationBody(clarification),
139 clarification.Items.Count,
140 IsActive: true,
141 IsResolved: false);
142 confirmations.Add(confirmation);
143
144 var lastMessageInThread = messages
145 .Where(message => message.ThreadId == activeThreadId)
146 .OrderByDescending(message => message.MessageIndex)
147 .FirstOrDefault();
148 if (lastMessageInThread is not null)
149 {
150 edges.Add(new ChatDecisionEdge(
151 lastMessageInThread.NodeId,
152 confirmation.NodeId,
153 "ask"));
154 }
155 }
156
157 var orderedThreads = threads.Values.OrderBy(thread => thread.Order).ToList();
158 var activeThreadLabel = orderedThreads.FirstOrDefault(thread => thread.IsActive)?.Title
159 ?? orderedThreads.FirstOrDefault(thread => thread.IsMainThread)?.Title
160 ?? "Chat";
161
162 return new ChatSurfaceState(
163 orderedThreads,
164 messages,
165 confirmations,
166 edges.OrderBy(edge => edge.FromNodeId, StringComparer.Ordinal).ThenBy(edge => edge.ToNodeId, StringComparer.Ordinal).ToList(),
167 currentIntent.ActiveThreadId,
168 activeThreadLabel);
169 }
170
171 private static int ComputeDepth(Guid threadId, IReadOnlyDictionary<Guid, ChatThreadNode> threads)
172 {
173 var seen = new HashSet<Guid>();
174 var depth = 0;
175 var current = threadId;
176 while (threads.TryGetValue(current, out var thread) && thread.ParentThreadId is { } parent)
177 {
178 if (!seen.Add(parent))
179 break;
180 depth++;
181 current = parent;
182 }
183
184 return depth;
185 }
186
187 private static string BuildThreadTitle(
188 Guid threadId,
189 ChatMessageNode firstMessage,
190 Guid mainThreadId,
191 string? branchHint,
192 IReadOnlyDictionary<Guid, string>? displayTitles)
193 {
194 if (displayTitles is not null
195 && displayTitles.TryGetValue(threadId, out var custom)
196 && !string.IsNullOrWhiteSpace(custom))
197 {
198 return TrimForTitle(custom);
199 }
200
201 if (threadId == mainThreadId)
202 return "Основная тема";
203
204 var basis = string.IsNullOrWhiteSpace(firstMessage.Content)
205 ? branchHint
206 : firstMessage.Content.Trim();
207 if (string.IsNullOrWhiteSpace(basis))
208 basis = $"Ветка {threadId:N}"[..12];
209
210 return TrimForTitle(basis);
211 }
212
213 private static string BuildClarificationBody(ClarificationBatch batch)
214 {
215 if (batch.Items.Count == 0)
216 return "Пакет уточнений пуст.";
217
218 return string.Join(Environment.NewLine, batch.Items.Select(item => $"- {item.Prompt.Trim()}"));
219 }
220
221 private static string TrimForTitle(string text)
222 {
223 text = text.Replace("\r", " ").Replace("\n", " ").Trim();
224 return text.Length <= 48 ? text : text[..48].TrimEnd() + "...";
225 }
226
227 private static string NormalizeRole(string role) =>
228 string.IsNullOrWhiteSpace(role) ? "assistant" : role.Trim().ToLowerInvariant();
229
230 public static string NodeIdForThread(Guid threadId) => $"thread:{threadId:N}";
231 public static string NodeIdForMessage(Guid messageId) => $"message:{messageId:N}";
232 public static string NodeIdForClarification(Guid batchId) => $"clarification:{batchId:N}";
233}
234
235/// <summary>Declutter v1: keep all semantic nodes, but normalize thread ordering to active-first focus in overview.</summary>
236public sealed class ChatSurfaceDeclutterStage : IChatSurfaceDeclutterStage
237{
238 public ChatSurfaceState Apply(in ChatSurfaceState state)
239 {
240 if (state.Threads.Count == 0)
241 return state;
242
243 var threads = state.Threads
244 .OrderByDescending(thread => thread.IsActive)
245 .ThenBy(thread => thread.Depth)
246 .ThenBy(thread => thread.Order)
247 .ToList();
248
249 return state with { Threads = threads };
250 }
251}
252
253/// <summary>Layout stage: строит overview тредов и ленты карточек по каждой линии работы.</summary>
254public sealed class ChatSurfaceLayoutStage : IChatSurfaceLayoutStage
255{
256 public ChatSurfaceLayout Layout(in ChatSurfaceState state, IReadOnlyList<ChatSedmTimelineEntry>? sedmTimelineEntries = null)
257 {
258 var sedmByThread = (sedmTimelineEntries ?? [])
259 .GroupBy(entry => entry.WorklineId)
260 .ToDictionary(group => group.Key, group => group.OrderBy(e => e.Sequence).ToList());
261 var confirmationsByThread = state.Confirmations
262 .GroupBy(confirmation => confirmation.ThreadId)
263 .ToDictionary(group => group.Key, group => group.OrderBy(confirmation => confirmation.Title, StringComparer.Ordinal).ToList());
264
265 var messagesByThread = state.Messages
266 .GroupBy(message => message.ThreadId)
267 .ToDictionary(group => group.Key, group => group.OrderBy(message => message.MessageIndex).ToList());
268
269 var lanes = new List<ChatSurfaceLane>();
270 foreach (var thread in state.Threads
271 .OrderByDescending(thread => thread.IsActive)
272 .ThenBy(thread => thread.Depth)
273 .ThenBy(thread => thread.Order))
274 {
275 var entries = new List<ChatSurfaceEntry>();
276 if (messagesByThread.TryGetValue(thread.ThreadId, out var messages))
277 {
278 foreach (var message in messages)
279 {
280 entries.Add(new ChatSurfaceEntry(
281 ChatSurfaceEntryKind.Message,
282 message.NodeId,
283 BuildMessageTitle(message, thread),
284 message.Content,
285 !string.IsNullOrWhiteSpace(message.SlashCommandPath)
286 ? ChatMessageVisualRole.SlashCommand
287 : ChatMessageVisualRoleMapping.FromMessageRole(message.Role),
288 message.MessageIndex,
289 MessageIndex: message.MessageIndex,
290 IsSelected: message.IsSelected,
291 StartsBranch: message.StartsBranch,
292 SlashCommandPath: message.SlashCommandPath,
293 SlashCommandArgs: message.SlashCommandArgs,
294 SlashCommandStatus: message.SlashCommandStatus,
295 Attachments: message.Attachments,
296 Audience: message.Audience));
297 }
298 }
299
300 if (sedmByThread.TryGetValue(thread.ThreadId, out var sedmEntries))
301 {
302 var orderBase = entries.Count == 0 ? thread.Order : entries.Max(entry => entry.Order) + 1;
303 foreach (var sedm in sedmEntries)
304 {
305 entries.Add(new ChatSurfaceEntry(
306 ChatSurfaceEntryKind.SedmCard,
307 $"sedm:{sedm.EventId:N}",
308 sedm.Title,
309 sedm.Body,
310 sedm.VisualRole,
311 orderBase++,
312 IsPending: false));
313 }
314 }
315
316 if (confirmationsByThread.TryGetValue(thread.ThreadId, out var confirmations))
317 {
318 var orderBase = entries.Count == 0 ? thread.Order : entries.Max(entry => entry.Order) + 1;
319 foreach (var confirmation in confirmations)
320 {
321 entries.Add(new ChatSurfaceEntry(
322 ChatSurfaceEntryKind.Confirmation,
323 confirmation.NodeId,
324 confirmation.Title,
325 confirmation.Body,
326 confirmation.IsResolved
327 ? ChatMessageVisualRole.ClarificationResolved
328 : ChatMessageVisualRole.ClarificationPending,
329 orderBase++,
330 IsPending: confirmation.IsActive && !confirmation.IsResolved));
331 }
332 }
333
334 lanes.Add(new ChatSurfaceLane(thread, entries.OrderBy(entry => entry.Order).ToList()));
335 }
336
337 var overview = lanes
338 .Select(lane => new ChatThreadOverviewItem(
339 lane.Thread.ThreadId,
340 lane.Thread.Title,
341 BuildThreadSummary(lane),
342 lane.Thread.IsActive,
343 lane.Thread.IsMainThread,
344 lane.Thread.Depth,
345 lane.Entries.Count))
346 .ToList();
347
348 return new ChatSurfaceLayout(overview, lanes);
349 }
350
351 private static string BuildThreadSummary(ChatSurfaceLane lane)
352 {
353 var lastMeaningful = lane.Entries
354 .Where(entry => entry.Kind == ChatSurfaceEntryKind.Message)
355 .Where(entry => entry.VisualRole is ChatMessageVisualRole.User or ChatMessageVisualRole.Assistant)
356 .OrderByDescending(entry => entry.Order)
357 .FirstOrDefault();
358
359 if (lastMeaningful is null)
360 {
361 var pending = lane.Entries.FirstOrDefault(entry => entry.Kind == ChatSurfaceEntryKind.Confirmation && entry.IsPending);
362 if (pending is not null)
363 return TruncateSummary(pending.Body);
364
365 var any = lane.Entries
366 .Where(entry => entry.Kind == ChatSurfaceEntryKind.Message)
367 .OrderByDescending(entry => entry.Order)
368 .FirstOrDefault();
369 if (any is null)
370 return "Пока без сообщений";
371
372 return TruncateSummary(any.Body);
373 }
374
375 return TruncateSummary(lastMeaningful.Body);
376 }
377
378 private static string TruncateSummary(string text)
379 {
380 var normalized = string.Join(' ', (text ?? "").Replace('\r', ' ').Replace('\n', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries));
381 if (normalized.Length == 0)
382 return "Пока без текста";
383 const int maxLen = 160;
384 return normalized.Length <= maxLen ? normalized : normalized[..maxLen] + "…";
385 }
386
387 private static string BuildMessageTitle(ChatMessageNode message, ChatThreadNode thread)
388 {
389 if (!string.IsNullOrWhiteSpace(message.SlashCommandPath))
390 {
391 return message.Audience == IntercomMessageAudience.SelfOnly
392 ? "Локально"
393 : "Команда";
394 }
395
396 if (message.StartsBranch)
397 return message.Role == "user" ? "Новая ветка" : "Ответвление";
398 if (thread.IsMainThread && message.MessageIndex == 0)
399 return message.Role == "user" ? "Старт темы" : "Первый ответ";
400
401 return message.Role switch
402 {
403 "user" => "Ты",
404 "assistant" => "Агент",
405 "thinking" => "Размышление",
406 "tool" => "Инструмент",
407 "slash_command" => message.Audience == IntercomMessageAudience.SelfOnly
408 ? "Локально"
409 : "Команда",
410 _ => message.Role
411 };
412 }
413}
414
415public sealed class ChatSurfaceCompositor(
416 IChatSurfaceIntentStage? intentStage = null,
417 IChatSurfaceDeclutterStage? declutterStage = null,
418 IChatSurfaceLayoutStage? layoutStage = null)
419{
420 private readonly IChatSurfaceIntentStage _intentStage = intentStage ?? new ChatSurfaceIntentStage();
421 private readonly IChatSurfaceDeclutterStage _declutterStage = declutterStage ?? new ChatSurfaceDeclutterStage();
422 private readonly IChatSurfaceLayoutStage _layoutStage = layoutStage ?? new ChatSurfaceLayoutStage();
423
424 public ChatSurfaceSnapshot Compose(ChatSurfaceIntent intent)
425 {
426 var resolved = _intentStage.Resolve(intent);
427 var decluttered = _declutterStage.Apply(resolved);
428 var layout = _layoutStage.Layout(decluttered, intent.SedmTimelineEntries);
429 var spine = intent.ProductSpine ?? ChatProductSpine.Empty;
430 return new ChatSurfaceSnapshot(
431 decluttered,
432 layout,
433 spine,
434 ChatSedmScopeStrip.Empty,
435 intent.TopicPicker,
436 intent.HighlightedMessageIndices);
437 }
438}
439
View only · write via MCP/CIDE