diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index e694ebd9..3a866034 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -4,7 +4,7 @@ @* Every assistant is a drop area: a file dropped anywhere inside it lands on the assistant's default zone, while a file dropped on one of its specific zones lands there. *@ - + @@ -188,4 +188,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor b/app/MindWork AI Studio/Components/AttachDocuments.razor index 7f308a75..0261368b 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor @@ -3,8 +3,13 @@ @if (this.UseSmallForm) { -
- @if (this.isDraggingOver) + + @if (isDropTarget) { } -
+ @if (this.ShowMediaStatus) { @@ -82,21 +87,25 @@ else { } -
- - @foreach (var fileAttachment in this.DocumentPaths) + + @foreach (var fileAttachment in this.DocumentPaths) + { + @if (this.IsUnavailable) { - @if (this.IsUnavailable) - { - - } - else - { - - } + } - -
+ else + { + + } + } + @if (!this.IsUnavailable) { diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index be657adf..c3895fa2 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -45,12 +45,6 @@ public partial class AttachDocuments : MSGComponentBase [Parameter] public bool CatchAllDocuments { get; set; } - /// - /// The area this component lives in, if it lives in one at all. - /// - [CascadingParameter] - private DropZoneScopeState? Scope { get; set; } - [Parameter] public bool UseSmallForm { get; set; } @@ -105,11 +99,6 @@ public partial class AttachDocuments : MSGComponentBase private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly string DROP_FILES_HERE_TEXT = TB("Drop files here to attach them."); - private readonly string dropZoneId = $"attach-documents-{Guid.NewGuid():N}"; - - private bool isDefaultZone; - private bool hasReportedDefaultZoneProblem; - private bool isDraggingOver; private bool isFileDialogOpen; private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null ? MediaImportOwner.ForChat(this.OwnerChat.ChatId) @@ -124,7 +113,7 @@ public partial class AttachDocuments : MSGComponentBase protected override async Task OnInitializedAsync() { this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; - this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + this.ApplyFilters([], []); await base.OnInitializedAsync(); } @@ -132,7 +121,6 @@ public partial class AttachDocuments : MSGComponentBase /// Rehydrates results after the component is assigned another chat or target. protected override async Task OnParametersSetAsync() { - this.UpdateDefaultZoneRole(); await base.OnParametersSetAsync(); await this.SyncCompletedMediaAttachmentsAsync(); } @@ -223,139 +211,22 @@ public partial class AttachDocuments : MSGComponentBase protected override void DisposeResources() { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; - - // Hand the role of the default target back to the area: - if (this.isDefaultZone) - this.Scope?.ReleaseDefaultZone(this); - base.DisposeResources(); } - protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default - { - switch (triggeredEvent) - { - case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: - this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); - break; - - case Event.PATHS_DROPPED when data is DroppedPaths dropped: - // Whoever the drop was meant for, the drag is over and no zone stays highlighted: - this.ApplyHighlight(false); - - if (!this.IsThisZone(dropped.ZoneId)) - return; - - if (this.IsUnavailable) - { - this.Logger.LogDebug("The attachment zone '{Name}' is unavailable and swallowed {Count} dropped path(s).", this.Name, dropped.Paths.Count); - return; - } - - await this.AddFileBatchAsync(dropped.Paths); - await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); - await this.OnChange(this.DocumentPaths); - this.StateHasChanged(); - break; - } - } - #endregion /// - /// Keeps the role of the default target in step with the CatchAllDocuments parameter. + /// Attaches what the user dropped on the zone of this component. /// - /// - /// The flag is a parameter, so it can change while this component lives. A zone inside a - /// collapsed panel is the case this exists for: MudBlazor leaves the content of a collapsed - /// panel in the DOM with a height of zero, so the zone stays alive and cannot be aimed at -- - /// yet it would keep the role and swallow every drop meant for the visible part of the page. - /// - private void UpdateDefaultZoneRole() + /// The dropped paths, in the order the runtime delivered them. + private async Task PathsDropped(List paths) { - if (this.CatchAllDocuments) - { - this.ClaimDefaultZoneRole(); - return; - } - - if (!this.isDefaultZone) - return; - - this.Scope?.ReleaseDefaultZone(this); - this.isDefaultZone = false; + await this.AddFileBatchAsync(paths); + await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); + await this.OnChange(this.DocumentPaths); } - /// - /// Asks the area for the role of its default target. - /// - private void ClaimDefaultZoneRole() - { - if (this.isDefaultZone) - return; - - if (this.Scope is null) - { - // - // There is nothing to claim: the surrounding page, assistant, or dialog is not a drop - // area at all. The flag would then do nothing, and silently -- which is how a zone ends - // up promising a behaviour it cannot deliver. So say it out loud: either the area needs - // a DropZoneScope, or the flag does not belong here. Reported once only, because the - // claim is retried on every parameter change. - // - if (!this.hasReportedDefaultZoneProblem) - { - this.hasReportedDefaultZoneProblem = true; - this.Logger.LogWarning("The attachment zone '{Name}' wants to be the default target of its area, but it does not live in a drop zone scope. Dropping next to this zone will do nothing.", this.Name); - } - - return; - } - - this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); - - // Losing the role to a neighbour is a decision, not a defect -- and it can be undone later, - // when that neighbour goes away. So this one only goes to the debug log, and only once: - if (this.isDefaultZone || this.hasReportedDefaultZoneProblem) - return; - - this.hasReportedDefaultZoneProblem = true; - this.Logger.LogDebug("The attachment zone '{Name}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.Name); - } - - /// - /// Decides whether the named zone is this one. - /// - /// - /// The area counts as this zone as long as this zone is its default target. That is the whole - /// mechanism behind dropping anywhere in the chat and still landing on the composer. - /// - /// The ID the hit test reported, or null when it hit nothing. - private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); - - /// - /// Highlights the zone, or takes the highlight away. - /// - /// - /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per - /// second, and without it every one of them would render every zone on the page anew. In the - /// small form the highlight also swaps the markup, so this keeps the composer from flickering. - /// - private void ApplyHighlight(bool shouldBeHighlighted) - { - var highlighted = shouldBeHighlighted && !this.IsUnavailable; - if (highlighted == this.isDraggingOver) - return; - - this.isDraggingOver = highlighted; - this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-4" : DEFAULT_DRAG_CLASS; - this.StateHasChanged(); - } - - private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-4 mt-4 mud-width-full mud-height-full"; - - private string dragClass = DEFAULT_DRAG_CLASS; - private async Task AddFilesManually() { if (this.IsUnavailable) diff --git a/app/MindWork AI Studio/Components/DropZoneScope.razor b/app/MindWork AI Studio/Components/DropZoneScope.razor deleted file mode 100644 index b0f3f597..00000000 --- a/app/MindWork AI Studio/Components/DropZoneScope.razor +++ /dev/null @@ -1,8 +0,0 @@ -@* One element, and the content immediately inside it. Layouts hang child selectors on this - element, for instance app.css does on .inner-scrolling-context, so an extra level here would - break them. The cascading value renders no element of its own. *@ -
- - @this.ChildContent - -
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DropZoneScope.razor.cs b/app/MindWork AI Studio/Components/DropZoneScope.razor.cs deleted file mode 100644 index 0ed4a75f..00000000 --- a/app/MindWork AI Studio/Components/DropZoneScope.razor.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Components; - -namespace AIStudio.Components; - -/// -/// Marks an area whose drops end up at its default target when they hit no specific zone. -/// -/// -/// -/// This is how the habitual behaviour survives the move to hit testing: a file dropped anywhere in -/// the chat, in an assistant, or in a dialog still arrives where it used to, while a file dropped -/// on a specific zone now arrives exactly there. No code decides between the two -- the browser -/// does, because the specific zone lies deeper in the DOM than the area around it, and the hit test -/// resolves from the inside out. -/// -/// -/// The component replaces an existing element rather than adding one: it takes the class and the -/// style of the element it stands in for. Where the areas already had a wrapper, which is the case -/// for every page and every assistant, nothing about the layout changes. -/// -/// -public partial class DropZoneScope : ComponentBase -{ - /// - /// The content of the area. - /// - [Parameter] - public RenderFragment? ChildContent { get; set; } - - /// - /// The CSS classes of the element this scope renders. - /// - [Parameter] - public string Class { get; set; } = string.Empty; - - /// - /// The inline style of the element this scope renders. - /// - [Parameter] - public string Style { get; set; } = string.Empty; - - /// - /// The state to cascade, for hosts which want to be the default target of their own area. - /// - /// - /// A host cannot read what it cascades itself, so a page which is its own drop target has to own - /// the state instead. The plugins page is such a case: it accepts an archive anywhere on it and - /// has no inner zone to hand the role to. Everybody else leaves this alone and lets the scope - /// keep its own state. - /// - [Parameter] - public DropZoneScopeState? State { get; set; } - - private readonly DropZoneScopeState ownState = new($"drop-zone-scope-{Guid.NewGuid():N}"); - - private DropZoneScopeState EffectiveState => this.State ?? this.ownState; -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor b/app/MindWork AI Studio/Components/PathDropZone.razor index d5c917b8..e3b365cf 100644 --- a/app/MindWork AI Studio/Components/PathDropZone.razor +++ b/app/MindWork AI Studio/Components/PathDropZone.razor @@ -1,7 +1,28 @@ @inherits MSGComponentBase -
- - @this.ChildContent - -
\ No newline at end of file +@if (this.IsFramed) +{ +
+ + @this.ChildContent?.Invoke(this.isHighlighted) + +
+} +else +{ + @* An area renders one element, with the content immediately inside it. Layouts hang child + selectors on that element, for instance app.css does on .inner-scrolling-context, so an extra + level here would break them. The cascading value renders no element of its own. *@ +
+ @if (this.areaState is null) + { + @this.ChildContent?.Invoke(this.isHighlighted) + } + else + { + + @this.ChildContent?.Invoke(this.isHighlighted) + + } +
+} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor.cs b/app/MindWork AI Studio/Components/PathDropZone.razor.cs index 5f572d46..6b982000 100644 --- a/app/MindWork AI Studio/Components/PathDropZone.razor.cs +++ b/app/MindWork AI Studio/Components/PathDropZone.razor.cs @@ -6,22 +6,40 @@ namespace AIStudio.Components; /// A drop zone which reports the paths of whatever was dropped on it, and nothing else. /// /// +/// /// Dropping is a native matter in AI Studio: the Tauri runtime reports real paths, which is why /// this zone can hand out folders just as well as files. What those paths mean is the consumer's /// business — this component reads no content and does not care whether a path leads to a file or /// to a folder. +/// +/// +/// One component serves both kinds of drop target, because both are the same thing to the hit test: +/// an element with an ID. A zone is a place one aims at, and it draws a frame around whatever it is +/// given. An area is a page, an assistant, or a dialog: it draws nothing, it marks the space in +/// which a drop counts at all, and the drops which hit none of its zones go to its default target. +/// Set IsArea for the second kind. +/// /// public partial class PathDropZone : MSGComponentBase { /// /// The content shown inside the zone. /// + /// + /// Its argument tells the content whether this zone is the one under the cursor right now, so it + /// can show where the drop would land. Everybody who does not care about that ignores it. + /// [Parameter] - public RenderFragment? ChildContent { get; set; } + public RenderFragment? ChildContent { get; set; } /// /// Reports the dropped paths, in the order the runtime delivered them. /// + /// + /// An area without this callback is a marker and nothing more: it takes no drops itself, it only + /// gives the drops aimed between its zones a place to be counted, from where the default target + /// of the area picks them up. + /// [Parameter] public EventCallback> OnPathsDropped { get; set; } @@ -32,21 +50,87 @@ public partial class PathDropZone : MSGComponentBase /// A drop aimed at this zone arrives here in any case. What this flag decides is the fate of the /// drops aimed anywhere else in the surrounding area which hit no zone of their own: with the /// flag, they arrive here as well. Only one zone per area can hold that role, and if several ask - /// for it, the first one in the markup gets it. + /// for it, the first one in the markup gets it. An area ignores the flag: an area which takes + /// drops at all is its own default target, see IsArea. /// [Parameter] public bool CatchAllDocuments { get; set; } /// - /// When true, the zone ignores drops and is not highlighted. + /// Decides, at the moment a drop arrives, whether this zone may take it. /// /// - /// It keeps its ID in the DOM nevertheless and therefore swallows the drops aimed at it. That is + /// A disabled zone keeps its ID in the DOM and therefore swallows the drops aimed at it. That is /// what the pointer says: it rests on a switched-off field, so nothing happens. Letting the drop - /// fall through to the area behind it would deliver the files somewhere else entirely. + /// fall through to the area behind it would deliver the files somewhere else entirely. This is + /// asked rather than passed as a value because the answer often depends on work in flight, and a + /// value would be as old as the last render of the consumer. /// [Parameter] - public bool Disabled { get; set; } + public Func Disabled { get; set; } = () => false; + + /// + /// Makes this element an area instead of a zone: it marks a page, an assistant, or a dialog, and + /// it draws no frame of its own. + /// + /// + /// This is how the habitual behaviour survives the move to hit testing: a file dropped anywhere + /// in the chat, in an assistant, or in a dialog still arrives where it used to, while a file + /// dropped on a specific zone now arrives exactly there. No code decides between the two — the + /// browser does, because a zone lies deeper in the DOM than the area around it, and the hit test + /// resolves from the inside out. An area replaces the element it stands in for rather than + /// adding one, so it takes over its class and its style. + /// + [Parameter] + public bool IsArea { get; set; } + + /// + /// Leaves out the frame this zone would otherwise draw around its content. + /// + /// + /// For zones whose content is the marker itself, such as a toolbar which shows a drop field + /// while a file hovers over it. An area never has a frame, so it does not need this flag. + /// + [Parameter] + public bool Frameless { get; set; } + + /// + /// The first part of the ID this element reports to the hit test, followed by a unique suffix. + /// + /// + /// It names the kind of zone in the log, next to the IDs the arbiter lists when a drop reached + /// nobody. Which is the whole reason it is a parameter: an ID of its own tells one from another, + /// but only a name tells what one is looking at. + /// + [Parameter] + public string IdPrefix { get; set; } = "path-drop-zone"; + + /// + /// The CSS classes of the element this component renders: of the frame for a zone which has one, + /// and of the element itself for an area and for a frameless zone. + /// + /// + /// A frame keeps its border and its width in any case; the classes given here replace the + /// padding and the margin it would use otherwise. + /// + [Parameter] + public string Class { get; set; } = string.Empty; + + /// + /// The classes a frame takes on in addition while it is the zone under the cursor. + /// + /// + /// Only a frame is highlighted this way. Without one there is nothing to draw on, and the + /// content says for itself what it looks like when it is the target, see ChildContent. + /// + [Parameter] + public string HighlightClass { get; set; } = "mud-border-primary border-2"; + + /// + /// The inline style of the element this component renders. + /// + [Parameter] + public string Style { get; set; } = string.Empty; /// /// The area this zone lives in, if it lives in one at all. @@ -57,20 +141,65 @@ public partial class PathDropZone : MSGComponentBase [Inject] private ILogger Logger { get; init; } = null!; - private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full"; + private const string FRAME_CLASSES = "relative rounded-lg border-2 border-dashed mud-width-full"; + private const string DEFAULT_SPACING_CLASSES = "pa-3 mb-3"; - private readonly string dropZoneId = $"path-drop-zone-{Guid.NewGuid():N}"; - - private string dragClass = DEFAULT_DRAG_CLASS; + private DropZoneScopeState? areaState; + private string zoneId = string.Empty; private bool isDefaultZone; private bool isHighlighted; private bool hasReportedDefaultZoneProblem; + private bool IsFramed => !this.IsArea && !this.Frameless; + + private string FrameClass => this.isHighlighted + ? $"{FRAME_CLASSES} {this.SpacingClasses} {this.HighlightClass}" + : $"{FRAME_CLASSES} {this.SpacingClasses}"; + + private string SpacingClasses => string.IsNullOrWhiteSpace(this.Class) ? DEFAULT_SPACING_CLASSES : this.Class; + + /// + /// The area this zone reports to, which for an area is itself. + /// + private DropZoneScopeState? EffectiveScope => this.areaState ?? this.Scope; + + /// + /// Whether this element wants to be the default target of its area. + /// + /// + /// An area which takes drops is that target by definition, because its own element is the area + /// and a drop next to its zones has nothing else to hit. It claims the role nevertheless, which + /// is what keeps a zone inside it from taking it and delivering the same drop a second time. + /// + private bool WantsDefaultZoneRole => this.IsArea ? this.OnPathsDropped.HasDelegate : this.CatchAllDocuments; + + /// + /// Whether a drop can arrive here at all. + /// + /// + /// An area which delivers to nobody is only a mark on the page, and it must not listen: the + /// highlight would render a whole page or assistant anew several times per drag, for a highlight + /// nobody asked to see. + /// + private bool CanBeTarget => !this.IsArea || this.OnPathsDropped.HasDelegate; + #region Overrides of MSGComponentBase protected override async Task OnInitializedAsync() { - this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + // + // The ID is built once and never again: the hit test names this element by it, so it has to + // outlive every render. The prefix is a parameter, and parameters are set before this point. + // + this.zoneId = $"{this.IdPrefix}-{Guid.NewGuid():N}"; + if (this.IsArea) + this.areaState = new DropZoneScopeState(this.zoneId); + + if (this.CanBeTarget) + this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + else + this.ApplyFilters([], []); + await base.OnInitializedAsync(); } @@ -86,7 +215,7 @@ public partial class PathDropZone : MSGComponentBase protected override void DisposeResources() { if (this.isDefaultZone) - this.Scope?.ReleaseDefaultZone(this); + this.EffectiveScope?.ReleaseDefaultZone(this); base.DisposeResources(); } @@ -106,13 +235,13 @@ public partial class PathDropZone : MSGComponentBase if (!this.IsThisZone(dropped.ZoneId)) return; - if (this.Disabled) + if (this.Disabled()) { - this.Logger.LogDebug("The path drop zone '{ZoneId}' is disabled and swallowed {Count} dropped path(s).", this.dropZoneId, dropped.Paths.Count); + this.Logger.LogDebug("The drop zone '{ZoneId}' cannot take drops right now and swallowed {Count} dropped path(s).", this.zoneId, dropped.Paths.Count); return; } - this.Logger.LogDebug("The path drop zone '{ZoneId}' caught {Count} path(s).", this.dropZoneId, dropped.Paths.Count); + this.Logger.LogDebug("The drop zone '{ZoneId}' caught {Count} path(s).", this.zoneId, dropped.Paths.Count); await this.OnPathsDropped.InvokeAsync(dropped.Paths); break; } @@ -131,7 +260,7 @@ public partial class PathDropZone : MSGComponentBase /// private void UpdateDefaultZoneRole() { - if (this.CatchAllDocuments) + if (this.WantsDefaultZoneRole) { this.ClaimDefaultZoneRole(); return; @@ -140,7 +269,7 @@ public partial class PathDropZone : MSGComponentBase if (!this.isDefaultZone) return; - this.Scope?.ReleaseDefaultZone(this); + this.EffectiveScope?.ReleaseDefaultZone(this); this.isDefaultZone = false; } @@ -152,13 +281,13 @@ public partial class PathDropZone : MSGComponentBase if (this.isDefaultZone) return; - if (this.Scope is null) + if (this.EffectiveScope is null) { // // There is nothing to claim: the surrounding page, assistant, or dialog is not a drop // area at all. The flag would then do nothing, and silently -- which is how a zone ends // up promising a behaviour it cannot deliver. So say it out loud: either the area needs - // a DropZoneScope, or the flag does not belong here. + // a drop area of its own, or the flag does not belong here. // // // Reported once only: the claim is retried on every parameter change, and repeating @@ -167,13 +296,13 @@ public partial class PathDropZone : MSGComponentBase if (!this.hasReportedDefaultZoneProblem) { this.hasReportedDefaultZoneProblem = true; - this.Logger.LogWarning("The path drop zone '{ZoneId}' wants to be the default target of its area, but it does not live in a drop zone scope. Dropping next to this zone will do nothing.", this.dropZoneId); + this.Logger.LogWarning("The drop zone '{ZoneId}' wants to be the default target of its area, but it does not live in a drop area. Dropping next to this zone will do nothing.", this.zoneId); } return; } - this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); + this.isDefaultZone = this.EffectiveScope.TryBecomeDefaultZone(this); // Losing the role to a neighbour is a decision, not a defect -- and it can be undone later, // when that neighbour goes away. So this one only goes to the debug log, and only once: @@ -181,7 +310,7 @@ public partial class PathDropZone : MSGComponentBase return; this.hasReportedDefaultZoneProblem = true; - this.Logger.LogDebug("The path drop zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.dropZoneId); + this.Logger.LogDebug("The drop zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.zoneId); } /// @@ -191,8 +320,8 @@ public partial class PathDropZone : MSGComponentBase /// The area counts as this zone as long as this zone is its default target. That is the whole /// mechanism behind dropping anywhere in a page and still landing here. /// - /// The ID the hit test reported, or null when it hit nothing. - private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); + /// The ID the hit test reported, or null when it hit nothing. + private bool IsThisZone(string? targetZoneId) => targetZoneId is not null && (targetZoneId == this.zoneId || (this.isDefaultZone && targetZoneId == this.EffectiveScope?.ScopeId)); /// /// Highlights the zone, or takes the highlight away. @@ -203,12 +332,11 @@ public partial class PathDropZone : MSGComponentBase /// private void ApplyHighlight(bool shouldBeHighlighted) { - var highlighted = shouldBeHighlighted && !this.Disabled; + var highlighted = shouldBeHighlighted && !this.Disabled(); if (highlighted == this.isHighlighted) return; this.isHighlighted = highlighted; - this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2" : DEFAULT_DRAG_CLASS; this.StateHasChanged(); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor b/app/MindWork AI Studio/Components/ReadFileContent.razor index a2b7f3db..bd1ba11a 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor @@ -2,39 +2,40 @@ @if (this.EnableDragDrop) { -
- - - @if (this.ShowAttachedDocumentState && this.hasLoadedFileContent) - { - - - - @this.ButtonText - - - - } - else - { - - @this.ButtonText - - } - - @if (this.IsCurrentTargetBusy) - { - - } - else - { - - @T("Drop one file here to load its content.") - - } - - -
+ + + @if (this.ShowAttachedDocumentState && this.hasLoadedFileContent) + { + + + + @this.ButtonText + + + + } + else + { + + @this.ButtonText + + } + + @if (this.IsCurrentTargetBusy) + { + + } + else + { + + @T("Drop one file here to load its content.") + + } + + } else { diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 8ecf467e..aa666217 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -57,12 +57,6 @@ public partial class ReadFileContent : MSGComponentBase [Parameter] public bool CatchAllDocuments { get; set; } - /// - /// The area this component lives in, if it lives in one at all. - /// - [CascadingParameter] - private DropZoneScopeState? Scope { get; set; } - /// /// Optionally restricts the file types offered by the native file picker /// and accepted by this component. @@ -85,15 +79,7 @@ public partial class ReadFileContent : MSGComponentBase [Inject] private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!; - private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full"; - - private readonly string dropZoneId = $"read-file-content-{Guid.NewGuid():N}"; - private string ButtonText => string.IsNullOrWhiteSpace(this.Text) ? T("Use file content as input") : this.Text; - private string dragClass = DEFAULT_DRAG_CLASS; - private bool isDefaultZone; - private bool isHighlighted; - private bool hasReportedDefaultZoneProblem; private bool isFileDialogOpen; private bool hasLoadedFileContent; private string loadedFileName = string.Empty; @@ -121,15 +107,13 @@ public partial class ReadFileContent : MSGComponentBase this.loadedFileName = string.Empty; } - this.UpdateDefaultZoneRole(); base.OnParametersSet(); } protected override async Task OnInitializedAsync() { this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; - if (this.EnableDragDrop) - this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + this.ApplyFilters([], []); await base.OnInitializedAsync(); await this.SyncCompletedMediaTextAsync(); @@ -194,139 +178,15 @@ public partial class ReadFileContent : MSGComponentBase this.MediaTranscriptionService.AcknowledgeDelivery(delivery); } - /// Unsubscribes from the singleton media service and releases the drop area. + /// Unsubscribes from the singleton media service. protected override void DisposeResources() { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; - - // Hand the role of the default target back to the area: - if (this.isDefaultZone) - this.Scope?.ReleaseDefaultZone(this); - base.DisposeResources(); } - protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default - { - if (!this.EnableDragDrop) - return; - - switch (triggeredEvent) - { - case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: - this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); - break; - - case Event.PATHS_DROPPED when data is DroppedPaths dropped: - // Whoever the drop was meant for, the drag is over and no zone stays highlighted: - this.ApplyHighlight(false); - - if (!this.IsThisZone(dropped.ZoneId)) - return; - - if (this.IsUnavailable) - { - this.Logger.LogDebug("The file zone '{ZoneId}' is unavailable and swallowed {Count} dropped path(s).", this.dropZoneId, dropped.Paths.Count); - return; - } - - await this.LoadFirstValidFile(dropped.Paths); - this.StateHasChanged(); - break; - } - } - #endregion - /// - /// Keeps the role of the default target in step with the CatchAllDocuments parameter. - /// - /// - /// The flag is a parameter, so it can change while this component lives. A zone inside a - /// collapsed panel is the case this exists for: MudBlazor leaves the content of a collapsed - /// panel in the DOM with a height of zero, so the zone stays alive and cannot be aimed at -- - /// yet it would keep the role and swallow every drop meant for the visible part of the page. - /// - private void UpdateDefaultZoneRole() - { - if (this.EnableDragDrop && this.CatchAllDocuments) - { - this.ClaimDefaultZoneRole(); - return; - } - - if (!this.isDefaultZone) - return; - - this.Scope?.ReleaseDefaultZone(this); - this.isDefaultZone = false; - } - - /// - /// Asks the area for the role of its default target. - /// - private void ClaimDefaultZoneRole() - { - if (this.isDefaultZone) - return; - - if (this.Scope is null) - { - // - // There is nothing to claim: the surrounding page, assistant, or dialog is not a drop - // area at all. The flag would then do nothing, and silently -- which is how a zone ends - // up promising a behaviour it cannot deliver. So say it out loud: either the area needs - // a DropZoneScope, or the flag does not belong here. Reported once only, because the - // claim is retried on every parameter change. - // - if (!this.hasReportedDefaultZoneProblem) - { - this.hasReportedDefaultZoneProblem = true; - this.Logger.LogWarning("The file zone '{ZoneId}' wants to be the default target of its area, but it does not live in a drop zone scope. Dropping next to this zone will do nothing.", this.dropZoneId); - } - - return; - } - - this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); - - // Losing the role to a neighbour is a decision, not a defect -- and it can be undone later, - // when that neighbour goes away. So this one only goes to the debug log, and only once: - if (this.isDefaultZone || this.hasReportedDefaultZoneProblem) - return; - - this.hasReportedDefaultZoneProblem = true; - this.Logger.LogDebug("The file zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.dropZoneId); - } - - /// - /// Decides whether the named zone is this one. - /// - /// - /// The area counts as this zone as long as this zone is its default target. That is the whole - /// mechanism behind dropping anywhere in an assistant and still landing here. - /// - /// The ID the hit test reported, or null when it hit nothing. - private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); - - /// - /// Highlights the zone, or takes the highlight away. - /// - /// - /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per - /// second, and without it every one of them would render every zone on the page anew. - /// - private void ApplyHighlight(bool shouldBeHighlighted) - { - var highlighted = shouldBeHighlighted && !this.IsUnavailable; - if (highlighted == this.isHighlighted) - return; - - this.isHighlighted = highlighted; - this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2" : DEFAULT_DRAG_CLASS; - this.StateHasChanged(); - } - private async Task SelectFile() { if (this.IsUnavailable) diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor b/app/MindWork AI Studio/Components/SelectDirectory.razor index 73731fe3..5025861d 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor @@ -2,7 +2,7 @@ @if (this.EnableDragDrop) { - + @this.Picker @T("You can also drag & drop the folder here.") diff --git a/app/MindWork AI Studio/Components/SelectFile.razor b/app/MindWork AI Studio/Components/SelectFile.razor index 1450f064..b6b0eb6c 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor +++ b/app/MindWork AI Studio/Components/SelectFile.razor @@ -2,7 +2,7 @@ @if (this.EnableDragDrop) { - + @this.Picker @T("You can also drag & drop the file here.") diff --git a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor index c2b8f965..638e9b9c 100644 --- a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor @@ -4,7 +4,9 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @* The name for the drop state is given although nobody uses it: the messages of this dialog + are listed in a table, whose rows would otherwise ask for the same name. *@ + @T("Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements.") @@ -203,7 +205,7 @@ } - + @if (this.IsReadOnly) diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor index 4cda2ad2..ad4f7c0f 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor @@ -6,7 +6,7 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @* ReSharper disable once CSharpWarnings::CS8974 *@ - + diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor index 96d58c07..3a94c100 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor @@ -6,7 +6,7 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @* ReSharper disable once CSharpWarnings::CS8974 *@ - + diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor index f6facdb3..b1851241 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor @@ -3,7 +3,7 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @T("See how we load your file. Review the content before we process it further.") @@ -102,7 +102,7 @@ } } - + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor index abab1c02..c68403b7 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor @@ -13,7 +13,7 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @@ -88,7 +88,7 @@ - + @T("Close") diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index e6c41206..e6106c30 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -4,7 +4,7 @@ @* The chat is a drop area: a file dropped anywhere in it hangs itself on the composer, which is what users are used to. *@ - + @@ -168,4 +168,4 @@ } - + diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 818647e9..65223482 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -5,8 +5,13 @@ @attribute [Route(Routes.PLUGINS)] @* The page is its own drop target: a plugin archive may be dropped anywhere on it, and there is no - inner zone to hand that role to. Hence the page owns the scope state instead of reading it. *@ - + inner zone to hand that role to. An area which takes drops itself is that target by definition. *@ + @T("Plugins") @@ -26,7 +31,7 @@ - + @@ -155,4 +160,4 @@ - + diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 5eac67b6..cddf43c7 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -42,14 +42,6 @@ public partial class Plugins : MSGComponentBase private bool isSharingPlugin; - /// - /// The drop area of this page. The page owns it rather than reading it from a cascading value, - /// because a component cannot read what it cascades itself. - /// - private readonly DropZoneScopeState dropZoneScope = new($"plugins-page-{Guid.NewGuid():N}"); - - private bool isDraggingOverPage; - private const string IMPORT_ICON = @" @@ -61,10 +53,7 @@ public partial class Plugins : MSGComponentBase protected override async Task OnInitializedAsync() { - this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED, Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); - - // The whole page is the drop target, so users can drop a plugin archive anywhere on it: - this.dropZoneScope.TryBecomeDefaultZone(this); + this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED ]); this.groupConfig = new TableGroupDefinition { @@ -90,12 +79,6 @@ public partial class Plugins : MSGComponentBase await this.TryAutoAuditAssistantsAsync(); } - protected override void DisposeResources() - { - this.dropZoneScope.ReleaseDefaultZone(this); - base.DisposeResources(); - } - #endregion private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta) @@ -275,7 +258,8 @@ public partial class Plugins : MSGComponentBase /// Highlights the plugin table while the user drags a file over the page, so it is visible /// where the file would land. /// - private string PluginTableClass => this.isDraggingOverPage + /// Whether the page is the target of the drop being aimed right now. + private static string PluginTableClass(bool isDropTarget) => isDropTarget ? "border-dashed border rounded-lg mud-border-primary border-4" : "border-dashed border rounded-lg"; @@ -570,54 +554,11 @@ public partial class Plugins : MSGComponentBase case Event.CONFIGURATION_CHANGED: await this.InvokeAsync(this.StateHasChanged); break; - - case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: - this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); - break; - - case Event.PATHS_DROPPED when data is DroppedPaths dropped: - // Whoever the drop was meant for, the drag is over and nothing stays highlighted: - this.ApplyHighlight(false); - - if (!this.IsThisZone(dropped.ZoneId)) - return; - - if (!this.CanCatchDroppedFile()) - { - LOG.LogDebug("The plugins page cannot import right now and swallowed {Count} dropped path(s).", dropped.Paths.Count); - return; - } - - await this.ImportDroppedPluginArchiveAsync(dropped.Paths); - break; } } #endregion - /// - /// Decides whether the named zone is this page. - /// - /// The ID the hit test reported, or null when it hit nothing. - private bool IsThisZone(string? zoneId) => zoneId is not null && zoneId == this.dropZoneScope.ScopeId; - - /// - /// Marks the page as the drop target, or takes that mark away. - /// - /// - /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per - /// second, and without it every one of them would render the whole plugin table anew. - /// - private void ApplyHighlight(bool shouldBeHighlighted) - { - var highlighted = shouldBeHighlighted && this.CanCatchDroppedFile(); - if (highlighted == this.isDraggingOverPage) - return; - - this.isDraggingOverPage = highlighted; - this.StateHasChanged(); - } - /// /// Decides whether this page may process dropped files: only when the organization allows /// importing plugins at all and no import is running.