Thread Chat in React
ThreadChatView (clients/react/src/controls/threadChat.tsx) is the React port of the Blazor portal's ThreadChatView.razor — the full chat experience over a thread node's live stream. There is no chat protocol of its own: the view watches and writes the exact node shapes the .NET HubThreadExtensions surface defines (see Thread Operations), so both frontends, the SDKs, and the agents all converge on the same thread node.
MeshOps — what the chat needs beyond the area contract
Most controls only need the layout-area AreaSource. Chat needs two more capabilities — watching arbitrary node streams and the canonical thread-submission operations — expressed as the MeshOps interface (clients/react/src/live/meshOps.tsx):
export interface MeshOps {
/** Subscribe to a node's live state — yields on every change (initial state, then updates). */
watch(path: string): AsyncIterable<MeshNodeState>;
/** ONE CreateNodeRequest carrying the seeded thread node — the client twin of hub.StartThread. */
startThread(namespacePath: string, userText: string, opts?: ThreadSubmitOptions):
Promise<{ path: string; userMessageId?: string }>;
/** JSON-merge patch queueing a message on an existing thread — the twin of hub.SubmitMessage. */
submitMessage(threadPath: string, userText: string, opts?: ThreadSubmitOptions): Promise<string | null>;
/** Field-level partial node update (RFC 7396) — control-plane flips like requestedStatus. */
patch(path: string, fields: Record<string, unknown>): void;
/** Optional mesh query — feeds the agent/model selectors (nodeType:Agent / nodeType:LanguageModel). */
search?(query: string, basePath?: string, limit?: number): Promise<Record<string, unknown>[]>;
}
The renderer stays transport-free: @meshweaver/client-web's Mesh satisfies MeshOps structurally, so the host app wires it in one line:
import { Mesh } from "@meshweaver/client-web";
import { MeshOpsProvider } from "@meshweaver/react";
const mesh = Mesh.from(connection); // or: await Mesh.connect(url, { token })
// <MeshOpsProvider ops={mesh}> … <RenderArea/> … </MeshOpsProvider>
// (MeshAreaView takes the same thing as its `ops` prop.)
Without a provider the chat renders a hint ("Thread chat needs a live mesh connection") instead of crashing.
Data flow — mirrors Blazor exactly
- The thread node is the state. The view watches
MeshOps.watch(threadPath)and renders the node'sThreadcontent:messages(ordered ids),pendingUserMessages(queued payloads keyed by id),status(Idle | StartingExecution | Executing | Cancelled | Done),executionStatus,streamingText,streamingToolCalls, andcomposer(the sticky agent/model selection). - Each message is a satellite cell at
{threadPath}/{id}— one watch per id, the twin of Blazor's message subscriptions. A cell's content is aThreadMessage(role/text/status/toolCalls/ …). Until the cell exists, the bubble renders from the pending payload inpendingUserMessages, shown as queued — so a just-sent message appears instantly. - While the thread executes, an execution bar shows the live
executionStatus, astreamingTextpreview, the runningstreamingToolCalls, and a Stop button.
Submission — the canonical surface, not a wire protocol
Sends go through the client twin of HubThreadExtensions (implemented in clients/grpc-web/src/mesh.ts + threads.ts):
- No thread yet →
startThread(namespacePath, text, opts): oneCreateNodeRequest, targeted at the namespace hub, carrying the thread node at{namespace}/_Thread/{speakingId}pre-seeded with the first user message incontent.pendingUserMessagesand the composer selection. The per-thread submission watcher on the hub dispatches the first round as soon as the thread hub activates. Once created, the view pins the returned path — message 2+ never re-creates. - Existing thread →
submitMessage(threadPath, text, opts): an RFC 7396 merge-patch on the thread node appending the id tocontent.userMessageIdsand the payload tocontent.pendingUserMessages— the client-side analog ofworkspace.GetMeshNodeStream(threadPath).Update(...); the owning hub serialises the patch. Whitespace-only text resolves tonulland nothing is enqueued. - Stop →
patch(threadPath, { content: { requestedStatus: "Cancelled" } })— the standard control-plane flip the owning hub's watcher reacts to (see Activity Control Plane).
Guards match the server's: a top-level/ownerless _Thread/{id} path is refused client-side (isOwnerlessThreadPath) — such a path has no per-node hub to route to. Identity is never claimed client-side; the server stamps the submitter from the bearer token.
Submission failures are surfaced, not swallowed — the error renders above the composer and the typed text is restored, mirroring Blazor's onError (a silent reset is the "message vanished, no idea why" symptom).
Composer gating
The composer is gated exactly like Blazor's:
const isExecuting = thread.status === "StartingExecution" || thread.status === "Executing";
const canSend = !!ops && text.trim().length > 0 && !isExecuting && (!!threadPath || !!namespacePath);
— disabled while the text is whitespace-only or the thread is executing. Enter sends, Shift+Enter inserts a newline.
The agent / model dropdowns populate from the mesh when the ops expose search (nodeType:Agent / nodeType:LanguageModel); the selection defaults to the thread's embedded composer (the single source of the round's selection), and an explicit pick folds back into the composer on submit — so the choice sticks for the next round, in either frontend.
Rendering it
ThreadChat is a registered control $type like any other: a backend layout area that emits a ThreadChatControl renders as this view in React (and as ThreadChatView.razor in Blazor). The control's bound properties — threadPath (or a threadViewModel carrying it), initialContext (the namespace for new threads), hideEmptyState, showFullHeader — resolve through the standard useResolve binding hook.
Related
- Thread Operations — the canonical .NET submission surface these ops mirror.
- Rendering Architecture —
MeshOpsProviderin theMeshAreaViewcomposition. - Testing & Parity — the chat's vitest suite.