The Mesh panel has been read-only since Phase 8. You could watch channels, see who was online, follow the message feed. But you couldn’t say anything.

That changes now. A fixed dock sits below the scrollable panel content — visible in all three views, always in reach. Channel select, message type select, textarea. Enter to send. Immediate poll on success so the message appears in the timeline without waiting for the next cycle.

I wanted this in all three views, not just Channel view. The use case is watching the Feed and wanting to drop a note into whatever channel is active — you shouldn’t have to switch views for that. Clicking a channel item in Overview or Feed now selects it as the send target. The dock follows.

The endpoint

The backend is one new method in MeshResource: POST /api/mesh/channels/{name}/messages. Body: {"content": "...", "type": "status"}.

try {
    MessageResult result =
        qhorusMcpTools.sendMessage(name, "human", type, req.content(), null, null);
    return Response.ok(result).build();
} catch (ToolCallException e) {
    Throwable cause = e.getCause();
    if (cause instanceof IllegalArgumentException) {
        return Response.status(404).entity(cause.getMessage()).build();
    }
    return Response.status(409).entity(
        cause != null ? cause.getMessage() : e.getMessage()).build();
}

The ToolCallException handling is worth noting. QhorusMcpTools has @WrapBusinessError on the class — every IllegalArgumentException and IllegalStateException thrown inside a @Tool method gets rewrapped into ToolCallException(Throwable cause) before it exits the CDI proxy. A bare catch (IllegalArgumentException) misses channel-not-found entirely. The distinction between 404 and 409 comes from inspecting e.getCause().

I’d already noted this in the Phase 8 entry as a gotcha for MeshResource.timeline(). The difference here is that we needed the distinction, not just a fallback.

Two bugs that CSS and JS let through

The dock textarea and channel select were invisible at first run. The CSS used var(--bg-secondary) for their backgrounds — a variable that doesn’t exist in this project’s :root. When a CSS custom property can’t be resolved, the browser substitutes the initial value for the property. For background, that’s transparent. Dark panel, transparent inputs — nothing visible.

The correct variable is var(--bg), same as the dialog inputs elsewhere. Claude caught it during review. One-line fix in two places.

The JS had a subtler gap. Channel names from Qhorus agents were being embedded directly in onclick attribute strings:

onclick="meshPanel.selectChannel('${escapeHtml(ch.name)}')"

escapeHtml() covers HTML entities — &, <, >, ", '. It does not touch JS metacharacters: ;, ), backslash. A channel name containing ');alert(1);// breaks out of the string cleanly. HTML escaping is not JS string escaping.

The fix is the pattern you’d reach for in any modern codebase but probably wouldn’t if you were just trying to get something working: data-channel attribute plus querySelectorAll binding after innerHTML is set. The DOM decodes the HTML entities back to characters before your event listener reads el.dataset.channel. The JS engine never sees the value inside a string literal.

The test infrastructure

Running @QuarkusTest with both Hibernate ORM and quarkus-qhorus’s hibernate-reactive-panache on the classpath hit a wall: H2 has no reactive driver. The test context threw a HibernateReactive boot failure — all tests in the run, not just Qhorus-related ones.

The fix is a single property:

%test.quarkus.datasource.reactive=false

This tells Quarkus to skip reactive datasource init in the test profile. Hibernate Reactive falls back to JDBC mode. The pattern comes from Qhorus’s own test properties — not documented anywhere in the Quarkus guides.

246 tests passing. The human is now a participant in the mesh, not just a reader.


<
Previous Post
Phase 13: DB Independence and the Reactive Question
>
Next Post
Ledger Adaptation and the Dual-Stack Decision