Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/MeshWeaver.AI/ThreadLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,11 @@ private static UiControl ThreadsView(LayoutAreaHost host, RenderingContext _)
.DistinctUntilChanged(p => p.key)
.Select(p => CollectUpdatedNodes(host.Hub, threadPath, p.ids))
.Switch()
// Dedup the aggregated change-set on its VISIBLE summary (path/op/version per entry).
// CollectUpdatedNodes re-emits as a round streams even when that summary is unchanged; without
// this the Header layout area re-rendered per streamed token — a residual render-storm loop.
// BuildHeader depends only on `updates`, so this is safe.
.DistinctUntilChanged(updates => string.Join(";", updates.Select(u => $"{u.Path}|{u.Operation}|{u.VersionAfter}")))
.Select(updates => BuildHeader(parentLink, updates, threadPath));

return initial.Concat(aggregated);
Expand Down
28 changes: 22 additions & 6 deletions src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -647,31 +647,47 @@ else
<div class="thread-chat-status-row">
@if (!string.IsNullOrEmpty(boundHarness))
{
<span class="thread-chat-status-item" title="Harness (/harness to change)">⬡ @harnessId</span>
<button type="button" class="thread-chat-status-item is-clickable"
title="Harness — click to switch runtime" @onclick='() => OnStatusChipClick("harness", false)'>
@ChipSvg("harness") @harnessId
</button>
}
@if (isClaudeCode || isCopilot)
{
@* Claude Code and GitHub Copilot run the user's OWN CLI subscription — there is no mesh
agent to pick, and the model is the CLI's OWN (probed from its init/hello message),
NOT the user's mesh Model-partition selection. Claude Code adds reasoning effort. *@
NOT the user's mesh Model-partition selection. Claude Code adds reasoning effort.
These chips forward /model|/effort 1:1 to the CLI (cliOwned: true). *@
@* harnessId is non-null here: this branch is gated by isClaudeCode || isCopilot,
which are false when harnessId is null (string.Equals(null, "ClaudeCode") == false). *@
var harnessRt = harnessRuntimes.GetValueOrDefault(harnessId!);
<span class="thread-chat-status-item" title="Model (reported by @harnessId)">✦ @(string.IsNullOrEmpty(harnessRt?.Model) ? "…" : harnessRt!.Model)</span>
<button type="button" class="thread-chat-status-item is-clickable"
title="Model (reported by @harnessId) — click to change" @onclick='() => OnStatusChipClick("model", true)'>
@ChipSvg("model") @(string.IsNullOrEmpty(harnessRt?.Model) ? "…" : harnessRt!.Model)
</button>
@if (isClaudeCode)
{
<span class="thread-chat-status-item" title="Reasoning effort (from settings.json)">⚡ @(string.IsNullOrEmpty(harnessRt?.Effort) ? "…" : harnessRt!.Effort)</span>
<button type="button" class="thread-chat-status-item is-clickable"
title="Reasoning effort — click to change" @onclick='() => OnStatusChipClick("effort", true)'>
@ChipSvg("effort") @(string.IsNullOrEmpty(harnessRt?.Effort) ? "…" : harnessRt!.Effort)
</button>
}
}
else
{
@if (!string.IsNullOrEmpty(boundAgentPath))
{
<span class="thread-chat-status-item" title="Agent (/agent to change)">🤖 @LastSegment(boundAgentPath)</span>
<button type="button" class="thread-chat-status-item is-clickable"
title="Agent — click to change" @onclick='() => OnStatusChipClick("agent", false)'>
@ChipSvg("agent") @LastSegment(boundAgentPath)
</button>
}
@if (!string.IsNullOrEmpty(boundModelPath))
{
<span class="thread-chat-status-item" title="Model (/model to change)">✦ @LastSegment(boundModelPath)</span>
<button type="button" class="thread-chat-status-item is-clickable"
title="Model — click to change" @onclick='() => OnStatusChipClick("model", false)'>
@ChipSvg("model") @LastSegment(boundModelPath)
</button>
}
}
@* The token chip renders its own leading separator only when it has usage data,
Expand Down
102 changes: 97 additions & 5 deletions src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,47 @@ private async Task HandleSlashCommandAsync(ParsedCommand parsedCommand)
await InvokeAsync(StateHasChanged);
}

/// <summary>
/// A status-bar chip was clicked — do exactly what typing the matching slash-command does:
/// <list type="bullet">
/// <item><c>harness</c> / mesh <c>agent</c> / mesh <c>model</c> → <see cref="HandleSlashCommandAsync"/>,
/// which resolves the skill and pops the SAME <c>OpenPicker</c> combobox (no hand-rolled dropdown).</item>
/// <item>a CLI harness's OWN model/effort (ClaudeCode / Copilot) → forward <c>/{command}</c> 1:1 to the
/// CLI, exactly as the FORWARD dispatch does for a typed command.</item>
/// </list>
/// </summary>
private Task OnStatusChipClick(string commandName, bool cliOwned)
{
if (cliOwned)
{
// The model/effort belong to the user's OWN CLI subscription — forward, don't pop a mesh picker.
if (!string.IsNullOrEmpty(threadPath))
Hub.SubmitMessage(threadPath, $"/{commandName}");
return Task.CompletedTask;
}
var parsed = ChatParser.Parse($"/{commandName}").Command;
return parsed is not null ? HandleSlashCommandAsync(parsed) : Task.CompletedTask;
}

/// <summary>
/// Inline SVG glyph for a status chip / completion category — crisp + themeable (currentColor),
/// replacing the emoji. 16×16, single path. Kinds: harness, model, effort, agent, skill.
/// </summary>
private static MarkupString ChipSvg(string kind)
{
var path = kind switch
{
"harness" => "M8 1.2 14 4.6v6.8L8 14.8 2 11.4V4.6z", // hexagon
"model" => "M8 1.3l1.7 4.4 4.6.3-3.5 3 1.1 4.5L8 11l-3.9 2.5 1.1-4.5-3.5-3 4.6-.3z", // sparkle/star
"effort" => "M9.2 1 3.4 9.1H7l-1 5.9 5.9-8.4H8.2z", // lightning bolt
"agent" => "M5 3h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm.8 4.2a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm4.4 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z", // chip/robot
"skill" => "M8 1l2 4 4.4.5-3.2 3 .9 4.4L8 10.8 3.9 12.9l.9-4.4L1.6 5.5 6 5z", // ribbon
_ => "M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2z", // dot
};
return new MarkupString(
$"<svg class=\"chip-svg\" viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"{path}\"/></svg>");
}

/// <summary>
/// Resolves a <c>nodeType:Skill</c> mesh node by its slash word and runs its action. Built-in
/// skills (/agent, /model, /harness — Pick behaviours shipped by
Expand Down Expand Up @@ -2303,13 +2344,16 @@ private Task SwitchToResumeModeAsync()
snapshot => InvokeAsync(() =>
{
if (_isDisposed) return;
resumeThreads = snapshot
var projected = snapshot
.Where(n => !string.IsNullOrEmpty(n.Path)
&& string.Equals(n.NodeType, ThreadNodeType.NodeType, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(n => n.LastModified)
.ToList();
var changed = VisibleThreadListChanged(ref _resumeThreadsKey, projected);
if (changed) resumeThreads = projected;
var wasLoading = resumeLoading;
resumeLoading = false;
StateHasChanged();
if (changed || wasLoading) StateHasChanged(); // re-render on a real change, or to clear the spinner
}),
ex => InvokeAsync(() =>
{
Expand Down Expand Up @@ -2348,6 +2392,21 @@ private Task OnSelectThread(MeshNode node)
/// ones I created. RLS already scopes the snapshot to what I can read. Independent of the current
/// thread, so it's set up once in <see cref="OnInitialized"/>.
/// </summary>
// Visible-shape dedup keys for the thread-list subscriptions (Path|Name sequence). Hub.GetQuery
// re-emits the WHOLE set on ANY thread content change, and a streaming round churns a thread's content
// many times/second — the nav menu only shows path+name, so a content-only churn must NOT re-render
// (that per-token StateHasChanged was a storm cascading down to the header area). Only re-render when
// the visible sequence actually changes.
private string? _myThreadsKey, _childThreadsKey, _resumeThreadsKey;

private static bool VisibleThreadListChanged(ref string? lastKey, IReadOnlyList<MeshNode> projected)
{
var key = string.Join(";", projected.Select(n => n.Path + "|" + n.Name));
if (key == lastKey) return false;
lastKey = key;
return true;
}

private void SetupMyThreadsSubscription()
{
myThreadsSubscription?.Dispose();
Expand All @@ -2357,13 +2416,15 @@ private void SetupMyThreadsSubscription()
snapshot => InvokeAsync(() =>
{
if (_isDisposed) return;
myThreads = snapshot
var projected = snapshot
.Where(n => !string.IsNullOrEmpty(n.Path)
&& string.Equals(n.NodeType, ThreadNodeType.NodeType, StringComparison.OrdinalIgnoreCase)
&& (string.IsNullOrEmpty(me)
|| string.Equals(n.CreatedBy, me, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(n => n.LastModified)
.ToList();
if (!VisibleThreadListChanged(ref _myThreadsKey, projected)) return;
myThreads = projected;
StateHasChanged();
}),
ex => Logger.LogDebug(ex, "[ThreadChat:{InstanceId}] my-threads query failed", _instanceId));
Expand All @@ -2390,12 +2451,14 @@ private void SetupChildThreadsSubscription(string path)
snapshot => InvokeAsync(() =>
{
if (_isDisposed) return;
childThreads = snapshot
var projected = snapshot
.Where(n => !string.IsNullOrEmpty(n.Path)
&& !string.Equals(n.Path, path, StringComparison.OrdinalIgnoreCase)
&& string.Equals(n.NodeType, ThreadNodeType.NodeType, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(n => n.LastModified)
.ToList();
if (!VisibleThreadListChanged(ref _childThreadsKey, projected)) return;
childThreads = projected;
StateHasChanged();
}),
ex => Logger.LogDebug(ex, "[ThreadChat:{InstanceId}] child-threads query failed", _instanceId));
Expand Down Expand Up @@ -2672,6 +2735,27 @@ CompletionItem Item(string name, string description) =>
// /harness stays available (it falls through to the node-pick path) so a CLI harness isn't a
// one-way door — the user can switch runtime back to MeshWeaver or another harness.
items.Add(Item("harness", "Switch the harness (runtime)"));

// ALL of the harness's OWN slash-commands AND skills, probed from the CLI's init/hello message
// (IHarnessRuntimeInfo → harnessRuntimes). The user gets Claude Code / Copilot's native command +
// skill autocomplete; each is forwarded 1:1 to the CLI by the FORWARD path in the dispatch.
var label = harness.Definition.DisplayName ?? harness.Id;
var seen = new HashSet<string>(items.Select(i => (i.Label ?? "").TrimStart('/')), StringComparer.OrdinalIgnoreCase);
if (harnessRuntimes.GetValueOrDefault(harness.Id) is { } rt)
{
foreach (var cmd in rt.SlashCommands)
{
var name = cmd.TrimStart('/');
if (name.Length > 0 && seen.Add(name))
items.Add(Item(name, $"{label} command"));
}
foreach (var skill in rt.Skills)
{
var name = skill.TrimStart('/');
if (name.Length > 0 && seen.Add(name))
items.Add(Item(name, $"{label} skill"));
}
}
return items.OrderBy(c => c.SortKey, StringComparer.Ordinal).ToList();
}

Expand Down Expand Up @@ -3235,11 +3319,19 @@ private static ToolCallDisplay FormatToolCallDisplay(ToolCallEntry call)
/// thread doesn't exist yet. Uses <see cref="SpinnerType.None"/> — the header
/// is ancillary; showing a skeleton for it looks like a phantom message cell.
/// </summary>
private LayoutAreaControl? _headerCell;
private string? _headerCellPath;
private LayoutAreaControl? GetHeaderCell()
{
if (string.IsNullOrEmpty(threadPath))
return null;
return new LayoutAreaControl(
// Cache by threadPath. Returning a FRESH control each render gave the header area a new ViewModel
// reference every parent render → it re-bound every time (defeating the OnParametersSet re-bind
// guard). A stable reference while the thread is unchanged ⇒ the header area binds once.
if (_headerCell is not null && _headerCellPath == threadPath)
return _headerCell;
_headerCellPath = threadPath;
return _headerCell = new LayoutAreaControl(
threadPath,
new LayoutAreaReference(ThreadNodeType.HeaderArea))
.WithSpinnerType(SpinnerType.None);
Expand Down
27 changes: 27 additions & 0 deletions src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,33 @@
max-width: 180px;
}

/* The chips are <button> now (clickable → picker/combobox). Strip native button chrome; keep the chip look. */
button.thread-chat-status-item {
background: none;
border: none;
margin: 0;
padding: 1px 5px;
font: inherit;
line-height: inherit;
color: inherit;
border-radius: 4px;
cursor: default;
}

.thread-chat-status-item.is-clickable {
cursor: pointer;
transition: background-color 0.12s ease;
}

.thread-chat-status-item.is-clickable:hover {
background-color: var(--neutral-fill-secondary-hover, rgba(127, 127, 127, 0.14));
}

.thread-chat-status-item .chip-svg {
flex: 0 0 auto;
opacity: 0.72;
}

.thread-chat-status-sep {
color: var(--neutral-stroke-rest);
}
Expand Down
38 changes: 37 additions & 1 deletion src/MeshWeaver.Blazor/BlazorView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,43 @@ public class BlazorView<TViewModel, TView> : ComponentBase, IAsyncDisposable
/// <summary>Inline CSS style string bound from <c>ViewModel.Style</c>; applied to the root element.</summary>
protected string? Style { get; set; }

/// <summary>Tears down previous data bindings and rebuilds them after every Blazor parameter update.</summary>
// Snapshot of the binding-relevant parameters at the last actual bind — OnParametersSet compares
// against these to skip redundant re-binds (see the guard there).
private TViewModel? _boundViewModel;
private ISynchronizationStream<JsonElement>? _boundStream;
private string? _boundArea;
private string? _boundDataContext;
private ModelParameter<JsonElement>? _boundModel;
private object? _boundContext;
private bool _hasBound;

/// <summary>Rebuilds data bindings after a Blazor parameter update — but ONLY when a binding-relevant
/// parameter actually changed (see the guard), never on every no-op parent render.</summary>
protected override void OnParametersSet()
{
base.OnParametersSet();
// Blazor calls OnParametersSet on EVERY parent render. Re-disposing + re-subscribing all bindings
// each time re-fires the ReplaySubject-backed sync stream → StateHasChanged → the parent re-renders
// → OnParametersSet → … a client-side render storm that pegs the circuit (the recurring "chat
// freeze"/wedge). When no binding-relevant parameter actually changed, the existing bindings are
// still correct — skip the re-bind and break the loop. Reference identity suffices: ViewModel /
// Stream / Model / Context are immutable records or stable per-area handles; Area / DataContext are
// strings compared by value.
if (_hasBound
&& ReferenceEquals(ViewModel, _boundViewModel)
&& ReferenceEquals(Stream, _boundStream)
&& Area == _boundArea
&& DataContext == _boundDataContext
&& ReferenceEquals(Model, _boundModel)
&& ReferenceEquals(Context, _boundContext))
return;
_boundViewModel = ViewModel;
_boundStream = Stream;
_boundArea = Area;
_boundDataContext = DataContext;
_boundModel = Model;
_boundContext = Context;
_hasBound = true;
BindDataAfterParameterReset();
}

Expand Down Expand Up @@ -339,6 +372,9 @@ Context is JsonObject jo
catch (Exception ex)
{
Logger.LogError(ex, "Critical error in DataBind for Area {area}", Area);
// Surface it — never let a binding fault freeze the area silently (the wedge symptom). The
// default-value fallback below keeps the property usable; SurfaceError shows the fault.
SurfaceError(ex, $"Data binding for area {Area}");
// Attempt to set default value as last resort
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,12 @@ export function registerCompletionProvider(editorId, config) {
// @ can be followed by paths with slashes: @agent/Name, @content/path/file
// / is only a trigger at word boundary (for commands like /agent)
if (trigger === '/') {
const regex = new RegExp(`(?:^|\\s)${escapedTrigger}([\\w\\-\\.]+)?$`);
// A slash-command is only valid at the very START of the composer (line 1, only
// optional whitespace before it). Triggering on a '/' mid-code — paths (`cd /etc`),
// "//" comments, `a / b`, `http://` — popped the command list, which is now heavy
// (every CLI command + skill) and churned Monaco → "stuck when entering code".
if (position.lineNumber !== 1) continue;
const regex = new RegExp(`^\\s*${escapedTrigger}([\\w\\-\\.]+)?$`);
const match = textUntilPosition.match(regex);
if (match) {
// Adjust match to not include the leading space
Expand Down
Loading
Loading