diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
new file mode 100644
index 000000000..ffb117c65
--- /dev/null
+++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
@@ -0,0 +1,227 @@
+import {
+ Button,
+ Callout,
+ Classes,
+ Dialog,
+ HTMLTable,
+ InputGroup,
+ Intent,
+ NonIdealState,
+ Spinner,
+ Tag,
+ Tooltip,
+} from "@blueprintjs/core";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import createOverlayRender from "roamjs-components/util/createOverlayRender";
+import {
+ discoverSharedNodes,
+ type DiscoveredSharedNode,
+} from "~/utils/discoverSharedNodes";
+import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
+
+const formatModifiedAt = (modifiedAt: string): string =>
+ new Date(modifiedAt).toLocaleString();
+
+const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
+
+ |
+ {node.sourceApp}
+ |
+
+
+ {node.sourceSpaceName}
+
+
+ {node.sourceSpaceId}
+
+ |
+
+
+ {node.title}
+
+ |
+
+ {node.sourceNodeId ? (
+
+ {node.sourceNodeId}
+
+ ) : (
+ Not provided
+ )}
+ |
+
+ {formatModifiedAt(node.modifiedAt)}
+ |
+
+ {node.alreadyImported ? (
+
+ Imported
+
+ ) : (
+ Available
+ )}
+ |
+
+);
+
+const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
+ const [nodes, setNodes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [search, setSearch] = useState("");
+
+ const loadNodes = useCallback(async (): Promise => {
+ setLoading(true);
+ setError("");
+ try {
+ const context = await getSupabaseContext();
+ if (!context) throw new Error("Could not connect to shared persistence.");
+ const client = await getLoggedInClient();
+ if (!client) throw new Error("Could not connect to shared persistence.");
+ setNodes(
+ await discoverSharedNodes({
+ client,
+ currentSpaceId: context.spaceId,
+ }),
+ );
+ } catch (loadError) {
+ console.error("Failed to discover shared nodes:", loadError);
+ setError(
+ loadError instanceof Error
+ ? loadError.message
+ : "Could not load shared nodes.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadNodes();
+ }, [loadNodes]);
+
+ const visibleNodes = useMemo(() => {
+ const normalizedSearch = search.trim().toLocaleLowerCase();
+ if (!normalizedSearch) return nodes;
+ return nodes.filter((node) =>
+ [
+ node.sourceApp,
+ node.sourceSpaceName,
+ node.sourceSpaceId,
+ node.title,
+ node.sourceNodeId,
+ ].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
+ );
+ }, [nodes, search]);
+
+ return (
+
+ );
+};
+
+type Props = Record;
+
+export const renderDiscoverSharedNodesDialog = createOverlayRender(
+ "discourse-discover-shared-nodes",
+ DiscoverSharedNodesDialog,
+);
+
+export default DiscoverSharedNodesDialog;
diff --git a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
new file mode 100644
index 000000000..ca007246f
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import { buildDiscoveredSharedNodes } from "~/utils/discoverSharedNodes";
+
+type BuildArgs = Parameters[0];
+
+const resources: BuildArgs["resources"] = [
+ { space_id: 20, source_local_id: "node-1" },
+ { space_id: 20, source_local_id: "schema-1" },
+];
+const spaces: BuildArgs["spaces"] = [
+ {
+ id: 20,
+ name: "Research vault",
+ platform: "Obsidian",
+ url: "obsidian:vault-a",
+ },
+];
+const concepts: BuildArgs["concepts"] = [
+ {
+ is_schema: false,
+ last_modified: "2026-06-14T12:00:00.000Z",
+ schema_id: 200,
+ source_local_id: "node-1",
+ space_id: 20,
+ },
+];
+const contents: BuildArgs["contents"] = [
+ {
+ content_type: "text/plain",
+ last_modified: "2026-06-14T13:00:00.000Z",
+ source_local_id: "node-1",
+ space_id: 20,
+ text: "EVD - REM sleep and recall",
+ variant: "direct",
+ },
+ {
+ content_type: "text/markdown",
+ last_modified: "2026-06-14T15:00:00.000Z",
+ source_local_id: "node-1",
+ space_id: 20,
+ text: "# EVD - REM sleep and recall",
+ variant: "full",
+ },
+];
+const sourceNodeRid = "orn:obsidian.note:vault-a/node-1";
+
+const build = ({
+ conceptsOverride = concepts,
+ contentsOverride = contents,
+ currentSpaceId = 10,
+ importedSourceRids = new Set(),
+ resourcesOverride = resources,
+ spacesOverride = spaces,
+}: {
+ conceptsOverride?: typeof concepts;
+ contentsOverride?: typeof contents;
+ currentSpaceId?: number;
+ importedSourceRids?: ReadonlySet;
+ resourcesOverride?: typeof resources;
+ spacesOverride?: typeof spaces;
+} = {}) =>
+ buildDiscoveredSharedNodes({
+ concepts: conceptsOverride,
+ contents: contentsOverride,
+ currentSpaceId,
+ importedSourceRids,
+ resources: resourcesOverride,
+ spaces: spacesOverride,
+ });
+
+describe("buildDiscoveredSharedNodes", () => {
+ it("builds a group-shared contract node with stable source identity", () => {
+ expect(build({ importedSourceRids: new Set([sourceNodeRid]) })).toEqual([
+ {
+ alreadyImported: true,
+ modifiedAt: "2026-06-14T15:00:00.000Z",
+ sourceApp: "Obsidian",
+ sourceNodeId: "node-1",
+ sourceNodeRid,
+ sourceSpaceId: "obsidian:vault-a",
+ sourceSpaceName: "Research vault",
+ title: "EVD - REM sleep and recall",
+ },
+ ]);
+ });
+
+ it("does not discover shared resources from the current space", () => {
+ expect(build({ currentSpaceId: 20 })).toEqual([]);
+ });
+
+ it("requires the exact shared resource identity", () => {
+ expect(
+ build({
+ resourcesOverride: [{ space_id: 21, source_local_id: "node-1" }],
+ }),
+ ).toEqual([]);
+ });
+
+ it.each([
+ {
+ name: "schema concept",
+ conceptsOverride: [{ ...concepts[0], is_schema: true }],
+ contentsOverride: contents,
+ },
+ {
+ name: "missing node type",
+ conceptsOverride: [{ ...concepts[0], schema_id: null }],
+ contentsOverride: contents,
+ },
+ {
+ name: "missing direct content",
+ conceptsOverride: concepts,
+ contentsOverride: [contents[1]],
+ },
+ {
+ name: "missing full content",
+ conceptsOverride: concepts,
+ contentsOverride: [contents[0]],
+ },
+ {
+ name: "untyped full content",
+ conceptsOverride: concepts,
+ contentsOverride: [contents[0], { ...contents[1], content_type: null }],
+ },
+ ])("filters a node with $name", ({ conceptsOverride, contentsOverride }) => {
+ expect(build({ conceptsOverride, contentsOverride })).toEqual([]);
+ });
+
+ it("matches imports by RID rather than source-local ID alone", () => {
+ expect(
+ build({
+ importedSourceRids: new Set(["orn:obsidian.note:another-vault/node-1"]),
+ })[0]?.alreadyImported,
+ ).toBe(false);
+ });
+
+ it("sorts newest nodes first", () => {
+ const olderConcept = {
+ ...concepts[0],
+ last_modified: "2026-06-10T12:00:00.000Z",
+ source_local_id: "node-2",
+ };
+ const olderContents = contents.map((content) => ({
+ ...content,
+ last_modified: "2026-06-10T12:00:00.000Z",
+ source_local_id: "node-2",
+ text:
+ content.variant === "direct"
+ ? "Older shared node"
+ : "# Older shared node",
+ }));
+ expect(
+ build({
+ conceptsOverride: [olderConcept, concepts[0]],
+ contentsOverride: [...olderContents, ...contents],
+ resourcesOverride: [
+ ...resources,
+ { space_id: 20, source_local_id: "node-2" },
+ ],
+ }).map((node) => node.sourceNodeId),
+ ).toEqual(["node-1", "node-2"]);
+ });
+});
diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts
new file mode 100644
index 000000000..0b5463601
--- /dev/null
+++ b/apps/roam/src/utils/discoverSharedNodes.ts
@@ -0,0 +1,329 @@
+import type { DGSupabaseClient } from "@repo/database/lib/client";
+import { getAvailableGroupIds } from "@repo/database/lib/groups";
+import { getAllPages } from "@repo/database/lib/pagination";
+import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid";
+import type { Tables } from "@repo/database/dbTypes";
+import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock";
+
+const IMPORTED_FROM_PROP_KEY = "importedFrom";
+const PAGE_SIZE = 1000;
+const RESOURCE_ID_CHUNK_SIZE = 100;
+
+type ResourceAccess = Pick<
+ Tables<"ResourceAccess">,
+ "space_id" | "source_local_id"
+>;
+type SharedConcept = Pick<
+ Tables<"my_concepts">,
+ "is_schema" | "last_modified" | "schema_id" | "source_local_id" | "space_id"
+>;
+type SharedContent = Pick<
+ Tables<"my_contents">,
+ | "content_type"
+ | "last_modified"
+ | "source_local_id"
+ | "space_id"
+ | "text"
+ | "variant"
+>;
+type SharedSpace = Pick<
+ Tables<"my_spaces">,
+ "id" | "name" | "platform" | "url"
+>;
+type ValidSharedSpace = {
+ name: string;
+ platform: "Roam" | "Obsidian";
+ url: string;
+};
+
+export type DiscoveredSharedNode = {
+ alreadyImported: boolean;
+ modifiedAt: string;
+ sourceApp: "Roam" | "Obsidian";
+ sourceNodeId?: string;
+ sourceNodeRid: string;
+ sourceSpaceId: string;
+ sourceSpaceName: string;
+ title: string;
+};
+
+type SharedNodeRows = {
+ concepts: SharedConcept[];
+ contents: SharedContent[];
+ resources: ResourceAccess[];
+ spaces: SharedSpace[];
+};
+
+const getResourceKey = ({
+ sourceLocalId,
+ spaceId,
+}: {
+ sourceLocalId: string;
+ spaceId: number;
+}): string => `${spaceId}:${sourceLocalId}`;
+
+const getLatestTimestamp = (timestamps: (string | null)[]): string | null => {
+ const validTimestamps = timestamps.filter(
+ (timestamp): timestamp is string =>
+ typeof timestamp === "string" && !Number.isNaN(Date.parse(timestamp)),
+ );
+ if (validTimestamps.length === 0) return null;
+ return validTimestamps.reduce((latest, timestamp) =>
+ Date.parse(timestamp) > Date.parse(latest) ? timestamp : latest,
+ );
+};
+
+export const buildDiscoveredSharedNodes = ({
+ concepts,
+ contents,
+ currentSpaceId,
+ importedSourceRids,
+ resources,
+ spaces,
+}: SharedNodeRows & {
+ currentSpaceId: number;
+ importedSourceRids: ReadonlySet;
+}): DiscoveredSharedNode[] => {
+ const sharedResourceKeys = new Set(
+ resources
+ .filter((resource) => resource.space_id !== currentSpaceId)
+ .map((resource) =>
+ getResourceKey({
+ sourceLocalId: resource.source_local_id,
+ spaceId: resource.space_id,
+ }),
+ ),
+ );
+ const spacesById = new Map(
+ spaces.flatMap((space): [number, ValidSharedSpace][] => {
+ if (
+ typeof space.id !== "number" ||
+ typeof space.name !== "string" ||
+ (space.platform !== "Roam" && space.platform !== "Obsidian") ||
+ typeof space.url !== "string"
+ )
+ return [];
+ return [
+ [
+ space.id,
+ {
+ name: space.name,
+ platform: space.platform,
+ url: space.url,
+ },
+ ],
+ ];
+ }),
+ );
+ const contentByResource = new Map<
+ string,
+ Partial>
+ >();
+
+ contents.forEach((content) => {
+ if (
+ typeof content.space_id !== "number" ||
+ typeof content.source_local_id !== "string" ||
+ (content.variant !== "direct" && content.variant !== "full")
+ )
+ return;
+ const key = getResourceKey({
+ sourceLocalId: content.source_local_id,
+ spaceId: content.space_id,
+ });
+ const variants = contentByResource.get(key) ?? {};
+ variants[content.variant] = content;
+ contentByResource.set(key, variants);
+ });
+
+ return concepts
+ .flatMap((concept): DiscoveredSharedNode[] => {
+ if (
+ concept.is_schema !== false ||
+ concept.schema_id === null ||
+ typeof concept.space_id !== "number" ||
+ typeof concept.source_local_id !== "string"
+ )
+ return [];
+
+ const resourceKey = getResourceKey({
+ sourceLocalId: concept.source_local_id,
+ spaceId: concept.space_id,
+ });
+ if (!sharedResourceKeys.has(resourceKey)) return [];
+
+ const space = spacesById.get(concept.space_id);
+ const variants = contentByResource.get(resourceKey);
+ const direct = variants?.direct;
+ const full = variants?.full;
+ if (
+ !space ||
+ typeof direct?.text !== "string" ||
+ typeof full?.text !== "string" ||
+ typeof full.content_type !== "string"
+ )
+ return [];
+
+ const modifiedAt = getLatestTimestamp([
+ concept.last_modified,
+ direct.last_modified,
+ full.last_modified,
+ ]);
+ if (!modifiedAt) return [];
+
+ let sourceNodeRid: string;
+ try {
+ sourceNodeRid = spaceUriAndLocalIdToRid(
+ space.url,
+ concept.source_local_id,
+ space.platform === "Obsidian" ? "note" : undefined,
+ );
+ } catch {
+ return [];
+ }
+
+ return [
+ {
+ alreadyImported: importedSourceRids.has(sourceNodeRid),
+ modifiedAt,
+ sourceApp: space.platform,
+ sourceNodeId: concept.source_local_id || undefined,
+ sourceNodeRid,
+ sourceSpaceId: space.url,
+ sourceSpaceName: space.name,
+ title: direct.text,
+ },
+ ];
+ })
+ .sort(
+ (left, right) =>
+ Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) ||
+ left.title.localeCompare(right.title),
+ );
+};
+
+const getGroupSharedResources = async (
+ client: DGSupabaseClient,
+): Promise => {
+ const groupIds = await getAvailableGroupIds(client);
+ if (groupIds.length === 0) return [];
+
+ const resources = await getAllPages(
+ client
+ .from("ResourceAccess")
+ .select("space_id, source_local_id")
+ .in("account_uid", groupIds)
+ .order("space_id")
+ .order("source_local_id"),
+ PAGE_SIZE,
+ );
+ if (!Array.isArray(resources)) throw resources;
+
+ return [
+ ...new Map(
+ resources.map((resource) => [
+ getResourceKey({
+ sourceLocalId: resource.source_local_id,
+ spaceId: resource.space_id,
+ }),
+ resource,
+ ]),
+ ).values(),
+ ];
+};
+
+const chunk = (values: T[], size: number): T[][] =>
+ Array.from({ length: Math.ceil(values.length / size) }, (_, index) =>
+ values.slice(index * size, (index + 1) * size),
+ );
+
+const getSharedNodeRows = async ({
+ client,
+ currentSpaceId,
+}: {
+ client: DGSupabaseClient;
+ currentSpaceId: number;
+}): Promise => {
+ const resources = (await getGroupSharedResources(client)).filter(
+ (resource) => resource.space_id !== currentSpaceId,
+ );
+ if (resources.length === 0)
+ return { concepts: [], contents: [], resources, spaces: [] };
+
+ const spaceIds = [...new Set(resources.map((resource) => resource.space_id))];
+ const spacesResponse = await client
+ .from("my_spaces")
+ .select("id, name, platform, url")
+ .in("id", spaceIds);
+ if (spacesResponse.error) throw spacesResponse.error;
+
+ const concepts: SharedConcept[] = [];
+ const contents: SharedContent[] = [];
+ for (const spaceId of spaceIds) {
+ const sourceLocalIds = resources
+ .filter((resource) => resource.space_id === spaceId)
+ .map((resource) => resource.source_local_id);
+ for (const ids of chunk(sourceLocalIds, RESOURCE_ID_CHUNK_SIZE)) {
+ const [conceptsResponse, contentsResponse] = await Promise.all([
+ client
+ .from("my_concepts")
+ .select(
+ "is_schema, last_modified, schema_id, source_local_id, space_id",
+ )
+ .eq("space_id", spaceId)
+ .eq("is_schema", false)
+ .in("source_local_id", ids),
+ client
+ .from("my_contents")
+ .select(
+ "content_type, last_modified, source_local_id, space_id, text, variant",
+ )
+ .eq("space_id", spaceId)
+ .in("source_local_id", ids)
+ .in("variant", ["direct", "full"]),
+ ]);
+ if (conceptsResponse.error) throw conceptsResponse.error;
+ if (contentsResponse.error) throw contentsResponse.error;
+ concepts.push(...conceptsResponse.data);
+ contents.push(...contentsResponse.data);
+ }
+ }
+
+ return {
+ concepts,
+ contents,
+ resources,
+ spaces: spacesResponse.data,
+ };
+};
+
+const getImportedSourceRids = async (): Promise> => {
+ const query = `[:find [?rid ...]
+ :where
+ [?page :block/props ?props]
+ [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData]
+ [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?imported]
+ [(get ?imported :sourceNodeRid) ?rid]]`;
+ const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[];
+ return new Set(
+ result.filter((rid): rid is string => typeof rid === "string"),
+ );
+};
+
+export const discoverSharedNodes = async ({
+ client,
+ currentSpaceId,
+}: {
+ client: DGSupabaseClient;
+ currentSpaceId: number;
+}): Promise => {
+ const [rows, importedSourceRids] = await Promise.all([
+ getSharedNodeRows({ client, currentSpaceId }),
+ getImportedSourceRids(),
+ ]);
+ return buildDiscoveredSharedNodes({
+ ...rows,
+ currentSpaceId,
+ importedSourceRids,
+ });
+};
diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts
index 00c7ff076..11d55ba38 100644
--- a/apps/roam/src/utils/registerCommandPaletteCommands.ts
+++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts
@@ -53,6 +53,7 @@ import {
getBlockSelection,
insertPageRefAtRange,
} from "./advancedSearchFooterUtils";
+import { renderDiscoverSharedNodesDialog } from "~/components/DiscoverSharedNodesDialog";
export const createDiscourseNodeFromCommand = (
extensionAPI: OnloadArgs["extensionAPI"],
@@ -341,6 +342,11 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => {
renderSettings({ onloadArgs });
};
+ const discoverSharedNodes = () => {
+ posthog.capture("Shared Nodes: Discover Command Triggered");
+ renderDiscoverSharedNodesDialog({});
+ };
+
const toggleDiscourseContextOverlay = async () => {
const currentValue = getPersonalSetting([
PERSONAL_KEYS.discourseContextOverlay,
@@ -415,6 +421,7 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => {
void addCommand("DG: Export - Discourse graph", exportDiscourseGraph);
void addCommand("DG: Open - Discourse settings", renderSettingsPopup);
if (isSyncEnabled()) {
+ void addCommand("DG: Discover shared nodes", discoverSharedNodes);
void addCommand("DG: Share current node", shareCurrentNode);
}
if (getFeatureFlag("Advanced node search enabled")) {