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
25 changes: 25 additions & 0 deletions clients/grpc-web/src/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,28 @@ describe("MeshWebConnection (gRPC-web split, in-memory)", () => {
mesh.close();
});
});

describe("unsolicited typed messages (onMessage)", () => {
it("dispatches a server-initiated NavigationRequest to its handler, after pending/stream demux", async () => {
const { fakeMeshTransport } = await import("./testTransport");
const { MeshWebConnection } = await import("./connection");
const conn = await MeshWebConnection.connect("https://portal.example", { transport: fakeMeshTransport() });
const seen: string[] = [];
const off = conn.onMessage("NavigationRequest", (d) => seen.push(String(d.message["uri"])));
// Round-trip an Echo so the fake's stream is live, then hand-feed an unsolicited delivery the
// way the server sends one: no RequestId, no streamId — just a typed message to this node.
await conn.observe("any", "EchoRequest", { text: "warm" });
(conn as unknown as { onFrame(r: string): void }).onFrame(
JSON.stringify({ id: "x", sender: "Store", target: conn.address,
message: { $type: "NavigationRequest", uri: "Store/Catalog/Catalog?category=Games" }, properties: {} }),
);
expect(seen).toEqual(["Store/Catalog/Catalog?category=Games"]);
off();
(conn as unknown as { onFrame(r: string): void }).onFrame(
JSON.stringify({ id: "y", sender: "Store", target: conn.address,
message: { $type: "NavigationRequest", uri: "Elsewhere" }, properties: {} }),
);
expect(seen).toEqual(["Store/Catalog/Catalog?category=Games"]); // unsubscribed — not delivered
conn.close();
});
});
25 changes: 24 additions & 1 deletion clients/grpc-web/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export class MeshWebConnection {
private connectionId = "";
private readonly pending = new Map<string, (d: Delivery) => void>();
private readonly subscriptions = new Map<string, (d: Delivery) => void>();
// Handlers for UNSOLICITED typed messages (no RequestId, no streamId) — the server-initiated
// channel: a clickable control's ClickedEvent is answered with a NavigationRequest addressed to
// this participant, exactly what Blazor's PortalApplication handles with WithHandler<...>.
private readonly typedHandlers = new Map<string, Set<(d: Delivery) => void>>();
// The SubscribeRequest params per live streamId — kept so a dropped Connect stream can be
// re-opened and every subscription REPLAYED (see open()'s reconnect loop). Without replay, a
// mid-delivery drop strands the UI on whatever subset of area frames arrived first.
Expand Down Expand Up @@ -157,7 +161,26 @@ export class MeshWebConnection {
return;
}
const streamId = (delivery.message["streamId"] ?? delivery.message["StreamId"]) as string | undefined;
if (streamId && this.subscriptions.has(streamId)) this.subscriptions.get(streamId)!(delivery);
if (streamId && this.subscriptions.has(streamId)) {
this.subscriptions.get(streamId)!(delivery);
return;
}
if (delivery.messageType) this.typedHandlers.get(delivery.messageType)?.forEach((h) => h(delivery));
}

/**
* Subscribe to unsolicited messages of `messageType` — the client twin of the Blazor portal
* hub's `WithHandler<T>` (e.g. `NavigationRequest`: a server click action tells THIS participant
* where to go). Returns the unsubscribe.
*/
onMessage(messageType: string, handler: (d: Delivery) => void): () => void {
let set = this.typedHandlers.get(messageType);
if (!set) this.typedHandlers.set(messageType, (set = new Set()));
set.add(handler);
return () => {
set!.delete(handler);
if (set!.size === 0) this.typedHandlers.delete(messageType);
};
}

/** Send `delivery` to the mesh via the unary Deliver, tagged with our connection id. */
Expand Down
19 changes: 19 additions & 0 deletions clients/grpc-web/src/mesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,22 @@ describe("Mesh ops (gRPC-web, in-memory)", () => {
mesh.close();
});
});

describe("Mesh.autocomplete — the ONE @-mention implementation (was duplicated per shell)", () => {
it("targets the viewer's home hub with AutocompleteRequest and returns the items", async () => {
const targets: string[] = [];
const mesh = await Mesh.connect("https://portal.example", {
transport: fakeMeshTransport({ onDeliver: (d) => { targets.push(String(d.target)); } }),
});
const items = await mesh.autocomplete("rbuergi", "Doc", "rbuergi/Thread/t1");
expect(items).toEqual([{ label: "hit:Doc", insertText: "@/Doc", path: "Doc" }]);
expect(targets).toContain("rbuergi");
mesh.close();
});

it("answers [] with no target — an anonymous composer types on undisturbed", async () => {
const mesh = await Mesh.connect("https://portal.example", { transport: fakeMeshTransport() });
expect(await mesh.autocomplete("", "Doc")).toEqual([]);
mesh.close();
});
});
26 changes: 26 additions & 0 deletions clients/grpc-web/src/mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,32 @@ export class Mesh {
return Array.isArray(parsed.results) ? parsed.results : [];
}

/**
* One-shot @-mention autocomplete — the wire `AutocompleteRequest(Query, Context)` every
* data-enabled hub handles (`DataExtensions.HandleAutocompleteRequest`), targeted at the
* VIEWER's home hub (`target`), which aggregates all providers. THE one implementation:
* portal and portal-next each hand-rolled this observe (the #1497 drift shape — same wire,
* two copies, camelCase/PascalCase tolerated differently); both fold into this. Answers []
* on any failure — a composer whose suggestions cannot be served types on undisturbed.
*/
async autocomplete(
target: string,
query: string,
contextPath?: string,
): Promise<Record<string, unknown>[]> {
if (!target) return [];
try {
const resp = await this.conn.observe(target, "AutocompleteRequest", {
query,
context: contextPath ?? null,
});
const items = (resp.message["items"] ?? resp.message["Items"]) as Record<string, unknown>[] | undefined;
return Array.isArray(items) ? items : [];
} catch {
return [];
}
}

/** Read a single node's current state (one snapshot off its live stream). */
async get(path: string): Promise<MeshNode> {
for await (const node of this.watch(path)) return node;
Expand Down
8 changes: 8 additions & 0 deletions clients/grpc-web/src/testTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export function fakeMeshTransport(opts: FakeMeshOptions = {}) {
case "EchoRequest":
respond(out, d, { $type: "EchoResponse", text: d.message.text });
break;
case "AutocompleteRequest":
// The @-mention surface (DataExtensions.HandleAutocompleteRequest) — echoes a
// suggestion derived from the query so tests can assert the round-trip + target.
respond(out, d, {
$type: "AutocompleteResponse",
items: [{ label: `hit:${d.message.query}`, insertText: `@/${d.message.query}`, path: String(d.message.query) }],
});
break;
// No QueryRequest responder — the REAL server has no such handler (issue #1473), and
// faking one here is exactly how Mesh.search shipped posting into the void. Search is
// REST (`/api/mesh/query-nodes`); tests exercise it via an injected fetch.
Expand Down
87 changes: 25 additions & 62 deletions clients/react-native/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,12 @@ import { rnPack } from "./src/rnPack";
import { sampleArea } from "./src/sample";
import { createLiveSource } from "./src/live";
import { buildMeshOps } from "./src/liveOps";
import { NavContext, CurrentAddressContext, type NavTarget } from "./src/nav";
import { NavContext, CurrentAddressContext, resolveNavigationUri, type NavTarget } from "./src/nav";
import { Shell, HOME } from "./src/Shell";
import { ensureWebStyles } from "./src/webStyles";
import { attachInstanceStore, currentInstance, discoverInstances, mergeDiscovered, setConnectStatus, type MeshInstance } from "./src/connection";
import { type ClientDestination } from "./src/screens";
import { ThemeProvider, useTheme } from "./src/theme";
import { ChatComposer } from "./src/chat";
import { ExpoAvRecorder } from "./src/speech/expoRecorder";
import { SpeechTranscriptionClient } from "./src/speech/transcription";
import type { ThreadSubmitter } from "./src/speech/pushToTalk";

// The client connects to the CURRENT mesh instance — "Local" is the mesh that served this app
// (same origin, anonymous, no CORS); a remote instance is a portal the user added by URL + token
Expand All @@ -38,31 +34,14 @@ import type { ThreadSubmitter } from "./src/speech/pushToTalk";
// against a static file server). connect() only resolves on a real ack, so a non-mesh origin
// can never swap the sample out for an empty live source.

// The chat composer + CENTRALIZED speech pipeline (distinct from the shell's VoiceScreen, which is
// the browser's on-device Web Speech API). `namespacePath` anchors new threads (your partition);
// speech records via expo-av and posts the audio to the portal's `POST /api/speech/transcribe`
// (the centralized Whisper container — see src/speech/transcription.ts; for a dev container use
// `speech: { url: "http://localhost:8080", path: "/inference" }`). Set CHAT to null to hide the
// composer entirely. Submission rides the SAME gRPC-web connection the renderer uses.
interface ChatOptions {
namespacePath: string;
speech?: { url?: string; token?: string; path?: string; language?: string } | null;
}
// Speech follows the CURRENT mesh instance: with no `speech.url`/`speech.path`, the transcription client
// POSTs to `{instance}/api/speech/transcribe` — the endpoint every backend now bakes in (the portal AND
// the local sidecar Memex.LocalMesh), so voice input works in every shell (web, the macOS/Windows desktop
// apps, and against a remote portal) with no separate container URL. To point at a bare dev Whisper
// container instead, set `speech: { url: "http://localhost:8080", path: "/inference" }`.
const CHAT: ChatOptions | null = {
namespacePath: "rbuergi",
speech: { language: "de" },
};
// const CHAT: ChatOptions | null = null;

// The app-level "Message the mesh…" bar is GONE: the composer is the mesh-rendered
// ThreadChatControl (the user home's Composer band, thread views), whose RN leaf now carries the
// SAME shared model as the web leaf — @-mention autocomplete included — plus the speech pipeline
// (ComposerBar). ONE composer, declared by the server, exactly like Blazor.
// Threads anchor in the viewer's OWN partition: on the native local mesh that is the device user
// (seeded by the sidecar's DeviceSeed); against a remote portal the configured namespacePath applies.
const chatNamespace = (inst: MeshInstance): string =>
Platform.OS !== "web" && inst.local ? "device-user" : (CHAT?.namespacePath ?? "");
Platform.OS !== "web" && inst.local ? "device-user" : "";

// 📱 The native-local landing: the device user's own node, rendered with its DECLARED default
// area (the User layout declares the activities — the same landing as the MAUI shell) — the app
Expand Down Expand Up @@ -105,11 +84,13 @@ function AppInner() {
// The CURRENT home: local → the device user's node; a signed-in remote → the viewer's OWN node
// (resolved from the portal via /api/mesh/whoami below); anonymous remote → the docs HOME.
const [home, setHome] = useState<NavTarget>(() => homeFor(currentInstance()));
// The signed-in REMOTE viewer's own partition (whoami) — read lazily by MeshOps (kernel anchor,
// autocomplete target), which is built before whoami resolves.
const viewerPartition = useRef("");
const [clientScreen, setClientScreen] = useState<ClientDestination | null>(null);
const [instanceTick, setInstanceTick] = useState(0);
const [source, setSource] = useState<AreaSource>(() => new StaticAreaSource(sampleArea));
const [liveConnected, setLiveConnected] = useState(false);
const [submitter, setSubmitter] = useState<ThreadSubmitter | undefined>(undefined);
// The factory `@@` layout-area embeds (LayoutAreaControl) open their nested area streams through.
const [embedFactory, setEmbedFactory] = useState<AreaSourceFactory | null>(null);
// The MeshOps surface (interactive-markdown render + kernel, thread submit, node ops) the tree consumes
Expand Down Expand Up @@ -144,6 +125,7 @@ function AppInner() {
useEffect(() => {
const inst = currentInstance();
const base = homeFor(inst);
viewerPartition.current = "";
setHome(base);
if (inst.local || !inst.token) return;
let live = true;
Expand All @@ -157,6 +139,7 @@ function AppInner() {
const userId = j?.userId ? String(j.userId).trim() : "";
if (!live || !userId) return;
const userHome: NavTarget = { address: userId, area: "" };
viewerPartition.current = userId;
setHome(userHome);
setNav((n) => (n.address === base.address && n.area === base.area ? userHome : n));
})
Expand All @@ -181,7 +164,7 @@ function AppInner() {
let live: Awaited<ReturnType<typeof createLiveSource>> | null = null;
let cancelled = false;
setConnectStatus(`Connecting to ${inst.name}…`);
createLiveSource({ url: inst.url, token: inst.token, address: nav.address, area: nav.area })
createLiveSource({ url: inst.url, token: inst.token, address: nav.address, area: nav.area, id: nav.id })
.then((l) => {
if (cancelled) {
l.connection.close();
Expand All @@ -191,13 +174,19 @@ function AppInner() {
setSource(l.source);
setLiveConnected(true);
setConnectStatus("");
// The SAME gRPC-web connection carries thread submissions (Mesh.startThread / Mesh.submitMessage)
// AND the nested streams that `@@("area/X")` layout-area embeds open.
setSubmitter(Mesh.from(l.connection));
setEmbedFactory(() => createGrpcEmbeddedFactory(l.connection));
// Server-initiated navigation (a clickable stack's ClickedEvent is answered with a
// NavigationRequest — the Store's category tiles) — the client twin of Blazor's
// NavigationService: resolve the uri's node/area split server-side and navigate.
l.connection.onMessage("NavigationRequest", (d) => {
const uri = String((d.message as { uri?: unknown }).uri ?? "");
if (!uri) return;
void resolveNavigationUri(uri, inst.url, inst.token || undefined).then(navigate);
});
// The full MeshOps over the same connection — renderMarkdown (server Markdig) + the per-view kernel
// anchor the interactive markdown + runnable code cells; the kernel activity lives in CHAT's partition.
setMeshOps(buildMeshOps(l.connection, inst.url, chatNamespace(inst), inst.token));
// anchor the interactive markdown + runnable code cells; the kernel activity lives in the viewer's partition.
setMeshOps(buildMeshOps(l.connection, inst.url,
() => chatNamespace(inst) || viewerPartition.current, inst.token));
// The LOCAL mesh is the instance STORE: hydrate the switcher's mesh list from its
// MemexInstance nodes. A remote connect detaches (the in-memory cache keeps serving).
void attachInstanceStore(
Expand Down Expand Up @@ -227,26 +216,9 @@ function AppInner() {
cancelled = true;
live?.connection.close();
};
}, [nav.address, nav.area, instanceTick]);
}, [nav.address, nav.area, nav.id, instanceTick]);


// Speech seams — the transcription endpoint follows the CURRENT instance (or an explicit
// CHAT.speech.url override); the composer hides the mic when speech isn't configured.
const speech = useMemo(() => {
if (!CHAT?.speech) return null;
const inst = currentInstance();
const url = CHAT.speech.url ?? inst.url;
if (!url) return null; // no portal to transcribe against
return {
recorder: new ExpoAvRecorder(),
transcriber: new SpeechTranscriptionClient({
url,
token: CHAT.speech.token ?? inst.token,
path: CHAT.speech.path,
language: CHAT.speech.language,
}),
language: CHAT.speech.language,
};
}, [instanceTick]);

// Offline (no ack yet): the sample tree's root area is "main"; live nav areas only exist
// once a mesh is streaming.
Expand Down Expand Up @@ -276,15 +248,6 @@ function AppInner() {
</EmbeddedAreaProvider>
</MeshOpsProvider>
</RegistryProvider>
{CHAT && (
<ChatComposer
submitter={submitter}
namespacePath={chatNamespace(currentInstance())}
recorder={speech?.recorder}
transcriber={speech?.transcriber}
language={speech?.language}
/>
)}
</SafeAreaView>
);
}
11 changes: 10 additions & 1 deletion clients/react-native/src/leftMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useState, type ReactNode } from "react";
import { View, Text, Pressable, ScrollView } from "react-native";
import type { AreaTree } from "@meshweaver/react/core";
import { type NavTarget } from "./nav";
import { IconGlyph } from "./rnIcon";
import { useStyles, type Palette } from "./theme";
import { StyleSheet } from "react-native";

Expand Down Expand Up @@ -116,7 +117,7 @@ export function LeftMenuView<D>({
if (it.area) onNavigate({ address: nav.address, area: it.area });
};

const rowLabel = (it: MenuItem) => `${it.icon ? `${it.icon} ` : ""}${it.label ?? it.area ?? ""}`;
const rowLabel = (it: MenuItem) => it.label ?? it.area ?? "";

const entry = (it: MenuItem, i: number) =>
it.area === SEPARATOR_AREA ? (
Expand All @@ -125,6 +126,7 @@ export function LeftMenuView<D>({
<NavRow
key={i}
label={rowLabel(it)}
icon={it.icon}
active={!clientScreen && nav.area === it.area}
onPress={() => activate(it)}
chevron={isSubmenuParent(it)}
Expand Down Expand Up @@ -193,13 +195,18 @@ export function LeftMenuView<D>({
*/
export function NavRow({
label,
icon,
active,
onPress,
chevron,
accessibilityLabel,
opensSubmenu,
}: {
label: string;
/** Optional icon string — emoji, inline SVG, or a mesh-relative .svg URL. Drawn as a real
* GLYPH (IconGlyph): inlining it into the label rendered the colorful node icons as their
* literal "/static/….svg" paths. */
icon?: string;
active: boolean;
onPress: () => void;
chevron?: boolean;
Expand All @@ -215,6 +222,7 @@ export function NavRow({
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={opensSubmenu ? { expanded: false } : { selected: active }}
>
{icon ? <View style={styles.navIcon}><IconGlyph icon={icon} size={18} /></View> : null}
<Text style={[styles.navItemText, active && styles.navItemTextActive]} numberOfLines={1}>{label}</Text>
{chevron ? <Text style={styles.navChevron}>›</Text> : null}
</Pressable>
Expand All @@ -230,6 +238,7 @@ const makeStyles = (p: Palette) =>
navItem: { flexDirection: "row", alignItems: "center", minHeight: 44, paddingHorizontal: 16, paddingVertical: 7, marginHorizontal: 8, borderRadius: 6 },
navItemHover: { backgroundColor: p.navHover },
navItemActive: { backgroundColor: p.navActiveBg },
navIcon: { width: 22, marginRight: 8, alignItems: "center" },
navItemText: { flexShrink: 1, fontSize: 13.5, color: p.text },
navItemTextActive: { color: p.navActiveText, fontWeight: "600" },
navChevron: { marginLeft: "auto", paddingLeft: 8, fontSize: 16, color: p.textMuted },
Expand Down
Loading
Loading