diff --git a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs index 85cc8e014..b174182a9 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Xaml.Controls; using OpenClawTray.FunctionalUI.Hosting; using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace OpenClawTray.Chat; @@ -81,7 +82,10 @@ public sealed class MountedFunctionalChat(Border target, FunctionalHostControl h public OpenClawChatRoot ChatRoot => root; /// Push a picked file into the composer as a pending attachment. - public void AttachFile(ChatAttachment attachment) => root.OnFileAttached?.Invoke(attachment); + public void AttachFile(ChatAttachment attachment) => AttachFiles(new[] { attachment }); + + /// Push picked files into the composer as pending attachments. + public void AttachFiles(IReadOnlyList attachments) => root.OnFilesAttached?.Invoke(attachments); /// Push streaming voice transcript text into the composer. public void SetVoiceTranscript(string? text) => root.SetVoiceTranscript?.Invoke(text); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index 69776f58a..6770a4744 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -38,7 +38,7 @@ public sealed class OpenClawChatRoot : Component private readonly Action? _onSpeakerMuteChanged; private readonly bool _initialMuted; private readonly bool _isCompact; - private Action? _onFileAttached; + private Action>? _onFilesAttached; private Action? _setVoiceTranscript; private Action? _setVoiceAudioLevel; private Action? _scrollToBottomToken; @@ -56,12 +56,12 @@ public sealed class OpenClawChatRoot : Component /// /// Callback invoked by the host window/page after a file is selected. - /// Sets the pending attachment and triggers a re-render. + /// Appends pending attachments and triggers a re-render. /// - public Action? OnFileAttached + public Action>? OnFilesAttached { - get => _onFileAttached; - set => _onFileAttached = value; + get => _onFilesAttached; + set => _onFilesAttached = value; } /// @@ -109,7 +109,9 @@ public OpenClawChatRoot( public override Element Render() { - var pendingAttachment = UseState(null, threadSafe: true); + var pendingAttachments = UseState>(Array.Empty(), threadSafe: true); + var pendingAttachmentsRef = UseRef>(pendingAttachments.Value); + pendingAttachmentsRef.Current = pendingAttachments.Value; var speakerMuted = UseState(_initialMuted, threadSafe: true); var voiceTranscript = UseState(null, threadSafe: true); var voiceAudioLevel = UseState(0f, threadSafe: true); @@ -122,9 +124,21 @@ public override Element Render() // Cleared automatically when the next snapshot arrives. var firstSendInFlight = UseState(false, threadSafe: true); - // Wire the OnFileAttached callback so the host window/page can set the - // pending attachment after the file picker completes. - _onFileAttached = att => pendingAttachment.Set(att); + void SetPendingAttachments(IReadOnlyList attachments) + { + pendingAttachmentsRef.Current = attachments; + pendingAttachments.Set(attachments); + } + + // Wire the attachment callback so the host window/page can append + // pending attachments after the file picker completes. + _onFilesAttached = attachments => + { + if (attachments.Count == 0) + return; + + SetPendingAttachments(pendingAttachmentsRef.Current.Concat(attachments).ToArray()); + }; _setVoiceTranscript = voiceTranscript.Set; _setVoiceAudioLevel = voiceAudioLevel.Set; _scrollToBottomToken = () => scrollToBottomToken.Set(scrollToBottomToken.Value + 1); @@ -452,7 +466,7 @@ Element BuildLoadingElement() if (effectiveThread is { } t) { firstSendInFlight.Set(true); - OnSend(t.Id, suggestion, null); + OnSend(t.Id, suggestion, Array.Empty()); } }, suggestionsDisabled: firstSendInFlight.Value); } @@ -535,10 +549,10 @@ Element BuildLoadingElement() AvailableModels: snapshot.AvailableModels, CurrentModel: composerThread.Model, CurrentThinkingLevel: composerThread.ThinkingLevel, - OnSend: (msg, att) => + OnSend: (msg, attachments) => { - pendingAttachment.Set(null); - OnSend(composerThread.Id!, msg, att); + SetPendingAttachments(Array.Empty()); + OnSend(composerThread.Id!, msg, attachments); }, OnStop: () => OnStop(composerThread.Id!), OnChannelChanged: id => @@ -551,8 +565,8 @@ Element BuildLoadingElement() OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(composerThread.Id!, allowAll, ct)), OnVoiceRequest: _onVoiceRequest, OnAttachClick: _onAttachClick, - PendingAttachment: pendingAttachment.Value, - OnAttachmentRemoved: () => pendingAttachment.Set(null), + PendingAttachments: pendingAttachments.Value, + OnAttachmentRemoved: attachment => SetPendingAttachments(RemoveAttachment(pendingAttachmentsRef.Current, attachment)), IsSpeakerMuted: speakerMuted.Value, OnSpeakerToggle: () => { @@ -564,7 +578,7 @@ Element BuildLoadingElement() VoiceTranscript: voiceTranscript.Value, VoiceAudioLevel: voiceAudioLevel.Value, RegisterVoiceStarter: starter => TriggerVoiceRecording = starter, - OnAttachmentPasted: att => pendingAttachment.Set(att), + OnAttachmentPasted: att => SetPendingAttachments(pendingAttachmentsRef.Current.Concat(new[] { att }).ToArray()), ShowToolCalls: showToolCalls.Value, OnShowToolCallsChanged: visible => { @@ -837,18 +851,35 @@ private static Element PlaceholderEmptyThreadState(string connectionState) ); } - private void OnSend(string threadId, string message, ChatAttachment? attachment) + private void OnSend(string threadId, string message, IReadOnlyList attachments) { _scrollToBottomToken?.Invoke(); - IReadOnlyList? attachments = attachment is not null - ? new[] { attachment } - : null; - if (attachments is not null) - RunFireAndForget(ct => _provider.SendMessageAsync(threadId, message, ct, attachments)); + if (attachments.Count > 0) + RunFireAndForget(ct => _provider.SendMessageAsync(threadId, message, ct, attachments.ToArray())); else RunFireAndForget(ct => _provider.SendMessageAsync(threadId, message, ct)); } + private static IReadOnlyList RemoveAttachment( + IReadOnlyList attachments, + ChatAttachment attachment) + { + var next = new List(attachments.Count); + var removed = false; + foreach (var current in attachments) + { + if (!removed && ReferenceEquals(current, attachment)) + { + removed = true; + continue; + } + + next.Add(current); + } + + return removed ? next.ToArray() : attachments; + } + private void OnStop(string threadId) { RunFireAndForget(ct => _provider.StopResponseAsync(threadId, ct)); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 53b26b681..4d3e20fdb 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -8,6 +8,7 @@ using OpenClawTray.FunctionalUI; using OpenClawTray.FunctionalUI.Core; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Windows.UI; @@ -46,7 +47,7 @@ public record OpenClawComposerProps( string[] AvailableModels, string? CurrentModel, string? CurrentThinkingLevel, - Action OnSend, + Action> OnSend, Action OnStop, Action OnChannelChanged, Action OnModelChanged, @@ -54,8 +55,8 @@ public record OpenClawComposerProps( Action OnPermissionsChanged, Func>? OnVoiceRequest = null, Action? OnAttachClick = null, - ChatAttachment? PendingAttachment = null, - Action? OnAttachmentRemoved = null, + IReadOnlyList? PendingAttachments = null, + Action? OnAttachmentRemoved = null, bool IsSpeakerMuted = false, Action? OnSpeakerToggle = null, Action? OnSettingsClick = null, @@ -101,9 +102,16 @@ public override Element Render() // One-time hook flag for the TextBox Paste event so we don't re-attach // the handler on every re-render (Set() runs each render). var pasteHookedRef = UseRef(false); - // Cache the BitmapImage built for the current attachment so we rebuild - // it only when the attachment instance changes (not on every render). - var attachmentImageRef = UseRef<(ChatAttachment? Att, Microsoft.UI.Xaml.Media.Imaging.BitmapImage? Bmp)>((null, null)); + // Cache BitmapImages built for current attachments so we rebuild them + // only when an attachment is added or removed (not on every render). + var attachmentImagesRef = UseRef>(new()); + var pendingAttachments = Props.PendingAttachments ?? Array.Empty(); + var imageCache = attachmentImagesRef.Current; + foreach (var cachedAttachment in imageCache.Keys.ToArray()) + { + if (!pendingAttachments.Contains(cachedAttachment)) + imageCache.Remove(cachedAttachment); + } // Extracted voice-start action so it can be triggered programmatically (e.g. hotkey) Action startVoiceRecording = () => @@ -168,9 +176,8 @@ public override Element Render() var sendAction = () => { var msg = inputRef.Current?.Trim(); - var attachment = Props.PendingAttachment; - if (string.IsNullOrEmpty(msg) && attachment is null) return; - Props.OnSend(msg ?? "", attachment); + if (string.IsNullOrEmpty(msg) && pendingAttachments.Count == 0) return; + Props.OnSend(msg ?? "", pendingAttachments.ToArray()); inputRef.Current = ""; hasTextState.Set(false); sendVersion.Set(sendVersion.Value + 1); @@ -417,45 +424,79 @@ public override Element Render() // shown. The preview sits inside the same Border as the textbox so it // visually reads as part of the chat input. Element attachmentPreview = Empty(); - if (Props.PendingAttachment is { } att) + if (pendingAttachments.Count > 0) { - var isImage = att.Type == "image"; - - Element removeBtn = Button( + Element BuildRemoveButton(ChatAttachment attachment, bool floating = false) => Button( TextBlock("\uE711") // Cancel glyph .Set(t => { t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; t.FontSize = 10; + if (floating) + { + t.HorizontalAlignment = HorizontalAlignment.Center; + t.VerticalAlignment = VerticalAlignment.Center; + } }), - () => Props.OnAttachmentRemoved?.Invoke()) + () => Props.OnAttachmentRemoved?.Invoke(attachment)) .Set(b => { - b.Padding = new Thickness(4, 2, 4, 2); + if (floating) + { + b.Width = 22; + b.Height = 22; + b.Padding = new Thickness(0); + b.CornerRadius = new CornerRadius(11); + b.BorderThickness = new Thickness(1); + } + else + { + b.Padding = new Thickness(4, 2, 4, 2); + b.CornerRadius = new CornerRadius(4); + } b.MinWidth = 0; b.MinHeight = 0; - b.CornerRadius = new CornerRadius(4); }) - .Resources(r => r - .Set("ButtonBackground", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBackgroundPointerOver", Ref("SubtleFillColorSecondaryBrush")) - .Set("ButtonBackgroundPressed", Ref("SubtleFillColorTertiaryBrush")) - .Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent)) - .Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent))) + .Resources(r => + { + if (floating) + { + r.Set("ButtonBackground", Ref("SolidBackgroundFillColorBaseBrush")); + r.Set("ButtonBackgroundPointerOver", Ref("SolidBackgroundFillColorTertiaryBrush")); + r.Set("ButtonBackgroundPressed", Ref("SolidBackgroundFillColorQuarternaryBrush")); + r.Set("ButtonForeground", Ref("TextFillColorPrimaryBrush")); + r.Set("ButtonForegroundPointerOver", Ref("TextFillColorPrimaryBrush")); + r.Set("ButtonForegroundPressed", Ref("TextFillColorPrimaryBrush")); + r.Set("ButtonBorderBrush", Ref("CardStrokeColorDefaultBrush")); + r.Set("ButtonBorderBrushPointerOver", Ref("CardStrokeColorDefaultBrush")); + r.Set("ButtonBorderBrushPressed", Ref("CardStrokeColorDefaultBrush")); + } + else + { + r.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent)); + r.Set("ButtonBackgroundPointerOver", Ref("SubtleFillColorSecondaryBrush")); + r.Set("ButtonBackgroundPressed", Ref("SubtleFillColorTertiaryBrush")); + r.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent)); + r.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent)); + r.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)); + } + + }) .AutomationName("Remove attachment"); + Element BuildAttachmentPreview(ChatAttachment att) + { + var isImage = att.Type == "image"; + if (isImage) { // Build (and cache) a BitmapImage from the base64 content. - // Rebuild only when the attachment instance changes — base64 + // Rebuild only when the attachment instance changes; base64 // decode + stream copy is non-trivial work to repeat per // keystroke re-render. - var cached = attachmentImageRef.Current; - Microsoft.UI.Xaml.Media.Imaging.BitmapImage? bmp = cached.Bmp; - if (!ReferenceEquals(cached.Att, att) || bmp is null) + if (!imageCache.TryGetValue(att, out var bmp)) { bmp = TryCreateBitmapFromBase64(att.Content); - attachmentImageRef.Current = (att, bmp); + imageCache[att] = bmp; } Element thumb; @@ -501,36 +542,7 @@ public override Element Render() // of the thumbnail. Distinct from the chip's flat removeBtn // because we need an opaque background (so the × is readable // over any image) and a contrast-friendly hover. - var floatingRemove = Button( - TextBlock("\uE711") - .Set(t => - { - t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily; - t.FontSize = 10; - t.HorizontalAlignment = HorizontalAlignment.Center; - t.VerticalAlignment = VerticalAlignment.Center; - }), - () => Props.OnAttachmentRemoved?.Invoke()) - .Set(b => - { - b.Width = 22; - b.Height = 22; - b.MinWidth = 0; b.MinHeight = 0; - b.Padding = new Thickness(0); - b.CornerRadius = new CornerRadius(11); - b.BorderThickness = new Thickness(1); - }) - .Resources(r => r - .Set("ButtonBackground", Ref("SolidBackgroundFillColorBaseBrush")) - .Set("ButtonBackgroundPointerOver", Ref("SolidBackgroundFillColorTertiaryBrush")) - .Set("ButtonBackgroundPressed", Ref("SolidBackgroundFillColorQuarternaryBrush")) - .Set("ButtonForeground", Ref("TextFillColorPrimaryBrush")) - .Set("ButtonForegroundPointerOver", Ref("TextFillColorPrimaryBrush")) - .Set("ButtonForegroundPressed", Ref("TextFillColorPrimaryBrush")) - .Set("ButtonBorderBrush", Ref("CardStrokeColorDefaultBrush")) - .Set("ButtonBorderBrushPointerOver", Ref("CardStrokeColorDefaultBrush")) - .Set("ButtonBorderBrushPressed", Ref("CardStrokeColorDefaultBrush"))) - .AutomationName("Remove attachment") + var floatingRemove = BuildRemoveButton(att, floating: true) .HAlign(HorizontalAlignment.Right) .VAlign(VerticalAlignment.Top) .Margin(0, -8, -8, 0); @@ -544,12 +556,12 @@ public override Element Render() floatingRemove.Grid(row: 0, column: 0) ).HAlign(HorizontalAlignment.Left); - attachmentPreview = Border(thumbWithClose) + return Border(thumbWithClose) .Padding(8, 12, 8, 4); } else { - attachmentPreview = Border( + return Border( Grid([GridSize.Auto, GridSize.Star(), GridSize.Auto], [GridSize.Auto], TextBlock("\uE8A5") // Page glyph .Set(t => @@ -568,10 +580,13 @@ public override Element Render() t.Margin = new Thickness(6, 0, 0, 0); }) .Grid(row: 0, column: 1), - removeBtn.Grid(row: 0, column: 2) + BuildRemoveButton(att).Grid(row: 0, column: 2) ) ).Padding(4, 4, 4, 0); } + } + + attachmentPreview = VStack(6, pendingAttachments.Select(BuildAttachmentPreview).ToArray()); } // Composer "card" — wraps the attachment preview (if any) and the @@ -764,7 +779,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = const string sendGlyph = "\uE724"; const string stopGlyph = "\uE71A"; - var hasText = hasTextState.Value || Props.PendingAttachment is not null; + var hasText = hasTextState.Value || pendingAttachments.Count > 0; var sendTooltip = LocalizationHelper.GetString("Chat_Composer_Tooltip_Send"); var glyphBrush = hasText ? (Brush)new SolidColorBrush(Colors.White) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs index 58b6744d4..5b97390ed 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -18,25 +19,74 @@ internal static class Win32FilePickerHelper /// Shows an "Open" dialog owned by . /// Returns the selected file path, or null if cancelled. /// - public static Task PickSingleFileAsync(IntPtr ownerHwnd, string title = "Open") + public static async Task PickSingleFileAsync(IntPtr ownerHwnd, string title = "Open") { - var tcs = new TaskCompletionSource(); + var paths = await PickFilesAsync(ownerHwnd, title, allowMultiple: false); + return paths.Count > 0 ? paths[0] : null; + } + + /// + /// Shows an "Open" dialog owned by . + /// Returns all selected file paths, or an empty list if cancelled. + /// + public static Task> PickMultipleFilesAsync(IntPtr ownerHwnd, string title = "Open") + => PickFilesAsync(ownerHwnd, title, allowMultiple: true); + + private static Task> PickFilesAsync(IntPtr ownerHwnd, string title, bool allowMultiple) + { + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); var staThread = new Thread(() => { try { var dialog = (IFileOpenDialog)new FileOpenDialogClass(); - dialog.SetOptions(FOS.FOS_FORCEFILESYSTEM | FOS.FOS_FILEMUSTEXIST); + var options = FOS.FOS_FORCEFILESYSTEM | FOS.FOS_FILEMUSTEXIST; + if (allowMultiple) + options |= FOS.FOS_ALLOWMULTISELECT; + dialog.SetOptions(options); dialog.SetTitle(title); var hr = dialog.Show(ownerHwnd); if (hr < 0) { - tcs.SetResult(null); // cancelled or error + tcs.SetResult(Array.Empty()); // cancelled or error return; } - dialog.GetResult(out var item); - item.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var filePath); - tcs.SetResult(filePath); + + if (allowMultiple) + { + dialog.GetResults(out var items); + if (items is null) + { + dialog.GetResult(out var fallbackItem); + if (fallbackItem is null) + { + tcs.SetResult(Array.Empty()); + return; + } + + fallbackItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var fallbackFilePath); + tcs.SetResult(new[] { fallbackFilePath }); + return; + } + + items.GetCount(out var count); + var paths = new List((int)count); + for (uint i = 0; i < count; i++) + { + items.GetItemAt(i, out var multiItem); + if (multiItem is null) + continue; + + multiItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var multiFilePath); + paths.Add(multiFilePath); + } + tcs.SetResult(paths); + return; + } + + dialog.GetResult(out var singleItem); + singleItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var singleFilePath); + tcs.SetResult(new[] { singleFilePath }); } catch (Exception ex) { @@ -82,10 +132,23 @@ private interface IFileOpenDialog void SetClientGuid(ref Guid guid); void ClearClientData(); void SetFilter(IntPtr pFilter); - void GetResults(out IntPtr ppenum); + void GetResults(out IShellItemArray ppenum); void GetSelectedItems(out IntPtr ppsai); } + [ComImport, Guid("B63EA76D-1F85-456F-A19C-48159EFA858B")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItemArray + { + void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppvOut); + void GetPropertyStore(int flags, ref Guid riid, out IntPtr ppv); + void GetPropertyDescriptionList(IntPtr keyType, ref Guid riid, out IntPtr ppv); + void GetAttributes(int attribFlags, uint sfgaoMask, out uint psfgaoAttribs); + void GetCount(out uint pdwNumItems); + void GetItemAt(uint dwIndex, out IShellItem ppsi); + void EnumItems(out IntPtr ppenumShellItems); + } + [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellItem @@ -101,6 +164,7 @@ private interface IShellItem private enum FOS : uint { FOS_FORCEFILESYSTEM = 0x40, + FOS_ALLOWMULTISELECT = 0x200, FOS_FILEMUSTEXIST = 0x1000, } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 15a04fba9..628960e3e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -10,6 +10,7 @@ using OpenClawTray.Windows; using OpenClaw.Connection; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; @@ -816,17 +817,21 @@ private async Task PickAndAttachFileAsync() } var hwnd = WinRT.Interop.WindowNative.GetWindowHandle((Window)_hub!); - var path = await Win32FilePickerHelper.PickSingleFileAsync(hwnd, LocalizationHelper.GetString("ChatPage_AttachFile")); + var paths = await Win32FilePickerHelper.PickMultipleFilesAsync(hwnd, LocalizationHelper.GetString("ChatPage_AttachFile")); - if (path is null) + if (paths.Count == 0) { Logger.Info("[ChatPage] File picker cancelled by user"); return; } - Logger.Info($"[ChatPage] File selected: {path}"); - var attachment = await ChatAttachment.FromFileAsync(path); - _functionalHost?.AttachFile(attachment); + var attachments = new List(paths.Count); + foreach (var path in paths) + { + Logger.Info($"[ChatPage] File selected: {path}"); + attachments.Add(await ChatAttachment.FromFileAsync(path)); + } + _functionalHost?.AttachFiles(attachments); } catch (InvalidOperationException ex) { diff --git a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs index e173d5d63..85e89c0b3 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs @@ -9,6 +9,7 @@ using OpenClawTray.Helpers; using OpenClawTray.Services; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; @@ -585,13 +586,16 @@ private async Task PickAndAttachFileAsync() ChatWindowPinState.IsPinned = true; var hwnd = WinRT.Interop.WindowNative.GetWindowHandle((Window)this); - var path = await Win32FilePickerHelper.PickSingleFileAsync(hwnd, "Attach file"); - if (path is null) return; - Logger.Info($"[ChatWindow] File selected: {path}"); + var paths = await Win32FilePickerHelper.PickMultipleFilesAsync(hwnd, "Attach files"); + if (paths.Count == 0) return; - Logger.Info($"[ChatWindow] File selected: {path}"); - var attachment = await ChatAttachment.FromFileAsync(path); - _functionalHost?.AttachFile(attachment); + var attachments = new List(paths.Count); + foreach (var path in paths) + { + Logger.Info($"[ChatWindow] File selected: {path}"); + attachments.Add(await ChatAttachment.FromFileAsync(path)); + } + _functionalHost?.AttachFiles(attachments); } catch (InvalidOperationException ex) { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 5872e9dd7..e528015c3 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -16,6 +16,7 @@ private sealed class FakeBridge : IChatGatewayBridge public List SentMessages { get; } = new(); public List SentSessionKeys { get; } = new(); public List SentSessionIds { get; } = new(); + public List?> SentAttachments { get; } = new(); public Queue SendResults { get; } = new(); public List AbortedRunIds { get; } = new(); public Func? SendBehavior { get; set; } @@ -36,6 +37,7 @@ public async Task SendChatMessageForRunAsync(string message, str SentMessages.Add(message); SentSessionKeys.Add(sessionKey); SentSessionIds.Add(sessionId); + SentAttachments.Add(attachments?.ToArray()); if (SendBehavior is not null) await SendBehavior(message, sessionKey, sessionId); @@ -2936,12 +2938,52 @@ public async Task SendMessageAsync_WithAttachment_SendsThroughInterface() await provider.SendMessageAsync("main", "Check this", default, new[] { attachment }); Assert.Contains(bridge.SentMessages, m => m == "Check this"); + var sentAttachment = Assert.Single(bridge.SentAttachments); + Assert.NotNull(sentAttachment); + Assert.Same(attachment, sentAttachment![0]); // The display text in the timeline should include the attachment indicator var timeline = snapshots[^1].Timelines["main"]; var userEntry = timeline.Entries.Last(e => e.Kind == ChatTimelineItemKind.User); Assert.Contains("test.txt", userEntry.Text); } + [Fact] + public async Task SendMessageAsync_WithMultipleAttachments_SendsAndRendersAllMarkers() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + var fileAttachment = new ChatAttachment + { + Type = "file", + MimeType = "text/plain", + FileName = "notes.txt", + Content = Convert.ToBase64String(new byte[] { 1 }), + SizeBytes = 1 + }; + var imageAttachment = new ChatAttachment + { + Type = "image", + MimeType = "image/png", + FileName = "diagram.png", + Content = Convert.ToBase64String(new byte[] { 2, 3 }), + SizeBytes = 2 + }; + + await provider.SendMessageAsync("main", "See both", default, new[] { fileAttachment, imageAttachment }); + + var sentAttachments = Assert.Single(bridge.SentAttachments); + Assert.NotNull(sentAttachments); + Assert.Collection( + sentAttachments!, + a => Assert.Same(fileAttachment, a), + a => Assert.Same(imageAttachment, a)); + + var timeline = snapshots[^1].Timelines["main"]; + var userEntry = timeline.Entries.Last(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("See both\n\u200B📎 notes.txt\n\u200B🖼️ diagram.png", userEntry.Text); + } + [Fact] public async Task AttachmentMetadata_PersistsAndRehydratesFromHistory() { @@ -2981,6 +3023,53 @@ await provider1.SendMessageAsync("main", "Check this", default, new[] Assert.Equal("Check this\n\u200B📎 test.txt", userEntry.Text); } + [Fact] + public async Task AttachmentMetadata_PersistsAndRehydratesMultipleAttachments() + { + using var tempDir = new TempDirectory(); + var toolPath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var attachmentPath = Path.Combine(tempDir.DirectoryPath, "attachment-metadata.json"); + var sentTs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var (_, provider1, _, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + await provider1.LoadAsync(); + await provider1.SendMessageAsync("main", "See both", default, new[] + { + new ChatAttachment + { + Type = "file", + MimeType = "text/plain", + FileName = "notes.txt", + Content = Convert.ToBase64String(new byte[] { 1 }), + SizeBytes = 1 + }, + new ChatAttachment + { + Type = "image", + MimeType = "image/png", + FileName = "diagram.png", + Content = Convert.ToBase64String(new byte[] { 2, 3 }), + SizeBytes = 2 + } + }); + + var (bridge2, provider2, snapshots, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + bridge2.HistoryBehavior = key => Task.FromResult(new ChatHistoryInfo + { + SessionKey = key ?? "", + SessionId = "session-1", + Messages = new[] + { + new ChatMessageInfo { Role = "user", Text = "See both", State = "final", Ts = sentTs } + } + }); + + await provider2.LoadHistoryAsync("main"); + + var userEntry = snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("See both\n\u200B📎 notes.txt\n\u200B🖼️ diagram.png", userEntry.Text); + } + [Fact] public async Task AttachmentMetadata_RehydratesAttachmentOnlyHistoryMessage() {