diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts new file mode 100644 index 0000000..46fa42e --- /dev/null +++ b/scripts/views-runner.ts @@ -0,0 +1,530 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +type EsbuildModule = { + build: (options: Record) => Promise; +}; + +type EsbuildBuildResult = { + metafile?: { + inputs?: Record; + }; +}; + +type CustomViewDefinition = { + kind: "trace" | "dataset"; + name: string; + slug: string; + component: unknown; + project?: string | { id?: string; name?: string }; + dataset?: { id?: string; name?: string }; +}; + +type ManifestEntry = { + kind: "view"; + view_type: "trace" | "dataset"; + name: string; + slug: string; + code: string; + project_id?: string; + project_name?: string; + dataset_id?: string; + dataset_name?: string; +}; + +type Manifest = { + runtime_context: { + runtime: "browser"; + version: "latest"; + }; + files: Array<{ + source_file: string; + dependencies: string[]; + entries: ManifestEntry[]; + }>; +}; + +const __filename = path.resolve(process.argv[1] || "views-runner.ts"); +const __dirname = path.dirname(__filename); + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeEsbuildModule(value: unknown): EsbuildModule | null { + if (isObject(value) && typeof value.build === "function") { + return value as EsbuildModule; + } + if ( + isObject(value) && + isObject(value.default) && + typeof value.default.build === "function" + ) { + return value.default as EsbuildModule; + } + return null; +} + +async function loadEsbuild(sourceFiles: string[]): Promise { + const candidates = [ + ...sourceFiles.map((file) => path.resolve(file)), + path.join(process.cwd(), "package.json"), + path.join(__dirname, "package.json"), + ]; + + for (const candidate of candidates) { + try { + const requireFrom = createRequire(pathToFileURL(candidate).href); + const resolved = requireFrom.resolve("esbuild"); + const loaded = requireFrom(resolved); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // Try the next search root. + } + } + + try { + const esbuildSpecifier = "esbuild"; + const loaded = await import(esbuildSpecifier); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // handled below + } + + throw new Error( + "failed to load esbuild for custom view bundling; install esbuild in your project", + ); +} + +function sdkPath(): string { + return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts"); +} + +function reactModuleSource(): string { + return ` +const ReactValue = globalThis.React || React; +export default ReactValue; +export const Children = ReactValue.Children; +export const Component = ReactValue.Component; +export const Fragment = ReactValue.Fragment; +export const Profiler = ReactValue.Profiler; +export const PureComponent = ReactValue.PureComponent; +export const StrictMode = ReactValue.StrictMode; +export const Suspense = ReactValue.Suspense; +export const cloneElement = ReactValue.cloneElement; +export const createContext = ReactValue.createContext; +export const createElement = ReactValue.createElement; +export const createRef = ReactValue.createRef; +export const forwardRef = ReactValue.forwardRef; +export const isValidElement = ReactValue.isValidElement; +export const lazy = ReactValue.lazy; +export const memo = ReactValue.memo; +export const startTransition = ReactValue.startTransition; +export const useCallback = ReactValue.useCallback; +export const useContext = ReactValue.useContext; +export const useDebugValue = ReactValue.useDebugValue; +export const useDeferredValue = ReactValue.useDeferredValue; +export const useEffect = ReactValue.useEffect; +export const useId = ReactValue.useId; +export const useImperativeHandle = ReactValue.useImperativeHandle; +export const useInsertionEffect = ReactValue.useInsertionEffect; +export const useLayoutEffect = ReactValue.useLayoutEffect; +export const useMemo = ReactValue.useMemo; +export const useReducer = ReactValue.useReducer; +export const useRef = ReactValue.useRef; +export const useState = ReactValue.useState; +export const useSyncExternalStore = ReactValue.useSyncExternalStore; +export const useTransition = ReactValue.useTransition; +`; +} + +function jsxRuntimeSource(): string { + return ` +const ReactValue = globalThis.React || React; +export const Fragment = ReactValue.Fragment; +export const jsx = ReactValue.createElement; +export const jsxs = ReactValue.createElement; +export const jsxDEV = ReactValue.createElement; +`; +} + +function installDiscoveryReactStub(): void { + const globalWithReact = globalThis as { + React?: Record; + }; + if (globalWithReact.React) { + return; + } + const createElement = (...args: unknown[]) => ({ + type: args[0], + props: args[1] ?? {}, + children: args.slice(2), + }); + globalWithReact.React = { + Children: {}, + Component: class {}, + Fragment: Symbol.for("react.fragment"), + Profiler: Symbol.for("react.profiler"), + PureComponent: class {}, + StrictMode: Symbol.for("react.strict_mode"), + Suspense: Symbol.for("react.suspense"), + cloneElement: createElement, + createContext: () => ({}), + createElement, + createRef: () => ({ current: null }), + forwardRef: (component: unknown) => component, + isValidElement: (value: unknown) => + typeof value === "object" && value !== null, + lazy: (loader: unknown) => loader, + memo: (component: unknown) => component, + startTransition: (callback: () => void) => callback(), + useCallback: (callback: unknown) => callback, + useContext: () => undefined, + useDebugValue: () => undefined, + useDeferredValue: (value: unknown) => value, + useEffect: () => undefined, + useId: () => "bt-view-preview-id", + useImperativeHandle: () => undefined, + useInsertionEffect: () => undefined, + useLayoutEffect: () => undefined, + useMemo: (factory: () => unknown) => factory(), + useReducer: (_reducer: unknown, initialValue: unknown) => [ + initialValue, + () => undefined, + ], + useRef: (current: unknown) => ({ current }), + useState: (initialValue: unknown) => [initialValue, () => undefined], + useSyncExternalStore: () => undefined, + useTransition: () => [false, () => undefined], + }; +} + +function braintrustViewPlugin() { + const helperPath = sdkPath(); + return { + name: "braintrust-custom-view-local-sdk", + setup(build: { + onResolve: ( + options: { filter: RegExp }, + callback: (args: { path: string }) => unknown, + ) => void; + onLoad: ( + options: { filter: RegExp; namespace?: string }, + callback: (args: { path: string }) => unknown, + ) => void; + }) { + build.onResolve({ filter: /^braintrust\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^@braintrust\/local\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^react$/ }, () => ({ + path: "react", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ + path: "react/jsx-runtime", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ + path: "react/jsx-dev-runtime", + namespace: "bt-view-react", + })); + build.onLoad( + { filter: /^react$/, namespace: "bt-view-react" }, + () => ({ + contents: reactModuleSource(), + loader: "js", + }), + ); + build.onLoad( + { filter: /^react\/jsx-(dev-)?runtime$/, namespace: "bt-view-react" }, + () => ({ + contents: jsxRuntimeSource(), + loader: "js", + }), + ); + }, + }; +} + +function projectFields( + project: CustomViewDefinition["project"], +): Pick { + if (project === undefined) { + return {}; + } + if (typeof project === "string") { + return { project_name: project }; + } + if (typeof project.id === "string" && project.id.trim()) { + return { project_id: project.id }; + } + if (typeof project.name === "string" && project.name.trim()) { + return { project_name: project.name }; + } + throw new Error("custom view project must be a string, { id }, or { name }"); +} + +function datasetFields( + view: CustomViewDefinition, +): Pick { + if (view.kind !== "dataset") { + return {}; + } + const dataset = view.dataset; + if (!dataset) { + throw new Error(`dataset custom view '${view.slug}' requires dataset`); + } + if (typeof dataset.id === "string" && dataset.id.trim()) { + return { dataset_id: dataset.id }; + } + if (typeof dataset.name === "string" && dataset.name.trim()) { + return { dataset_name: dataset.name }; + } + throw new Error( + `dataset custom view '${view.slug}' dataset must be { id } or { name }`, + ); +} + +function validateCustomView( + value: unknown, + sourceFile: string, +): asserts value is CustomViewDefinition { + if (!isObject(value)) { + throw new Error( + `${sourceFile} must default-export customTraceView(...) or customDatasetView(...)`, + ); + } + if (value.kind !== "trace" && value.kind !== "dataset") { + throw new Error( + `${sourceFile} default export must be a trace or dataset custom view`, + ); + } + if (typeof value.name !== "string" || !value.name.trim()) { + throw new Error(`${sourceFile} custom view name is required`); + } + if (typeof value.slug !== "string" || !value.slug.trim()) { + throw new Error(`${sourceFile} custom view '${value.name}' slug is required`); + } + if (typeof value.component !== "function") { + throw new Error( + `${sourceFile} custom view '${value.slug}' component must be a function`, + ); + } +} + +function defaultExportFromModule(loaded: unknown): unknown { + if ( + !isObject(loaded) || + !Object.prototype.hasOwnProperty.call(loaded, "default") + ) { + return undefined; + } + const value = loaded.default; + if ( + isObject(value) && + Object.prototype.hasOwnProperty.call(value, "default") + ) { + return value.default; + } + return value; +} + +async function bundleForDiscovery( + esbuild: EsbuildModule, + sourceFile: string, + outputFile: string, +): Promise { + return await esbuild.build({ + entryPoints: [sourceFile], + outfile: outputFile, + bundle: true, + platform: "node", + target: "node18", + format: "cjs", + write: true, + metafile: true, + plugins: [braintrustViewPlugin()], + }); +} + +async function collectView( + esbuild: EsbuildModule, + sourceFile: string, + tempDir: string, +): Promise<{ view: CustomViewDefinition; dependencies: string[] }> { + installDiscoveryReactStub(); + const outputFile = path.join( + tempDir, + `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, + ); + const buildResult = await bundleForDiscovery( + esbuild, + sourceFile, + outputFile, + ); + const loaded = await import( + `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}` + ); + const view = defaultExportFromModule(loaded); + validateCustomView(view, sourceFile); + return { view, dependencies: dependencyFiles(buildResult, tempDir) }; +} + +function virtualEntrySource(sourceFile: string): string { + return ` +import view from ${JSON.stringify(sourceFile)}; +if (!view || (view.kind !== "trace" && view.kind !== "dataset") || !view.component) { + throw new Error("Custom view file must default-export customTraceView(...) or customDatasetView(...)"); +} +export default view.component; +`; +} + +async function bundleBrowserCode( + esbuild: EsbuildModule, + sourceFile: string, + view: CustomViewDefinition, + tempDir: string, +): Promise<{ code: string; dependencies: string[] }> { + const entryFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, + ); + const outputFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, + ); + fs.writeFileSync(entryFile, virtualEntrySource(sourceFile), "utf8"); + + const buildResult = await esbuild.build({ + entryPoints: [entryFile], + outfile: outputFile, + bundle: true, + platform: "browser", + target: "es2019", + format: "iife", + globalName: "__BraintrustCustomView", + write: true, + metafile: true, + treeShaking: true, + jsxFactory: "React.createElement", + jsxFragment: "React.Fragment", + footer: { + js: "module.exports = __BraintrustCustomView;", + }, + plugins: [braintrustViewPlugin()], + }); + + return { + code: fs.readFileSync(outputFile, "utf8"), + dependencies: dependencyFiles(buildResult, tempDir), + }; +} + +function dependencyFiles( + buildResult: EsbuildBuildResult, + tempDir: string, +): string[] { + const inputs = Object.keys(buildResult.metafile?.inputs ?? {}); + const dependencies = new Set(); + const tempRoot = fs.realpathSync.native(tempDir); + for (const input of inputs) { + if (input.includes(":") && !path.isAbsolute(input)) { + continue; + } + let file = path.isAbsolute(input) + ? path.normalize(input) + : path.resolve(process.cwd(), input); + if (fs.existsSync(file)) { + file = fs.realpathSync.native(file); + } + const relativeToTemp = path.relative(tempRoot, file); + if ( + relativeToTemp === "" || + (!relativeToTemp.startsWith("..") && !path.isAbsolute(relativeToTemp)) + ) { + continue; + } + dependencies.add(file); + } + return [...dependencies].sort(); +} + +async function buildManifest(files: string[]): Promise { + const sourceFiles = files.map((file) => path.resolve(file)); + const esbuild = await loadEsbuild(sourceFiles); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-")); + + try { + const manifest: Manifest = { + runtime_context: { + runtime: "browser", + version: "latest", + }, + files: [], + }; + + for (const sourceFile of sourceFiles) { + if (!fs.existsSync(sourceFile)) { + throw new Error(`custom view file not found: ${sourceFile}`); + } + const collected = await collectView(esbuild, sourceFile, tempDir); + const bundled = await bundleBrowserCode( + esbuild, + sourceFile, + collected.view, + tempDir, + ); + const dependencies = [ + ...new Set([...collected.dependencies, ...bundled.dependencies]), + ].sort(); + const entries: ManifestEntry[] = [ + { + kind: "view", + view_type: collected.view.kind, + name: collected.view.name, + slug: collected.view.slug, + code: bundled.code, + ...projectFields(collected.view.project), + ...datasetFields(collected.view), + }, + ]; + manifest.files.push({ source_file: sourceFile, dependencies, entries }); + } + + return manifest; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function main(): Promise { + const files = process.argv.slice(2); + if (files.length === 0) { + throw new Error("views-runner requires at least one view file"); + } + + const manifest = await buildManifest(files); + process.stdout.write(`${JSON.stringify(manifest)}\n`); +} + +main().catch((error: unknown) => { + const message = + error instanceof Error + ? error.message + : `failed to build custom view manifest: ${String(error)}`; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/views-sdk.ts b/scripts/views-sdk.ts new file mode 100644 index 0000000..5097c4d --- /dev/null +++ b/scripts/views-sdk.ts @@ -0,0 +1,16 @@ +type CustomViewDefinition = Record; +type Component = (props: unknown) => unknown; + +export function customTraceView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "trace" as const }; +} + +export function customDatasetView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "dataset" as const }; +} diff --git a/src/datasets/api.rs b/src/datasets/api.rs index f07532d..119dea4 100644 --- a/src/datasets/api.rs +++ b/src/datasets/api.rs @@ -230,6 +230,16 @@ pub async fn list_dataset_rows_limited( Ok((rows, truncated)) } +pub async fn get_dataset_row_by_id( + client: &ApiClient, + dataset_id: &str, + row_id: &str, +) -> Result> { + let query = build_dataset_row_by_id_query(dataset_id, row_id, DatasetRowsPreviewLength::Full); + let response = client.btql_structured::(&query).await?; + Ok(response.data.into_iter().next()) +} + pub async fn create_dataset( client: &ApiClient, project_id: &str, @@ -401,6 +411,38 @@ fn build_dataset_rows_query( query } +fn build_dataset_row_by_id_query( + dataset_id: &str, + row_id: &str, + preview_length: DatasetRowsPreviewLength, +) -> Value { + json!({ + "select": dataset_rows_select_fields(), + "from": { + "op": "function", + "name": {"op": "ident", "name": ["dataset"]}, + "args": [{"op": "literal", "value": dataset_id}] + }, + "filter": { + "op": "and", + "children": [ + { + "op": "ge", + "left": {"op": "ident", "name": ["created"]}, + "right": {"op": "literal", "value": DATASET_ROWS_SINCE} + }, + { + "op": "eq", + "left": {"op": "ident", "name": ["id"]}, + "right": {"op": "literal", "value": row_id} + } + ] + }, + "preview_length": preview_length.btql_value(), + "limit": 1 + }) +} + fn dataset_rows_select_fields() -> Vec { DATASET_RECORD_FIELDS .iter() diff --git a/src/main.rs b/src/main.rs index 676e79e..52f2b6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ mod traces; mod ui; mod util_cmd; mod utils; +mod views; use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; @@ -60,6 +61,7 @@ Core auth Authenticate bt with Braintrust switch Switch org and project context view View logs, traces, and spans + views Push and preview custom views Projects & resources projects Manage projects @@ -133,6 +135,8 @@ enum Commands { Auth(CLIArgs), /// View logs, traces, and spans View(CLIArgs), + /// Push and preview custom views + Views(CLIArgs), #[cfg(unix)] /// Run eval files Eval(CLIArgs), @@ -176,6 +180,7 @@ impl Commands { Commands::Sql(cmd) => &cmd.base, Commands::Auth(cmd) => &cmd.base, Commands::View(cmd) => &cmd.base, + Commands::Views(cmd) => &cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, @@ -202,6 +207,7 @@ impl Commands { Commands::Sql(cmd) => &mut cmd.base, Commands::Auth(cmd) => &mut cmd.base, Commands::View(cmd) => &mut cmd.base, + Commands::Views(cmd) => &mut cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, @@ -305,6 +311,7 @@ fn try_main() -> Result<()> { match cli.command { Commands::Auth(cmd) => auth::run(cmd.base, cmd.args).await?, Commands::View(cmd) => traces::run(cmd.base, cmd.args).await?, + Commands::Views(cmd) => views::run(cmd.base, cmd.args).await?, Commands::Init(cmd) => init::run(cmd.base, cmd.args).await?, Commands::Sql(cmd) => sql::run(cmd.base, cmd.args).await?, Commands::Setup(cmd) => setup::run_setup_top(cmd.base, cmd.args).await?, diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..041a809 --- /dev/null +++ b/src/views.rs @@ -0,0 +1,2533 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; + +use actix_web::{web, App, HttpResponse, HttpServer}; +use anyhow::{anyhow, bail, Context, Result}; +use clap::{builder::BoolishValueParser, Args, Subcommand}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use urlencoding::encode; + +use crate::args::BaseArgs; +use crate::auth; +use crate::datasets::api as datasets_api; +use crate::functions::{self, api as functions_api, IfExistsMode}; +use crate::http::ApiClient; +use crate::js_runner; +use crate::project_context::{resolve_project_optional, resolve_required_project}; +use crate::projects::api::{get_project_by_name, list_projects, Project}; +use crate::ui::{self, with_spinner}; +use crate::utils::{app_project_url, app_project_url_with_encoded_path}; + +const VIEWS_JS_RUNNER_FILE: &str = "views-runner.ts"; +const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; +const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); +const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); +const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; +const DEFAULT_CUSTOM_VIEWS_DIR: &str = "braintrust-custom-views"; +const CUSTOM_VIEW_TSCONFIG_FILE: &str = "tsconfig.json"; +const CUSTOM_VIEW_TYPES_FILE: &str = "custom-view-env.d.ts"; +const VIEW_FILE_PATTERN_HELP: &str = + "*.view.tsx, *.view.ts, *.view.jsx, *.view.js, *-view.tsx, *-view.ts, *-view.jsx, or *-view.js"; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt views push ./views + bt views push ./conversation.view.tsx --if-exists replace + bt views trace bootstrap 'Trace Review' + bt views dataset bootstrap 'Dataset Review' --dataset test-dataset + bt views trace preview ./conversation.trace-view.tsx --url + bt views trace preview ./conversation.trace-view.tsx --trace-id + bt views dataset preview ./dataset.dataset-view.tsx --dataset test-dataset --row-index 0 +")] +pub struct ViewsArgs { + #[command(subcommand)] + command: ViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum ViewsCommands { + /// Push local custom view definitions + Push(ViewsPushArgs), + /// Work with trace custom views + Trace(TraceViewsArgs), + /// Work with dataset custom views + Dataset(DatasetViewsArgs), +} + +#[derive(Debug, Clone, Args)] +struct ViewsPushArgs { + /// File or directory path(s) to scan for custom view definitions. + #[arg(value_name = "PATH")] + paths: Vec, + + /// File or directory path(s) to scan for custom view definitions. + #[arg( + long = "file", + env = "BT_VIEWS_PUSH_FILES", + value_name = "PATH", + value_delimiter = ',' + )] + file_flag: Vec, + + /// Behavior when a custom view with the same slug already exists. + #[arg( + long = "if-exists", + env = "BT_VIEWS_PUSH_IF_EXISTS", + value_enum, + default_value = "error" + )] + if_exists: IfExistsMode, + + /// Skip confirmation prompt. + #[arg( + long, + short = 'y', + env = "BT_VIEWS_PUSH_YES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + yes: bool, +} + +impl ViewsPushArgs { + fn resolved_paths(&self) -> Vec { + let mut paths = self.paths.clone(); + paths.extend(self.file_flag.iter().cloned()); + if paths.is_empty() { + vec![PathBuf::from(".")] + } else { + paths + } + } +} + +#[derive(Debug, Clone, Args)] +struct TraceViewsArgs { + #[command(subcommand)] + command: TraceViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum TraceViewsCommands { + /// Create a starter trace custom view file + Bootstrap(TraceViewBootstrapArgs), + /// Preview one trace custom view locally + Preview(TraceViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewsArgs { + #[command(subcommand)] + command: DatasetViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum DatasetViewsCommands { + /// Create a starter dataset custom view file + Bootstrap(DatasetViewBootstrapArgs), + /// Preview one dataset custom view locally + Preview(DatasetViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct BootstrapCommonArgs { + /// Custom view name. + #[arg(value_name = "NAME")] + name: String, + + /// Output file or directory path. Defaults to braintrust-custom-views/.-view.tsx. + #[arg(long = "file", env = "BT_VIEWS_BOOTSTRAP_FILE", value_name = "PATH")] + file_flag: Option, + + /// Overwrite an existing file. + #[arg( + long, + short = 'f', + env = "BT_VIEWS_BOOTSTRAP_FORCE", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + force: bool, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, + + /// Dataset name to reference in the starter view. + #[arg( + long, + env = "BT_VIEWS_BOOTSTRAP_DATASET", + value_name = "NAME", + conflicts_with = "dataset_id" + )] + dataset: Option, + + /// Dataset id to reference in the starter view. + #[arg( + long = "dataset-id", + env = "BT_VIEWS_BOOTSTRAP_DATASET_ID", + value_name = "ID", + conflicts_with = "dataset" + )] + dataset_id: Option, +} + +#[derive(Debug, Serialize)] +struct BootstrapResult { + path: String, + tsconfig_path: String, + tsconfig_created: bool, + types_path: String, + types_created: bool, + view_type: ViewType, +} + +#[derive(Debug, Clone, Args)] +struct PreviewCommonArgs { + /// Custom view file to preview. + #[arg(value_name = "PATH")] + path: PathBuf, + + /// View slug or name to preview. + #[arg(long, env = "BT_VIEWS_PREVIEW_VIEW")] + view: Option, + + /// Local port to bind. Defaults to an ephemeral port. + #[arg(long, env = "BT_VIEWS_PREVIEW_PORT", default_value_t = 0)] + port: u16, + + /// Do not open a browser. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_NO_OPEN", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + no_open: bool, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: TracePreviewTargetArgs, +} + +#[derive(Debug, Clone, Args)] +struct TracePreviewTargetArgs { + /// Braintrust app URL to resolve trace preview data from. + #[arg(long, env = "BT_VIEWS_PREVIEW_URL")] + url: Option, + + /// Project ID to query for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_PROJECT_ID")] + project_id: Option, + + /// Root span id for trace preview data. + #[arg( + long = "trace-id", + alias = "root-span-id", + env = "BT_VIEWS_PREVIEW_TRACE_ID" + )] + trace_id: Option, + + /// Selected span id or row id for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_SPAN_ID")] + span_id: Option, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: DatasetPreviewTargetArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetPreviewTargetArgs { + /// Dataset name or id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_DATASET")] + dataset: Option, + + /// Dataset row id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_ID")] + row_id: Option, + + /// Dataset row index for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_INDEX")] + row_index: Option, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifest { + runtime_context: ViewsRuntimeContext, + #[serde(default)] + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct ViewsRuntimeContext { + runtime: String, + version: String, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifestFile { + source_file: String, + #[serde(default)] + dependencies: Vec, + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct ViewManifestEntry { + view_type: ViewType, + name: String, + slug: String, + code: String, + #[serde(default)] + project_id: Option, + #[serde(default)] + project_name: Option, + #[serde(default)] + dataset_id: Option, + #[serde(default)] + dataset_name: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum ViewType { + Trace, + Dataset, +} + +impl ViewType { + fn label(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Dataset => "dataset", + } + } +} + +#[derive(Debug, Clone)] +struct PreparedView { + source_file: String, + entry: ViewManifestEntry, + project: Project, + dataset: Option, +} + +#[derive(Debug, Serialize)] +struct PushedView { + source_file: String, + name: String, + slug: String, + view_type: ViewType, + project_id: String, + project_name: String, + dataset_id: Option, + function_id: Option, + url: Option, +} + +#[derive(Debug, Deserialize)] +struct BtqlResponse { + data: Vec>, +} + +struct PreviewServerState { + client: ApiClient, + project_id: Option, + data: Value, + source: PreviewSource, + title: String, + dependency_paths: Mutex>, +} + +struct PreviewContext { + client: ApiClient, + project: Option, + entry: ViewManifestEntry, + dependency_paths: Vec, +} + +struct TracePreviewData { + project_id: String, + data: Value, +} + +#[derive(Clone)] +struct PreviewSource { + path: PathBuf, + view: Option, + view_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SpanFieldsRequest { + #[serde(rename = "spanIds")] + span_ids: Vec, + fields: Option>, +} + +pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { + match args.command { + ViewsCommands::Push(push_args) => push(base, push_args).await, + ViewsCommands::Trace(trace_args) => match trace_args.command { + TraceViewsCommands::Bootstrap(bootstrap_args) => bootstrap_trace(base, bootstrap_args), + TraceViewsCommands::Preview(preview_args) => preview_trace(base, preview_args).await, + }, + ViewsCommands::Dataset(dataset_args) => match dataset_args.command { + DatasetViewsCommands::Bootstrap(bootstrap_args) => { + bootstrap_dataset(base, bootstrap_args) + } + DatasetViewsCommands::Preview(preview_args) => { + preview_dataset(base, preview_args).await + } + }, + } +} + +fn bootstrap_trace(base: BaseArgs, args: TraceViewBootstrapArgs) -> Result<()> { + let slug = bootstrap_slug(&args.common.name)?; + let default_file_name = format!("{slug}.trace-view.tsx"); + let content = trace_view_bootstrap_template(&args.common.name, &slug); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Trace, &result) +} + +fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<()> { + let slug = bootstrap_slug(&args.common.name)?; + let dataset_ref = match ( + args.dataset_id.as_deref().map(str::trim), + args.dataset.as_deref().map(str::trim), + ) { + (Some(dataset_id), _) if !dataset_id.is_empty() => { + format!("{{ id: {dataset_id:?} }}") + } + (_, Some(dataset)) if !dataset.is_empty() => format!("{{ name: {dataset:?} }}"), + _ => "{ name: \"test-dataset\" }".to_string(), + }; + let default_file_name = format!("{slug}.dataset-view.tsx"); + let content = dataset_view_bootstrap_template(&args.common.name, &slug, &dataset_ref); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Dataset, &result) +} + +fn bootstrap_slug(name: &str) -> Result { + let mut slug = String::new(); + let mut pending_separator = false; + + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + if pending_separator && !slug.is_empty() { + slug.push('-'); + } + slug.push(ch.to_ascii_lowercase()); + pending_separator = false; + } else if !slug.is_empty() { + pending_separator = true; + } + } + + if slug.is_empty() { + bail!("custom view name must contain at least one ASCII letter or number"); + } + Ok(slug) +} + +struct BootstrapWriteResult { + view_path: PathBuf, + tsconfig_path: PathBuf, + tsconfig_status: BootstrapSupportFileStatus, + types_path: PathBuf, + types_status: BootstrapSupportFileStatus, +} + +impl BootstrapWriteResult { + fn tsconfig_created(&self) -> bool { + matches!(self.tsconfig_status, BootstrapSupportFileStatus::Created) + } + + fn types_created(&self) -> bool { + matches!(self.types_status, BootstrapSupportFileStatus::Created) + } +} + +#[derive(Debug, Clone, Copy)] +enum BootstrapSupportFileStatus { + Created, + Reused, + Updated, +} + +impl BootstrapSupportFileStatus { + fn label(self) -> &'static str { + match self { + Self::Created => "Created", + Self::Reused => "Reused", + Self::Updated => "Updated", + } + } +} + +fn write_bootstrap_scaffold( + args: BootstrapCommonArgs, + default_file_name: &str, + content: &str, +) -> Result { + let selected_path = args + .file_flag + .unwrap_or_else(|| PathBuf::from(DEFAULT_CUSTOM_VIEWS_DIR)); + let (view_path, config_dir) = if selected_path.is_dir() || selected_path.extension().is_none() { + (selected_path.join(default_file_name), Some(selected_path)) + } else { + let config_dir = selected_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf); + (selected_path, config_dir) + }; + let tsconfig_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TSCONFIG_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TSCONFIG_FILE)); + let types_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TYPES_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TYPES_FILE)); + + let is_tsx_view_file = view_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".view.tsx") || name.ends_with("-view.tsx")); + if !is_tsx_view_file { + bail!("custom view bootstrap path must end with .view.tsx or -view.tsx"); + } + if view_path.exists() && !args.force { + bail!( + "custom view file already exists: {}. Use --force to overwrite.", + view_path.display() + ); + } + if let Some(parent) = view_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + let tsconfig_exists = tsconfig_path.exists(); + if tsconfig_exists && !tsconfig_path.is_file() { + bail!( + "custom view tsconfig path already exists and is not a file: {}", + tsconfig_path.display() + ); + } + let types_exists = types_path.exists(); + if types_exists && !types_path.is_file() { + bail!( + "custom view types path already exists and is not a file: {}", + types_path.display() + ); + } + std::fs::write(&view_path, content) + .with_context(|| format!("failed to write custom view file {}", view_path.display()))?; + + let tsconfig_status = if !tsconfig_exists || args.force { + std::fs::write(&tsconfig_path, CUSTOM_VIEW_TSCONFIG_TEMPLATE).with_context(|| { + format!( + "failed to write custom view tsconfig {}", + tsconfig_path.display() + ) + })?; + if tsconfig_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + let types_status = if !types_exists || args.force { + std::fs::write(&types_path, CUSTOM_VIEW_TYPES_TEMPLATE).with_context(|| { + format!("failed to write custom view types {}", types_path.display()) + })?; + if types_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + Ok(BootstrapWriteResult { + view_path, + tsconfig_path, + tsconfig_status, + types_path, + types_status, + }) +} + +fn print_bootstrap_result( + json_output: bool, + view_type: ViewType, + result: &BootstrapWriteResult, +) -> Result<()> { + let tsconfig_action = result.tsconfig_status.label(); + let types_action = result.types_status.label(); + let result = BootstrapResult { + path: result.view_path.display().to_string(), + tsconfig_path: result.tsconfig_path.display().to_string(), + tsconfig_created: result.tsconfig_created(), + types_path: result.types_path.display().to_string(), + types_created: result.types_created(), + view_type, + }; + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "Created {} custom view starter at {}", + view_type.label(), + result.path + ); + println!( + "{tsconfig_action} TypeScript config at {}", + result.tsconfig_path + ); + println!( + "{types_action} custom view type declarations at {}", + result.types_path + ); + println!( + "Preview it with: bt views {} preview {}", + view_type.label(), + result.path + ); + } + Ok(()) +} + +const CUSTOM_VIEW_TSCONFIG_TEMPLATE: &str = r#"{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "**/*.view.ts", + "**/*.view.tsx", + "**/*.view.js", + "**/*.view.jsx", + "**/*-view.ts", + "**/*-view.tsx", + "**/*-view.js", + "**/*-view.jsx", + "**/*.d.ts" + ] +} +"#; + +const CUSTOM_VIEW_TYPES_TEMPLATE: &str = r#"type CustomViewElementProps = { + [propName: string]: unknown; +}; + +type CustomViewSelectChangeEvent = { + target: { value: string }; + currentTarget: { value: string }; + preventDefault: () => void; + stopPropagation: () => void; +}; + +interface CustomViewSelectProps extends CustomViewElementProps { + value?: string | number | readonly string[]; + defaultValue?: string | number | readonly string[]; + onChange?: (event: CustomViewSelectChangeEvent) => void; +} + +interface CustomViewOptionProps extends CustomViewElementProps { + value?: string | number; +} + +interface CustomViewIntrinsicElements { + select: CustomViewSelectProps; + option: CustomViewOptionProps; + [elementName: string]: CustomViewElementProps; +} + +declare namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} +} + +declare module "react" { + type ReactNode = any; + type ComponentType = (props: Props) => ReactNode; + type Dispatch = (value: Value) => void; + type SetStateAction = Value | ((previous: Value) => Value); + + const React: { + createElement: (...args: any[]) => ReactNode; + Fragment: any; + }; + + export default React; + export const Children: any; + export const Component: any; + export const Fragment: any; + export const Profiler: any; + export const PureComponent: any; + export const StrictMode: any; + export const Suspense: any; + export const cloneElement: (...args: any[]) => ReactNode; + export const createContext: (...args: any[]) => any; + export const createElement: (...args: any[]) => ReactNode; + export const createRef: (...args: any[]) => any; + export const forwardRef: (component: any) => any; + export const isValidElement: (value: any) => boolean; + export const lazy: (loader: any) => any; + export const memo: (component: ComponentType) => ComponentType; + export const startTransition: (callback: () => void) => void; + export const useCallback: any>(callback: Value, deps?: any[]) => Value; + export const useContext: (context: any) => any; + export const useDebugValue: (...args: any[]) => void; + export const useDeferredValue: (value: Value) => Value; + export const useEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useId: () => string; + export const useImperativeHandle: (...args: any[]) => void; + export const useInsertionEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useLayoutEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useMemo: (factory: () => Value, deps?: any[]) => Value; + export const useReducer: (...args: any[]) => any; + export const useRef: (initialValue: Value) => { current: Value }; + export const useState: (initialValue: Value | (() => Value)) => [Value, Dispatch>]; + export const useSyncExternalStore: (...args: any[]) => any; + export const useTransition: () => [boolean, (callback: () => void) => void]; +} + +declare module "react/jsx-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsx: (...args: any[]) => any; + export const jsxs: (...args: any[]) => any; +} + +declare module "react/jsx-dev-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsxDEV: (...args: any[]) => any; +} +"#; + +const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customTraceView } from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customTraceView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + }, + ({ trace, span, selectSpan }) => { + const spanIds = trace.spanOrder.slice(0, 100); + + return ( +
+
+ Trace starter view + +
+ +
+
Selected span
+
row id: {span.id}
+
span id: {span.span_id}
+
children: {span.children.length}
+
+ +
+
+            {pretty(span.data.input)}
+          
+
+            {pretty(span.data.output)}
+          
+
+
+ ); + }, +); +"##; + +const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customDatasetView } from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customDatasetView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + dataset: __DATASET_REF__, + }, + ({ id, input, expected, metadata, tags = [] }) => { + return ( +
+
+ Dataset starter view +
row id: {id}
+ {tags.length > 0 ? ( +
tags: {tags.join(", ")}
+ ) : null} +
+ +
+
+            {pretty(input)}
+          
+
+            {pretty(expected)}
+          
+
+            {pretty(metadata)}
+          
+
+
+ ); + }, +); +"##; + +fn js_string_literal(value: &str) -> String { + serde_json::to_string(value).expect("strings serialize to JSON") +} + +fn trace_view_bootstrap_template(name: &str, slug: &str) -> String { + TRACE_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) +} + +fn dataset_view_bootstrap_template(name: &str, slug: &str, dataset_ref: &str) -> String { + DATASET_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) + .replace("__DATASET_REF__", dataset_ref) +} + +async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { + let auth_ctx = functions::resolve_auth_context(&base).await?; + let default_project = resolve_required_project(&base, &auth_ctx.client, true).await?; + let files = collect_view_files(&args.resolved_paths())?; + if files.is_empty() { + bail!("no custom view files found; expected files matching {VIEW_FILE_PATTERN_HELP}"); + } + + let manifest = run_views_runner(&files)?; + validate_manifest_runtime(&manifest)?; + let prepared = prepare_views(&auth_ctx.client, &default_project, &manifest).await?; + if prepared.is_empty() { + bail!("no custom views were registered by the selected files"); + } + + if !args.yes && ui::can_prompt() { + let prompt = format!("Push {} custom view(s)?", prepared.len()); + let confirmed = dialoguer::Confirm::new() + .with_prompt(prompt) + .default(true) + .interact()?; + if !confirmed { + bail!("custom view push cancelled"); + } + } + + let events = prepared + .iter() + .map(|view| build_insert_event(view, args.if_exists)) + .collect::>(); + + let ignored = with_spinner( + "Pushing custom views...", + functions_api::insert_functions(&auth_ctx.client, &events), + ) + .await? + .ignored_entries + .unwrap_or(0); + + let pushed = resolve_pushed_views(&auth_ctx.client, &auth_ctx.app_url, &prepared).await?; + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "pushed": pushed, + "ignored": ignored, + }))? + ); + return Ok(()); + } + + eprintln!( + "{} Pushed {} custom view(s)", + dialoguer::console::style("✓").green(), + prepared.len().saturating_sub(ignored) + ); + for view in pushed { + match view.url { + Some(url) => println!("{} ({}) {}", view.name, view.slug, url), + None => println!("{} ({})", view.name, view.slug), + } + } + + Ok(()) +} + +async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> { + let context = resolve_preview_context( + &base, + &args.common, + Some(ViewType::Trace), + args.target.url.as_deref(), + ) + .await?; + let trace_data = + build_trace_preview_data(&context.client, context.project.as_ref(), &args.target).await?; + serve_preview( + base, + &args.common, + context.client, + Some(trace_data.project_id), + context.entry, + context.dependency_paths, + trace_data.data, + ) + .await +} + +async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { + let context = + resolve_preview_context(&base, &args.common, Some(ViewType::Dataset), None).await?; + let project = context + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let preview_data = + build_dataset_preview_data(&context.client, project, &context.entry, &args.target).await?; + serve_preview( + base, + &args.common, + context.client, + None, + context.entry, + context.dependency_paths, + preview_data, + ) + .await +} + +async fn resolve_preview_context( + base: &BaseArgs, + args: &PreviewCommonArgs, + view_type: Option, + trace_url: Option<&str>, +) -> Result { + if !args.path.exists() { + bail!("custom view file not found: {}", args.path.display()); + } + if !args.path.is_file() { + bail!( + "custom view preview requires a single view file; got {}", + args.path.display() + ); + } + if !is_view_file(&args.path) { + bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}"); + } + let files = vec![args.path.clone()]; + let manifest = run_views_runner(&files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); + let dependency_paths = preview_dependency_paths(&manifest); + + let auth_ctx = auth::login_read_only(&base).await?; + let client = ApiClient::new(&auth_ctx)?; + let project = if trace_url_supplies_project(entry.view_type, trace_url) { + None + } else if entry.view_type == ViewType::Trace && trace_url.is_some() { + match resolve_project_optional(&base, &client, true).await? { + Some(default_project) => { + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + } + None => None, + } + } else { + let default_project = resolve_required_project(&base, &client, true).await?; + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + }; + Ok(PreviewContext { + client, + project, + entry, + dependency_paths, + }) +} + +async fn serve_preview( + base: BaseArgs, + args: &PreviewCommonArgs, + client: ApiClient, + trace_project_id: Option, + entry: ViewManifestEntry, + dependency_paths: Vec, + preview_data: Value, +) -> Result<()> { + let listener = TcpListener::bind(("127.0.0.1", args.port)) + .with_context(|| format!("failed to bind preview server on port {}", args.port))?; + let addr = listener + .local_addr() + .context("failed to read preview address")?; + let url = format!("http://{addr}"); + let state = web::Data::new(PreviewServerState { + client, + project_id: trace_project_id, + data: preview_data, + source: PreviewSource { + path: args.path.clone(), + view: args.view.clone(), + view_type: Some(entry.view_type), + }, + title: entry.name.clone(), + dependency_paths: Mutex::new(dependency_paths), + }); + let server = HttpServer::new(move || { + App::new() + .app_data(state.clone()) + .route("/", web::get().to(preview_index)) + .route("/preview-version", web::get().to(preview_version)) + .route("/span-fields", web::post().to(preview_span_fields)) + }) + .workers(1) + .listen(listener)? + .run(); + let handle = server.handle(); + tokio::spawn(server); + + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "url": url, + "view": { + "name": entry.name, + "slug": entry.slug, + "type": entry.view_type, + }, + }))? + ); + } else { + println!("Previewing {} at {}", entry.name, url); + } + + if !args.no_open { + open::that(&url)?; + } + + tokio::signal::ctrl_c() + .await + .context("failed to wait for Ctrl+C")?; + handle.stop(true).await; + Ok(()) +} + +struct PreviewPage { + title: String, + code: String, + dependency_paths: Vec, +} + +async fn preview_index(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + let version = preview_source_version(&state.source, &dependency_paths); + let html = match load_preview_page(&state.source) { + Ok(page) => { + if let Ok(mut dependency_paths) = state.dependency_paths.lock() { + *dependency_paths = page.dependency_paths.clone(); + } + render_preview_html(&page.title, &page.code, &state.data, &version) + } + Err(err) => render_preview_error_html(&state.title, &format!("{err:#}"), &version), + }; + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + +async fn preview_version(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + HttpResponse::Ok().json(json!({ + "version": preview_source_version(&state.source, &dependency_paths), + })) +} + +async fn preview_span_fields( + state: web::Data, + body: web::Json, +) -> HttpResponse { + let Some(project_id) = state.project_id.as_deref() else { + return HttpResponse::BadRequest().json(json!({ + "error": "fetchSpanFields is only available for trace previews" + })); + }; + + let mut response = Map::new(); + let spans = state + .data + .get("trace") + .and_then(|trace| trace.get("spans")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + for span_id in &body.span_ids { + let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone()); + match fetch_full_span_row(&state.client, project_id, &row_id).await { + Ok(Some(row)) => { + response.insert( + span_id.clone(), + fields_from_row(row, body.fields.as_deref()), + ); + } + Ok(None) => { + response.insert(span_id.clone(), json!({})); + } + Err(err) => { + return HttpResponse::InternalServerError().json(json!({ + "error": format!("{err:#}") + })); + } + } + } + + HttpResponse::Ok().json(Value::Object(response)) +} + +fn load_preview_page(source: &PreviewSource) -> Result { + let files = vec![source.path.clone()]; + let manifest = run_views_runner(&files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?; + let dependency_paths = preview_dependency_paths(&manifest); + Ok(PreviewPage { + title: entry.name.clone(), + code: entry.code.clone(), + dependency_paths, + }) +} + +fn preview_dependency_paths(manifest: &ViewsManifest) -> Vec { + let mut paths = BTreeSet::new(); + for file in &manifest.files { + paths.insert(file.source_file.clone()); + for dependency in &file.dependencies { + paths.insert(dependency.clone()); + } + } + paths.into_iter().map(PathBuf::from).collect() +} + +fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf]) -> String { + let mut parts = Vec::new(); + push_preview_file_version(&mut parts, &source.path); + for dependency in dependency_paths { + if dependency != &source.path { + push_preview_file_version(&mut parts, dependency); + } + } + parts.join("|") +} + +fn push_preview_file_version(parts: &mut Vec, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + parts.push(format!("{}:{modified}:{}", path.display(), metadata.len())); + } + Err(err) => { + parts.push(format!("{}:error:{err}", path.display())); + } + } +} + +fn render_preview_html(title: &str, code: &str, data: &Value, version: &str) -> String { + let data_json = script_json(data); + let code_json = script_json(&Value::String(code.to_string())); + let title_json = script_json(&Value::String(title.to_string())); + let version_json = script_json(&Value::String(version.to_string())); + format!( + r##" + + + + + {} + + + + + + +
+ + +"##, + html_escape(title), + ) +} + +fn render_preview_error_html(title: &str, error: &str, version: &str) -> String { + let title_json = script_json(&Value::String(title.to_string())); + let error_json = script_json(&Value::String(error.to_string())); + let version_json = script_json(&Value::String(version.to_string())); + format!( + r##" + + + + + {} + + + +

+  
+
+"##,
+        html_escape(title),
+    )
+}
+
+fn script_json(value: &Value) -> String {
+    serde_json::to_string(value)
+        .unwrap_or_else(|_| "null".to_string())
+        .replace(" String {
+    value
+        .replace('&', "&")
+        .replace('<', "<")
+        .replace('>', ">")
+        .replace('"', """)
+}
+
+fn collect_view_files(paths: &[PathBuf]) -> Result> {
+    let mut files = Vec::new();
+    for path in paths {
+        let path = path.as_path();
+        if !path.exists() {
+            bail!("custom view path not found: {}", path.display());
+        }
+        if path.is_file() {
+            if is_view_file(path) {
+                files.push(path.to_path_buf());
+            }
+            continue;
+        }
+        collect_view_files_in_dir(path, &mut files)?;
+    }
+    files.sort();
+    files.dedup();
+    Ok(files)
+}
+
+fn collect_view_files_in_dir(dir: &Path, files: &mut Vec) -> Result<()> {
+    for entry in std::fs::read_dir(dir)
+        .with_context(|| format!("failed to read directory {}", dir.display()))?
+    {
+        let entry = entry?;
+        let path = entry.path();
+        if path.is_dir() {
+            if should_skip_dir(&path) {
+                continue;
+            }
+            collect_view_files_in_dir(&path, files)?;
+        } else if path.is_file() && is_view_file(&path) {
+            files.push(path);
+        }
+    }
+    Ok(())
+}
+
+fn should_skip_dir(path: &Path) -> bool {
+    path.file_name()
+        .and_then(|name| name.to_str())
+        .is_some_and(|name| {
+            matches!(
+                name,
+                ".git" | ".bt" | "node_modules" | "target" | "dist" | "build" | ".venv" | "venv"
+            )
+        })
+}
+
+fn is_view_file(path: &Path) -> bool {
+    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
+        return false;
+    };
+    [
+        ".view.tsx",
+        ".view.ts",
+        ".view.jsx",
+        ".view.js",
+        "-view.tsx",
+        "-view.ts",
+        "-view.jsx",
+        "-view.js",
+    ]
+    .iter()
+    .any(|suffix| name.ends_with(suffix))
+}
+
+fn run_views_runner(files: &[PathBuf]) -> Result {
+    let sdk_script = js_runner::materialize_runner_script_in_cwd(
+        "views-runners",
+        VIEWS_JS_SDK_FILE,
+        VIEWS_JS_SDK_SOURCE,
+    )
+    .context("failed to materialize custom views SDK helper")?;
+    let runner_script = js_runner::materialize_runner_script_in_cwd(
+        "views-runners",
+        VIEWS_JS_RUNNER_FILE,
+        VIEWS_JS_RUNNER_SOURCE,
+    )
+    .context("failed to materialize custom views runner")?;
+    let mut command = js_runner::build_js_runner_command(None, &runner_script, files);
+    command.env("BT_VIEWS_SDK_PATH", &sdk_script);
+
+    let output = command.output().with_context(|| {
+        format!(
+            "failed to spawn custom views runner: {}",
+            command_display(&command)
+        )
+    })?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        let stdout = String::from_utf8_lossy(&output.stdout);
+        let details = stderr.trim();
+        let details = if details.is_empty() {
+            stdout.trim()
+        } else {
+            details
+        };
+        bail!(
+            "custom views runner exited with status {}: {}",
+            output.status,
+            details
+        );
+    }
+
+    let stdout =
+        String::from_utf8(output.stdout).context("custom views runner output was not UTF-8")?;
+    serde_json::from_str(&stdout).with_context(|| {
+        format!(
+            "failed to parse custom views runner output as JSON: {}",
+            stdout.trim()
+        )
+    })
+}
+
+fn command_display(command: &Command) -> String {
+    let mut rendered = command.get_program().to_string_lossy().to_string();
+    for arg in command.get_args() {
+        rendered.push(' ');
+        rendered.push_str(&arg.to_string_lossy());
+    }
+    rendered
+}
+
+fn validate_manifest_runtime(manifest: &ViewsManifest) -> Result<()> {
+    if manifest.runtime_context.runtime != "browser" {
+        bail!(
+            "custom views runner returned unsupported runtime '{}'",
+            manifest.runtime_context.runtime
+        );
+    }
+    if manifest.runtime_context.version.trim().is_empty() {
+        bail!("custom views runner returned an empty runtime version");
+    }
+    Ok(())
+}
+
+async fn prepare_views(
+    client: &ApiClient,
+    default_project: &Project,
+    manifest: &ViewsManifest,
+) -> Result> {
+    let mut prepared = Vec::new();
+    let mut seen = BTreeSet::new();
+    for file in &manifest.files {
+        for entry in &file.entries {
+            let key = (
+                file.source_file.clone(),
+                entry.view_type,
+                entry.slug.clone(),
+            );
+            if !seen.insert(key) {
+                bail!(
+                    "duplicate custom view slug '{}' in {}",
+                    entry.slug,
+                    file.source_file
+                );
+            }
+            let project = resolve_project_for_entry(client, default_project, entry).await?;
+            let dataset = if entry.view_type == ViewType::Dataset {
+                Some(resolve_dataset_for_entry(client, &project, entry).await?)
+            } else {
+                None
+            };
+            prepared.push(PreparedView {
+                source_file: file.source_file.clone(),
+                entry: entry.clone(),
+                project,
+                dataset,
+            });
+        }
+    }
+    Ok(prepared)
+}
+
+async fn resolve_project_for_entry(
+    client: &ApiClient,
+    default_project: &Project,
+    entry: &ViewManifestEntry,
+) -> Result {
+    if let Some(project_id) = entry
+        .project_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+    {
+        if project_id == default_project.id {
+            return Ok(default_project.clone());
+        }
+        let projects = list_projects(client).await?;
+        return projects
+            .into_iter()
+            .find(|project| project.id == project_id)
+            .ok_or_else(|| anyhow!("project id '{project_id}' not found"));
+    }
+
+    if let Some(project_name) = entry
+        .project_name
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+    {
+        if project_name == default_project.name {
+            return Ok(default_project.clone());
+        }
+        return get_project_by_name(client, project_name)
+            .await?
+            .ok_or_else(|| anyhow!("project '{project_name}' not found"));
+    }
+
+    Ok(default_project.clone())
+}
+
+async fn resolve_dataset_for_entry(
+    client: &ApiClient,
+    project: &Project,
+    entry: &ViewManifestEntry,
+) -> Result {
+    if let Some(dataset_id) = entry
+        .dataset_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+    {
+        let datasets = datasets_api::list_datasets(client, &project.id).await?;
+        return datasets
+            .into_iter()
+            .find(|dataset| dataset.id == dataset_id)
+            .ok_or_else(|| {
+                anyhow!(
+                    "dataset id '{dataset_id}' not found in project '{}'",
+                    project.name
+                )
+            });
+    }
+
+    let dataset_name = entry
+        .dataset_name
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .ok_or_else(|| {
+            anyhow!(
+                "dataset custom view '{}' requires dataset id or name",
+                entry.slug
+            )
+        })?;
+
+    datasets_api::get_dataset_by_name(client, &project.id, dataset_name)
+        .await?
+        .ok_or_else(|| {
+            anyhow!(
+                "dataset '{dataset_name}' not found in project '{}'",
+                project.name
+            )
+        })
+}
+
+fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value {
+    let mut object = Map::new();
+    object.insert(
+        "project_id".to_string(),
+        Value::String(view.project.id.clone()),
+    );
+    object.insert("name".to_string(), Value::String(view.entry.name.clone()));
+    object.insert("slug".to_string(), Value::String(view.entry.slug.clone()));
+    object.insert(
+        "function_type".to_string(),
+        Value::String("custom_view".to_string()),
+    );
+    object.insert(
+        "if_exists".to_string(),
+        Value::String(if_exists.as_str().to_string()),
+    );
+    object.insert(
+        "function_data".to_string(),
+        json!({
+            "type": "code",
+            "data": {
+                "type": "inline",
+                "runtime_context": {
+                    "runtime": "browser",
+                    "version": "latest",
+                },
+                "code": view.entry.code,
+            }
+        }),
+    );
+
+    if let Some(dataset) = &view.dataset {
+        object.insert(
+            "origin".to_string(),
+            json!({
+                "object_type": "dataset",
+                "object_id": dataset.id,
+            }),
+        );
+    }
+
+    Value::Object(object)
+}
+
+async fn resolve_pushed_views(
+    client: &ApiClient,
+    app_url: &str,
+    views: &[PreparedView],
+) -> Result> {
+    let mut pushed = Vec::new();
+    for view in views {
+        let function =
+            functions_api::get_function_by_slug(client, &view.project.id, &view.entry.slug, None)
+                .await
+                .ok()
+                .flatten();
+        let function_id = function.as_ref().map(|function| function.id.clone());
+        let url = function_id
+            .as_deref()
+            .map(|id| custom_view_url(app_url, client.org_name(), view, id));
+        pushed.push(PushedView {
+            source_file: view.source_file.clone(),
+            name: view.entry.name.clone(),
+            slug: view.entry.slug.clone(),
+            view_type: view.entry.view_type,
+            project_id: view.project.id.clone(),
+            project_name: view.project.name.clone(),
+            dataset_id: view.dataset.as_ref().map(|dataset| dataset.id.clone()),
+            function_id,
+            url,
+        });
+    }
+    Ok(pushed)
+}
+
+fn custom_view_url(
+    app_url: &str,
+    org_name: &str,
+    view: &PreparedView,
+    function_id: &str,
+) -> String {
+    match (&view.entry.view_type, view.dataset.as_ref()) {
+        (ViewType::Dataset, Some(dataset)) => {
+            let mut url = app_project_url(
+                app_url,
+                org_name,
+                &view.project.name,
+                &["datasets", &dataset.name],
+            );
+            write!(url, "?dvt=custom&dv={}", encode(function_id)).ok();
+            url
+        }
+        _ => app_project_url_with_encoded_path(
+            app_url,
+            org_name,
+            &view.project.name,
+            &format!("logs?tvt=custom&tv={}", encode(function_id)),
+        ),
+    }
+}
+
+fn select_preview_entry<'a>(
+    manifest: &'a ViewsManifest,
+    selector: Option<&str>,
+    view_type: Option,
+) -> Result<&'a ViewManifestEntry> {
+    let entries = manifest
+        .files
+        .iter()
+        .flat_map(|file| file.entries.iter())
+        .filter(|entry| view_type.is_none_or(|view_type| entry.view_type == view_type))
+        .collect::>();
+    if entries.is_empty() {
+        match view_type {
+            Some(view_type) => bail!(
+                "selected file did not register any {} custom views",
+                view_type.label()
+            ),
+            None => bail!("selected file did not register any custom views"),
+        }
+    }
+    if let Some(selector) = selector {
+        let matches = entries
+            .into_iter()
+            .filter(|entry| entry.slug == selector || entry.name == selector)
+            .collect::>();
+        return match matches.as_slice() {
+            [entry] => Ok(*entry),
+            [] => match view_type {
+                Some(view_type) => bail!(
+                    "{} custom view '{selector}' not found in selected file",
+                    view_type.label()
+                ),
+                None => bail!("custom view '{selector}' not found in selected file"),
+            },
+            _ => match view_type {
+                Some(view_type) => bail!(
+                    "{} custom view selector '{selector}' matched multiple views",
+                    view_type.label()
+                ),
+                None => bail!("custom view selector '{selector}' matched multiple views"),
+            },
+        };
+    }
+    if entries.len() == 1 {
+        return Ok(entries[0]);
+    }
+    match view_type {
+        Some(view_type) => bail!(
+            "selected file registers multiple {} custom views; pass --view ",
+            view_type.label()
+        ),
+        None => bail!("selected file registers multiple custom views; pass --view "),
+    }
+}
+
+async fn build_dataset_preview_data(
+    client: &ApiClient,
+    project: &Project,
+    entry: &ViewManifestEntry,
+    args: &DatasetPreviewTargetArgs,
+) -> Result {
+    let dataset = match args.dataset.as_deref() {
+        Some(selector) => resolve_dataset_by_selector(client, project, selector).await?,
+        None => resolve_dataset_for_entry(client, project, entry).await?,
+    };
+
+    let row = if let Some(row_id) = args.row_id.as_deref() {
+        datasets_api::get_dataset_row_by_id(client, &dataset.id, row_id)
+            .await?
+            .ok_or_else(|| anyhow!("dataset row id '{row_id}' not found in '{}'", dataset.name))?
+    } else {
+        let index = args.row_index.unwrap_or(0);
+        let limit = index + 1;
+        let (rows, _) = datasets_api::list_dataset_rows_limited(
+            client,
+            &dataset.id,
+            Some(limit),
+            datasets_api::DatasetRowsPreviewLength::Full,
+        )
+        .await?;
+        rows.into_iter().nth(index).ok_or_else(|| {
+            anyhow!(
+                "dataset '{}' does not have row index {}",
+                dataset.name,
+                index
+            )
+        })?
+    };
+
+    Ok(json!({
+        "props": {
+            "id": row.get("id").cloned().unwrap_or(Value::Null),
+            "input": row.get("input").cloned().unwrap_or(Value::Null),
+            "expected": row.get("expected").cloned().unwrap_or(Value::Null),
+            "metadata": row.get("metadata").cloned().unwrap_or_else(|| json!({})),
+            "tags": row.get("tags").cloned().unwrap_or_else(|| json!([])),
+        },
+        "dataset": dataset,
+    }))
+}
+
+async fn resolve_dataset_by_selector(
+    client: &ApiClient,
+    project: &Project,
+    selector: &str,
+) -> Result {
+    let datasets = datasets_api::list_datasets(client, &project.id).await?;
+    datasets
+        .into_iter()
+        .find(|dataset| dataset.id == selector || dataset.name == selector)
+        .ok_or_else(|| {
+            anyhow!(
+                "dataset '{selector}' not found in project '{}'",
+                project.name
+            )
+        })
+}
+
+async fn build_trace_preview_data(
+    client: &ApiClient,
+    project: Option<&Project>,
+    args: &TracePreviewTargetArgs,
+) -> Result {
+    let target = resolve_trace_preview_target(client, project, args).await?;
+    let rows = fetch_trace_rows(
+        client,
+        &target.project_id,
+        &target.root_span_id,
+        DEFAULT_TRACE_PREVIEW_LIMIT,
+    )
+    .await?;
+    if rows.is_empty() {
+        bail!("trace '{}' returned no spans", target.root_span_id);
+    }
+    let (trace, selected_span) =
+        build_trace_payload(rows, &target.root_span_id, target.span_id.as_deref())?;
+    Ok(TracePreviewData {
+        project_id: target.project_id,
+        data: json!({
+            "trace": trace,
+            "span": selected_span,
+        }),
+    })
+}
+
+#[derive(Debug)]
+struct TracePreviewTarget {
+    project_id: String,
+    root_span_id: String,
+    span_id: Option,
+}
+
+async fn resolve_trace_preview_target(
+    client: &ApiClient,
+    default_project: Option<&Project>,
+    args: &TracePreviewTargetArgs,
+) -> Result {
+    if let Some(url) = args.url.as_deref() {
+        let parsed = parse_trace_url(url)?;
+        let project_id = match parsed.project.as_deref() {
+            Some(project)
+                if default_project
+                    .map(|default_project| {
+                        project == default_project.id || project == default_project.name
+                    })
+                    .unwrap_or(false) =>
+            {
+                default_project.expect("checked above").id.clone()
+            }
+            Some(project) if is_uuid_like(project) => project.to_string(),
+            Some(project) => {
+                get_project_by_name(client, project)
+                    .await?
+                    .ok_or_else(|| anyhow!("project '{project}' from trace URL not found"))?
+                    .id
+            }
+            None => default_project
+                .map(|project| project.id.clone())
+                .ok_or_else(|| {
+                    anyhow!(
+                        "trace URL must include a project path like /app//p//... or a project must be supplied"
+                    )
+                })?,
+        };
+        let root_span_id = parsed
+            .row_ref
+            .or(parsed.span_id.clone())
+            .ok_or_else(|| anyhow!("trace URL must include query parameter r or s"))?;
+        return Ok(TracePreviewTarget {
+            project_id,
+            root_span_id,
+            span_id: args.span_id.clone().or(parsed.span_id),
+        });
+    }
+
+    let project_id = args
+        .project_id
+        .clone()
+        .or_else(|| default_project.map(|project| project.id.clone()))
+        .ok_or_else(|| {
+            anyhow!("trace preview requires --project-id or --project when --trace-id is used")
+        })?;
+    let root_span_id = args
+        .trace_id
+        .clone()
+        .ok_or_else(|| anyhow!("trace preview requires --trace-id or --url"))?;
+    Ok(TracePreviewTarget {
+        project_id,
+        root_span_id,
+        span_id: args.span_id.clone(),
+    })
+}
+
+#[derive(Debug)]
+struct ParsedPreviewTraceUrl {
+    project: Option,
+    row_ref: Option,
+    span_id: Option,
+}
+
+fn parse_trace_url(input: &str) -> Result {
+    let parsed_url = Url::parse(input)
+        .or_else(|_| Url::parse(&format!("https://{}", input.trim_start_matches('/'))))
+        .context("invalid trace URL")?;
+    let mut parsed = ParsedPreviewTraceUrl {
+        project: None,
+        row_ref: None,
+        span_id: None,
+    };
+    if let Some(segments) = parsed_url.path_segments() {
+        let parts = segments.filter(|part| !part.is_empty()).collect::>();
+        if parts.len() >= 4 && parts[0] == "app" && parts[2] == "p" {
+            parsed.project = Some(parts[3].to_string());
+        }
+    }
+    for (key, value) in parsed_url.query_pairs() {
+        match key.as_ref() {
+            "r" if !value.is_empty() => parsed.row_ref = Some(value.to_string()),
+            "s" if !value.is_empty() => parsed.span_id = Some(value.to_string()),
+            _ => {}
+        }
+    }
+    Ok(parsed)
+}
+
+fn trace_url_supplies_project(view_type: ViewType, trace_url: Option<&str>) -> bool {
+    view_type == ViewType::Trace
+        && trace_url
+            .and_then(|url| parse_trace_url(url).ok())
+            .and_then(|parsed| parsed.project)
+            .is_some()
+}
+
+fn is_uuid_like(value: &str) -> bool {
+    let bytes = value.as_bytes();
+    if bytes.len() != 36 {
+        return false;
+    }
+    for (idx, b) in bytes.iter().enumerate() {
+        let is_hyphen = matches!(idx, 8 | 13 | 18 | 23);
+        if is_hyphen {
+            if *b != b'-' {
+                return false;
+            }
+        } else if !(*b as char).is_ascii_hexdigit() {
+            return false;
+        }
+    }
+    true
+}
+
+async fn fetch_trace_rows(
+    client: &ApiClient,
+    project_id: &str,
+    root_span_id: &str,
+    limit: usize,
+) -> Result>> {
+    let query = format!(
+        "select: * | from: project_logs({}) spans | filter: root_span_id = {} | preview_length: 125 | sort: _pagination_key ASC | limit: {}",
+        sql_quote(project_id),
+        sql_quote(root_span_id),
+        limit,
+    );
+    execute_query(client, &query)
+        .await
+        .with_context(|| format!("BTQL query failed: {query}"))
+        .map(|response| response.data)
+}
+
+async fn fetch_full_span_row(
+    client: &ApiClient,
+    project_id: &str,
+    row_id: &str,
+) -> Result>> {
+    let query = format!(
+        "select: * | from: project_logs({}) spans | filter: id = {} | preview_length: -1 | limit: 1",
+        sql_quote(project_id),
+        sql_quote(row_id),
+    );
+    execute_query(client, &query)
+        .await
+        .with_context(|| format!("BTQL query failed: {query}"))
+        .map(|response| response.data.into_iter().next())
+}
+
+async fn execute_query(client: &ApiClient, query: &str) -> Result {
+    let body = json!({
+        "query": query,
+        "fmt": "json",
+        "query_source": "bt_views_preview_7e8680d0f2484e2fbef8a61f2de0b9df",
+    });
+    let org_name = client.org_name();
+    let headers = if org_name.is_empty() {
+        Vec::new()
+    } else {
+        vec![("x-bt-org-name", org_name)]
+    };
+    client.post_with_headers("/btql", &body, &headers).await
+}
+
+fn sql_quote(value: &str) -> String {
+    format!("'{}'", value.replace('\'', "''"))
+}
+
+fn build_trace_payload(
+    rows: Vec>,
+    root_span_id: &str,
+    selected_selector: Option<&str>,
+) -> Result<(Value, Value)> {
+    let mut spans = Map::new();
+    let mut span_order = Vec::new();
+    let mut parent_children: BTreeMap> = BTreeMap::new();
+    let mut row_id_to_span_id = BTreeMap::new();
+
+    for row in &rows {
+        let Some(span_id) = row.get("span_id").and_then(Value::as_str) else {
+            continue;
+        };
+        span_order.push(Value::String(span_id.to_string()));
+        if let Some(row_id) = row.get("id").and_then(Value::as_str) {
+            row_id_to_span_id.insert(row_id.to_string(), span_id.to_string());
+        }
+        if let Some(parent) = parent_span_id(row) {
+            parent_children
+                .entry(parent)
+                .or_default()
+                .push(span_id.to_string());
+        }
+    }
+
+    for row in rows {
+        let Some(span_id) = row.get("span_id").and_then(Value::as_str) else {
+            continue;
+        };
+        let span_id = span_id.to_string();
+        let children = parent_children
+            .get(&span_id)
+            .cloned()
+            .unwrap_or_default()
+            .into_iter()
+            .map(Value::String)
+            .collect::>();
+        spans.insert(span_id.clone(), row_to_custom_span(row, children));
+    }
+
+    let selected_span_id = selected_selector
+        .and_then(|selector| {
+            spans
+                .contains_key(selector)
+                .then(|| selector.to_string())
+                .or_else(|| row_id_to_span_id.get(selector).cloned())
+        })
+        .or_else(|| {
+            spans
+                .contains_key(root_span_id)
+                .then(|| root_span_id.to_string())
+        })
+        .or_else(|| {
+            span_order
+                .first()
+                .and_then(Value::as_str)
+                .map(ToOwned::to_owned)
+        })
+        .ok_or_else(|| anyhow!("trace has no selectable spans"))?;
+    let selected_span = spans
+        .get(&selected_span_id)
+        .cloned()
+        .ok_or_else(|| anyhow!("selected span '{selected_span_id}' not found"))?;
+
+    Ok((
+        json!({
+            "rootSpanId": root_span_id,
+            "selectedSpanId": selected_span_id,
+            "spanOrder": span_order,
+            "spans": spans,
+        }),
+        selected_span,
+    ))
+}
+
+fn row_to_custom_span(row: Map, children: Vec) -> Value {
+    let data_fields = [
+        "input",
+        "output",
+        "expected",
+        "metadata",
+        "scores",
+        "metrics",
+        "error",
+        "tags",
+        "span_attributes",
+    ];
+    let mut data = Map::new();
+    for field in data_fields {
+        if let Some(value) = row.get(field) {
+            data.insert(field.to_string(), value.clone());
+        }
+    }
+
+    let mut span = Map::new();
+    for field in ["id", "span_id", "root_span_id", "parent_span_id"] {
+        if let Some(value) = row.get(field) {
+            span.insert(field.to_string(), value.clone());
+        }
+    }
+    if let Some(value) = row.get("span_parents") {
+        span.insert("span_parents".to_string(), value.clone());
+    }
+    span.insert("data".to_string(), Value::Object(data));
+    span.insert("children".to_string(), Value::Array(children));
+    Value::Object(span)
+}
+
+fn parent_span_id(row: &Map) -> Option {
+    row.get("parent_span_id")
+        .and_then(Value::as_str)
+        .map(ToOwned::to_owned)
+        .or_else(|| {
+            row.get("span_parents")
+                .and_then(Value::as_array)
+                .and_then(|parents| parents.last())
+                .and_then(Value::as_str)
+                .map(ToOwned::to_owned)
+        })
+}
+
+fn resolve_preview_row_id(spans: &Map, requested: &str) -> Option {
+    spans
+        .get(requested)
+        .and_then(|span| span.get("id"))
+        .and_then(Value::as_str)
+        .map(ToOwned::to_owned)
+        .or_else(|| {
+            spans.values().find_map(|span| {
+                let row_id = span.get("id").and_then(Value::as_str)?;
+                (row_id == requested).then(|| row_id.to_string())
+            })
+        })
+}
+
+fn fields_from_row(row: Map, fields: Option<&[String]>) -> Value {
+    let requested = fields
+        .map(|fields| fields.iter().map(String::as_str).collect::>())
+        .unwrap_or_else(|| vec!["input", "output", "expected", "metadata"]);
+    let mut object = Map::new();
+    for field in requested {
+        if matches!(field, "input" | "output" | "expected" | "metadata") {
+            if let Some(value) = row.get(field) {
+                object.insert(field.to_string(), value.clone());
+            }
+        }
+    }
+    Value::Object(object)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn test_client() -> ApiClient {
+        let login = braintrust_sdk_rust::LoginState::new();
+        login.set(
+            "sk-test".to_string(),
+            "org_test".to_string(),
+            "test-org".to_string(),
+            "http://127.0.0.1:1".to_string(),
+            "http://127.0.0.1:1".to_string(),
+        );
+        ApiClient::new(&crate::auth::LoginContext {
+            login,
+            api_url: "http://127.0.0.1:1".to_string(),
+            app_url: "http://127.0.0.1:1".to_string(),
+        })
+        .expect("test client")
+    }
+
+    fn test_project() -> Project {
+        Project {
+            id: "proj_test".to_string(),
+            name: "test-project".to_string(),
+            org_id: "org_test".to_string(),
+            description: None,
+        }
+    }
+
+    #[test]
+    fn detects_view_files() {
+        assert!(is_view_file(Path::new("conversation.view.tsx")));
+        assert!(is_view_file(Path::new("dataset.view.js")));
+        assert!(is_view_file(Path::new("conversation.trace-view.tsx")));
+        assert!(is_view_file(Path::new("dataset.dataset-view.js")));
+        assert!(!is_view_file(Path::new("regular.tsx")));
+    }
+
+    #[test]
+    fn preview_html_includes_hot_reload_polling() {
+        let html = render_preview_html(
+            "Preview",
+            "module.exports = function View() {}",
+            &json!({}),
+            "v1",
+        );
+
+        assert!(html.contains("previewVersion"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_error_html_includes_hot_reload_polling() {
+        let html = render_preview_error_html("Preview", "bundle failed", "v1");
+
+        assert!(html.contains("bundle failed"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_source_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        std::fs::write(&path, "export default null;\n").expect("write view");
+        let source = PreviewSource {
+            path: path.clone(),
+            view: None,
+            view_type: Some(ViewType::Trace),
+        };
+
+        let before = preview_source_version(&source, &[]);
+        std::fs::write(&path, "export default function View() { return null; }\n")
+            .expect("rewrite view");
+        let after = preview_source_version(&source, &[]);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_dependency_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        let dependency = dir.path().join("component.tsx");
+        std::fs::write(&path, "export { default } from './component';\n").expect("write view");
+        std::fs::write(
+            &dependency,
+            "export default function View() { return null; }\n",
+        )
+        .expect("write dependency");
+        let source = PreviewSource {
+            path,
+            view: None,
+            view_type: Some(ViewType::Trace),
+        };
+
+        let dependency_paths = vec![dependency.clone()];
+        let before = preview_source_version(&source, &dependency_paths);
+        std::fs::write(
+            &dependency,
+            "export default function View() { return 'changed'; }\n",
+        )
+        .expect("rewrite dependency");
+        let after = preview_source_version(&source, &dependency_paths);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn parses_trace_preview_url_project_and_span_selectors() {
+        let parsed = parse_trace_url(
+            "https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span&s=selected-span",
+        )
+        .expect("parse URL");
+
+        assert_eq!(parsed.project.as_deref(), Some("test-project"));
+        assert_eq!(parsed.row_ref.as_deref(), Some("root-span"));
+        assert_eq!(parsed.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[test]
+    fn trace_url_project_only_skips_project_resolution_for_trace_views() {
+        let url = Some("https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span");
+
+        assert!(trace_url_supplies_project(ViewType::Trace, url));
+        assert!(!trace_url_supplies_project(ViewType::Dataset, url));
+        assert!(!trace_url_supplies_project(
+            ViewType::Trace,
+            Some("https://www.braintrust.dev/app/test-org/logs?r=root-span")
+        ));
+        assert!(!trace_url_supplies_project(ViewType::Trace, None));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_project_id_does_not_require_default_project() {
+        let client = test_client();
+        let project_id = "00000000-0000-4000-8000-000000000001";
+        let target = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some(format!(
+                    "https://www.braintrust.dev/app/test-org/p/{project_id}/logs?r=root-span&s=selected-span"
+                )),
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, project_id);
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_uses_default_project() {
+        let client = test_client();
+        let project = test_project();
+        let target = resolve_trace_preview_target(
+            &client,
+            Some(&project),
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                project_id: None,
+                trace_id: None,
+                span_id: Some("selected-span".to_string()),
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, "proj_test");
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_errors_without_default_project() {
+        let client = test_client();
+        let err = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect_err("missing project should fail");
+
+        assert!(err
+            .to_string()
+            .contains("trace URL must include a project path"));
+    }
+
+    #[test]
+    fn normalizes_bootstrap_name_to_slug() {
+        assert_eq!(
+            bootstrap_slug("Trace Review 2026").unwrap(),
+            "trace-review-2026"
+        );
+        assert_eq!(
+            bootstrap_slug("  Dataset__Review!! ").unwrap(),
+            "dataset-review"
+        );
+        assert!(bootstrap_slug("!!!").is_err());
+    }
+
+    #[test]
+    fn builds_inline_custom_view_insert_event() {
+        let view = PreparedView {
+            source_file: "test.view.tsx".to_string(),
+            entry: ViewManifestEntry {
+                view_type: ViewType::Trace,
+                name: "Test View".to_string(),
+                slug: "test-view".to_string(),
+                code: "module.exports = function View() {}".to_string(),
+                project_id: None,
+                project_name: None,
+                dataset_id: None,
+                dataset_name: None,
+            },
+            project: test_project(),
+            dataset: None,
+        };
+
+        let event = build_insert_event(&view, IfExistsMode::Replace);
+        assert_eq!(event["function_type"], "custom_view");
+        assert_eq!(event["if_exists"], "replace");
+        assert_eq!(event["function_data"]["type"], "code");
+        assert_eq!(event["function_data"]["data"]["type"], "inline");
+        assert_eq!(
+            event["function_data"]["data"]["runtime_context"]["runtime"],
+            "browser"
+        );
+        assert!(event.get("origin").is_none());
+        assert!(event.get("description").is_none());
+        assert!(event.get("metadata").is_none());
+        assert!(event.get("tags").is_none());
+    }
+
+    #[test]
+    fn dataset_insert_event_sets_origin() {
+        let view = PreparedView {
+            source_file: "dataset.view.tsx".to_string(),
+            entry: ViewManifestEntry {
+                view_type: ViewType::Dataset,
+                name: "Dataset View".to_string(),
+                slug: "dataset-view".to_string(),
+                code: "module.exports = function View() {}".to_string(),
+                project_id: None,
+                project_name: None,
+                dataset_id: Some("dataset_test".to_string()),
+                dataset_name: None,
+            },
+            project: test_project(),
+            dataset: Some(datasets_api::Dataset {
+                id: "dataset_test".to_string(),
+                name: "test-dataset".to_string(),
+                project_id: Some("proj_test".to_string()),
+                description: None,
+                created: None,
+                created_at: None,
+                metadata: None,
+            }),
+        };
+
+        let event = build_insert_event(&view, IfExistsMode::Error);
+        assert_eq!(event["origin"]["object_type"], "dataset");
+        assert_eq!(event["origin"]["object_id"], "dataset_test");
+    }
+}
diff --git a/tests/cli.rs b/tests/cli.rs
index 952a95d..6fd3167 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -97,6 +97,321 @@ fn topics_report_help_accepts_global_org_short_conflict_free() {
         .stdout(predicate::str::contains("--output"));
 }
 
+#[test]
+fn views_help_accepts_push_trace_and_dataset_subcommands() {
+    bt_command()
+        .args(["views", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("push"))
+        .stdout(predicate::str::contains("trace"))
+        .stdout(predicate::str::contains("dataset"));
+}
+
+#[test]
+fn views_rejects_trace_and_dataset_aliases() {
+    for alias in ["traces", "datasets"] {
+        bt_command()
+            .args(["views", alias, "--help"])
+            .assert()
+            .failure();
+    }
+}
+
+#[test]
+fn views_trace_help_lists_bootstrap_and_preview() {
+    bt_command()
+        .args(["views", "trace", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("bootstrap"))
+        .stdout(predicate::str::contains("preview"));
+}
+
+#[test]
+fn views_dataset_help_lists_bootstrap_and_preview() {
+    bt_command()
+        .args(["views", "dataset", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("bootstrap"))
+        .stdout(predicate::str::contains("preview"));
+}
+
+#[test]
+fn views_push_help_lists_custom_view_flags() {
+    bt_command()
+        .args(["views", "push", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("--if-exists"));
+}
+
+#[test]
+fn views_trace_preview_help_lists_trace_selectors() {
+    bt_command()
+        .args(["views", "trace", "preview", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("--trace-id"))
+        .stdout(predicate::str::contains("--url"))
+        .stdout(predicate::str::contains("--dataset").not())
+        .stdout(predicate::str::contains("--row-index").not());
+}
+
+#[test]
+fn views_dataset_preview_help_lists_dataset_selectors() {
+    bt_command()
+        .args(["views", "dataset", "preview", "--help"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("--dataset"))
+        .stdout(predicate::str::contains("--trace-id").not())
+        .stdout(predicate::str::contains("--url").not())
+        .stdout(predicate::str::contains("--row-index"));
+}
+
+#[test]
+fn views_preview_hidden_aggregate_command_is_removed() {
+    bt_command()
+        .args(["views", "preview", "--help"])
+        .assert()
+        .failure();
+}
+
+#[test]
+fn views_trace_bootstrap_creates_starter_file() {
+    let dir = tempfile::tempdir().expect("tempdir");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args(["views", "trace", "bootstrap", "Trace Review"])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/trace-review.trace-view.tsx",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/tsconfig.json",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/custom-view-env.d.ts",
+        ));
+
+    let contents = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/trace-review.trace-view.tsx"),
+    )
+    .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+    assert!(contents.contains(r#"name: "Trace Review""#));
+    assert!(contents.contains(r#"slug: "trace-review""#));
+    assert!(contents.contains("({ trace, span, selectSpan }) => {"));
+    assert!(contents.contains("onChange={(event) => selectSpan?.(event.target.value)}"));
+    assert!(!contents.contains("function StarterTraceView"));
+    assert!(!contents.contains("StarterTraceView,"));
+    assert!(!contents.contains("trace: { spanOrder: string[] }"));
+    assert!(!contents.contains("event:"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterTraceView"));
+
+    let tsconfig = fs::read_to_string(dir.path().join("braintrust-custom-views/tsconfig.json"))
+        .expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(tsconfig.contains(r#""moduleResolution": "Bundler""#));
+    assert!(tsconfig.contains(r#""noEmit": true"#));
+    assert!(tsconfig.contains(r#""allowJs": true"#));
+    assert!(tsconfig.contains(r#""**/*.view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*.view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*.d.ts""#));
+
+    let types = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/custom-view-env.d.ts"),
+    )
+    .expect("read custom view type declarations");
+    assert!(types.contains("declare namespace JSX"));
+    assert!(types.contains("interface CustomViewIntrinsicElements"));
+    assert!(types.contains("select: CustomViewSelectProps"));
+    assert!(types.contains("option: CustomViewOptionProps"));
+    assert!(types.contains("onChange?: (event: CustomViewSelectChangeEvent) => void"));
+    assert!(types.contains("interface IntrinsicElements extends CustomViewIntrinsicElements"));
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(types.contains(r#"declare module "react""#));
+}
+
+#[test]
+fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() {
+    let dir = tempfile::tempdir().expect("tempdir");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "dataset",
+            "bootstrap",
+            "Dataset Review",
+            "--dataset",
+            "test-dataset",
+            "--file",
+            "custom.dataset.view.tsx",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("custom.dataset.view.tsx"));
+
+    let contents =
+        fs::read_to_string(dir.path().join("custom.dataset.view.tsx")).expect("read starter view");
+    assert!(contents.contains("export default customDatasetView"));
+    assert!(contents.contains(r#"name: "Dataset Review""#));
+    assert!(contents.contains(r#"slug: "dataset-review""#));
+    assert!(contents.contains(r#"dataset: { name: "test-dataset" }"#));
+    assert!(contents.contains("({ id, input, expected, metadata, tags = [] }) => {"));
+    assert!(!contents.contains("function StarterDatasetView"));
+    assert!(!contents.contains("StarterDatasetView,"));
+    assert!(!contents.contains("id: string;"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterDatasetView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("tsconfig.json")).expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(dir.path().join("custom-view-env.d.ts").exists());
+}
+
+#[test]
+fn views_bootstrap_json_reports_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+
+    let output = bt_command()
+        .current_dir(dir.path())
+        .args(["views", "--json", "trace", "bootstrap", "Trace Review"])
+        .assert()
+        .success()
+        .get_output()
+        .stdout
+        .clone();
+    let payload: serde_json::Value =
+        serde_json::from_slice(&output).expect("parse bootstrap json output");
+
+    assert_eq!(
+        payload["path"],
+        "braintrust-custom-views/trace-review.trace-view.tsx"
+    );
+    assert_eq!(
+        payload["tsconfig_path"],
+        "braintrust-custom-views/tsconfig.json"
+    );
+    assert_eq!(payload["tsconfig_created"], true);
+    assert_eq!(
+        payload["types_path"],
+        "braintrust-custom-views/custom-view-env.d.ts"
+    );
+    assert_eq!(payload["types_created"], true);
+    assert_eq!(payload["view_type"], "trace");
+}
+
+#[test]
+fn views_bootstrap_preserves_existing_tsconfig_without_force() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Reused TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Reused custom view type declarations",
+        ));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert_eq!(
+        tsconfig,
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n"
+    );
+    assert!(dir
+        .path()
+        .join("custom/trace-review.trace-view.tsx")
+        .exists());
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert_eq!(types, "declare const preserved: true;\n");
+}
+
+#[test]
+fn views_bootstrap_force_overwrites_view_and_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/trace-review.trace-view.tsx"),
+        "old view\n",
+    )
+    .expect("write view");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+            "--force",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Updated TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Updated custom view type declarations",
+        ));
+
+    let contents = fs::read_to_string(dir.path().join("custom/trace-review.trace-view.tsx"))
+        .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(!tsconfig.contains(r#""jsx": "preserve""#));
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(!types.contains("declare const preserved"));
+}
+
 #[test]
 fn status_quiet_and_verbose_conflict() {
     bt_command()