Forge
csharpdeeb25a2
1using System.Collections.Specialized;
2using System.ComponentModel;
3using Avalonia;
4using Avalonia.Controls;
5using Avalonia.Input;
6using Avalonia.Layout;
7using Avalonia.Media;
8using Avalonia.Interactivity;
9using Avalonia.Threading;
10using Avalonia.VisualTree;
11using CascadeIDE.Features.Chat;
12using CascadeIDE.Models;
13using CascadeIDE.Views.Chat;
14using CascadeIDE.Views.SkiaKit;
15using CommunityToolkit.Mvvm.Input;
16
17namespace CascadeIDE.Views;
18
19public partial class ChatPanelView : UserControl
20{
21 private ChatPanelViewModel? _subscribedVm;
22
23 public ChatPanelView()
24 {
25 InitializeComponent();
26 WireIntercomSurface(IntercomSkiaSurface);
27 IntercomSkiaSurface.ComposerDraftChanged += OnIntercomComposerDraftChanged;
28 IntercomSkiaSurface.CommandLineDraftChanged += OnIntercomCommandLineDraftChanged;
29 IntercomSkiaSurface.NavigatorSearchDraftChanged += OnIntercomNavigatorSearchDraftChanged;
30 Loaded += OnLoaded;
31 DataContextChanged += OnDataContextChanged;
32 }
33
34 private void OnLoaded(object? sender, RoutedEventArgs e) =>
35 TryApplyPanelFonts();
36
37 private void OnDataContextChanged(object? sender, EventArgs e)
38 {
39 if (_subscribedVm is not null)
40 {
41 _subscribedVm.IntercomPanelFontsChanged -= OnIntercomPanelFontsChanged;
42 _subscribedVm.IntercomTciChromeChanged -= OnIntercomTciChromeChanged;
43 _subscribedVm.ComposerPopupSuggestions.CollectionChanged -= OnComposerPopupSuggestionsChanged;
44 _subscribedVm.PropertyChanged -= OnChatPanelVmPropertyChanged;
45 }
46
47 _subscribedVm = DataContext as ChatPanelViewModel;
48 if (_subscribedVm is not null)
49 {
50 _subscribedVm.IntercomPanelFontsChanged += OnIntercomPanelFontsChanged;
51 _subscribedVm.IntercomTciChromeChanged += OnIntercomTciChromeChanged;
52 _subscribedVm.ComposerPopupSuggestions.CollectionChanged += OnComposerPopupSuggestionsChanged;
53 _subscribedVm.PropertyChanged += OnChatPanelVmPropertyChanged;
54 }
55
56 TryApplyPanelFonts();
57 ApplyIntercomTciChrome();
58 }
59
60 private void OnIntercomComposerDraftChanged(object? sender, EventArgs e)
61 {
62 if (DataContext is not ChatPanelViewModel vm)
63 return;
64
65 var text = IntercomSkiaSurface.ComposerText ?? "";
66 var caret = Math.Clamp(IntercomSkiaSurface.ComposerCaretIndex, 0, text.Length);
67 if (vm.ChatComposerCaretIndex != caret)
68 vm.ChatComposerCaretIndex = caret;
69 if (!string.Equals(vm.ChatInput, text, StringComparison.Ordinal))
70 vm.ChatInput = text;
71 vm.RefreshComposerAutocomplete(text, caret);
72 vm.RefreshComposerSlashPreview(text, caret);
73 }
74
75 private void OnIntercomCommandLineDraftChanged(object? sender, EventArgs e)
76 {
77 if (DataContext is not ChatPanelViewModel vm || !vm.ShowIntercomCockpitCommandLine)
78 return;
79
80 var text = IntercomSkiaSurface.CommandLineText ?? "/";
81 var caret = Math.Clamp(IntercomSkiaSurface.CommandLineCaretIndex, 0, text.Length);
82 if (vm.CockpitCommandLineCaretIndex != caret)
83 vm.CockpitCommandLineCaretIndex = caret;
84 if (!string.Equals(vm.CockpitCommandLineText, text, StringComparison.Ordinal))
85 vm.CockpitCommandLineText = text;
86 vm.RefreshCockpitCommandLineAutocomplete(text, caret);
87 }
88
89 private void OnIntercomNavigatorSearchDraftChanged(object? sender, EventArgs e)
90 {
91 if (DataContext is not ChatPanelViewModel vm)
92 return;
93
94 var query = IntercomSkiaSurface.TopicNavigatorSearchQuery ?? "";
95 if (!string.Equals(vm.TopicNavigatorSearchQuery, query, StringComparison.Ordinal))
96 vm.TopicNavigatorSearchQuery = query;
97 }
98
99 private void OnComposerPopupSuggestionsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
100 IntercomSkiaSurface.InvalidateVisual();
101
102 private void OnChatPanelVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
103 {
104 if (e.PropertyName is nameof(ChatPanelViewModel.IntercomComposerPlaceholder)
105 or nameof(ChatPanelViewModel.IsChatLoading)
106 or nameof(ChatPanelViewModel.FollowUpQueueCount))
107 IntercomSkiaSurface.InvalidateVisual();
108
109 if (e.PropertyName is nameof(ChatPanelViewModel.ChatInput)
110 or nameof(ChatPanelViewModel.IsChatLoading)
111 or nameof(ChatPanelViewModel.FollowUpQueueCount))
112 _subscribedVm?.SendChatCommand.NotifyCanExecuteChanged();
113
114 if (e.PropertyName is nameof(ChatPanelViewModel.ChatSlashPathPrefix)
115 or nameof(ChatPanelViewModel.ChatSlashNextStepLabel)
116 or nameof(ChatPanelViewModel.ChatSlashBreadcrumb))
117 IntercomSkiaSurface.InvalidateVisual();
118 }
119
120 private void OnIntercomPanelFontsChanged(object? sender, IntercomFontsSettings fonts) =>
121 ChatPanelTypographyApplier.Apply(this, fonts);
122
123 private void OnIntercomTciChromeChanged(object? sender, EventArgs e) => ApplyIntercomTciChrome();
124
125 private void ApplyIntercomTciChrome()
126 {
127 if (DataContext is not ChatPanelViewModel vm)
128 return;
129
130 SkiaSlashCommandChip.ConfigureIconPlacement(vm.IntercomTciValidationIcon);
131 IntercomSkiaSurface.InvalidateVisual();
132 }
133
134 private void TryApplyPanelFonts()
135 {
136 if (DataContext is not ChatPanelViewModel vm)
137 return;
138
139 var fonts = vm.IntercomFonts;
140 ChatPanelTypographyApplier.Apply(this, fonts);
141 Dispatcher.UIThread.Post(
142 () => ChatPanelTypographyApplier.Apply(this, fonts),
143 DispatcherPriority.Loaded);
144 }
145
146 private void WireIntercomSurface(SkiaChatSurfaceControl surface)
147 {
148 surface.SendRequested += (_, _) =>
149 {
150 if (DataContext is ChatPanelViewModel vm)
151 TryExecuteSendChat(vm, surface);
152 };
153
154 surface.ThinkingToggleRequested += (_, messageIndex) =>
155 {
156 if (DataContext is not ChatPanelViewModel vm)
157 return;
158 vm.SelectedMessageIndex = messageIndex;
159 _ = vm.ToggleSelectedThinkingDetails();
160 };
161
162 surface.AttachmentRevealRequested += (_, e) =>
163 {
164 if (DataContext is not ChatPanelViewModel vm)
165 return;
166 _ = vm.RevealAttachmentFromFeedAsync(e.Anchor, e.Select, messageIndex: e.MessageIndex);
167 };
168
169 surface.MessageSelectContextRequested += (_, messageIndex) =>
170 {
171 if (DataContext is not ChatPanelViewModel vm)
172 return;
173 showMessageSelectContextMenu(surface, vm, messageIndex);
174 };
175
176 surface.TopicCreateRequested += (_, _) =>
177 {
178 if (DataContext is not ChatPanelViewModel vm)
179 return;
180 var n = vm.ChatSurfaceSnapshot.Layout.Overview.Count + 1;
181 _ = vm.CreateTopicWithTitle($"Тема {n}");
182 };
183
184 surface.TopicNavigatorToggleRequested += (_, _) =>
185 {
186 if (DataContext is ChatPanelViewModel vm)
187 vm.ToggleIntercomTopicNavigator();
188 };
189
190 surface.TopicRenameRequested += (_, e) =>
191 _ = HandleTopicRenameRequestAsync(surface, e);
192
193 surface.IntercomAttachDropped += (_, e) =>
194 {
195 if (DataContext is not ChatPanelViewModel vm)
196 return;
197
198 vm.ClarificationStatusText = vm.ApplyAttachDropPayload(e.Payload);
199 surface.Focus();
200 };
201
202 surface.ComposerKeyDown += (_, e) =>
203 {
204 if (DataContext is not ChatPanelViewModel vm)
205 return;
206
207 var handle = vm.TryHandleSlashComposerKey(e.Kind, e.KeyEvent);
208 if (!handle.Handled)
209 {
210 // Перенос строки только по явному chord из настроек (разделение с отправкой, как в мессенджерах).
211 if (e.Kind == IntercomComposerKeyKind.Enter
212 && !vm.IsCockpitCommandLineOpen
213 && e.KeyEvent is { } nk
214 && ChatSendKeyMatcher.Matches(nk, vm.GetComposerNewLineKey()))
215 {
216 var text = surface.ComposerText ?? vm.ChatInput;
217 var caret = Math.Clamp(surface.ComposerCaretIndex, 0, text.Length);
218 if (vm.TryInsertComposerNewLineAtCaret(text, caret, out var next, out var newCaret))
219 {
220 surface.ComposerText = next;
221 surface.ComposerCaretIndex = newCaret;
222 }
223 }
224
225 return;
226 }
227
228 if (handle.SyncCommandLineFromViewModel)
229 {
230 if (vm.ShowIntercomCockpitCommandLine)
231 syncCommandLineFromViewModel(surface, vm);
232 else if (!vm.IsCockpitCommandLineOpen)
233 surface.CommandLineText = "/";
234 }
235
236 if (handle.SyncComposerCaretFromViewModel)
237 syncComposerFromViewModel(surface, vm);
238
239 if (handle.RunCockpitCommit)
240 _ = vm.TryCommitCockpitCommandLineAsync();
241
242 if (handle.RunSendChat)
243 TryExecuteSendChat(vm, surface);
244 };
245 }
246
247 private async Task HandleTopicRenameRequestAsync(SkiaChatSurfaceControl surface, TopicRenameRequestEventArgs e)
248 {
249 if (DataContext is not ChatPanelViewModel vm)
250 return;
251
252 var owner = TopLevel.GetTopLevel(this) as Window;
253 if (owner is null)
254 return;
255
256 if (e.ShowContextMenu)
257 {
258 showTopicRenameContextMenu(surface, vm, owner, e.ThreadId);
259 return;
260 }
261
262 await RunTopicRenameDialogAsync(surface, vm, owner, e.ThreadId).ConfigureAwait(true);
263 }
264
265 private void showTopicRenameContextMenu(
266 SkiaChatSurfaceControl surface,
267 ChatPanelViewModel vm,
268 Window owner,
269 Guid threadId)
270 {
271 var current = vm.TryGetThreadTitleForRename(threadId) ?? "";
272 var menu = new ContextMenu();
273 var renameItem = new MenuItem { Header = "Переименовать…" };
274 renameItem.Click += async (_, _) =>
275 {
276 await RunTopicRenameDialogAsync(surface, vm, owner, threadId, current).ConfigureAwait(true);
277 };
278 menu.Items.Add(renameItem);
279 menu.Open(surface);
280 }
281
282 private static async Task RunTopicRenameDialogAsync(
283 SkiaChatSurfaceControl surface,
284 ChatPanelViewModel vm,
285 Window owner,
286 Guid threadId,
287 string? presetTitle = null)
288 {
289 var current = presetTitle ?? vm.TryGetThreadTitleForRename(threadId) ?? "";
290 var newTitle = await TopicRenameDialog.ShowAsync(owner, current).ConfigureAwait(true);
291 if (newTitle is null)
292 return;
293
294 var result = vm.RenameTopicWithTitle(newTitle, threadId);
295 if (!result.Success)
296 {
297 await ShowTopicRenameErrorAsync(owner, result.Message).ConfigureAwait(true);
298 return;
299 }
300
301 surface.DetailThreadId = vm.SelectedChatThreadId;
302 surface.InvalidateVisual();
303 }
304
305 private static async Task ShowTopicRenameErrorAsync(Window owner, string message)
306 {
307 var ok = new Button { Content = "OK", MinWidth = 80, HorizontalAlignment = HorizontalAlignment.Right };
308 var dlg = new Window
309 {
310 Title = "Переименование темы",
311 Width = 360,
312 Height = 140,
313 CanResize = false,
314 WindowStartupLocation = WindowStartupLocation.CenterOwner,
315 Content = new StackPanel
316 {
317 Margin = new Thickness(16),
318 Spacing = 12,
319 Children =
320 {
321 new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
322 ok,
323 },
324 },
325 };
326 ok.Click += (_, _) => dlg.Close();
327 await dlg.ShowDialog(owner);
328 }
329
330 private static void TryExecuteSendChat(ChatPanelViewModel vm, SkiaChatSurfaceControl surface)
331 {
332 SyncComposerDraftToViewModel(vm, surface);
333
334 if (vm.SendChatCommand is IAsyncRelayCommand async && async.CanExecute(null))
335 _ = async.ExecuteAsync(null);
336 else if (vm.SendChatCommand is IRelayCommand relay && relay.CanExecute(null))
337 relay.Execute(null);
338 }
339
340 /// <summary>Свести Skia composer (и IME preedit) в VM перед отправкой — иначе ChatInput может отставать.</summary>
341 private static void SyncComposerDraftToViewModel(ChatPanelViewModel vm, SkiaChatSurfaceControl surface)
342 {
343 var text = surface.ComposerText ?? "";
344 var caret = Math.Clamp(surface.ComposerCaretIndex, 0, text.Length);
345 if (!string.IsNullOrEmpty(surface.ComposerPreeditText))
346 {
347 text = text.Insert(caret, surface.ComposerPreeditText);
348 caret += surface.ComposerPreeditText.Length;
349 surface.ComposerPreeditText = null;
350 surface.ComposerText = text;
351 surface.ComposerCaretIndex = caret;
352 }
353
354 if (vm.ChatComposerCaretIndex != caret)
355 vm.ChatComposerCaretIndex = caret;
356 if (!string.Equals(vm.ChatInput, text, StringComparison.Ordinal))
357 vm.ChatInput = text;
358 }
359
360 private static void syncCommandLineFromViewModel(SkiaChatSurfaceControl surface, ChatPanelViewModel vm)
361 {
362 surface.CommandLineText = vm.CockpitCommandLineText;
363 surface.CommandLineCaretIndex = vm.CockpitCommandLineCaretIndex;
364 surface.CollapseCommandLineSelection();
365 surface.NotifyCommandLineImeStateChanged();
366 }
367
368 private static void syncComposerFromViewModel(SkiaChatSurfaceControl surface, ChatPanelViewModel vm)
369 {
370 surface.ComposerPreeditText = null;
371 surface.ComposerText = vm.ChatInput;
372 surface.ComposerCaretIndex = vm.ChatComposerCaretIndex;
373 surface.CollapseComposerSelection();
374 surface.NotifyComposerImeStateChanged();
375 }
376
377 private static void showMessageSelectContextMenu(
378 SkiaChatSurfaceControl surface,
379 ChatPanelViewModel vm,
380 int messageIndex)
381 {
382 var label = vm.TryGetFeedOrdinalForMessageIndex(messageIndex, out var ordinal)
383 ? $"Выбрать сообщение #{ordinal}"
384 : "Выбрать сообщение";
385
386 var menu = new ContextMenu();
387 var item = new MenuItem { Header = label };
388 item.Click += (_, _) =>
389 {
390 if (ordinal > 0)
391 {
392 _ = vm.SelectMessageByOrdinalInDetailLane(ordinal);
393 surface.SelectedMessageIndex = vm.SelectedMessageIndex;
394 return;
395 }
396
397 _ = vm.SelectMessageByIndex(messageIndex);
398 surface.SelectedMessageIndex = vm.SelectedMessageIndex;
399 };
400 menu.Items.Add(item);
401
402 if (vm.TryGetMessageContent(messageIndex, out _))
403 {
404 var preview = new MenuItem { Header = "Открыть как Markdown" };
405 preview.Click += (_, _) => vm.OpenMessageMarkdownPreview(messageIndex);
406 menu.Items.Add(preview);
407 }
408
409 menu.Open(surface);
410 }
411
412 internal bool TryHitComposerAt(Point windowLocalPoint)
413 {
414 if (TopLevel.GetTopLevel(this) is not Visual window)
415 return false;
416
417 var chatPt = this.TranslatePoint(windowLocalPoint, window);
418 if (chatPt is null)
419 return false;
420
421 var surfacePt = IntercomSkiaSurface.TranslatePoint(chatPt.Value, this);
422 if (surfacePt is null)
423 return false;
424
425 return IntercomSkiaSurface.TryHitComposerBounds((float)surfacePt.Value.X, (float)surfacePt.Value.Y);
426 }
427}
428
View only · write via MCP/CIDE