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

  1. The thread node is the state. The view watches MeshOps.watch(threadPath) and renders the node's Thread content: messages (ordered ids), pendingUserMessages (queued payloads keyed by id), status (Idle | StartingExecution | Executing | Cancelled | Done), executionStatus, streamingText, streamingToolCalls, and composer (the sticky agent/model selection).
  2. 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 a ThreadMessage (role / text / status / toolCalls / …). Until the cell exists, the bubble renders from the pending payload in pendingUserMessages, shown as queued — so a just-sent message appears instantly.
  3. While the thread executes, an execution bar shows the live executionStatus, a streamingText preview, the running streamingToolCalls, 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):

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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.