diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor index 37596c90b..e803c7079 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor @@ -925,11 +925,14 @@ else } else { - @* Submit is disabled until the thread is idle — the mid-round inbox is off, so - follow-ups are ingested only after the current round finishes. Esc cancels. *@ + @* Never block on the running round. A message sent mid-round is accepted immediately + (echoed via PendingUserMessages) and drained when the current round finishes — the + composer clears on submit and is ready for the next message right away. Esc cancels + the in-flight round. Disabled ONLY for genuinely un-sendable states: empty text or + no model. *@ + Disabled="@(string.IsNullOrWhiteSpace(MessageText) || HasNoModels)" + Title="@(HasNoModels ? "No model available — configure one to chat" : "Send")"> } diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs index 39193c282..b95908b2b 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs @@ -868,12 +868,11 @@ private void SendMessage() if (_isDisposed) return; - // 🚫 Submit is gated on the thread being IDLE. The mid-round inbox is disabled — - // follow-ups are ingested only after the current round finishes — so we don't accept - // a submit while executing. The Send button is disabled too (see the footer); this - // guards the Enter-key path. Esc still cancels the in-flight round. - if (ThreadViewModel?.IsExecuting == true) - return; + // Never block on the running round: an Enter mid-round is accepted immediately and queued + // via PendingUserMessages (drained when the round finishes) — the composer clears on submit + // and stays usable, so the user is never locked out while a response streams. Esc still + // cancels the in-flight round; TryBeginSubmit (in SubmitMessageCore) dedups an accidental + // double-submit of the same text within its debounce window. // No await in the click path — dispatch to Blazor render context and return void. // All Hub operations use Post + RegisterCallback; all IMeshService operations diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadSidePanelContent.razor.cs b/src/MeshWeaver.Blazor.Portal/Chat/ThreadSidePanelContent.razor.cs index 3f6625dea..07f1442b5 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadSidePanelContent.razor.cs +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadSidePanelContent.razor.cs @@ -165,15 +165,34 @@ private void OpenComposerObserver(string path) .Subscribe( threadPath => InvokeAsync(() => { - SidePanelState.SetContentPath(threadPath); - selectedThreadPath = threadPath; - StateHasChanged(); - // Consume the signal so it doesn't re-fire on the next subscribe. + // Consume the signal FIRST so it can't re-fire on the next subscribe or when the + // user returns to this page (a lingering OpenThreadPath would re-navigate). StreamCache.Update(path, n => { var c = n.ContentAs(Hub.JsonSerializerOptions, _logger); return c?.OpenThreadPath is null ? n : n with { Content = c with { OpenThreadPath = null } }; - }, Hub.JsonSerializerOptions).Subscribe(_ => { }, _ => { }); + }, Hub.JsonSerializerOptions).Subscribe(_ => { }, + ex => _logger?.LogWarning(ex, + "[ThreadSidePanel] clearing OpenThreadPath failed for {Path} — the signal may re-fire", path)); + + // Started from the MAIN/home screen (root URL) → open the new thread FULL-SCREEN: the + // ApplicationPage catch-all ("/{*Path}") renders the full Thread view. Anywhere else + // (e.g. a "+" new chat started INSIDE the side panel) keep the in-panel behavior. + // Strip any query string / fragment first so "/?x=1" or "/#y" still counts as home — + // ToBaseRelativePath keeps them, which would otherwise defeat the root check. + var relative = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + var cut = relative.IndexOfAny(['?', '#']); + if (cut >= 0) relative = relative[..cut]; + relative = relative.TrimEnd('/'); + if (string.IsNullOrEmpty(relative)) + { + NavigationManager.NavigateTo($"/{threadPath}"); + return; + } + + SidePanelState.SetContentPath(threadPath); + selectedThreadPath = threadPath; + StateHasChanged(); }), ex => _logger?.LogDebug(ex, "[ThreadSidePanel] composer observer errored for {Path}", path)); } diff --git a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css index 12764c3f2..f63baf3c5 100644 --- a/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css +++ b/src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css @@ -61,6 +61,17 @@ background: var(--neutral-layer-3, #f0f0f0) var(--icon-url) no-repeat center / 18px; } +/* Inline-SVG avatar. Harnesses (Claude Code, GitHub Copilot) embed their logo as a raw with + a viewBox but NO width/height, injected via MarkupString. That carries no CSS-isolation + scope attribute, so a scoped rule can't reach it — and with no intrinsic size it renders at the + browser default (~300×150) and overflows / collapses, so the icon looked blank or generic. + Constrain any inline avatar SVG to fill the avatar box. */ +::deep .mesh-node-picker-avatar svg { + width: 100%; + height: 100%; + display: block; +} + .mesh-node-picker-info { flex: 1; min-width: 0; @@ -154,7 +165,12 @@ left: 0; right: 0; bottom: 0; - z-index: 9; + /* Floating tier (above app chrome). The picker lives inside the chat composer, inside the side + panel whose header is z-index:10 (and the portal menu bar at z-index:1100). At the old + z-index:9/10 the upward-opening dropdown painted UNDER the side-panel top bar (close button + etc.) — the reported overlap. Lift the overlay + dropdown to the same floating tier the + slash-command palette uses so they render ABOVE all chrome. */ + z-index: 9999; background: transparent; } @@ -169,7 +185,7 @@ border: 1px solid var(--neutral-stroke-rest, #d1d1d1); border-radius: 4px; box-shadow: var(--elevation-shadow-flyout); - z-index: 10; + z-index: 10000; padding: 4px; margin-top: 2px; } diff --git a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js index 134e9bb40..5ed72277e 100644 --- a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js +++ b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js @@ -309,13 +309,31 @@ export function initEditor(editorId, placeholder, dotNetRef, codeEditMode = fals // the editor out when the container height changes. Debounced so rapid typing doesn't // thrash the DOM; shrink-back on delete works the same way (smaller contentHeight). if (autoGrow) { + // 🚨 Grow the OUTER container (.monaco-editor-container), NOT the inner + // #editorId element. #editorId IS the .monaco-editor-view div, which + // MonacoEditorView.razor.css pins to `height: 100% !important` — and a CSS + // `!important` rule overrides an inline style, so setting `.style.height` + // on it was SILENTLY IGNORED. The composer therefore stayed stuck at the + // 80px min-height and long input scrolled invisibly (lines "moved up"). + // The outer container carries the min-height/max-height clamps and has no + // !important height rule, so sizing IT lets the inner 100% fill it: the box + // expands with the content and only scrolls once max-height is reached. editorInstance.onDidContentSizeChange((e) => { const st = editorState.get(editorId); if (!st) return; if (st.resizeTimeout) clearTimeout(st.resizeTimeout); st.resizeTimeout = setTimeout(() => { - const c = document.getElementById(editorId); - if (c) c.style.height = e.contentHeight + 'px'; + const inner = document.getElementById(editorId); + const outer = inner?.closest('.monaco-editor-container'); + // Guard against a dispose race: the editor can be torn down during the 50ms + // debounce window, after which layout() throws "Couldn't find the editor…". + // getDomNode() returns null once the instance is disposed — bail if so. + if (outer && editorInstance.getDomNode()) { + outer.style.height = e.contentHeight + 'px'; + // Re-lay out to the new height now; automaticLayout's ResizeObserver + // would otherwise catch up a frame later. + editorInstance.layout(); + } }, 50); }); }