Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Models.AgentChat;
3
4namespace CascadeIDE.Features.Chat;
5
6public partial class ChatPanelViewModel
7{
8 private readonly Dictionary<Guid, string> _threadDisplayTitles = new();
9 private readonly List<ChatThreadForkRecord> _threadForks = [];
10
11 private TopicPickerPresentation _topicPickerPresentation;
12
13 public void SetTopicPickerPresentation(TopicPickerPresentation presentation)
14 {
15 _topicPickerPresentation = presentation;
16 RefreshChatSurfaceSnapshot();
17 }
18
19 private IReadOnlyDictionary<Guid, string> BuildThreadDisplayTitles() => _threadDisplayTitles;
20
21 private void ApplyThreadTitlesFromMetadata(ChatSessionMetadata meta)
22 {
23 _threadDisplayTitles.Clear();
24 if (meta.ThreadTitles is null)
25 return;
26
27 foreach (var pair in meta.ThreadTitles)
28 {
29 if (!Guid.TryParse(pair.Key, out var threadId))
30 continue;
31 if (string.IsNullOrWhiteSpace(pair.Value))
32 continue;
33 _threadDisplayTitles[threadId] = pair.Value.Trim();
34 }
35 }
36
37 private async Task PersistThreadTitlesAsync()
38 {
39 try
40 {
41 var meta = await _sessionStore.LoadOrCreateMetadataAsync(_sessionId, CancellationToken.None).ConfigureAwait(false);
42 var titles = _threadDisplayTitles.Count == 0
43 ? null
44 : _threadDisplayTitles.ToDictionary(static kv => kv.Key.ToString("N"), static kv => kv.Value, StringComparer.OrdinalIgnoreCase);
45 var updated = meta with
46 {
47 ThreadTitles = titles,
48 SchemaVersion = Math.Max(meta.SchemaVersion, 2),
49 UpdatedAtUtc = DateTimeOffset.UtcNow,
50 };
51 await _sessionStore.SaveMetadataAsync(updated, CancellationToken.None).ConfigureAwait(false);
52 }
53 catch
54 {
55 // best-effort
56 }
57 }
58
59 /// <summary>Новая ветка: следующее user-сообщение получит <see cref="ChatMessageViewModel.ParentMessageId"/>, если задано.</summary>
60 public string ForkThread(Guid? parentMessageId, string? displayTitle = null)
61 {
62 var previous = _activeThreadId;
63 if (previous == Guid.Empty)
64 previous = _mainThreadId;
65 _activeThreadId = Guid.NewGuid();
66 _pendingParentForNextMessage = parentMessageId;
67 RecordThreadFork(_activeThreadId, previous, parentMessageId);
68 ApplyDisplayTitleForThread(_activeThreadId, displayTitle);
69 SelectedChatThreadId = _activeThreadId;
70 _ = PersistEventAsync(
71 ChatHistoryEventKind.ThreadForked,
72 ChatHistoryPayloadMapping.ToThreadForkedPayload(_activeThreadId, previous, parentMessageId),
73 envelopeThreadId: _activeThreadId);
74 RefreshChatSurfaceSnapshot();
75 return "OK";
76 }
77
78 /// <summary>Новая ветка с явным заголовком (slash <c>/topic create</c>, <c>/card</c>).</summary>
79 public TopicCreateResult CreateTopicWithTitle(string? title)
80 {
81 if (string.IsNullOrWhiteSpace(title))
82 return TopicCreateResult.Fail("Укажи заголовок: /topic create <название>");
83
84 var trimmed = title.Trim();
85 var forkResult = ForkThread(parentMessageId: null, displayTitle: trimmed);
86 if (!string.Equals(forkResult, "OK", StringComparison.Ordinal))
87 return TopicCreateResult.Fail(forkResult);
88
89 _topicPickerPresentation = TopicPickerPresentation.None;
90 IsChatOverviewMode = false;
91 RefreshChatSurfaceSnapshot();
92 _ = Harness.OnTopicForkedAsync();
93 UiScheduler.Default.Post(ApplyTopicForkBriefToComposer);
94 return TopicCreateResult.Ok($"Создана тема: {trimmed}");
95 }
96
97 /// <summary>Переименовать тему (slash <c>/topic rename</c>, Navigator, вкладка). Пустой <paramref name="threadId"/> — выбранная.</summary>
98 public TopicRenameResult RenameTopicWithTitle(string? title, Guid? threadId = null)
99 {
100 if (string.IsNullOrWhiteSpace(title))
101 return TopicRenameResult.Fail("Укажи название: /topic rename <название>");
102
103 var id = threadId is { } explicitId && explicitId != Guid.Empty
104 ? explicitId
105 : SelectedChatThreadId;
106 if (id == Guid.Empty)
107 id = _activeThreadId != Guid.Empty ? _activeThreadId : _mainThreadId;
108 if (id == Guid.Empty)
109 return TopicRenameResult.Fail("Нет активной темы для переименования.");
110
111 if (!ChatSurfaceSnapshot.State.Threads.Any(t => t.ThreadId == id))
112 return TopicRenameResult.Fail("Тема не найдена в сессии.");
113
114 var trimmed = title.Trim();
115 ApplyDisplayTitleForThread(id, trimmed);
116 if (SelectedChatThreadId != id)
117 SelectedChatThreadId = id;
118 IsChatOverviewMode = false;
119 RefreshChatSurfaceSnapshot();
120 return TopicRenameResult.Ok($"Тема переименована: {trimmed}");
121 }
122
123 /// <summary>Заголовок темы из snapshot (для диалога переименования).</summary>
124 public string? TryGetThreadTitleForRename(Guid threadId)
125 {
126 if (threadId == Guid.Empty)
127 return null;
128
129 foreach (var t in ChatSurfaceSnapshot.State.Threads)
130 {
131 if (t.ThreadId == threadId)
132 return t.Title;
133 }
134
135 return null;
136 }
137
138 private void RecordThreadFork(Guid newThreadId, Guid previousThreadId, Guid? parentMessageId)
139 {
140 if (newThreadId == Guid.Empty)
141 return;
142
143 _threadForks.RemoveAll(f => f.NewThreadId == newThreadId);
144 _threadForks.Add(new ChatThreadForkRecord(newThreadId, previousThreadId, parentMessageId));
145 }
146
147 private void ApplyDisplayTitleForThread(Guid threadId, string? displayTitle)
148 {
149 if (string.IsNullOrWhiteSpace(displayTitle))
150 {
151 ThreadBranchHint = $"Ветка {threadId:N}";
152 return;
153 }
154
155 var trimmed = displayTitle.Trim();
156 _threadDisplayTitles[threadId] = trimmed;
157 ThreadBranchHint = trimmed;
158 _ = PersistThreadTitlesAsync();
159 }
160
161 partial void OnSelectedChatThreadIdChanged(Guid value)
162 {
163 if (_topicPickerPresentation != TopicPickerPresentation.None)
164 _topicPickerPresentation = TopicPickerPresentation.None;
165
166 _ = MaybeMaterializeContextCardOnWorklineSwitchAsync();
167 RefreshChatSurfaceSnapshot();
168 }
169}
170
View only · write via MCP/CIDE