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
15 changes: 11 additions & 4 deletions src/MeshWeaver.AI/AgentView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,13 @@ public static UiControl Details(LayoutAreaHost host, RenderingContext ctx)
(h, c) => host.Workspace.GetMeshNodeStream()
.Select(node =>
{
var agent = AsAgentConfiguration(node, host.Hub.JsonSerializerOptions);
if (node == null || agent == null)
if (node == null)
return RenderLoading("Loading agent...");
// A freshly-created Agent has no AgentConfiguration content yet — the create
// flow persists an Active node with null Content and edits it afterward. Show a
// default (empty) config instead of an endless "Loading…".
var agent = AsAgentConfiguration(node, host.Hub.JsonSerializerOptions)
?? new AgentConfiguration { Id = node.Id };
return BuildDetailsLayout(host, node, agent);
}),
"Content"
Expand Down Expand Up @@ -260,9 +264,12 @@ public static UiControl Edit(LayoutAreaHost host, RenderingContext ctx)
(h, c) => host.Workspace.GetMeshNodeStream()
.Select(node =>
{
var agent = AsAgentConfiguration(node, host.Hub.JsonSerializerOptions);
if (node == null || agent == null)
if (node == null)
return RenderLoading("Loading agent...");
// A freshly-created Agent has no AgentConfiguration content yet — edit a
// default (empty) config; the first Save persists it to the node stream.
var agent = AsAgentConfiguration(node, host.Hub.JsonSerializerOptions)
?? new AgentConfiguration { Id = node.Id };
return BuildEditLayout(host, node, agent);
}),
"Content"
Expand Down
8 changes: 7 additions & 1 deletion src/MeshWeaver.AI/ModelProviderLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ public static MessageHubConfiguration AddModelProviderViews(this MessageHubConfi
=> host.Workspace.GetMeshNodeStream()
.Select(node =>
{
if (node?.Content is not ModelProviderConfiguration cfg)
if (node is null)
return (UiControl?)Controls.Markdown("_No provider data._");

// A freshly-created provider has no config yet — the create flow persists an Active
// node with null Content and edits it here (Overview doubles as the Edit area). Show a
// default config so the endpoint + Enter Key controls render; writes persist it.
var cfg = node.ContentAs<ModelProviderConfiguration>(host.Hub.JsonSerializerOptions)
?? new ModelProviderConfiguration { Provider = node.Name ?? node.Id };

var path = node.Path;
var title = cfg.Label ?? cfg.Provider ?? path.Split('/').Last();
var keyState = string.IsNullOrEmpty(cfg.ApiKey) ? "not set" : "set ✓";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ The legacy `workspace.UpdateMeshNode(update)` extension is `[Obsolete]` and poin
### Subscribe is mandatory on every cold-write surface

- `meshService.CreateNode(node)` / `UpdateNode(node)` / `DeleteNode(path)` — cold; subscribe to commit.
- `meshService.MoveNode(...)` / `meshService.CreateTransient(node)` — cold; subscribe.
- `meshService.MoveNode(...)` / `meshService.CopyNode(...)` — cold; subscribe.
- `remoteStream.Update(current => updated, ex => …)` — the `ex` callback fires on the stream's hub; the returned `void` IS the subscription.

---
Expand Down Expand Up @@ -486,7 +486,7 @@ If you find yourself using `.Take(N)` outside a `SelectMany` that immediately pr

1. **No `Task<T>` / `async` / `await` in mesh-reachable code.** Public methods on services, handlers, layout areas, and click actions return `IObservable<T>` (or `void`). An `async Task` method that awaits a hub operation deadlocks the hub ActionBlock.

2. **No `*Async` extension shims on `IMeshService`.** Use `meshService.CreateNode(node)` / `UpdateNode(node)` / `DeleteNode(path)` / `CreateTransient(node)` — these return `IObservable<MeshNode>`. Never use `.CreateNodeAsync(...)` / `.UpdateNodeAsync(...)` / `.DeleteNodeAsync(...)` / `.CreateTransientAsync(...)` — those extensions bridge to Task via `.ToTask()` and deadlock every time they are reached from a hub handler.
2. **No `*Async` extension shims on `IMeshService`.** Use `meshService.CreateNode(node)` / `UpdateNode(node)` / `DeleteNode(path)` — these return `IObservable<MeshNode>`. Never use `.CreateNodeAsync(...)` / `.UpdateNodeAsync(...)` / `.DeleteNodeAsync(...)` — those extensions bridge to Task via `.ToTask()` and deadlock every time they are reached from a hub handler.

3. **Use `hub.Observe(...)` instead of `RegisterCallback` / `AwaitResponse`.** The Task-returning overloads are `[Obsolete]`. Production code MUST use `hub.Observe(delivery)` (already-posted) or `hub.Observe(request, options?)` (also posts) — both return `IObservable<IMessageDelivery[<TResponse>]>`. `DeliveryFailure` flows via `OnError`; no Task-await deadlock surface, no silently-skipped callback.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ There are three access patterns, each covering a distinct class of operation:
| Create node | Service | `meshService.CreateNode(node).Subscribe(...)` |
| Update node | Service | `meshService.UpdateNode(node).Subscribe(...)` (routes through `GetMeshNodeStream(path).Update`) |
| Create-or-update (upsert) | Request | `hub.Observe<CreateOrUpdateNodeResponse>(new CreateOrUpdateNodeRequest(node)).Subscribe(...)` |
| Create transient | Service | `meshService.CreateTransient(node).Subscribe(...)` |
| Delete node | Service | `meshService.DeleteNode(path).Subscribe(...)` |
| Write as system / hub | AccessService | `using (accessService.ImpersonateAsSystem()) { … }` / `ImpersonateAsHub(hub)` |
| Move node | Message | `hub.Observe(new MoveNodeRequest(src, dst)).Subscribe(...)` |
Expand Down Expand Up @@ -131,9 +130,6 @@ meshService.CreateNode(node)
.SelectMany(created => meshService.UpdateNode(created with { Name = "Renamed Team" }))
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "create+update failed"));

// Transient node (UI edit-then-confirm flows)
meshService.CreateTransient(node).Subscribe(...);

// Delete — removes the node and all its descendants, bottom to top
meshService.DeleteNode("org/Acme/OldTeam").Subscribe(...);

Expand All @@ -146,7 +142,6 @@ hub.Observe<CreateOrUpdateNodeResponse>(new CreateOrUpdateNodeRequest(node))

- `CreateNode` — runs `INodeValidator`, sets state to Active.
- `UpdateNode` — validates; routes through the canonical `GetMeshNodeStream(path).Update` write path on the owning hub.
- `CreateTransient` — persists in Transient state; caller confirms or discards.
- `DeleteNode` — removes the node and all descendants (bottom to top).
- `CreateOrUpdateNodeRequest` — upsert; checks existence on the handler side and dispatches create or merge-patch update (see [CQRS](/Doc/Architecture/CqrsAndContentAccess)).
- Identity is auto-captured from `AccessService` and carried across `.Subscribe()` boundaries — see [AccessContextPropagation](/Doc/Architecture/AccessContextPropagation).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Doc/MyDoc/_Comment/abc123/reply1 (Reply node)
| Mutation entry point | Hub message handlers | Click actions in layout areas |
| Child list | Indexed `ThreadMessages` on the parent | Discovered via `Query` |
| Text edits | `DataChangeRequest` via `_Exec` sub-hub | Direct `stream.Update` |
| Node creation | `CreateNodeAsync` → confirm in handler | `CreateTransientAsync` → confirm via `stream.Update` |
| Node creation | `CreateNodeAsync` → confirm in handler | `CreateNode` (Active) → edit via `stream.Update` |

---

Expand Down
89 changes: 38 additions & 51 deletions src/MeshWeaver.Graph/CommentLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ internal static UiControl BuildOverview(LayoutAreaHost host, MeshNode? node, str
return Task.CompletedTask;
}));
if (canAct)
rightGroup = rightGroup.WithView(BuildReplyButton(host, hubPath, comment, currentUser));
rightGroup = rightGroup.WithView(BuildReplyButton(host, hubPath));
if (canAct && !isResolved && IsTopLevelComment(hubPath, comment))
rightGroup = rightGroup.WithView(BuildResolveButton(host, hubPath, comment));
if (canDelete || canComment)
Expand Down Expand Up @@ -215,7 +215,7 @@ internal static UiControl BuildOverview(LayoutAreaHost host, MeshNode? node, str
.DistinctUntilChanged()
.Select(replyPath => string.IsNullOrEmpty(replyPath)
? (UiControl?)null
: BuildReplyCreateForm(h, replyPath, replyPathStateId));
: BuildReplyCreateForm(h, replyPath, replyPathStateId, comment, currentUser));
});
}

Expand Down Expand Up @@ -365,13 +365,13 @@ private static UiControl BuildCommentEditor(LayoutAreaHost host, string hubPath,

/// <summary>
/// Builds the inline reply creation form with markdown editor, Cancel, and Create buttons.
/// Cancel deletes the transient node and hides the form.
/// Create sets the reply text, marks the node Active, and hides the form.
/// Cancel just closes the draft form (nothing was persisted).
/// Create writes the reply Active with its text in a single <c>CreateNode</c>.
/// </summary>
private static UiControl BuildReplyCreateForm(LayoutAreaHost host, string replyPath, string replyPathStateId)
private static UiControl BuildReplyCreateForm(LayoutAreaHost host, string replyPath, string replyPathStateId,
Comment comment, string currentUser)
{
var nodeFactory = host.Hub.ServiceProvider.GetRequiredService<IMeshService>();
var meshQuery = host.Hub.ServiceProvider.GetRequiredService<IMeshService>();
var replyTextDataId = $"replyText_{replyPath.Replace("/", "_")}";

host.UpdateData(replyTextDataId, new Dictionary<string, object?> { ["text"] = "" });
Expand Down Expand Up @@ -399,40 +399,45 @@ private static UiControl BuildReplyCreateForm(LayoutAreaHost host, string replyP
.WithAppearance(Appearance.Neutral)
.WithClickAction(_ =>
{
nodeFactory.DeleteNode(replyPath).Subscribe(
__ => host.UpdateData(replyPathStateId, ""),
_ => host.UpdateData(replyPathStateId, ""));
// Nothing was persisted — the draft lives only in the form's data stream.
host.UpdateData(replyPathStateId, "");
return Task.CompletedTask;
}))
.WithView(Controls.Button("Create")
.WithAppearance(Appearance.Accent)
.WithIconStart(FluentIcons.Add())
.WithClickAction(ctx =>
{
// Read text, then look up the transient reply in the workspace stream
// (not QueryAsync — AsynchronousCalls.md) and flip it to Active with the text.
// Read the draft text, then write the reply with ONE CreateNode (Active, through the
// access-control pipeline). No transient placeholder — the reply is authored at its
// own replyPath, a child of the comment being replied to.
ctx.Host.Stream.GetDataStream<Dictionary<string, object?>>(replyTextDataId)
.Take(1)
.Subscribe(data =>
{
var text = data?.GetValueOrDefault("text")?.ToString() ?? "";

var cache = host.Hub.ServiceProvider.GetRequiredService<IMeshNodeStreamCache>();
cache.Update(replyPath, n =>
var replyId = replyPath.Split('/').Last();
var replyNamespace = replyPath[..replyPath.LastIndexOf('/')];
var replyNode = new MeshNode(replyId, replyNamespace)
{
var replyComment = n.ContentAs<Comment>(host.Hub.JsonSerializerOptions);
// Existing node whose content can't be recovered → leave it alone, NEVER clobber.
if (n.Content is not null && replyComment is null)
return n;
replyComment ??= new Comment();
return n with
Name = $"Reply to {comment.Author}",
NodeType = CommentNodeType.NodeType,
State = MeshNodeState.Active,
Content = new Comment
{
State = MeshNodeState.Active,
Content = replyComment with { Text = text }
};
}, host.Hub.JsonSerializerOptions).Subscribe(
Id = replyId,
PrimaryNodePath = comment.PrimaryNodePath,
Author = currentUser,
Status = CommentStatus.Active,
Text = text
}
};
nodeFactory.CreateNode(replyNode).Subscribe(
_ => host.UpdateData(replyPathStateId, ""),
_ => host.UpdateData(replyPathStateId, ""));
// Keep the draft form OPEN on failure (don't clear the state) so the user's
// reply isn't silently lost, and log so the failure is diagnosable.
ex => host.Hub.ServiceProvider.GetService<ILogger<LayoutAreaHost>>()
?.LogWarning(ex, "Failed to create reply at {Path}", replyPath));
});
return Task.CompletedTask;
})));
Expand All @@ -441,40 +446,22 @@ private static UiControl BuildReplyCreateForm(LayoutAreaHost host, string replyP
}

/// <summary>
/// Builds the Reply icon button. Creates a transient reply node via IMeshService and shows inline Create area.
/// Builds the Reply icon button. Opens the inline reply form for a fresh reply path — NOTHING is
/// persisted until the user clicks Create (then one <c>CreateNode</c> writes the reply Active).
/// No transient placeholder is written, so an unsent draft never appears and there is no
/// PG-invisible node to resolve.
/// </summary>
private static UiControl BuildReplyButton(LayoutAreaHost host, string hubPath, Comment comment, string currentUser)
private static UiControl BuildReplyButton(LayoutAreaHost host, string hubPath)
{
return Controls.Html("<span style=\"cursor: pointer; font-size: 0.8rem; color: var(--accent-fill-rest);\" title=\"Reply\">↩</span>")
.WithClickAction(_ =>
{
var replyId = Guid.NewGuid().AsString();
var replyPath = $"{hubPath}/{replyId}";
var replyPathStateId = $"replyPath_{hubPath.Replace("/", "_")}";

var replyNode = new MeshNode(replyId, hubPath)
{
Name = $"Reply to {comment.Author}",
NodeType = CommentNodeType.NodeType,
State = MeshNodeState.Transient,
Content = new Comment
{
Id = replyId,
PrimaryNodePath = comment.PrimaryNodePath,
Author = currentUser,
Status = CommentStatus.Active
}
};

var nodeFactory = host.Hub.ServiceProvider.GetRequiredService<IMeshService>();
nodeFactory.CreateTransient(replyNode).Subscribe(
_ =>
{
var expandedStateId = $"replies_expanded_{hubPath.Replace("/", "_")}";
host.UpdateData(expandedStateId, true);
host.UpdateData(replyPathStateId, replyPath);
},
_ => { });
var expandedStateId = $"replies_expanded_{hubPath.Replace("/", "_")}";
host.UpdateData(expandedStateId, true);
host.UpdateData(replyPathStateId, replyPath);
return Task.CompletedTask;
});
}
Expand Down
Loading
Loading