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
2 changes: 1 addition & 1 deletion src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ else
@if (!string.IsNullOrEmpty(boundModelPath))
{
<button type="button" class="thread-chat-status-item is-clickable"
title="Model — click to change" @onclick='() => OnStatusChipClick("model", false)'>
title="Model: @boundModelPath — click to change" @onclick='() => OnStatusChipClick("model", false)'>
@ChipSvg("model") @LastSegment(boundModelPath)
</button>
}
Expand Down
9 changes: 8 additions & 1 deletion src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,10 @@
.thread-chat-status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
/* Single row — never wrap. Overflowing chips are clipped (each chip ellipsis-truncates its own
text via .thread-chat-status-item), and the full value is on the chip's title (hover). */
flex-wrap: nowrap;
overflow: hidden;
gap: 10px;
padding: 0 2px 6px 2px;
font-size: 11px;
Expand All @@ -427,6 +430,10 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
/* Allow the chip to shrink below its content width so its text ellipsis-truncates when the
single-row status bar runs out of horizontal room (min-width:auto would block the ellipsis). */
min-width: 0;
flex: 0 1 auto;
}

/* The chips are <button> now (clickable → picker/combobox). Strip native button chrome; keep the chip look. */
Expand Down
67 changes: 51 additions & 16 deletions src/MeshWeaver.Graph/DeleteLayoutArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,22 +204,32 @@ private static Task StartDelete(
if (response.Message is DeleteNodeResponse { Success: true })
{
ctx.Host.UpdateData(progressId, DeleteStatus.Done);
// Redirect to the nearest ancestor that is an ACTUAL mesh node.
// The node we were looking at no longer exists, and its immediate
// parent PATH is frequently a virtual grouping (e.g. ".../Script")
// with no node of its own — redirecting straight there would just
// land the user on another "No node found" page. Resolve the closest
// existing ancestor instead; a top-level node (none) goes home. The
// bare node URL renders that node's default area (Mesh URL shape).
ResolveNearestExistingAncestor(meshQuery, nodePath)
.Take(1)
.Timeout(TimeSpan.FromSeconds(10))
.Catch<string?, Exception>(_ => Observable.Return(GetParentPath(nodePath)))
.Subscribe(ancestor =>
{
var target = ancestor is null ? "/" : $"/{ancestor}";
ctx.Host.UpdateArea(ctx.Area, new RedirectControl(target));
});
// Redirect to the parent page. The node we were looking at no longer
// exists, so we must leave its page FAST — otherwise the just-deleted
// node's area re-renders against a gone node and errors (the "reload
// then error"). Two paths:
// • The immediate parent is a SATELLITE grouping (_Thread, _Activity,
// …) — it has no node of its own, so resolve the target by PURE PATH
// (walk up past satellite segments) IMMEDIATELY, no query. This is
// the common case (a thread → the user's home) and, crucially, a
// distributed portal's cross-partition existence probe can't stall
// the redirect behind a timeout (which is what left the dead page up
// long enough to reload + error).
// • A NON-satellite parent (a virtual grouping like ".../Script") can
// only be told apart from a real node by asking — keep the existence
// walk there, but bounded, and fall back to the pure-path ancestor
// (never the maybe-virtual immediate parent) so the fallback can't
// land on another "No node found" page.
var immediateParent = GetParentPath(nodePath);
var target = IsSatelliteSegment(immediateParent)
? Observable.Return(NearestNonSatelliteAncestor(nodePath))
: ResolveNearestExistingAncestor(meshQuery, nodePath)
.Take(1)
.Timeout(TimeSpan.FromSeconds(5))
.Catch<string?, Exception>(_ => Observable.Return(NearestNonSatelliteAncestor(nodePath)));
target.Subscribe(ancestor =>
ctx.Host.UpdateArea(ctx.Area,
new RedirectControl(ancestor is null ? "/" : $"/{ancestor}")));
}
else
{
Expand Down Expand Up @@ -282,6 +292,31 @@ private static void ShowDialog(UiActionContext ctx, string title, string message
return lastSlash > 0 ? path[..lastSlash] : null;
}

/// <summary>Last segment of the path (the bit after the final '/'), or the whole path.</summary>
private static string LastSegment(string path)
=> path[(path.LastIndexOf('/') + 1)..];

/// <summary>
/// True when the path's last segment is a SATELLITE grouping (<c>_Thread</c>, <c>_Activity</c>,
/// <c>_Comment</c>, …) — a '_'-prefixed segment that anchors satellites but is never a node of its
/// own. Redirecting there after a delete always lands on "No node found".
/// </summary>
private static bool IsSatelliteSegment(string? path)
=> path is not null && LastSegment(path).StartsWith('_');

/// <summary>
/// Nearest ancestor whose last segment is NOT a satellite grouping — resolved by PURE PATH, no
/// query (so it can never stall on a distributed portal). A thread <c>{user}/_Thread/{id}</c>
/// resolves to <c>{user}</c>; a top-level node resolves to <c>null</c> (redirect home).
/// </summary>
internal static string? NearestNonSatelliteAncestor(string nodePath)
{
for (var p = GetParentPath(nodePath); p is not null; p = GetParentPath(p))
if (!LastSegment(p).StartsWith('_'))
return p;
return null;
}

/// <summary>
/// Resolves the nearest ANCESTOR of <paramref name="nodePath"/> that is an actual mesh node,
/// walking up the path nearest-first. The immediate parent PATH segment is frequently a virtual
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,24 @@ public async Task Redirect_goes_home_for_top_level_node()
var target = await DeleteLayoutArea.ResolveNearestExistingAncestor(MeshSvc, "SomeTopLevelNode").Take(1).ToTask();
target.Should().BeNull("a top-level node has no ancestor; the flow redirects home");
}

/// <summary>
/// The satellite-parent fast path: deleting a node whose immediate parent is a satellite grouping
/// (a thread under <c>_Thread</c>, a comment under <c>_Comment</c>) resolves the redirect target by
/// PURE PATH — no query — so on a distributed portal a cross-partition existence probe can't stall
/// the redirect and leave the just-deleted page up long enough to reload + error. This is the case
/// the user hit deleting a thread.
/// </summary>
[Fact]
public void NearestNonSatelliteAncestor_skips_satellite_grouping()
{
// Thread {user}/_Thread/{id} → skip the _Thread satellite → the user's home.
Assert.Equal("rbuergi", DeleteLayoutArea.NearestNonSatelliteAncestor("rbuergi/_Thread/hello-6b4c"));
// Comment {thread}/_Comment/{id} → skip _Comment → the owning thread (a real node).
Assert.Equal("rbuergi/_Thread/hello", DeleteLayoutArea.NearestNonSatelliteAncestor("rbuergi/_Thread/hello/_Comment/c1"));
// A real immediate parent is kept as-is.
Assert.Equal("Acme/Sales", DeleteLayoutArea.NearestNonSatelliteAncestor("Acme/Sales/Q3"));
// Top-level node → null (redirect home).
Assert.Null(DeleteLayoutArea.NearestNonSatelliteAncestor("SomeTopLevelNode"));
}
}
Loading