diff --git a/apps/server/src/provider/CodexInlineVisualization.ts b/apps/server/src/provider/CodexInlineVisualization.ts new file mode 100644 index 00000000..7d3bf556 --- /dev/null +++ b/apps/server/src/provider/CodexInlineVisualization.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { constants as NodeFsConstants } from "node:fs"; +import * as NodeFs from "node:fs/promises"; +import NodePath from "node:path"; + +import { + CodexInlineVisualizationReadError, + type CodexInlineVisualizationReadResult, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const MAX_CODEX_INLINE_VISUALIZATION_BYTES = 5_000_000; + +const CODEX_INLINE_VISUALIZATION_FILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*\.html$/; +const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const isReadError = Schema.is(CodexInlineVisualizationReadError); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +/** Codex shards visualizations using the UUIDv7 timestamp in local time. */ +export function codexVisualizationDateShard(providerThreadId: string): string | null { + if (!UUID_V7.test(providerThreadId)) { + return null; + } + + const timestampHex = `${providerThreadId.slice(0, 8)}${providerThreadId.slice(9, 13)}`; + const timestampMs = Number.parseInt(timestampHex, 16); + const timestamp = new Date(timestampMs); + if (!Number.isFinite(timestampMs) || Number.isNaN(timestamp.getTime())) { + return null; + } + + const year = String(timestamp.getFullYear()).padStart(4, "0"); + const month = String(timestamp.getMonth() + 1).padStart(2, "0"); + const day = String(timestamp.getDate()).padStart(2, "0"); + return NodePath.join(year, month, day); +} + +function readError(message: string, cause?: unknown): CodexInlineVisualizationReadError { + return new CodexInlineVisualizationReadError({ + message, + ...(cause === undefined ? {} : { cause }), + }); +} + +export const readCodexInlineVisualization = Effect.fn("CodexInlineVisualization.read")( + function* (input: { + readonly codexHomePath: string; + readonly providerThreadId: string; + readonly file: string; + }): Effect.fn.Return { + if (!CODEX_INLINE_VISUALIZATION_FILE_NAME.test(input.file)) { + return yield* readError("The visualization filename is invalid."); + } + + const dateShard = codexVisualizationDateShard(input.providerThreadId); + if (!dateShard) { + return yield* readError("This Codex thread cannot be mapped to a visualization directory."); + } + + const filePath = NodePath.join( + input.codexHomePath, + "visualizations", + dateShard, + input.providerThreadId, + input.file, + ); + + return yield* Effect.tryPromise({ + try: async () => { + const linkInfo = await NodeFs.lstat(filePath); + if (linkInfo.isSymbolicLink() || !linkInfo.isFile()) { + throw readError("The visualization is not a regular file."); + } + if (linkInfo.size > MAX_CODEX_INLINE_VISUALIZATION_BYTES) { + throw readError("The visualization is too large to display."); + } + + const noFollow = NodeFsConstants.O_NOFOLLOW ?? 0; + const handle = await NodeFs.open(filePath, NodeFsConstants.O_RDONLY | noFollow); + try { + const openedInfo = await handle.stat(); + if ( + !openedInfo.isFile() || + openedInfo.dev !== linkInfo.dev || + openedInfo.ino !== linkInfo.ino + ) { + throw readError("The visualization changed while it was being opened."); + } + if (openedInfo.size > MAX_CODEX_INLINE_VISUALIZATION_BYTES) { + throw readError("The visualization is too large to display."); + } + const bytes = await handle.readFile(); + if (bytes.byteLength > MAX_CODEX_INLINE_VISUALIZATION_BYTES) { + throw readError("The visualization is too large to display."); + } + return { + file: input.file, + contents: textDecoder.decode(bytes), + sizeBytes: bytes.byteLength, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + isReadError(cause) + ? cause + : readError("The visualization could not be read for this Codex thread.", cause), + }); + }, +); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index e2635921..9b86ace4 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -11,6 +11,11 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { + codexVisualizationDateShard, + MAX_CODEX_INLINE_VISUALIZATION_BYTES, + readCodexInlineVisualization, +} from "../CodexInlineVisualization.ts"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); const decodeCodexSettings = (input: { @@ -81,6 +86,85 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); }); + describe("Codex inline visualizations", () => { + const providerThreadId = "019f8ca0-8000-7992-a481-9125813cb125"; + + it("derives the local date shard from a native UUIDv7 thread id", () => { + const timestamp = new Date(Number.parseInt("019f8ca08000", 16)); + const expected = [ + String(timestamp.getFullYear()).padStart(4, "0"), + String(timestamp.getMonth() + 1).padStart(2, "0"), + String(timestamp.getDate()).padStart(2, "0"), + ].join("/"); + + expect(codexVisualizationDateShard(providerThreadId)).toBe(expected); + expect(codexVisualizationDateShard("not-a-native-thread-id")).toBeNull(); + }); + + it.effect("reads only the expected visualization fragment", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const codexHomePath = yield* makeTempDir("threadlines-codex-visualization-"); + const shard = codexVisualizationDateShard(providerThreadId); + expect(shard).not.toBeNull(); + const filePath = path.join( + codexHomePath, + "visualizations", + shard ?? "", + providerThreadId, + "connection-map.html", + ); + yield* writeTextFile(filePath, "
Connection map
"); + + const result = yield* readCodexInlineVisualization({ + codexHomePath, + providerThreadId, + file: "connection-map.html", + }); + + expect(result).toEqual({ + file: "connection-map.html", + contents: "
Connection map
", + sizeBytes: 25, + }); + }), + ); + + it.effect("rejects symlinks and oversized visualization files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHomePath = yield* makeTempDir("threadlines-codex-visualization-"); + const shard = codexVisualizationDateShard(providerThreadId) ?? ""; + const directory = path.join(codexHomePath, "visualizations", shard, providerThreadId); + const targetPath = path.join(directory, "target.html"); + const symlinkPath = path.join(directory, "linked-file.html"); + const oversizedPath = path.join(directory, "oversized-file.html"); + yield* writeTextFile(targetPath, "
target
"); + if (process.platform !== "win32") { + yield* fileSystem.symlink(targetPath, symlinkPath); + const symlinkError = yield* readCodexInlineVisualization({ + codexHomePath, + providerThreadId, + file: "linked-file.html", + }).pipe(Effect.flip); + expect(symlinkError.message).toContain("not a regular file"); + } + + yield* fileSystem.writeFile( + oversizedPath, + new Uint8Array(MAX_CODEX_INLINE_VISUALIZATION_BYTES + 1), + ); + const oversizedError = yield* readCodexInlineVisualization({ + codexHomePath, + providerThreadId, + file: "oversized-file.html", + }).pipe(Effect.flip); + expect(oversizedError.message).toContain("too large"); + }), + ); + }); + describe("materializeCodexShadowHome", () => { it.effect("materializes a shadow home with shared state links and private auth", () => Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9c41dc57..5a3e0e02 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -31,6 +31,9 @@ import { OrchestrationThreadSearchError, ORCHESTRATION_WS_METHODS, ChatAttachmentReadError, + CodexInlineVisualizationReadError, + CodexSettings, + ProviderDriverKind, ProjectFaviconError, ProjectListEntriesError, ProjectReadFileError, @@ -72,6 +75,9 @@ import { import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ProviderService } from "./provider/Services/ProviderService.ts"; +import { readCodexInlineVisualization } from "./provider/CodexInlineVisualization.ts"; +import { resolveCodexHomeLayout } from "./provider/Drivers/CodexHomeLayout.ts"; +import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { startProviderReviewForThread } from "./provider/ProviderReviewCoordinator.ts"; import { importExternalProviderThread } from "./provider/ExternalThreadImport.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -131,6 +137,8 @@ import { type SessionCredentialChange, } from "./auth/Services/SessionCredentialService.ts"; import { respondToAuthError } from "./auth/http.ts"; + +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); @@ -1476,6 +1484,71 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.visualizationsRead]: (input) => + observeRpcEffect( + WS_METHODS.visualizationsRead, + Effect.gen(function* () { + const threadOption = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe( + Effect.mapError( + (cause) => + new CodexInlineVisualizationReadError({ + message: "The visualization thread could not be loaded.", + cause, + }), + ), + ); + if (Option.isNone(threadOption)) { + return yield* new CodexInlineVisualizationReadError({ + message: "The visualization thread was not found.", + }); + } + + const thread = threadOption.value; + const providerThreadId = thread.session?.providerThreadId; + if (!providerThreadId) { + return yield* new CodexInlineVisualizationReadError({ + message: "This thread does not have a native Codex session.", + }); + } + + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new CodexInlineVisualizationReadError({ + message: "The Codex provider settings could not be loaded.", + cause, + }), + ), + ); + const providerConfig = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (!providerConfig || providerConfig.driver !== ProviderDriverKind.make("codex")) { + return yield* new CodexInlineVisualizationReadError({ + message: "This thread is not backed by a configured Codex provider.", + }); + } + + const codexSettings = yield* decodeCodexSettings(providerConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new CodexInlineVisualizationReadError({ + message: "The Codex provider settings are invalid.", + cause, + }), + ), + ); + const homeLayout = yield* resolveCodexHomeLayout(codexSettings); + return yield* readCodexInlineVisualization({ + codexHomePath: homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath, + providerThreadId, + file: input.file, + }); + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index d5a999de..18561987 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -383,6 +383,45 @@ describe("ChatMarkdown", () => { } }); + it("renders Codex inline visualization directives in a script-only sandbox", async () => { + const readVisualization = vi.fn(async () => ({ + file: "connection-map.html", + contents: '
', + sizeBytes: 72, + })); + __setEnvironmentApiOverrideForTests(CHAT_MARKDOWN_ENVIRONMENT_ID, { + visualizations: { read: readVisualization }, + } as unknown as EnvironmentApi); + const screen = await render( + , + ); + + try { + await vi.waitFor(() => { + expect(readVisualization).toHaveBeenCalledWith({ + threadId: CHAT_MARKDOWN_THREAD_ID, + file: "connection-map.html", + }); + }); + const iframe = document.querySelector( + 'iframe[title="Interactive visualization: connection map"]', + ); + expect(iframe).not.toBeNull(); + expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); + expect(iframe?.getAttribute("sandbox")).not.toContain("allow-same-origin"); + expect(iframe?.srcdoc).toContain("Content-Security-Policy"); + expect(iframe?.srcdoc).toContain('id="connection-map"'); + expect(document.body.textContent).not.toContain("::codex-inline-vis"); + } finally { + await screen.unmount(); + } + }); + it("wraps long fenced text and shows copy feedback", async () => { const code = `Please run ${"a-very-long-unbroken-value".repeat(20)} when ready.`; const writeText = vi.fn(async () => undefined); diff --git a/apps/web/src/components/ChatMarkdown.test.ts b/apps/web/src/components/ChatMarkdown.test.ts index 9309f98b..22139c08 100644 --- a/apps/web/src/components/ChatMarkdown.test.ts +++ b/apps/web/src/components/ChatMarkdown.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { splitMarkdownBlocks } from "./ChatMarkdown.tsx"; +import { + parseCodexInlineVisualizations, + stripCodexInlineVisualizationDirectives, +} from "../lib/codexInlineVisualization"; describe("splitMarkdownBlocks", () => { it("returns no blocks for empty text", () => { @@ -49,3 +53,41 @@ describe("splitMarkdownBlocks", () => { expect(blocks.join("\n\n")).toBe(text); }); }); + +describe("Codex inline visualization directives", () => { + it("extracts exact standalone directives between markdown sections", () => { + expect( + parseCodexInlineVisualizations( + 'Before\n\n::codex-inline-vis{file="connection-map.html"}\n\nAfter', + ), + ).toEqual([ + { type: "markdown", key: "markdown:0", text: "Before\n" }, + { type: "visualization", key: "visualization:8", file: "connection-map.html" }, + { type: "markdown", key: "markdown:55", text: "\nAfter" }, + ]); + }); + + it("leaves directives inside code fences and malformed filenames as markdown", () => { + const text = + '```text\n::codex-inline-vis{file="inside-code.html"}\n```\n\n::codex-inline-vis{file="../escape.html"}'; + expect(parseCodexInlineVisualizations(text)).toEqual([ + { type: "markdown", key: "markdown:0", text }, + ]); + }); + + it("hides an unfinished final directive while streaming", () => { + expect( + parseCodexInlineVisualizations("Done\n\n::codex-inline-vis{file=", { + isStreaming: true, + }), + ).toEqual([{ type: "markdown", key: "markdown:0", text: "Done\n" }]); + }); + + it("removes rendered directives from copied assistant text", () => { + expect( + stripCodexInlineVisualizationDirectives( + 'Before\n\n::codex-inline-vis{file="connection-map.html"}\n\nAfter', + ), + ).toBe("Before\n\nAfter"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 679f807c..7f5eb44b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,5 @@ import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; -import type { EnvironmentId, ServerProviderSkill } from "@threadlines/contracts"; +import type { EnvironmentId, ServerProviderSkill, ThreadId } from "@threadlines/contracts"; import React, { Children, Suspense, @@ -48,6 +48,8 @@ import { useMarkdownFileLinkKinds, } from "../hooks/useMarkdownFileLinkKinds"; import { cn } from "../lib/utils"; +import { parseCodexInlineVisualizations } from "../lib/codexInlineVisualization"; +import { CodexInlineVisualization } from "./chat/CodexInlineVisualization"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -74,6 +76,7 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; environmentId?: EnvironmentId | undefined; + threadId?: ThreadId | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; searchHighlightQuery?: string | undefined; @@ -861,7 +864,7 @@ function StreamingTailBlock({ ); } -function ChatMarkdown({ +function ChatMarkdownBody({ text, cwd, environmentId, @@ -913,9 +916,50 @@ function ChatMarkdown({ ); } + return body; +} + +function ChatMarkdown({ + text, + cwd, + environmentId, + threadId, + isStreaming = false, + skills = EMPTY_MARKDOWN_SKILLS, + searchHighlightQuery, +}: ChatMarkdownProps) { + const { resolvedTheme } = useTheme(); + const canRenderVisualizations = environmentId !== undefined && threadId !== undefined; + const segments = canRenderVisualizations + ? parseCodexInlineVisualizations(text, { isStreaming }) + : [{ type: "markdown" as const, key: "markdown:0", text }]; + return (
- {body} + {segments.map((segment, index) => { + if (segment.type === "visualization") { + return environmentId && threadId ? ( + + ) : null; + } + return ( + + ); + })}
); } diff --git a/apps/web/src/components/chat/CodexInlineVisualization.tsx b/apps/web/src/components/chat/CodexInlineVisualization.tsx new file mode 100644 index 00000000..395c79ff --- /dev/null +++ b/apps/web/src/components/chat/CodexInlineVisualization.tsx @@ -0,0 +1,416 @@ +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { ExternalLinkIcon, XIcon } from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; + +import { ensureEnvironmentApi } from "../../environmentApi"; +import { LRUCache } from "../../lib/lruCache"; +import { Button } from "../ui/button"; + +const INITIAL_VISUALIZATION_HEIGHT = 240; +const MIN_VISUALIZATION_HEIGHT = 80; +const MAX_VISUALIZATION_HEIGHT = 10_000; +const BRIDGE_SOURCE = "threadlines-codex-inline-vis"; +const visualizationContentsCache = new LRUCache(32, 40 * 1024 * 1024); +const visualizationReadPromises = new Map>(); + +type VisualizationTheme = "light" | "dark"; + +interface CodexInlineVisualizationProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly file: string; + readonly theme: VisualizationTheme; +} + +type LoadState = + | { readonly status: "loading" } + | { readonly status: "error"; readonly message: string } + | { readonly status: "loaded"; readonly contents: string }; + +interface VisualizationBridgeMessage { + readonly source?: unknown; + readonly token?: unknown; + readonly type?: unknown; + readonly height?: unknown; + readonly url?: unknown; + readonly message?: unknown; +} + +function errorMessage(cause: unknown): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + if ( + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" && + cause.message.trim().length > 0 + ) { + return cause.message; + } + return "The visualization could not be loaded."; +} + +function safeJson(value: string): string { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + +function visualizationCacheKey(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly file: string; +}): string { + return `${input.environmentId}\u0000${input.threadId}\u0000${input.file}`; +} + +async function readVisualizationContents(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly file: string; +}): Promise { + const cacheKey = visualizationCacheKey(input); + const cached = visualizationContentsCache.get(cacheKey); + if (cached !== null) { + return cached; + } + + const pending = visualizationReadPromises.get(cacheKey); + if (pending) { + return pending; + } + + const read = (async () => { + const api = ensureEnvironmentApi(input.environmentId); + if (!api.visualizations) { + throw new Error("This Threadlines server does not support inline visualizations yet."); + } + const result = await api.visualizations.read({ threadId: input.threadId, file: input.file }); + visualizationContentsCache.set(cacheKey, result.contents, result.contents.length * 2); + return result.contents; + })(); + visualizationReadPromises.set(cacheKey, read); + const clearPending = () => { + if (visualizationReadPromises.get(cacheKey) === read) { + visualizationReadPromises.delete(cacheKey); + } + }; + void read.then(clearPending, clearPending); + return read; +} + +export function buildCodexInlineVisualizationDocument(input: { + readonly contents: string; + readonly theme: VisualizationTheme; + readonly bridgeToken: string; +}): string { + const dark = input.theme === "dark"; + const colors = dark + ? { + background: "#171717", + foreground: "#f3f3f3", + muted: "#292929", + mutedForeground: "#a3a3a3", + border: "#3a3a3a", + primary: "#e7e7e7", + primaryForeground: "#171717", + secondary: "#303030", + secondaryForeground: "#ededed", + } + : { + background: "#ffffff", + foreground: "#171717", + muted: "#f1f1f1", + mutedForeground: "#666666", + border: "#dedede", + primary: "#252525", + primaryForeground: "#ffffff", + secondary: "#eeeeee", + secondaryForeground: "#252525", + }; + const bridgeToken = safeJson(input.bridgeToken); + + return ` + + + + + + + + + + +${input.contents} + +`; +} + +export function CodexInlineVisualization({ + environmentId, + threadId, + file, + theme, +}: CodexInlineVisualizationProps) { + const iframeRef = useRef(null); + const bridgeToken = useId(); + const [height, setHeight] = useState(INITIAL_VISUALIZATION_HEIGHT); + const [loadState, setLoadState] = useState({ status: "loading" }); + const [pendingExternalUrl, setPendingExternalUrl] = useState(null); + const [runtimeError, setRuntimeError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoadState({ status: "loading" }); + setHeight(INITIAL_VISUALIZATION_HEIGHT); + setPendingExternalUrl(null); + setRuntimeError(null); + void (async () => { + try { + const contents = await readVisualizationContents({ environmentId, threadId, file }); + if (!cancelled) { + setLoadState({ status: "loaded", contents }); + } + } catch (cause) { + if (!cancelled) { + setLoadState({ status: "error", message: errorMessage(cause) }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [environmentId, file, threadId]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) return; + const data = event.data; + if (data?.source !== BRIDGE_SOURCE || data.token !== bridgeToken) return; + if (data.type === "height" && typeof data.height === "number") { + setHeight( + Math.max( + MIN_VISUALIZATION_HEIGHT, + Math.min(MAX_VISUALIZATION_HEIGHT, Math.ceil(data.height)), + ), + ); + } else if (data.type === "external-link" && typeof data.url === "string") { + try { + const url = new URL(data.url); + if (url.protocol === "https:" || url.protocol === "http:") { + setPendingExternalUrl(url.href); + } + } catch { + // Ignore malformed URLs posted by untrusted visualization content. + } + } else if (data.type === "error" && typeof data.message === "string") { + setRuntimeError(data.message.slice(0, 180)); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [bridgeToken]); + + const document = useMemo( + () => + loadState.status === "loaded" + ? buildCodexInlineVisualizationDocument({ + contents: loadState.contents, + theme, + bridgeToken, + }) + : null, + [bridgeToken, loadState, theme], + ); + + if (loadState.status === "loading") { + return ( +
+ ); + } + + if (loadState.status === "error") { + return ( +
+ Visualization unavailable.{" "} + {loadState.message} +
+ ); + } + + return ( +
+ {pendingExternalUrl ? ( +
+ + Open {pendingExternalUrl}? + + + +
+ ) : null} +