diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs index 8d7431ea9..458dbac4f 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs @@ -60,6 +60,22 @@ protected override async Task OnInitializedAsync() private bool IsDirectoryAvailable => this.directoryInfo.Exists; + /// + /// Takes the next file which the directory scan found. + /// + /// + /// This runs on the scan's own thread, and it does so deliberately, although the scan asks its + /// callers to reach for a dispatcher. That request is about updating the UI, and none of these + /// callbacks does: they write fields and nothing else.

+ /// Why that holds is worth writing down, because the code does not show it. The string builder + /// has exactly one writer -- this method, on that one thread -- and nobody else ever reads it. + /// What the renderer reads is the text field beside it, and assigning a string reference is + /// atomic, so a render sees the whole previous text or the whole new one, never half of either. + /// Building that text anew costs little, because the scan stops reporting files once it has + /// reported a hundred. And a render happens only when the refresh timer ticks, which goes + /// through the dispatcher, so a reading taken a moment too early is replaced 1.6 seconds later + /// anyway. + ///
private void UpdateFileList(string file) { this.directoryFiles.Append("- "); @@ -67,13 +83,38 @@ private void UpdateFileList(string file) this.directoryFilesText = this.directoryFiles.ToString(); } + /// + /// Takes the size which the directory scan has added up so far. + /// + /// + /// Two threads call this, but never at the same time: the scan reports its progress from its + /// own thread, and the final figure follows once that thread has finished. A long is written in + /// one piece on all six targets we ship, which are 64 bit throughout, and the renderer reads it + /// only when the refresh timer ticks. The remark on the file list carries the reasoning these + /// callbacks share. + /// private void UpdateDirectorySize(long size) { this.directorySizeBytes = size; } + /// + /// Takes the number of files which the directory scan has counted so far. + /// + /// + /// Reported from the same two threads as the size above, and safe for the same reason. + /// private void UpdateDirectoryFiles(long numFiles) => this.directorySizeNumFiles = numFiles; + /// + /// Takes the news that the directory scan has finished. + /// + /// + /// This one, unlike the three above, does not run on the scan's thread. The scan invokes it + /// after awaiting its worker, and that continuation returns to the dispatcher this dialog was + /// initialized on. Stopping the timer and asking for a render from here is therefore no + /// different from doing either in a lifecycle method. + /// private void DirectoryOperationDone() { this.refreshTimer.Stop(); diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index e6106c30d..802a9b624 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -36,7 +36,7 @@ @if (this.AreWorkspacesVisible) { - + @if (this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_SIDEBAR && this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible) { diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 0ab09d159..41139a41d 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -27,6 +27,7 @@ public partial class Chat : MSGComponentBase private string currentWorkspaceName = string.Empty; private Workspaces? workspaces; private double splitterPosition = 30; + private bool skipRenderAfterSplitterChange; private readonly ChatComposerState composerState = new(); private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6)); @@ -39,6 +40,16 @@ protected override async Task OnInitializedAsync() this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition; this.splitterSaveTimer.AutoReset = false; + // + // Mind that this handler deliberately stays off the renderer thread, although it writes the + // configuration data from a thread pool thread. The position is a single double, and every + // target we ship is 64 bit, so the write cannot tear -- and the worst a lost one could do is + // a splitter standing somewhere else after the next start. What a jump to the dispatcher + // would cost instead is paid by the user: storing the settings serializes all of them and + // writes two files, and it would do that in the very queue which draws the drag they are in + // the middle of. The splitter then stutters under their hand. Whoever synchronizes the + // configuration data one day should do it without moving that work onto the renderer. + // this.splitterSaveTimer.Elapsed += (_, _) => { this.SettingsManager.ConfigurationData.Workspace.SplitterPosition = this.splitterPosition; @@ -47,7 +58,31 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } - + + /// + /// Decides whether this page renders again. + /// + /// + /// Dragging the splitter reports every movement, and Blazor renders this page after each of + /// them. That render is pure waste: all it would contribute is the position the splitter just + /// reported, and the splitter has it already -- it renders itself after its own event, which is + /// what resizes the two panels. What this page rebuilds instead is everything else it holds, + /// the workspace tree above all, which has no render guard of its own and draws an item with + /// three buttons for every chat. That is what the user sees stutter while they drag.

+ /// Dropping that one render costs nothing, because the splitter never needed it. Should a + /// message from the bus ask for a render in the very same moment, this swallows it -- both sit + /// on the same dispatcher and the render of a movement follows it without a gap, so the window + /// is as good as closed, and the next render brings the message along anyway. + ///
+ protected override bool ShouldRender() + { + if (!this.skipRenderAfterSplitterChange) + return true; + + this.skipRenderAfterSplitterChange = false; + return false; + } + #endregion private string WorkspaceSidebarToggleIcon => this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible ? Icons.Material.Filled.ArrowCircleLeft : Icons.Material.Filled.ArrowCircleRight; @@ -75,6 +110,7 @@ private void SplitterChanged(double position) this.splitterPosition = position; this.splitterSaveTimer.Stop(); this.splitterSaveTimer.Start(); + this.skipRenderAfterSplitterChange = true; } private void ToggleWorkspacesOverlay()