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()
{