diff --git a/app/MindWork AI Studio/Components/DropZoneArbiter.razor b/app/MindWork AI Studio/Components/DropZoneArbiter.razor
new file mode 100644
index 00000000..5538381d
--- /dev/null
+++ b/app/MindWork AI Studio/Components/DropZoneArbiter.razor
@@ -0,0 +1,3 @@
+@inherits MSGComponentBase
+
+@* This component renders nothing on purpose. Its whole work is in DropZoneArbiter.razor.cs. *@
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs b/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs
new file mode 100644
index 00000000..04ad350c
--- /dev/null
+++ b/app/MindWork AI Studio/Components/DropZoneArbiter.razor.cs
@@ -0,0 +1,199 @@
+using AIStudio.Tools.Rust;
+
+using Microsoft.AspNetCore.Components;
+
+namespace AIStudio.Components;
+
+///
+/// Decides which drop zone a native drag and drop event was aimed at.
+///
+///
+///
+/// AI Studio knows no browser drag and drop. The Tauri runtime reports the native events together
+/// with the cursor position, and this component turns that position into the ID of the zone
+/// underneath. It lets the browser answer that question, because only the browser knows what the
+/// page looks like right now: which dialog is open, which zone is scrolled out of sight, which
+/// overlay is in the way.
+///
+///
+/// It renders nothing and exists once per circuit, rendered from Routes.razor beside the MudBlazor
+/// providers and thus outside the router. A component rather than a service, because a service has
+/// no reliable moment at which JS interop becomes possible; and not a part of MainLayout, because
+/// arbitration would be a foreign body in that file.
+///
+///
+/// There is deliberately no fallback for a hit test which cannot be carried out: a circuit whose
+/// browser is gone learns nothing about the page and must therefore do nothing. The app keeps
+/// disconnected circuits for a long time, see the retention settings in Program.cs, and the message
+/// bus reaches all of them. Anything which caught a drop without asking the browser would process
+/// one and the same drop once per circuit.
+///
+///
+public partial class DropZoneArbiter : MSGComponentBase
+{
+ [Inject]
+ private IJSRuntime JsRuntime { get; init; } = null!;
+
+ [Inject]
+ private ILogger Logger { get; init; } = null!;
+
+ ///
+ /// Which zone we named last, so that an unchanged highlight costs no message.
+ ///
+ private string? highlightedZoneId;
+
+ ///
+ /// True while a hit test for the highlight is on its way to the browser.
+ ///
+ private bool isHighlightHitTestRunning;
+
+ #region Overrides of MSGComponentBase
+
+ protected override async Task OnInitializedAsync()
+ {
+ this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED ]);
+ await base.OnInitializedAsync();
+ }
+
+ protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
+ {
+ switch (triggeredEvent)
+ {
+ //
+ // A drag entered the window or moved inside it. Both say where the cursor is, and
+ // nothing more, so both lead to the same question: which zone lights up?
+ //
+ case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED or TauriEventType.FILE_DROP_OVER } tauriEvent:
+ await this.MoveHighlight(tauriEvent);
+ break;
+
+ case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths } tauriEvent:
+ await this.DeliverDroppedPaths(tauriEvent, paths);
+ break;
+
+ //
+ // The drag left the window, or the window lost the focus while a drag was running. Tauri
+ // reports no position for either, and there is nothing left to aim at anyway.
+ //
+ case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED or TauriEventType.WINDOW_NOT_FOCUSED }:
+ await this.NameHighlightedZone(null);
+ break;
+ }
+ }
+
+ #endregion
+
+ ///
+ /// Highlights the zone under the cursor of a running drag.
+ ///
+ ///
+ /// A drag-over event arrives faster than one interop round trip takes, and the message bus
+ /// delivers without awaiting the receiver. A hit test which is still on its way therefore
+ /// suppresses the next one instead of queueing it: the following event catches up with the
+ /// movement anyway, and a queue would only ever fall further behind the cursor.
+ ///
+ private async Task MoveHighlight(TauriEvent tauriEvent)
+ {
+ if (this.isHighlightHitTestRunning)
+ return;
+
+ this.isHighlightHitTestRunning = true;
+ try
+ {
+ var (wasTested, zoneId) = await this.DetermineZoneUnderCursor(tauriEvent);
+ if (!wasTested)
+ return;
+
+ await this.NameHighlightedZone(zoneId);
+ }
+ finally
+ {
+ this.isHighlightHitTestRunning = false;
+ }
+ }
+
+ ///
+ /// Hands the dropped paths to the zone under the cursor, if there is one.
+ ///
+ ///
+ /// Unlike the highlight, this hit test is never suppressed: a drop happens once and must not be
+ /// lost. The highlight goes away first and in every case, because the drag is over no matter
+ /// whether the drop finds a zone.
+ ///
+ private async Task DeliverDroppedPaths(TauriEvent tauriEvent, List paths)
+ {
+ await this.NameHighlightedZone(null);
+
+ var (wasTested, zoneId) = await this.DetermineZoneUnderCursor(tauriEvent);
+ if (!wasTested)
+ return;
+
+ if (zoneId is null)
+ {
+ //
+ // Nothing under the cursor takes drops, so nothing happens -- which is the point of the
+ // whole exercise. The zones which were available are worth logging here, though: this is
+ // the one moment where the question "which one should it have been?" gets asked, and it
+ // happens once per drag rather than ten times a second.
+ //
+ if (this.Logger.IsEnabled(LogLevel.Debug))
+ {
+ var (_, availableZones) = await this.JsRuntime.TryInvokeAsync(this.CircuitState, "dropZones.list");
+ this.Logger.LogDebug("{Count} dropped path(s) reached no drop zone. Available zones: {Zones}", paths.Count, availableZones is null ? "unknown" : string.Join(", ", availableZones));
+ }
+
+ return;
+ }
+
+ this.Logger.LogDebug("{Count} path(s) were dropped on the zone '{ZoneId}'.", paths.Count, zoneId);
+ await this.SendMessage(Event.PATHS_DROPPED, new DroppedPaths(zoneId, paths));
+ }
+
+ ///
+ /// Tells the zones which one of them is under the cursor, unless they know already.
+ ///
+ /// The ID of the zone under the cursor, or null for none.
+ private async Task NameHighlightedZone(string? zoneId)
+ {
+ if (zoneId == this.highlightedZoneId)
+ return;
+
+ this.highlightedZoneId = zoneId;
+ await this.SendMessage(Event.HIGHLIGHT_DROP_ZONE, new DropZoneHighlight(zoneId));
+ }
+
+ ///
+ /// Asks the browser which drop zone lies under the cursor of a drag and drop event.
+ ///
+ ///
+ /// The two parts of the result must stay apart. Whether the browser answered at all comes first:
+ /// while a circuit is disconnected nobody answers, and acting on an answer we never got is
+ /// exactly the mistake this design exists to avoid. Only then comes what the answer was, and
+ /// there a null is a legitimate one -- the browser looked and found no zone.
+ ///
+ private async Task<(bool WasTested, string? ZoneId)> DetermineZoneUnderCursor(TauriEvent tauriEvent)
+ {
+ if (!tauriEvent.TryGetDropPosition(out var x, out var y))
+ {
+ // The runtime sends a position with every drag and drop event which has one, so this
+ // means we are talking to a runtime which does not, i.e. an older one:
+ this.Logger.LogWarning("The Tauri event {EventType} carried no cursor position, so the drop zone under it stays unknown.", tauriEvent.EventType);
+ return (false, null);
+ }
+
+ // A failed or skipped call is already logged by the extension method, which tells a
+ // disconnected circuit from a broken call. Nothing to add here, and nothing to do:
+ var hitTest = await this.JsRuntime.TryInvokeAsync(this.CircuitState, "dropZones.hitTest", x, y);
+
+ //
+ // One line per hit test, which is about ten per second while a drag lasts. That is the
+ // instrument for checking the coordinate space: the position has to follow the cursor, in
+ // the middle of the window as well as in all four corners, and on a display with a scale
+ // factor other than one. A mistake there shows up as a factor, an offset, or a mirrored y.
+ //
+ if (hitTest.WasInvoked)
+ this.Logger.LogDebug("The event {EventType} at ({X}, {Y}) hit the drop zone '{ZoneId}'.", tauriEvent.EventType, x, y, hitTest.Value ?? "");
+
+ return hitTest;
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Routes.razor b/app/MindWork AI Studio/Routes.razor
index 3988f98d..4f6389c8 100644
--- a/app/MindWork AI Studio/Routes.razor
+++ b/app/MindWork AI Studio/Routes.razor
@@ -1,4 +1,5 @@
-@using Microsoft.AspNetCore.Components.Routing
+@using AIStudio.Components
+@using Microsoft.AspNetCore.Components.Routing
@using MudBlazor
@@ -10,4 +11,8 @@
-
\ No newline at end of file
+
+
+@* Outside the router on purpose: which drop zone a drop belongs to is a question of the whole
+ session, not of the current page. *@
+
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/DropZoneHighlight.cs b/app/MindWork AI Studio/Tools/DropZoneHighlight.cs
new file mode 100644
index 00000000..3c28d063
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/DropZoneHighlight.cs
@@ -0,0 +1,12 @@
+namespace AIStudio.Tools;
+
+///
+/// Names the drop zone under the cursor of a running drag.
+///
+///
+/// Every zone receives this and compares the ID with its own: at most one zone is highlighted at a
+/// time, and all others have to give their highlight up. A null ID means that the cursor is over no
+/// zone at all, or that the drag has ended.
+///
+/// The ID of the zone under the cursor, or null when there is none.
+public readonly record struct DropZoneHighlight(string? ZoneId);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/DroppedPaths.cs b/app/MindWork AI Studio/Tools/DroppedPaths.cs
new file mode 100644
index 00000000..7e3a978c
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/DroppedPaths.cs
@@ -0,0 +1,14 @@
+namespace AIStudio.Tools;
+
+///
+/// Hands the dropped paths to the drop zone which was under the cursor.
+///
+///
+/// The zone is named rather than addressed, because the message bus broadcasts. Only the zone whose
+/// own ID matches acts on this, and every other zone ignores it -- including the zones of circuits
+/// whose browser is long gone, because an ID belongs to one instance in one circuit. What the paths
+/// mean is the receiving zone's business: they may lead to files just as well as to folders.
+///
+/// The ID of the zone the paths were dropped on.
+/// The dropped paths, in the order the runtime delivered them.
+public readonly record struct DroppedPaths(string ZoneId, List Paths);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs
index 6820e9b1..e68ad498 100644
--- a/app/MindWork AI Studio/Tools/Event.cs
+++ b/app/MindWork AI Studio/Tools/Event.cs
@@ -222,6 +222,16 @@ public enum Event
/// Unregisters a file drop area from file attachment handling.
///
UNREGISTER_FILE_DROP_AREA,
+
+ ///
+ /// Names the drop zone under the cursor of a running drag so that exactly this one is highlighted.
+ ///
+ HIGHLIGHT_DROP_ZONE,
+
+ ///
+ /// Delivers dropped paths to the drop zone which was under the cursor.
+ ///
+ PATHS_DROPPED,