diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0601012f2..f66bf9c49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Lint check run: pnpm lint + - name: Build workspace packages + run: pnpm --filter @runtimed/components build + - name: Type check run: pnpm type-check diff --git a/Dockerfile.iframe-outputs b/Dockerfile.iframe-outputs index b4c49c47f..2941abe7e 100644 --- a/Dockerfile.iframe-outputs +++ b/Dockerfile.iframe-outputs @@ -7,8 +7,9 @@ WORKDIR /app # Copy workspace files COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -# Copy schema package.json (only workspace dependency needed for iframe) +# Copy workspace package.json files (needed for pnpm workspace resolution) COPY packages/schema/package.json packages/schema/tsconfig.json ./packages/schema/ +COPY packages/components/package.json packages/components/tsconfig.json ./packages/components/ # Install dependencies with cache mount RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ @@ -19,10 +20,13 @@ COPY packages/schema/src ./packages/schema/src COPY src/components/ui ./src/components/ui COPY src/lib ./src/lib COPY src/util/iframe.ts ./src/util/iframe.ts -COPY src/components/outputs/shared-with-iframe ./src/components/outputs/shared-with-iframe +COPY packages/components ./packages/components COPY iframe-outputs ./iframe-outputs COPY vite.config.ts tsconfig.json tsconfig.node.json ./ +# Build the components package first (required for iframe-outputs) +RUN pnpm --filter @runtimed/components build + # Build the iframe outputs RUN pnpm build:iframe diff --git a/Dockerfile.sync b/Dockerfile.sync index 9dd733bc2..a1f1cc68a 100644 --- a/Dockerfile.sync +++ b/Dockerfile.sync @@ -15,6 +15,10 @@ COPY packages/ai-core/package.json ./packages/ai-core/ COPY packages/pyodide-runtime/package.json ./packages/pyodide-runtime/ COPY packages/schema/package.json ./packages/schema/ +# Remove @runtimed/components from dependencies (sync service doesn't need it) +# This allows pnpm install to succeed without the components package +RUN node -e "const pkg = require('./package.json'); delete pkg.dependencies['@runtimed/components']; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));" + # Install dependencies RUN pnpm install diff --git a/Dockerfile.web b/Dockerfile.web index cefb9cbd2..bd3b490ea 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -12,6 +12,7 @@ COPY packages/agent-core/package.json ./packages/agent-core/ COPY packages/ai-core/package.json ./packages/ai-core/ COPY packages/pyodide-runtime/package.json ./packages/pyodide-runtime/ COPY packages/schema/package.json ./packages/schema/ +COPY packages/components/package.json ./packages/components/ # Install dependencies with cache mount RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ diff --git a/ecosystem.config.json b/ecosystem.config.json index 68abfd9e3..1a587c6bb 100644 --- a/ecosystem.config.json +++ b/ecosystem.config.json @@ -23,6 +23,14 @@ "env": { "NODE_ENV": "development" } + }, + { + "name": "components", + "script": "pnpm", + "args": "--filter @runtimed/components dev", + "env": { + "NODE_ENV": "development" + } } ] } diff --git a/eslint.config.js b/eslint.config.js index 04a347109..9adaa122b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -354,11 +354,10 @@ export default [ }, { ignores: [ - "dist/**", - "node_modules/**", - "*.d.ts", + "**/dist/**", + "**/node_modules/**", + "**/*.d.ts", "scripts/**", - "iframe-outputs/worker/dist/**", "iframe-outputs/worker/.wrangler/**", ], }, diff --git a/iframe-outputs/src/react-main.tsx b/iframe-outputs/src/react-main.tsx index 63d9ca1c9..0ee4d7a2e 100644 --- a/iframe-outputs/src/react-main.tsx +++ b/iframe-outputs/src/react-main.tsx @@ -1,7 +1,6 @@ import { createRoot } from "react-dom/client"; -import { IframeReactApp } from "./components/IframeReactApp"; +import { IframeReactApp, sendFromIframe } from "@runtimed/components"; import "./style.css"; -import { sendFromIframe } from "@/components/outputs/shared-with-iframe/comms"; // Main React initialization for iframe outputs function initializeReactIframe() { diff --git a/iframe-outputs/src/style.css b/iframe-outputs/src/style.css index 7a90ea6ae..d68dab7c7 100644 --- a/iframe-outputs/src/style.css +++ b/iframe-outputs/src/style.css @@ -13,7 +13,7 @@ Docs: https://tailwindcss.com/docs/preflight#overview @import "tw-animate-css"; @plugin "@tailwindcss/typography"; -@source "../../src/components/outputs/shared-with-iframe"; +@source "../../packages/components/src"; @source "../../src/components/ui"; :root { diff --git a/package.json b/package.json index 2d52daa0e..8bfd17b84 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@react-spring/web": "^10.0.1", "@runtimed/agent-core": "workspace:*", "@runtimed/ai-core": "workspace:*", + "@runtimed/components": "workspace:*", "@runtimed/pyodide-runtime": "workspace:*", "@runtimed/schema": "workspace:*", "@tanstack/react-query": "^5.85.5", diff --git a/packages/components/README.md b/packages/components/README.md new file mode 100644 index 000000000..3ab237dd9 --- /dev/null +++ b/packages/components/README.md @@ -0,0 +1,177 @@ +# @runtimed/components + +React components for rendering notebook cell outputs. This package provides a comprehensive set of output renderers for displaying code execution results, including rich multimedia formats, terminal output, AI tool interactions, and geographic data. + +## Installation + +```bash +pnpm add @runtimed/components +# or +npm install @runtimed/components +``` + +**Peer Dependencies**: React 19+ + +## Quick Start + +```tsx +import { SingleOutput, OutputsContainer } from "@runtimed/components"; +import "@runtimed/components/styles.css"; + +function NotebookOutputs({ outputs }) { + return ( + + {outputs.map((output) => ( + + ))} + + ); +} +``` + +## Components + +### Output Renderers + +| Component | Description | +| ------------------- | ----------------------------------------------------------------------- | +| `SingleOutput` | Smart router that selects the appropriate renderer based on output type | +| `OutputsContainer` | Wrapper for consistent output styling | +| `RichOutputContent` | Renders multimedia content by MIME type | + +### Specific Renderers + +| Component | MIME Types | +| ------------------ | ------------------------------------------------------ | +| `PlainTextOutput` | `text/plain` | +| `MarkdownRenderer` | `text/markdown` - GFM, KaTeX math, syntax highlighting | +| `HtmlOutput` | `text/html` | +| `JsonOutput` | `application/json` - interactive tree view | +| `ImageOutput` | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | +| `SvgOutput` | `image/svg+xml` | +| `AnsiOutput` | Terminal output with ANSI color codes | +| `GeoJsonMapOutput` | `application/geo+json` - MapLibre-powered maps | + +### AI Tool Components + +| Component | Purpose | +| ---------------------- | ------------------------------------------- | +| `AiToolCallOutput` | Displays AI tool invocation details | +| `AiToolResultOutput` | Renders tool execution results | +| `AiToolApprovalOutput` | UI for human-in-the-loop approval workflows | + +### Iframe Integration + +For sandboxed output rendering: + +```tsx +import { IframeReactApp, IframeOutput } from "@runtimed/components"; + +// Parent: embed outputs in an iframe + + +// Child iframe: render the app + +``` + +Communication utilities: + +```tsx +import { + useIframeCommsParent, + useIframeCommsChild, + sendToIframe, + sendFromIframe, +} from "@runtimed/components"; +``` + +### UI Components + +Basic UI building blocks: + +```tsx +import { Button, Card, Spinner } from "@runtimed/components"; + + + +``` + +## Utilities + +```tsx +import { cn, groupConsecutiveStreamOutputs } from "@runtimed/components"; + +// Merge Tailwind classes +cn("px-2 py-1", condition && "bg-blue-500"); + +// Group stdout/stderr streams for cleaner display +const grouped = groupConsecutiveStreamOutputs(outputs); +``` + +## Styling + +Import the CSS for proper styling: + +```tsx +import "@runtimed/components/styles.css"; +``` + +The package uses Tailwind CSS v4. Components are designed to work in both light and dark themes. + +## Features + +- **Lazy loading**: Heavy components like `MarkdownRenderer` are dynamically imported +- **Error boundaries**: Outputs gracefully handle rendering failures +- **Artifact support**: Handles both inline data and artifact URLs for large outputs +- **Suspense-ready**: Built-in loading states with `SuspenseSpinner` + +## Output Data Format + +Components expect outputs conforming to `@runtimed/schema` types: + +```typescript +import type { OutputData, OutputType } from "@runtimed/components"; + +interface OutputData { + id: string; + outputType: + | "multimedia_display" + | "multimedia_result" + | "terminal" + | "markdown" + | "error"; + data?: string | null; + representations?: Record; + streamName?: "stdout" | "stderr"; +} +``` + +## Development + +```bash +# Build +pnpm build + +# Watch mode +pnpm dev + +# Type check +pnpm type-check + +# Lint +pnpm lint +``` + +## Demo + +The package includes a demo page for testing all output types: + +```tsx +import { OutputTypesDemoPage } from "@runtimed/components"; + +; +``` + +## License + +MIT diff --git a/packages/components/jsr.json b/packages/components/jsr.json new file mode 100644 index 000000000..9a620cfdb --- /dev/null +++ b/packages/components/jsr.json @@ -0,0 +1,6 @@ +{ + "name": "@runtimed/components", + "version": "0.3.0-beta.1", + "exports": "./src/index.ts", + "license": "BSD-3-Clause" +} diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 000000000..493632188 --- /dev/null +++ b/packages/components/package.json @@ -0,0 +1,96 @@ +{ + "name": "@runtimed/components", + "version": "0.3.0-beta.1", + "description": "React components for rendering notebook cell outputs", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "default": "./dist/index.mjs" + }, + "./styles.css": "./dist/styles.css" + }, + "scripts": { + "type-check": "tsc --noEmit --allowImportingTsExtensions", + "lint": "eslint src/", + "lint:check": "eslint src/ --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check .", + "build": "tsdown; pnpm build:css", + "build:css": "tailwindcss -i src/styles.css -o dist/styles.css --minify", + "dev": "tsdown --watch & tailwindcss -i src/styles.css -o dist/styles.css --watch", + "prepublishOnly": "pnpm build" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "@uiw/react-json-view": "2.0.0-alpha.40", + "react-error-boundary": "^6.0.0", + "@radix-ui/react-slot": "^1.2.3", + "@runtimed/schema": "workspace:*", + "ansi-to-react": "^6.1.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "geojson-map-fit-mercator": "^1.1.0", + "katex": "^0.16.22", + "lucide-react": "^0.545.0", + "maplibre-gl": "^5.7.1", + "maplibre-react-components": "^0.2.6", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^15.6.1", + "react-use": "^17.6.0", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.10", + "@types/node": "^24.0.10", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/react-syntax-highlighter": "^15.5.13", + "@typescript-eslint/eslint-plugin": "^8.35.1", + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^9.30.1", + "prettier": "^3.6.0", + "react": "19.2.1", + "react-dom": "19.2.1", + "tsdown": "0.20.0-beta.1", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=23.0.0", + "pnpm": ">=10.9.0" + }, + "keywords": [ + "react", + "components", + "notebook", + "outputs", + "runt", + "anode" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/runtimed/anode.git", + "directory": "packages/components" + }, + "publishConfig": { + "access": "public" + }, + "author": "Runt Team", + "license": "MIT" +} diff --git a/packages/components/src/ExecutionCount.tsx b/packages/components/src/ExecutionCount.tsx new file mode 100644 index 000000000..adea72f73 --- /dev/null +++ b/packages/components/src/ExecutionCount.tsx @@ -0,0 +1,23 @@ +import { cn } from "./utils/cn"; + +interface ExecutionCountProps { + count: number | null; + isExecuting?: boolean; + className?: string; +} + +export function ExecutionCount({ + count, + isExecuting, + className, +}: ExecutionCountProps) { + const display = isExecuting ? "*" : count ?? " "; + return ( + + [{display}]: + + ); +} diff --git a/packages/components/src/Incrementor.tsx b/packages/components/src/Incrementor.tsx new file mode 100644 index 000000000..be3696f89 --- /dev/null +++ b/packages/components/src/Incrementor.tsx @@ -0,0 +1,6 @@ +import { useState } from "react"; + +export function Incrementor() { + const [count, setCount] = useState(0); + return ; +} diff --git a/packages/components/src/OutputTypesDemoPage.tsx b/packages/components/src/OutputTypesDemoPage.tsx new file mode 100644 index 000000000..0062aaf39 --- /dev/null +++ b/packages/components/src/OutputTypesDemoPage.tsx @@ -0,0 +1,345 @@ +import React from "react"; +import type { OutputData, OutputType } from "@runtimed/schema"; +import { IframeOutput } from "./outputs/IframeOutput.js"; +import { SingleOutput } from "./outputs/SingleOutput.js"; +import { OutputsContainer } from "./outputs/OutputsContainer.js"; +import { SuspenseSpinner } from "./outputs/SuspenseSpinner.js"; + +const createOutput = ( + id: string, + outputType: OutputType, + data: Partial +): OutputData => { + return { + id, + cellId: "demo-cell", + outputType, + position: 0, + streamName: null, + executionCount: null, + displayId: null, + artifactId: null, + mimeType: null, + metadata: null, + representations: null, + data: null, + ...data, + } as OutputData; +}; + +export const OutputTypesDemoPage: React.FC<{ iframeUri?: string }> = ({ + iframeUri, +}) => { + const finalIframeUriPrefix = iframeUri ?? "."; + + // Terminal outputs + const stdoutOutput: OutputData = createOutput("terminal-stdout", "terminal", { + streamName: "stdout", + data: "Hello, World!\nThis is stdout output with ANSI colors:\n\x1b[32mGreen text\x1b[0m\n\x1b[31mRed text\x1b[0m\n\x1b[1mBold text\x1b[0m", + position: 1, + }); + + const stderrOutput: OutputData = createOutput("terminal-stderr", "terminal", { + streamName: "stderr", + data: "Warning: This is stderr output\n\x1b[33mYellow warning\x1b[0m", + position: 2, + }); + + // Markdown output + const markdownOutput: OutputData = createOutput("markdown-1", "markdown", { + data: `# Markdown Output Demo + +This is a **markdown** output with: + +- Lists +- \`code\` blocks +- [Links](https://example.com) + +\`\`\`python +def hello(): + print("Hello from markdown!") +\`\`\` + +> Blockquote example`, + position: 3, + }); + + // Error output + const errorOutput: OutputData = createOutput("error-1", "error", { + data: JSON.stringify({ + ename: "ValueError", + evalue: "Invalid value provided", + traceback: [ + "Traceback (most recent call last):", + ' File "", line 1, in ', + "ValueError: Invalid value provided", + ], + }), + position: 4, + }); + + // HTML output (using IframeOutput) + const htmlOutput: OutputData = createOutput("html-1", "multimedia_display", { + data: "
HTML Content
", + mimeType: "text/html", + representations: { + "text/html": { + type: "inline", + data: `
+

HTML Output Demo

+

This is rendered HTML content with styling.

+
    +
  • Styled list item 1
  • +
  • Styled list item 2
  • +
+
`, + }, + }, + position: 5, + }); + + // SVG output (using IframeOutput) + const svgOutput: OutputData = createOutput("svg-1", "multimedia_display", { + data: "SVG Content", + mimeType: "image/svg+xml", + representations: { + "image/svg+xml": { + type: "inline", + data: ` + + + + + + + + + SVG Output +`, + }, + }, + position: 6, + }); + + // JSON output + const jsonOutput: OutputData = createOutput("json-1", "multimedia_result", { + data: '{"key": "value"}', + mimeType: "application/json", + representations: { + "application/json": { + type: "inline", + data: { + name: "Demo Data", + items: [1, 2, 3, 4, 5], + nested: { + key: "value", + number: 42, + }, + }, + }, + }, + executionCount: 1, + position: 7, + }); + + // GeoJSON output + const geojsonOutput: OutputData = createOutput( + "geojson-1", + "multimedia_result", + { + data: '{"type":"FeatureCollection"}', + mimeType: "application/geo+json", + representations: { + "application/geo+json": { + type: "inline", + data: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { + type: "Point", + coordinates: [-122.4194, 37.7749], + }, + properties: { + name: "San Francisco", + population: 873965, + }, + }, + { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [-122.5, 37.7], + [-122.4, 37.7], + [-122.4, 37.8], + [-122.5, 37.8], + [-122.5, 37.7], + ], + ], + }, + properties: { + name: "Sample Area", + area: "~77 km²", + }, + }, + ], + }, + }, + }, + executionCount: 2, + position: 8, + } + ); + + // Plain text output + const plainTextOutput: OutputData = createOutput( + "text-1", + "multimedia_result", + { + data: "Plain text content", + mimeType: "text/plain", + representations: { + "text/plain": { + type: "inline", + data: "This is plain text output.\nIt can span multiple lines.\n\nWith paragraphs too!", + }, + }, + executionCount: 3, + position: 9, + } + ); + + // Image output (PNG - base64 data URL example) + const imageOutput: OutputData = createOutput( + "image-1", + "multimedia_display", + { + data: "Image data", + mimeType: "image/png", + representations: { + "image/png": { + type: "inline", + data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAnElEQVR42u3RAQ0AAAgDIE1u9FvDOahAVzLFGS1ECEKEIEQIQoQgRIgQIQgRghAhCBGCECEIQYgQhAhBiBCECEEIQoQgRAhChCBECEIQIgQhQhAiBCFCEIIQIQgRghAhCBGCEIQIQYgQhAhBiBCEIEQIQoQgRAhChCAEIUIQIgQhQhAiBCEIEYIQIQgRghAhCBEiRAhChCBECEK+W3uw+TnWoJc/AAAAAElFTkSuQmCC", + }, + "text/plain": { + type: "inline", + data: "[Image: 1x1 pixel PNG]", + }, + }, + position: 10, + } + ); + + const allOutputs: Array<{ + name: string; + type: string; + outputs: OutputData[]; + }> = [ + { + name: "Plain Text Output", + type: "text", + outputs: [plainTextOutput], + }, + { + name: "Terminal Output (stdout)", + type: "terminal-stdout", + outputs: [stdoutOutput], + }, + { + name: "Terminal Output (stderr)", + type: "terminal-stderr", + outputs: [stderrOutput], + }, + { + name: "Error Output", + type: "error", + outputs: [errorOutput], + }, + { + name: "Markdown Output", + type: "markdown", + outputs: [markdownOutput], + }, + { + name: "JSON Output", + type: "json", + outputs: [jsonOutput], + }, + { + name: "Image Output (PNG)", + type: "image", + outputs: [imageOutput], + }, + { + name: "SVG Output (Iframe)", + type: "svg", + outputs: [svgOutput], + }, + { + name: "HTML Output (Iframe)", + type: "html", + outputs: [htmlOutput], + }, + { + name: "GeoJSON Output", + type: "geojson", + outputs: [geojsonOutput], + }, + ]; + + return ( +
+

Cell Output Types Demo

+

+ This page demonstrates all the different cell output types supported by + the system. HTML and SVG outputs are rendered using IframeOutput for + sandboxed rendering. +

+ +
+ {allOutputs.map((section) => { + return ( +
+
+

{section.name}

+
+ + {/* Render HTML and SVG using IframeOutput */} +
+ {section.type === "html" || section.type === "svg" ? ( +
+
+                      {finalIframeUriPrefix}/react.html
+                    
+ +
+ ) : ( +
+ + + {section.outputs.map((output) => ( + + ))} + + +
+ )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/iframe-outputs/src/components/IframeReactApp.tsx b/packages/components/src/iframe-outputs/IframeReactApp.tsx similarity index 68% rename from iframe-outputs/src/components/IframeReactApp.tsx rename to packages/components/src/iframe-outputs/IframeReactApp.tsx index dc2dad404..3e4395fac 100644 --- a/iframe-outputs/src/components/IframeReactApp.tsx +++ b/packages/components/src/iframe-outputs/IframeReactApp.tsx @@ -1,7 +1,7 @@ -import { OutputsContainer } from "@/components/outputs/shared-with-iframe/OutputsContainer"; -import { SingleOutput } from "@/components/outputs/shared-with-iframe/SingleOutput"; -import { SuspenseSpinner } from "@/components/outputs/shared-with-iframe/SuspenseSpinner"; -import { useIframeCommsChild } from "@/components/outputs/shared-with-iframe/comms"; +import { OutputsContainer } from "../outputs/OutputsContainer.js"; +import { SingleOutput } from "../outputs/SingleOutput.js"; +import { SuspenseSpinner } from "../outputs/SuspenseSpinner.js"; +import { useIframeCommsChild } from "../outputs/comms.js"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts new file mode 100644 index 000000000..bd5714b23 --- /dev/null +++ b/packages/components/src/index.ts @@ -0,0 +1,86 @@ +// Output components +export { IframeOutput } from "./outputs/IframeOutput.js"; +export type { IframeOutputProps } from "./outputs/IframeOutput.js"; + +export { SingleOutput } from "./outputs/SingleOutput.js"; +export { OutputsContainer } from "./outputs/OutputsContainer.js"; +export { SuspenseSpinner, DelayedSpinner } from "./outputs/SuspenseSpinner.js"; +export { RichOutputContent } from "./outputs/RichOutputContent.js"; + +// Iframe React app for rendering outputs in an iframe +export { IframeReactApp } from "./iframe-outputs/IframeReactApp.js"; + +// Specific output renderers +export { + AnsiOutput, + AnsiStreamOutput, + AnsiErrorOutput, +} from "./outputs/AnsiOutput.js"; +export { PlainTextOutput } from "./outputs/PlainTextOutput.js"; +export { MarkdownRenderer } from "./outputs/MarkdownRenderer.js"; +export { JsonOutput } from "./outputs/JsonOutput.js"; +export { HtmlOutput } from "./outputs/HtmlOutput.js"; +export { ImageOutput } from "./outputs/ImageOutput.js"; +export { SvgOutput } from "./outputs/SvgOutput.js"; +export { SyntaxHighlighter } from "./outputs/SyntaxHighlighter.js"; +export type { SyntaxHighlighterProps } from "./outputs/SyntaxHighlighter.js"; +export { GeoJsonMapOutput } from "./outputs/geojson/GeoJsonMapOutput.js"; +export { MapFeature } from "./outputs/geojson/MapFeature.js"; +export { + normalizeData, + mapFitFeatures2, + geoJsonTypes, +} from "./outputs/geojson/geojson-utils.js"; +export type { + MapFitFeaturesResult, + MapFitFeaturesOptions, +} from "./outputs/geojson/geojson-utils.js"; + +// AI tool outputs +export { AiToolCallOutput } from "./outputs/AiToolCallOutput.js"; +export { AiToolResultOutput } from "./outputs/AiToolResultOutput.js"; +export { AiToolApprovalOutput } from "./outputs/AiToolApprovalOutput.js"; + +// Iframe communication utilities +export { + sendFromIframe, + sendToIframe, + addParentMessageListener, + removeParentMessageListener, + useIframeCommsParent, + useIframeCommsChild, +} from "./outputs/comms.js"; +export type { ToIframeEvent, FromIframeEvent } from "./outputs/comms.js"; + +// UI components +export { Button, buttonVariants } from "./ui/button.js"; +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./ui/card.js"; +export { Spinner } from "./ui/Spinner.js"; +export type { SpinnerSize } from "./ui/Spinner.js"; +export { ExecutionCount } from "./ExecutionCount.js"; + +// Utilities +export { cn } from "./utils/cn.js"; +export { throwIfNotInIframe } from "./utils/iframe.js"; +export { groupConsecutiveStreamOutputs } from "./utils/output-grouping.js"; + +// Demo pages +export { OutputTypesDemoPage } from "./OutputTypesDemoPage.js"; +export { Incrementor } from "./Incrementor.js"; + +// Re-export types from schema for convenience +export type { + OutputData, + OutputType, + CellType, + AiToolCallData, + AiToolResultData, +} from "@runtimed/schema"; diff --git a/src/components/outputs/shared-with-iframe/AiToolApprovalOutput.tsx b/packages/components/src/outputs/AiToolApprovalOutput.tsx similarity index 98% rename from src/components/outputs/shared-with-iframe/AiToolApprovalOutput.tsx rename to packages/components/src/outputs/AiToolApprovalOutput.tsx index 74b968ed8..6af76f97f 100644 --- a/src/components/outputs/shared-with-iframe/AiToolApprovalOutput.tsx +++ b/packages/components/src/outputs/AiToolApprovalOutput.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Button } from "../../ui/button"; -import { Card } from "../../ui/card"; +import { Button } from "../ui/button.js"; +import { Card } from "../ui/card.js"; import { Shield, ShieldCheck, diff --git a/src/components/outputs/shared-with-iframe/AiToolCallOutput.tsx b/packages/components/src/outputs/AiToolCallOutput.tsx similarity index 98% rename from src/components/outputs/shared-with-iframe/AiToolCallOutput.tsx rename to packages/components/src/outputs/AiToolCallOutput.tsx index 3457f87f2..5182aeedd 100644 --- a/src/components/outputs/shared-with-iframe/AiToolCallOutput.tsx +++ b/packages/components/src/outputs/AiToolCallOutput.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; import { ChevronDown, Edit, FilePlus, Info } from "lucide-react"; -import { AiToolCallData } from "@runtimed/schema"; +import type { AiToolCallData } from "@runtimed/schema"; interface AiToolCallOutputProps { toolData: AiToolCallData; diff --git a/src/components/outputs/shared-with-iframe/AiToolResultOutput.tsx b/packages/components/src/outputs/AiToolResultOutput.tsx similarity index 95% rename from src/components/outputs/shared-with-iframe/AiToolResultOutput.tsx rename to packages/components/src/outputs/AiToolResultOutput.tsx index 1e0ea62d7..67d3bd93a 100644 --- a/src/components/outputs/shared-with-iframe/AiToolResultOutput.tsx +++ b/packages/components/src/outputs/AiToolResultOutput.tsx @@ -1,6 +1,6 @@ import React from "react"; import { CheckCircle, XCircle, Clock, Info } from "lucide-react"; -import { AiToolResultData } from "@runtimed/schema"; +import type { AiToolResultData } from "@runtimed/schema"; interface AiToolResultOutputProps { resultData: AiToolResultData; diff --git a/src/components/outputs/shared-with-iframe/AnsiOutput.tsx b/packages/components/src/outputs/AnsiOutput.tsx similarity index 90% rename from src/components/outputs/shared-with-iframe/AnsiOutput.tsx rename to packages/components/src/outputs/AnsiOutput.tsx index 3bbbc97b0..bcac29e14 100644 --- a/src/components/outputs/shared-with-iframe/AnsiOutput.tsx +++ b/packages/components/src/outputs/AnsiOutput.tsx @@ -1,5 +1,12 @@ import React from "react"; -import Ansi from "ansi-to-react"; +import AnsiModule from "ansi-to-react"; + +// Handle nested default export from ansi-to-react +// Some bundlers create mod.default.default structure +const Ansi = + (AnsiModule as any).default?.default || + (AnsiModule as any).default || + AnsiModule; interface AnsiOutputProps { children: string; diff --git a/src/components/outputs/shared-with-iframe/HtmlOutput.tsx b/packages/components/src/outputs/HtmlOutput.tsx similarity index 93% rename from src/components/outputs/shared-with-iframe/HtmlOutput.tsx rename to packages/components/src/outputs/HtmlOutput.tsx index ccf49189f..c5cfc70ad 100644 --- a/src/components/outputs/shared-with-iframe/HtmlOutput.tsx +++ b/packages/components/src/outputs/HtmlOutput.tsx @@ -1,4 +1,4 @@ -import { throwIfNotInIframe } from "@/util/iframe"; +import { throwIfNotInIframe } from "../utils/iframe.js"; import React, { useEffect, useRef } from "react"; interface HtmlOutputProps { diff --git a/packages/components/src/outputs/IframeOutput.tsx b/packages/components/src/outputs/IframeOutput.tsx new file mode 100644 index 000000000..d3566c8e1 --- /dev/null +++ b/packages/components/src/outputs/IframeOutput.tsx @@ -0,0 +1,80 @@ +import type { CellType, OutputData } from "@runtimed/schema"; +import { useState, useRef, useEffect } from "react"; +import { useDebounce } from "react-use"; +import { useIframeCommsParent } from "./comms.js"; + +export interface IframeOutputProps { + outputs: OutputData[]; + style?: React.CSSProperties; + className?: string; + onHeightChange?: (height: number) => void; + isReact?: boolean; + defaultHeight?: string; + onDoubleClick?: () => void; + onMarkdownRendered?: () => void; + cellType?: CellType; + iframeUri: string; +} + +export const IframeOutput: React.FC = ({ + outputs, + className, + style, + isReact, + onHeightChange, + defaultHeight = "0px", + onDoubleClick, + onMarkdownRendered, + cellType, + iframeUri, +}) => { + const { iframeRef, iframeHeight } = useIframeCommsParent({ + defaultHeight, + onHeightChange, + outputs, + onDoubleClick, + onMarkdownRendered, + }); + + const [debouncedIframeHeight, setDebouncedIframeHeight] = + useState(iframeHeight); + + // Iframe can get height updates pretty often, but we want to avoid layout jumping each time + // TODO: ensure that it's a leading debounce! + useDebounce(() => setDebouncedIframeHeight(iframeHeight), 50, [iframeHeight]); + + const isAiCell = cellType === "ai"; + const scrollContainerRef = useRef(null); + + // Auto-scroll to bottom when content changes for AI cells + useEffect(() => { + if (isAiCell && scrollContainerRef.current) { + const container = scrollContainerRef.current; + container.scrollTop = container.scrollHeight; + } + }, [isAiCell, outputs, debouncedIframeHeight]); + + const iframeElement = ( +