diff --git a/docs/operator-apps.md b/docs/operator-apps.md
index 9e0911b..5a5dd8f 100644
--- a/docs/operator-apps.md
+++ b/docs/operator-apps.md
@@ -82,10 +82,19 @@ An operator view is declared inside workflow metadata:
}
```
-Supported widgets are `image`, `status`, `metrics`, `fields`, and `actions`.
-Image, status, and metric widgets read live node output ports. Field widgets
-update declared node parameters. Actions can update parameters, cook a declared
-node output, or call a node's existing direct-control endpoint.
+Supported widgets are `image`, `viewer`, `status`, `metrics`, `fields`, and
+`actions`. Image, viewer, status, and metric widgets read live node output
+ports. A `viewer` embeds an HTTP(S) URL produced by a trusted workflow node and
+is intended for managed simulation, robot-scene, and other interactive browser
+surfaces. Field widgets update declared node parameters. Actions can update
+parameters, cook a declared node output, or call a node's existing
+direct-control endpoint.
+
+Use `input: "file_path"` for a path that must exist on the App host. It keeps
+the path editable and adds a **Browse…** button backed by Blacknode's filesystem
+browser. Declare `extensions` to filter selectable files, and optionally set
+`picker_title` and `button_label`. This is appropriate for robot descriptions,
+scenes, datasets, checkpoints, and other host-side artifacts.
Sections render in the central workspace by default. Set a section's optional
`region` to `parameters` to place its fields and actions in the right-side
diff --git a/docs/packages.md b/docs/packages.md
index c4b5742..4ce5c89 100644
--- a/docs/packages.md
+++ b/docs/packages.md
@@ -316,7 +316,7 @@ blacknode-ros2/
__init__.py
publish.py
templates/ # optional workflow JSONs for the Templates tab
- tests/ # optional pytest suite, run with the core suite
+ tests/ # optional package-specific test suite
requirements.txt # optional pip dependencies
README.md
```
@@ -604,10 +604,11 @@ Category colors come from a module-level `BLACKNODE_CATEGORIES = {"ROS 2":
## Tests
-Running `pytest` from the Blacknode repo root collects `tests/` **and**
-`packages/*/tests/` — every installed package is tested together with the
-core. Keep package test filenames unique across packages (prefix them with
-the package name, e.g. `test_ros2_topics.py`) so module names don't collide.
+Running `pytest` from the Blacknode repo root collects the core `tests/` suite.
+Extension packages are independently versioned repositories and may require
+their own optional dependencies, environment setup, hardware, or managed
+services. Run a package's test command from that package worktree as documented
+in its `AGENTS.md`.
Package tests can import the core (`blacknode.node`, test helpers) and the
package's own modules via the stable alias:
diff --git a/editor/src/components/LocalFilePicker.tsx b/editor/src/components/LocalFilePicker.tsx
index c9902e2..bc9b748 100644
--- a/editor/src/components/LocalFilePicker.tsx
+++ b/editor/src/components/LocalFilePicker.tsx
@@ -76,7 +76,7 @@ export default function LocalFilePicker({
{title}
- Select a local {extensions.join(', ')} scene
+ Select a local {extensions.join(', ')} file
×
@@ -118,7 +118,7 @@ export default function LocalFilePicker({
{loading &&
Opening folder…
}
{!loading && error && {error}
}
{!loading && !error && listing?.entries.length === 0 && (
- No matching scene files or folders here.
+ No matching files or folders here.
)}
{!loading && !error && listing?.entries.map(entry => (
+ candidates.push(record.viewer_url, record.url)
+ }
+ for (const candidate of candidates) {
+ if (typeof candidate !== 'string') continue
+ const trimmed = candidate.trim()
+ if (/^(https?:\/\/|\/(?!\/))/i.test(trimmed)) return trimmed
+ }
+ return ''
+}
+
+function OperatorViewer({ widget, nodes }: {
+ widget: Extract
+ nodes: ReturnType['nodes']
+}) {
+ const url = viewerUrl(valueFor(widget.source, nodes))
+ return (
+
+ {widget.title} {url ? 'LIVE' : 'WAITING'}
+
+ {url
+ ?
+ :
{widget.empty ?? 'Start the workflow to open this viewer.'}
}
+
+
+ )
+}
+
function statusLabel(item: OperatorStatusItem, value: unknown): string {
if (value === true) return item.true_label ?? 'Ready'
if (value === false) return item.false_label ?? 'Not ready'
@@ -204,6 +236,7 @@ function OperatorField({ item }: { item: OperatorFieldItem }) {
const updateParam = useStore(state => state.updateParam)
const storedValue = node?.data.params?.[item.param]
const [draft, setDraft] = useState(String(storedValue ?? ''))
+ const [filePickerOpen, setFilePickerOpen] = useState(false)
useEffect(() => setDraft(String(storedValue ?? '')), [storedValue])
@@ -253,6 +286,16 @@ function OperatorField({ item }: { item: OperatorFieldItem }) {
}
}
+ const selectFilePath = async (path: string) => {
+ setDraft(path)
+ setFilePickerOpen(false)
+ try {
+ await updateTargets(path)
+ } catch (error) {
+ notice('error', `Could not update ${item.label}`, error instanceof Error ? error.message : String(error))
+ }
+ }
+
const swapValues = async () => {
if (item.confirm && !window.confirm(item.confirm)) return
const pairs = item.swap_pairs ?? []
@@ -301,6 +344,42 @@ function OperatorField({ item }: { item: OperatorFieldItem }) {
)
}
+ if (item.input === 'file_path') {
+ return (
+
+
+ {item.label}
+
+ setDraft(event.target.value)}
+ onBlur={() => void commit()}
+ onKeyDown={event => {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ event.currentTarget.blur()
+ }}
+ />
+ setFilePickerOpen(true)}>
+ {item.button_label ?? 'Browse…'}
+
+
+
+ {filePickerOpen && (
+
void selectFilePath(path)}
+ onCancel={() => setFilePickerOpen(false)}
+ />
+ )}
+
+ )
+ }
+
const common = {
value: draft,
placeholder: item.placeholder,
@@ -425,6 +504,7 @@ function OperatorWidgetView({ widget, nodes, busyId, bindings, onRun }: {
onRun: (item: OperatorActionItem) => void
}) {
if (widget.type === 'image') return
+ if (widget.type === 'viewer') return
if (widget.type === 'status') return
if (widget.type === 'metrics') return
if (widget.type === 'fields') return
diff --git a/editor/src/index.css b/editor/src/index.css
index 9c684ee..5827e6b 100644
--- a/editor/src/index.css
+++ b/editor/src/index.css
@@ -6990,7 +6990,8 @@ button.bn-device-fact {
text-transform: uppercase;
}
-.bn-operator-image-card > header {
+.bn-operator-image-card > header,
+.bn-operator-viewer-card > header {
display: flex;
min-height: 36px;
align-items: center;
@@ -6999,18 +7000,21 @@ button.bn-device-fact {
border-bottom: 1px solid var(--line);
}
-.bn-operator-image-card > header strong {
+.bn-operator-image-card > header strong,
+.bn-operator-viewer-card > header strong {
font-size: 13px;
}
-.bn-operator-image-card > header span {
+.bn-operator-image-card > header span,
+.bn-operator-viewer-card > header span {
color: var(--tx3);
font-size: 11px;
font-weight: 800;
letter-spacing: .1em;
}
-.bn-operator-image-card > header span.is-live {
+.bn-operator-image-card > header span.is-live,
+.bn-operator-viewer-card > header span.is-live {
color: var(--ok);
}
@@ -7036,6 +7040,22 @@ button.bn-device-fact {
object-fit: contain;
}
+.bn-operator-viewer-frame {
+ display: grid;
+ min-height: 420px;
+ height: min(68vh, 760px);
+ overflow: hidden;
+ background: #05080d;
+ place-items: center;
+}
+
+.bn-operator-viewer-frame iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #05080d;
+}
+
.bn-operator-image-card.is-dashboard .bn-operator-image-frame img {
display: block;
height: auto;
@@ -7140,6 +7160,28 @@ button.bn-device-fact {
gap: 4px;
}
+.bn-operator-file-path-field {
+ min-width: 0;
+ grid-column: 1 / -1;
+}
+
+.bn-operator-file-path-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px;
+}
+
+.bn-operator-file-path-control button {
+ min-height: 36px;
+ padding: 7px 12px;
+ border: 1px solid var(--bn-operator-accent);
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--bn-operator-accent) 10%, var(--lift));
+ color: var(--tx1);
+ cursor: pointer;
+ font: 700 12px var(--font-ui);
+}
+
.bn-operator-swap-field {
display: flex;
min-width: 0;
diff --git a/editor/src/operatorView.ts b/editor/src/operatorView.ts
index 7eef5cc..6cf4fd1 100644
--- a/editor/src/operatorView.ts
+++ b/editor/src/operatorView.ts
@@ -29,9 +29,11 @@ export interface OperatorFieldItem {
node_id: string
param: string
label: string
- input?: 'text' | 'number' | 'textarea' | 'calibration_file' | 'swap'
+ input?: 'text' | 'number' | 'textarea' | 'file_path' | 'calibration_file' | 'swap'
placeholder?: string
button_label?: string
+ picker_title?: string
+ extensions?: string[]
confirm?: string
min?: number
max?: number
@@ -81,6 +83,13 @@ export type OperatorWidget =
empty?: string
aspect?: 'video' | 'dashboard'
}
+ | {
+ type: 'viewer'
+ id: string
+ title: string
+ source: OperatorValueSource
+ empty?: string
+ }
| { type: 'status'; id: string; title?: string; items: OperatorStatusItem[] }
| { type: 'metrics'; id: string; title?: string; items: OperatorMetricItem[] }
| { type: 'fields'; id: string; title?: string; items: OperatorFieldItem[] }
@@ -133,7 +142,11 @@ export function isWorkflowOperatorView(value: unknown): value is WorkflowOperato
&& typeof item.node_id === 'string'
&& typeof item.param === 'string'
&& typeof item.label === 'string'
- && (item.input === undefined || ['text', 'number', 'textarea', 'calibration_file', 'swap'].includes(String(item.input)))
+ && (item.input === undefined || ['text', 'number', 'textarea', 'file_path', 'calibration_file', 'swap'].includes(String(item.input)))
+ && (item.extensions === undefined || (
+ Array.isArray(item.extensions)
+ && item.extensions.every(extension => typeof extension === 'string')
+ ))
&& (item.apply_to === undefined || (
Array.isArray(item.apply_to)
&& item.apply_to.every(target => isRecord(target) && typeof target.node_id === 'string' && typeof target.param === 'string')
diff --git a/pyproject.toml b/pyproject.toml
index 5517f78..056c074 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,11 +54,9 @@ where = ["python"]
[tool.pytest.ini_options]
# Collect only the real suite. Without this, a bare `pytest` (as CI runs) also
# globs stray `*_test.py` dev scripts under scripts/, whose top-level code can
-# abort collection. The glob picks up the test suite of every extension
-# package cloned into packages/, so `pytest` from the repo root tests core and
-# installed packages together. Package test filenames must be unique across
-# packages (prefix them with the package name).
-testpaths = ["tests", "packages/*/tests"]
+# abort collection. Extension packages are independent repositories with their
+# own environments and test commands; run each package suite from its worktree.
+testpaths = ["tests"]
# When the Rust core ships (roadmap milestone 2), restore the maturin backend:
# [build-system]
diff --git a/python/blacknode/package_index.py b/python/blacknode/package_index.py
index 5610dce..1476461 100644
--- a/python/blacknode/package_index.py
+++ b/python/blacknode/package_index.py
@@ -1107,6 +1107,7 @@
"default": True,
"node_types": [
"NewtonJointCommand",
+ "NewtonScene",
"NewtonSimulation",
"NewtonUSDScene",
"NewtonViewerConfig",
@@ -1166,6 +1167,7 @@
"NewtonJointCommand",
"NewtonReplayBridge",
"NewtonROSBridge",
+ "NewtonScene",
"NewtonSimulation",
"NewtonUSDScene",
"NewtonViewerConfig",
diff --git a/tests/test_editor_operator_view.py b/tests/test_editor_operator_view.py
index fa90ce6..bb5cff9 100644
--- a/tests/test_editor_operator_view.py
+++ b/tests/test_editor_operator_view.py
@@ -19,8 +19,11 @@ def test_operator_view_contract_is_declarative_and_versioned():
assert "region?: 'main' | 'parameters'" in contract
assert "export type OperatorWidget" in contract
assert "type: 'image'" in contract
+ assert "type: 'viewer'" in contract
assert "type: 'fields'" in contract
assert "type: 'actions'" in contract
+ assert "'file_path'" in contract
+ assert "extensions?: string[]" in contract
assert "isWorkflowOperatorView" in contract
@@ -64,6 +67,36 @@ def test_editor_hides_builder_chrome_but_keeps_edit_workflow_escape_hatch():
assert "Support the robot" in view
assert ".bn-operator-view {" in styles
assert ".bn-operator-image-frame" in styles
+ assert ".bn-operator-viewer-frame" in styles
+
+
+def test_operator_view_embeds_declared_interactive_viewer_urls():
+ view = (
+ ROOT / "editor" / "src" / "components" / "WorkflowOperatorView.tsx"
+ ).read_text(encoding="utf-8")
+ styles = (ROOT / "editor" / "src" / "index.css").read_text(encoding="utf-8")
+
+ assert "function OperatorViewer" in view
+ assert "if (widget.type === 'viewer')" in view
+ assert "if (/^(https?:\\/\\/|\\/(?!\\/))/i.test(trimmed))" in view
+ assert 'allow="clipboard-read; clipboard-write; fullscreen"' in view
+ assert 'referrerPolicy="no-referrer"' in view
+ assert ".bn-operator-viewer-frame iframe" in styles
+
+
+def test_operator_fields_can_browse_host_files():
+ contract = (ROOT / "editor" / "src" / "operatorView.ts").read_text(encoding="utf-8")
+ view = (
+ ROOT / "editor" / "src" / "components" / "WorkflowOperatorView.tsx"
+ ).read_text(encoding="utf-8")
+ styles = (ROOT / "editor" / "src" / "index.css").read_text(encoding="utf-8")
+
+ assert "picker_title?: string" in contract
+ assert "extensions?: string[]" in contract
+ assert "